From c7891a6a9d06abc6a5fa800e397b43645d0c99d7 Mon Sep 17 00:00:00 2001 From: go0p Date: Thu, 23 Jul 2026 15:16:50 +0800 Subject: [PATCH] Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality. --- ARCHITECTURE.md | 81 +- README.md | 8 +- docs/AUTO_PROFILE_INFERENCE_ARCHITECTURE.md | 589 +++++ docs/BROWSER_TRANSFORM_GATEWAY.md | 259 +++ docs/DEEP_CAPTURE_ARCHITECTURE.md | 149 ++ .../FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md | 615 ++++++ docs/PERMISSIONS.md | 9 +- docs/PRIVACY_POLICY.md | 18 +- docs/PRODUCT_ARCHITECTURE_ROADMAP.md | 141 +- docs/PROXY_ARCHITECTURE.md | 115 + docs/store-review/CHROME_WEB_STORE.md | 14 +- docs/store-review/FIREFOX_AMO.md | 2 +- docs/store-review/LIMITED_USE_DISCLOSURE.md | 3 +- package.json | 8 + pnpm-lock.yaml | 58 +- scripts/audit-build.mjs | 75 +- scripts/verify-aesrsa-transaction.mjs | 224 ++ scripts/verify-g4-protocols.mjs | 212 ++ scripts/verify-ui.mjs | 1925 ++++++++++++++++- src/app/background/index.ts | 342 ++- src/entrypoints/options/App.css | 323 ++- src/entrypoints/options/App.tsx | 312 +-- src/entrypoints/options/style.css | 1 + src/entrypoints/page-observer-main-world.ts | 453 ---- src/entrypoints/page-recorder-main-world.ts | 1224 +++++++++++ src/entrypoints/popup/App.css | 213 +- src/entrypoints/popup/App.tsx | 184 +- src/entrypoints/popup/style.css | 3 +- .../popup/views/CookieQuickView.tsx | 113 + .../popup/views/OverviewQuickView.tsx | 83 + .../popup/views/ProxyQuickView.tsx | 187 ++ .../popup/views/UserAgentQuickView.tsx | 99 + .../browser-crypto/adapters/adapters.test.ts | 338 +++ .../browser-crypto/adapters/catalog.ts | 73 + .../browser-crypto/adapters/common.ts | 46 + .../browser-crypto/adapters/contract.ts | 74 + .../browser-crypto/adapters/cryptojs.ts | 155 ++ src/features/browser-crypto/adapters/index.ts | 32 + src/features/browser-crypto/adapters/jose.ts | 247 +++ .../browser-crypto/adapters/jsencrypt.ts | 112 + .../browser-crypto/adapters/jsrsasign.ts | 290 +++ .../browser-crypto/adapters/node-forge.ts | 386 ++++ .../adapters/protocol-acceptance.test.ts | 114 + .../browser-crypto/adapters/registry.test.ts | 264 +++ .../browser-crypto/adapters/registry.ts | 143 ++ .../browser-crypto/adapters/sm-crypto.ts | 201 ++ .../browser-crypto/adapters/webcrypto.ts | 112 + src/features/browser-crypto/model.test.ts | 66 + src/features/browser-crypto/model.ts | 97 + .../browser-inference/inference.test.ts | 372 ++++ src/features/browser-inference/inference.ts | 622 ++++++ .../browser-inference/stack-hints.test.ts | 47 + src/features/browser-inference/stack-hints.ts | 101 + .../browser-recording/RecordingWorkspace.tsx | 558 +++++ src/features/browser-recording/constants.ts | 2 + .../boundaries/communication.test.ts | 146 ++ .../main-world/boundaries/communication.ts | 379 ++++ .../boundaries/request-preparation.test.ts | 108 + .../boundaries/request-preparation.ts | 270 +++ .../main-world/retained-call-budget.test.ts | 33 + .../main-world/retained-call-budget.ts | 80 + src/features/browser-recording/service.ts | 988 +++++++++ .../browser-recording/timeline.test.ts | 176 ++ src/features/browser-recording/timeline.ts | 197 ++ .../BrowserTransformWorkspace.tsx | 771 +++++++ .../browser-transform-workspace.css | 289 +++ .../browser-transform/concurrency.test.ts | 38 + src/features/browser-transform/concurrency.ts | 41 + src/features/browser-transform/guided.test.ts | 122 ++ src/features/browser-transform/guided.ts | 208 ++ .../browser-transform/mapping.test.ts | 147 ++ src/features/browser-transform/mapping.ts | 451 ++++ .../browser-transform/profile-draft.ts | 65 + .../browser-transform/replay-draft.test.ts | 107 + .../browser-transform/replay-draft.ts | 144 ++ src/features/browser-transform/service.ts | 221 ++ src/features/cookies/presentation.ts | 21 + .../deep-capture/DeepCaptureWorkspace.tsx | 547 +++++ .../business-frame-ranker.test.ts | 128 ++ .../deep-capture/business-frame-ranker.ts | 185 ++ .../deep-capture/callable-sample.test.ts | 42 + src/features/deep-capture/callable-sample.ts | 60 + .../deep-capture/deep-capture-workspace.css | 692 ++++++ .../deep-capture/function-parameters.test.ts | 18 + .../deep-capture/function-parameters.ts | 76 + src/features/deep-capture/service.test.ts | 78 + src/features/deep-capture/service.ts | 1004 +++++++++ src/features/diagnostics/export.ts | 7 +- src/features/floating-panel/FloatingPanel.tsx | 4 +- src/features/grants/service.ts | 153 +- src/features/identity/user-agent-profiles.ts | 48 + src/features/identity/user-agent.test.ts | 56 +- src/features/identity/user-agent.ts | 108 +- src/features/network-capture/workflows.ts | 4 +- src/features/page-callable/constants.ts | 1 + src/features/page-callable/execution.test.ts | 29 + src/features/page-callable/execution.ts | 61 + .../page-callable/request-transaction.test.ts | 45 + .../page-callable/request-transaction.ts | 474 ++++ src/features/page-callable/service.test.ts | 51 + src/features/page-callable/service.ts | 228 ++ src/features/page-observation/service.ts | 217 -- src/features/proxy/compiler.test.ts | 129 +- src/features/proxy/compiler.ts | 316 ++- src/features/proxy/hash.ts | 8 + src/features/proxy/parser.test.ts | 45 + src/features/proxy/parser.ts | 264 +++ src/features/proxy/repository.ts | 285 +++ src/features/proxy/service.ts | 536 ++++- src/features/proxy/ui/AutoSwitchView.tsx | 166 ++ src/features/proxy/ui/ProxyProfilesView.tsx | 100 + src/features/proxy/ui/RuleSourcesView.tsx | 178 ++ src/features/proxy/ui/presentation.ts | 47 + src/features/proxy/ui/proxy-workspace.css | 1259 +++++++++++ src/features/proxy/ui/types.ts | 11 + src/platform/storage/state.ts | 45 +- src/protocol/bridge.test.ts | 17 + src/protocol/bridge.ts | 74 +- src/protocol/capabilities.ts | 51 +- src/protocol/extension.test.ts | 224 ++ src/protocol/extension.ts | 194 +- src/protocol/transform.ts | 134 ++ src/types/messages.ts | 71 +- src/types/models.ts | 724 ++++++- wxt.config.ts | 3 + 125 files changed, 25018 insertions(+), 1675 deletions(-) create mode 100644 docs/AUTO_PROFILE_INFERENCE_ARCHITECTURE.md create mode 100644 docs/BROWSER_TRANSFORM_GATEWAY.md create mode 100644 docs/DEEP_CAPTURE_ARCHITECTURE.md create mode 100644 docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md create mode 100644 docs/PROXY_ARCHITECTURE.md create mode 100644 scripts/verify-aesrsa-transaction.mjs create mode 100644 scripts/verify-g4-protocols.mjs delete mode 100644 src/entrypoints/page-observer-main-world.ts create mode 100644 src/entrypoints/page-recorder-main-world.ts create mode 100644 src/entrypoints/popup/views/CookieQuickView.tsx create mode 100644 src/entrypoints/popup/views/OverviewQuickView.tsx create mode 100644 src/entrypoints/popup/views/ProxyQuickView.tsx create mode 100644 src/entrypoints/popup/views/UserAgentQuickView.tsx create mode 100644 src/features/browser-crypto/adapters/adapters.test.ts create mode 100644 src/features/browser-crypto/adapters/catalog.ts create mode 100644 src/features/browser-crypto/adapters/common.ts create mode 100644 src/features/browser-crypto/adapters/contract.ts create mode 100644 src/features/browser-crypto/adapters/cryptojs.ts create mode 100644 src/features/browser-crypto/adapters/index.ts create mode 100644 src/features/browser-crypto/adapters/jose.ts create mode 100644 src/features/browser-crypto/adapters/jsencrypt.ts create mode 100644 src/features/browser-crypto/adapters/jsrsasign.ts create mode 100644 src/features/browser-crypto/adapters/node-forge.ts create mode 100644 src/features/browser-crypto/adapters/protocol-acceptance.test.ts create mode 100644 src/features/browser-crypto/adapters/registry.test.ts create mode 100644 src/features/browser-crypto/adapters/registry.ts create mode 100644 src/features/browser-crypto/adapters/sm-crypto.ts create mode 100644 src/features/browser-crypto/adapters/webcrypto.ts create mode 100644 src/features/browser-crypto/model.test.ts create mode 100644 src/features/browser-crypto/model.ts create mode 100644 src/features/browser-inference/inference.test.ts create mode 100644 src/features/browser-inference/inference.ts create mode 100644 src/features/browser-inference/stack-hints.test.ts create mode 100644 src/features/browser-inference/stack-hints.ts create mode 100644 src/features/browser-recording/RecordingWorkspace.tsx create mode 100644 src/features/browser-recording/constants.ts create mode 100644 src/features/browser-recording/main-world/boundaries/communication.test.ts create mode 100644 src/features/browser-recording/main-world/boundaries/communication.ts create mode 100644 src/features/browser-recording/main-world/boundaries/request-preparation.test.ts create mode 100644 src/features/browser-recording/main-world/boundaries/request-preparation.ts create mode 100644 src/features/browser-recording/main-world/retained-call-budget.test.ts create mode 100644 src/features/browser-recording/main-world/retained-call-budget.ts create mode 100644 src/features/browser-recording/service.ts create mode 100644 src/features/browser-recording/timeline.test.ts create mode 100644 src/features/browser-recording/timeline.ts create mode 100644 src/features/browser-transform/BrowserTransformWorkspace.tsx create mode 100644 src/features/browser-transform/browser-transform-workspace.css create mode 100644 src/features/browser-transform/concurrency.test.ts create mode 100644 src/features/browser-transform/concurrency.ts create mode 100644 src/features/browser-transform/guided.test.ts create mode 100644 src/features/browser-transform/guided.ts create mode 100644 src/features/browser-transform/mapping.test.ts create mode 100644 src/features/browser-transform/mapping.ts create mode 100644 src/features/browser-transform/profile-draft.ts create mode 100644 src/features/browser-transform/replay-draft.test.ts create mode 100644 src/features/browser-transform/replay-draft.ts create mode 100644 src/features/browser-transform/service.ts create mode 100644 src/features/cookies/presentation.ts create mode 100644 src/features/deep-capture/DeepCaptureWorkspace.tsx create mode 100644 src/features/deep-capture/business-frame-ranker.test.ts create mode 100644 src/features/deep-capture/business-frame-ranker.ts create mode 100644 src/features/deep-capture/callable-sample.test.ts create mode 100644 src/features/deep-capture/callable-sample.ts create mode 100644 src/features/deep-capture/deep-capture-workspace.css create mode 100644 src/features/deep-capture/function-parameters.test.ts create mode 100644 src/features/deep-capture/function-parameters.ts create mode 100644 src/features/deep-capture/service.test.ts create mode 100644 src/features/deep-capture/service.ts create mode 100644 src/features/identity/user-agent-profiles.ts create mode 100644 src/features/page-callable/constants.ts create mode 100644 src/features/page-callable/execution.test.ts create mode 100644 src/features/page-callable/execution.ts create mode 100644 src/features/page-callable/request-transaction.test.ts create mode 100644 src/features/page-callable/request-transaction.ts create mode 100644 src/features/page-callable/service.test.ts create mode 100644 src/features/page-callable/service.ts delete mode 100644 src/features/page-observation/service.ts create mode 100644 src/features/proxy/hash.ts create mode 100644 src/features/proxy/parser.test.ts create mode 100644 src/features/proxy/parser.ts create mode 100644 src/features/proxy/repository.ts create mode 100644 src/features/proxy/ui/AutoSwitchView.tsx create mode 100644 src/features/proxy/ui/ProxyProfilesView.tsx create mode 100644 src/features/proxy/ui/RuleSourcesView.tsx create mode 100644 src/features/proxy/ui/presentation.ts create mode 100644 src/features/proxy/ui/proxy-workspace.css create mode 100644 src/features/proxy/ui/types.ts create mode 100644 src/protocol/transform.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index debb579..3ded9d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ - Reuse a user's real, authenticated browser session without exporting a complete browser profile. - Let an AI agent inspect a deliberately shared tab and request human takeover for QR codes, MFA, CAPTCHA, or device confirmation. -- Keep proxy, Cookie, User-Agent, page-context, and page-function capabilities behind one typed command boundary. +- Keep proxy, Cookie, User-Agent, page-context, page-function, recording, Chromium debugger and browser transform capabilities behind one typed command boundary. - Make grants short lived, tab scoped, visible, and revocable. ## Layers @@ -30,7 +30,16 @@ The background service owns browser capabilities. Every remote command passes th | `browser.invoke` | `browser.page.invoke` | Calls an existing page-world function by path | | `browser.eval` expression | `browser.page.eval.expression` | Executes one parenthesized expression in a granted page world | | `browser.eval` program | `browser.page.eval.program` | Executes statements and side effects under an independent high-risk scope | -| `browser.observe.*` | `browser.observation.read/control/sensitive.read` | Controls bounded Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS observation | +| `browser.recording.status/get` | `browser.recording.read` | Reads bounded business Traces, Pipeline events and value links for a granted document | +| `browser.recording.start/stop/clear` | `browser.recording.control` | Controls the document-bound MAIN-world recorder | +| recording value previews | `browser.recording.sensitive.read` | Includes bounded short-lived input/output previews; off by default | +| `browser.callable.list` | `browser.recording.read` | Lists callable metadata retained by the current granted document | +| `browser.callable.create/execute/delete` | `browser.callable.execute` | Creates or invokes a recorded call or captured business closure without exporting keys; deep-capture creation also requires debugger control | +| `browser.deep_capture.status` | `browser.debugger.read` | Reads bounded pause state, frames and scopes | +| `browser.deep_capture.start/keepalive/resume/detach` | `browser.debugger.control` | Controls a one-shot Chromium function/request breakpoint and its pause lifecycle | +| `browser.transform.profile.list` | `browser.transform.read` | Lists document-bound plaintext/wire transform profiles visible to the active grant | +| `browser.transform.profile.save/delete` | `browser.transform.manage` | Creates, updates or removes a validated transform pipeline for a granted document | +| `browser.transform.execute` | `browser.transform.execute` | Runs one request or response through the selected live-page function pipeline | | `proxy.list` | `browser.proxy.read` | Lists extension proxy profiles | | `proxy.switch` | `browser.proxy.write` | Switches the browser proxy profile | @@ -68,11 +77,13 @@ Request IDs allow concurrent calls in both directions. The extension accepts at ### Yakit device tasks -Pairing and device CRUD remain on `RequestYakURL`. Executable work uses one server-streaming RPC, `ExecuteBrowserExtensionTask`, with stable routing fields (`task_id`, `device_id`, `schema`, JSON payload and timeout). The initial schemas are: +Pairing and device CRUD remain on `RequestYakURL`. Executable work uses one server-streaming RPC, `ExecuteBrowserExtensionTask`, with stable routing fields (`task_id`, `device_id`, `schema`, JSON payload and timeout). The stable schemas are: - `capability.call`: invokes one extension capability with `{method, params}` and returns its JSON result; - `yak.script`: executes Yak in the owning gRPC process and injects a request-bound `browser.ExtensionCall` and `browser.ExtensionStatus` for the selected device. +Yakit treats `capability.call` as a schema-controlled command channel rather than adding one gRPC method per browser feature. Its selected-device workspace defaults to a browser-workflow view with Plaintext Gateway, Recorder and Deep Capture modes; raw capability JSON and Yak scripts remain advanced modes. Recording, `browser.callable.*`, `browser.deep_capture.*` and `browser.transform.*` calls therefore use the same streamed task lifecycle, cancellation, device routing and output limits as every other extension capability. + The engine supports multiple simultaneous browser connections. Calls are routed by paired device ID, pending responses are bound to the target WebSocket, and a disconnect immediately fails that device's outstanding calls. A schema handler cannot silently fall back to another online browser. Task events use a small common vocabulary (`queued`, `running`, `log`, `result`, `warning`, `error`, `cancelled`, `completed`) with monotonic sequence and timestamp fields. The RPC bounds payload size, timeout, concurrent scripts, per-event data and aggregate output; cancelling the stream propagates through the Yak context to the extension request. @@ -109,6 +120,42 @@ Each capture session is bound to one tab, frame, and document. Chrome MV3 stores Generating a replay packet requires captured request headers. The packet is reconstructed as HTTP/1.1 with the observed header values and bounded body bytes. Truncated or omitted bodies produce an explicit limitation warning. Sending to Yakit is a confirmed Bridge request: Yak validates a maximum 2 MiB packet, saves a Web Fuzzer page configuration in the current project database, broadcasts the new tab to Yakit, and returns its `pageId` before the extension reports success. +## Browser recording and page callables + +The independent `page-recorder-main-world.js` entrypoint temporarily wraps user-visible interactions, Fetch, XHR, form submission, Beacon, WebSocket, Worker/SharedWorker/MessagePort boundaries, a crypto-adapter registry, `btoa`, and `atob`. WebCrypto, CryptoJS, JSEncrypt, sm-crypto, and node-forge feed one open-but-bounded `crypto` event contract rather than library-specific event kinds. Worker and MessagePort round trips inherit their originating Trace through a bounded channel context, but are explicitly labeled `correlated` rather than being misrepresented as exact value equality. It does not replace `webRequest`: recording explains page-side data flow, while network capture preserves the browser's actual outbound request. + +Each click or submit begins a five-second business Trace. Nested and subsequent events share that Trace. Inputs and outputs are reduced to bounded evidence paths, byte lengths, encodings, and a randomly seeded 64-bit correlation fingerprint. The seed remains inside one page document and is regenerated for every document observer, so fingerprints cannot be compared across document boundaries. Matching an earlier output fingerprint to a later input fingerprint creates an exact Pipeline link. This is evidence of value equality inside one document segment, not proof of semantic causality. + +Raw previews are disabled by default. Enabling them requires `browser.recording.sensitive.read` and caps each preview at 8 KiB. A user-started recording is a tab/frame-scoped Session: the current document keeps live hooks and handles, while the background merges bounded document segments into extension-only `storage.session`. A full navigation is recorded as a first-class Trace event; the previous segment is sealed, the destination document receives a new observer with the same Session identity and a synchronized global sequence, and recording continues until explicit stop, expiry, clear, or tab close. The single per-target Session is removed by a new recording, explicit clear, tab close, or browser-session end. Previews are never written to persistent storage or included in audit or AI request-analysis payloads. Recording is bounded to 500 aggregate events, 48 evidence items per side, 1,000 links, and 64 live callable handles per document. + +Navigation is both a business event and a strict execution-context boundary. Full document navigation, reload, browser Back/Forward, same-document History changes and fragment changes are distinguished. If Back/Forward restores the original document from BFCache, its recorder, handles and callables are resumed without clearing earlier evidence; if the browser performs a hard reload, the historical evidence remains but the destroyed closure heap is truthfully unavailable. MAIN-world lifecycle and the tab-scoped Session are separate states, so a temporary document transition no longer appears as a completed recording. A grant-owned recording remains document-bound and stops at navigation instead of silently extending an Agent's authority into a new document. + +When an observed stateless or receiver-bound operation can be replayed, the recorder retains an opaque reference to the original function, actual receiver, argument template, and non-extractable `CryptoKey` or library key object. Stateful and streaming node-forge sessions instead expose correlated create/init/update/final evidence and direct the user to retain their one-shot business wrapper. Creating a `BrowserPageCallable` places only metadata and a named data slot in the shared current-document registry; key material remains in the retained call template. Library adapters expose only key kind, modulus size, and a document-salted fingerprint—never PEM/private-key material, modulus, exponent, or an instance. Retained call handles are bounded by count, a 2 MiB per-handle limit, and an 8 MiB aggregate limit; oversized calls remain visible as metadata but cannot pin their arguments as replay handles. A manual stop restores wrapped APIs but keeps callables usable in the same live document. Navigation or refresh destroys the page heap and intentionally invalidates every handle. Clear, grant expiry, and grant revocation restore APIs and destroy events, retained handles, and recorded-call entries. + +Inference is request-centered. One exact crypto-output-to-request-field edge plus a live handle can produce a ready profile directly, including JSEncrypt RSA into form, JSON, header, query, or raw body destinations. Multiple crypto outputs in one request become one request-level candidate. Even when every edge is exact, those primitives are not replayed separately because AES keys, RSA-wrapped keys, IVs, nonces, signatures, and timestamps may share one dynamic business context; Deep Capture is required to retain that higher-level callable. Known-library adapters are semantic accelerators rather than the generality boundary: ESM/bundler closures, Worker/WASM paths and unknown business wrappers must remain usable through request/message boundary evidence and business-callable capture even when the algorithm cannot yet be named. The adapter refactor, high-value provider order and anti-fixture acceptance matrix are defined in [`docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md`](docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md). + +Options and Yakit expose the same Session -> Trace -> event/evidence/callable model. The UI uses one oldest-to-newest recording timeline, numbered execution cards and relative timestamps. A neutral vertical rail communicates execution order; exact value links use a separate success treatment so temporal order is never mistaken for data-flow proof. Navigation cards show source, destination, lifecycle phase and document availability. The workspace validates a callable with new arguments before it is used by a Yak or AI workflow. + +## Chromium Deep Capture and business callables + +The Recorder remains the low-overhead discovery layer. When a Trace identifies the relevant unified crypto call or request, Chromium Deep Capture attaches through `chrome.debugger`, enables the Runtime/Debugger/DOMDebugger domains and installs one one-shot breakpoint. Crypto matching uses `Debugger.setBreakpointOnFunctionCall` on the real installed adapter wrapper function, so production minification and page CSP cannot remove or block the breakpoint. Request matching uses a bounded XHR/fetch URL substring. + +Pause processing is two-stage. The background publishes at most 14 call-frame skeletons immediately, schedules a 45-second alarm watchdog, then reads up to six local/closure/module scopes for the first eight frames in parallel. UI keepalive extends the deadline; loss of all control surfaces resumes the page. Status, keepalive, resume, detach and adapter creation use only grant identity, `webNavigation`, session state and CDP while paused. They never inject a script into the paused document, avoiding a control-plane deadlock. + +`browser.callable.create` with `source: deep-capture` defaults to a backend-trusted `selected-frame` strategy. Multi-source stack hints and deterministic CDP inspection select a unique page business frame, resolve its real function object from the frame name, receiver descriptor or scope binding, verify its function location and block network/DOM/navigation/storage side effects. A user expression is an advanced fallback and passes the same gate. The resulting function and receiver enter the same page-owned callable registry used by recorded calls; metadata alone crosses the extension boundary. Formal parameters, including those after default values, become ordered input slots. Options can use exact same-name values already present in the authorized paused scope to initialize a non-persistent local replay sample and `body.` guide. After resume, `browser.callable.execute` invokes that closure with at most 64 JSON arguments and returns a bounded structured result. Non-extractable keys, key promises and other closure objects remain in the page. Navigation destroys every callable. + +Debugger read, debugger control and callable execution are separate scopes. A grant cannot control a local or different grant's debugger session. Replacement, expiry, revocation and tab closure release owned sessions. Firefox does not request `debugger` or advertise Deep Capture, but it can retain recorder-created page callables. Detailed invariants and real AES-GCM/HMAC acceptance criteria are in [`docs/DEEP_CAPTURE_ARCHITECTURE.md`](docs/DEEP_CAPTURE_ARCHITECTURE.md). + +## Browser Transform Gateway + +The Transform Gateway turns retained page callables into a native Web Fuzzer data plane. Yakit keeps the request and response editor in plaintext. The owning Yak gRPC process calls the selected paired browser after the user's `beforeRequest` hot patch and before network transmission, then calls it again immediately after receiving a response and before the user's `afterRequest` hot patch. `RequestRaw`/`ResponseRaw` remain logical plaintext; `WireRequestRaw`/`WireResponseRaw` preserve the actual transmitted packets for side-by-side inspection and history. + +Profiles are bound to one current `tabId + frameId + documentId + origin`, route-filtered by HTTP method and wildcard URL, and composed as a Pipeline v2 ordered DAG of `context.read`, whitelisted `builtin`, `page.call` and `output.write` nodes. Nodes may reference only earlier results. After the background validates route, origin and document identity, the complete bounded DAG executes in the target MAIN world with one extension-to-page round trip rather than one round trip per node. Outputs support complete/field-level bodies, headers and query parameters. Paths reject prototype traversal, header mappings reject CR/LF injection, and Yak independently rejects any returned URL that changes scheme, host, port or path. Execution values are lossless within an 8 MiB body limit and fail explicitly outside their structural bounds. A bounded per-profile gate protects page functions that are not safely re-entrant. + +Request conversion is fail-closed: no error path sends the plaintext packet. Response conversion failure returns an explicit synthetic failure while retaining the wire response for diagnosis. Navigation, refresh, grant expiry, callable loss, route mismatch and browser disconnect never silently retarget or fall back. Chromium exposes the workspace because Deep Capture can retain business closures; Firefox hides the unavailable Gateway/Deep Capture modes. + +The complete product contract, schema, ordering and acceptance fixture are documented in [`docs/BROWSER_TRANSFORM_GATEWAY.md`](docs/BROWSER_TRANSFORM_GATEWAY.md). + ## Page-world code ### Structured context and node references @@ -151,13 +198,33 @@ The old extension established the essential behavior by injecting `inject.js` an Both adapters share the same expression/program return rules, result serializer, Promise behavior, timeout bounds, and error envelope. Local Eval is initiated by an explicit user action. Remote expression and program modes require separate scopes and a target whose tab, frame, document and origin still match. Because the page controls its JavaScript environment, all results remain untrusted input. -The public Firefox MV3 AMO channel is invoke-only at the extension boundary: it requests neither `userScripts` nor general page invocation/Eval, does not package `page-main-world.js`, and advertises neither Bridge capability. This follows Mozilla's current restriction of `userScripts` to user-script managers. Structured context, stable node commands, network capture, observation and human handoff remain available. +The public Firefox MV3 AMO channel is invoke-only at the extension boundary: it requests neither `userScripts` nor general page invocation/Eval, does not package `page-main-world.js`, and advertises neither Bridge capability. This follows Mozilla's current restriction of `userScripts` to user-script managers. Structured context, stable node commands, network capture, browser recording and human handoff remain available. -The same page bridge supports `browser.invoke` for the narrower case where the Agent already knows a concrete global function path. Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS observation uses the same grant and lifecycle boundaries through a separate bounded MAIN-world observer. +The same page bridge supports `browser.invoke` for the narrower case where the Agent already knows a concrete global function path. Browser recording uses the same target/grant boundary through a separately packaged, bounded MAIN-world recorder and does not depend on general Eval. + +## Proxy routing + +Proxy routing is a compile-and-apply subsystem, not an extension-side per-request rules engine. Durable endpoint/rule/source summaries remain in `settings.proxy.v1`; downloaded source revisions, normalized 512-rule chunks, and the eight newest compiled artifacts live in `yakit-proxy-rules` IndexedDB. Large exact/suffix host sets compile to PAC tries, while wildcard and regex conditions are instantiated once outside `FindProxyForURL`. + +Automatic order is manual rules, ordered sources, then the default endpoint. Source exclusions run before positive rules unless a SwitchyOmega `@with result` list explicitly owns file order. Rule-source updates stage a new revision and only replace the state reference after parse, compilation, `browser.proxy.settings`, and serialized state commit succeed. The last live PAC and source revision remain active on failure. + +Yakit MITM is a built-in fixed endpoint, not a remotely managed MITM process. Popup keeps two operations visually and behaviorally separate: a current-hostname assignment can target any Direct or fixed HTTP(S)/SOCKS endpoint and atomically enables automatic routing, while the global mode list changes the entire browser without creating a rule. “Automatic” clears the exact-host override so subscriptions and the default endpoint resume control. The extension does not control Yak MITM lifecycle or downstream interception policy. Full formats, budgets, failure behavior, and verification requirements are documented in `docs/PROXY_ARCHITECTURE.md`. + +## Popup and Options tool boundary + +The popup is the current-tab command surface. It uses a fixed 48px icon rail with four modules: overview, proxy, Cookie Editor and User-Agent. The Yak SVG mark remains visible in the header without repeating the full product name. Bridge state is represented by a focusable status dot with a tooltip and explicit accessible label; green means connected, amber means connecting/negotiating, gray means unpaired or offline, and red means an error. + +Cookie Editor and User-Agent are exposed as rail modules for actions that should complete in one or two steps: + +- Cookie values are masked by default and only revealed by an explicit click; the quick editor supports the common name/value/path/SameSite/flag fields and preserves existing partition metadata. +- User-Agent quick switching offers browser default, built-in device templates and saved custom profiles, then applies the selected header to the current hostname and reloads the target tab. +- Both quick views report the current target hostname and link to the full Options tool. They never implement a second browser API path; all reads and mutations use the typed runtime request map. + +Options is the durable management surface. Its `常用工具` navigation group contains the full Cookie Editor and User-Agent manager, including filtering, import/export, CHIPS fields, per-host assignments, custom profile editing and deletion. This split keeps the popup small enough for repeated use while retaining the security controls and information density required for deep workflows. ## Page UI loading -The content script is a roughly 10-12.2 KiB native DOM shell. It owns the Yak launcher, bridge indicator, drag position, left/right snapping, and handoff-triggered expansion. React, Radix, and the floating workbench are loaded in `floating.html` only after the user expands the launcher or a handoff targets that tab; the iframe is released after 60 seconds collapsed. Build auditing prevents the content script from exceeding its size budget. +The content script is a roughly 10-12.2 KiB native DOM shell. It owns the Yak launcher, bridge indicator, drag position, left/right snapping, and handoff-triggered expansion. React, Radix, and the floating workbench are loaded in `floating.html` only after the user expands the launcher or a handoff targets that tab; the iframe is released after 60 seconds collapsed. Build auditing reports the content-script size as an advisory trend; lazy loading and the 60-second release policy are verified from runtime behavior rather than enforced through a fixed bundle-size gate. Popup, Options, and the floating workbench share one token-based design system in `src/styles/`: `tokens.css` defines the palette, type scale (11-20px), radii, and shadows, including a full dark set under `[data-theme='dark']`; `ui.css` styles the shared Radix-backed components. The vivid brand orange is reserved for non-text accents; filled primary buttons and text links use a deeper AA-contrast orange. All surfaces are light-first — the orange yak mark is shown bare without a backing tile. The theme preference (`system`/`light`/`dark`) lives in its own `settings.appearance.v1` local-storage key, is written only from extension UI, and is applied to `` by each entrypoint through `src/platform/storage/appearance.ts`; the content-script launcher reads the same key in-page (falling back to the OS scheme) to theme its shadow-DOM shell. @@ -172,4 +239,4 @@ Audit events live under a separate storage key and are serialized independently - Managed storage can lock transport, endpoint/host, grant duration/origins, program Eval and panel availability. Enforcement is in background handlers. - Aggregate Service Worker, Bridge, heartbeat and capability metrics stay local. Explicit diagnostics export omits URLs, values, payloads, Eval code and task/grant identifiers. - Public review artifacts live under `docs/store-review`; privacy, permission and enterprise deployment contracts live under `docs/`. -- Store/Enterprise Chromium E2E covers 320/390/desktop UI, service-worker restart, frame/document/origin boundaries, request/observation workflows, handoff, audit/diagnostic redaction and state concurrency. Go tests cover Bridge v3 pairing, code derivation, signed challenge/auth, revocation, YakURL control, chunking/session recovery and Native Messaging proxy framing. +- Store/Enterprise Chromium E2E covers 320/390/desktop UI, service-worker restart, frame/document/origin boundaries, request/recording workflows, exact value and correlated channel links, document callable replay, all five crypto adapters, node-forge stateful sessions, independent SM2/SM4/RSA/AES/digest/HMAC/signature validation, randomized non-global ESM + WebAssembly closure recovery, Worker holdout, recorder load/memory budgets, JSEncrypt RSA receiver retention and guided form-field profiles, distinct WebCrypto operation breakpoints, real closure-held AES-GCM/HMAC request encryption, encrypted-response restoration and server validation, handoff, audit/diagnostic redaction and state concurrency. Go tests cover Bridge v3 pairing, code derivation, signed challenge/auth, revocation, YakURL control, chunking/session recovery, transform URL confinement and Native Messaging proxy framing. diff --git a/README.md b/README.md index c0be318..ed03e77 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Browser security tools and a consent-gated context bridge for Yak AI agents. -The WXT extension includes proxy profiles and PAC routing rules, Cookie and User-Agent tools, a Shadow DOM edge panel, authenticated-tab context capture, and controlled execution in the page's real JavaScript world. Structured context uses bounded text, forms, authentication signals, open Shadow DOM traversal, context diffs, and document-bound node references instead of exporting full page HTML. AI access is bound to a concrete tab, frame, document, origin, task, scope set, and expiration time. Yak/Yakit product assets are kept in `public/` and exposed to content scripts through explicit web-accessible resources. +The WXT extension includes proxy profiles and PAC routing rules, Cookie and User-Agent tools, a Shadow DOM edge panel, authenticated-tab context capture, controlled execution in the page's real JavaScript world, and a Chromium Deep Capture debugger for real frontend crypto workflows. Structured context uses bounded text, forms, authentication signals, open Shadow DOM traversal, context diffs, and document-bound node references instead of exporting full page HTML. The Recorder discovers business Traces through one crypto-adapter model for WebCrypto, CryptoJS, JSEncrypt, sm-crypto, and node-forge, plus Beacon/Worker/SharedWorker/MessagePort boundaries. An exact receiver-bound primitive-to-request-field chain can become a plaintext gateway directly, while stateful or multi-call AES/RSA/signature envelopes are promoted to a request-level graph and captured as one business callable. Deep Capture can pause the next selected crypto call, message boundary, or request, deterministically rank live page frames, recover ESM/module script URLs, and retain an in-scope closure—including a closure holding a `CryptoKey` or `WebAssembly.Instance`—without exporting key material. The Browser Transform Gateway composes those callables with typed Pipeline v2 nodes so Yakit Web Fuzzer can edit plaintext while the live browser produces and consumes the real wire format. AI access is bound to a concrete tab, frame, document, origin, task, scope set, and expiration time. Yak/Yakit product assets are kept in `public/` and exposed to content scripts through explicit web-accessible resources. When an Agent reaches a QR code, MFA, CAPTCHA, or device confirmation, it can create a human handoff. The target tab is focused, the extension presents the request in Popup, Options, and the edge panel, and the Agent receives a completion or cancellation event after the user decides. The network workspace can capture a granted document's real Fetch/XHR requests and open an authenticated replay packet in Yakit Web Fuzzer. Sensitive headers, Cookie, and body capture are off by default and session-only. A separate local audit log stores only method, target, timing, and outcome metadata; it does not store page content, Cookie values, Eval source, network payloads, arguments, or results. @@ -54,7 +54,7 @@ pnpm verify:ui:enterprise:fallback pnpm verify:native ``` -`verify:production` runs Vitest and enforces content-script, background, total-size, permission, managed-policy, execution-channel, `webRequest`, and web-accessible-resource budgets across four packages. Browser E2E covers Chrome Store User Scripts, Enterprise User Scripts, and the Enterprise injected fallback, including document-bound grants, context diff, stable node operations, expression/program scope separation, pause/resume/revoke, human handoff, request/crypto observation, Yakit workflows, split storage, Service Worker restart, audit/diagnostic redaction, strict CSP, fail-closed tab teardown, and 320/390/desktop UI bounds. `verify:native` builds the Go host and exercises Chromium Native Messaging through the host into a loopback Yak Bridge fixture; because Playwright cannot operate Chrome's toolbar permission prompt, only its disposable test copy pre-grants `nativeMessaging`, while the source Store package is asserted to remain optional. +`verify:production` runs Vitest and enforces permission, managed-policy, execution-channel, `webRequest`, `debugger`, fixture-leakage, and web-accessible-resource policies across four packages. Content-script, background, recorder, compressed-background, and total package sizes remain visible as advisory reference metrics; exceeding those references does not block a build. Runtime performance is verified with bounded workloads and real browser flows instead of treating bundle size as a proxy for responsiveness. Browser E2E covers Chrome Store User Scripts, Enterprise User Scripts, and the Enterprise injected fallback, including document-bound grants, context diff, stable node operations, expression/program scope separation, pause/resume/revoke, human handoff, request capture, exact value and correlated channel Trace links, form/query field evidence, short-sample replay, recording-to-callable interaction, callable lifecycle management, same-tab navigation continuation and browser Back, deep-capture frame provenance/scope expansion, Yakit workflows, split storage, Service Worker restart, audit/diagnostic redaction, strict CSP, fail-closed tab teardown, and 320/390/desktop UI bounds. The Chromium fixture additionally uses real sm-crypto and minified node-forge browser bundles, a randomized non-global ESM closure holding a real WebAssembly instance, and an opaque Worker path; every retained callable/profile is checked by an independent server. The performance gate covers 1,000 small calls, 10 × 1 MiB calls, event exhaustion, oversized replay-handle rejection, and post-stop API restoration. `verify:native` builds the Go host and exercises Chromium Native Messaging through the host into a loopback Yak Bridge fixture; because Playwright cannot operate Chrome's toolbar permission prompt, only its disposable test copy pre-grants `nativeMessaging`, while the source Store package is asserted to remain optional. Browser verification prefers `CHROMIUM_PATH`, then `CHROME_PATH`, Playwright's Chromium cache, Chrome for Testing, or system Chromium. It deliberately does not auto-select stable Google Chrome because current stable Chrome ignores unattended `--load-extension` startup flags. @@ -68,7 +68,7 @@ go run common/yak/cmd/yak.go grpc --host 0.0.0.0 Open **系统设置 -> 浏览器集成** in Yakit, then open **引擎连接** in the extension and choose **查找本机 Yakit**. Both surfaces display the same six-digit verification code. Compare the code and approve the pending browser in Yakit. The approval persists an origin-bound device identity; later connections authenticate automatically with signed challenges. Removing the device in Yakit immediately disconnects it and requires a new approval. -To run a browser task, create a sharing grant for the target tab in the extension, return to **系统设置 -> 浏览器集成**, and click the online browser row. The device workspace can call a scoped capability directly or run Yak code with a request-bound `browser.ExtensionCall`. Task state, logs, JSON results, cancellation, and errors are streamed in that workspace. Do not use the generic `ExecYakScript`/`grpc_execYak` runner for this flow: that runner starts a child Yak process and cannot own the parent gRPC process's live browser connections. +To run a browser task, create a control sharing grant for the target tab in the extension, return to **系统设置 -> 浏览器集成**, and click the online browser row. The default browser-workspace view contains Plaintext Gateway, Recorder and Deep Capture modes; raw capability JSON and Yak code with request-bound `browser.ExtensionCall` remain advanced modes. Select a saved browser/profile pair from Web Fuzzer's **浏览器明文** control to make its editor the logical plaintext view; **明文 / 线上** shows the actual transmitted request and response beside it. Task state, logs, JSON results, cancellation, and errors are streamed in that workspace. Do not use the generic `ExecYakScript`/`grpc_execYak` runner for this flow: that runner starts a child Yak process and cannot own the parent gRPC process's live browser connections. Advanced transport settings remain available for a non-default loopback port or Native Messaging deployment. `--browser-extension-bridge-port` changes the Yak listener, and `--disable-browser-extension-bridge` disables it explicitly. @@ -81,4 +81,4 @@ go build -o yakit-browser-agent-host ./common/browser/nativehostcmd ./native-host/install.sh --host-binary /absolute/path/to/yakit-browser-agent-host --extension-id YOUR_EXTENSION_ID ``` -Windows uses `native-host/install.ps1`. Native Messaging is an optional browser permission requested only when Native mode is selected. See [Native Host installation](native-host/README.md), [enterprise policy](docs/ENTERPRISE_POLICY.md), [permissions](docs/PERMISSIONS.md), [privacy](docs/PRIVACY_POLICY.md), and the [release review packet](docs/store-review/RELEASE_CHECKLIST.md). +Windows uses `native-host/install.ps1`. Native Messaging is an optional browser permission requested only when Native mode is selected. See [Browser Transform Gateway](docs/BROWSER_TRANSFORM_GATEWAY.md), [Deep Capture architecture](docs/DEEP_CAPTURE_ARCHITECTURE.md), the [frontend crypto generalization roadmap](docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md), [Native Host installation](native-host/README.md), [enterprise policy](docs/ENTERPRISE_POLICY.md), [permissions](docs/PERMISSIONS.md), [privacy](docs/PRIVACY_POLICY.md), and the [release review packet](docs/store-review/RELEASE_CHECKLIST.md). diff --git a/docs/AUTO_PROFILE_INFERENCE_ARCHITECTURE.md b/docs/AUTO_PROFILE_INFERENCE_ARCHITECTURE.md new file mode 100644 index 0000000..7cc074d --- /dev/null +++ b/docs/AUTO_PROFILE_INFERENCE_ARCHITECTURE.md @@ -0,0 +1,589 @@ +# Browser Profile 自动推断与 AI 协作架构 + +## 1. 产品决定 + +自动推断 Profile 不是明文网关的辅助功能,而是浏览器现场的默认完成路径。 + +用户不应先理解混淆变量、复制密钥、编写包装函数,再手工配置参数路径和输出映射。正常流程必须从一次真实业务操作开始: + +```text +用户执行登录 / 查询 / 提交 + -> Recorder 生成有界业务 Trace + -> 确定性推断器关联明文点、页面调用和请求字段 + -> 已知模式直接生成候选 + -> 未知模式请求 AI 解释业务帧和参数语义 + -> 必要时引导用户再执行一次操作以捕获业务闭包 + -> 编译为文档绑定的 Profile + -> 使用录制样本做页面内回放校验 +``` + +手写 JavaScript 保留为高级模式,不再作为主流程或文档中的首选方案。 + +本设计不包含“发送真实 HTTP 请求验证”。真实请求仍由 Yak / Web Fuzzer 的既有数据面负责。本阶段只负责发现、推断、捕获、编译和页面内样本校验。 + +## 2. 用户结果 + +以一次 CryptoJS 调用为例,默认界面应展示: + +```text +已识别请求转换 + +POST /api/login +JSON 明文 -> CryptoJS.AES.encrypt -> body.encryptedData + +输入 argument 0 <- 请求明文 JSON +Key argument 1 <- 页面内 WordArray · 16 B +IV options.iv <- 页面内 WordArray · 16 B +模式 CBC / Pkcs7 +输出 toString -> URL encode -> encryptedData + +证据 4 项 · 高置信度 +[生成 Profile] +``` + +`_0x67b862` 一类混淆名称只能出现在折叠的原始证据中。主界面使用 `Key`、`IV`、`明文输入`、`请求字段` 等语义角色。 + +用户应能回答三个问题: + +1. 插件为什么认为这是加密链路; +2. 哪些结论是确定事实,哪些是推测; +3. 还需要用户执行什么操作才能完成 Profile。 + +## 3. 设计原则 + +### 3.1 证据先于 AI + +指纹相等、请求字段解析、调用顺序、运行时对象类型和调用栈属于确定性证据。AI 不重复判断这些事实,只消费其结构化结果。 + +### 3.2 AI 不能成为执行边界 + +AI 可以: + +- 给业务 frame 排序; +- 将参数标注为 payload、key、iv、nonce、timestamp 或 signature; +- 从有限源码片段中解释序列化和包装步骤; +- 在多个候选之间给出理由; +- 建议下一次捕获点。 + +AI 不可以: + +- 直接提交任意 JavaScript 作为生产 Profile; +- 引用不存在的事件、frame、参数或页面函数; +- 读取或输出 key、Cookie、token、密码等原始值; +- 绕过 grant、document、origin 或人工接管状态; +- 将猜测标记为已经验证的事实。 + +### 3.3 页面是执行环境,不是密钥导出器 + +Key、IV、CryptoKey、key promise、WASM 实例和闭包变量继续保留在原页面。Profile 只保存页面内 opaque callable 引用和经过校验的参数映射。 + +### 3.4 已知模式不依赖 AI,未知模式不依赖库清单 + +WebCrypto、CryptoJS、JSEncrypt 以及后续 sm-crypto、node-forge 等已知模式,连同 URLSearchParams、JSON、FormData 和常见编码链,应优先由确定性规则推断。AI 只处理业务语义和未知代码,避免增加延迟、成本和不确定性。 + +录制协议只暴露统一的 `crypto` 事件,库差异进入结构化 `adapterId / providerKind / family / operation / algorithm / mode / padding / encoding / state / key metadata`。推断器、时间线、Deep Capture 和 Agent 不再分别判断 `webcrypto`、`cryptojs` 等事件类型。新增密码库时只扩展 MAIN-world adapter、扩展自带的 manifest 和受限元数据归一化器,不扩展整条产品协议。 + +已知 adapter 只负责提供更准确的参数角色、算法和状态语义,不是通用性的唯一来源。对于 ESM/Webpack 闭包、Worker、WASM 或完全未知的业务封装,系统必须从请求/消息边界和调用栈恢复上层业务 callable;算法尚未命名不能单独成为 `insufficient-evidence`。adapter 协议的开放化、Worker/MessagePort 边界、高价值库优先级与反靶场特化验收见 [`FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md`](FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md)。 + +### 3.5 无兼容负担 + +插件尚未正式投入使用。页面配方、运行时适配器和 Transform Profile 可以直接收敛到新模型,不保留旧数据迁移或双写逻辑。 + +## 4. 总体架构 + +```text +MAIN-world Recorder + | bounded events + opaque handles + semantic argument metadata + v +Evidence Normalizer + | request fields / call slots / encodings / exact & normalized links + v +Evidence Graph + | proven edges + supported edges + hypotheses + +-----------------------+ + | | + v v +Deterministic Inference AI Analysis + | known patterns | frame ranking / semantic labels / unknown code + +-----------+-------------+ + v + Candidate Merger + | schema validation + evidence reference validation + v + Pipeline Compiler v2 + | page callable graph, no arbitrary generated code + v + Local Sample Replay + | deterministic compare or structural assertions + v + Document-bound Profile +``` + +推断计算在扩展后台完成。Yakit、Options 和 AI Agent 读取同一候选结构,不各自实现一套启发式规则。 + +## 5. Evidence Graph + +### 5.1 节点 + +```ts +type EvidenceNode = + | RecordingEventNode + | RecordedValueNode + | RequestFieldNode + | CallableNode + | CallArgumentNode + | StackFrameNode + | SourceExcerptNode +``` + +节点只使用录制会话内稳定 ID。原始敏感值不是图节点属性。 + +### 5.2 边 + +```ts +type EvidenceStrength = "proven" | "supported" | "hypothesis" + +type EvidenceEdgeKind = + | "exact-value" + | "normalized-value" + | "parent-call" + | "same-trace" + | "stack-frame" + | "argument-role" + | "request-destination" +``` + +- `exact-value`:同一录制盐下的指纹完全相同; +- `normalized-value`:经过有界白名单转换后相同,例如 URL decode、JSON field extraction 或 Base64 表示; +- `parent-call`:Recorder 的同步父调用关系; +- `same-trace`:弱证据,只证明时间和用户操作相关; +- `hypothesis`:只能由 AI 或启发式产生,必须列出依据。 + +### 5.3 请求边界归一化 + +网络事件在边界处解析,不全局 Hook `JSON.stringify` 或 `encodeURIComponent`: + +- JSON:递归提取最多 64 层、100,000 节点; +- `application/x-www-form-urlencoded`:字段级 URL decode; +- `FormData`:字段名、字符串值和文件元数据; +- Headers:规范化名称但保留原始大小写用于展示; +- Query:字段级解析; +- 原始 body:保留整体指纹和类型。 + +归一化候选只允许白名单操作并设置总预算。不得对每个值进行无界编码组合爆炸。 + +### 5.4 参数语义 + +Recorder 对已知库记录参数角色而不是变量名: + +```ts +interface CallArgumentEvidence { + index: number + role: "data" | "key" | "iv" | "algorithm" | "options" | "signature" | + "salt" | "nonce" | "aad" | "unknown" + dataType: string + byteLength?: number + replaceable: boolean + retained: boolean + summary?: string +} +``` + +例如 CryptoJS AES: + +- `argument 0`:data,可替换; +- `argument 1`:key,不导出,页面内保留; +- `argument 2`:options,提取 mode、padding 和 IV 长度,不提取 IV 值。 + +例如 JSEncrypt RSA: + +- `argument 0`:UTF-8 data,可替换;对象输入按稳定 JSON 序列化后再交给原函数; +- receiver:保留实际 JSEncrypt 实例,不重建、不导出; +- key:只记录 public/private、模数位数和本次录制随机加盐的指纹; +- padding:记录 `PKCS1-v1_5` 等可解释元数据; +- output:记录 Base64 形态并与 JSON/Form/Header/Query 请求字段做 exact link; +- 公私钥 PEM、模数、指数和页面实例永不进入候选或 AI 上下文。 + +如果一次 RSA 输出精确进入一个请求字段,且原函数、receiver 和参数模板仍在当前 document 中,候选可以直接进入 `ready`,不要求用户填写函数表达式或先进入 Deep Capture。 + +## 6. 统一 Page Callable + +当前页面配方和深度捕获适配器表达的是同一概念:在当前文档中可重复调用的页面函数。两套注册表应合并为 `BrowserPageCallable`。 + +```ts +interface BrowserPageCallable { + id: string + kind: "recorded-call" | "business-closure" | "global-function" + name: string + target: BrowserTarget + lifecycle: "document" + inputSlots: CallableInputSlot[] + output: CallableOutputShape + provenance: { + recordingId?: string + traceId?: string + eventId?: string + frameId?: string + sourceUrl?: string + lineNumber?: number + } +} +``` + +`recorded-call` 保存原函数、receiver、固定参数模板和可替换槽位;`business-closure` 保存 CDP 暂停时捕获的业务函数与闭包;两者使用同一执行、授权、生命周期和审计接口。 + +Profile 不再引用 `recipeId` 或 `adapterId`,只引用 `callableId`。 + +## 7. Pipeline v2 + +手工 JavaScript 中常见的 JSON 序列化、编码、调用和封装应变成可审计的类型化节点: + +```ts +type PipelineNode = + | { kind: "context.read"; path: string } + | { kind: "builtin"; operation: BuiltinOperation; inputs: NodeRef[]; options?: object } + | { kind: "page.call"; callableId: string; arguments: NodeRef[] } + | { kind: "output.write"; destination: string; source: NodeRef; encoding: ValueEncoding } +``` + +首批 `BuiltinOperation`: + +```text +value.literal +json.stringify +json.parse +text.toString +url.encode +url.decode +base64.encode +base64.decode +hex.encode +hex.decode +object.pick +object.compose +form.compose +``` + +`value.literal` 只允许字符串、数字、布尔值或 `null`,用于编译器生成固定的协议元数据,例如表单 +`Content-Type`。它不接受输入,也不能持有函数、对象或页面秘密。 + +每个节点有明确输入输出类型和大小预算。未知操作不能通过 AI 临时创造;用户确实需要自定义代码时,进入独立的高级节点,并沿用程序 Eval 的高风险授权。 + +## 8. 推断候选 + +候选不是立即生效的 Profile: + +```ts +interface BrowserProfileInferenceCandidate { + id: string + recordingId: string + traceId: string + target: BrowserTarget + request: { eventId: string; method: string; url: string } + direction: "request" | "response" + status: "ready" | "capture-required" | "mapping-required" | "insufficient-evidence" + confidence: { score: number; level: "high" | "medium" | "low" } + summary: string + pipeline: PipelineNodeDraft[] + evidence: InferenceEvidenceRef[] + missing: InferenceMissingStep[] + aiContext: BrowserInferenceAIContext +} +``` + +置信度不是 AI 的主观百分比。分数由固定规则产生,并在 UI 中解释: + +- 请求字段与加密输出 exact link:强加分; +- 可重复 callable 已保留:强加分; +- 同一用户 Trace 且顺序正确:中等加分; +- 仅时间接近:弱加分; +- 多个同分候选:降分; +- 缺少输入映射或输出封装:状态不能为 ready。 + +## 9. 自动业务函数捕获 + +低层 `CryptoJS.AES.encrypt` 或 `crypto.subtle.encrypt` 往往不足以构造完整线上报文。推断器应把它作为断点入口,然后寻找上层业务函数。 + +```text +候选指出需要业务 callable + -> 用户点击“自动捕获完整加密流程” + -> 插件在已知低层调用处 arm 一次性断点 + -> 用户重复相同操作 + -> 页面暂停并立即显示控制面 + -> 后台排除 Hook/依赖帧,并使用多来源共同祖先提示排序页面帧 + -> 纯函数用 selected-frame;负责 DOM 取值/组包/发送的函数用 request-transaction + -> 页面立即恢复 + -> 新明文映射到参数或页面控件,仅返回被拦截的线上 envelope +``` + +录制器只从每个来源事件的有界同步栈提取页面帧提示,并对 `functionName + script URL` 求交集;支持来源更多、平均深度更浅的共同祖先优先。捕获入口选择最早已确认的密码来源,而不是已经离开上层异步函数后的 Fetch 边界。后台再结合真实 CDP `scriptId`、函数位置、来源分类和副作用检查做最终选择,因此前端不能通过提交 URL、行号或函数名把任意对象伪装成推荐帧。 + +当最高候选唯一、可解析且未发现副作用时,默认路径使用 `selected-frame`。如果多个密码来源的最近共同页面祖先本身包含网络、DOM 或条件导航,系统不会跳过它去选更外层的事件 handler,而是建立 `request-transaction`:保留真实函数、receiver 和固定参数,在页面内替换明文控件,拦截唯一的目标 Fetch/XHR/Beacon/Form,校验所有预期输出字段后回滚 DOM。 + +存储副作用、多个或未授权请求、无法唯一绑定函数、或共同祖先证据并列时,系统保持页面暂停并解释原因。函数引用表达式只存在于高级模式。页面暂停不等待远程 AI;AI 只能在页面恢复后基于同一份有界证据做解释和候选补丁。 + +函数捕获后,后台从 `Function.prototype.toString` 恢复包括默认参数在内的有序参数名。单参数业务函数默认读取整个逻辑 Body;多参数且名称可靠时,引导配置生成 `body.` 读取节点;`arg0` 这类占位名不会被冒充为已确认字段。Options 同时从已授权暂停帧的 local/block/closure scope 取同名原始值,构造一次性的本地回放 Body。完整暂停作用域始终只存在于当前会话;只有用户明确生成并保存明文网关后,选中的短时样本才会复制到独立的本机回放草稿。该草稿按 `profileId + request/response` 隔离,不写入 Profile、Bridge、审计、Yak/AI、诊断或导出,并可由用户单独清空。 + +### 9.1 多密码调用按请求建图 + +一个请求可能同时包含 AES ciphertext、RSA-encrypted session key、HMAC signature、nonce 和 timestamp。即使每个低层输出都与请求字段精确匹配,也不能把这些调用分别保存后独立回放:它们可能共享同一随机 key、IV、nonce 或闭包状态。 + +推断器因此按请求边界合并多个来源,生成一个 request-level candidate: + +```text +plaintext -----------------> AES.encrypt ----------> body.data +dynamic AES key -----------> JSEncrypt.encrypt ----> body.encryptedKey +canonical request fields --> HMAC.sign ------------> header.X-Sign + | + +-- 同一上层业务 callable 保证动态值一致 +``` + +界面展示每个密码调用及其线上目标,但状态固定为 `capture-required`。用户点击“自动捕获完整加密流程”时,Deep Capture 优先在仍保留上层业务调用栈的密码来源处武装断点,并捕获一次上层业务封装;系统不会把多个看似 ready 的低层调用拆成多个可执行 Profile,也不会误导用户反复缩短已经足够短的录制操作。 + +### 9.2 请求事务的输入与输出契约 + +`request-transaction` 对明文只暴露一个 `body` 输入槽,对 Pipeline 返回被页面业务代码生成的整个请求 Body。因此 AES + RSA 之类多输出流程会直接编译为: + +```text +context.read(body) + -> page.call(sendDataAesRsa 请求事务) + -> output.write(body) +``` + +事务保留暂停现场的 URL/event/receiver 等固定参数。逻辑 Body 是对象时,先按 input `name/id` 向页面控件做同名映射;参数名明确是 `payload/data/body/request/params/input` 时才直接替换参数。已混淆的单参数如果其保留值解析后等于目标 URL,必须继续保留,不得被明文对象覆盖。 + +## 10. AI Agent 集成 + +### 10.1 绑定资源 + +Yakit 从浏览器集成页启动 AI 分析时,附加一个类型化资源: + +```text +AttachedResourceInfo.type = browser_session +AttachedResourceInfo.key = context +``` + +Value 只在 Yak 进程内解析,包含 device、grant、document、selected trace 和 candidate ID。渲染给模型的内容只包含安全摘要,不暴露 device token、grant secret 或录制值。 + +资源必须绑定: + +```text +timeline session +AI task +deviceId +grantId +tabId + frameId + documentId + origin +expiresAt +``` + +### 10.2 Agent 工具 + +不要把几十个 Bridge RPC 原样暴露给模型,也不要提供通用 `method + params` 工具。首批提供三个领域工具: + +```text +browser_observe + page summary / actionable nodes / trace / inference / status / diff + +browser_inference + list candidates / inspect evidence / arm capture / choose callable / + propose mapping / compile candidate / local replay + +browser_act + stable node action / tab activation / human handoff +``` + +工具回调从当前 AI task 的 `browser_session` 资源解析绑定,AI 参数中不存在 `deviceId`、`grantId` 或任意 Bridge method。 + +`browser_observe` 默认只读;`browser_inference` 的读取和推断无需额外确认,arm debugger、创建 callable 和发布 Profile 使用现有细分 scope;`browser_act` 遵循 Agent review policy 和人机接管状态。 + +### 10.3 AI 输出 Schema + +AI 只能返回候选补丁: + +```ts +interface AIInferencePatch { + candidateId: string + labels: Array<{ evidenceId: string; role: SemanticRole; reason: string }> + preferredFrameId?: string + argumentBindings?: Array<{ slotId: string; contextPath: string; reason: string }> + suggestedBuiltins?: Array<{ operation: BuiltinOperation; evidenceIds: string[] }> + unresolved: string[] +} +``` + +Candidate Merger 必须验证所有 ID 存在、document 未变化、操作在白名单内、映射路径合法。验证失败只产生新的待处理项,不能退化为执行 AI 代码。 + +## 11. UI / UX + +录制是入口,自动推断是录制完成后的主结果。三列工作台保持不变: + +```text +Trace 列 | 数据流与候选 | 推断证据 / 下一步 +``` + +右侧主区域按状态显示: + +- `ready`:一键生成 Profile; +- `capture-required`:解释原因并提供“自动捕获完整加密流程”; +- `mapping-required`:只让用户选择少量无法确定的明文字段; +- `insufficient-evidence`:建议重新录制,并明确缺少哪类证据。 + +证据采用三种强度: + +- 已证实:实线和明确措辞; +- 有支持:普通文本并展示依据; +- 待确认:虚线或次级文本,不使用成功色。 + +AI 是候选的解释者,不单独占据一个聊天面板。主要入口是“让 AI 深入分析”,结果回填到同一证据区域。需要继续对话时再打开 Yakit AI 会话,并携带相同 `browser_session` 资源。 + +手工 Pipeline 编辑器移入“高级编辑”,默认只展示推断出的可读流程和少量可修改字段。 + +默认 Profile 编辑器不是节点画布,而是三个业务决定: + +```text +1. 明文从哪里来 +2. 交给哪个页面函数 +3. 线上结果写到哪里 +``` + +当第三步选择“写入表单字段”并填写 `encryptedData` 时,编译器自动生成 +`form.compose(keys=["encryptedData"])`、固定 Content-Type、Header 输出和 Body 输出。用户不需要看到或填写 +`keys`、节点 ID、输入引用和输出引用。已有非规范 DAG 不会被静默改写,只能继续在高级模式中编辑,或由用户明确替换为引导流程。 + +## 12. 性能预算 + +- 单次快照最多 500 事件、每事件 48 个 evidence; +- 图构建使用 fingerprint/path 索引,目标复杂度 `O(E + V)`; +- normalized link 每值最多生成 8 个白名单变体; +- 候选最多 16 个,发送给 AI 的候选最多 3 个; +- scope 每次最多 8 个 frame,源码片段按需读取并限制总字节; +- 推断结果按 `recordingId + event revision` 缓存,增量追加事件时只处理新增部分; +- 不在页面主线程执行全量源码搜索、AST 构建或全局 JSON/URL 编码 Hook; +- Pipeline 在目标 document 内一次执行完成,每次请求/响应只跨扩展到页面边界一次,不按节点往返; +- 页面暂停路径绝不等待网络或 AI。 + +## 13. 隐私与授权 + +- 默认推断只使用指纹、类型、长度、路径、算法摘要和源码位置; +- 敏感录制预览即使被用户开启,也不自动进入 AI context; +- Key、IV、CryptoKey 和闭包值只显示语义、类型与长度; +- 源码片段可能包含硬编码 secret,发送 AI 前先进行字面量脱敏并由用户授权; +- 推断读取使用 `browser.recording.read`; +- scope/source 深入读取使用 `browser.debugger.read`; +- arm/resume 与 callable 创建使用 `browser.debugger.control`; +- callable 创建、执行与本地回放使用 `browser.callable.execute`;从暂停 frame 捕获 callable 还需要 `browser.debugger.control`; +- Profile 发布使用 `browser.transform.manage`; +- document、origin 或 grant 变化后候选立即标记 stale,不静默重绑。 + +## 14. 生命周期与恢复 + +Profile 是 document-bound。刷新后不能继续调用旧闭包,但推断定义可以保留为恢复计划: + +```text +页面刷新 + -> callable stale + -> Profile disabled + -> 插件按原 operation / script / route 重新 arm + -> 用户正常执行一次业务操作 + -> 重新捕获 callable + -> 本地样本校验 + -> 用户确认后重新启用 +``` + +恢复计划不保存 key 或源码计算结果,只保存捕获入口、业务 frame 特征、参数语义和映射结构。 + +## 15. 分阶段实现 + +### P0:证据与候选基线 + +- [已完成] 使用统一 `crypto` 事件记录 WebCrypto / CryptoJS / JSEncrypt / sm-crypto / node-forge 的 adapter、provider kind、family、调用、参数角色、类型、长度和 state/retained 状态; +- [已完成] MAIN-world 密码适配器注册表支持稳定 adapter 与运行时晚加载 adapter; +- [已完成] JSEncrypt RSA encrypt/decrypt/sign/verify 保留真实 receiver,并仅输出公私钥类型、位数和加盐指纹; +- [已完成] 为 CryptoJS 结果补充安全的字符串表示 evidence; +- [已完成] 从 exact link、请求字段和调用顺序生成只读候选; +- [已完成] Options / Yakit 展示置信度、证据和缺失步骤; +- [已完成] 候选结构可通过 `browser.recording.get` 提供给 Agent。 + +### P1:统一 Callable 与 Pipeline v2 + +- [已完成] 删除 recipe / adapter 双模型,不保留旧方法别名或迁移分支; +- [已完成] 页面 callable 使用统一注册表、来源信息、生命周期和命名 input slot schema; +- [已完成] Pipeline v2 使用有序 DAG,并加入类型化 context.read / builtin / page.call / output.write 节点; +- [已完成] builtin 限定为 JSON、文本、URL、Base64、Hex、对象和表单组合白名单; +- [已完成] 输出支持 body、字段级 body、header 和 query,并由 Yak 二次限制 URL 只能改变 query; +- [已完成] 单条 exact value link 且保留可执行调用句柄的 stateless/receiver 模式可直接编译候选;stateful/stream 模式必须捕获上层 callable; +- [已完成] 同一请求的多个密码来源合并为 request-level candidate,并强制捕获上层业务 callable 以保持动态值关系; +- [已完成] JSON 字段、表单字段、Header、Query 和完整 Body 会编译为对应的引导式输出,不要求用户理解 DAG; +- [已完成] 录制短时样本自动填入 Options/Yakit 明文网关本地回放,并允许编辑后恢复原样本; +- 为页面内回放生成确定性/结构性断言。 + +### P2:自动业务函数捕获 + +- [已完成] 候选一键 arm,并在已有捕获等待或页面暂停时拒绝覆盖; +- [已完成] 业务 frame 使用来源、边界距离、函数可解析性、副作用、命名和作用域信息做确定性排序; +- 参数槽位与 request context 自动映射; +- 文档刷新后的引导式重新捕获。 + +### P3:Yak AI Agent + +- `browser_session` attached resource; +- task-bound 三个 Agent 工具; +- AIInferencePatch schema 与 Candidate Merger; +- Yakit 从候选直接启动带上下文的 AI 会话; +- Agent 操作写入现有 session timeline。 + +### P4:复杂应用 + +- Axios/interceptor、GraphQL、WebSocket frame、protobuf 与自定义 serializer; +- [已完成] 按通用化路线迁移 adapter host,加入 sm-crypto、node-forge 与 Beacon/Worker/MessagePort 边界,并通过随机 ESM + WASM holdout; +- jsrsasign、jose 与后续现代密码生态按真实样本继续推进; +- sourcemap 存在时的业务 frame 增强; +- 多候选对比和跨操作共用 callable 识别。 + +P4 的实现顺序、协议草案、性能门禁和随机化测试矩阵以 [`FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md`](FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md) 为准。 + +## 16. 验收夹具 + +至少覆盖: + +1. 固定 CryptoJS AES,混淆变量名,JSON 字段输出; +2. 真实 JSEncrypt RSA + form-urlencoded `data` 字段,独立服务端用私钥解密验收;保留实例 receiver,停止录制后对象明文仍可回放; +3. RSA 候选和 AI 上下文只包含 key 类型、位数与加盐指纹,不包含 PEM 或模数; +4. WebCrypto AES-GCM + HMAC,闭包内不可导出 key 和动态 nonce/IV; +5. AES + RSA + HMAC 同请求多来源图,不允许拆分低层调用回放; +6. 动态 key promise,页面刷新后重新捕获; +7. Axios interceptor 中的请求签名; +8. Form URL encode 和 Header signature; +9. 自定义业务 wrapper,低层库调用不足以构造完整报文; +10. 未知函数与多个同分业务 frame,AI 只能补全候选,不能直接执行代码; +11. WASM 导出函数,只能观察输入输出和业务 wrapper; +12. 敏感预览开启时,AI payload、审计和诊断仍不含原始值; +13. 500 事件 / 24,000 evidence 的性能与内存预算。 + +## 17. 目标目录 + +```text +src/features/browser-recording/ + evidence.ts + recorder.ts + +src/features/browser-inference/ + graph.ts + normalize.ts + rules/ + candidates.ts + compiler.ts + ai-context.ts + +src/features/browser-callable/ + registry.ts + execute.ts + lifecycle.ts + +src/features/browser-transform/ + pipeline-v2.ts + profile.ts + replay.ts +``` + +Yak 侧将 `browser_session` 资源解析和 Agent 工具放在独立包中,依赖一个最小的 Bridge caller interface,避免 `common/ai` 直接依赖 gRPC Server。 diff --git a/docs/BROWSER_TRANSFORM_GATEWAY.md b/docs/BROWSER_TRANSFORM_GATEWAY.md new file mode 100644 index 0000000..fd3f782 --- /dev/null +++ b/docs/BROWSER_TRANSFORM_GATEWAY.md @@ -0,0 +1,259 @@ +# Browser Transform Gateway + +## 1. Product contract + +The Browser Transform Gateway exists for one concrete testing workflow: + +> The operator edits and fuzzes meaningful plaintext in Yakit, while the live authenticated browser page performs the same encryption, signing, serialization, dynamic-parameter generation, or response decryption that the production application performs. + +The result sent on the network must be accepted by the real server. A fixed codec demo, copied JavaScript function, or standalone mock key does not satisfy this contract. + +The primary workflow is: + +```text +real browser operation + -> Recorder correlates user input, crypto calls, and network requests + -> an exact recorded call is retained directly when it already covers the required transform + -> otherwise Deep Capture pauses at the relevant higher-level business call + -> operator retains the real in-scope function as a page callable + -> operator composes callables and typed nodes into a request/response transform profile + -> Yakit Web Fuzzer remains a plaintext editor + -> Yak asks the selected live browser to transform the request immediately before sending + -> Yak sends the resulting wire packet + -> Yak optionally asks the browser to transform the wire response + -> Yakit displays plaintext and preserves a separate wire view +``` + +The browser is therefore an execution environment, not a passive code source. Non-extractable `CryptoKey` objects, closure variables, key promises, runtime tokens, random generators, timestamps, WASM instances, and application serializers remain in the page that already owns them. + +There are two valid discovery outcomes: + +1. **Direct recorded callable.** One observed primitive already accepts the logical plaintext and its output is proven to enter one wire destination. For example, `JSEncrypt.encrypt` with its real instance receiver can map an object body to form field `data`. The operator generates the guided gateway directly; no function expression or debugger pause is required. +2. **Business callable.** A request combines multiple primitives or surrounding serialization/dynamic state. AES ciphertext, RSA-wrapped key, signature, nonce, timestamp, and request canonicalization are treated as one request graph, then Deep Capture retains the higher-level closure. The extension never replays those low-level calls independently merely because each output has an exact field link. + +## 2. Relationship to JS-RPC and JS-Forward + +JS-RPC, JS-Forward, browser-side hook tools, and this gateway share the same basic idea: forward values into a browser JavaScript environment and receive transformed values back. The important product difference is the ownership and workflow around that call. + +| Concern | Traditional forwarding setup | Browser Transform Gateway | +| --- | --- | --- | +| Function discovery | User locates and exposes a function manually | Recorder and Deep Capture lead from a real request to the relevant business frame | +| Runtime environment | Usually a manually maintained browser tab or injected service | Explicitly selected, paired, document-bound authenticated tab | +| Data-plane integration | External HTTP port or custom script modifies packets | Native Web Fuzzer pre-send and post-response hooks in the owning Yak gRPC process | +| Request editing | Often ciphertext-oriented or script-oriented | Plaintext is the canonical editable request | +| Observability | Tool-specific logs | Plaintext request, wire request, wire response, plaintext response, and step timing | +| Lifecycle | Caller must notice stale pages/functions | Navigation and refresh fail with document/origin errors; no silent retargeting | +| Authorization | Commonly a shared local endpoint | Paired device, task, grant, target, scope, and capability schema | + +An external forwarding port can be added later as another Yak data-plane adapter for Burp/Fiddler compatibility. It must reuse the same profile execution contract and must not become a second configuration or authorization system. + +Research notes and comparisons are retained in [`study.md`](study.md). They inform discovery and UX, but the production acceptance criterion is always whether a server accepts the transformed packet. + +## 3. Component responsibilities + +### Browser extension + +- discovers page-side data flow through Recorder; +- captures a real business closure through Chromium Deep Capture; +- stores only document-bound callable metadata and transform profiles; +- keeps an optional replay draft per profile and direction in extension-local storage after the operator saves a gateway; +- validates route, method, origin, document, function binding, paths, and output mappings; +- executes an ordered Pipeline v2 DAG in the live MAIN world; +- sends the complete validated DAG and packet through one extension-to-page round trip instead of crossing the boundary for every node; +- returns bounded URL/body/header mutations plus per-node duration; +- never exports closure bindings or key material. + +The replay draft is deliberately not a field of the transform profile. It may contain a plaintext account, password, +token, request headers, or a selected short capture sample. It is keyed by `profileId + request/response`, stays in +`browser.storage.local`, and is excluded from profile export, Bridge/RPC capabilities, Yak/AI context, audit, and +diagnostics. Deleting a profile deletes both directional drafts. The editor autosaves at most 256 KiB per direction; +larger input remains usable in the current Options page but replaces no persisted value. + +### Yak engine + +- performs profile preflight through the Bridge owned by the current gRPC process; +- composes the browser transform with existing Web Fuzzer hot-patch hooks; +- calls the selected browser immediately before the real request and immediately after the real response; +- fails closed before network transmission if request conversion fails; +- emits an explicit synthetic `598 Browser Transform Failed` response if response conversion fails; +- preserves logical and wire packets separately in every Fuzzer result and history item. + +### Yakit + +- lists only online paired browsers and profiles visible to the active grant; +- provides the full profile editor in Browser Integration; +- lets Web Fuzzer select one browser/profile pair without leaving the request workflow; +- keeps `RequestRaw` and `ResponseRaw` as the canonical plaintext editor/display values; +- exposes `WireRequestRaw` and `WireResponseRaw` through a stable side-by-side comparison; +- restores the selected browser/profile when reopening Fuzzer history. + +## 4. Transform profile + +A profile is intentionally document-bound and contains: + +- a name and enabled state; +- `tabId + frameId + documentId + origin`; +- allowed HTTP methods and a bounded wildcard URL pattern; +- an optional request pipeline; +- an optional response pipeline; +- `failMode: closed`; +- a bounded per-profile concurrency limit from 1 to 8. + +Method, URL, headers, body, captured short sample, and the last local replay result are not profile fields. The first +five can be restored from the separate local-only replay draft; execution results and errors are never persisted. + +At least one direction must be enabled. Every enabled direction contains at least one node and one `output.write` node. + +A path-only URL pattern such as `/api/*` or `*/api/login` is restricted to the bound page origin. Cross-origin APIs must be intentional: use a full pattern such as `https://api.example.test/*`. This prevents a broadly reusable path rule from turning a page-held key into a cross-origin signing oracle. + +### Pipeline v2 nodes + +Each node has a stable ID and may reference only an earlier node. This makes the data flow explicit and prevents cycles or undeclared reads. The supported node kinds are: + +| Node | Purpose | +| --- | --- | +| `context.read` | Read a safe path from the immutable input context | +| `builtin` | Apply one whitelisted JSON/text/URL/Base64/Hex/object/form operation | +| `page.call` | Invoke one document-bound `BrowserPageCallable` with referenced arguments | +| `output.write` | Write a referenced value to an allowed packet destination | + +The normal editor presents these nodes through a three-step guided compiler: choose the plaintext source, choose the +live page callable, and choose the wire destination. The ordered DAG is an implementation detail under “Advanced +Pipeline”; operators do not manually select node references for common request encryption. + +For example, choosing `form field` with the name `encryptedData` compiles to: + +```text +context.read(body) + -> page.call(recorded AES callable) + -> form.compose(keys=["encryptedData"]) + -> output.write(body) + +value.literal("application/x-www-form-urlencoded") + -> output.write(header.Content-Type) +``` + +`value.literal` is a bounded whitelist operation that accepts only a primitive value and no inputs. It exists so the +compiler can express fixed protocol metadata without arbitrary JavaScript. Existing non-canonical DAGs remain in the +advanced editor and are never silently rewritten. + +`context.read` accepts these safe roots: + +```text +method +url +statusCode +headers.content-type +body +body.account +body.password +text +bodyBase64 +query +query.name +``` + +Missing node IDs, forward references, duplicate IDs, malformed paths, and prototype traversal segments are rejected. Arbitrary JavaScript is not a Pipeline node. + +The background validates the profile, route, origin and live document before dispatch. The selected document then evaluates the complete bounded DAG locally, including all `page.call` nodes, and returns one structured result. This keeps multi-node profiles from multiplying `scripting.executeScript` latency and keeps the Pipeline executor out of the always-on Service Worker bundle. + +### Output nodes + +An `output.write` maps a prior node result to exactly one supported destination: + +```text +body replace the complete body +body.password update a JSON body field +header.X-Sign set a header; null/undefined removes it +query.signature set a URL query field; null/undefined removes it +``` + +Output encoding is explicit: `auto`, `text`, `json`, or `base64`. Header names and values reject CR/LF injection. JSON and form field mapping preserve their structured wire format and never mutate object prototypes. The extension can return a query-mutated URL, but Yak independently verifies that scheme, hostname, effective port and path are unchanged before replacing the request target. + +## 5. Ordering + +Request execution order is deliberate: + +```text +plaintext request in Web Fuzzer + -> user beforeRequest hot patch + -> browser request transform + -> actual wire request +``` + +Response execution uses the inverse boundary: + +```text +actual wire response + -> browser response transform + -> user afterRequest hot patch + -> plaintext response in Web Fuzzer +``` + +This allows ordinary Web Fuzzer mutation logic to work on meaningful application data. The browser transform remains the last operation before transmission and the first operation after receipt. + +Redirected requests are transformed independently. A redirect to a route outside the selected profile fails closed instead of leaking a plaintext request to an unintended endpoint. + +## 6. Failure and lifecycle semantics + +The request path never falls back to sending plaintext. Profile lookup failure, offline device, expired grant, stale document, changed origin, unavailable callable, route mismatch, illegal URL mutation, queue overflow, invalid mapping, timeout, and page exception all abort transmission. + +The response path never presents undecoded wire data as if it were plaintext. It returns an explicit transformation failure while preserving the wire response for diagnosis. + +Profiles are not portable secrets. They may remain visible after a navigation so the operator can understand what became stale, but execution requires the exact current document and all referenced page callables. A reload intentionally requires recapture and rebinding. + +Local replay drafts can contain secrets even though profiles do not. Navigation or a temporarily stale callable keeps +the draft intact so the operator does not lose work. The operator can clear the current direction explicitly, and +deleting its owning profile removes both request and response drafts. The draft does not make a stale callable +executable and is never silently rebound to another origin. + +Chromium is required for capturing closure-bound business callables. Firefox keeps Recorder-created callables but does not advertise or display the Transform Gateway and Deep Capture workspaces. + +## 7. Bounds and performance + +- request and response bodies are limited to 8 MiB; +- a profile has at most 64 nodes per direction; +- a builtin or page-call node has at most 64 input references; +- a profile queue is bounded to 128 waiting operations; +- per-profile concurrency is 1, 2, 4, or 8 in the UI; +- page-callable output is lossless within bounds; cycles, functions, symbols, excessive depth/nodes, and oversized values fail explicitly; +- parsed and mapped JSON is limited to 64 levels and 100,000 nodes before a page function is invoked; +- previews may be truncated, execution values are never silently truncated; +- Bridge messages remain under the existing 16 MiB aggregate limit and use chunking above 512 KiB. + +Concurrency must reflect the page function's state model. Use `1` when the application mutates shared counters, nonce state, or token caches. Higher values are appropriate only after verifying that the retained business function is re-entrant. + +## 8. Authorization + +Transform capabilities are separated by intent: + +| Capability | Scope | +| --- | --- | +| `browser.transform.profile.list` | `browser.transform.read` | +| `browser.transform.profile.save/delete` | `browser.transform.manage` | +| `browser.transform.execute` | `browser.transform.execute` | + +The Bridge router revalidates the profile target against the active grant for list, save, delete, and execute. An existing profile ID cannot be rebound to another page document. + +## 9. Acceptance criteria + +The production fixture uses live request and response functions that close over non-extractable AES-GCM and HMAC keys. The request function creates a new timestamp, nonce, and IV per call, encrypts a JSON login payload, and signs the resulting envelope. The server returns a second AES-GCM envelope that only the retained page response function opens. Acceptance requires all of the following: + +1. A plaintext account/password packet is transformed through the retained page closure. +2. The wire packet does not contain the plaintext password. +3. The independent test server verifies HMAC, decrypts AES-GCM, and recovers the original request values. +4. The server returns an encrypted response with no plaintext password, and the retained page closure restores it to JSON. +5. Repeated calls produce different nonce and IV values. +6. A mismatched path or implicit cross-origin URL fails closed. +7. Web Fuzzer preserves and displays both plaintext and wire packets in both directions. +8. Refreshing the bound document invalidates execution rather than silently using a new page. + +Extension browser E2E covers the real page and server boundary. Yak unit tests cover hook ordering, request/response conversion, trace preservation, and failure behavior. Yakit TypeScript verification covers the integrated selector, editor, and packet comparison surfaces. + +The browser E2E suite also covers the direct RSA path independently: the real JSEncrypt browser bundle receives a generated RSA public key, its Base64 ciphertext is linked to `application/x-www-form-urlencoded` field `data`, and a separate HTTP test server holding the private key must decrypt and recover the original JSON. Raw key material must remain absent from the candidate/AI context. After recording stops, both Bridge- and UI-created callables must still invoke the retained receiver with a new structured plaintext value whose ciphertext the server-side decryptor can open. + +## 10. Current boundary + +The first production data-plane integration is Yakit Web Fuzzer. Direct Burp/Fiddler interception, WebSocket frame transformation, streaming bodies, and unattended cross-document callable recovery remain outside the current contract. They should be built as explicit extensions of this gateway, not as hidden fallbacks. + +Automatic Profile inference is now part of the core product path rather than a later convenience. Recorder evidence, retained page callables, deterministic rules and task-bound AI analysis must lead from one real browser operation to an explainable Profile candidate. The architecture, evidence contract, AI boundary and phased implementation are defined in [`AUTO_PROFILE_INFERENCE_ARCHITECTURE.md`](AUTO_PROFILE_INFERENCE_ARCHITECTURE.md). diff --git a/docs/DEEP_CAPTURE_ARCHITECTURE.md b/docs/DEEP_CAPTURE_ARCHITECTURE.md new file mode 100644 index 0000000..61b817f --- /dev/null +++ b/docs/DEEP_CAPTURE_ARCHITECTURE.md @@ -0,0 +1,149 @@ +# Deep Capture Architecture + +## Product boundary + +Deep Capture is for an authorized tester who can reproduce a real browser operation but does not want to rebuild a site's frontend encryption environment in a separate JS-RPC service. + +The user chooses the business action and reproduces it once. Request-level inference selects the capture boundary and, when the evidence is unique, the background selects and retains the relevant business frame automatically. The user chooses a stack frame only when candidates are ambiguous; a function expression is an advanced fallback. The extension supplies the browser-only parts: the live document, lexical scope, non-extractable keys, dynamically generated IV/nonce/timestamp values, function receiver and authenticated session. + +This is deliberately not a promise to autonomously solve QR codes, CAPTCHA, MFA, device confirmation or every obfuscated application. Those steps remain visible human actions. The product goal is to remove avoidable environment reconstruction after the user reaches the real business operation. + +## Workflow + +```text +Real user operation + -> lightweight Recorder discovers a Trace and target operation + -> Deep Capture arms one crypto function or request breakpoint + -> Chromium pauses at the next real invocation + -> call frames become visible immediately + -> local / closure / module scopes are collected in parallel + -> shared stack hints and CDP metadata rank page business frames + -> a pure frame becomes a business closure; a send/DOM frame becomes a request transaction + -> function object + receiver + fixed call-frame arguments stay inside the live document + -> page resumes + -> plaintext maps to formal parameters or matching page controls + -> a request transaction captures the target envelope without sending it + -> extension, Yakit or Yak invokes the page callable with new JSON arguments + -> dynamic browser behavior and server validation remain real +``` + +The Recorder is the discovery/index layer. It records bounded interactions, requests, Beacon/WebSocket/Worker/MessagePort activity, unified WebCrypto/CryptoJS/JSEncrypt/sm-crypto/node-forge crypto calls, transforms, Trace membership, exact value links and explicitly correlated channel links. A recorded-call callable replays one eligible stateless or receiver-bound primitive by replacing its named data argument while retaining the original function, receiver and fixed argument template. Stateful sessions remain evidence and are promoted to their enclosing business closure. + +Deep Capture is the runtime/context layer. It captures a business function from a paused lexical environment, so one business-closure callable may preserve several internal crypto calls, closure variables, key promises, dynamic parameters and serialization steps. If the closest common business function also reads DOM controls, builds the request and calls Fetch/XHR/Beacon/Form, the same frame is retained as a `request-transaction` instead of being skipped in favor of an outer click handler. Both sources use the same registry and execution protocol while retaining distinct provenance and input-slot metadata. + +## Chromium implementation + +The background service uses `chrome.debugger` and these Chrome DevTools Protocol domains: + +- `Runtime` resolves the live wrapper function and reads object properties; +- `Debugger` enables pauses, function-call breakpoints, call frames, scopes and `evaluateOnCallFrame`; +- `DOMDebugger` installs a one-shot XHR/fetch URL breakpoint; +- `Network` prepares the session for later request correlation without intercepting traffic in this phase. + +Crypto capture does not depend on a source `debugger` statement. Production minifiers may remove that statement, and page CSP may block dynamic code construction. Instead, the recorder exposes the exact installed adapter or communication-boundary wrapper by its opaque `wrapperHandleId`. The background sets `Debugger.setBreakpointOnFunctionCall` on that object and removes the breakpoint on the first pause. Request-only unknown code can still use a bounded XHR/fetch URL breakpoint. + +Chrome may omit `callFrame.url` for ESM/module frames. The service therefore maintains a per-tab, 4,096-entry LRU-style `Debugger.scriptParsed` index and resolves the frame source from `location.scriptId`. This makes dynamically named ESM chunks first-class capture targets without scanning a bundler cache or exposing their exports on `window`. + +Request capture uses `DOMDebugger.setXHRBreakpoint` with a bounded URL substring. It is also one-shot. + +The current implementation supports Chromium main documents. Firefox does not request `debugger`, does not advertise Deep Capture Bridge capabilities and continues to provide Recorder-created callables. + +## Pause control plane + +A paused page cannot execute `scripting.executeScript`. Status, keepalive, resume, detach and callable creation must therefore never depend on an injected document probe. + +During a pause, target authorization uses only: + +- the grant's tab/frame/document/origin tuple; +- `tabs` and `webNavigation` state; +- extension session storage owned by the background; +- CDP commands on the already attached target. + +Page execution is used only before the pause to install/resolve a target function and after the pause to list, invoke or delete retained callables. This separation prevents the debugger control plane from deadlocking on the page it controls. + +## Two-stage collection + +The pause event publishes a stack skeleton before reading scope properties. This gives UI and Bridge clients an immediately observable `paused` state and lets them extend the deadline. Scope collection then fills the first eight frames in parallel. + +Current bounds are: + +| Resource | Bound | +| --- | ---: | +| Pause watchdog | 45 seconds | +| Call frames | 14 | +| Frames with scope expansion | 8 | +| Scopes per frame | 6 | +| Variables per scope | 48 | +| Variable preview | 512 characters | +| Expandable variable detail | 4,096 characters per variable | +| Expandable detail per scope | 16,384 characters | +| Page callable arguments | 64 JSON values | +| Function expression | 4,096 characters | + +The extension UI sends keepalive every 10 seconds while paused. Yakit uses `browser.deep_capture.keepalive` as its paused-state poll. If all control surfaces disappear, the alarm watchdog resumes the page automatically. + +Every frame carries an explicit `sourceKind`: `extension-hook`, `page`, or `library`. Exact recorder/debugger wrapper names and extension URLs are classified as extension hooks; dependency/runtime URLs are classified as libraries; remaining frames are page code. Request-level inference contributes bounded common-ancestor hints from multiple source stacks. The background combines those hints with frame depth, CDP script identity, function location and risk inspection; the UI cannot supply trusted source metadata. Options and Yakit display the labels and reasons, and prevent an extension hook or dependency frame from being captured as a business callable. Scope rows are keyboard-operable expanders: the list keeps a compact preview, while the expanded block shows a bounded value or function-source detail with copy actions. This makes injected wrappers visibly different from application functions without exporting unbounded debugger data. + +## Unified page callable + +`browser.callable.create` with `source: deep-capture` has three explicit strategies. `selected-frame` resolves a pure function from the stored current call frame and rejects network/DOM/navigation/storage side effects. `request-transaction` retains the closest request-building business frame and its bounded request contract. `expression` remains an advanced fallback and passes the pure-function inspection gate. Client-provided source URLs and line numbers are not accepted. The returned function object and its frame receiver are placed in the shared `BrowserPageCallable` registry keyed by an opaque UUID. Recorder-created calls use the same registry with `source: recording`. Only metadata crosses the extension boundary: + +- callable ID, name and kind; +- ordered input slots and output type/encoding; +- function name; +- source URL and line; +- recording/Trace/event provenance when available; +- creation time; +- for a request transaction, expected method, URL reference, output destinations and allowed boundary kinds; +- `document` lifecycle. + +Formal parameter names are recovered from bounded function source, including parameters after the first default value, and become ordered input slots. Fixed parameter values and `this` are retained by reading the named parameters from the actual CDP call frame; the debugger evaluation wrapper's `arguments` object is never used as business input. Options may correlate those names with values already present in the authorized paused scope to initialize a local replay Body. That short-lived sample never enters Bridge payloads, audit records, callable metadata or profile storage. + +A request transaction exposes one logical `body` input. Execution snapshots bounded form controls and DOM mutations, maps object fields to matching input names/IDs, and temporarily replaces Fetch, XHR send, Beacon and Form submit boundaries. Exactly one request must match the configured method and URL after resolving relative URLs against the current document. The body must contain every inferred destination, such as `body.encryptedData`, `body.encryptedKey` and `body.encryptedIv`. The real transport is never called; controls and observed DOM mutations are rolled back in `finally`. Multiple requests, another URL, an unsupported/file body, timeout, over-budget data or missing fields fail closed. Ordinary business closures also receive runtime transport guards so a transitive helper cannot silently send a request that shallow source inspection missed. + +The registry does not export closure bindings, `CryptoKey` material or the function source. `browser.callable.execute` calls the retained function in the MAIN world and returns a bounded structured result. ArrayBuffer and typed-array results are normalized to byte metadata plus Base64. Execution results are lossless within the 8 MiB string/byte, 100,000-node and depth-32 bounds; cycles, functions, symbols and oversized structures fail explicitly. Only UI previews are truncated. + +Page callables are the execution primitive used by the [Browser Transform Gateway](BROWSER_TRANSFORM_GATEWAY.md). Deep Capture discovers and retains the real business function; a Pipeline v2 profile reads plaintext request/response context, invokes one or more callables and writes explicit results back to the wire packet. + +Navigation, reload or document destruction removes the registry naturally. Explicit deletion removes one callable. Callable IDs are not portable credentials. + +## Authorization and lifecycle + +Deep Capture adds three independent scopes: + +| Scope | Allows | +| --- | --- | +| `browser.debugger.read` | Read status, call frames and scopes | +| `browser.debugger.control` | Attach, arm, keep alive, resume, detach and capture a function from a paused frame | +| `browser.callable.execute` | Create, execute and delete live-document page callables | + +Remote calls remain bound to the active grant's tab, main frame, document, origin and expiry. A grant cannot control a local or different grant's debugger session. Grant replacement, expiry and revocation detach sessions owned by that grant. Tab closure removes session state. Chrome DevTools and an extension debugger may compete for the same target; the UI reports the attach/detach failure rather than silently changing targets. + +## Real acceptance fixture + +The production E2E fixture uses a local authenticated page with: + +- native WebCrypto rather than a string mock; +- non-extractable AES-GCM and HMAC keys imported inside a closure; +- a local `buildLoginEnvelope` function that is not placed on `window`; +- dynamic timestamp, nonce and IV values; +- encrypted account/password JSON; +- an HMAC over envelope fields; +- server-side HMAC verification and AES-GCM decryption. + +The test records a real operation containing AES-GCM and HMAC, infers their common `buildLoginEnvelope` ancestor, pauses on the earliest confirmed crypto source, automatically captures the selected frame, restores both `password` and defaulted `account` parameters, generates `body.password` and `body.account` bindings from the paused sample, and executes the complete local Pipeline. Independent server validation also invokes the closure with new credentials, asserts different nonce/IV values and accepts the generated envelope. A hash stub or a hard-coded frontend demo does not satisfy this acceptance criterion. + +A second real-browser fixture covers the mixed AES + RSA request transaction at `127.0.0.1:82`. Three exact output links must select `sendDataAesRsa`, not its outer `onclick`. The test supplies new username/password values through the page controls, captures `encryptedData`, `encryptedKey` and `encryptedIv`, proves that neither deep-capture recovery nor callable replay added a browser request, and sends the captured envelope independently to the fixture server for acceptance. + +## Known limits + +- Chromium Deep Capture only; Firefox remains on recording and recorded-call page callables. +- Main document only in the current phase. Cross-frame debugging needs an explicit CDP target/session design rather than silently reusing frame grants. +- Source-map remapping is not implemented; URLs and generated line/column values come from CDP. +- Highly optimized, native, WASM-heavy or deliberately anti-debugging applications may expose incomplete names or scopes. +- Runtime transport interception covers dynamic global Fetch, XHR, Beacon and Form boundaries. A function that captured a private transport reference before interception, sends inside another Worker/realm, performs unconditional direct navigation, or mutates storage through an unobserved helper is not claimed as safely automatic; the current system must block on detected evidence or report the failed/stale transaction. +- DOM rollback is bounded and best-effort. It is not a general browser transaction or a replacement for a disposable test profile. +- The tester may need to select a function-valued scope variable or use the advanced in-scope expression when an anonymous or optimized frame cannot be resolved uniquely. +- The callable intentionally stays document-bound. Portable code generation requires a separate reviewed artifact model and cannot assume captured closure/key objects are serializable. + +References: [Chrome Debugger API](https://developer.chrome.com/docs/extensions/reference/api/debugger), [CDP Debugger domain](https://chromedevtools.github.io/devtools-protocol/tot/Debugger/), and [CDP DOMDebugger domain](https://chromedevtools.github.io/devtools-protocol/tot/DOMDebugger/). diff --git a/docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md b/docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md new file mode 100644 index 0000000..c6fd0c2 --- /dev/null +++ b/docs/FRONTEND_CRYPTO_GENERALIZATION_ROADMAP.md @@ -0,0 +1,615 @@ +# 前端密码能力通用化重构与适配器路线 + +> 状态:G0–G4 已完成并通过真实浏览器/独立验证器验收;G5 按真实样本继续推进 +> +> 更新时间:2026-07-21 +> +> 关联文档:[`AUTO_PROFILE_INFERENCE_ARCHITECTURE.md`](AUTO_PROFILE_INFERENCE_ARCHITECTURE.md)、[`DEEP_CAPTURE_ARCHITECTURE.md`](DEEP_CAPTURE_ARCHITECTURE.md)、[`BROWSER_TRANSFORM_GATEWAY.md`](BROWSER_TRANSFORM_GATEWAY.md)、[`study.md`](study.md) + +## 1. 结论 + +当前实现的**数据模型、请求推断和 G4 高价值协议覆盖是通用的;WASM、流式协议与长尾生态仍需由真实样本继续驱动**。 + +现有靶场体验顺滑,主要因为它同时满足了三个有利条件: + +1. 使用全局可访问的 `window.CryptoJS` 或 `window.JSEncrypt`; +2. 加密后通过常规 Fetch/Form 请求发送; +3. 密码调用输出可以和请求字段建立精确值关联。 + +生产代码并没有依赖 `127.0.0.1:82`、`/encrypt/aes.php`、`/encrypt/rsa.php`、固定用户名、固定密码或固定业务字段。请求字段推断也已经支持 JSON、Form、Header、Query 和完整 Body。因此当前实现不是为靶场硬编码的结果。 + +但“没有靶场硬编码”不等于“已经覆盖真实世界”。当前 MAIN-world 录制器通过有界 manifest 为以下可访问对象安装语义 Hook: + +- 当前页面 Realm 的 `SubtleCrypto`; +- `CryptoJS`、`JSEncrypt`、`sm-crypto` 与 `node-forge`; +- `jsrsasign` 的 Signature/JWS/JWT/JWK; +- 页面显式暴露的 `jose` 高层 builder 与 verify/decrypt 函数。 + +没有全局导出的 ESM/Webpack 闭包、Worker 内密码运算、WASM 和完全未知的业务封装不会通过侵入 bundler cache 强行发现;它们继续走请求/消息边界、WebCrypto、证据图和 Deep Capture 业务闭包恢复。这是正式的通用路径,不是失败后的临时兜底。 + +因此本轮重构采用以下产品判断: + +> 已知库适配器是语义加速器,不是产品能力的地基。请求与消息边界、业务函数恢复、文档绑定 callable 和服务端认可的真实回放,才是通用能力的地基。 + +最终验收不是“界面显示识别到 AES/RSA”,而是: + +```text +用户执行一次真实操作 + -> 插件定位明文、页面业务调用和线上目标 + -> 已知库时给出准确算法语义,未知库时仍能定位业务封装 + -> 页面保留 key / IV / nonce / receiver / closure / WASM 状态 + -> Yakit Web Fuzzer 编辑明文 + -> 浏览器生成真实线上报文 + -> 独立服务端成功解密、验签或接受请求 +``` + +算法名称可以暂时未知,业务链路不能因此不可用。 + +## 2. 重构目标与非目标 + +### 2.1 目标 + +- 支持全局库、打包闭包、混淆函数、Worker 消息边界和 WASM 外围业务函数; +- 已知密码库接入同一 adapter contract,不再把逻辑堆入 MAIN-world 录制器; +- 未知库也可以从请求/消息边界进入 Deep Capture,恢复上层业务 callable; +- 自动 Profile 以请求为中心,保留 AES + RSA + HMAC + timestamp 等同一业务上下文; +- 页面秘密始终留在页面对象、闭包、CryptoKey 或 WASM 内存中,不通过协议导出; +- 适配器安装、事件归一化、证据建图、AI 分析和 Profile 执行各自独立; +- 使用随机化、跨打包形态的真实服务端夹具证明没有按图索骥; +- 在录制开启时保持有界开销,录制停止后完整恢复页面 API 且不存在后台轮询。 + +### 2.2 非目标 + +- 不追求穷举所有 JavaScript 密码库; +- 不要求先还原算法、密钥或混淆源码才能使用明文网关; +- 不把页面 key、PEM、CryptoKey、闭包变量或 WASM 内存导出到扩展、Yak 或 AI; +- 不在页面主线程进行全量源码搜索、全局对象枚举或 AST 扫描; +- 不为某个站点、接口路径、字段名或靶场流程维护特殊规则; +- 不保留旧 provider 枚举、旧录制协议或旧适配器目录的迁移兼容层。 + +## 3. 四层通用架构 + +```text +L0 业务边界探针 + Fetch / XHR / Form / sendBeacon / WebSocket / Worker / MessagePort / Navigation + | + | 有界输入输出、调用顺序、同步/异步栈、值关联 + v +L1 通用运行时边界 + WebCrypto / random / encoding / WebAssembly 装载 / serializer 边界 + | + | 原生算法元数据、TypedArray 形态、opaque object + v +L2 已知语义适配器 + CryptoJS / JSEncrypt / sm-crypto / node-forge / jsrsasign / jose / sodium ... + | + | 参数角色、模式、padding、state model、可复跑能力 + v +L3 未知业务函数恢复 + 请求断点 -> 页面业务帧排序 -> closure callable -> 自动 Profile +``` + +四层不是按顺序全部执行的流水线。L0 始终提供兜底证据;L1/L2 提供更强语义和更精确的断点;L3 在低层 primitive 不足、库不可见或业务封装复杂时恢复完整现场。 + +### 3.1 L0:业务边界是最低保证 + +请求和消息边界回答三个最重要的问题: + +1. 哪段值真正离开了页面; +2. 它被写入 Body、字段、Header、Query、WebSocket frame 还是 Worker 消息; +3. 哪个页面调用链在边界之前构造了它。 + +现有 Fetch/XHR/Form/WebSocket 继续保留,并补齐: + +- `navigator.sendBeacon`; +- `Worker.prototype.postMessage`; +- `MessagePort.prototype.postMessage`; +- `SharedWorker.port` 消息边界; +- 有界同步栈和可用时的异步栈来源; +- TypedArray、ArrayBuffer、Blob、FormData 和 transferable 的结构化摘要; +- 同一 Trace 内从输入、消息到请求的精确/归一化值关联。 + +页面侧边界看不到 Worker 内部每一步是事实,不应伪装成已识别。即使 Worker 内部无法安装密码适配器,插件仍可关联“页面明文消息 -> Worker 返回值 -> 请求字段”,并以消息边界或调用 Worker 的页面业务函数作为 callable 捕获入口。 + +Service Worker 内部运算不属于普通页面 MAIN world。第一阶段只保证通过 `webRequest` 和页面消息/请求边界观察真实线上结果;更深的 Worker/Service Worker 调试目标支持需要独立评估 CDP Target 生命周期,不能和页面适配器混为一个实现。 + +### 3.2 L1:通用运行时边界 + +首批运行时探针包括: + +- WebCrypto `SubtleCrypto`; +- `crypto.getRandomValues` 和 `randomUUID` 的调用关系摘要,不记录随机原值; +- `TextEncoder` / `TextDecoder`、Base64、Hex 等有界编码链; +- `WebAssembly.instantiate` / `instantiateStreaming` 的模块与实例身份摘要; +- 请求边界处的 JSON、Form、Query 和 Header 结构化解析。 + +不得全局 Hook 每一次 `JSON.stringify`、`encodeURIComponent` 或遍历所有 WASM exports。高频通用函数只在请求边界归一化,或在已确定的 Trace/Deep Capture 窗口内按需观察,避免让正常页面承担持续成本。 + +WASM 的第一目标不是反编译算法,而是保留调用它的页面业务 wrapper、输入输出关联和实例生命周期。只要该 wrapper 能在原页面复跑,明文网关就不需要导出 WASM 内存或重写算法。 + +### 3.3 L2:已知语义适配器 + +适配器负责把“某个函数被调用”解释成统一语义: + +- provider/adapter 身份; +- symmetric、asymmetric、digest、MAC、signature、KDF 或 key-management family; +- data、key、iv、nonce、aad、signature、options 等参数角色; +- algorithm、mode、padding、input/output encoding; +- stateless、receiver-bound、stateful-session、streaming 或 async-ready 状态模型; +- 是否可以安全保留原函数、receiver 和参数模板作为 recorded-call callable。 + +适配器不负责请求字段推断、UI 文案、AI prompt、Profile 编译或 Bridge RPC。新增库不应修改这些下游层。 + +### 3.4 L3:未知业务函数恢复 + +“不知道是哪一个库”不能成为终点。通用回退流程是: + +```text +请求/消息边界已定位 + -> 武装下一次相同边界 + -> 用户重复一次真实操作 + -> 立即发布有界调用栈 + -> 排除 extension hook 和已知依赖 frame + -> 结合参数相关性、请求接近度、源码位置、同步/异步父栈给业务 frame 排序 + -> 捕获完整业务 closure callable + -> 页面恢复 + -> 用短时样本做页面内回放 +``` + +页面函数叫 `encryptPayload`、`pack`、`request` 或 `_0x3f2a` 都不影响流程。AI 可以解释 frame 和参数语义,但只能返回引用既有 evidence 的候选补丁,不能生成并直接执行任意代码。 + +## 4. 适配器协议重构 + +### 4.1 删除封闭 provider 枚举 + +当前 `BrowserCryptoProvider` 是 `webcrypto | cryptojs | jsencrypt | forge | custom` 的封闭联合。继续添加库会迫使协议、归一化器、UI 和测试重复修改。 + +新协议使用有界 adapter ID 和稳定 provider kind: + +```ts +type BrowserCryptoProviderKind = + | "native" + | "library" + | "business" + | "wasm" + | "unknown" + +interface BrowserRecordingCrypto { + adapterId: string // 受限 slug,例如 "webcrypto"、"sm-crypto" + providerKind: BrowserCryptoProviderKind + family: BrowserCryptoFamily + operation: string // 适配器内部稳定 operation ID + algorithm?: string + mode?: string + padding?: string + inputEncoding?: BrowserPageCallableValueEncoding + outputEncoding?: BrowserPageCallableValueEncoding + state?: { + model: "stateless" | "receiver" | "session" | "stream" | "async-ready" + correlationId?: string + phase?: "create" | "init" | "update" | "final" | "one-shot" + } + key?: { + kind: "public" | "private" | "secret" | "unknown" + bits?: number + fingerprint?: string + } +} +``` + +`adapterId`、`operation` 和所有字符串必须限长并按字符集校验。UI 显示名来自扩展自带的 adapter manifest,不信任页面提供的 HTML 或展示文本。未知 ID 使用安全的纯文本回退标签。 + +Deep Capture 不再依赖 `CryptoJS.AES.encrypt` 这类展示字符串查找函数,而是绑定录制器已经保留的 wrapper handle: + +```text +adapterId + operation + wrapperHandleId + documentId +``` + +这样库被混淆、别名导出或方法名重复时,也不会武装错误函数。 + +### 4.2 统一 adapter contract + +```ts +interface PageCryptoAdapter { + manifest: { + id: string + displayName: string + providerKind: BrowserCryptoProviderKind + dynamic: boolean + } + discover(context: AdapterDiscoveryContext): AdapterTarget[] + install(target: AdapterTarget, host: AdapterHost): AdapterInstallation +} + +interface AdapterInstallation { + id: string + operations: InstalledOperation[] + restore(): void +} + +interface AdapterHost { + wrap(input: WrapOperationInput): InstalledOperation + emit(input: NormalizedCryptoCall): void + retain(input: RetainedCallInput): string | undefined + fingerprint(value: unknown): ValueEvidence[] +} +``` + +公共 `wrap` 基础设施必须统一处理: + +- 原 property descriptor、原函数和原 receiver; +- 同步返回、Promise resolve/reject 和库返回 `false/null` 的语义; +- re-entrancy 防护,避免适配器调用辅助方法时递归记录; +- 参数与输出大小预算; +- wrapper handle 与 Deep Capture 一次性断点; +- 页面后续替换函数时不覆盖页面的新值; +- restore 只恢复自己仍然拥有的 descriptor; +- 停止、清空、导航、grant 撤销和异常安装时的幂等清理。 + +适配器只能使用 host 提供的 evidence、emit 和 retain 能力,不各自维护事件队列、Trace、指纹算法或 callable registry。 + +### 4.3 状态型与流式 API + +不能把所有库都按 `encrypt(data, key) -> ciphertext` 的一次函数处理。 + +例如 node-forge 常见调用链是: + +```text +createCipher -> start -> update -> finish -> output +``` + +jsrsasign 的签名流程可能是: + +```text +new Signature -> init -> updateString/updateHex -> sign +``` + +这些调用需要同一 `correlationId` 和 phase 序列。只有满足以下条件才允许生成 recorded-call callable: + +- 可替换明文输入明确; +- 原 receiver/session 仍有效; +- 重放不会复用已经消费的流状态; +- 输出与请求目标存在 proven link; +- 调用没有网络、DOM、导航等额外副作用。 + +不满足时适配器只提供语义证据,并把候选标记为 `capture-required`,由 Deep Capture 保留上层一次性业务封装。 + +### 4.4 晚加载与打包形态 + +现有每秒扫描动态全局库的方式需要替换为有界调度: + +- 录制开始时立即检查一次已知全局路径; +- 捕获动态 `'); + return; + } + if (url.pathname === '/jsrsasign.js') { + response.setHeader('Content-Type', 'text/javascript; charset=utf-8'); + response.end(await readFile(jsrsasignPath)); + return; + } + if (url.pathname.startsWith('/jose/')) { + const relative = url.pathname.slice('/jose/'.length); + const path = resolve(joseRoot, relative); + assert(path.startsWith(`${joseRoot}/`) || path === joseRoot, 'Invalid jose module path'); + response.setHeader('Content-Type', 'text/javascript; charset=utf-8'); + response.end(await readFile(path)); + return; + } + if (url.pathname === '/g4-submit') { + capturedRequest = { + method: request.method, + signature: String(request.headers['x-signature'] || ''), + body: JSON.parse(await readBody(request)), + }; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ ok: true })); + return; + } + response.statusCode = 404; + response.end('not found'); + } catch (error) { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + } +}); + +let browser; +try { + const address = await listen(server); + const origin = `http://127.0.0.1:${address.port}`; + const keypair = KEYUTIL.generateKeypair('RSA', 1024); + const privateKey = KEYUTIL.getPEM(keypair.prvKeyObj, 'PKCS8PRV'); + const publicKey = KEYUTIL.getPEM(keypair.pubKeyObj); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const secretBase64 = Buffer.from(secret).toString('base64'); + + browser = await chromium.launch({ executablePath, headless: true, args: ['--no-sandbox', '--disable-gpu'] }); + const page = await browser.newPage(); + await page.goto(origin, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__g4Ready === true); + await page.evaluate(({ privateKey, secretBase64 }) => { + const bytes = Uint8Array.from(atob(secretBase64), (character) => character.charCodeAt(0)); + class Axios { + async request(config) { + const canonical = JSON.stringify(config.data); + const signer = new window.KJUR.crypto.Signature({ alg: 'SHA256withRSA' }); + signer.init(privateKey); + signer.updateString(canonical); + const signature = signer.sign(); + const jwt = await new window.jose.SignJWT({ account: config.data.account }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(bytes); + const jwe = await new window.jose.CompactEncrypt(new TextEncoder().encode(canonical)) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(bytes); + const response = await fetch('/g4-submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': signature }, + body: JSON.stringify({ canonical, jwt, jwe }), + }); + return await response.json(); + } + } + window.axios = { Axios }; + window.__g4Fixture = { privateKey, bytes }; + window.__g4Originals = { + stringify: JSON.stringify, + axiosRequest: Axios.prototype.request, + signature: window.KJUR.crypto.Signature, + signJwt: window.jose.SignJWT, + compactEncrypt: window.jose.CompactEncrypt, + }; + }, { privateKey, secretBase64 }); + await page.addScriptTag({ path: recorderPath }); + await page.evaluate(() => { + window.__YAKIT_PAGE_RECORDER_V9__.command('start', { + captureValues: false, + maxEntries: 200, + maxValueBytes: 2_048, + }); + }); + await page.click('#run'); + const result = await page.evaluate(async () => { + const client = new window.axios.Axios(); + return await client.request({ data: { account: 'admin', nonce: '1700000000' } }); + }); + assert(result?.ok === true, 'Browser G4 request did not complete'); + await page.waitForTimeout(50); + + const snapshot = await page.evaluate(() => window.__YAKIT_PAGE_RECORDER_V9__.command('get', { limit: 200 })); + const events = snapshot.events || []; + const jsrsasignEvents = events.filter((event) => event.crypto?.adapterId === 'jsrsasign'); + const joseEvents = events.filter((event) => event.crypto?.adapterId === 'jose'); + const transformOperations = new Set(events.filter((event) => event.kind === 'transform').map((event) => event.operation)); + const requestEvent = events.find((event) => event.kind === 'fetch' && event.url?.includes('/g4-submit')); + + for (const phase of ['create', 'init', 'update', 'final']) { + assert(jsrsasignEvents.some((event) => event.crypto?.state?.phase === phase), `Browser jsrsasign missed ${phase}`); + } + const jsrsasignCorrelation = new Set(jsrsasignEvents.map((event) => event.crypto?.state?.correlationId).filter(Boolean)); + assert(jsrsasignCorrelation.size === 1, 'Browser jsrsasign stages did not share one correlation ID'); + for (const operation of ['SignJWT.create', 'SignJWT.sign', 'CompactEncrypt.create', 'CompactEncrypt.encrypt']) { + assert(joseEvents.some((event) => event.crypto?.operation === operation), `Browser jose missed ${operation}`); + } + assert(joseEvents.filter((event) => ['SignJWT.sign', 'CompactEncrypt.encrypt'].includes(event.crypto?.operation)).every((event) => event.durationMs >= 0), 'Browser jose Promise results did not settle'); + assert(transformOperations.has('JSON.stringify'), 'Browser fixture missed JSON serialization evidence'); + assert(transformOperations.has('axios.request'), 'Browser fixture missed Axios request-builder evidence'); + assert(requestEvent?.inputs?.some((item) => item.path === '$headers.x-signature'), 'Browser fixture missed Header signature evidence'); + const metadata = JSON.stringify(snapshot); + assert(!metadata.includes(privateKey), 'Recorder metadata leaked the private key'); + assert(!metadata.includes(capturedRequest.body.canonical), 'Metadata-only recording leaked the canonical plaintext'); + + const verifier = new KJUR.crypto.Signature({ alg: 'SHA256withRSA' }); + verifier.init(publicKey); + verifier.updateString(capturedRequest.body.canonical); + assert(verifier.verify(capturedRequest.signature), 'Independent jsrsasign verifier rejected the browser signature'); + const jwt = await jwtVerify(capturedRequest.body.jwt, secret, { algorithms: ['HS256'] }); + assert(jwt.payload.account === 'admin', 'Independent jose verifier rejected the browser JWT'); + const decrypted = await compactDecrypt(capturedRequest.body.jwe, secret, { + keyManagementAlgorithms: ['dir'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + assert(new TextDecoder().decode(decrypted.plaintext) === capturedRequest.body.canonical, 'Independent jose decrypt did not recover the canonical request'); + + const restored = await page.evaluate(() => { + window.__YAKIT_PAGE_RECORDER_V9__.command('stop'); + return { + stringify: JSON.stringify === window.__g4Originals.stringify, + axiosRequest: window.axios.Axios.prototype.request === window.__g4Originals.axiosRequest, + signature: window.KJUR.crypto.Signature === window.__g4Originals.signature, + signJwt: window.jose.SignJWT === window.__g4Originals.signJwt, + compactEncrypt: window.jose.CompactEncrypt === window.__g4Originals.compactEncrypt, + }; + }); + assert(Object.values(restored).every(Boolean), `G4 runtime did not restore page methods: ${JSON.stringify(restored)}`); + + console.log(JSON.stringify({ + jsrsasignEvents: jsrsasignEvents.length, + joseEvents: joseEvents.length, + transforms: [...transformOperations], + requestHeaderLinked: true, + independentVerification: true, + restored: true, + }, null, 2)); +} finally { + await browser?.close(); + await close(server); +} diff --git a/scripts/verify-ui.mjs b/scripts/verify-ui.mjs index bededb1..9f3523c 100644 --- a/scripts/verify-ui.mjs +++ b/scripts/verify-ui.mjs @@ -1,6 +1,7 @@ import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises'; -import { randomBytes, webcrypto } from 'node:crypto'; +import { generateKeyPairSync, randomBytes, webcrypto } from 'node:crypto'; import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { chromium } from 'playwright-core'; @@ -8,6 +9,15 @@ import { WebSocketServer } from 'ws'; import { resolveChromiumPath } from './resolve-chromium.mjs'; const root = resolve(import.meta.dirname, '..'); +const require = createRequire(import.meta.url); +const NodeJSEncrypt = require('jsencrypt'); +const NodeSMCrypto = require('sm-crypto'); +const NodeForge = require('node-forge'); +const jsencryptBrowserPath = resolve(root, 'node_modules/jsencrypt/bin/jsencrypt.min.js'); +const sm2BrowserPath = resolve(root, 'node_modules/sm-crypto/dist/sm2.js'); +const sm3BrowserPath = resolve(root, 'node_modules/sm-crypto/dist/sm3.js'); +const sm4BrowserPath = resolve(root, 'node_modules/sm-crypto/dist/sm4.js'); +const nodeForgeBrowserPath = resolve(root, 'node_modules/node-forge/dist/forge.min.js'); const extensionPath = resolve(root, process.env.EXTENSION_PATH || '.output/chrome-mv3'); const extensionManifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8')); const shouldEnableUserScripts = process.env.ENABLE_USER_SCRIPTS !== '0' && extensionManifest.permissions?.includes('userScripts'); @@ -16,17 +26,259 @@ const executablePath = await resolveChromiumPath(); const userDataDir = await mkdtemp(join(tmpdir(), 'yakit-extension-')); await mkdir(artifacts, { recursive: true }); -const server = createServer((request, response) => { +const cryptoLabAESBytes = Buffer.from('00112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f', 'hex'); +const cryptoLabHMACBytes = Buffer.from('ffeeddccbbaa998877665544332211000f1e2d3c4b5a69788796a5b4c3d2e1f0', 'hex'); +const cryptoLabAESKey = webcrypto.subtle.importKey('raw', cryptoLabAESBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +const cryptoLabHMACKey = webcrypto.subtle.importKey('raw', cryptoLabHMACBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']); +const rsaLabKeyPair = generateKeyPairSync('rsa', { modulusLength: 1024 }); +const rsaLabPublicKey = rsaLabKeyPair.publicKey.export({ type: 'spki', format: 'pem' }); +const rsaLabPrivateKey = rsaLabKeyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }); +const rsaLabPublicJWK = rsaLabKeyPair.publicKey.export({ format: 'jwk' }); +const rsaLabPublicModulusHex = Buffer.from(rsaLabPublicJWK.n, 'base64url').toString('hex'); +const rsaLabDecryptor = new NodeJSEncrypt(); +rsaLabDecryptor.setPrivateKey(rsaLabPrivateKey); +const smLabKeyPair = NodeSMCrypto.sm2.generateKeyPairHex(); +const smLabSM4Key = '0123456789abcdeffedcba9876543210'; +const smLabSM4IV = 'fedcba98765432100123456789abcdef'; +const forgeLabAESKey = '00112233445566778899aabbccddeeff'; +const forgeLabAESIV = '102132435465768798a9bacbdcedfe0f'; +const forgeLabPrivateKey = NodeForge.pki.privateKeyFromPem(rsaLabPrivateKey); +const closureHoldoutSeed = randomBytes(6).toString('hex'); +const closureModulePath = `/assets/opaque-${closureHoldoutSeed}.mjs`; +const closureSubmitPath = `/gateway/opaque-${closureHoldoutSeed}`; +const closurePayloadField = `blob_${closureHoldoutSeed}`; +const closureDigestField = `proof_${closureHoldoutSeed}`; +const closureFunctionName = `build_${closureHoldoutSeed}`; +const closureSenderName = `send_${closureHoldoutSeed}`; +const closureInitialMarker = `module-recording-${closureHoldoutSeed}`; +const closureReplayMarker = `module-replay-${closureHoldoutSeed}`; +const WASM_XOR_MASK = 23; + +function decryptRSALabValue(value) { + const plaintext = rsaLabDecryptor.decrypt(value); + if (typeof plaintext !== 'string') throw new Error('JSEncrypt server could not decrypt the RSA ciphertext'); + return plaintext; +} + +async function decryptCryptoLabResponse(envelope) { + const plaintext = await webcrypto.subtle.decrypt({ + name: 'AES-GCM', + iv: Buffer.from(envelope.iv, 'base64'), + additionalData: Buffer.from(`response:${envelope.nonce}`), + }, await cryptoLabAESKey, Buffer.from(envelope.ciphertext, 'base64')); + return JSON.parse(Buffer.from(plaintext).toString('utf8')); +} + +const server = createServer(async (request, response) => { + if (request.url?.startsWith(closureModulePath)) { + response.setHeader('content-type', 'text/javascript; charset=utf-8'); + response.end(`const encoder = new TextEncoder(); + const wasmBytes = Uint8Array.from([0,97,115,109,1,0,0,0,1,6,1,96,1,127,1,127,3,2,1,0,7,7,1,3,109,105,120,0,0,10,9,1,7,0,32,0,65,${WASM_XOR_MASK},115,11]); + const { instance } = await WebAssembly.instantiate(wasmBytes); + const toBase64 = (value) => { + let binary = ''; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary); + }; + async function ${closureFunctionName}(payload) { + const plaintext = encoder.encode(JSON.stringify(payload)); + const transformed = Uint8Array.from(plaintext, (byte) => instance.exports.mix(byte)); + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', transformed)); + return { + [${JSON.stringify(closurePayloadField)}]: toBase64(transformed), + [${JSON.stringify(closureDigestField)}]: toBase64(digest), + }; + } + async function ${closureSenderName}() { + const marker = document.querySelector('#opaque-module-input').value; + const envelope = await ${closureFunctionName}({ marker, nested: { seed: ${JSON.stringify(closureHoldoutSeed)} } }); + const response = await fetch(${JSON.stringify(closureSubmitPath)}, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), + }); + const result = await response.json(); + if (!result.ok) throw new Error(result.error || 'opaque module rejected'); + document.querySelector('#opaque-module-result').textContent = result.marker; + } + document.querySelector('#opaque-module-submit').addEventListener('click', () => void ${closureSenderName}()); + document.querySelector('#opaque-module-lab').dataset.ready = 'true';`); + return; + } + if (request.url?.startsWith(closureSubmitPath)) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + try { + const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const transformed = Buffer.from(envelope[closurePayloadField], 'base64'); + const plaintextBytes = Buffer.from(transformed.map((byte) => byte ^ WASM_XOR_MASK)); + const payload = JSON.parse(plaintextBytes.toString('utf8')); + const digest = Buffer.from(await webcrypto.subtle.digest('SHA-256', transformed)).toString('base64'); + if (envelope[closureDigestField] !== digest) throw new Error('opaque module digest mismatch'); + if (typeof payload.marker !== 'string' || payload.nested?.seed !== closureHoldoutSeed) { + throw new Error('opaque module payload mismatch'); + } + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: true, marker: payload.marker })); + } catch (error) { + response.statusCode = 400; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: false, error: error.message })); + } + return; + } + if (request.url?.startsWith('/opaque-worker.js')) { + response.setHeader('content-type', 'text/javascript; charset=utf-8'); + response.end(`self.addEventListener('message', (event) => { + const plaintext = JSON.stringify(event.data); + const bytes = new TextEncoder().encode(plaintext); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + self.postMessage({ sealed: btoa(binary), byteLength: bytes.byteLength }); + });`); + return; + } + if (request.url?.startsWith('/opaque-worker-submit')) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + try { + const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const plaintext = JSON.parse(Buffer.from(envelope.sealed, 'base64').toString('utf8')); + if (plaintext.marker !== 'worker-boundary-holdout-811') throw new Error('unexpected Worker plaintext'); + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: true, marker: plaintext.marker })); + } catch (error) { + response.statusCode = 400; + response.end(JSON.stringify({ ok: false, error: error.message })); + } + return; + } + if (request.url?.startsWith('/beacon-boundary')) { + response.statusCode = 204; + response.end(); + return; + } + if (request.url?.startsWith('/semantic-adapter-submit')) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + try { + const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const smPlaintext = NodeSMCrypto.sm2.doDecrypt(envelope.sm2Ciphertext, smLabKeyPair.privateKey, 1); + if (smPlaintext !== envelope.expectedSMPlaintext) throw new Error('SM2 server decryption mismatch'); + if (!NodeSMCrypto.sm2.doVerifySignature( + envelope.expectedSMPlaintext, envelope.sm2Signature, smLabKeyPair.publicKey, + )) throw new Error('SM2 server signature verification failed'); + if (NodeSMCrypto.sm3(envelope.expectedSMPlaintext) !== envelope.sm3Digest) throw new Error('SM3 digest mismatch'); + const sm4Plaintext = NodeSMCrypto.sm4.decrypt(envelope.sm4Ciphertext, smLabSM4Key, { + mode: 'cbc', iv: smLabSM4IV, + }); + if (sm4Plaintext !== envelope.expectedSMPlaintext) throw new Error('SM4 server decryption mismatch'); + + const decipher = NodeForge.cipher.createDecipher('AES-CBC', NodeForge.util.hexToBytes(forgeLabAESKey)); + decipher.start({ iv: NodeForge.util.hexToBytes(forgeLabAESIV) }); + decipher.update(NodeForge.util.createBuffer(NodeForge.util.hexToBytes(envelope.forgeAesCiphertext))); + if (!decipher.finish()) throw new Error('node-forge AES server decryption failed'); + if (decipher.output.getBytes() !== envelope.expectedForgePlaintext) throw new Error('node-forge AES plaintext mismatch'); + const forgeRsaPlaintext = forgeLabPrivateKey.decrypt( + NodeForge.util.decode64(envelope.forgeRsaCiphertext), 'RSAES-PKCS1-V1_5', + ); + if (forgeRsaPlaintext !== envelope.expectedForgePlaintext) throw new Error('node-forge RSA plaintext mismatch'); + const digest = NodeForge.md.sha256.create(); + digest.update(envelope.expectedForgePlaintext, 'utf8'); + if (digest.digest().toHex() !== envelope.forgeDigest) throw new Error('node-forge digest mismatch'); + const hmac = NodeForge.hmac.create(); + hmac.start('sha256', NodeForge.util.hexToBytes(forgeLabAESKey)); + hmac.update(envelope.expectedForgePlaintext); + if (hmac.digest().toHex() !== envelope.forgeHmac) throw new Error('node-forge HMAC mismatch'); + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: true })); + } catch (error) { + response.statusCode = 400; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: false, error: error.message })); + } + return; + } + if (request.url?.startsWith('/proxy-rules')) { + if (request.headers['if-none-match'] === '"yakit-e2e-rules-v1"') { + response.statusCode = 304; + response.end(); + return; + } + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.setHeader('etag', '"yakit-e2e-rules-v1"'); + response.end('[AutoProxy 0.2]\n@@||allowed.subscription.test^\n||blocked.subscription.test^\n'); + return; + } if (request.url?.startsWith('/api/session')) { const chunks = []; request.on('data', (chunk) => chunks.push(chunk)); request.on('end', () => { response.setHeader('content-type', 'application/json; charset=utf-8'); response.setHeader('x-yakit-e2e-response', 'captured'); - response.end(JSON.stringify({ ok: true, receivedBytes: Buffer.concat(chunks).length })); + response.end(JSON.stringify({ + ok: true, + receivedBytes: Buffer.concat(chunks).length, + userAgent: request.headers['user-agent'] || '', + })); }); return; } + if (request.url?.startsWith('/encrypt/rsa.php')) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + try { + const ciphertext = new URLSearchParams(Buffer.concat(chunks).toString('utf8')).get('data'); + if (!ciphertext) throw new Error('missing RSA form field data'); + const plaintext = decryptRSALabValue(ciphertext); + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: true, plaintext })); + } catch (error) { + response.statusCode = 400; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: false, error: error.message })); + } + return; + } + if (request.url?.startsWith('/crypto-lab/submit')) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + try { + const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const signedValue = [envelope.timestamp, envelope.nonce, envelope.iv, envelope.ciphertext].join('.'); + const signatureValid = await webcrypto.subtle.verify( + 'HMAC', await cryptoLabHMACKey, Buffer.from(envelope.signature, 'base64'), Buffer.from(signedValue), + ); + if (!signatureValid) throw new Error('invalid signature'); + const plaintext = await webcrypto.subtle.decrypt({ + name: 'AES-GCM', + iv: Buffer.from(envelope.iv, 'base64'), + additionalData: Buffer.from(`${envelope.timestamp}:${envelope.nonce}`), + }, await cryptoLabAESKey, Buffer.from(envelope.ciphertext, 'base64')); + const payload = JSON.parse(Buffer.from(plaintext).toString('utf8')); + if (payload.timestamp !== envelope.timestamp || payload.nonce !== envelope.nonce || typeof payload.password !== 'string') { + throw new Error('invalid encrypted payload'); + } + const responsePayload = Buffer.from(JSON.stringify({ + ok: true, account: payload.account, password: payload.password, nonce: payload.nonce, + })); + const responseIV = randomBytes(12); + const responseCiphertext = await webcrypto.subtle.encrypt({ + name: 'AES-GCM', + iv: responseIV, + additionalData: Buffer.from(`response:${payload.nonce}`), + }, await cryptoLabAESKey, responsePayload); + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ + nonce: payload.nonce, + iv: responseIV.toString('base64'), + ciphertext: Buffer.from(responseCiphertext).toString('base64'), + })); + } catch (error) { + response.statusCode = 400; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ ok: false, error: error.message })); + } + return; + } if (request.url?.startsWith('/frame-')) { const cross = request.url.startsWith('/frame-cross'); response.setHeader('content-type', 'text/html; charset=utf-8'); @@ -39,6 +291,11 @@ const server = createServer((request, response) => { response.end('Strict CSP Page

Strict CSP Page

'); return; } + if (request.url?.startsWith('/recording-complete')) { + response.setHeader('content-type', 'text/html; charset=utf-8'); + response.end('Recording Complete Dashboard

Recording Complete Dashboard

The login navigation completed in the same browser tab.

'); + return; + } response.setHeader('content-type', 'text/html; charset=utf-8'); response.setHeader('set-cookie', 'yakit_e2e_session=authenticated; Path=/; HttpOnly; SameSite=Lax'); const port = server.address()?.port; @@ -48,9 +305,59 @@ const server = createServer((request, response) => { main { width: min(920px, calc(100% - 48px)); margin: 48px auto; padding: 32px; background: white; border: 1px solid #dce1e4; } h1 { margin: 0 0 12px; font-size: 28px; } p { color: #667078; } form { display: grid; gap: 10px; width: 340px; margin-top: 28px; } input, button { height: 38px; } -

Authenticated Security Console

Local page for extension UI and main-world execution verification.

`); + (() => { + if (!crypto.subtle) { + document.querySelector('#crypto-lab').dataset.ready = 'unsupported'; + return; + } + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const aesBytes = Uint8Array.from(${JSON.stringify([...cryptoLabAESBytes])}); + const hmacBytes = Uint8Array.from(${JSON.stringify([...cryptoLabHMACBytes])}); + const aesKeyPromise = crypto.subtle.importKey('raw', aesBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + const hmacKeyPromise = crypto.subtle.importKey('raw', hmacBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const toBase64 = (value) => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); + }; + const fromBase64 = (value) => Uint8Array.from(atob(value), (byte) => byte.charCodeAt(0)); + async function buildLoginEnvelope(password, account = 'analyst') { + const [aesKey, hmacKey] = await Promise.all([aesKeyPromise, hmacKeyPromise]); + const timestamp = Date.now(); + const nonce = toBase64(crypto.getRandomValues(new Uint8Array(16))); + const ivBytes = crypto.getRandomValues(new Uint8Array(12)); + const iv = toBase64(ivBytes); + const plaintext = encoder.encode(JSON.stringify({ account, password, timestamp, nonce })); + const ciphertext = toBase64(await crypto.subtle.encrypt({ + name: 'AES-GCM', iv: ivBytes, additionalData: encoder.encode(timestamp + ':' + nonce), + }, aesKey, plaintext)); + const signedValue = [timestamp, nonce, iv, ciphertext].join('.'); + const signature = toBase64(await crypto.subtle.sign('HMAC', hmacKey, encoder.encode(signedValue))); + return { timestamp, nonce, iv, ciphertext, signature }; + } + async function openLoginResponse(envelope) { + const aesKey = await aesKeyPromise; + const plaintext = await crypto.subtle.decrypt({ + name: 'AES-GCM', iv: fromBase64(envelope.iv), additionalData: encoder.encode('response:' + envelope.nonce), + }, aesKey, fromBase64(envelope.ciphertext)); + return JSON.parse(decoder.decode(plaintext)); + } + async function submitEncryptedLogin() { + const password = document.querySelector('#crypto-password').value; + const envelope = await buildLoginEnvelope(password); + const response = await fetch('/crypto-lab/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), + }); + document.querySelector('#crypto-result').textContent = JSON.stringify(await openLoginResponse(await response.json())); + } + document.querySelector('#crypto-submit').addEventListener('click', () => void submitEncryptedLogin()); + Promise.all([aesKeyPromise, hmacKeyPromise]).then(() => { document.querySelector('#crypto-lab').dataset.ready = 'true'; }); + })(); + `); }); const pageSocketServer = new WebSocketServer({ server, path: '/page-socket' }); pageSocketServer.on('connection', (socket) => socket.on('message', (message) => socket.send(`echo:${message.toString()}`))); @@ -58,6 +365,39 @@ await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen const address = server.address(); const testUrl = `http://127.0.0.1:${address.port}/authenticated`; const insecureTestUrl = `http://yakit-insecure.test:${address.port}/insecure`; + +function installPageCryptoFixtures(publicKey) { + window.CryptoJS = { + SHA256(value) { + return { sigBytes: 32, toString: () => `sha256:${value}` }; + }, + }; + if (typeof window.JSEncrypt !== 'function') throw new Error('The real JSEncrypt browser bundle did not install'); + window.__yakitRsa = new window.JSEncrypt(); + window.__yakitRsa.setPublicKey(publicKey); + window.__yakitObserverOriginals = { + fetch: window.fetch, + xhrOpen: XMLHttpRequest.prototype.open, + xhrSend: XMLHttpRequest.prototype.send, + webSocket: window.WebSocket, + worker: window.Worker, + workerPostMessage: window.Worker?.prototype.postMessage, + messageChannel: window.MessageChannel, + messagePortPostMessage: window.MessagePort?.prototype.postMessage, + sendBeacon: Navigator.prototype.sendBeacon, + digest: Object.getPrototypeOf(crypto.subtle).digest, + cryptoJsSha256: window.CryptoJS.SHA256, + jsencryptEncrypt: window.JSEncrypt.prototype.encrypt, + sm2Encrypt: window.sm2?.doEncrypt, + sm3: window.sm3, + sm4Encrypt: window.sm4?.encrypt, + forgeCreateCipher: window.forge?.cipher.createCipher, + forgePublicKeyFromPem: window.forge?.pki.publicKeyFromPem, + forgeSha256Create: window.forge?.md.sha256.create, + forgeHmacCreate: window.forge?.hmac.create, + }; +} + const bridgeHTTPServer = createServer(); const pairingServer = new WebSocketServer({ noServer: true }); const bridgeServer = new WebSocketServer({ noServer: true }); @@ -211,15 +551,21 @@ const bridgeConnection = new Promise((resolveConnection, rejectConnection) => { }); }); -function nextBridgeResponse(socket, id) { +function nextBridgeResponse(socket, id, timeoutMs = 30_000) { return new Promise((resolveResponse, rejectResponse) => { + const timeout = setTimeout(() => { + socket.off('message', onMessage); + rejectResponse(new Error(`Bridge response timed out: ${id}`)); + }, timeoutMs); const onMessage = (raw) => { try { const message = JSON.parse(raw.toString()); if (message.id !== id) return; + clearTimeout(timeout); socket.off('message', onMessage); resolveResponse(message); } catch (error) { + clearTimeout(timeout); socket.off('message', onMessage); rejectResponse(error); } @@ -306,34 +652,168 @@ try { const cache = await caches.open('yakit-e2e-session-cache'); await cache.put('/e2e-cached-session', new Response('cached')); history.pushState({ source: 'e2e' }, '', '/authenticated?spa=inventory'); - window.CryptoJS = { - SHA256(value) { - return { sigBytes: 32, toString: () => `sha256:${value}` }; - }, - }; - window.__yakitObserverOriginals = { - fetch: window.fetch, - xhrOpen: XMLHttpRequest.prototype.open, - xhrSend: XMLHttpRequest.prototype.send, - webSocket: window.WebSocket, - digest: Object.getPrototypeOf(crypto.subtle).digest, - cryptoJsSha256: window.CryptoJS.SHA256, - }; }); - const popup = await context.newPage(); - await popup.setViewportSize({ width: 390, height: 560 }); + await popup.setViewportSize({ width: 390, height: 600 }); await popup.goto(`chrome-extension://${extensionId}/popup.html`); await popup.locator('.popup-shell').waitFor(); await popup.getByText('Authenticated Security Console', { exact: true }).waitFor(); const popupBrandsLoaded = await popup.locator('.yak-mark, .yakit-mark').evaluateAll((images) => images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)); if (!popupBrandsLoaded) throw new Error('Popup brand assets did not load'); + await popup.locator('.popup-rail').waitFor(); + await popup.locator('.popup-brand-mark .yak-mark').waitFor(); + await popup.locator('.popup-engine-status').waitFor(); await popup.screenshot({ path: resolve(artifacts, 'popup.png') }); + await popup.locator('.popup-engine-status').hover(); + await popup.getByText(/尚未配对引擎/).waitFor(); + + await popup.getByRole('button', { name: '代理', exact: true }).click(); + await popup.locator('.popup-proxy-view').waitFor(); + await popup.evaluate(async () => { + const response = await chrome.runtime.sendMessage({ action: 'proxy.save', payload: { + id: 'e2e-popup-proxy-a', name: 'E2E Proxy A', kind: 'fixed_servers', scheme: 'http', + host: '127.0.0.1', port: 2080, bypass: [], + } }); + if (!response?.ok) throw new Error(response?.error || 'Unable to create E2E Proxy A'); + }); + const siteProxySelect = popup.getByLabel('当前站点代理出口'); + await siteProxySelect.locator('option[value="e2e-popup-proxy-a"]').waitFor({ state: 'attached' }); + await siteProxySelect.selectOption('e2e-popup-proxy-a'); + await popup.locator('.popup-site-status.is-success').getByText('已应用 · E2E Proxy A', { exact: true }).waitFor(); + if (await popup.getByRole('button', { name: '保存', exact: true }).count()) { + throw new Error('Popup site route still requires an explicit save button'); + } + const popupSiteRoute = await popup.evaluate(async () => { + const state = await chrome.runtime.sendMessage({ action: 'state.get' }); + const preview = await chrome.runtime.sendMessage({ action: 'proxy.rules.preview', payload: { url: location.href.replace('chrome-extension://', 'https://') } }); + return { state: state.data, preview }; + }); + const popupRouteRule = popupSiteRoute.state.proxyRules.find((rule) => rule.condition.type === 'host_exact' && rule.condition.value === '127.0.0.1'); + if (popupSiteRoute.state.activeProxyId !== 'auto' || popupRouteRule?.proxyProfileId !== 'e2e-popup-proxy-a') { + throw new Error(`Popup site route did not select arbitrary proxy A: ${JSON.stringify(popupSiteRoute)}`); + } + await popup.locator('.popup-global-notice').waitFor({ state: 'detached' }); + await popup.waitForTimeout(120); + const popupProxyBounds = await popup.evaluate(() => ({ + scrollY: window.scrollY, + viewportHeight: window.innerHeight, + documentHeight: document.documentElement.scrollHeight, + shellTop: document.querySelector('.popup-shell')?.getBoundingClientRect().top, + shellBottom: document.querySelector('.popup-shell')?.getBoundingClientRect().bottom, + })); + if (popupProxyBounds.scrollY !== 0 || popupProxyBounds.shellTop !== 0 || popupProxyBounds.shellBottom > popupProxyBounds.viewportHeight) { + throw new Error(`Popup proxy escaped its viewport: ${JSON.stringify(popupProxyBounds)}`); + } + await popup.screenshot({ path: resolve(artifacts, 'popup-proxy.png') }); + const proxyOptionsPromise = context.waitForEvent('page'); + await popup.getByRole('button', { name: '打开代理策略', exact: true }).click(); + const proxyOptions = await proxyOptionsPromise; + await proxyOptions.waitForLoadState('domcontentloaded'); + if (!proxyOptions.url().includes('/options.html') || !proxyOptions.url().endsWith('#rules')) { + throw new Error(`Popup global action did not open proxy options: ${proxyOptions.url()}`); + } + await proxyOptions.close(); + await siteProxySelect.selectOption('__automatic__'); + await popup.locator('.popup-site-status.is-success').getByText('已恢复自动判断', { exact: true }).waitFor(); + const clearedPopupSiteRoute = await popup.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'state.get' })).data); + if (clearedPopupSiteRoute.proxyRules.some((rule) => rule.condition.type === 'host_exact' && rule.condition.value === '127.0.0.1')) { + throw new Error(`Popup site route reset left an exact override: ${JSON.stringify(clearedPopupSiteRoute.proxyRules)}`); + } + await popup.getByRole('button', { name: '运行概览', exact: true }).click(); + + await popup.getByRole('button', { name: /Cookie Editor/ }).click(); + await popup.locator('.popup-cookie-view').waitFor(); + const popupSessionCookie = popup.locator('.popup-cookie-row').filter({ hasText: 'yakit_e2e_session' }); + await popupSessionCookie.waitFor(); + if (!(await popupSessionCookie.textContent()).includes('authenticated')) throw new Error('Popup Cookie Editor did not display the Cookie value directly'); + if (await popupSessionCookie.getByRole('button', { name: /显示|隐藏/ }).count()) { + throw new Error('Popup Cookie Editor still renders a value visibility control'); + } + await popup.getByRole('button', { name: '新增 Cookie' }).click(); + const popupCookieEditor = popup.locator('.popup-cookie-editor'); + await popupCookieEditor.locator('input').nth(0).fill('popup_e2e_cookie'); + await popupCookieEditor.locator('input').nth(1).fill('popup-cookie-value'); + await popup.getByRole('button', { name: '创建 Cookie' }).click(); + const popupCreatedCookie = popup.locator('.popup-cookie-row').filter({ hasText: 'popup_e2e_cookie' }); + await popupCreatedCookie.waitFor(); + await popupCreatedCookie.getByRole('button', { name: '删除 popup_e2e_cookie' }).click(); + await popupCreatedCookie.waitFor({ state: 'detached' }); + await popup.screenshot({ path: resolve(artifacts, 'popup-cookie-editor.png') }); + await popup.getByRole('button', { name: '运行概览' }).click(); + + const requestUserAgent = async (source) => webPage.evaluate(async (requestSource) => { + const response = await fetch(`/api/session?source=${encodeURIComponent(requestSource)}&nonce=${Date.now()}`); + return (await response.json()).userAgent; + }, source); + const waitForRequestUserAgent = async (source, accept) => { + let value = ''; + for (let attempt = 0; attempt < 30; attempt += 1) { + value = await requestUserAgent(`${source}-${attempt}`); + if (accept(value)) return value; + await webPage.waitForTimeout(100); + } + return value; + }; + const browserDefaultUserAgent = await requestUserAgent('ua-default'); + await popup.getByRole('button', { name: 'User-Agent', exact: true }).click(); + await popup.locator('.popup-ua-view').waitFor(); + await popup.getByRole('radio', { name: /Chrome \/ Windows/ }).click(); + await popup.getByRole('button', { name: '应用并刷新' }).click(); + await popup.locator('.popup-ua-current > strong', { hasText: 'Chrome / Windows' }).waitFor(); + const chromeWindowsUserAgent = await waitForRequestUserAgent('ua-chrome-windows', (value) => ( + value.includes('Windows NT 10.0') && value.includes('Chrome/138') + )); + if (!chromeWindowsUserAgent.includes('Windows NT 10.0') || !chromeWindowsUserAgent.includes('Chrome/138')) { + throw new Error(`Popup UA preset did not modify real request headers: ${chromeWindowsUserAgent}`); + } + await popup.getByRole('button', { name: '自定义…' }).click(); + const customUAEditor = popup.locator('.popup-ua-custom'); + await customUAEditor.locator('input').fill('Popup E2E Agent'); + await customUAEditor.locator('textarea').fill('Yakit-Popup-E2E/1.0'); + await popup.getByRole('button', { name: '保存、应用并刷新' }).click(); + await popup.locator('.popup-ua-current > strong', { hasText: 'Popup E2E Agent' }).waitFor(); + const customUserAgent = await waitForRequestUserAgent('ua-custom', (value) => value === 'Yakit-Popup-E2E/1.0'); + if (customUserAgent !== 'Yakit-Popup-E2E/1.0') throw new Error(`Popup custom UA did not apply: ${customUserAgent}`); + await popup.screenshot({ path: resolve(artifacts, 'popup-user-agent.png') }); + await popup.getByRole('radio', { name: /浏览器默认/ }).click(); + await popup.getByRole('button', { name: '应用并刷新' }).click(); + await popup.locator('.popup-ua-current > strong', { hasText: '浏览器默认' }).waitFor(); + const restoredUserAgent = await waitForRequestUserAgent('ua-restored', (value) => value === browserDefaultUserAgent); + if (restoredUserAgent !== browserDefaultUserAgent) throw new Error(`Popup UA reset did not restore browser default: ${restoredUserAgent}`); + const customProfileCleanup = await popup.evaluate(async () => { + const catalog = await chrome.runtime.sendMessage({ action: 'ua.catalog' }); + const profile = catalog.data?.find((item) => item.name === 'Popup E2E Agent'); + return profile ? await chrome.runtime.sendMessage({ action: 'ua.profile.delete', payload: { id: profile.id } }) : undefined; + }); + if (customProfileCleanup && !customProfileCleanup.ok) throw new Error(`Could not remove Popup E2E UA profile: ${JSON.stringify(customProfileCleanup)}`); + await popup.getByRole('button', { name: '运行概览' }).click(); + await webPage.addScriptTag({ path: jsencryptBrowserPath }); + await webPage.addScriptTag({ path: sm2BrowserPath }); + await webPage.addScriptTag({ path: sm3BrowserPath }); + await webPage.addScriptTag({ path: sm4BrowserPath }); + await webPage.addScriptTag({ path: nodeForgeBrowserPath }); + const cryptoLibraryShape = await webPage.evaluate(() => ({ + jsencrypt: typeof window.JSEncrypt, + sm2: typeof window.sm2?.doEncrypt, + sm3: typeof window.sm3, + sm4: typeof window.sm4?.encrypt, + forgeCipher: typeof window.forge?.cipher?.createCipher, + forgePki: typeof window.forge?.pki?.publicKeyFromPem, + })); + if (Object.values(cryptoLibraryShape).some((kind) => kind !== 'function')) { + throw new Error(`Real browser crypto libraries did not install after the UA reload tests: ${JSON.stringify(cryptoLibraryShape)}`); + } + await webPage.evaluate(installPageCryptoFixtures, rsaLabPublicKey); + await webPage.evaluate(() => { + history.pushState({ source: 'e2e-after-ua' }, '', '/authenticated?spa=ua-restored'); + history.replaceState({ source: 'e2e' }, '', '/authenticated?spa=inventory'); + }); const options = await context.newPage(); await options.setViewportSize({ width: 1440, height: 900 }); await options.goto(`chrome-extension://${extensionId}/options.html?tabId=${targetTab.id}#overview`); await options.locator('.app-shell').waitFor(); + await options.getByText('常用工具', { exact: true }).waitFor(); if (await options.locator('.target-tab-select').inputValue() !== String(targetTab.id)) throw new Error('Options did not preserve the explicit target tab'); const lateOpenedPage = await context.newPage(); await lateOpenedPage.goto(testUrl); @@ -388,11 +868,9 @@ try { await options.getByRole('button', { name: 'Cookie Editor' }).click(); const testCookie = options.getByRole('button', { name: 'yakit_e2e_session' }); await testCookie.waitFor(); - const cookieValueButton = options.getByTitle('显示 Cookie 值').first(); - if (!(await cookieValueButton.textContent()).includes('[hidden')) throw new Error('Cookie Editor exposed a value by default'); - await cookieValueButton.click(); - if (!(await options.getByTitle('隐藏 Cookie 值').first().textContent()).includes('authenticated')) throw new Error('Cookie Editor did not reveal a value on explicit click'); - await options.getByTitle('隐藏 Cookie 值').first().click(); + const cookieValue = options.locator('.cookie-value').filter({ hasText: 'authenticated' }).first(); + await cookieValue.waitFor(); + if (await options.getByTitle(/显示 Cookie 值|隐藏 Cookie 值/).count()) throw new Error('Cookie Editor still renders value visibility controls'); await testCookie.click(); if (await options.locator('.rule-editor input').first().inputValue() !== 'yakit_e2e_session') throw new Error('Cookie Editor did not load the selected HttpOnly cookie'); if (await options.getByText('HttpOnly', { exact: true }).count() === 0) throw new Error('Cookie Editor did not expose HttpOnly metadata'); @@ -422,6 +900,10 @@ try { throw new Error(`Cookie import/export/bulk delete failed: ${JSON.stringify(cookieTransferChecks)}`); } await options.screenshot({ path: resolve(artifacts, 'options-cookie-editor.png') }); + await options.getByRole('button', { name: 'UA 快速切换' }).click(); + await options.getByRole('heading', { name: 'User-Agent 快速切换' }).waitFor(); + await options.getByText('每个 hostname 只保留一个生效预设', { exact: true }).waitFor(); + await options.screenshot({ path: resolve(artifacts, 'options-user-agent.png') }); await options.getByRole('button', { name: '登录态工作区' }).click(); await options.getByRole('tab', { name: '主世界 Eval' }).click(); await options.screenshot({ path: resolve(artifacts, 'options-context-eval.png') }); @@ -439,14 +921,15 @@ try { if (protocolChecks.invalid?.ok || !protocolChecks.invalid?.error?.includes('参数无效')) throw new Error(`Runtime schema accepted an unknown field: ${JSON.stringify(protocolChecks.invalid)}`); if (protocolChecks.floatingPanel?.side !== 'right' || protocolChecks.floatingPanel?.y !== 0.46) throw new Error(`Concurrent state updates lost data: ${JSON.stringify(protocolChecks.floatingPanel)}`); if (!protocolChecks.proxyResponse?.ok || protocolChecks.proxyMode !== 'direct') throw new Error(`Direct proxy mode was not applied explicitly: ${JSON.stringify(protocolChecks)}`); - const proxyRuleChecks = await options.evaluate(async ({ url }) => { + const proxyRuleChecks = await options.evaluate(async ({ url, sourceUrl }) => { const send = async (action, payload) => { const response = await chrome.runtime.sendMessage({ action, payload }); if (!response?.ok) throw new Error(response?.error || action); return response.data; }; - const directRule = { id: 'e2e-direct-rule', name: 'E2E direct', enabled: true, patterns: ['127.0.0.1'], proxyProfileId: 'direct', priority: 200 }; - const mitmRule = { id: 'e2e-mitm-rule', name: 'E2E MITM conflict', enabled: true, patterns: ['127.0.0.1'], proxyProfileId: 'yakit-mitm', priority: 100 }; + const now = Date.now(); + const directRule = { id: 'e2e-direct-rule', name: 'E2E direct', enabled: true, condition: { type: 'host_exact', value: '127.0.0.1' }, proxyProfileId: 'direct', order: 0, createdAt: now, updatedAt: now }; + const mitmRule = { id: 'e2e-mitm-rule', name: 'E2E MITM', enabled: true, condition: { type: 'host_exact', value: '127.0.0.1' }, proxyProfileId: 'yakit-mitm', order: 1, createdAt: now, updatedAt: now }; await send('proxy.rule.save', directRule); await send('proxy.rule.save', mitmRule); await send('proxy.rules.settings', { defaultProfileId: 'direct', failMode: 'open' }); @@ -457,31 +940,40 @@ try { const reordered = await send('proxy.rules.reorder', { ids: ['e2e-mitm-rule', 'e2e-direct-rule'] }); const secondPreview = await send('proxy.rules.preview', { url }); await send('proxy.rules.reorder', { ids: ['e2e-direct-rule', 'e2e-mitm-rule'] }); + const savedSource = await send('proxy.source.save', { + name: 'E2E AutoProxy subscription', url: sourceUrl, format: 'auto', enabled: true, + matchProfileId: 'yakit-mitm', bypassProfileId: 'direct', updateIntervalMinutes: 60, + }); + const refreshedSourceState = await send('proxy.source.refresh', { id: savedSource.id }); + const sourcePage = await send('proxy.source.rules', { id: savedSource.id, offset: 0, limit: 100 }); + const blockedPreview = await send('proxy.rules.preview', { url: 'https://blocked.subscription.test/path' }); + const allowedPreview = await send('proxy.rules.preview', { url: 'https://allowed.subscription.test/path' }); const configuration = await send('proxy.config.export'); const imported = await send('proxy.config.import', { configuration }); - await send('proxy.rules.apply'); - return { firstPreview, secondPreview, pac, auth, authStatus, reordered: reordered.proxyRules, imported: imported.proxyRouting, configuration }; - }, { url: testUrl }); - if (!proxyRuleChecks.firstPreview.conflict || proxyRuleChecks.firstPreview.effectiveProfileId !== 'direct' || proxyRuleChecks.secondPreview.effectiveProfileId !== 'yakit-mitm') { - throw new Error(`Proxy priority/conflict preview failed: ${JSON.stringify(proxyRuleChecks)}`); + const applied = await send('proxy.auto.apply'); + return { firstPreview, secondPreview, blockedPreview, allowedPreview, pac, auth, authStatus, applied, sourcePage, refreshedSources: refreshedSourceState.proxyRuleSources, reordered: reordered.proxyRules, imported: imported.proxyRouting, configuration }; + }, { url: testUrl, sourceUrl: new URL('/proxy-rules', testUrl).toString() }); + if (proxyRuleChecks.firstPreview.matchedRuleId !== 'e2e-direct-rule' || proxyRuleChecks.firstPreview.effectiveProfileId !== 'direct' || proxyRuleChecks.secondPreview.effectiveProfileId !== 'yakit-mitm') { + throw new Error(`Proxy deterministic order/preview failed: ${JSON.stringify(proxyRuleChecks)}`); } - if (!proxyRuleChecks.pac.includes('priority=200') || !proxyRuleChecks.pac.includes('; DIRECT') || !proxyRuleChecks.auth.configured || !proxyRuleChecks.authStatus.configured) { + if (!proxyRuleChecks.pac.pacScript.includes('PROXY 127.0.0.1:8083; DIRECT') || !proxyRuleChecks.auth.configured || !proxyRuleChecks.authStatus.configured || proxyRuleChecks.applied.activeProxyId !== 'auto') { throw new Error(`Proxy PAC/fail-open/auth failed: ${JSON.stringify(proxyRuleChecks)}`); } - await webPage.evaluate(async () => { - const response = await fetch('/api/session?source=proxy-rule-stats'); - if (!response.ok) throw new Error(`Proxy stats request failed: ${response.status}`); - }); - const proxyStats = await options.evaluate(async () => { - const response = await chrome.runtime.sendMessage({ action: 'proxy.rules.stats' }); - if (!response?.ok) throw new Error(response?.error || 'proxy.rules.stats'); - return response.data; - }); - if (!proxyStats.some((item) => item.ruleId === 'e2e-direct-rule' && item.hits > 0)) throw new Error(`Proxy rule hit statistics were not recorded: ${JSON.stringify(proxyStats)}`); - await options.getByRole('button', { name: '代理规则' }).click(); - await options.getByText('多个出口冲突,使用最高优先级', { exact: true }).waitFor(); + if (proxyRuleChecks.sourcePage.total !== 2 || proxyRuleChecks.blockedPreview.effectiveProfileId !== 'yakit-mitm' + || proxyRuleChecks.allowedPreview.effectiveProfileId !== 'direct' || !proxyRuleChecks.configuration.sources[0]?.content?.includes('[AutoProxy 0.2]')) { + throw new Error(`Proxy subscription/IndexedDB/config exchange failed: ${JSON.stringify(proxyRuleChecks)}`); + } + await options.getByRole('button', { name: '自动切换' }).click(); + await options.getByRole('heading', { name: '自动切换' }).waitFor(); + await options.getByText('配置与浏览器一致', { exact: true }).waitFor(); await options.waitForTimeout(350); - await options.screenshot({ path: resolve(artifacts, 'options-proxy-rules.png') }); + await options.screenshot({ path: resolve(artifacts, 'options-auto-switch.png') }); + await options.getByRole('button', { name: '规则订阅' }).click(); + await options.getByRole('heading', { name: '规则订阅' }).waitFor(); + await options.getByRole('heading', { name: 'E2E AutoProxy subscription' }).waitFor(); + await options.getByText('规范化规则', { exact: true }).waitFor(); + await options.waitForTimeout(350); + await options.screenshot({ path: resolve(artifacts, 'options-rule-sources.png') }); await options.evaluate(async () => { await chrome.runtime.sendMessage({ action: 'proxy.auth.set', payload: { profileId: 'yakit-mitm', password: '' } }); await chrome.runtime.sendMessage({ action: 'proxy.switch', payload: { id: 'direct' } }); @@ -610,7 +1102,7 @@ try { 'browser.dom.write', 'browser.tab.activate', 'browser.page.invoke', 'browser.page.eval.expression', 'browser.human.takeover', 'browser.network.read', 'browser.network.capture', 'browser.network.sensitive.read', - 'browser.observation.read', 'browser.observation.control', + 'browser.recording.read', 'browser.recording.control', 'browser.proxy.read', 'browser.proxy.write', ], durationMinutes: 5, @@ -641,7 +1133,7 @@ try { 'browser.dom.write', 'browser.tab.activate', 'browser.page.invoke', 'browser.page.eval.expression', 'browser.human.takeover', 'browser.network.read', 'browser.network.capture', 'browser.network.sensitive.read', - 'browser.observation.read', 'browser.observation.control', + 'browser.recording.read', 'browser.recording.control', 'browser.proxy.read', 'browser.proxy.write', ], durationMinutes: 5, @@ -669,53 +1161,326 @@ try { if (!deniedProgramEval.error?.message?.includes('browser.page.eval.program')) { throw new Error(`Expression-only grant allowed program Eval: ${JSON.stringify(deniedProgramEval)}`); } - const observationStart = await callBridge(bridgeSocket, 'verify-observation-start', 'browser.observe.start', { + const recordingStart = await callBridge(bridgeSocket, 'verify-recording-start', 'browser.recording.start', { tabId: targetTab.id, captureValues: false, - maxEntries: 100, + maxEntries: 200, }); - if (observationStart.error || observationStart.result?.active !== true) { - throw new Error(`Page observation did not start: ${JSON.stringify(observationStart)}`); + if (recordingStart.error || recordingStart.result?.status?.active !== true) { + throw new Error(`Browser recording did not start: ${JSON.stringify(recordingStart)}`); } await webPage.evaluate(async (socketPort) => { const form = document.querySelector('form'); form.addEventListener('submit', (event) => event.preventDefault(), { once: true }); form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - await fetch('/api/session?source=observer-fetch', { method: 'POST', body: 'observer-fetch-secret-171' }); + const linkedValue = window.CryptoJS.SHA256('recorder-linked-secret-171').toString(); + await fetch('/api/session?source=recorder-linked-fetch', { method: 'POST', body: linkedValue }); + const formLinkedValue = window.CryptoJS.SHA256('recorder-form-linked-secret-181').toString(); + await fetch('/api/session?source=recorder-form-linked-fetch', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: new URLSearchParams({ encryptedData: formLinkedValue, channel: 'browser' }), + }); + const rsaPlaintext = JSON.stringify({ username: 'recorder-rsa-admin-191', password: 'recorder-rsa-password-192' }); + const rsaCiphertext = window.__yakitRsa.encrypt(rsaPlaintext); + const rsaResponse = await fetch('/encrypt/rsa.php?source=recorder-rsa-profile', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: new URLSearchParams({ data: rsaCiphertext }), + }); + const rsaValidation = await rsaResponse.json(); + if (!rsaValidation.ok || rsaValidation.plaintext !== rsaPlaintext) { + throw new Error(`Independent RSA server rejected the browser ciphertext: ${JSON.stringify(rsaValidation)}`); + } await new Promise((resolveRequest, rejectRequest) => { const request = new XMLHttpRequest(); - request.open('POST', '/api/session?source=observer-xhr'); + request.open('POST', '/api/session?source=recorder-xhr'); request.onload = resolveRequest; request.onerror = rejectRequest; - request.send('observer-xhr-secret-272'); + request.send('recorder-xhr-secret-272'); }); - await crypto.subtle.digest('SHA-256', new TextEncoder().encode('observer-webcrypto-secret-373')); - window.CryptoJS.SHA256('observer-cryptojs-secret-474'); + await crypto.subtle.digest('SHA-256', new TextEncoder().encode('recorder-webcrypto-secret-373')); await new Promise((resolveSocket, rejectSocket) => { const socket = new WebSocket(`ws://127.0.0.1:${socketPort}/page-socket`); - socket.onopen = () => socket.send('observer-websocket-secret-575'); + socket.onopen = () => socket.send('recorder-websocket-secret-575'); socket.onmessage = () => socket.close(); socket.onclose = resolveSocket; socket.onerror = rejectSocket; }); + const marker = document.createElement('button'); + marker.textContent = 'Worker boundary holdout'; + document.body.append(marker); + marker.click(); + marker.remove(); + await new Promise((resolveWorker, rejectWorker) => { + const worker = new Worker('/opaque-worker.js?chunk=randomized-811'); + worker.addEventListener('message', async (event) => { + try { + const response = await fetch('/opaque-worker-submit?route=randomized-812', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(event.data), + }); + const result = await response.json(); + worker.terminate(); + if (!result.ok) throw new Error(JSON.stringify(result)); + resolveWorker(); + } catch (error) { rejectWorker(error); } + }, { once: true }); + worker.addEventListener('error', rejectWorker, { once: true }); + worker.postMessage({ marker: 'worker-boundary-holdout-811', nested: { value: 812 } }); + }); + await new Promise((resolveMessage) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolveMessage(); }; + channel.port2.postMessage({ marker: 'message-port-boundary-813' }); + }); + if (!navigator.sendBeacon('/beacon-boundary?route=randomized-814', 'beacon-boundary-value-814')) { + throw new Error('sendBeacon rejected the E2E boundary payload'); + } }, address.port); - const observationList = await callBridge(bridgeSocket, 'verify-observation-list', 'browser.observe.list', { tabId: targetTab.id, limit: 100 }); - const observedKinds = new Set(observationList.result?.map((item) => item.kind)); - for (const kind of ['fetch', 'xhr', 'form', 'websocket', 'webcrypto', 'cryptojs']) { - if (!observedKinds.has(kind)) throw new Error(`Page observation missed ${kind}: ${JSON.stringify(observationList)}`); + await webPage.evaluate(async ({ + smPublicKey, smPrivateKey, sm4Key, sm4IV, forgePublicKey, forgeAesKey, forgeAesIV, + }) => { + const marker = document.createElement('button'); + marker.textContent = 'Semantic adapter holdout'; + document.body.append(marker); + marker.click(); + marker.remove(); + + const expectedSMPlaintext = 'semantic-sm-plaintext-821'; + const sm2Ciphertext = window.sm2.doEncrypt(expectedSMPlaintext, smPublicKey, 1); + const sm2Signature = window.sm2.doSignature(expectedSMPlaintext, smPrivateKey); + const sm3Digest = window.sm3(expectedSMPlaintext); + const sm4Ciphertext = window.sm4.encrypt(expectedSMPlaintext, sm4Key, { mode: 'cbc', iv: sm4IV }); + if (window.sm2.doDecrypt(sm2Ciphertext, smPrivateKey, 1) !== expectedSMPlaintext + || !window.sm2.doVerifySignature(expectedSMPlaintext, sm2Signature, smPublicKey) + || window.sm4.decrypt(sm4Ciphertext, sm4Key, { mode: 'cbc', iv: sm4IV }) !== expectedSMPlaintext) { + throw new Error('Browser sm-crypto self verification failed'); + } + + const expectedForgePlaintext = 'semantic-forge-plaintext-822'; + const cipher = window.forge.cipher.createCipher('AES-CBC', window.forge.util.hexToBytes(forgeAesKey)); + cipher.start({ iv: window.forge.util.hexToBytes(forgeAesIV) }); + cipher.update(window.forge.util.createBuffer(expectedForgePlaintext, 'utf8')); + if (!cipher.finish()) throw new Error('Browser node-forge AES encryption failed'); + const forgeAesCiphertext = cipher.output.toHex(); + const publicKey = window.forge.pki.publicKeyFromPem(forgePublicKey); + const forgeRsaCiphertext = window.forge.util.encode64(publicKey.encrypt( + expectedForgePlaintext, 'RSAES-PKCS1-V1_5', + )); + const digest = window.forge.md.sha256.create(); + digest.update(expectedForgePlaintext, 'utf8'); + const forgeDigest = digest.digest().toHex(); + const hmac = window.forge.hmac.create(); + hmac.start('sha256', window.forge.util.hexToBytes(forgeAesKey)); + hmac.update(expectedForgePlaintext); + const forgeHmac = hmac.digest().toHex(); + + const response = await fetch('/semantic-adapter-submit?route=randomized-823', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + expectedSMPlaintext, sm2Ciphertext, sm2Signature, sm3Digest, sm4Ciphertext, + expectedForgePlaintext, forgeAesCiphertext, forgeRsaCiphertext, forgeDigest, forgeHmac, + }), + }); + const result = await response.json(); + if (!result.ok) throw new Error(`Independent semantic adapter server rejected the envelope: ${JSON.stringify(result)}`); + }, { + smPublicKey: smLabKeyPair.publicKey, + smPrivateKey: smLabKeyPair.privateKey, + sm4Key: smLabSM4Key, + sm4IV: smLabSM4IV, + forgePublicKey: rsaLabPublicKey, + forgeAesKey: forgeLabAESKey, + forgeAesIV: forgeLabAESIV, + }); + await webPage.locator('#opaque-module-lab[data-ready="true"]').waitFor(); + await webPage.locator('#opaque-module-input').fill(closureInitialMarker); + await webPage.locator('#opaque-module-submit').click(); + await webPage.locator('#opaque-module-result').getByText(closureInitialMarker, { exact: true }).waitFor(); + await webPage.locator('#crypto-lab[data-ready="true"]').waitFor(); + await webPage.locator('#crypto-password').fill('recorder-webcrypto-envelope-474'); + await webPage.locator('#crypto-submit').click(); + await webPage.locator('#crypto-result').getByText('recorder-webcrypto-envelope-474').waitFor(); + const recordingSnapshot = await callBridge(bridgeSocket, 'verify-recording-get', 'browser.recording.get', { tabId: targetTab.id, limit: 200 }); + const observedKinds = new Set(recordingSnapshot.result?.events?.map((item) => item.kind)); + for (const kind of ['fetch', 'xhr', 'form', 'beacon', 'worker', 'message', 'websocket', 'crypto']) { + if (!observedKinds.has(kind)) throw new Error(`Browser recording missed ${kind}: ${JSON.stringify(recordingSnapshot)}`); } - const redactedObservations = JSON.stringify(observationList.result); - for (const secret of ['observer-fetch-secret-171', 'observer-xhr-secret-272', 'observer-webcrypto-secret-373', 'observer-cryptojs-secret-474', 'observer-websocket-secret-575']) { - if (redactedObservations.includes(secret)) throw new Error(`Metadata-only observation leaked a value: ${secret}`); + const workerSendEvent = recordingSnapshot.result?.events?.find((item) => item.kind === 'worker' && item.operation === 'worker.postMessage'); + const workerReceiveEvent = recordingSnapshot.result?.events?.find((item) => item.kind === 'worker' && item.operation === 'worker.message'); + const workerRequestEvent = recordingSnapshot.result?.events?.find((item) => item.kind === 'fetch' && item.url?.includes('/opaque-worker-submit')); + if (!workerSendEvent?.wrapperHandleId || !workerReceiveEvent || workerSendEvent.channelId !== workerReceiveEvent.channelId + || workerSendEvent.traceId !== workerReceiveEvent.traceId + || !recordingSnapshot.result?.links?.some((item) => item.kind === 'channel' && item.fromEventId === workerSendEvent.id && item.toEventId === workerReceiveEvent.id)) { + throw new Error(`Worker boundary did not retain its exact handle and async Trace correlation: ${JSON.stringify(recordingSnapshot)}`); } - const deniedSensitiveObservation = await callBridge(bridgeSocket, 'verify-observation-sensitive-denied', 'browser.observe.start', { + const unknownBoundaryCandidate = recordingSnapshot.result?.profileCandidates?.find((candidate) => ( + candidate.request.eventId === workerRequestEvent?.id && candidate.source.operation === 'unknown-business-envelope' + )); + if (!unknownBoundaryCandidate || unknownBoundaryCandidate.status !== 'capture-required') { + throw new Error(`Unknown Worker/ESM boundary did not remain actionable: ${JSON.stringify(recordingSnapshot)}`); + } + const observedCryptoProviders = new Set(recordingSnapshot.result?.events + ?.filter((item) => item.kind === 'crypto') + .map((item) => item.crypto?.adapterId)); + for (const provider of ['webcrypto', 'cryptojs', 'jsencrypt', 'sm-crypto', 'node-forge']) { + if (!observedCryptoProviders.has(provider)) { + throw new Error(`Browser recording missed the ${provider} crypto adapter: ${JSON.stringify(recordingSnapshot)}`); + } + } + const smOperations = new Set(recordingSnapshot.result?.events + ?.filter((item) => item.kind === 'crypto' && item.crypto?.adapterId === 'sm-crypto') + .map((item) => item.crypto?.operation)); + for (const operation of ['sm2.encrypt', 'sm2.decrypt', 'sm2.sign', 'sm2.verify', 'sm3.digest', 'sm4.encrypt', 'sm4.decrypt']) { + if (!smOperations.has(operation)) throw new Error(`Real sm-crypto fixture missed ${operation}: ${JSON.stringify(recordingSnapshot)}`); + } + const forgeEvents = recordingSnapshot.result?.events + ?.filter((item) => item.kind === 'crypto' && item.crypto?.adapterId === 'node-forge') || []; + for (const phase of ['create', 'init', 'update', 'final']) { + if (!forgeEvents.some((item) => item.crypto?.state?.phase === phase && item.crypto?.state?.correlationId)) { + throw new Error(`Real node-forge fixture missed correlated ${phase} state: ${JSON.stringify(recordingSnapshot)}`); + } + } + if (!forgeEvents.some((item) => item.crypto?.operation === 'rsa.encrypt' && item.callHandleId && item.callableCapable) + || !forgeEvents.some((item) => item.crypto?.operation === 'cipher.encrypt.output.toHex')) { + throw new Error(`Real node-forge fixture missed RSA callable or cipher output boundary: ${JSON.stringify(recordingSnapshot)}`); + } + const linkedCryptoEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'crypto' && item.crypto?.adapterId === 'cryptojs' && item.crypto?.operation === 'SHA256' + )); + const webCryptoEncryptEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'crypto' && item.crypto?.adapterId === 'webcrypto' + && item.crypto?.operation === 'encrypt' && item.wrapperHandleId + )); + const webCryptoDecryptEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'crypto' && item.crypto?.adapterId === 'webcrypto' + && item.crypto?.operation === 'decrypt' && item.wrapperHandleId + )); + const closureRequestEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'fetch' && item.url?.includes(closureSubmitPath) + )); + const webCryptoDigestEvents = recordingSnapshot.result?.events?.filter((item) => ( + item.kind === 'crypto' && item.crypto?.adapterId === 'webcrypto' + && item.crypto?.operation === 'digest' && item.wrapperHandleId + )) || []; + const webCryptoDigestEvent = webCryptoDigestEvents.find((item) => item.scriptUrl?.includes(closureModulePath)) + || webCryptoDigestEvents[0]; + const exactValueLinks = recordingSnapshot.result?.links?.filter((item) => ( + item.kind === 'value' && item.confidence === 'exact' + )) || []; + const closureReachableEvents = new Set(webCryptoDigestEvent ? [webCryptoDigestEvent.id] : []); + for (let pass = 0; pass < exactValueLinks.length; pass += 1) { + let changed = false; + for (const link of exactValueLinks) { + if (closureReachableEvents.has(link.fromEventId) && !closureReachableEvents.has(link.toEventId)) { + closureReachableEvents.add(link.toEventId); + changed = true; + } + } + if (!changed) break; + } + const closureDigestLink = recordingSnapshot.result?.links?.find((item) => ( + closureReachableEvents.has(item.fromEventId) + && item.toEventId === closureRequestEvent?.id + && item.toPath === `$body:json.${closureDigestField}` + && item.kind === 'value' + && item.confidence === 'exact' + )); + if (!linkedCryptoEvent?.wrapperHandleId || !webCryptoEncryptEvent?.wrapperHandleId + || !webCryptoDecryptEvent?.wrapperHandleId || !webCryptoDigestEvent?.wrapperHandleId) { + throw new Error(`Recording did not retain exact crypto wrapper handles: ${JSON.stringify({ + linkedCryptoEvent, + webCryptoEncryptEvent, + webCryptoDecryptEvent, + webCryptoDigestEvents, + cryptoOperations: recordingSnapshot.result?.events?.filter((item) => item.kind === 'crypto') + .map((item) => `${item.crypto?.adapterId}:${item.crypto?.operation}:${Boolean(item.wrapperHandleId)}`), + })}`); + } + if (!closureRequestEvent || !closureDigestLink) { + throw new Error(`Randomized ESM/WASM holdout did not use the generic evidence graph: ${JSON.stringify({ + closureSubmitPath, + closureDigestField, + request: closureRequestEvent, + digests: webCryptoDigestEvents, + links: recordingSnapshot.result?.links?.filter((item) => item.toEventId === closureRequestEvent?.id), + })}`); + } + const linkedFetchEvent = recordingSnapshot.result?.events?.find((item) => item.kind === 'fetch' && item.url?.includes('recorder-linked-fetch')); + if (!linkedCryptoEvent || !linkedFetchEvent || !recordingSnapshot.result?.links?.some((link) => link.fromEventId === linkedCryptoEvent.id && link.toEventId === linkedFetchEvent.id)) { + throw new Error(`Recording did not link CryptoJS output to Fetch input: ${JSON.stringify(recordingSnapshot)}`); + } + if (!recordingSnapshot.result?.traces?.some((trace) => trace.eventIds.includes(linkedCryptoEvent.id) && trace.eventIds.includes(linkedFetchEvent.id))) { + throw new Error(`Linked events were not grouped into one business Trace: ${JSON.stringify(recordingSnapshot)}`); + } + const inferredProfile = recordingSnapshot.result?.profileCandidates?.find((candidate) => ( + candidate.source?.eventId === linkedCryptoEvent.id && candidate.request?.eventId === linkedFetchEvent.id + )); + if (inferredProfile?.status !== 'ready' + || inferredProfile.request?.destination !== 'body' + || inferredProfile.request?.serialization !== 'raw-body' + || inferredProfile.confidence?.level !== 'high' + || inferredProfile.source?.arguments?.[0]?.role !== 'data' + || inferredProfile.aiContext?.valuePolicy !== 'metadata-only') { + throw new Error(`Recording did not infer a safe high-confidence Profile candidate: ${JSON.stringify(recordingSnapshot)}`); + } + const formLinkedFetchEvent = recordingSnapshot.result?.events?.find((item) => item.kind === 'fetch' && item.url?.includes('recorder-form-linked-fetch')); + const formFieldLink = recordingSnapshot.result?.links?.find((link) => ( + link.toEventId === formLinkedFetchEvent?.id && link.toPath === '$body:form.encryptedData' + )); + const formLinkedCandidate = recordingSnapshot.result?.profileCandidates?.find((candidate) => ( + candidate.source?.eventId === formFieldLink?.fromEventId && candidate.request?.eventId === formLinkedFetchEvent?.id + )); + if (!formLinkedFetchEvent || !formFieldLink || formLinkedCandidate?.request?.destination !== 'body.encryptedData' + || formLinkedCandidate?.request?.serialization !== 'form-field' + || formLinkedCandidate?.status !== 'ready' || formLinkedCandidate?.confidence?.level !== 'high') { + throw new Error(`Recording did not preserve the generic form field value chain: ${JSON.stringify(recordingSnapshot)}`); + } + const rsaCryptoEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'crypto' && item.crypto?.adapterId === 'jsencrypt' && item.crypto?.operation === 'encrypt' + )); + const rsaRequestEvent = recordingSnapshot.result?.events?.find((item) => ( + item.kind === 'fetch' && item.url?.includes('/encrypt/rsa.php?source=recorder-rsa-profile') + )); + const rsaFieldLink = recordingSnapshot.result?.links?.find((link) => ( + link.fromEventId === rsaCryptoEvent?.id + && link.toEventId === rsaRequestEvent?.id + && link.toPath === '$body:form.data' + )); + const rsaCandidate = recordingSnapshot.result?.profileCandidates?.find((candidate) => ( + candidate.source?.eventId === rsaCryptoEvent?.id && candidate.request?.eventId === rsaRequestEvent?.id + )); + if (!rsaCryptoEvent || !rsaRequestEvent || !rsaFieldLink + || rsaCryptoEvent.crypto?.family !== 'asymmetric' + || rsaCryptoEvent.crypto?.padding !== 'PKCS1-v1_5' + || rsaCryptoEvent.crypto?.key?.kind !== 'public' + || rsaCryptoEvent.crypto?.key?.bits !== 1024 + || !rsaCryptoEvent.crypto?.key?.fingerprint + || rsaCandidate?.status !== 'ready' + || rsaCandidate.request?.destination !== 'body.data' + || rsaCandidate.request?.serialization !== 'form-field') { + throw new Error(`JSEncrypt RSA was not inferred as an executable form-field Profile: ${JSON.stringify(recordingSnapshot)}`); + } + const rsaAIContext = JSON.stringify(rsaCandidate.aiContext); + if (rsaAIContext.includes('BEGIN PUBLIC KEY') || rsaAIContext.includes(rsaLabPublicModulusHex)) { + throw new Error(`JSEncrypt inference leaked raw public-key material: ${rsaAIContext}`); + } + const redactedRecording = JSON.stringify(recordingSnapshot.result); + for (const secret of ['recorder-linked-secret-171', 'recorder-form-linked-secret-181', 'recorder-rsa-admin-191', 'recorder-rsa-password-192', 'recorder-xhr-secret-272', 'recorder-webcrypto-secret-373', 'recorder-webcrypto-envelope-474', 'recorder-websocket-secret-575', 'worker-boundary-holdout-811', 'message-port-boundary-813', 'beacon-boundary-value-814', closureInitialMarker, 'BEGIN PUBLIC KEY']) { + if (redactedRecording.includes(secret)) throw new Error(`Metadata-only recording leaked a value: ${secret}`); + } + for (const keyMaterial of [smLabKeyPair.privateKey, smLabSM4Key, forgeLabAESKey, 'semantic-sm-plaintext-821', 'semantic-forge-plaintext-822', 'BEGIN PRIVATE KEY']) { + if (redactedRecording.includes(keyMaterial)) throw new Error('Metadata-only recording leaked semantic adapter key material or plaintext'); + } + const deniedSensitiveRecording = await callBridge(bridgeSocket, 'verify-recording-sensitive-denied', 'browser.recording.start', { tabId: targetTab.id, captureValues: true, }); - if (!deniedSensitiveObservation.error?.message?.includes('browser.observation.sensitive.read')) { - throw new Error(`Observation value capture did not require its sensitive scope: ${JSON.stringify(deniedSensitiveObservation)}`); + if (!deniedSensitiveRecording.error?.message?.includes('browser.recording.sensitive.read')) { + throw new Error(`Recording value capture did not require its sensitive scope: ${JSON.stringify(deniedSensitiveRecording)}`); } - await callBridge(bridgeSocket, 'verify-observation-stop-metadata', 'browser.observe.stop', { tabId: targetTab.id }); + await callBridge(bridgeSocket, 'verify-recording-stop-metadata', 'browser.recording.stop', { tabId: targetTab.id }); await options.evaluate(async ({ tabId, frameIds }) => { const response = await chrome.runtime.sendMessage({ action: 'grant.create', @@ -725,40 +1490,608 @@ try { 'browser.tabs.read', 'browser.dom.read', 'browser.storage.read', 'browser.cookies.read', 'browser.dom.write', 'browser.tab.activate', 'browser.page.invoke', 'browser.page.eval.expression', 'browser.page.eval.program', 'browser.human.takeover', 'browser.network.read', 'browser.network.capture', 'browser.network.sensitive.read', - 'browser.observation.read', 'browser.observation.control', 'browser.observation.sensitive.read', + 'browser.recording.read', 'browser.recording.control', 'browser.recording.sensitive.read', 'browser.callable.execute', + 'browser.debugger.read', 'browser.debugger.control', + 'browser.transform.read', 'browser.transform.manage', 'browser.transform.execute', 'browser.proxy.read', 'browser.proxy.write', ], durationMinutes: 5, }, }); - if (!response?.ok) throw new Error(response?.error || 'grant.create observation sensitive'); + if (!response?.ok) throw new Error(response?.error || 'grant.create recording sensitive'); }, { tabId: targetTab.id, frameIds: [sameOriginFrame.frameId, crossOriginFrame.frameId] }); - const sensitiveObservationStart = await callBridge(bridgeSocket, 'verify-observation-sensitive-start', 'browser.observe.start', { + const sensitiveRecordingStart = await callBridge(bridgeSocket, 'verify-recording-sensitive-start', 'browser.recording.start', { tabId: targetTab.id, captureValues: true, }); - if (sensitiveObservationStart.error) throw new Error(`Sensitive page observation did not start: ${JSON.stringify(sensitiveObservationStart)}`); + if (sensitiveRecordingStart.error) throw new Error(`Sensitive browser recording did not start: ${JSON.stringify(sensitiveRecordingStart)}`); const programEval = await callBridge(bridgeSocket, 'verify-program-eval', 'browser.eval', { tabId: targetTab.id, mode: 'program', code: 'const programValue = 41; return programValue + 1', }); if (programEval.error || programEval.result?.value !== 42) throw new Error(`Program Eval scope did not execute: ${JSON.stringify(programEval)}`); - await webPage.evaluate(() => window.CryptoJS.SHA256('observer-sensitive-preview-686')); - const sensitiveObservations = await callBridge(bridgeSocket, 'verify-observation-sensitive-list', 'browser.observe.list', { tabId: targetTab.id }); - if (!JSON.stringify(sensitiveObservations.result).includes('observer-sensitive-preview-686')) { - throw new Error(`Explicit observation value capture did not return its bounded preview: ${JSON.stringify(sensitiveObservations)}`); + await webPage.evaluate(() => { + window.CryptoJS.SHA256('recorder-sensitive-preview-686'); + window.__yakitRsa.encrypt(JSON.stringify({ username: 'sensitive-rsa-admin-687', password: 'sensitive-rsa-password-688' })); + }); + const sensitiveRecording = await callBridge(bridgeSocket, 'verify-recording-sensitive-get', 'browser.recording.get', { tabId: targetTab.id }); + if (!JSON.stringify(sensitiveRecording.result).includes('recorder-sensitive-preview-686')) { + throw new Error(`Explicit recording value capture did not return its bounded preview: ${JSON.stringify(sensitiveRecording)}`); + } + const callableSource = sensitiveRecording.result?.events?.find((item) => ( + item.kind === 'crypto' + && item.crypto?.adapterId === 'jsencrypt' + && item.crypto?.operation === 'encrypt' + && item.callHandleId + && item.callableCapable + )); + if (!callableSource) throw new Error(`Sensitive recording did not retain an executable JSEncrypt call handle: ${JSON.stringify(sensitiveRecording)}`); + const createdCallable = await callBridge(bridgeSocket, 'verify-callable-create', 'browser.callable.create', { + tabId: targetTab.id, + source: 'recording', + callHandleId: callableSource.callHandleId, + name: 'E2E JSEncrypt RSA Callable', + }); + if (createdCallable.error || !createdCallable.result?.id) throw new Error(`Could not create a recorded page callable: ${JSON.stringify(createdCallable)}`); + await callBridge(bridgeSocket, 'verify-recording-stop-sensitive', 'browser.recording.stop', { tabId: targetTab.id }); + const replayedCallable = await callBridge(bridgeSocket, 'verify-callable-execute-after-stop', 'browser.callable.execute', { + tabId: targetTab.id, + callableId: createdCallable.result.id, + args: [{ username: 'callable-rsa-admin-797', password: 'callable-rsa-password-798' }], + }); + const replayedRsaPlaintext = replayedCallable.result?.value + ? decryptRSALabValue(replayedCallable.result.value) + : undefined; + const replayedRsaPayload = replayedRsaPlaintext ? JSON.parse(replayedRsaPlaintext) : undefined; + if (replayedCallable.error + || replayedRsaPayload?.username !== 'callable-rsa-admin-797' + || replayedRsaPayload?.password !== 'callable-rsa-password-798') { + throw new Error(`Document-bound JSEncrypt callable lost its receiver or input adaptation after recording stopped: ${JSON.stringify({ replayedCallable, replayedRsaPlaintext, replayedRsaPayload })}`); + } + + await webPage.evaluate(() => { + window.sendDigestWithNetwork = function sendDigestWithNetwork(value) { + const encryptedData = window.CryptoJS.SHA256(value).toString(); + return fetch('/api/session?source=deep-risk-classification', { + method: 'POST', + body: encryptedData, + }); + }; + }); + const riskyCaptureStart = await callBridge(bridgeSocket, 'verify-risky-capture-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: linkedCryptoEvent.crypto.adapterId, + operation: linkedCryptoEvent.crypto.operation, + wrapperHandleId: linkedCryptoEvent.wrapperHandleId, + }, + }); + if (riskyCaptureStart.error || riskyCaptureStart.result?.state !== 'armed') { + throw new Error(`Side-effect classification capture did not arm: ${JSON.stringify(riskyCaptureStart)}`); + } + await webPage.evaluate(() => setTimeout(() => void window.sendDigestWithNetwork('risk-classification-value'), 50)); + let riskyPause; + for (let attempt = 0; attempt < 60; attempt += 1) { + const next = await callBridge(bridgeSocket, `verify-risky-capture-status-${attempt}`, 'browser.deep_capture.status', { tabId: targetTab.id }); + if (next.error) throw new Error(`Side-effect classification status failed: ${JSON.stringify(next)}`); + if (next.result?.state === 'paused') { + riskyPause = next.result; + await callBridge(bridgeSocket, `verify-risky-capture-keepalive-${attempt}`, 'browser.deep_capture.keepalive', { tabId: targetTab.id }); + const frame = riskyPause.pause?.frames?.find((item) => item.functionName === 'sendDigestWithNetwork'); + if (frame?.functionInspection) break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + const riskyFrame = riskyPause?.pause?.frames?.find((frame) => frame.functionName === 'sendDigestWithNetwork'); + if (riskyFrame?.sourceKind !== 'page' || !riskyFrame.functionInspection?.resolved || !riskyFrame.functionInspection.riskFlags?.includes('network')) { + throw new Error(`Network side effect was not classified on the business frame: ${JSON.stringify(riskyPause)}`); + } + if (riskyPause.pause?.automaticCapture?.state !== 'blocked') { + throw new Error(`A network-sending business function was not blocked from automatic capture: ${JSON.stringify(riskyPause)}`); + } + const blockedRiskyCallable = await callBridge(bridgeSocket, 'verify-risky-callable-blocked', 'browser.callable.create', { + tabId: targetTab.id, + source: 'deep-capture', + strategy: 'selected-frame', + callFrameId: riskyFrame.id, + }); + if (blockedRiskyCallable.error?.code !== 'callable_capture_blocked') { + throw new Error(`Side-effect gate allowed a network-sending callable: ${JSON.stringify(blockedRiskyCallable)}`); + } + await callBridge(bridgeSocket, 'verify-risky-capture-resume', 'browser.deep_capture.resume', { tabId: targetTab.id }); + await callBridge(bridgeSocket, 'verify-risky-capture-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + + const closureIsNotGlobal = await webPage.evaluate((functionName) => ( + typeof window[functionName] === 'undefined' + ), closureFunctionName); + if (!closureIsNotGlobal) throw new Error('The randomized ESM holdout accidentally exposed its business function globally'); + await webPage.locator('#opaque-module-input').fill(closureReplayMarker); + const closureCaptureStart = await callBridge(bridgeSocket, 'verify-closure-capture-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: webCryptoDigestEvent.crypto.adapterId, + operation: webCryptoDigestEvent.crypto.operation, + wrapperHandleId: webCryptoDigestEvent.wrapperHandleId, + }, + }); + if (closureCaptureStart.error || closureCaptureStart.result?.state !== 'armed') { + throw new Error(`Randomized ESM/WASM closure capture did not arm: ${JSON.stringify(closureCaptureStart)}`); + } + await webPage.evaluate(() => { + setTimeout(() => document.querySelector('#opaque-module-submit').click(), 50); + }); + let closurePause; + for (let attempt = 0; attempt < 80; attempt += 1) { + const next = await callBridge(bridgeSocket, `verify-closure-capture-status-${attempt}`, 'browser.deep_capture.status', { tabId: targetTab.id }); + if (next.error) throw new Error(`Randomized ESM/WASM capture status failed: ${JSON.stringify(next)}`); + if (next.result?.state === 'paused') { + closurePause = next.result; + await callBridge(bridgeSocket, `verify-closure-capture-keepalive-${attempt}`, 'browser.deep_capture.keepalive', { tabId: targetTab.id }); + const frame = closurePause.pause?.frames?.find((item) => item.functionName === closureFunctionName); + if (!closurePause.pause?.collecting && frame?.functionInspection) break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + const closureFrame = closurePause?.pause?.frames?.find((frame) => frame.functionName === closureFunctionName); + const closureVariables = closureFrame?.scopes?.flatMap((scope) => scope.variables) || []; + if (!closureFrame || closureFrame.sourceKind !== 'page' || !closureFrame.url.includes(closureModulePath) + || !closureFrame.functionInspection?.resolved || closureFrame.functionInspection.riskFlags?.length + || !closureVariables.some((variable) => variable.name === 'instance') + || closurePause.pause?.recommendedFrameId !== closureFrame.id + || closurePause.pause?.automaticCapture?.state !== 'ready' + || closurePause.pause?.automaticCapture?.frameId !== closureFrame.id) { + throw new Error(`Randomized ESM/WASM business frame was not recovered deterministically: ${JSON.stringify(closurePause)}`); + } + const closureCallable = await callBridge(bridgeSocket, 'verify-closure-callable-create', 'browser.callable.create', { + tabId: targetTab.id, + source: 'deep-capture', + strategy: 'selected-frame', + callFrameId: closureFrame.id, + name: 'E2E opaque module envelope', + }); + if (closureCallable.error || closureCallable.result?.kind !== 'business-closure') { + throw new Error(`Randomized ESM/WASM callable could not be retained: ${JSON.stringify(closureCallable)}`); + } + await webPage.locator('#opaque-module-result').getByText(closureReplayMarker, { exact: true }).waitFor(); + const closureReplay = await callBridge(bridgeSocket, 'verify-closure-callable-execute', 'browser.callable.execute', { + tabId: targetTab.id, + callableId: closureCallable.result.id, + args: [{ marker: closureReplayMarker, nested: { seed: closureHoldoutSeed } }], + }); + if (closureReplay.error || !closureReplay.result?.value || typeof closureReplay.result.value !== 'object') { + throw new Error(`Randomized ESM/WASM callable replay failed: ${JSON.stringify(closureReplay)}`); + } + const closureServerResponse = await fetch(new URL(closureSubmitPath, testUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(closureReplay.result.value), + }); + const closureServerResult = await closureServerResponse.json(); + if (!closureServerResponse.ok || !closureServerResult.ok || closureServerResult.marker !== closureReplayMarker) { + throw new Error(`Independent server rejected the retained ESM/WASM callable: ${JSON.stringify(closureServerResult)}`); + } + await callBridge(bridgeSocket, 'verify-closure-capture-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + + await webPage.locator('#crypto-lab[data-ready="true"]').waitFor(); + await webPage.locator('#crypto-password').fill('deep-capture-first-901'); + const deepCaptureStart = await callBridge(bridgeSocket, 'verify-deep-capture-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: webCryptoEncryptEvent.crypto.adapterId, + operation: webCryptoEncryptEvent.crypto.operation, + wrapperHandleId: webCryptoEncryptEvent.wrapperHandleId, + }, + }); + if (deepCaptureStart.error || deepCaptureStart.result?.state !== 'armed') { + throw new Error(`Deep capture did not arm: ${JSON.stringify(deepCaptureStart)}`); + } + await webPage.evaluate(() => { + setTimeout(() => document.querySelector('#crypto-submit').click(), 50); + }); + let deepPause; + let lastDeepStatus; + for (let attempt = 0; attempt < 80; attempt += 1) { + const next = await callBridge(bridgeSocket, `verify-deep-capture-status-${attempt}`, 'browser.deep_capture.status', { tabId: targetTab.id }); + if (next.error) throw new Error(`Deep capture status failed: ${JSON.stringify(next)}`); + lastDeepStatus = next.result; + if (next.result?.state === 'paused') { + deepPause = next.result; + await callBridge(bridgeSocket, `verify-deep-capture-keepalive-${attempt}`, 'browser.deep_capture.keepalive', { tabId: targetTab.id }); + const frame = deepPause.pause?.frames?.find((item) => item.functionName === 'buildLoginEnvelope'); + const variables = frame?.scopes?.flatMap((scope) => scope.variables) || []; + if (!deepPause.pause?.collecting && variables.some((variable) => variable.name === 'password')) break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + const businessFrame = deepPause?.pause?.frames?.find((frame) => frame.functionName === 'buildLoginEnvelope'); + const businessVariables = businessFrame?.scopes?.flatMap((scope) => scope.variables) || []; + if (!businessFrame || businessFrame.sourceKind !== 'page' + || deepPause?.pause?.frames?.[0]?.sourceKind !== 'extension-hook' + || !businessFrame.scopes.some((scope) => scope.type === 'closure' || scope.type === 'local') + || !businessFrame.functionInspection?.resolved || businessFrame.functionInspection.riskFlags?.length + || !businessVariables.some((variable) => variable.name === 'password' && variable.preview.includes('deep-capture-first-901'))) { + throw new Error(`Deep capture did not expose the real business frame and lexical inputs: ${JSON.stringify({ deepPause, lastDeepStatus })}`); + } + await options.getByRole('button', { name: '网络活动' }).click(); + await options.locator('#deep-mode-tab').click(); + const pausedDeepWorkbench = options.locator('.deep-paused-workbench'); + await pausedDeepWorkbench.waitFor(); + await options.locator('.deep-stack button.is-selected').getByText('buildLoginEnvelope', { exact: true }).waitFor(); + await options.locator('.deep-stack').getByText('插件 Hook', { exact: true }).first().waitFor(); + await options.locator('.deep-stack').getByText('页面函数', { exact: true }).first().waitFor(); + const expandablePassword = options.locator('.deep-scope-variable').filter({ hasText: 'password' }).first(); + await expandablePassword.locator(':scope > button').click(); + await expandablePassword.locator('pre').getByText('deep-capture-first-901', { exact: true }).waitFor(); + if (await expandablePassword.locator(':scope > button').getAttribute('aria-expanded') !== 'true') { + throw new Error('Deep capture scope row did not expose its inline value block'); + } + const pausedDeepBounds = await pausedDeepWorkbench.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (pausedDeepBounds.scrollWidth > pausedDeepBounds.clientWidth) { + throw new Error(`Paused deep workbench overflowed horizontally: ${JSON.stringify(pausedDeepBounds)}`); + } + await options.screenshot({ path: resolve(artifacts, 'options-deep-capture-paused.png') }); + const capturedCallable = await callBridge(bridgeSocket, 'verify-captured-callable-create', 'browser.callable.create', { + tabId: targetTab.id, + source: 'deep-capture', + strategy: 'selected-frame', + callFrameId: businessFrame.id, + name: 'E2E login envelope', + }); + if (capturedCallable.error || capturedCallable.result?.provenance?.functionName !== 'buildLoginEnvelope') { + throw new Error(`Could not retain the real business closure: ${JSON.stringify(capturedCallable)}`); + } + await options.locator('#recording-mode-tab').click(); + await webPage.locator('#crypto-result').getByText('deep-capture-first-901').waitFor(); + await webPage.locator('#crypto-password').fill('deep-capture-response-906'); + const responseCaptureStart = await callBridge(bridgeSocket, 'verify-response-capture-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: webCryptoDecryptEvent.crypto.adapterId, + operation: webCryptoDecryptEvent.crypto.operation, + wrapperHandleId: webCryptoDecryptEvent.wrapperHandleId, + }, + }); + if (responseCaptureStart.error || responseCaptureStart.result?.state !== 'armed') { + throw new Error(`Response decrypt capture did not arm: ${JSON.stringify(responseCaptureStart)}`); + } + await webPage.evaluate(() => { + setTimeout(() => document.querySelector('#crypto-submit').click(), 50); + }); + let responsePause; + for (let attempt = 0; attempt < 80; attempt += 1) { + const next = await callBridge(bridgeSocket, `verify-response-capture-status-${attempt}`, 'browser.deep_capture.status', { tabId: targetTab.id }); + if (next.error) throw new Error(`Response decrypt capture status failed: ${JSON.stringify(next)}`); + if (next.result?.state === 'paused') { + responsePause = next.result; + await callBridge(bridgeSocket, `verify-response-capture-keepalive-${attempt}`, 'browser.deep_capture.keepalive', { tabId: targetTab.id }); + if (responsePause.pause?.frames?.some((frame) => frame.functionName === 'openLoginResponse')) break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + const responseBusinessFrame = responsePause?.pause?.frames?.find((frame) => frame.functionName === 'openLoginResponse'); + if (!responseBusinessFrame) { + throw new Error(`Deep capture did not expose the real response decrypt function: ${JSON.stringify(responsePause)}`); + } + const responseCallable = await callBridge(bridgeSocket, 'verify-response-callable-create', 'browser.callable.create', { + tabId: targetTab.id, + source: 'deep-capture', + strategy: 'selected-frame', + callFrameId: responseBusinessFrame.id, + name: 'E2E login response decryptor', + }); + if (responseCallable.error || responseCallable.result?.provenance?.functionName !== 'openLoginResponse') { + throw new Error(`Could not retain the real response decrypt closure: ${JSON.stringify(responseCallable)}`); + } + await webPage.locator('#crypto-result').getByText('deep-capture-response-906').waitFor(); + const transformProfile = await callBridge(bridgeSocket, 'verify-transform-profile-save', 'browser.transform.profile.save', { + name: 'E2E login plaintext gateway', + enabled: true, + target: { tabId: targetTab.id, frameId: 0 }, + origin: new URL(testUrl).origin, + match: { methods: ['POST'], urlPattern: '*/crypto-lab/submit' }, + request: { + enabled: true, + nodes: [ + { id: 'request-password', name: 'Read password', kind: 'context.read', path: 'body.password' }, + { id: 'request-account', name: 'Read account', kind: 'context.read', path: 'body.account' }, + { + id: 'build-login-envelope', + name: 'Build real login envelope', + kind: 'page.call', + callableId: capturedCallable.result.id, + arguments: [{ nodeId: 'request-password' }, { nodeId: 'request-account' }], + }, + { + id: 'write-login-envelope', + name: 'Write encrypted envelope', + kind: 'output.write', + source: { nodeId: 'build-login-envelope' }, + destination: 'body', + encoding: 'json', + }, + ], + }, + response: { + enabled: true, + nodes: [ + { id: 'response-body', name: 'Read response body', kind: 'context.read', path: 'body' }, + { + id: 'open-login-response', + name: 'Open real login response', + kind: 'page.call', + callableId: responseCallable.result.id, + arguments: [{ nodeId: 'response-body' }], + }, + { + id: 'write-plaintext-response', + name: 'Write plaintext response', + kind: 'output.write', + source: { nodeId: 'open-login-response' }, + destination: 'body', + encoding: 'json', + }, + ], + }, + failMode: 'closed', + maxConcurrency: 1, + }); + if (transformProfile.error || !transformProfile.result?.id || !transformProfile.result?.target?.documentId) { + throw new Error(`Could not bind the plaintext gateway to the live document: ${JSON.stringify(transformProfile)}`); + } + const plaintextGatewayBody = { account: 'gateway-operator', password: 'gateway-plaintext-906' }; + const transformedRequest = await callBridge(bridgeSocket, 'verify-transform-request', 'browser.transform.execute', { + profileId: transformProfile.result.id, + direction: 'request', + packet: { + method: 'POST', + url: new URL('/crypto-lab/submit', testUrl).href, + headers: [{ name: 'Content-Type', value: 'application/json' }], + bodyBase64: Buffer.from(JSON.stringify(plaintextGatewayBody)).toString('base64'), + }, + }); + if (transformedRequest.error || transformedRequest.result?.profileId !== transformProfile.result.id) { + throw new Error(`Plaintext gateway request transform failed: ${JSON.stringify(transformedRequest)}`); + } + const wireEnvelope = JSON.parse(Buffer.from(transformedRequest.result.bodyBase64, 'base64').toString('utf8')); + if (!wireEnvelope.ciphertext || !wireEnvelope.signature || JSON.stringify(wireEnvelope).includes(plaintextGatewayBody.password)) { + throw new Error(`Plaintext gateway did not produce a real encrypted wire body: ${JSON.stringify(wireEnvelope)}`); + } + const wireServerResponse = await fetch(new URL('/crypto-lab/submit', testUrl), { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(wireEnvelope), + }).then((response) => response.json()); + if (!wireServerResponse.ciphertext || JSON.stringify(wireServerResponse).includes(plaintextGatewayBody.password)) { + throw new Error(`Real server did not accept the request and return an encrypted response: ${JSON.stringify(wireServerResponse)}`); + } + const transformedResponse = await callBridge(bridgeSocket, 'verify-transform-response', 'browser.transform.execute', { + profileId: transformProfile.result.id, + direction: 'response', + packet: { + method: 'POST', + url: new URL('/crypto-lab/submit', testUrl).href, + statusCode: 200, + headers: [{ name: 'Content-Type', value: 'application/json' }], + bodyBase64: Buffer.from(JSON.stringify(wireServerResponse)).toString('base64'), + }, + }); + const plaintextServerResponse = transformedResponse.error + ? undefined + : JSON.parse(Buffer.from(transformedResponse.result.bodyBase64, 'base64').toString('utf8')); + if (!plaintextServerResponse?.ok || plaintextServerResponse.password !== plaintextGatewayBody.password + || plaintextServerResponse.account !== plaintextGatewayBody.account) { + throw new Error(`Plaintext gateway did not restore the real encrypted server response: ${JSON.stringify({ transformedResponse, plaintextServerResponse })}`); + } + const rejectedTransformRoute = await callBridge(bridgeSocket, 'verify-transform-route-closed', 'browser.transform.execute', { + profileId: transformProfile.result.id, + direction: 'request', + packet: { + method: 'POST', + url: new URL('/api/session', testUrl).href, + headers: [], + bodyBase64: Buffer.from(JSON.stringify(plaintextGatewayBody)).toString('base64'), + }, + }); + if (rejectedTransformRoute.error?.code !== 'transform_route_mismatch') { + throw new Error(`Plaintext gateway did not fail closed on a mismatched route: ${JSON.stringify(rejectedTransformRoute)}`); + } + const rejectedTransformOrigin = await callBridge(bridgeSocket, 'verify-transform-origin-closed', 'browser.transform.execute', { + profileId: transformProfile.result.id, + direction: 'request', + packet: { + method: 'POST', + url: `http://localhost:${address.port}/crypto-lab/submit`, + headers: [], + bodyBase64: Buffer.from(JSON.stringify(plaintextGatewayBody)).toString('base64'), + }, + }); + if (rejectedTransformOrigin.error?.code !== 'transform_route_mismatch') { + throw new Error(`Path-only plaintext gateway route crossed the bound origin: ${JSON.stringify(rejectedTransformOrigin)}`); + } + const visibleTransformProfiles = await callBridge(bridgeSocket, 'verify-transform-profile-list', 'browser.transform.profile.list', { + tabId: targetTab.id, + }); + if (visibleTransformProfiles.error || !visibleTransformProfiles.result?.some((profile) => profile.id === transformProfile.result.id)) { + throw new Error(`Authorized transform profile was not visible to Yakit: ${JSON.stringify(visibleTransformProfiles)}`); + } + const callableExecutionA = await callBridge(bridgeSocket, 'verify-callable-execute-a', 'browser.callable.execute', { + tabId: targetTab.id, + callableId: capturedCallable.result.id, + args: ['deep-capture-replay-902', 'automation-a'], + }); + const callableExecutionB = await callBridge(bridgeSocket, 'verify-callable-execute-b', 'browser.callable.execute', { + tabId: targetTab.id, + callableId: capturedCallable.result.id, + args: ['deep-capture-replay-903', 'automation-b'], + }); + if (callableExecutionA.error || callableExecutionB.error + || callableExecutionA.result?.value?.nonce === callableExecutionB.result?.value?.nonce + || callableExecutionA.result?.value?.iv === callableExecutionB.result?.value?.iv) { + throw new Error(`Page callable did not preserve dynamic browser behavior: ${JSON.stringify({ callableExecutionA, callableExecutionB })}`); + } + const callableWireResponse = await fetch(new URL('/crypto-lab/submit', testUrl), { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(callableExecutionA.result.value), + }).then((response) => response.json()); + const callableValidation = await decryptCryptoLabResponse(callableWireResponse); + if (!callableValidation.ok || callableValidation.password !== 'deep-capture-replay-902' || callableValidation.account !== 'automation-a') { + throw new Error(`Captured page callable did not pass server validation: ${JSON.stringify(callableValidation)}`); + } + const detachedDeepCapture = await callBridge(bridgeSocket, 'verify-deep-capture-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + if (detachedDeepCapture.error || detachedDeepCapture.result?.state !== 'detached') { + throw new Error(`Deep capture did not detach cleanly: ${JSON.stringify(detachedDeepCapture)}`); + } + const deepRecordingSnapshot = await callBridge( + bridgeSocket, + 'verify-deep-recording-source', + 'browser.recording.get', + { tabId: targetTab.id, limit: 100 }, + ); + const filteredSource = [...(deepRecordingSnapshot.result?.events || [])].reverse() + .find((event) => event.kind === 'crypto' && event.crypto?.adapterId === 'webcrypto' + && event.crypto?.operation === 'encrypt' && event.scriptUrl && event.wrapperHandleId); + if (!filteredSource?.scriptUrl || !filteredSource.wrapperHandleId) { + throw new Error(`Deep capture did not retain a script source for filtered re-arm: ${JSON.stringify(deepRecordingSnapshot)}`); + } + await webPage.locator('#crypto-password').fill('deep-filter-match-904'); + const filteredCaptureStart = await callBridge(bridgeSocket, 'verify-deep-filter-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: filteredSource.crypto.adapterId, + operation: filteredSource.crypto.operation, + wrapperHandleId: filteredSource.wrapperHandleId, + scriptUrl: filteredSource.scriptUrl, + }, + }); + if (filteredCaptureStart.error || filteredCaptureStart.result?.state !== 'armed') { + throw new Error(`Script-filtered deep capture did not arm: ${JSON.stringify(filteredCaptureStart)}`); + } + await webPage.evaluate(() => setTimeout(() => document.querySelector('#crypto-submit').click(), 50)); + let filteredPause; + for (let attempt = 0; attempt < 60; attempt += 1) { + const next = await callBridge(bridgeSocket, `verify-deep-filter-status-${attempt}`, 'browser.deep_capture.status', { tabId: targetTab.id }); + if (next.result?.state === 'paused') { + filteredPause = next.result; + break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + if (!filteredPause) throw new Error(`Script-filtered deep capture did not pause at its source: ${filteredSource.scriptUrl}`); + const filteredResume = await callBridge(bridgeSocket, 'verify-deep-filter-resume', 'browser.deep_capture.resume', { tabId: targetTab.id }); + if (filteredResume.error) throw new Error(`Script-filtered deep capture did not resume: ${JSON.stringify(filteredResume)}`); + await webPage.locator('#crypto-result').getByText('deep-filter-match-904').waitFor(); + await callBridge(bridgeSocket, 'verify-deep-filter-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + + await webPage.locator('#crypto-password').fill('deep-filter-miss-905'); + const missedCaptureStart = await callBridge(bridgeSocket, 'verify-deep-filter-miss-start', 'browser.deep_capture.start', { + tabId: targetTab.id, + matcher: { + kind: 'crypto', + adapterId: filteredSource.crypto.adapterId, + operation: filteredSource.crypto.operation, + wrapperHandleId: filteredSource.wrapperHandleId, + scriptUrl: 'https://not-the-calling-script.invalid/no-match.js', + }, + }); + if (missedCaptureStart.error) throw new Error(`Non-matching deep capture did not arm: ${JSON.stringify(missedCaptureStart)}`); + await webPage.evaluate(() => setTimeout(() => document.querySelector('#crypto-submit').click(), 50)); + await webPage.locator('#crypto-result').getByText('deep-filter-miss-905').waitFor(); + const missedCaptureStatus = await callBridge(bridgeSocket, 'verify-deep-filter-miss-status', 'browser.deep_capture.status', { tabId: targetTab.id }); + if (missedCaptureStatus.error || missedCaptureStatus.result?.state !== 'armed') { + throw new Error(`A non-matching script incorrectly consumed the deep breakpoint: ${JSON.stringify(missedCaptureStatus)}`); + } + await callBridge(bridgeSocket, 'verify-deep-filter-miss-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + const idleRecorderDurationMs = await webPage.evaluate(() => { + const started = performance.now(); + let checksum = 0; + for (let index = 0; index < 1_000; index += 1) { + checksum += window.CryptoJS.SHA256(`idle-${index}`).sigBytes; + } + if (checksum !== 32_000) throw new Error('Idle recorder benchmark changed page return values'); + return performance.now() - started; + }); + const performanceRecordingStart = await callBridge( + bridgeSocket, + 'verify-recorder-performance-start', + 'browser.recording.start', + { tabId: targetTab.id, captureValues: false, maxEntries: 20 }, + ); + if (performanceRecordingStart.error) { + throw new Error(`Recorder performance session did not start: ${JSON.stringify(performanceRecordingStart)}`); + } + const recorderPerformance = await webPage.evaluate(() => { + const smallStarted = performance.now(); + let checksum = 0; + for (let index = 0; index < 1_000; index += 1) { + checksum += window.CryptoJS.SHA256(`active-${index}`).sigBytes; + } + const smallCallsMs = performance.now() - smallStarted; + const largePayload = 'x'.repeat(1024 * 1024); + const largeStarted = performance.now(); + for (let index = 0; index < 10; index += 1) { + checksum += window.CryptoJS.SHA256(largePayload).sigBytes; + } + const largeCallsMs = performance.now() - largeStarted; + const oversizedPayload = 'y'.repeat(3 * 1024 * 1024); + checksum += window.CryptoJS.SHA256(oversizedPayload).sigBytes; + if (checksum !== 32_352) throw new Error('Active recorder benchmark changed page return values'); + return { smallCallsMs, largeCallsMs }; + }); + const performanceSnapshot = await callBridge( + bridgeSocket, + 'verify-recorder-performance-get', + 'browser.recording.get', + { tabId: targetTab.id, limit: 20 }, + ); + const oversizedEvent = performanceSnapshot.result?.events?.find((item) => item.byteLength === 3 * 1024 * 1024); + if (performanceSnapshot.error || performanceSnapshot.result?.status?.count !== 20 + || performanceSnapshot.result?.status?.droppedCount < 991 + || oversizedEvent?.callHandleId || oversizedEvent?.callableCapable) { + throw new Error(`Recorder budgets were not enforced under load: ${JSON.stringify({ + status: performanceSnapshot.result?.status, + oversizedEvent, + })}`); + } + if (recorderPerformance.smallCallsMs > 3_000 || recorderPerformance.largeCallsMs > 8_000) { + throw new Error(`Recorder performance gate exceeded: ${JSON.stringify({ idleRecorderDurationMs, ...recorderPerformance })}`); + } + const performanceRecordingStop = await callBridge( + bridgeSocket, + 'verify-recorder-performance-stop', + 'browser.recording.stop', + { tabId: targetTab.id }, + ); + if (performanceRecordingStop.error || performanceRecordingStop.result?.status?.active) { + throw new Error(`Recorder performance session did not stop cleanly: ${JSON.stringify(performanceRecordingStop)}`); } - await callBridge(bridgeSocket, 'verify-observation-stop-sensitive', 'browser.observe.stop', { tabId: targetTab.id }); const observerRestored = await webPage.evaluate(() => ({ fetch: window.fetch === window.__yakitObserverOriginals.fetch, xhrOpen: XMLHttpRequest.prototype.open === window.__yakitObserverOriginals.xhrOpen, xhrSend: XMLHttpRequest.prototype.send === window.__yakitObserverOriginals.xhrSend, webSocket: window.WebSocket === window.__yakitObserverOriginals.webSocket, + worker: window.Worker === window.__yakitObserverOriginals.worker, + workerPostMessage: window.Worker?.prototype.postMessage === window.__yakitObserverOriginals.workerPostMessage, + messageChannel: window.MessageChannel === window.__yakitObserverOriginals.messageChannel, + messagePortPostMessage: window.MessagePort?.prototype.postMessage === window.__yakitObserverOriginals.messagePortPostMessage, + sendBeacon: Navigator.prototype.sendBeacon === window.__yakitObserverOriginals.sendBeacon, digest: Object.getPrototypeOf(crypto.subtle).digest === window.__yakitObserverOriginals.digest, cryptoJs: window.CryptoJS.SHA256 === window.__yakitObserverOriginals.cryptoJsSha256, + jsencrypt: window.JSEncrypt.prototype.encrypt === window.__yakitObserverOriginals.jsencryptEncrypt, + sm2: window.sm2.doEncrypt === window.__yakitObserverOriginals.sm2Encrypt, + sm3: window.sm3 === window.__yakitObserverOriginals.sm3, + sm4: window.sm4.encrypt === window.__yakitObserverOriginals.sm4Encrypt, + forgeCipher: window.forge.cipher.createCipher === window.__yakitObserverOriginals.forgeCreateCipher, + forgePki: window.forge.pki.publicKeyFromPem === window.__yakitObserverOriginals.forgePublicKeyFromPem, + forgeDigest: window.forge.md.sha256.create === window.__yakitObserverOriginals.forgeSha256Create, + forgeHmac: window.forge.hmac.create === window.__yakitObserverOriginals.forgeHmacCreate, })); - if (Object.values(observerRestored).some((value) => !value)) throw new Error(`Page APIs were not restored after observation: ${JSON.stringify(observerRestored)}`); + if (Object.values(observerRestored).some((value) => !value)) throw new Error(`Page APIs were not restored after recording: ${JSON.stringify(observerRestored)}`); const insecurePage = await context.newPage(); await insecurePage.goto(insecureTestUrl); @@ -826,14 +2159,14 @@ try { cryptoJsSha256: window.CryptoJS.SHA256, }; }); - const insecureObservationStart = await options.evaluate(async (tabId) => { + const insecureRecordingStart = await options.evaluate(async (tabId) => { return await chrome.runtime.sendMessage({ - action: 'observation.start', + action: 'recording.start', payload: { tabId, captureValues: false, maxEntries: 100 }, }); }, insecureTab.id); - if (!insecureObservationStart?.ok || insecureObservationStart.data?.active !== true) { - throw new Error(`Insecure HTTP observation did not start: ${JSON.stringify(insecureObservationStart)}`); + if (!insecureRecordingStart?.ok || insecureRecordingStart.data?.status?.active !== true) { + throw new Error(`Insecure HTTP recording did not start: ${JSON.stringify(insecureRecordingStart)}`); } const insecureOriginalResults = await insecurePage.evaluate(async (socketPort) => { const cryptoJs = window.CryptoJS.SHA256('insecure-cryptojs-value').toString(); @@ -860,20 +2193,20 @@ try { if (insecureOriginalResults.cryptoJs !== 'insecure-sha256:insecure-cryptojs-value' || insecureOriginalResults.fetchStatus !== 200 || insecureOriginalResults.webSocket !== 'echo:insecure-websocket-value') { - throw new Error(`Observation changed insecure page behavior: ${JSON.stringify(insecureOriginalResults)}`); + throw new Error(`Recording changed insecure page behavior: ${JSON.stringify(insecureOriginalResults)}`); } - const insecureObservations = await options.evaluate(async (tabId) => { - return await chrome.runtime.sendMessage({ action: 'observation.list', payload: { tabId, limit: 100 } }); + const insecureRecording = await options.evaluate(async (tabId) => { + return await chrome.runtime.sendMessage({ action: 'recording.get', payload: { tabId, limit: 100 } }); }, insecureTab.id); - if (!insecureObservations?.ok) throw new Error(`Could not read insecure HTTP observations: ${JSON.stringify(insecureObservations)}`); - const insecureKinds = new Set(insecureObservations.data.map((item) => item.kind)); - for (const kind of ['fetch', 'xhr', 'websocket', 'cryptojs']) { - if (!insecureKinds.has(kind)) throw new Error(`Insecure HTTP observation missed ${kind}: ${JSON.stringify(insecureObservations)}`); + if (!insecureRecording?.ok) throw new Error(`Could not read insecure HTTP recording: ${JSON.stringify(insecureRecording)}`); + const insecureKinds = new Set(insecureRecording.data.events.map((item) => item.kind)); + for (const kind of ['fetch', 'xhr', 'websocket', 'crypto']) { + if (!insecureKinds.has(kind)) throw new Error(`Insecure HTTP recording missed ${kind}: ${JSON.stringify(insecureRecording)}`); } - const insecureObservationStop = await options.evaluate(async (tabId) => { - return await chrome.runtime.sendMessage({ action: 'observation.stop', payload: { tabId } }); + const insecureRecordingStop = await options.evaluate(async (tabId) => { + return await chrome.runtime.sendMessage({ action: 'recording.stop', payload: { tabId } }); }, insecureTab.id); - if (!insecureObservationStop?.ok) throw new Error(`Could not stop insecure HTTP observation: ${JSON.stringify(insecureObservationStop)}`); + if (!insecureRecordingStop?.ok) throw new Error(`Could not stop insecure HTTP recording: ${JSON.stringify(insecureRecordingStop)}`); const insecureRestored = await insecurePage.evaluate(() => ({ fetch: window.fetch === window.__yakitInsecureObserverOriginals.fetch, xhrOpen: XMLHttpRequest.prototype.open === window.__yakitInsecureObserverOriginals.xhrOpen, @@ -1054,7 +2387,273 @@ try { if (!(key in runtimeAndStorage.session)) throw new Error(`Split session storage key is missing: ${key}`); } + const automaticCaptureStartedAt = Date.now(); + const automaticCaptureRecording = await callBridge( + bridgeSocket, + 'verify-automatic-business-capture-recording-start', + 'browser.recording.start', + { tabId: targetTab.id, captureValues: true, maxEntries: 80 }, + ); + if (automaticCaptureRecording.error) { + throw new Error(`Could not start the automatic business capture recording: ${JSON.stringify(automaticCaptureRecording)}`); + } + await webPage.locator('#crypto-password').fill('automatic-capture-seed-951'); + await webPage.locator('#crypto-submit').click(); + await webPage.locator('#crypto-result').getByText('automatic-capture-seed-951').waitFor(); + const automaticCaptureSnapshot = await callBridge( + bridgeSocket, + 'verify-automatic-business-capture-recording-get', + 'browser.recording.get', + { tabId: targetTab.id, limit: 80 }, + ); + const automaticCandidate = automaticCaptureSnapshot.result?.profileCandidates?.find((candidate) => ( + candidate.status === 'capture-required' + && candidate.sources?.length >= 2 + && candidate.request?.url?.includes('/crypto-lab/submit') + )); + const commonBusinessHint = automaticCandidate?.capturePlan?.frameHints?.find((hint) => ( + hint.functionName === 'buildLoginEnvelope' + )); + if (!automaticCandidate || automaticCandidate.capturePlan?.matcherEventId !== automaticCandidate.source.eventId + || !commonBusinessHint || commonBusinessHint.support < 2) { + throw new Error(`Multi-call inference did not identify a common business ancestor: ${JSON.stringify(automaticCaptureSnapshot)}`); + } + await options.getByRole('button', { name: '网络活动' }).click(); + await options.locator('#recording-mode-tab').click(); + await options.getByRole('button', { name: '刷新录制', exact: true }).click(); + const automaticInference = options.locator('.profile-inference').filter({ hasText: '/crypto-lab/submit' }); + await automaticInference.waitFor(); + await automaticInference.getByRole('button', { name: '自动捕获完整加密流程', exact: true }).click(); + await options.locator('#deep-mode-tab[aria-selected="true"]').waitFor(); + await options.getByText('等待目标页面命中', { exact: true }).waitFor(); + + await webPage.locator('#crypto-password').fill('automatic-capture-replay-952'); + await webPage.locator('#crypto-submit').click(); + await options.locator('#gateway-mode-tab[aria-selected="true"]').waitFor({ timeout: 30_000 }); + const automaticGateway = options.locator('.transform-guide'); + await automaticGateway.waitFor(); + if (await automaticGateway.getByLabel('输出形态').inputValue() !== 'body') { + throw new Error('Automatic multi-call capture did not generate a whole-envelope gateway'); + } + const automaticInputs = automaticGateway.locator('.transform-guide-inputs > div'); + if (await automaticInputs.count() !== 2 + || await automaticInputs.nth(0).locator('select').inputValue() !== 'body-field' + || await automaticInputs.nth(0).locator('input').inputValue() !== 'password' + || await automaticInputs.nth(1).locator('select').inputValue() !== 'body-field' + || await automaticInputs.nth(1).locator('input').inputValue() !== 'account') { + throw new Error('Automatic business capture did not create parameter-level input bindings'); + } + const automaticReplayBody = JSON.parse(await options.getByLabel('回放 Body').inputValue()); + if (automaticReplayBody.password !== 'automatic-capture-replay-952' || automaticReplayBody.account !== 'analyst') { + throw new Error(`Automatic business capture did not preserve its paused local sample: ${JSON.stringify(automaticReplayBody)}`); + } + const automaticSampleLabel = await options.locator('.transform-test-field-label em').getAttribute('title'); + if (!automaticSampleLabel?.includes('buildLoginEnvelope')) { + throw new Error(`Automatic business capture did not identify the replay sample source: ${automaticSampleLabel}`); + } + const automaticCallables = await callBridge( + bridgeSocket, + 'verify-automatic-business-capture-callables', + 'browser.callable.list', + { tabId: targetTab.id }, + ); + const automaticCallable = automaticCallables.result?.findLast((callable) => ( + callable.kind === 'business-closure' + && callable.provenance?.functionName === 'buildLoginEnvelope' + && callable.createdAt >= automaticCaptureStartedAt + )); + if (automaticCallables.error || !automaticCallable?.id || automaticCallable.inputSlots?.length !== 2 + || automaticCallable.inputSlots[0]?.name !== 'password' || automaticCallable.inputSlots[1]?.name !== 'account' + || automaticCallable.operation !== 'buildLoginEnvelope') { + throw new Error(`One-click capture did not retain the complete business callable: ${JSON.stringify(automaticCallables)}`); + } + await options.locator('.transform-editor-actions').getByRole('button', { name: '保存', exact: true }).click(); + const executeAutomaticPipeline = options.getByRole('button', { name: '执行 Pipeline', exact: true }); + await executeAutomaticPipeline.waitFor({ state: 'visible' }); + await executeAutomaticPipeline.click(); + await options.getByText('转换完成', { exact: true }).waitFor(); + const automaticLogicalOutput = JSON.parse(await options.locator('.transform-test-result pre').textContent()); + if (!automaticLogicalOutput.body?.ciphertext || !automaticLogicalOutput.body?.signature || !automaticLogicalOutput.body?.iv) { + throw new Error(`Automatic business gateway did not execute the complete page closure: ${JSON.stringify(automaticLogicalOutput)}`); + } + const automaticProfiles = await callBridge( + bridgeSocket, + 'verify-automatic-business-profile-list', + 'browser.transform.profile.list', + { tabId: targetTab.id }, + ); + const automaticProfile = automaticProfiles.result?.find((profile) => ( + profile.createdAt >= automaticCaptureStartedAt + && profile.request?.nodes?.some((node) => node.kind === 'page.call' && node.callableId === automaticCallable.id) + )); + if (automaticProfiles.error || !automaticProfile?.id) { + throw new Error(`Automatic business gateway was not persisted for execution: ${JSON.stringify(automaticProfiles)}`); + } + const removedAutomaticProfile = await callBridge( + bridgeSocket, + 'verify-automatic-business-profile-delete', + 'browser.transform.profile.delete', + { id: automaticProfile.id }, + ); + if (removedAutomaticProfile.error || removedAutomaticProfile.result?.some((profile) => profile.id === automaticProfile.id)) { + throw new Error(`Automatic business gateway test profile was not removed: ${JSON.stringify(removedAutomaticProfile)}`); + } + await webPage.locator('#crypto-result').getByText('automatic-capture-replay-952').waitFor(); + await options.screenshot({ path: resolve(artifacts, 'options-automatic-business-capture.png') }); + await callBridge(bridgeSocket, 'verify-automatic-business-recording-stop', 'browser.recording.stop', { tabId: targetTab.id }); + await callBridge(bridgeSocket, 'verify-automatic-business-capture-detach', 'browser.deep_capture.detach', { tabId: targetTab.id }); + + const guidedFormRecordingStart = await callBridge(bridgeSocket, 'verify-guided-form-recording-start', 'browser.recording.start', { + tabId: targetTab.id, + captureValues: true, + maxEntries: 40, + }); + if (guidedFormRecordingStart.error) { + throw new Error(`Could not start the guided form recording: ${JSON.stringify(guidedFormRecordingStart)}`); + } + await webPage.evaluate(async () => { + const plaintext = JSON.stringify({ username: 'admin', password: '123456' }); + const encryptedData = window.__yakitRsa.encrypt(plaintext); + const response = await fetch('/encrypt/rsa.php?source=guided-rsa-profile', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: new URLSearchParams({ data: encryptedData }), + }); + const validation = await response.json(); + if (!validation.ok || validation.plaintext !== plaintext) { + throw new Error(`Guided RSA request was not accepted by the independent server: ${JSON.stringify(validation)}`); + } + }); + const guidedFormRecording = await callBridge(bridgeSocket, 'verify-guided-form-recording-get', 'browser.recording.get', { + tabId: targetTab.id, + limit: 40, + }); + const guidedFormCandidate = guidedFormRecording.result?.profileCandidates?.find((candidate) => ( + candidate.status === 'ready' + && candidate.request?.serialization === 'form-field' + && candidate.request?.destination === 'body.data' + && candidate.source?.crypto?.adapterId === 'jsencrypt' + )); + if (!guidedFormCandidate?.source?.eventId) { + throw new Error(`Dedicated recording did not produce a guided form candidate: ${JSON.stringify(guidedFormRecording)}`); + } + + await options.getByRole('button', { name: '网络活动' }).click(); + await options.locator('#gateway-mode-tab').click(); + const transformWorkbench = options.locator('.transform-workbench'); + await transformWorkbench.waitFor(); + await options.getByText('E2E login plaintext gateway', { exact: true }).waitFor(); + const transformBounds = await transformWorkbench.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (transformBounds.scrollWidth > transformBounds.clientWidth) { + throw new Error(`Plaintext gateway workbench overflowed horizontally: ${JSON.stringify(transformBounds)}`); + } + await options.screenshot({ path: resolve(artifacts, 'options-browser-transform-gateway.png') }); + await options.locator('#recording-mode-tab').click(); + await options.getByRole('button', { name: '刷新录制', exact: true }).click(); + const recordingWorkbench = options.locator('.recording-workbench'); + await recordingWorkbench.waitFor(); + await recordingWorkbench.scrollIntoViewIfNeeded(); + const recordingBounds = await recordingWorkbench.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (recordingBounds.scrollWidth > recordingBounds.clientWidth) { + throw new Error(`Recording workbench overflowed horizontally: ${JSON.stringify(recordingBounds)}`); + } + const inferredProfilePanel = options.locator('.profile-inference'); + await inferredProfilePanel.waitFor(); + const inferredProfileHeading = await inferredProfilePanel.locator('.profile-inference__heading strong').innerText(); + if (!/JSEncrypt/.test(inferredProfileHeading) || /base64\.encode/i.test(inferredProfileHeading)) { + throw new Error(`Profile inference promoted an encoding helper instead of the crypto source: ${inferredProfileHeading}`); + } + await inferredProfilePanel.getByText(/高置信度/).waitFor(); + await options.getByText('验证页面函数', { exact: true }).waitFor(); + await options.screenshot({ path: resolve(artifacts, 'options-browser-recording.png') }); + await options.locator(`[data-event-id="${guidedFormCandidate.source.eventId}"]`).click(); + const formInferencePanel = options.locator('.profile-inference'); + await formInferencePanel.locator('.profile-inference__heading strong').getByText(/body\.data/).waitFor(); + await options.getByRole('button', { name: '停止录制并保存', exact: true }).click(); + const createRecordedCallable = options.getByRole('button', { name: '创建', exact: true }); + await createRecordedCallable.waitFor(); + await options.waitForTimeout(500); + await createRecordedCallable.click(); + await options.getByText('验证页面函数', { exact: true }).waitFor(); + const guidedCallableList = await callBridge( + bridgeSocket, + 'verify-guided-callable-list', + 'browser.callable.list', + { tabId: targetTab.id }, + ); + const guidedCallableForDelete = guidedCallableList.result?.find((callable) => ( + callable.provenance?.eventId === guidedFormCandidate.source.eventId + )); + if (guidedCallableList.error || !guidedCallableForDelete?.name) { + throw new Error(`The UI-created page callable was not retained: ${JSON.stringify(guidedCallableList)}`); + } + if (guidedCallableForDelete.crypto?.adapterId !== 'jsencrypt' + || guidedCallableForDelete.crypto?.key?.bits !== 1024 + || guidedCallableForDelete.crypto?.padding !== 'PKCS1-v1_5') { + throw new Error(`The UI-created callable lost its RSA adapter metadata: ${JSON.stringify(guidedCallableForDelete)}`); + } + const guidedRsaReplay = await callBridge(bridgeSocket, 'verify-guided-rsa-replay', 'browser.callable.execute', { + tabId: targetTab.id, + callableId: guidedCallableForDelete.id, + args: [{ username: 'guided-rsa-admin', password: 'guided-rsa-password' }], + }); + const guidedRsaReplayPlaintext = guidedRsaReplay.result?.value + ? decryptRSALabValue(guidedRsaReplay.result.value) + : undefined; + const guidedRsaReplayPayload = guidedRsaReplayPlaintext ? JSON.parse(guidedRsaReplayPlaintext) : undefined; + if (guidedRsaReplay.error + || guidedRsaReplayPayload?.username !== 'guided-rsa-admin' + || guidedRsaReplayPayload?.password !== 'guided-rsa-password') { + throw new Error(`The UI-created RSA callable could not replay through its retained receiver: ${JSON.stringify({ guidedRsaReplay, guidedRsaReplayPlaintext, guidedRsaReplayPayload })}`); + } + await formInferencePanel.getByRole('button', { name: '生成明文网关', exact: true }).click(); + const guidedGateway = options.locator('.transform-guide'); + await guidedGateway.waitFor(); + if (await guidedGateway.getByLabel('输出形态').inputValue() !== 'form-field' + || await guidedGateway.getByLabel('表单字段名').inputValue() !== 'data') { + throw new Error('Form evidence was not compiled into a guided form-field gateway'); + } + if (await options.getByLabel('URL 模式').inputValue() !== '*/encrypt/rsa.php' + || !await options.getByLabel('回放请求 URL').inputValue().then((value) => value.startsWith(`${new URL(testUrl).origin}/encrypt/rsa.php?`))) { + throw new Error('Relative request evidence was not resolved against the current page'); + } + await guidedGateway.getByText('自动设置表单 Content-Type', { exact: true }).waitFor(); + if (await options.locator('.transform-node-list').isVisible()) { + throw new Error('Guided gateway leaked the advanced DAG editor'); + } + const replayBody = await options.getByLabel('回放 Body').inputValue(); + if (JSON.stringify(JSON.parse(replayBody)) !== JSON.stringify({ username: 'admin', password: '123456' })) { + throw new Error(`Recorded short sample was not carried into local replay: ${replayBody}`); + } + await options.locator('.transform-test-field-label').getByText('短时样本', { exact: true }).waitFor(); + await options.screenshot({ path: resolve(artifacts, 'options-browser-transform-guided-rsa.png') }); + await options.locator('.transform-callable-menu > summary').click(); + const disposableCallable = options.locator('.transform-callable-list section').filter({ hasText: guidedCallableForDelete.name }); + await disposableCallable.getByRole('button', { name: `删除 ${guidedCallableForDelete.name}`, exact: true }).click(); + await disposableCallable.getByRole('button', { name: '确认删除', exact: true }).click(); + await disposableCallable.waitFor({ state: 'detached' }); + await options.screenshot({ path: resolve(artifacts, 'options-browser-transform-callables.png') }); + await options.locator('#recording-mode-tab').click(); + await options.locator('#deep-mode-tab').click(); + const deepCaptureWorkspace = options.locator('.deep-capture'); + await deepCaptureWorkspace.waitFor(); + await deepCaptureWorkspace.getByText('E2E login envelope', { exact: true }).waitFor(); + const deepCaptureBounds = await deepCaptureWorkspace.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (deepCaptureBounds.scrollWidth > deepCaptureBounds.clientWidth) { + throw new Error(`Deep capture workbench overflowed horizontally: ${JSON.stringify(deepCaptureBounds)}`); + } + await options.screenshot({ path: resolve(artifacts, 'options-deep-capture.png') }); + await options.locator('#recording-mode-tab').click(); await options.locator('.network-control-bar .ui-switch').nth(0).click(); await options.locator('.network-control-bar .ui-switch').nth(1).click(); await options.getByRole('button', { name: '开始捕获' }).click(); @@ -1093,10 +2692,10 @@ try { const pocPacket = Buffer.from(pocMessage.params?.rawRequestBase64 || '', 'base64').toString('utf8'); if (!pocPacket.includes('network-sensitive-e2e-771')) throw new Error(`Yak PoC generation did not receive the captured request: ${pocPacket}`); await options.getByText('browser-e2e.yak', { exact: true }).waitFor(); - await options.getByRole('button', { name: '分析' }).click(); + await options.getByRole('button', { name: '分析', exact: true }).click(); const analysisMessage = await analysisPrepareRequest; - if (JSON.stringify(analysisMessage.params?.observations || []).includes('observer-sensitive-preview-686')) { - throw new Error('AI analysis payload included a sensitive observation preview'); + if (JSON.stringify(analysisMessage.params?.observations || []).includes('recorder-sensitive-preview-686')) { + throw new Error('AI analysis payload included a sensitive recording preview'); } await options.getByText('AI 分析上下文', { exact: true }).waitFor(); await options.screenshot({ path: resolve(artifacts, 'options-network-analysis.png') }); @@ -1156,7 +2755,7 @@ try { const mobileOptions = await context.newPage(); await mobileOptions.setViewportSize({ width: 390, height: 844 }); - await mobileOptions.goto(`chrome-extension://${extensionId}/options.html#overview`); + await mobileOptions.goto(`chrome-extension://${extensionId}/options.html?tabId=${targetTab.id}#overview`); await mobileOptions.locator('.app-shell').waitFor(); await mobileOptions.waitForTimeout(300); const mobileOverflow = await mobileOptions.evaluate(() => document.documentElement.scrollWidth - innerWidth); @@ -1167,6 +2766,27 @@ try { const mobileNetworkOverflow = await mobileOptions.evaluate(() => document.documentElement.scrollWidth - innerWidth); if (mobileNetworkOverflow > 0) throw new Error(`Network mobile layout overflows by ${mobileNetworkOverflow}px`); await mobileOptions.screenshot({ path: resolve(artifacts, 'options-network-mobile.png'), fullPage: true }); + await mobileOptions.locator('#gateway-mode-tab').click(); + const mobileTransformWorkbench = mobileOptions.locator('.transform-workbench'); + await mobileTransformWorkbench.waitFor(); + await mobileOptions.getByText('E2E login plaintext gateway', { exact: true }).waitFor(); + await mobileOptions.waitForTimeout(250); + const mobileTransformBounds = await mobileTransformWorkbench.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + viewportOverflow: document.documentElement.scrollWidth - innerWidth, + })); + if (mobileTransformBounds.viewportOverflow > 0 || mobileTransformBounds.scrollWidth > mobileTransformBounds.clientWidth) { + throw new Error(`Plaintext gateway mobile layout overflowed: ${JSON.stringify(mobileTransformBounds)}`); + } + await mobileOptions.screenshot({ path: resolve(artifacts, 'options-browser-transform-gateway-mobile.png'), fullPage: true }); + await mobileOptions.locator('#deep-mode-tab').click(); + await mobileOptions.locator('.deep-capture').waitFor(); + await mobileOptions.waitForTimeout(250); + const mobileDeepOverflow = await mobileOptions.evaluate(() => document.documentElement.scrollWidth - innerWidth); + if (mobileDeepOverflow > 0) throw new Error(`Deep capture mobile layout overflows by ${mobileDeepOverflow}px`); + await mobileOptions.screenshot({ path: resolve(artifacts, 'options-deep-capture-mobile.png'), fullPage: true }); + await mobileOptions.locator('#recording-mode-tab').click(); await mobileOptions.getByRole('button', { name: '登录态工作区' }).click(); await mobileOptions.getByRole('button', { name: '采集页面' }).click(); await mobileOptions.locator('.context-session-strip').waitFor(); @@ -1324,6 +2944,111 @@ try { if (!beforeRestart.grantId || afterRestart.grantId !== beforeRestart.grantId || afterRestart.taskId !== beforeRestart.taskId || afterRestart.runtime.grantId !== beforeRestart.runtime.grantId) { throw new Error(`Service Worker restart lost grant/task runtime: ${JSON.stringify({ beforeRestart, afterRestart })}`); } + + await webPage.goto(testUrl); + await webPage.locator('#crypto-lab[data-ready="true"]').waitFor(); + await webPage.evaluate(() => { + window.CryptoJS = { + SHA256(value) { + return { sigBytes: 32, toString: () => `sha256:${value}` }; + }, + }; + }); + const navigationRecordingStart = await options.evaluate(async ({ tabId }) => { + return await chrome.runtime.sendMessage({ + action: 'recording.start', + payload: { tabId, frameId: 0, captureValues: true, maxEntries: 40, maxValueBytes: 8_192 }, + }); + }, { tabId: targetTab.id }); + if (!navigationRecordingStart?.ok || navigationRecordingStart.data?.status?.active !== true) { + throw new Error(`Could not start the navigation recording: ${JSON.stringify(navigationRecordingStart)}`); + } + await webPage.evaluate(() => { + window.CryptoJS.SHA256(JSON.stringify({ username: 'redirect-admin', password: 'redirect-secret' })); + }); + const navigationSnapshotBefore = await options.evaluate(async ({ tabId }) => { + return await chrome.runtime.sendMessage({ action: 'recording.get', payload: { tabId, frameId: 0, limit: 40 } }); + }, { tabId: targetTab.id }); + if (!navigationSnapshotBefore?.ok || !JSON.stringify(navigationSnapshotBefore.data).includes('redirect-admin')) { + throw new Error(`Navigation recording did not cache the short sample: ${JSON.stringify(navigationSnapshotBefore)}`); + } + const recordingCompleteUrl = `${new URL(testUrl).origin}/recording-complete`; + await webPage.goto(recordingCompleteUrl); + await webPage.getByRole('heading', { name: 'Recording Complete Dashboard' }).waitFor(); + + let navigationSession; + for (let attempt = 0; attempt < 40; attempt += 1) { + const response = await options.evaluate(async ({ tabId }) => { + return await chrome.runtime.sendMessage({ action: 'recording.get', payload: { tabId, frameId: 0, limit: 40 } }); + }, { tabId: targetTab.id }); + const navigationEvent = response?.data?.events?.find((item) => ( + item.kind === 'navigation' && item.navigation?.toUrl === recordingCompleteUrl + )); + if (response?.ok + && response.data?.status?.active === true + && response.data?.status?.documentAvailable === true + && ['committed', 'completed'].includes(navigationEvent?.navigation?.phase)) { + navigationSession = response.data; + break; + } + await options.waitForTimeout(100); + } + const recordedNavigation = navigationSession?.events?.find((item) => ( + item.kind === 'navigation' && item.navigation?.toUrl === recordingCompleteUrl + )); + if (navigationSession?.status?.navigation?.toUrl !== recordingCompleteUrl + || navigationSession.status.target?.tabId !== targetTab.id + || !recordedNavigation + || !JSON.stringify(navigationSession.events).includes('redirect-admin')) { + throw new Error(`Recording did not continue across a same-tab navigation: ${JSON.stringify(navigationSession)}`); + } + + await options.getByRole('button', { name: '网络活动' }).click(); + await options.locator('#recording-mode-tab').click(); + await options.getByRole('button', { name: '刷新录制', exact: true }).click(); + await options.locator('.recording-pipeline-step.is-navigation').waitFor(); + await options.getByText('页面跳转', { exact: true }).first().waitFor(); + await options.getByText(/最早 ↓ 最新/).waitFor(); + await options.waitForFunction(({ tabId }) => { + const select = document.querySelector('[aria-label="目标标签页"]'); + return select instanceof HTMLSelectElement + && select.value === String(tabId) + && select.selectedOptions[0]?.textContent === 'Recording Complete Dashboard'; + }, { tabId: targetTab.id }); + if (Number(await options.getByLabel('目标标签页').inputValue()) !== targetTab.id) { + throw new Error('Same-tab navigation changed the selected browser target'); + } + await options.screenshot({ path: resolve(artifacts, 'options-browser-recording-navigation.png') }); + + await webPage.goBack(); + await webPage.locator('#crypto-lab[data-ready="true"]').waitFor(); + let restoredRecording; + let lastRestoredRecordingResponse; + for (let attempt = 0; attempt < 40; attempt += 1) { + const response = await options.evaluate(async ({ tabId }) => { + return await chrome.runtime.sendMessage({ action: 'recording.get', payload: { tabId, frameId: 0, limit: 40 } }); + }, { tabId: targetTab.id }); + lastRestoredRecordingResponse = response; + const navigationEvents = response?.data?.events?.filter((item) => item.kind === 'navigation') || []; + if (response?.ok + && response.data?.status?.active === true + && response.data?.status?.documentAvailable === true + && navigationEvents.length >= 2 + && response.data.status.navigation?.kind === 'back-forward') { + restoredRecording = response.data; + break; + } + await options.waitForTimeout(100); + } + if (!restoredRecording || !JSON.stringify(restoredRecording.events).includes('redirect-admin')) { + throw new Error(`Browser Back did not restore the active recording session: ${JSON.stringify(lastRestoredRecordingResponse)}`); + } + await options.getByRole('button', { name: '刷新录制', exact: true }).click(); + await options.waitForFunction(() => document.querySelectorAll('.recording-traces button').length >= 2); + await options.locator('.recording-traces button').last().click(); + await options.locator('.recording-pipeline-step.is-navigation').waitFor(); + await options.screenshot({ path: resolve(artifacts, 'options-browser-recording-restored.png') }); + const unpairedIdentity = await options.evaluate(async () => { const before = await chrome.runtime.sendMessage({ action: 'state.get' }); const unpaired = await chrome.runtime.sendMessage({ action: 'bridge.unpair' }); @@ -1344,7 +3069,7 @@ try { } if (browserErrors.length > 0) throw new Error(`Browser page errors:\n${browserErrors.join('\n')}`); - console.log(JSON.stringify({ extensionId, testUrl, evalResult: evalResponse.data, timeoutError: timeoutResponse.error, bridgeEval: bridgeEval.result, cancelledBridgeError: cancelledBridgeEval.error, handoffEvent, auditEventCount: networkAudit.length, capturedNetworkUrl: capturedRequest.url, fuzzerPageId: 'e2e-fuzzer-page', protocolChecks, staleDocumentError: staleDocumentEval.error, staleOriginError: staleOriginEval.error?.message, serviceWorkerRestart: { beforeRestart, afterRestart }, unpairedIdentity, rightMetrics, panelMetrics, narrowPanel, artifacts }, null, 2)); + console.log(JSON.stringify({ extensionId, testUrl, evalResult: evalResponse.data, timeoutError: timeoutResponse.error, bridgeEval: bridgeEval.result, cancelledBridgeError: cancelledBridgeEval.error, handoffEvent, auditEventCount: networkAudit.length, capturedNetworkUrl: capturedRequest.url, fuzzerPageId: 'e2e-fuzzer-page', protocolChecks, staleDocumentError: staleDocumentEval.error, staleOriginError: staleOriginEval.error?.message, serviceWorkerRestart: { beforeRestart, afterRestart }, unpairedIdentity, recorderPerformance: { idleCallsMs: idleRecorderDurationMs, ...recorderPerformance }, rightMetrics, panelMetrics, narrowPanel, artifacts }, null, 2)); } finally { await context?.close(); server.close(); diff --git a/src/app/background/index.ts b/src/app/background/index.ts index 7277b8d..02c111d 100644 --- a/src/app/background/index.ts +++ b/src/app/background/index.ts @@ -5,13 +5,23 @@ import { } from '@/features/network-capture/service'; import { capturedRequestEnginePayload } from '@/features/network-capture/workflows'; import { - clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation, - stopPageObservation, stopPageObservationsForGrant, -} from '@/features/page-observation/service'; + browserRecordingStatus, clearBrowserRecording, createRecordedPageCallable, getBrowserRecording, startBrowserRecording, + stopBrowserRecording, stopBrowserRecordingsForGrant, +} from '@/features/browser-recording/service'; +import { + createCapturedPageCallable, deepCaptureStatus, detachDeepCapture, + initializeDeepCaptureService, keepDeepCaptureAlive, + resumeDeepCapture, startDeepCapture, stopDeepCapturesForGrant, +} from '@/features/deep-capture/service'; +import { deletePageCallable, executePageCallable, listPageCallables } from '@/features/page-callable/service'; +import { + deleteBrowserTransformProfile, executeBrowserTransform, listBrowserTransformProfiles, + saveBrowserTransformProfile, +} from '@/features/browser-transform/service'; import type { ExtensionRequest, ExtensionResponse } from '@/types/messages'; import { parseExtensionRequest } from '@/protocol/extension'; import type { - BridgeGrantTarget, BrowserRequestAnalysisBundle, BrowserTarget, YakPocGenerateResult, YakitFuzzerOpenResult, + BridgeGrantTarget, BrowserRequestAnalysisBundle, BrowserTarget, UserAgentProfile, YakPocGenerateResult, YakitFuzzerOpenResult, } from '@/types/models'; import { engineBridge } from '@/features/engine-bridge/service'; import { getFrameInventory } from '@/features/page-context/frames'; @@ -22,11 +32,16 @@ import { import { listCookies, removeCookie, setCookie } from '@/features/cookies/service'; import { exportCookies, importCookies } from '@/features/cookies/transfer'; import { - applyProxyRules, clearProxyRuleStats, compileProxyRules, getProxyRuleStats, hasProxyAuthPassword, - previewProxyRules, setProxyAuthPassword, switchProxy, + applyProxyRules, clearCurrentSiteRoute, compileCurrentProxyRules, dirtyProxyState, exportProxyConfiguration, + getProxyRuleSourcePage, hasProxyAuthPassword, importProxyConfiguration, previewCurrentProxyRules, + refreshProxyRuleSource, removeProxyRuleSource, routeCurrentSite, saveProxyProfile, saveProxyRuleSource, + setProxyAuthPassword, switchProxy, } from '@/features/proxy/service'; import { getState, updateState } from '@/platform/storage/state'; -import { applyUserAgentRules } from '@/features/identity/user-agent'; +import { + applyUserAgentAssignments, resolveUserAgent, userAgentHostname, validateUserAgent, +} from '@/features/identity/user-agent'; +import { BUILTIN_USER_AGENT_PROFILES, getUserAgentProfiles } from '@/features/identity/user-agent-profiles'; import { errorCode, ExtensionError } from '@/shared/errors'; import { appendAuditEvent, clearAuditEvents, listAuditEvents } from '@/features/diagnostics/audit'; import { @@ -105,6 +120,28 @@ async function requiredRequestTarget( return target; } +async function requiredDebuggerTarget( + input: { tabId?: number; frameId?: number; documentId?: string }, + sender: Browser.runtime.MessageSender, +): Promise { + const boundTabId = senderBoundTabId(sender); + if (boundTabId && input.tabId && boundTabId !== input.tabId) { + throw new ExtensionError('target_denied', '页面内请求不能操作其他标签页'); + } + const tabId = boundTabId || input.tabId; + if (!tabId) throw new ExtensionError('target_unavailable', '请选择一个可访问的 HTTP(S) 标签页'); + const frameId = boundTabId && !isFloatingSender(sender) ? sender.frameId ?? 0 : input.frameId ?? 0; + if (boundTabId && !isFloatingSender(sender) && input.frameId !== undefined && input.frameId !== frameId) { + throw new ExtensionError('target_denied', '页面内请求不能操作其他 frame'); + } + const frame = await browser.webNavigation.getFrame({ tabId, frameId }); + if (!frame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在'); + if (input.documentId && frame.documentId && input.documentId !== frame.documentId) { + throw new ExtensionError('stale_document', '目标页面已经刷新或导航'); + } + return { tabId, frameId, documentId: frame.documentId || input.documentId }; +} + function originOf(url: string): string { const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('只能授权 HTTP(S) 标签页'); @@ -146,18 +183,22 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. }))); case 'frame.list': return ok(await getFrameInventory(targetTabId(request.payload.tabId, sender)!)); case 'proxy.save': { - const profile = request.payload; - return ok(await updateState((state) => ({ - ...state, - proxyProfiles: [...state.proxyProfiles.filter((item) => item.id !== profile.id), profile], - }))); + return ok(await saveProxyProfile(request.payload)); } case 'proxy.delete': { const { id } = request.payload; - return ok(await updateState((state) => ({ - ...state, - proxyProfiles: state.proxyProfiles.filter((item) => item.id !== id || item.builtin), - proxyRules: state.proxyRules.filter((rule) => rule.proxyProfileId !== id), + const state = await getState(); + const profile = state.proxyProfiles.find((item) => item.id === id); + if (!profile || profile.builtin) throw new Error('内置代理出口不能删除'); + if (state.activeProxyId === id) throw new Error('该出口正在使用,请先切换到其他出口'); + if (state.proxyRules.some((rule) => rule.proxyProfileId === id) + || state.proxyRuleSources.some((source) => source.matchProfileId === id || source.bypassProfileId === id) + || state.proxyRouting.defaultProfileId === id) { + throw new Error('该出口仍被自动切换规则引用,请先修改相关规则'); + } + return ok(await updateState((current) => dirtyProxyState({ + ...current, + proxyProfiles: current.proxyProfiles.filter((item) => item.id !== id), }))); } case 'proxy.switch': @@ -169,26 +210,18 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. if (!profiles.some((profile) => profile.id === rule.proxyProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) { throw new Error('规则 PAC 只能使用直接连接或固定代理出口'); } - return ok(await updateState((state) => ({ + return ok(await updateState((state) => dirtyProxyState({ ...state, proxyRules: [...state.proxyRules.filter((item) => item.id !== rule.id), rule], }))); } case 'proxy.rule.delete': { const { id } = request.payload; - return ok(await updateState((state) => ({ ...state, proxyRules: state.proxyRules.filter((item) => item.id !== id) }))); - } - case 'proxy.rules.apply': - await applyProxyRules(); - return ok(await getState()); - case 'proxy.rules.preview': { - const state = await getState(); - return ok(previewProxyRules(request.payload.url, state.proxyRules, state.proxyProfiles, state.proxyRouting)); - } - case 'proxy.rules.compile': { - const state = await getState(); - return ok(compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting)); + return ok(await updateState((state) => dirtyProxyState({ ...state, proxyRules: state.proxyRules.filter((item) => item.id !== id) }))); } + case 'proxy.auto.apply': return ok(await applyProxyRules()); + case 'proxy.rules.preview': return ok(await previewCurrentProxyRules(request.payload.url)); + case 'proxy.rules.compile': return ok(await compileCurrentProxyRules()); case 'proxy.rules.reorder': { const ids = request.payload.ids; const state = await getState(); @@ -196,44 +229,44 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. throw new Error('规则排序必须包含当前全部规则且不能重复'); } const byId = new Map(state.proxyRules.map((rule) => [rule.id, rule])); - return ok(await updateState((current) => ({ + return ok(await updateState((current) => dirtyProxyState({ ...current, - proxyRules: ids.map((id, index) => ({ ...byId.get(id)!, priority: (ids.length - index) * 10 })), + proxyRules: ids.map((id, order) => ({ ...byId.get(id)!, order, updatedAt: Date.now() })), }))); } case 'proxy.rules.settings': { const input = request.payload; const state = await getState(); if (!state.proxyProfiles.some((profile) => profile.id === input.defaultProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) throw new Error('默认出口必须是直接连接或固定代理'); - return ok(await updateState((current) => ({ ...current, proxyRouting: input }))); + return ok(await updateState((current) => dirtyProxyState({ ...current, proxyRouting: input }))); } - case 'proxy.rules.stats': return ok(getProxyRuleStats()); - case 'proxy.rules.stats.clear': - await clearProxyRuleStats(); - return ok(); + case 'proxy.source.save': return ok(await saveProxyRuleSource(request.payload)); + case 'proxy.source.refresh': return ok(await refreshProxyRuleSource(request.payload.id)); + case 'proxy.source.delete': return ok(await removeProxyRuleSource(request.payload.id)); + case 'proxy.sources.reorder': { + const ids = request.payload.ids; + const state = await getState(); + if (ids.length !== state.proxyRuleSources.length || new Set(ids).size !== ids.length + || ids.some((id) => !state.proxyRuleSources.some((source) => source.id === id))) { + throw new Error('规则源排序必须包含当前全部订阅且不能重复'); + } + const byId = new Map(state.proxyRuleSources.map((source) => [source.id, source])); + return ok(await updateState((current) => dirtyProxyState({ + ...current, + proxyRuleSources: ids.map((id, order) => ({ ...byId.get(id)!, order })), + }))); + } + case 'proxy.source.rules': return ok(await getProxyRuleSourcePage( + request.payload.id, request.payload.offset, request.payload.limit, request.payload.query, + )); + case 'proxy.site.route': return ok(await routeCurrentSite(request.payload.url, request.payload.profileId)); + case 'proxy.site.route.clear': return ok(await clearCurrentSiteRoute(request.payload.url)); case 'proxy.auth.set': await setProxyAuthPassword(request.payload.profileId, request.payload.password); return ok({ configured: hasProxyAuthPassword(request.payload.profileId) }); case 'proxy.auth.status': return ok({ configured: hasProxyAuthPassword(request.payload.profileId) }); - case 'proxy.config.export': { - const state = await getState(); - return ok({ version: 1 as const, profiles: state.proxyProfiles, rules: state.proxyRules, routing: state.proxyRouting }); - } - case 'proxy.config.import': { - const configuration = request.payload.configuration; - const profileIds = new Set(configuration.profiles.map((profile) => profile.id)); - if (profileIds.size !== configuration.profiles.length || !profileIds.has(configuration.routing.defaultProfileId)) throw new Error('代理配置包含重复或缺失的出口 ID'); - if (configuration.rules.some((rule) => !profileIds.has(rule.proxyProfileId))) throw new Error('代理规则引用了不存在的出口'); - const routableIds = new Set(configuration.profiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id)); - if (!routableIds.has(configuration.routing.defaultProfileId) || configuration.rules.some((rule) => !routableIds.has(rule.proxyProfileId))) throw new Error('规则 PAC 只能使用直接连接或固定代理出口'); - return ok(await updateState((current) => ({ - ...current, - proxyProfiles: configuration.profiles, - proxyRules: configuration.rules, - proxyRouting: configuration.routing, - activeProxyId: 'direct', - }))); - } + case 'proxy.config.export': return ok(await exportProxyConfiguration()); + case 'proxy.config.import': return ok(await importProxyConfiguration(request.payload.configuration)); case 'cookie.list': return ok(await listCookies(request.payload.url)); case 'cookie.set': return ok(await setCookie(request.payload)); case 'cookie.remove': { @@ -248,27 +281,79 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. } case 'cookie.import': return ok(await importCookies(request.payload.url, request.payload.format, request.payload.text)); case 'cookie.export': return ok(exportCookies(await listCookies(request.payload.url), request.payload.format, request.payload.includeValues)); - case 'ua.save': { - const rule = request.payload; - const state = await updateState((current) => ({ - ...current, - userAgentRules: [...current.userAgentRules.filter((item) => item.id !== rule.id), rule], - })); - if (state.activeGrant) await startAgentRuntime(state.activeGrant); - await applyUserAgentRules(state.userAgentRules); - return ok(state); - } - case 'ua.delete': { - const state = await updateState((current) => ({ - ...current, - userAgentRules: current.userAgentRules.filter((item) => item.id !== request.payload.id), - })); - await applyUserAgentRules(state.userAgentRules); - return ok(state); - } - case 'ua.apply': { + case 'ua.catalog': { const state = await getState(); - await applyUserAgentRules(state.userAgentRules); + return ok(getUserAgentProfiles(state.customUserAgentProfiles)); + } + case 'ua.resolve': { + const state = await getState(); + return ok(resolveUserAgent(request.payload.url, state.userAgentAssignments, state.customUserAgentProfiles)); + } + case 'ua.profile.save': { + const input = request.payload; + const profileId = input.id || crypto.randomUUID(); + if (BUILTIN_USER_AGENT_PROFILES.some((profile) => profile.id === profileId)) throw new Error('不能覆盖内置 User-Agent 预设'); + const profile: UserAgentProfile = { + id: profileId, + name: input.name.trim(), + userAgent: validateUserAgent(input.userAgent), + category: 'custom', + builtin: false, + }; + const state = await updateState((current) => ({ + ...current, + customUserAgentProfiles: [ + ...current.customUserAgentProfiles.filter((item) => item.id !== profile.id), + profile, + ], + })); + await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles); + void appendAuditEvent({ category: 'settings', action: 'ua.profile.save', outcome: 'success', summary: profile.name }); + return ok(profile); + } + case 'ua.profile.delete': { + if (BUILTIN_USER_AGENT_PROFILES.some((profile) => profile.id === request.payload.id)) throw new Error('不能删除内置 User-Agent 预设'); + const state = await updateState((current) => ({ + ...current, + customUserAgentProfiles: current.customUserAgentProfiles.filter((item) => item.id !== request.payload.id), + userAgentAssignments: current.userAgentAssignments.filter((item) => item.profileId !== request.payload.id), + })); + await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles); + void appendAuditEvent({ category: 'settings', action: 'ua.profile.delete', outcome: 'success' }); + return ok(state); + } + case 'ua.site.apply': { + const input = request.payload; + const before = await getState(); + const profile = getUserAgentProfiles(before.customUserAgentProfiles).find((item) => item.id === input.profileId); + if (!profile) throw new Error('User-Agent 预设不存在'); + const hostname = userAgentHostname(input.url); + const now = Date.now(); + const state = await updateState((current) => { + const existing = current.userAgentAssignments.find((item) => item.hostname === hostname); + return { + ...current, + userAgentAssignments: [ + ...current.userAgentAssignments.filter((item) => item.hostname !== hostname), + { + id: existing?.id || crypto.randomUUID(), hostname, profileId: profile.id, + createdAt: existing?.createdAt || now, updatedAt: now, + }, + ], + }; + }); + await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles); + void appendAuditEvent({ category: 'settings', action: 'ua.site.apply', outcome: 'success', summary: `${hostname} · ${profile.name}` }); + return ok(state); + } + case 'ua.site.reset': { + const hostname = userAgentHostname(request.payload.url); + const state = await updateState((current) => ({ + ...current, + userAgentAssignments: current.userAgentAssignments.filter((item) => item.hostname !== hostname), + })); + await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles); + void appendAuditEvent({ category: 'settings', action: 'ua.site.reset', outcome: 'success', summary: hostname }); return ok(state); } case 'context.capture': { @@ -354,7 +439,8 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. if (before.activeGrant) { await Promise.all([ stopNetworkCapturesForGrant(before.activeGrant.id), - stopPageObservationsForGrant(before.activeGrant.id), + stopBrowserRecordingsForGrant(before.activeGrant.id), + stopDeepCapturesForGrant(before.activeGrant.id), ]); } if (before.handoff?.state === 'waiting_for_user' && state.handoff) { @@ -386,7 +472,8 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. if (before.activeGrant) { await Promise.all([ stopNetworkCapturesForGrant(before.activeGrant.id), - stopPageObservationsForGrant(before.activeGrant.id), + stopBrowserRecordingsForGrant(before.activeGrant.id), + stopDeepCapturesForGrant(before.activeGrant.id), ]); } await setAgentRuntimeState('revoked', before.activeGrant); @@ -499,32 +586,94 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. void appendAuditEvent({ category: 'capability', action: 'network.capture.prepare_analysis', outcome: 'success', targetTabId: target.tabId }); return ok(result); } - case 'observation.start': { + case 'recording.start': { const input = request.payload; const target = await requiredRequestTarget(input, sender); - const status = await startPageObservation(target, input); + const snapshot = await startBrowserRecording(target, input); void appendAuditEvent({ - category: 'capability', action: 'observation.start', outcome: 'success', targetTabId: target.tabId, + category: 'capability', action: 'recording.start', outcome: 'success', targetTabId: target.tabId, summary: input.captureValues ? '包含用户明确启用的短时值预览' : '仅元数据', }); + return ok(snapshot); + } + case 'recording.status': return ok(await browserRecordingStatus(await requiredRequestTarget(request.payload, sender))); + case 'recording.get': { + const target = await requiredRequestTarget(request.payload, sender); + return ok(await getBrowserRecording(target, request.payload.limit, true)); + } + case 'recording.clear': { + const target = await requiredRequestTarget(request.payload, sender); + const snapshot = await clearBrowserRecording(target, true); + void appendAuditEvent({ category: 'capability', action: 'recording.clear', outcome: 'success', targetTabId: target.tabId }); + return ok(snapshot); + } + case 'recording.stop': { + const target = await requiredRequestTarget(request.payload, sender); + const snapshot = await stopBrowserRecording(target, true); + void appendAuditEvent({ category: 'capability', action: 'recording.stop', outcome: 'success', targetTabId: target.tabId }); + return ok(snapshot); + } + case 'callable.create': { + const target = request.payload.source === 'deep-capture' + ? await requiredDebuggerTarget(request.payload, sender) + : await requiredRequestTarget(request.payload, sender); + const callable = request.payload.source === 'deep-capture' + ? await createCapturedPageCallable(target, request.payload.callFrameId, request.payload) + : await createRecordedPageCallable(target, request.payload); + void appendAuditEvent({ category: 'capability', action: 'callable.create', outcome: 'success', targetTabId: target.tabId, summary: callable.name }); + return ok(callable); + } + case 'callable.list': return ok(await listPageCallables(await requiredRequestTarget(request.payload, sender))); + case 'callable.execute': { + const target = await requiredRequestTarget(request.payload, sender); + const result = await executePageCallable(target, request.payload.callableId, request.payload.args); + void appendAuditEvent({ category: 'capability', action: 'callable.execute', outcome: 'success', targetTabId: target.tabId, summary: `${result.durationMs.toFixed(1)} ms` }); + return ok(result); + } + case 'callable.delete': return ok(await deletePageCallable( + await requiredRequestTarget(request.payload, sender), request.payload.callableId, + )); + case 'deep.capture.start': { + const target = await requiredRequestTarget(request.payload, sender); + const status = await startDeepCapture(target, request.payload.matcher); + void appendAuditEvent({ + category: 'capability', action: 'deep.capture.start', outcome: 'success', targetTabId: target.tabId, + summary: request.payload.matcher.kind === 'request' + ? request.payload.matcher.urlPattern + : request.payload.matcher.operation, + }); return ok(status); } - case 'observation.status': return ok(await pageObservationStatus(await requiredRequestTarget(request.payload, sender))); - case 'observation.list': { - const target = await requiredRequestTarget(request.payload, sender); - return ok(await listPageObservations(target, request.payload.limit, true)); - } - case 'observation.clear': { - const target = await requiredRequestTarget(request.payload, sender); - const status = await clearPageObservations(target); - void appendAuditEvent({ category: 'capability', action: 'observation.clear', outcome: 'success', targetTabId: target.tabId }); + case 'deep.capture.status': return ok(await deepCaptureStatus(await requiredDebuggerTarget(request.payload, sender))); + case 'deep.capture.keepalive': return ok(await keepDeepCaptureAlive(await requiredDebuggerTarget(request.payload, sender))); + case 'deep.capture.resume': return ok(await resumeDeepCapture(await requiredDebuggerTarget(request.payload, sender))); + case 'deep.capture.detach': { + const target = await requiredDebuggerTarget(request.payload, sender); + const status = await detachDeepCapture(target); + void appendAuditEvent({ category: 'capability', action: 'deep.capture.detach', outcome: 'success', targetTabId: target.tabId }); return ok(status); } - case 'observation.stop': { - const target = await requiredRequestTarget(request.payload, sender); - const status = await stopPageObservation(target); - void appendAuditEvent({ category: 'capability', action: 'observation.stop', outcome: 'success', targetTabId: target.tabId }); - return ok(status); + case 'transform.profile.list': { + const input = request.payload; + const target = input.tabId ? await requiredRequestTarget(input, sender) : undefined; + return ok(await listBrowserTransformProfiles(target ? { tabId: target.tabId, frameId: target.frameId } : undefined)); + } + case 'transform.profile.save': { + const profile = await saveBrowserTransformProfile(request.payload); + void appendAuditEvent({ + category: 'capability', action: 'transform.profile.save', outcome: 'success', + targetTabId: profile.target.tabId, summary: profile.name, + }); + return ok(profile); + } + case 'transform.profile.delete': return ok(await deleteBrowserTransformProfile(request.payload.id)); + case 'transform.execute': { + const result = await executeBrowserTransform(request.payload); + void appendAuditEvent({ + category: 'capability', action: `transform.${result.direction}`, outcome: 'success', + durationMs: result.durationMs, summary: `${result.nodeDurations.length} 个 Pipeline 节点`, + }); + return ok(result); } case 'audit.list': return ok(await listAuditEvents(request.payload.limit)); case 'audit.clear': { @@ -587,9 +736,10 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime. } export async function runBackground(): Promise { + initializeDeepCaptureService(); recordServiceWorkerStart(); browser.runtime.onMessage.addListener((input: unknown, sender: Browser.runtime.MessageSender, sendResponse) => { - if (['bridge.status.changed', 'bridge.pairing.status.changed'].includes((input as { action?: string })?.action || '')) return undefined; + if (['bridge.status.changed', 'bridge.pairing.status.changed', 'network.capture.changed', 'deep.capture.changed'].includes((input as { action?: string })?.action || '')) return undefined; void Promise.resolve().then(() => parseExtensionRequest(input)).then((request) => handleRequest(request, sender)).then(sendResponse).catch((error) => sendResponse(fail(error))); return true; }); @@ -598,6 +748,6 @@ export async function runBackground(): Promise { if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge) || JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) { await updateState(() => state); } - await applyUserAgentRules(state.userAgentRules).catch(console.error); + await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles).catch(console.error); if (state.bridge.autoConnect && state.bridge.pairedEngine) await engineBridge.connect(state.bridge).catch(console.error); } diff --git a/src/entrypoints/options/App.css b/src/entrypoints/options/App.css index d878d05..5968666 100644 --- a/src/entrypoints/options/App.css +++ b/src/entrypoints/options/App.css @@ -45,7 +45,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .page-heading > button:not(.ui-button):focus-visible, .editor-actions > button:not(.ui-button):focus-visible, .panel-title > button:not(.ui-button):focus-visible, -.data-row:focus-visible, .network-row:focus-visible, .observation-row:focus-visible, +.data-row:focus-visible, .network-row:focus-visible, .recording-traces button:focus-visible, +.recording-pipeline-step > button:focus-visible, .task-workflow-list button:focus-visible, .context-node-list button:focus-visible, .sidebar nav button:focus-visible { outline: none; @@ -57,7 +58,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; /* 所有单列纵向 grid 容器必须显式 minmax(0,1fr),否则子元素 max-content 会撑破窄屏 */ .content-area, .section-view, .settings-form, .list-pane, .editor-pane, .rule-editor, .pairing-workspace, .panel-policy-settings, .grant-editor, .protocol-panel, -.observation-section, .network-inspector, .context-primary, .context-inspector, +.recording-section, .network-inspector, .context-primary, .context-inspector, .context-inspector > section, .context-diff, .context-inventory, .context-node-browser, .context-mode, .context-json, .context-utility-panel, .tab-picker, .tab-picker-group, .data-list, .task-workflow-list, .cookie-transfer, .network-artifact { grid-template-columns: minmax(0, 1fr); } @@ -72,7 +73,10 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--surface); color: var(--foreground); } .sidebar-brand { height: 64px; padding: 0 14px; display: flex; align-items: center; border-bottom: 1px solid var(--border); } .sidebar-brand .product-brand { width: 100%; color: var(--foreground); } -.sidebar nav { padding: 14px 10px; display: grid; gap: 2px; } +.sidebar nav { min-height: 0; padding: 10px 10px 16px; overflow-y: auto; display: grid; gap: 10px; scrollbar-width: thin; } +.sidebar-group { display: grid; gap: 2px; } +.sidebar-group__label { min-height: 24px; padding: 0 10px; display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; } +.sidebar-group__label svg { color: var(--primary); } .sidebar nav button { width: 100%; height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px 1fr 14px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-strong); font-size: var(--text-md); font-weight: 500; text-align: left; cursor: pointer; transition: background-color .14s ease, color .14s ease; } .sidebar nav button:hover { background: var(--surface-subtle); color: var(--foreground); } .sidebar nav button.active { background: var(--surface-subtle); color: var(--foreground); box-shadow: inset 3px 0 0 var(--primary); } @@ -142,6 +146,32 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .page-heading h1 { margin: 0; font-size: var(--text-2xl); font-weight: 700; line-height: 28px; } .page-heading p { margin: 5px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 17px; } .section-view h2 { margin: 0; font-size: var(--text-lg); font-weight: 650; } +.page-eyebrow { display: block; margin-bottom: 4px; color: var(--primary); font-size: var(--text-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } + +/* User-Agent 常用工具 */ +.ua-current-site { min-height: 82px; padding: 14px 16px; display: grid; grid-template-columns: minmax(210px, .9fr) minmax(260px, 1fr) auto; align-items: center; gap: 16px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface); } +.ua-current-site > div:first-child { min-width: 0; } +.ua-current-site > div:first-child span, .ua-current-site > div:first-child strong, .ua-current-site > div:first-child small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.ua-current-site > div:first-child span { color: var(--muted); font-size: var(--text-xs); } +.ua-current-site > div:first-child strong { margin-top: 3px; font-size: var(--text-lg); } +.ua-current-site > div:first-child small { margin-top: 3px; color: var(--muted-strong); font-size: var(--text-sm); } +.ua-management { min-height: 420px; display: grid; grid-template-columns: minmax(420px, 1.35fr) minmax(320px, .8fr); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface); } +.ua-assignments { min-width: 0; padding: 16px; border-right: 1px solid var(--border); } +.ua-profile-editor { min-width: 0; padding: 16px; display: grid; gap: 12px; align-content: start; } +.ua-assignment-list { margin-top: 12px; display: grid; } +.ua-assignment-list > div { min-height: 58px; display: grid; grid-template-columns: minmax(160px, .55fr) minmax(220px, 1fr) 34px; align-items: center; gap: 12px; border-top: 1px solid var(--border); } +.ua-assignment-list > div:last-child { border-bottom: 1px solid var(--border); } +.ua-assignment-list strong, .ua-assignment-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.ua-assignment-list strong { font-size: var(--text-sm); } +.ua-assignment-list small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); } +.ua-assignment-list code { overflow: hidden; color: var(--muted-strong); font-size: 10px; white-space: nowrap; text-overflow: ellipsis; } +.custom-ua-list { display: grid; } +.custom-ua-list > div { min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) 34px; align-items: center; border-top: 1px solid var(--border); } +.custom-ua-list > div:last-child { border-bottom: 1px solid var(--border); } +.custom-ua-list > div > button:first-child { min-width: 0; padding: 7px 0; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; } +.custom-ua-list strong, .custom-ua-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.custom-ua-list strong { font-size: var(--text-sm); } +.custom-ua-list small { margin-top: 2px; color: var(--muted); font-size: 9px; } .empty-state { min-height: 130px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border-radius: var(--radius-md); color: var(--muted); font-size: var(--text-md); text-align: center; } .status-good { color: var(--success); font-weight: 600; } .status-error { color: var(--danger); font-weight: 600; } @@ -150,7 +180,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; /* 代码/报文块 —— 浅色主题用浅灰嵌底,暗色主题用深面板 */ .network-packet, .invoke-result, .network-artifact pre, .context-json pre, -.proxy-tools pre, .observation-values pre, .observation-stack pre { +.proxy-tools pre, .recording-values pre, .recording-evidence pre, .recording-recipe-result pre { margin: 0; padding: 12px 13px; border: 1px solid var(--border); @@ -165,7 +195,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; } [data-theme='dark'] .network-packet, [data-theme='dark'] .invoke-result, [data-theme='dark'] .network-artifact pre, [data-theme='dark'] .context-json pre, [data-theme='dark'] .proxy-tools pre, -[data-theme='dark'] .observation-values pre, [data-theme='dark'] .observation-stack pre { +[data-theme='dark'] .recording-values pre, [data-theme='dark'] .recording-evidence pre, +[data-theme='dark'] .recording-recipe-result pre { border-color: #262c33; background: #12161b; color: #d6dde4; @@ -271,19 +302,7 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .rule-editor > h2 { margin-bottom: 2px; } .rule-editor > p { margin: -4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; } -/* 代理规则 */ -.proxy-routing-bar { padding: 15px 18px; display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); } -.proxy-routing-bar .ui-field { width: 200px; } -.proxy-preview-input { min-width: 0; flex: 1; display: grid; gap: 6px; } -.proxy-preview-input > label { color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; } -.proxy-preview-input > div { display: flex; gap: 6px; align-items: center; } -.proxy-preview-result { min-width: 180px; padding: 9px 13px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); } -.proxy-preview-result.conflict { background: var(--warning-soft); } -.proxy-preview-result small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } -.proxy-preview-result strong { font-size: var(--text-md); font-weight: 650; } -.proxy-preview-result span { color: var(--muted); font-size: var(--text-sm); } -.proxy-preview-result i { color: var(--warning); font-size: var(--text-sm); font-style: normal; font-weight: 600; } -.rule-table, .proxy-rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; } +.rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; } .table-head, .table-row { padding: 0 16px; display: grid; gap: 12px; align-items: center; } .table-head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .table-row { min-height: 46px; border-bottom: 1px solid var(--border); font-size: var(--text-md); } @@ -291,21 +310,6 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .table-row > * { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .table-row code { font-size: var(--text-sm); } .rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(110px, 1fr) minmax(180px, 2fr) minmax(110px, 1fr) 64px 34px; } -.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: 20px minmax(150px, 1.3fr) minmax(130px, 1fr) 100px 54px 62px 34px; } -.proxy-rule-table .table-row { cursor: grab; } -.proxy-rule-table .table-row > svg { color: var(--muted); } -.proxy-rule-name { padding: 0; display: block; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; } -.proxy-rule-name:hover strong { color: var(--primary-text); } -.proxy-rule-name strong, .proxy-rule-name small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.proxy-rule-name strong { font-size: var(--text-md); font-weight: 600; } -.proxy-rule-name small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); } -.proxy-tools { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } -.proxy-tools > section { min-width: 0; padding: 15px 16px; display: grid; gap: 11px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); } -.proxy-tools > section > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.proxy-tools pre { max-height: 220px; } -.proxy-tools textarea { min-height: 160px; font-family: var(--font-mono); font-size: var(--text-sm); } -.proxy-stats p { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--text-md); } -.proxy-stats > span { color: var(--muted); font-size: var(--text-sm); } /* Cookie Editor */ .url-bar { display: flex; align-items: center; gap: 12px; } @@ -325,17 +329,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .cookie-name-button { padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; } .cookie-name-button strong { display: block; overflow: hidden; font-size: var(--text-md); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } .cookie-name-button:hover strong { color: var(--primary-text); } -.cookie-value-button { min-width: 0; padding: 3px 6px; display: flex; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); cursor: pointer; } -.cookie-value-button:hover { background: var(--surface-subtle); } -.cookie-value-button code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; } -.cookie-value-button svg { flex: 0 0 auto; color: var(--muted); } +.cookie-value { min-width: 0; padding: 3px 6px; overflow: hidden; color: var(--muted-strong); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; } .cookie-columns > span > small { display: block; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 15px; white-space: nowrap; text-overflow: ellipsis; } .tag-list { display: flex; flex-wrap: wrap; gap: 4px; } .tag-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; } .cookie-editor-pane { position: sticky; top: 76px; } -.secret-field { position: relative; } -.secret-field .ui-button--icon { position: absolute; right: 6px; top: 6px; width: 28px; height: 28px; } -.secret-field.masked textarea { -webkit-text-security: disc; } .cookie-transfer { display: grid; gap: 10px; } .cookie-transfer .segmented { justify-self: start; } .transfer-status { color: var(--muted); font-size: var(--text-sm); } @@ -393,29 +391,191 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .network-artifact strong { font-size: var(--text-md); font-weight: 650; } .network-artifact pre { max-height: 260px; } -/* 页面行为观测 */ -.observation-section { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); } -.observation-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; } -.observation-heading span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } -.observation-heading h2 { margin-top: 3px; } -.observation-controls { padding: 0; box-shadow: none; } -.observation-kinds { margin-left: auto; color: var(--muted); font-size: var(--text-sm); } -.observation-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; } -.observation-timeline { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } -.observation-table-head { padding: 0 13px; min-height: 34px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } -.observation-row { width: 100%; padding: 8px 13px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; } -.observation-row:last-child { border-bottom: 0; } -.observation-row:hover { background: var(--surface-subtle); } -.observation-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); } -.observation-row > strong { overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; } -.observation-row > span, .observation-row > time { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.observation-target strong, .observation-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.observation-target strong { font-weight: 600; } -.observation-target small { margin-top: 1px; color: var(--muted); } -.observation-inspector { position: static; padding: 0; box-shadow: none; } -.observation-values, .observation-stack { display: grid; gap: 7px; } -.observation-values > strong, .observation-stack > strong { font-size: var(--text-sm); font-weight: 650; } -.observation-values pre, .observation-stack pre { max-height: 180px; } +/* 浏览器现场录制:Trace -> Pipeline -> 证据与页面函数 */ +.recording-section { display: grid; gap: 12px; } +.recording-heading { min-height: 54px; display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(230px, 1fr); align-items: end; gap: 16px; } +.recording-heading__identity > span { display: block; margin-bottom: 3px; color: var(--primary); font-size: var(--text-xs); font-weight: 700; } +.recording-heading__actions { min-width: 230px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.recording-heading__actions.is-inactive { visibility: hidden; pointer-events: none; } +.recording-mode-switch { height: 34px; padding: 3px; display: grid; grid-template-columns: repeat(3, auto); gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); } +.recording-mode-switch button { height: 28px; padding: 0 9px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); font: inherit; font-size: var(--text-xs); font-weight: 600; cursor: pointer; } +.recording-mode-switch button:disabled { cursor: not-allowed; opacity: .48; } +.recording-mode-switch button.is-selected { background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-sm); } +.recording-mode-switch button.is-selected svg { color: var(--primary); } +.recording-mode-panel[hidden] { display: none; } +.recording-state { min-height: 30px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; } +.recording-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); } +.recording-state.is-active { border-color: color-mix(in srgb, var(--danger) 32%, var(--border)); background: var(--danger-soft); color: var(--danger); } +.recording-state.is-active i { background: var(--danger); animation: pulse 1.2s infinite; } +.recording-controls { min-height: 52px; padding: 8px 10px 8px 14px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); } +.recording-controls > label { min-width: 220px; display: flex; align-items: center; gap: 9px; } +.recording-controls > label span { min-width: 0; } +.recording-controls > label strong, .recording-controls > label small { display: block; } +.recording-controls > label strong { font-size: var(--text-sm); font-weight: 650; } +.recording-controls > label small { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); } +.recording-summary { min-width: 0; margin-left: auto; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; } +.recording-navigation { min-height: 58px; padding: 9px 13px; display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 10px; border: 1px solid color-mix(in srgb, var(--warning) 32%, var(--border)); border-radius: var(--radius-md); background: var(--warning-soft); } +.recording-navigation > svg { color: var(--warning); } +.recording-navigation.is-restored { border-color: color-mix(in srgb, var(--success) 32%, var(--border)); background: var(--success-soft); } +.recording-navigation.is-restored > svg { color: var(--success); } +.recording-navigation.is-failed { border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); background: var(--danger-soft); } +.recording-navigation.is-failed > svg { color: var(--danger); } +.recording-navigation > div { min-width: 0; } +.recording-navigation strong, +.recording-navigation span, +.recording-navigation code { display: block; } +.recording-navigation strong { font-size: var(--text-sm); } +.recording-navigation span { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; } +.recording-navigation code { margin-top: 3px; overflow: hidden; color: var(--muted-strong); font-size: 10px; white-space: nowrap; text-overflow: ellipsis; } +.recording-error { min-height: 46px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; border-left: 3px solid var(--danger); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); } +.recording-error .ui-button { margin-left: auto; } +.recording-empty { min-height: 230px; padding: 30px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); text-align: center; } +.recording-empty svg { color: var(--border-strong); } +.recording-empty strong { font-size: var(--text-lg); } +.recording-empty span { max-width: 520px; color: var(--muted); font-size: var(--text-sm); line-height: 1.55; } +.recording-workbench { min-height: 560px; display: grid; grid-template-columns: minmax(210px, 240px) minmax(340px, 1fr) minmax(310px, 380px); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); } +.recording-traces, .recording-pipeline, .recording-inspector { min-width: 0; min-height: 0; } +.recording-traces { border-right: 1px solid var(--border); background: var(--surface-subtle); } +.recording-traces > header, .recording-pipeline > header { min-height: 45px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); } +.recording-traces > header strong, .recording-pipeline > header strong { font-size: var(--text-sm); font-weight: 700; } +.recording-traces > header > div strong, .recording-traces > header > div small { display: block; } +.recording-traces > header > div small { margin-top: 1px; color: var(--muted); font-size: 10px; font-weight: 500; } +.recording-traces > header > span { min-width: 22px; height: 20px; padding: 0 6px; display: inline-grid; place-items: center; border-radius: 999px; background: var(--surface); color: var(--muted); font-size: var(--text-xs); } +.recording-traces > div { max-height: 700px; overflow: auto; } +.recording-traces button { position: relative; width: 100%; min-height: 72px; padding: 10px 11px; display: grid; grid-template-columns: 31px minmax(0, 1fr); gap: 0 9px; align-items: start; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; } +.recording-traces button:hover { background: var(--surface); } +.recording-traces button.is-selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--primary); } +.recording-trace-index { width: 31px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-strong); font: 700 10px/1 var(--font-mono); letter-spacing: .03em; } +.recording-traces button.is-selected .recording-trace-index { border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); background: var(--primary-soft); color: var(--primary-text); } +.recording-traces button > span:nth-child(2) { min-width: 0; } +.recording-traces button strong, .recording-traces button small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.recording-traces button strong { font-size: var(--text-sm); font-weight: 650; } +.recording-traces button small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); } +.recording-traces button time { grid-column: 2; margin-top: 5px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; } +.recording-traces button time i { padding-left: 7px; border-left: 1px solid var(--border); color: var(--muted-strong); font-style: normal; } +.recording-pipeline { border-right: 1px solid var(--border); } +.recording-pipeline > header > div { min-width: 0; } +.recording-pipeline > header > div strong, .recording-pipeline > header > div span { display: block; } +.recording-pipeline > header > div span { margin-top: 1px; color: var(--muted); font-size: var(--text-xs); } +.recording-pipeline > header > i { display: inline-flex; align-items: center; gap: 4px; color: var(--success); font-size: var(--text-xs); font-style: normal; white-space: nowrap; } +.recording-pipeline__body { max-height: 700px; padding: 13px 13px 18px 10px; overflow: auto; } +.recording-pipeline-step { position: relative; display: grid; grid-template-columns: 31px minmax(0, 1fr); align-items: stretch; } +.recording-step-rail { min-height: 73px; display: grid; grid-template-rows: 30px minmax(0, 1fr); justify-items: center; color: var(--muted); } +.recording-step-rail > i { position: relative; z-index: 1; width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; background: var(--surface); color: var(--muted-strong); font: 700 9px/1 var(--font-mono); font-style: normal; } +.recording-step-rail > span { width: 1px; min-height: 43px; display: grid; align-items: end; justify-items: center; background: var(--border); color: var(--muted); } +.recording-step-rail > span svg { width: 11px; margin: 0 0 -5px; padding: 1px 0; background: var(--background); } +.recording-pipeline-step.is-navigation .recording-step-rail > i { border-color: color-mix(in srgb, var(--warning) 55%, var(--border)); background: var(--warning-soft); color: var(--warning); } +.recording-pipeline-step > button { width: 100%; min-height: 73px; padding: 10px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--foreground); text-align: left; cursor: pointer; transition: border-color .14s ease, background-color .14s ease, box-shadow .14s ease; } +.recording-pipeline-step > button:hover { border-color: var(--border-strong); background: var(--surface-subtle); } +.recording-pipeline-step > button.is-linked { border-left: 3px solid var(--success); } +.recording-pipeline-step > button.is-selected { border-color: var(--primary); box-shadow: 0 0 0 2px var(--focus); } +.recording-pipeline-step.is-navigation > button { margin: 4px 0 9px; border-style: dashed; border-color: color-mix(in srgb, var(--warning) 42%, var(--border)); background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface)); } +.recording-event-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--muted-strong); } +.recording-event-icon.kind-crypto { background: var(--warning-soft); color: var(--warning); } +.recording-event-icon.kind-fetch, .recording-event-icon.kind-xhr, .recording-event-icon.kind-form { background: var(--primary-soft); color: var(--primary-text); } +.recording-event-icon.kind-websocket { background: var(--success-soft); color: var(--success); } +.recording-event-icon.kind-navigation { background: var(--warning-soft); color: var(--warning); } +.recording-pipeline-step button > span:nth-child(2) { min-width: 0; } +.recording-pipeline-step small, .recording-pipeline-step strong, .recording-pipeline-step em, .recording-pipeline-step b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.recording-pipeline-step small { color: var(--muted); font-size: 10px; font-weight: 650; } +.recording-pipeline-step strong { margin-top: 2px; font-size: var(--text-sm); font-weight: 650; } +.recording-pipeline-step em { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); font-style: normal; } +.recording-pipeline-step b { margin-top: 4px; color: var(--warning); font-size: 10px; font-weight: 650; } +.recording-event-meta { display: grid; justify-items: end; gap: 5px; } +.recording-event-meta i { padding: 2px 6px; border-radius: 999px; background: var(--warning-soft); color: var(--warning); font-size: 10px; font-style: normal; white-space: nowrap; } +.recording-event-meta i.is-history { background: var(--surface-strong); color: var(--muted); } +.recording-event-meta time { color: var(--muted); font-size: var(--text-xs); white-space: nowrap; } +.recording-event-meta small { color: var(--muted); font-size: 10px; font-weight: 500; white-space: nowrap; } +.recording-inspector > dl.recording-navigation-detail { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.recording-inspector > dl.recording-navigation-detail div:nth-child(odd) { border-left: 0; } +.recording-inspector > dl.recording-navigation-detail div:nth-child(n + 3) { border-top: 1px solid var(--border); } +.recording-navigation-detail dd { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.recording-column-empty { min-height: 180px; padding: 24px; display: grid; place-items: center; color: var(--muted); font-size: var(--text-sm); text-align: center; } +.recording-inspector { max-height: 746px; padding: 14px; overflow: auto; display: grid; gap: 14px; align-content: start; } +.recording-inspector > header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.recording-inspector > header > div { min-width: 0; } +.recording-inspector > header span, .recording-inspector > header strong, .recording-inspector > header small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.recording-inspector > header span { color: var(--primary); font-size: var(--text-xs); font-weight: 700; } +.recording-inspector > header strong { margin-top: 3px; font-size: var(--text-lg); } +.recording-inspector > header small { margin-top: 3px; color: var(--muted); font-size: var(--text-xs); } +.recording-inspector > header > i { flex: 0 0 auto; color: var(--muted); font-size: var(--text-xs); font-style: normal; } +.recording-inspector > header > i.is-error { color: var(--danger); font-weight: 700; } +.recording-inspector > dl { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.recording-inspector > dl div { min-width: 0; padding: 9px 5px; text-align: center; } +.recording-inspector > dl div + div { border-left: 1px solid var(--border); } +.recording-inspector dt { color: var(--muted); font-size: 10px; } +.recording-inspector dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; } +.recording-values { display: grid; gap: 6px; } +.recording-values > strong { font-size: var(--text-sm); } +.recording-values pre, .recording-evidence pre { max-height: 160px; } +.recording-evidence { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.recording-evidence summary { padding: 9px 0; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; } +.recording-evidence[open] { padding-bottom: 10px; } +.profile-inference { padding: 11px 0 0; display: grid; gap: 10px; border-top: 2px solid var(--primary); } +.profile-inference.is-medium { border-top-color: var(--warning); } +.profile-inference.is-low { border-top-color: var(--border-strong); } +.profile-inference__heading { min-width: 0; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: start; gap: 8px; } +.profile-inference__mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: var(--radius-sm); background: var(--primary-soft); color: var(--primary); } +.profile-inference__heading > span:nth-child(2) { min-width: 0; } +.profile-inference__heading small, .profile-inference__heading strong { display: block; } +.profile-inference__heading small { color: var(--primary-text); font-size: var(--text-xs); font-weight: 700; } +.profile-inference__heading strong { margin-top: 2px; font-size: var(--text-sm); line-height: 1.45; } +.profile-inference__heading > i { min-height: 23px; padding: 0 7px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border)); border-radius: 999px; color: var(--success); font-size: 10px; font-style: normal; font-weight: 650; white-space: nowrap; } +.profile-inference.is-medium .profile-inference__heading > i { border-color: color-mix(in srgb, var(--warning) 34%, var(--border)); color: var(--warning); } +.profile-inference.is-low .profile-inference__heading > i { border-color: var(--border); color: var(--muted-strong); } +.profile-inference__flow { min-width: 0; padding: 8px 9px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface-subtle); } +.profile-inference__flow > span { min-width: 0; display: inline-flex; align-items: center; gap: 4px; color: var(--muted); } +.profile-inference__flow code { max-width: 210px; overflow: hidden; color: var(--foreground); font-size: 10px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } +.profile-inference__sources { display: grid; gap: 5px; } +.profile-inference__sources > div { min-width: 0; padding: 7px 8px; display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 2px 7px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-subtle); } +.profile-inference__sources span { grid-row: span 2; width: 21px; height: 21px; display: grid; place-items: center; border-radius: 50%; background: var(--warning-soft); color: var(--warning); font-size: 9px; font-weight: 700; } +.profile-inference__sources strong, .profile-inference__sources small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.profile-inference__sources strong { font-size: var(--text-xs); } +.profile-inference__sources small { color: var(--muted); font-size: 10px; } +.profile-inference__arguments { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 12px; } +.profile-inference__arguments > div { min-width: 0; } +.profile-inference__arguments dt { color: var(--muted); font-size: 10px; } +.profile-inference__arguments dd { margin: 2px 0 0; overflow: hidden; font-size: var(--text-xs); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } +.profile-inference__evidence { border-top: 1px solid var(--border); } +.profile-inference__evidence summary { padding-top: 8px; color: var(--muted-strong); font-size: var(--text-xs); font-weight: 650; cursor: pointer; } +.profile-inference__evidence ol { margin: 8px 0 0; padding: 0; display: grid; gap: 6px; list-style: none; } +.profile-inference__evidence li { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: start; gap: 7px; color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.45; } +.profile-inference__evidence li > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--border-strong); } +.profile-inference__evidence li[data-strength='proven'] > i { background: var(--success); } +.profile-inference__evidence li[data-strength='supported'] > i { background: var(--primary); } +.profile-inference__next { padding: 9px 0 0; display: grid; gap: 9px; border-top: 1px solid var(--border); } +.profile-inference__next > span { color: var(--muted-strong); font-size: var(--text-xs); line-height: 1.5; } +.profile-inference__next .ui-button { justify-self: stretch; } +.recording-deep-action { padding: 11px; display: grid; gap: 10px; border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border)); border-radius: var(--radius-md); background: var(--primary-soft); } +.recording-deep-action > div:first-child { display: flex; align-items: center; gap: 8px; color: var(--primary); } +.recording-deep-action > div:first-child span { min-width: 0; } +.recording-deep-action strong, .recording-deep-action small { display: block; } +.recording-deep-action strong { color: var(--foreground); font-size: var(--text-sm); } +.recording-deep-action small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); } +.recording-recipe-action { padding: 11px; display: grid; gap: 10px; border: 1px solid color-mix(in srgb, var(--warning) 34%, var(--border)); border-radius: var(--radius-md); background: var(--warning-soft); } +.recording-recipe-action > div:first-child { display: flex; align-items: center; gap: 8px; color: var(--warning); } +.recording-recipe-action > div:first-child span { min-width: 0; } +.recording-recipe-action strong, .recording-recipe-action small { display: block; } +.recording-recipe-action strong { color: var(--foreground); font-size: var(--text-sm); } +.recording-recipe-action small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-xs); } +.recording-recipe-editor { display: grid; gap: 9px; } +.recording-recipe-editor > label, .recording-recipe-editor > div:not(.recording-recipe-editor__actions) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.recording-recipe-editor > label { grid-template-columns: minmax(0, 1fr); } +.recording-recipe-editor label { min-width: 0; display: grid; gap: 4px; } +.recording-recipe-editor label > span { color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; } +.recording-recipe-editor__actions, .recording-recipe-buttons { display: flex; justify-content: flex-end; gap: 7px; } +.recording-recipes { padding-top: 13px; display: grid; gap: 9px; border-top: 1px solid var(--border); } +.recording-recipes__heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.recording-recipes__heading strong { font-size: var(--text-sm); } +.recording-recipes__heading select { width: min(190px, 58%); } +.recording-recipe-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-strong); font-size: var(--text-xs); } +.recording-recipe-meta i { color: var(--primary-text); font-style: normal; } +.recording-recipes textarea { min-height: 82px; resize: vertical; font-family: var(--font-mono); font-size: var(--text-sm); } +.recording-recipe-result { display: grid; gap: 6px; } +.recording-recipe-result > div { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto 34px; align-items: center; gap: 7px; } +.recording-recipe-result strong { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; } +.recording-recipe-result span { color: var(--muted); font-size: var(--text-xs); white-space: nowrap; } +.recording-recipe-result pre { max-height: 190px; } /* ---------- 登录态工作区 ---------- */ .context-options { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } @@ -584,18 +744,29 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; /* ---------- 窄屏适配 ---------- */ @media (max-width: 1080px) { .task-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .network-layout, .observation-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); } + .network-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); } .network-inspector, .context-inspector, .cookie-editor-pane, .protocol-panel { position: static; } + .recording-workbench { grid-template-columns: 220px minmax(0, 1fr); } + .recording-inspector { grid-column: 1 / -1; max-height: none; border-top: 1px solid var(--border); } + .recording-pipeline { border-right: 0; } .proxy-tools { grid-template-columns: minmax(0, 1fr); } .bridge-identity-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .ua-management { grid-template-columns: minmax(0, 1fr); } + .ua-assignments { border-right: 0; border-bottom: 1px solid var(--border); } + .ua-current-site { grid-template-columns: minmax(200px, .8fr) minmax(240px, 1fr); } + .ua-current-site > .editor-actions { grid-column: 1 / 3; justify-content: flex-end; } + .recording-heading { grid-template-columns: minmax(0, 1fr) auto; } + .recording-heading__actions { grid-column: 1 / -1; min-width: 0; } } @media (max-width: 720px) { .app-shell { grid-template-columns: minmax(0, 1fr); } .sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); } .sidebar-brand { height: 56px; } - .sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; padding: 10px; } - .sidebar nav button { width: auto; grid-template-columns: 18px 1fr; } + .sidebar nav { display: flex; overflow-x: auto; overflow-y: hidden; padding: 10px; } + .sidebar-group { display: contents; } + .sidebar-group__label { display: none; } + .sidebar nav button { width: auto; flex: 0 0 auto; grid-template-columns: 18px max-content; white-space: nowrap; } .sidebar nav button > svg:last-child { display: none; } .sidebar-theme { margin-top: 0; grid-auto-flow: column; align-items: center; justify-content: space-between; } .sidebar-theme select { width: 150px; } @@ -603,6 +774,11 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .topbar { padding: 0 16px; } .content-area { padding: 16px; } .page-heading { flex-direction: column; align-items: flex-start; } + .ua-current-site { grid-template-columns: minmax(0, 1fr); } + .ua-current-site > .editor-actions { grid-column: auto; justify-content: stretch; } + .ua-current-site > .editor-actions .ui-button { flex: 1; } + .ua-assignment-list > div { grid-template-columns: minmax(0, 1fr) 34px; } + .ua-assignment-list code { display: none; } .task-command-bar, .agent-runtime-summary { flex-direction: column; display: flex; align-items: stretch; } .task-status-grid, .context-session-strip, .diff-summary, .context-inventory-grid, .grant-options, .panel-policy-grid, .form-grid, .paired-engine-meta { grid-template-columns: minmax(0, 1fr); } .agent-action-row { grid-template-columns: 12px 76px minmax(0, 1fr) 76px; } @@ -612,9 +788,18 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; .activity-table__row > span:nth-child(2), .activity-table__row > span:nth-child(4), .activity-table__row > span:last-child { display: none; } .network-table-head, .network-row { grid-template-columns: 56px 50px minmax(0, 1fr) 66px; } .network-table-head span:nth-child(4), .network-row > span:nth-child(4) { display: none; } - .observation-table-head, .observation-row { grid-template-columns: 76px minmax(0, 1fr) 80px; } - .observation-table-head span:nth-child(2), .observation-table-head span:nth-child(4), - .observation-row > span:nth-child(2), .observation-row > span:nth-child(4) { display: none; } + .recording-heading { grid-template-columns: minmax(0, 1fr); align-items: flex-start; } + .recording-mode-switch { justify-self: start; } + .recording-heading__actions { grid-column: auto; justify-self: stretch; justify-content: flex-start; } + .recording-controls { flex-wrap: wrap; } + .recording-summary { width: 100%; order: 3; margin-left: 0; } + .recording-navigation { grid-template-columns: 22px minmax(0, 1fr); } + .recording-navigation > .ui-button { grid-column: 1 / -1; justify-self: stretch; } + .recording-workbench { grid-template-columns: minmax(0, 1fr); } + .recording-traces, .recording-pipeline { border-right: 0; border-bottom: 1px solid var(--border); } + .recording-traces > div, .recording-pipeline__body { max-height: 420px; } + .recording-inspector { grid-column: auto; border-top: 0; } + .recording-event-meta i { display: none; } .cookie-columns { grid-template-columns: 24px minmax(0, 1fr) minmax(0, 1fr) 34px; } .cookie-columns > span:nth-child(4), .cookie-columns > span:nth-child(5) { display: none; } .cookie-toolbar select { min-width: 0; flex: 1; } diff --git a/src/entrypoints/options/App.tsx b/src/entrypoints/options/App.tsx index e2fb832..1da1916 100644 --- a/src/entrypoints/options/App.tsx +++ b/src/entrypoints/options/App.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { browser, type Browser } from 'wxt/browser'; import { - Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, Cookie, Copy, - Database, Download, Eye, EyeOff, GripVertical, History, KeyRound, MousePointer2, Network, Play, Plus, Power, Radio, - RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, X, + Activity, AlertTriangle, Bot, Braces, Check, ChevronRight, CircleGauge, CloudDownload, Cookie, Copy, + Database, Download, Eye, History, KeyRound, MousePointer2, Network, Play, Power, Radio, + RefreshCw, Route, Save, Search, Send, Server, ShieldCheck, Square, Trash2, Upload, UserRoundCog, Wrench, X, } from 'lucide-react'; -import { v7 as uuidv7 } from 'uuid'; import { ProductBrand, YakitMark } from '@/components/brand/Brand'; import { Button } from '@/components/ui/button'; import { Field } from '@/components/ui/field'; @@ -14,38 +13,54 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AUDIT_CATEGORY_LABELS, AUDIT_OUTCOME_LABELS, HANDOFF_REASON_LABELS, waitingHandoff, } from '@/features/handoff/presentation'; +import { cookieKey, cookieRemovalInput } from '@/features/cookies/presentation'; +import { AutoSwitchView } from '@/features/proxy/ui/AutoSwitchView'; +import { ProxyProfilesView } from '@/features/proxy/ui/ProxyProfilesView'; +import { RuleSourcesView } from '@/features/proxy/ui/RuleSourcesView'; +import { RecordingWorkspace } from '@/features/browser-recording/RecordingWorkspace'; import { CAPABILITY_LABELS, CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities'; import { AGENT_RUNTIME_STORAGE_KEY, AUDIT_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage'; import type { ActiveTabInfo, AgentRuntime, AuditEvent, BridgePairingStatus, BridgeStatus, BrowserCookie, BrowserRequestAnalysisBundle, CookieInput, CookieTransferFormat, EnterprisePolicyStatus, ExtensionState, HumanHandoff, NetworkCaptureStatus, NetworkRequestExport, NetworkRequestRecord, PageContext, PageEvalResult, - PageFrameSummary, PageNodeDetails, PageNodeSummary, PageObservationRecord, PageObservationStatus, - ProxyConfiguration, ProxyProfile, ProxyRule, ProxyRulePreview, ProxyRuleStats, UserAgentRule, YakPocGenerateResult, + PageFrameSummary, PageNodeDetails, PageNodeSummary, + UserAgentProfile, UserAgentProfileInput, YakPocGenerateResult, } from '@/types/models'; import { errorMessage, request } from '@/platform/messaging/runtime'; import { APPEARANCE_STORAGE_KEY, getAppearance, setThemePreference, type ThemePreference } from '@/platform/storage/appearance'; import './App.css'; -type Section = 'overview' | 'proxies' | 'rules' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity'; +type Section = 'overview' | 'proxies' | 'rules' | 'sources' | 'cookies' | 'user-agent' | 'network' | 'context' | 'engine' | 'activity'; const FIREFOX_AMO_BUILD = import.meta.env.FIREFOX && import.meta.env.MODE === 'store'; -const SECTIONS: Array<{ id: Section; label: string; icon: ReactNode }> = [ - { id: 'overview', label: '运行概览', icon: }, - { id: 'proxies', label: '代理配置', icon: }, - { id: 'rules', label: '代理规则', icon: }, - { id: 'cookies', label: 'Cookie Editor', icon: }, - { id: 'user-agent', label: 'UA 请求头', icon: }, - { id: 'network', label: '网络活动', icon: }, - { id: 'context', label: '登录态工作区', icon: }, - { id: 'engine', label: '引擎连接', icon: }, - { id: 'activity', label: '操作记录', icon: }, +const NAVIGATION: Array<{ label: string; icon?: ReactNode; items: Array<{ id: Section; label: string; icon: ReactNode }> }> = [ + { label: '工作区', items: [{ id: 'overview', label: '运行概览', icon: }] }, + { + label: '网络与流量', + items: [ + { id: 'proxies', label: '代理出口', icon: }, + { id: 'rules', label: '自动切换', icon: }, + { id: 'sources', label: '规则订阅', icon: }, + { id: 'network', label: '网络活动', icon: }, + ], + }, + { + label: '常用工具', icon: , + items: [ + { id: 'cookies', label: 'Cookie Editor', icon: }, + { id: 'user-agent', label: 'UA 快速切换', icon: }, + ], + }, + { + label: 'Agent 与系统', + items: [ + { id: 'context', label: '登录态工作区', icon: }, + { id: 'engine', label: '引擎连接', icon: }, + { id: 'activity', label: '操作记录', icon: }, + ], + }, ]; - -const UA_PRESETS = [ - ['Chrome / Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'], - ['Safari / iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'], - ['Googlebot', 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'], -] as const; +const SECTIONS = NAVIGATION.flatMap((group) => group.items); const CONTEXT_SECTION_LABELS: Record = { capture_options: '采集范围', @@ -183,7 +198,7 @@ function App() {