From eb546f9f50ecf0fd88afda437c40b6d897eb517c Mon Sep 17 00:00:00 2001 From: Wolfie Date: Sun, 30 Aug 2026 19:30:41 -0700 Subject: [PATCH] Scope task actors and handoff concurrency --- docs/adr/0001-agenttab-runtime.md | 12 +- docs/launch/chrome-web-store.md | 4 +- docs/launch/directory-listings.md | 4 +- docs/launch/reddit-posts.md | 10 +- docs/launch/show-hn.md | 2 +- docs/mcp.md | 6 +- docs/multi-agent.md | 18 +- docs/rust-host.md | 2 +- docs/security.md | 6 +- docs/verification.md | 6 +- host-rs/crates/agenttab-host/src/handoff.rs | 111 ++- host-rs/crates/agenttab-host/src/journal.rs | 82 +- host-rs/crates/agenttab-host/src/native.rs | 18 +- host-rs/crates/agenttab-host/src/runtime.rs | 194 ++++- host-rs/crates/agenttab-host/src/server.rs | 18 +- host-rs/crates/agenttab-protocol/src/lib.rs | 31 +- packages/extension/src/background.ts | 45 +- packages/extension/src/handoff.ts | 30 +- packages/extension/src/native.ts | 2 +- packages/extension/src/ownership.ts | 469 ++++++++--- packages/extension/src/protocol.ts | 1 - packages/extension/src/scheduler.ts | 145 +++- packages/extension/src/storage.ts | 111 ++- packages/extension/test/extension.test.ts | 838 ++++++++++++++++++-- 24 files changed, 1809 insertions(+), 356 deletions(-) diff --git a/docs/adr/0001-agenttab-runtime.md b/docs/adr/0001-agenttab-runtime.md index 904bb03..63ec04b 100644 --- a/docs/adr/0001-agenttab-runtime.md +++ b/docs/adr/0001-agenttab-runtime.md @@ -28,7 +28,7 @@ A task workspace is visible in Chrome. Task-owned tabs are grouped for display, **Your Turn** is the human-only input boundary. AgentTab MUST hand control to the user for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other input that automation must not observe or synthesize. -While any Your Turn handoff is active, AgentTab MUST enforce a global observation blackout across every task and client. The extension and host each fail closed. AgentTab MUST NOT capture human keystrokes. Handoff clears only after its declared completion condition or explicit Done, capture scrubbing, and host acknowledgement. +While Your Turn is active, AgentTab MUST enforce an observation and mutation blackout for its exact task/tab binding across every client. Other tabs MAY continue. The extension MUST detach the handed-off tab before focus, and the host MUST fail closed globally only while the extension's durable binding is unavailable during reconciliation. AgentTab MUST NOT capture human keystrokes. Handoff clears only after its declared completion condition or explicit Done, tab capture scrubbing, and host acknowledgement. ### Commit @@ -108,15 +108,15 @@ Ownership can be granted only by: 2. a child tab with an owned `openerTabId` 3. explicit `browser_open({ mode: "adopt_active" })` -Adoption MUST be visible. It groups the active tab and shows a brief non-blocking indicator. If grouping fails, creation or adoption rolls back with `outcome: "not_started"`. AgentTab MUST NOT retain invisible ownership with `groupId: null`. +Adoption MUST show a brief non-blocking indicator and SHOULD group the active tab. Grouping is a best-effort presentation step: a failure MUST NOT roll back creation or adoption, and `groupId: null` remains valid authoritative ownership state. -Dedicated-window eligibility MUST be derived from the persisted task record, never from a caller-supplied ownership claim. `placement: "new_window"` MUST fail after the task owns a tab, MUST reject foreground creation, and MUST roll back the created tab if visible grouping fails. Standard mode MUST NOT expose generic focus, resize, move, state-change, or close-window operations. `browser_handoff` remains the sole normal focus transition. +Dedicated-window eligibility MUST be derived from the persisted task record, never from a caller-supplied ownership claim. `placement: "new_window"` MUST fail after the task owns a tab and MUST reject foreground creation. A cosmetic grouping failure does not revoke the created tab. Standard mode MUST NOT expose generic focus, resize, move, state-change, or close-window operations. `browser_handoff` remains the sole normal focus transition. -Tab groups are display-only. Manual grouping never grants ownership. Ungrouping or moving a tab out of its task group immediately revokes ownership, cancels queued mutations, and notifies the host. +Tab groups are display-only. Manual grouping never grants, transfers, or revokes ownership. The persisted task ledger remains authoritative when a tab is ungrouped or moved; closing the tab or task revokes it and cancels queued mutations. -Each tab has one serialized writer queue. Separate-tab mutations may overlap. Reads may overlap only when they cannot observe half-applied mutation state. Ordered task mutations preserve delivery order. Browser-global state uses a separate automatic lock. Agent-facing global lease operations do not exist. +Each tab has one serialized writer queue. Separate-tab mutations may overlap even within one task. Reads may overlap only when they cannot observe half-applied mutation state. Task lifecycle operations preserve per-task delivery order. Explicit global Pause remains a barrier; routine browser work has no browser-global lock. Agent-facing global lease operations do not exist. -Task cleanup persists deletion before calling `chrome.tabs.remove` so `tabs.onRemoved` cannot recreate an empty task record. +Task cleanup installs a durable tombstone, rejects queued task lifecycle work, and persists deletion before calling `chrome.tabs.remove` so queued opens or `tabs.onRemoved` cannot recreate an empty task record. Durable numeric tab bindings also carry a browser-session epoch mirrored in `chrome.storage.session`; a mismatch clears every old tab-bound capability before startup authorization. ## Page revisions and references diff --git a/docs/launch/chrome-web-store.md b/docs/launch/chrome-web-store.md index 1544993..c897f03 100644 --- a/docs/launch/chrome-web-store.md +++ b/docs/launch/chrome-web-store.md @@ -32,7 +32,7 @@ Standard MCP access exposes exactly seven tools: `browser_open`, `browser_snapsh ### Human controls -**Your Turn** is for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During a handoff, AgentTab applies an observation blackout: standard capture and observation requests for every task return `needs_user`. The runtime clears the blackout only after the declared completion condition or explicit Done and its recovery checks. AgentTab does not capture human keystrokes. +**Your Turn** is for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During a handoff, AgentTab blackouts the exact handed-off tab while unrelated task tabs continue. The runtime clears that blackout only after the declared completion condition or explicit Done and its recovery checks. AgentTab does not capture human keystrokes. **Commit** is a best-effort review barrier for recognizable sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. Before acting, AgentTab prepares, classifies, and revalidates the target. A recognizable consequential action is staged with a preview, then requires approval in a human popup and the requesting agent's one-use token. The record expires after a short interval, cannot be replayed, and is invalidated if the page or target changes. Harmless actions proceed without Commit review. Commit reduces recognizable risk; it cannot prove that a page has no hidden external effect. @@ -51,7 +51,7 @@ This section is draft review copy for the v2 contract. It must be reconciled aga | `nativeMessaging` | Required permission | Connects the MV3 extension to the user-installed local AgentTab host. It is the extension-to-host link for task ownership, lifecycle reconciliation, handoff state, Commit staging, and command results. It does not connect the extension to a cloud service. | | `debugger` | Required permission | Supports the task-scoped browser capabilities required for accessibility snapshots, precise click, type, fill, select, scroll, key press, inactive screenshots, network-idle observation, and exact download completion attribution. AgentTab attaches lazily only to task-owned tabs, reuses the task connection while needed, and exposes no generic CDP method in Standard mode. | | `tabs` | Required permission | Lets AgentTab create and visibly adopt task tabs, track their lifecycle and document revision, focus a handoff tab when the user asks, and clean up a closed task. It is not used to make unrelated tabs owned by an agent. | -| `tabGroups` | Required permission | Shows task-owned tabs as a visible workspace with working, needs-you, or finished status. Group membership is display-only and never authorizes an operation. Removing or moving a tab out of its task group revokes its ownership. | +| `tabGroups` | Required permission | Shows task-owned tabs as a visible workspace with working, needs-you, or finished status. Group membership is display-only and never grants, transfers, or revokes ownership; grouping failure does not block task work. | | `storage` | Required permission | Persists the minimum extension state needed to recover task status, pause state, handoff blackout state, revision floors, and user interface preferences across MV3 service-worker restarts. It is not an analytics store and is not used to collect browsing history. | | `alarms` | Required permission | Schedules bounded MV3 lifecycle work such as reconnect, expiry, and recovery checks after service-worker suspension. It is not used for tracking, advertising, or remote scheduling. | | `scripting` | Optional permission | Requested only after the user explicitly clicks **Enable AgentTab automation** in the AgentTab popup. It is not a required install-time permission, denial leaves the extension visibly disabled, and it does not add a Standard raw-script API. | diff --git a/docs/launch/directory-listings.md b/docs/launch/directory-listings.md index 5c983d1..1405f4d 100644 --- a/docs/launch/directory-listings.md +++ b/docs/launch/directory-listings.md @@ -26,14 +26,14 @@ AgentTab is a local browser runtime for AI agents. It gives each agent task-owne AgentTab is for browser tasks that need the Chrome profile already on a user's computer. Its promise is: **Give an agent a tab, not the keys to your browser.** -Each agent begins with a task workspace. AgentTab creates or visibly adopts a tab for that task, permits child-tab inheritance from owned tabs, and serializes writes to the same tab. Task groups make active work visible but are display-only: they never grant ownership. A tab moved out of its task group is no longer available to that task. +Each agent begins with a task workspace. AgentTab creates or visibly adopts a tab for that task, permits child-tab inheritance from owned tabs, and serializes writes to the same tab. Task groups are a best-effort visual aid: they never grant, transfer, or revoke the ownership recorded in the task ledger. The runtime is local-only. One minimal MV3 extension connects through Chrome Native Messaging to a local Rust host. MCP and other local adapters connect to the host through a user-owned Unix socket on macOS and Linux or a current-user named pipe on Windows. There is no cloud relay, hosted browser session, telemetry service, or routine remote control plane. The Standard MCP surface has exactly seven tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, and `browser_commit`. A separate `browser_developer` tool exists only after a persistent, explicit Developer mode opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. AgentTab declares the `` host permission so its defined `chrome.scripting` text, HTML, selector, wait, and scroll paths can operate in task-owned pages that the user directs an agent to use. This supports those bounded paths across sites; it does not add raw cookie, storage, arbitrary JavaScript, CDP, or network APIs to Standard mode. -**Your Turn** is the human handoff state for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During handoff, AgentTab applies an observation blackout across every task, so standard observations return `needs_user`; it does not capture human keystrokes. +**Your Turn** is the human handoff state for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During handoff, AgentTab blackouts the exact handed-off tab while unrelated task tabs continue; it does not capture human keystrokes. **Commit** is a best-effort review barrier for recognizable send, publish, purchase, delete, upload, authorization, and permission-grant controls. It stages a recognizable action with a preview, requires a human popup approval and the requesting agent's one-use token, and revalidates the page and target before execution. It is not a guarantee that every page-triggered external effect is recognizable. diff --git a/docs/launch/reddit-posts.md b/docs/launch/reddit-posts.md index eb6f017..33a0b37 100644 --- a/docs/launch/reddit-posts.md +++ b/docs/launch/reddit-posts.md @@ -12,18 +12,18 @@ AgentTab: Give an AI agent a tab, not the keys to your browser I am working on AgentTab, a local browser runtime for AI agents built around a simple boundary: **Give an agent a tab, not the keys to your browser.** -The starting point is a task workspace rather than general browser control. An agent creates or visibly adopts a tab for its task. Child tabs can inherit the workspace from an owned opener, same-tab writes are serialized, and the task sees only its own tabs. The colored Chrome task group is there to make work visible, not to authorize it. Moving a tab out of the group revokes that task's ownership. +The starting point is a task workspace rather than general browser control. An agent creates or visibly adopts a tab for its task. Child tabs can inherit the workspace from an owned opener, same-tab writes are serialized, and the task sees only its own tabs. The colored Chrome task group is a best-effort visual aid; the persisted task ledger authorizes ownership, so moving or ungrouping a tab does not interrupt the task. The runtime is local-only: one minimal MV3 extension, a local Rust host, Chrome Native Messaging between them, and per-user operating-system-native IPC for local clients. MCP is an adapter to that local runtime. On macOS and Linux the host uses a user-owned Unix socket; on Windows it uses a current-user named pipe. There is no cloud browser, cloud relay, telemetry service, or routine remote control plane. The Standard MCP surface is intentionally small: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, and `browser_commit`. The only additional tool is `browser_developer`, and it requires a persistent explicit Developer mode opt-in. Standard mode does not hand agents raw cookies, browser storage, arbitrary scripts, raw CDP, or raw network APIs. The extension declares the `` host permission so its defined `chrome.scripting` text, HTML, selector, wait, and scroll paths can work in task-owned pages a person directs the agent to use. This broad site reach does not expose raw cookie, storage, arbitrary JavaScript, CDP, or network APIs in Standard mode. -Two controls define the human boundary. **Your Turn** is for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During that handoff, AgentTab blackouts standard observation for every task, so captures return `needs_user`; it does not capture the person's keystrokes. **Commit** is a best-effort review barrier for recognizable sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. It stages the action, shows a human popup preview, and requires that human's approval plus the requesting agent's one-use token before execution. It revalidates the page and element first. +Two controls define the human boundary. **Your Turn** is for passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. During that handoff, AgentTab blackouts the exact handed-off tab while unrelated task tabs continue; it does not capture the person's keystrokes. **Commit** is a best-effort review barrier for recognizable sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. It stages the action, shows a human popup preview, and requires that human's approval plus the requesting agent's one-use token before execution. It revalidates the page and element first. This is not profile isolation. An owned tab still runs in the signed-in Chrome profile the person uses. A hostile page can contain prompt injection, and a control can produce an effect that is not recognizable from its visible label. Your Turn and Commit reduce bounded risks but cannot remove them. The local agent and the local software attached to the profile must still be trusted. -AgentTab `v2.0.0-rc.1` is unreleased and this is not a launch post. I am preserving the draft for later feedback on task ownership, the global handoff blackout, Commit's best-effort semantics, and the seven-tool MCP default. +AgentTab `v2.0.0-rc.1` is unreleased and this is not a launch post. I am preserving the draft for later feedback on task ownership, the tab-scoped handoff blackout, Commit's best-effort semantics, and the seven-tool MCP default. ## Local-first and privacy community variant @@ -35,11 +35,11 @@ AgentTab: Local task workspaces for AI agents in a signed-in browser I have been designing AgentTab for the case where an agent genuinely needs the browser session already on a computer, but should not receive broad browser control by default. -AgentTab is a **Local browser runtime for AI agents**. It gives each agent a visible task workspace in the signed-in Chrome profile and keeps ownership on the runtime side. A task can create a tab, inherit a child tab from one it owns, or visibly adopt the active tab. Grouping does not create authority. An ungrouped or moved tab is immediately unavailable to that task. +AgentTab is a **Local browser runtime for AI agents**. It gives each agent a visible task workspace in the signed-in Chrome profile and keeps ownership on the runtime side. A task can create a tab, inherit a child tab from one it owns, or visibly adopt the active tab. Grouping does not create, transfer, or revoke authority; the runtime's task ledger does. The architecture is deliberately local. A minimal MV3 extension connects to one Rust host through Chrome Native Messaging. Local MCP clients use per-user operating-system-native IPC to reach the host, rather than a network listener or remote service. The runtime has no cloud relay, hosted browser session, analytics, or telemetry. -The handoff model is called **Your Turn**. If a task reaches a password, passkey, two-factor challenge, CAPTCHA, payment secret, or another human-only step, the person takes over. AgentTab persists that state and blackouts observation for every task while the person works. Once the person signals Done or the declared completion condition is reached, the runtime scrubs the handoff path before normal observation resumes. +The handoff model is called **Your Turn**. If a task reaches a password, passkey, two-factor challenge, CAPTCHA, payment secret, or another human-only step, the person takes over. AgentTab persists the exact task/tab binding and blackouts that tab while unrelated tabs continue. Once the person signals Done or the declared completion condition is reached, the runtime scrubs the handoff path before observation resumes on that tab. For recognizable consequential actions, **Commit** stages instead of acting. It is designed for sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. Approval happens in a human popup and is bound to the requesting agent's one-use token, the task, the tab, the target fingerprint, and the current page state. The final execution checks those bindings again. That reduces recognizable risk, but it cannot guarantee that a page has not attached a hidden effect to an innocent-looking control. diff --git a/docs/launch/show-hn.md b/docs/launch/show-hn.md index 3ae696f..92b9abc 100644 --- a/docs/launch/show-hn.md +++ b/docs/launch/show-hn.md @@ -23,7 +23,7 @@ The runtime is one minimal MV3 extension plus a local Rust host. The extension u Standard MCP access is deliberately small: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, and `browser_commit`. There is one optional Developer-only tool, `browser_developer`, behind a persistent explicit opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. AgentTab declares the `` host permission so its defined `chrome.scripting` text, HTML, selector, wait, and scroll paths can run in task-owned pages the user selects. That broad site reach does not give Standard mode raw cookie, storage, arbitrary JavaScript, CDP, or network APIs. -Two human controls are central. **Your Turn** handles passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. While a handoff is active, AgentTab applies an observation blackout for every task, so normal capture and observation calls return `needs_user`; it does not capture the person's keystrokes. **Commit** is a best-effort barrier for recognizable sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. The runtime stages a recognizable action, shows a human popup preview, and allows a one-use token from the requesting agent to execute only after approval. It revalidates the target immediately before execution and invalidates the staged action if the page or target changes. +Two human controls are central. **Your Turn** handles passwords, passkeys, two-factor authentication, CAPTCHA, payment secrets, and other human-only input. While a handoff is active, AgentTab blackouts the exact handed-off tab while unrelated task tabs continue; it does not capture the person's keystrokes. **Commit** is a best-effort barrier for recognizable sends, publishes, purchases, deletes, uploads, authorizations, and permission grants. The runtime stages a recognizable action, shows a human popup preview, and allows a one-use token from the requesting agent to execute only after approval. It revalidates the target immediately before execution and invalidates the staged action if the page or target changes. This is still real-profile automation. Task ownership coordinates execution; it does not isolate cookies, accounts, or identity. A page can contain prompt injection, a control can hide an effect behind an innocent label, and Commit cannot prove that every external effect is recognizable. The runtime is local-only and has no telemetry, but users still need to trust the local agents and software they connect to their signed-in profile. diff --git a/docs/mcp.md b/docs/mcp.md index 650cb97..7cde942 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -100,9 +100,11 @@ Raw TypeScript and Python SDK clients raise `AgentTabTransportError` for an ambi ### Your Turn handoff -Call `browser_handoff` before the user enters credentials or completes another human-only step. AgentTab activates a global blackout, focuses the declared tab, opens its user-facing handoff state, and denies browser observation and capture for every task while the handoff is active. Automation resumes only after the declared navigation, URL, selector, or manual completion condition is satisfied and the handoff is cleared. +Call `browser_handoff` before the user enters credentials or completes another human-only step. AgentTab drains and blackouts the declared task tab, detaches its debugger session, focuses it, and opens the user-facing handoff state. Other task tabs and independent tasks remain available for unattended work. Automation on the handed-off tab resumes only after the declared navigation, URL, selector, or manual completion condition is satisfied and the durable handoff clear is acknowledged. -The agent must not attempt snapshots, page reads, or mutations during this interval. It should report the handoff prompt to the user and wait for the terminal tool result or an explicit user completion. +While a handoff is active, `browser_tabs` remains usable but omits the handed-off tab's page metadata and returns its identifier in `handoff`. This lets the task discover and continue unrelated work without observing the human-controlled page. + +The requesting agent must not attempt snapshots, page reads, or mutations on that tab during this interval. It should report the handoff prompt to the user and wait for the terminal tool result or an explicit user completion. ### Staged Commit diff --git a/docs/multi-agent.md b/docs/multi-agent.md index 9a27dc8..c4eeaec 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -22,17 +22,21 @@ Only three paths can grant tab ownership: 2. Chrome reports a child tab whose opener is already owned. 3. The user permits `browser_open` with `mode: "adopt_active"` for the currently active tab. -AgentTab gives owned tabs a visible task group. The group makes work legible to the user, but group membership alone never grants authority. Manual grouping does not adopt a tab. If Chrome cannot create or preserve the group, creation or adoption fails rather than keeping hidden ownership. +AgentTab tries to give owned tabs a visible task group. The group makes work legible to the user, but the persisted task ledger is authoritative: group membership never grants, transfers, or revokes ownership. Manual grouping does not adopt a tab, and a Chrome grouping failure does not block creation or adoption. `browser_open` defaults to placing a new tab in the task's existing window. `placement: "new_window"` is intentionally narrower than a general window-control capability: it is accepted only while the task owns no tabs, it always creates an unfocused normal window, and the extension grants ownership from the persisted task record. It cannot focus, resize, move, change, or close an unrelated window. `browser_handoff` remains the sole normal focus transition. -Moving an owned tab out of its task group, ungrouping it, closing it, or finding inconsistent ownership immediately revokes it. Revocation increments the tab generation and rejects queued work before it is dispatched. A child-popup grouping race does not give AgentTab authority to close a user tab. +Moving or ungrouping an owned tab changes only its presentation. Closing it or explicitly closing its task revokes ownership, increments the tab generation, and rejects queued work before dispatch. A child-popup grouping race does not give AgentTab authority to close a user tab. + +Numeric Chrome tab IDs are authoritative only within one browser session. The extension mirrors a random browser-session epoch between `chrome.storage.session` and durable local state. If the marker changes after a full browser restart, extension reload, or update, AgentTab clears persisted tab, revision, staged-action, debugger-cleanup, and handoff bindings before the native handshake. A newly reused numeric tab ID therefore cannot inherit an earlier task's authority. ## Ordering and concurrency -Mutations for one tab are ordered. Reads wait behind the relevant tab mutation so they do not observe an in-progress change. Browser-global operations use a separate global barrier. +Mutations for one tab are ordered. Reads wait behind the relevant tab mutation so they do not observe an in-progress change. Lightweight task lifecycle work is ordered per task. + +Separate tabs can make progress concurrently, including tabs owned by the same task. Separate tasks use independent host request locks and extension ownership actors, so a slow create or assertion in one task does not hold up another. Full ownership reconciliation is a cross-actor barrier. There is no browser-global queue in the routine request path; only explicit lifecycle barriers such as Pause stop unrelated work. -The current extension scheduler also maintains an ordered tail per task. It therefore does **not** promise parallel mutation execution for separate tabs in the same task, even though separate connections and host request handling can be concurrent. Different tasks can make progress independently unless a global barrier applies. Do not build an adapter that depends on cross-tab write parallelism. +Task close installs a durable tombstone before admitting further lifecycle work. It waits behind already-running task lifecycle work, rejects queued or later opens, drains the task's tab actors, persists task deletion, and only then asks Chrome to remove the tabs. Native and popup close use the same path. Tombstones are intentionally not TTL-pruned: without an explicit host retirement acknowledgement, expiry could let a delayed closed-task capability recreate ownership. On the host, `browser_act`, `browser_handoff`, and the `browser_commit` token derived from an act resolve to the same tab queue, so arrival order cannot reverse at the handoff/commit boundary. Every existing-page mutation includes `expected_page_revision`. The host and extension reject actions when ownership, document revision, or ref epoch no longer matches. A page snapshot does not grant an indefinite right to act on a later page. @@ -42,11 +46,11 @@ Pause is a barrier, not an optimistic UI toggle. It stops new admissions, lets a A host that has not completed its native handshake and reconciliation remains unavailable for browser work. The connection status can report its lifecycle, but callers must retry only after it becomes ready or the user resumes it. -## Global Your Turn blackout +## Tab-scoped Your Turn blackout -Only one handoff can be active. Starting `browser_handoff` pauses the scheduler, records the marker durably, and focuses the human's task tab. While it is active, page observations, captures, and browser work are denied across every task and connection. The host independently enforces this blackout and restores it after restart from SQLite state. +Only one handoff can be active in the popup at a time. Starting `browser_handoff` blocks and drains the declared tab, records the marker durably, detaches that tab's debugger session, and focuses it for the human. Work on that tab is denied across connections while unrelated tabs and tasks continue. The host independently enforces the exact task/tab binding and restores it after restart from SQLite state. If the extension disconnects before its binding can be reconciled, the host temporarily fails closed for all tabs. -Automation resumes only after the declared completion condition or explicit completion, capture scrubbing, an acknowledged handoff-clear event, and a non-paused state. Handoff is the sole normal AgentTab focus transition for human input. +Automation on the handed-off tab resumes only after the declared completion condition or explicit completion, tab capture scrubbing, and an acknowledged handoff-clear event. A manual global Pause remains independent and can keep all automation paused. Handoff is the sole normal AgentTab focus transition for human input. ## Consequential work across agents diff --git a/docs/rust-host.md b/docs/rust-host.md index a5beae5..eaa6002 100644 --- a/docs/rust-host.md +++ b/docs/rust-host.md @@ -35,7 +35,7 @@ Standard mode has no TCP listener or bearer token. The advanced `agenttab proxy The implemented lifecycle states are `starting`, `reconciling`, `ready`, `paused`, and terminal. Browser work is admitted only in `ready`. In `starting` or `reconciling` it returns `runtime_not_ready`; in `paused` it returns `automation_paused`; in terminal state it returns a protocol-recovery error. -Pause admission is also enforced by the extension scheduler. It closes new admission, waits for in-flight work, persists pause state, and rejects queued work before dispatch. Handoff is a global write barrier and causes a host-side blackout check both before and after request admission. +Pause admission is also enforced by the extension scheduler. It closes new admission, waits for in-flight work, persists pause state, and rejects queued work before dispatch. Handoff instead acquires the declared tab's ordered path and causes a host-side task/tab blackout check after request admission. Staged Commit tokens are resolved through SQLite to that same tab path before the per-connection queue is issued. Unrelated tabs and tasks continue; an unreconciled extension disconnect temporarily fails closed globally. ## Durable state diff --git a/docs/security.md b/docs/security.md index 952377e..9e99133 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,7 +42,9 @@ AgentTab classifies every opened or listed task tab as `automation_route: "full" ## Ownership, revisions, and human control -A tab becomes owned only when AgentTab creates it, when Chrome reports it as a child of an owned opener, or through `browser_open` with `mode: "adopt_active"`. The visible Chrome group is evidence of that ownership, not an authority grant by itself. Moving or ungrouping a tab revokes ownership and cancels queued work. A grouping failure rolls creation or adoption back rather than retaining invisible ownership. A caller may request `placement: "new_window"`, but the extension derives eligibility from its stored task record: the task must own no tabs, the window is created unfocused in normal state, and a failed group grant removes the created tab. A client-supplied ownership claim cannot authorize an existing window. +A tab becomes owned only when AgentTab creates it, when Chrome reports it as a child of an owned opener, or through `browser_open` with `mode: "adopt_active"`. The persisted task ledger is authoritative; the visible Chrome group is cosmetic and never grants, transfers, or revokes ownership. Moving, ungrouping, or failing to group a tab therefore does not interrupt unattended work. Closing the tab or task revokes ownership and cancels queued work. A caller may request `placement: "new_window"`, but the extension derives eligibility from its stored task record: the task must own no tabs and the window is created unfocused in normal state. A client-supplied ownership claim cannot authorize an existing window. + +Chrome may reuse numeric tab IDs after a full browser restart. AgentTab binds durable ownership to a browser-session epoch mirrored through `chrome.storage.session`; an epoch mismatch clears old tab-bound authority before native reconciliation. Closing a task also writes a durable tombstone, so queued or later opens cannot recreate it, including after an extension worker restart. Actions that operate on an existing page carry an expected page revision. Navigation and document replacement advance that revision; stale refs and stale revisions fail instead of being applied to a later document. @@ -58,7 +60,7 @@ Commit is a best-effort semantic barrier, not proof that an action is harmless. ## Your Turn blackout -During a `browser_handoff`, AgentTab pauses browser work and applies a global blackout across tasks. Page observations and captures are denied while the human enters information. The extension persists the active handoff before focusing the tab; the host restores the blackout from durable state after restart. Completion requires the declared condition or explicit completion, capture scrubbing, and host acknowledgement before automation resumes. +During a `browser_handoff`, AgentTab drains and blackouts the declared tab while the human enters information. The extension detaches that tab's debugger session before focus, persists the exact task/tab binding, and the host restores it from durable state after restart. Other tabs can continue unattended work. Completion requires the declared condition or explicit completion, tab capture scrubbing, and host acknowledgement before automation resumes on that tab. If extension state is unavailable during a disconnect, the host temporarily blocks all tabs until reconciliation. This reduces exposure during handoff. It cannot protect secrets from a compromised device, a malicious webpage, or browser extensions with their own access. diff --git a/docs/verification.md b/docs/verification.md index 325afbc..9a8b5cb 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -30,15 +30,17 @@ Use a disposable Chrome profile and a disposable test account. Reload the unpack 1. required `debugger` availability plus optional `scripting` grant and revocation from the popup; 2. host handshake and reconciliation to ready; -3. create and adopt-active task tabs, child popup inheritance, visible grouping, and ownership revocation after an ungroup or move; +3. create and adopt-active task tabs, child popup inheritance, best-effort visible grouping, stale cosmetic-group cleanup, ownership persistence after an ungroup or move, revocation after tab/task closure, and rejection of an open queued behind close; 4. accessibility, text, HTML, and screenshot snapshots; stale revision/ref rejection; wait conditions; and debugger detach/restart; 5. ready, working, needs-you, resumed, and finished popup states; 6. Pause, restart while paused, reconciliation, and Resume; -7. global blackout during `browser_handoff`, including host and extension restart during the handoff; +7. exact-tab blackout during `browser_handoff`, same-tab act/handoff/Commit ordering, unrelated-tab progress, fail-closed disconnect recovery, and host/extension restart during the handoff; 8. recognizable consequential controls staged without side effect, a changed target rejected, one unchanged Commit execution, and harmless controls executed without review. Never Commit a real send, purchase, delete, permission grant, or upload against a live account merely to prove the barrier. Use controlled fixtures and stop at the staged preview for live authenticated checks. +For the browser-session ownership check, persist an owned tab, fully quit Chrome, and arrange for the next session to contain an unrelated tab with the same numeric test ID. The startup hello must contain no inherited binding, and authorization against that tab must return `ownership_denied`. + ## Platform evidence Linux and Windows IPC behavior requires the platform-specific jobs in CI: diff --git a/host-rs/crates/agenttab-host/src/handoff.rs b/host-rs/crates/agenttab-host/src/handoff.rs index e515211..a78e35b 100644 --- a/host-rs/crates/agenttab-host/src/handoff.rs +++ b/host-rs/crates/agenttab-host/src/handoff.rs @@ -1,39 +1,78 @@ -use agenttab_protocol::RpcError; -use std::sync::atomic::{AtomicBool, Ordering}; +use agenttab_protocol::{NativeHandoff, RpcError}; +use parking_lot::RwLock; +use uuid::Uuid; -#[derive(Debug, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HandoffStatus { + Inactive, + Scoped { task_id: Uuid, tab_id: u64 }, + Unknown, +} + +#[derive(Debug)] pub struct HandoffState { - active: AtomicBool, + status: RwLock, +} + +impl Default for HandoffState { + fn default() -> Self { + Self { + status: RwLock::new(HandoffStatus::Inactive), + } + } } impl HandoffState { pub fn is_active(&self) -> bool { - self.active.load(Ordering::Acquire) + !matches!(*self.status.read(), HandoffStatus::Inactive) + } + + pub fn restore(&self, handoff: &NativeHandoff) { + *self.status.write() = if handoff.active { + match (handoff.task_id, handoff.tab_id) { + (Some(task_id), Some(tab_id)) => HandoffStatus::Scoped { task_id, tab_id }, + _ => HandoffStatus::Unknown, + } + } else { + HandoffStatus::Inactive + }; + } + + pub fn block_all_until_reconciled(&self) { + *self.status.write() = HandoffStatus::Unknown; } - pub fn restore(&self, active: bool) { - self.active.store(active, Ordering::Release); + pub fn begin(&self, task_id: Uuid, tab_id: u64) -> Result<(), RpcError> { + let mut status = self.status.write(); + if !matches!(*status, HandoffStatus::Inactive) { + return Err(RpcError::new( + "handoff_in_progress", + "Another AgentTab credential handoff is already active", + )); + } + *status = HandoffStatus::Scoped { task_id, tab_id }; + Ok(()) } - pub fn begin(&self) -> Result<(), RpcError> { - self.active - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .map(|_| ()) - .map_err(|_| { - RpcError::new( - "handoff_in_progress", - "Another AgentTab credential handoff is already active", - ) - }) + pub fn clear(&self) { + *self.status.write() = HandoffStatus::Inactive; } - pub fn observation_gate(&self) -> Result<(), RpcError> { - if self.is_active() { + pub fn observation_gate(&self, task_id: Uuid, tab_id: Option) -> Result<(), RpcError> { + let blocked = match *self.status.read() { + HandoffStatus::Inactive => false, + HandoffStatus::Unknown => true, + HandoffStatus::Scoped { + task_id: handoff_task, + tab_id: handoff_tab, + } => task_id == handoff_task && tab_id.map_or(true, |tab_id| tab_id == handoff_tab), + }; + if blocked { Err(RpcError::new( "handoff_blackout", - "Browser observations are disabled during credential handoff", + "Automation is disabled while the human controls this tab", ) - .with_recovery("Wait for the human to finish or cancel the active handoff.")) + .with_recovery("Wait for the human to finish or cancel this tab's active handoff.")) } else { Ok(()) } @@ -45,12 +84,30 @@ mod tests { use super::*; #[test] - fn blackout_remains_until_native_completion_is_reconciled() { + fn blackout_is_scoped_to_the_handoff_tab() { + let state = HandoffState::default(); + let task = Uuid::new_v4(); + let other_task = Uuid::new_v4(); + state.begin(task, 7).unwrap(); + assert!(state.observation_gate(task, Some(7)).is_err()); + assert!(state.observation_gate(task, Some(8)).is_ok()); + assert!(state.observation_gate(other_task, Some(7)).is_ok()); + assert!(state.begin(other_task, 9).is_err()); + state.clear(); + assert!(state.observation_gate(task, Some(7)).is_ok()); + } + + #[test] + fn unknown_disconnect_state_fails_closed_until_reconciliation() { let state = HandoffState::default(); - state.begin().unwrap(); - assert!(state.observation_gate().is_err()); - assert!(state.begin().is_err()); - state.restore(false); - assert!(state.observation_gate().is_ok()); + state.block_all_until_reconciled(); + assert!(state.observation_gate(Uuid::new_v4(), Some(1)).is_err()); + state.restore(&NativeHandoff { + active: false, + task_id: None, + tab_id: None, + started_at_ms: None, + }); + assert!(!state.is_active()); } } diff --git a/host-rs/crates/agenttab-host/src/journal.rs b/host-rs/crates/agenttab-host/src/journal.rs index 50e0243..2ad3b97 100644 --- a/host-rs/crates/agenttab-host/src/journal.rs +++ b/host-rs/crates/agenttab-host/src/journal.rs @@ -432,11 +432,7 @@ impl Journal { let Some(task_id) = tab.task_id else { continue; }; - let Some(group_id) = tab.group_id else { - return Err(JournalError::InvalidInventory( - "task-owned tab has no visible group".into(), - )); - }; + let group_id = tab.group_id.unwrap_or(-1); if tab.tab_id == 0 || tab.window_id == 0 || tab.url.is_empty() { return Err(JournalError::InvalidInventory( "task-owned tab lacks a valid tab, window, or URL".into(), @@ -858,6 +854,24 @@ impl Journal { })) } + pub fn staged_commit_tab_id( + &self, + task_id: Uuid, + host_token: &str, + ) -> Result, JournalError> { + let token_hash = capability_hash(host_token); + let connection = self.connection.lock(); + let tab_id: Option = connection + .query_row( + "SELECT tab_id FROM staged_commits + WHERE token_hash = ?1 AND task_id = ?2", + params![token_hash.as_slice(), task_id.to_string()], + |row| row.get(0), + ) + .optional()?; + tab_id.map(sqlite_to_u64).transpose() + } + pub fn approve_popup_staged_commit( &self, task_id: Uuid, @@ -1308,15 +1322,24 @@ impl Journal { } pub fn handoff_active(&self) -> Result { + Ok(self.handoff_binding()?.is_some()) + } + + pub fn handoff_binding(&self) -> Result, JournalError> { let connection = self.connection.lock(); - let active: Option = connection + let binding: Option<(i64, Option, Option)> = connection .query_row( - "SELECT active FROM handoff_state WHERE singleton = 1", + "SELECT active, task_id, tab_id FROM handoff_state WHERE singleton = 1", [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; - Ok(active == Some(1)) + match binding { + Some((1, Some(task_id), Some(tab_id))) => { + Ok(Some((Uuid::parse_str(&task_id)?, sqlite_to_u64(tab_id)?))) + } + _ => Ok(None), + } } } @@ -1723,6 +1746,18 @@ mod tests { let token = journal .store_staged_commit(&staged, std::slice::from_ref(&upload_path)) .unwrap(); + assert_eq!( + journal + .staged_commit_tab_id(task.task_id, &token.staged_token) + .unwrap(), + Some(7) + ); + assert_eq!( + journal + .staged_commit_tab_id(other.task_id, &token.staged_token) + .unwrap(), + None + ); assert!(matches!( journal.consume_staged_commit(other.task_id, &token.staged_token, Uuid::now_v7()), Err(JournalError::InvalidStagedToken) @@ -1743,6 +1778,12 @@ mod tests { }; assert_eq!(consumed.native_token, staged.native_token); assert_eq!(consumed.upload_paths, vec![upload_path.clone()]); + assert_eq!( + journal + .staged_commit_tab_id(task.task_id, &token.staged_token) + .unwrap(), + Some(7) + ); assert!(matches!( journal.consume_staged_commit(task.task_id, &token.staged_token, Uuid::now_v7()), Err(JournalError::InvalidStagedToken) @@ -1750,6 +1791,12 @@ mod tests { journal .finish_staged_commit(&consumed.native_token) .unwrap(); + assert_eq!( + journal + .staged_commit_tab_id(task.task_id, &token.staged_token) + .unwrap(), + None + ); assert!(journal .reconcile_staged_commits(std::slice::from_ref(&staged)) .unwrap() @@ -2051,6 +2098,10 @@ mod tests { }; journal.reconcile_handoff(&active).unwrap(); assert!(journal.handoff_active().unwrap()); + assert_eq!( + journal.handoff_binding().unwrap(), + Some((active.task_id.unwrap(), 7)) + ); let clear = NativeHandoff { active: false, @@ -2079,4 +2130,17 @@ mod tests { .apply_handoff_event(&clear, Some("handoff-clear-0001")) .unwrap(); } + + #[test] + fn task_ownership_survives_missing_cosmetic_group_metadata() { + let temp = tempfile::tempdir().unwrap(); + let journal = open_journal(&temp); + let task = journal.create_task(None).unwrap(); + let mut tab = owned_tab(task.task_id, 3); + tab.group_id = None; + + journal.reconcile_inventory(&[tab]).unwrap(); + + assert!(journal.verify_task_tab(task.task_id, 7, Some(3)).is_ok()); + } } diff --git a/host-rs/crates/agenttab-host/src/native.rs b/host-rs/crates/agenttab-host/src/native.rs index 878f519..0011bb3 100644 --- a/host-rs/crates/agenttab-host/src/native.rs +++ b/host-rs/crates/agenttab-host/src/native.rs @@ -129,14 +129,14 @@ impl StdioNative { Ok(None) => { self.reconcile_extension_disconnect("native messaging stream closed"); self.lifecycle.extension_disconnected(); - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.fail_all(NativeError::Disconnected); return Ok(()); } Err(error) => { self.reconcile_extension_disconnect("native messaging stream failed"); self.lifecycle.terminal(error.to_string()); - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.fail_all(NativeError::Protocol(error.to_string())); return Err(error); } @@ -144,7 +144,7 @@ impl StdioNative { if let Err(error) = self.handle_inbound(value) { self.reconcile_extension_disconnect("native protocol failed"); self.lifecycle.terminal(error.to_string()); - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.fail_all(NativeError::Protocol(error.to_string())); return Err(error); } @@ -184,7 +184,7 @@ impl StdioNative { sink.reconcile(&hello.inventory, &hello.staged_commits, &hello.handoff) .map_err(ProtocolError::InvalidNativeEvent)?; } - self.handoff.restore(hello.handoff.active); + self.handoff.restore(&hello.handoff); self.disconnected.store(false, Ordering::Release); self.lifecycle.complete_reconciliation(hello.paused); let state = if hello.paused { @@ -244,7 +244,7 @@ impl StdioNative { self.lifecycle.set_paused(event.paused); } NativeEventPayload::Handoff(handoff) => { - self.handoff.restore(handoff.active); + self.handoff.restore(&handoff); if !handoff.active { if !applied { return Err(ProtocolError::InvalidNativeEvent( @@ -261,7 +261,7 @@ impl StdioNative { } } NativeEventPayload::ExtensionDisconnected(_) => { - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.lifecycle.extension_disconnected(); self.fail_all(NativeError::Disconnected); } @@ -278,7 +278,7 @@ impl StdioNative { Some("disconnect_recovery") => { let _ = NativeDisconnectRecovery::parse(value)?; self.disconnected.store(true, Ordering::Release); - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.lifecycle.begin_reconciliation(); self.fail_all(NativeError::Disconnected); } @@ -332,7 +332,7 @@ impl StdioNative { self.reconcile_extension_disconnect("native event acknowledgement failed"); self.disconnected.store(true, Ordering::Release); self.lifecycle.extension_disconnected(); - self.handoff.restore(true); + self.handoff.block_all_until_reconciled(); self.fail_all(NativeError::Disconnected); } } @@ -567,7 +567,7 @@ mod tests { lifecycle.begin_reconciliation(); lifecycle.complete_reconciliation(false); let handoff = Arc::new(HandoffState::default()); - handoff.restore(true); + handoff.block_all_until_reconciled(); let output = SharedWriter::default(); let native = StdioNative::new(output.clone(), lifecycle, handoff.clone()); let sink = Arc::new(DurableHandoffSink::default()); diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs index 3bf2017..52ed166 100644 --- a/host-rs/crates/agenttab-host/src/runtime.rs +++ b/host-rs/crates/agenttab-host/src/runtime.rs @@ -164,7 +164,6 @@ pub struct Runtime { native: Arc, handoff: Arc, task_locks: Mutex>>>, - global_gate: RwLock<()>, tab_urls: Arc>>, upload_staging_dir: PathBuf, } @@ -187,8 +186,13 @@ impl Runtime { ) -> Result, RuntimeBuildError> { paths.prepare()?; let journal = Arc::new(Journal::open(&paths.state_db)?); - if journal.handoff_active()? { - handoff.restore(true); + if let Some((task_id, tab_id)) = journal.handoff_binding()? { + handoff.restore(&NativeHandoff { + active: true, + task_id: Some(task_id), + tab_id: Some(tab_id), + started_at_ms: None, + }); } let tab_urls = Arc::new(RwLock::new(HashMap::new())); let sink = Arc::new(JournalNativeEventSink { @@ -210,7 +214,6 @@ impl Runtime { native, handoff, task_locks: Mutex::new(HashMap::new()), - global_gate: RwLock::new(()), tab_urls, upload_staging_dir: paths.upload_staging_dir.clone(), }); @@ -462,6 +465,43 @@ impl Runtime { connection.confirm_resume_capability(confirmation, &self.journal) } + pub(crate) fn request_queue_scope( + &self, + connection: &ConnectionContext, + raw: &Value, + ) -> RequestLockScope { + let Some(method) = raw + .get("method") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + else { + return RequestLockScope::Task; + }; + self.resolved_request_lock_scope( + connection.task_id().ok().flatten(), + method, + raw.get("params").unwrap_or(&Value::Null), + ) + } + + fn resolved_request_lock_scope( + &self, + task_id: Option, + method: RpcMethod, + params: &Value, + ) -> RequestLockScope { + if method == RpcMethod::BrowserCommit { + if let (Some(task_id), Some(staged_token)) = + (task_id, params.get("staged_token").and_then(Value::as_str)) + { + if let Ok(Some(tab_id)) = self.journal.staged_commit_tab_id(task_id, staged_token) { + return RequestLockScope::Tab(tab_id); + } + } + } + request_lock_scope(method, params) + } + pub fn handle(&self, connection: &Arc, raw: Value) -> Value { let fallback_request_id = raw .get("request_id") @@ -684,7 +724,6 @@ impl Runtime { .lifecycle .gate(request.method) .err() - .or_else(|| self.handoff_blackout_error()) .or_else(|| self.guardrails.authorize(request.method, ¶ms).err()); if let Some(error) = early_error { let response = @@ -738,12 +777,9 @@ impl Runtime { true, ); } - let (_global_read, _global_write) = if request.method == RpcMethod::BrowserHandoff { - (None, Some(self.global_gate.write())) - } else { - (Some(self.global_gate.read()), None) - }; - let lock_key = request_lock_key(task_id, request.method, ¶ms_value); + let lock_scope = + self.resolved_request_lock_scope(Some(task_id), request.method, ¶ms_value); + let lock_key = request_lock_key_for_scope(task_id, lock_scope); let task_lock = { let mut locks = self.task_locks.lock(); locks.retain(|_, lock| lock.strong_count() > 0); @@ -788,7 +824,7 @@ impl Runtime { ); } }; - if let Some(error) = self.handoff_blackout_error() { + if let Some(error) = self.handoff_blackout_error(task_id, request.method, ¶ms) { let response = RpcResponse::failure(request.request_id.clone(), Outcome::NotStarted, error); return self.audited_value( @@ -1030,14 +1066,17 @@ impl Runtime { ) } - fn handoff_blackout_error(&self) -> Option { - self.handoff.is_active().then(|| { - RpcError::new( - "handoff_blackout", - "Automation is disabled while credential handoff is active", - ) - .with_recovery("Wait for the human to finish or cancel the active handoff.") - }) + fn handoff_blackout_error( + &self, + task_id: Uuid, + method: RpcMethod, + params: &MethodParams, + ) -> Option { + if matches!(method, RpcMethod::BrowserOpen | RpcMethod::BrowserTabs) { + return None; + } + let tab_id = requested_tab(params).map(|(tab_id, _)| tab_id); + self.handoff.observation_gate(task_id, tab_id).err() } fn dispatch( @@ -1051,8 +1090,8 @@ impl Runtime { mut params_value: Value, ) -> RpcResponse { let timeout = dispatch_timeout(params); - if method == RpcMethod::BrowserHandoff { - if let Err(error) = self.handoff.begin() { + if let MethodParams::Handoff(handoff) = params { + if let Err(error) = self.handoff.begin(task_id, handoff.tab_id) { return RpcResponse::failure(request_id, Outcome::NotStarted, error); } } @@ -1176,7 +1215,7 @@ impl Runtime { } }; if method == RpcMethod::BrowserHandoff && native.outcome == Outcome::NotStarted { - self.handoff.restore(false); + self.handoff.clear(); } if native.outcome == Outcome::CommitRequired { let stage_error = match native.staged.as_ref() { @@ -1340,11 +1379,15 @@ fn requested_tab(params: &MethodParams) -> Option<(u64, Option)> { | MethodParams::Wait(BrowserWaitParams { tab_id, .. }) => Some((*tab_id, None)), MethodParams::Act(params) => Some((params.tab_id, Some(params.expected_page_revision))), MethodParams::Handoff(params) => Some((params.tab_id, Some(params.expected_page_revision))), + MethodParams::Developer(params) => params + .params + .get("tab_id") + .and_then(Value::as_u64) + .map(|tab_id| (tab_id, None)), MethodParams::Open(_) | MethodParams::Tabs(_) | MethodParams::Commit(_) - | MethodParams::Status(_) - | MethodParams::Developer(_) => None, + | MethodParams::Status(_) => None, } } fn is_tab_only_request(params: &MethodParams) -> bool { @@ -1502,25 +1545,42 @@ fn native_failure(request_id: &str, error: NativeError) -> RpcResponse { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum RequestLockScope { - Global, + Task, Tab(u64), } pub(crate) fn request_lock_scope(method: RpcMethod, params: &Value) -> RequestLockScope { if matches!( method, - RpcMethod::BrowserSnapshot | RpcMethod::BrowserAct | RpcMethod::BrowserWait + RpcMethod::BrowserSnapshot + | RpcMethod::BrowserAct + | RpcMethod::BrowserWait + | RpcMethod::BrowserHandoff ) { if let Some(tab_id) = params.get("tab_id").and_then(Value::as_u64) { return RequestLockScope::Tab(tab_id); } } - RequestLockScope::Global + if method == RpcMethod::BrowserDeveloper { + if let Some(tab_id) = params + .get("params") + .and_then(|params| params.get("tab_id")) + .and_then(Value::as_u64) + { + return RequestLockScope::Tab(tab_id); + } + } + RequestLockScope::Task } +#[cfg(test)] fn request_lock_key(task_id: Uuid, method: RpcMethod, params: &Value) -> String { - match request_lock_scope(method, params) { - RequestLockScope::Global => "global".into(), + request_lock_key_for_scope(task_id, request_lock_scope(method, params)) +} + +fn request_lock_key_for_scope(task_id: Uuid, scope: RequestLockScope) -> String { + match scope { + RequestLockScope::Task => format!("{task_id}:task"), RequestLockScope::Tab(tab_id) => format!("{task_id}:tab:{tab_id}"), } } @@ -2186,10 +2246,10 @@ mod tests { } #[test] - fn browser_global_requests_share_one_lock_across_tasks() { + fn request_locks_are_task_scoped_and_tab_specific() { let first_task = Uuid::new_v4(); let second_task = Uuid::new_v4(); - assert_eq!( + assert_ne!( request_lock_key(first_task, RpcMethod::BrowserOpen, &json!({})), request_lock_key(second_task, RpcMethod::BrowserOpen, &json!({})) ); @@ -2197,6 +2257,67 @@ mod tests { request_lock_key(first_task, RpcMethod::BrowserAct, &json!({"tab_id": 7})), request_lock_key(second_task, RpcMethod::BrowserAct, &json!({"tab_id": 7})) ); + assert_eq!( + request_lock_key(first_task, RpcMethod::BrowserAct, &json!({"tab_id": 7})), + request_lock_key(first_task, RpcMethod::BrowserHandoff, &json!({"tab_id": 7})) + ); + assert_eq!( + request_lock_key( + first_task, + RpcMethod::BrowserDeveloper, + &json!({"action": "Runtime.evaluate", "params": {"tab_id": 7}}), + ), + request_lock_key(first_task, RpcMethod::BrowserAct, &json!({"tab_id": 7})) + ); + } + + #[test] + fn staged_commit_resolves_to_the_same_tab_queue_as_act_and_handoff() { + let (_temp, runtime, connection) = connected_runtime(FakeNative::normal()); + let task_id = own_tab(&runtime, &connection, 9); + let handles = runtime + .journal + .store_staged_commit( + &NativeStagedCommit { + native_token: "native-token-for-ordering".into(), + task_id, + tab_id: 3, + page_revision: 9, + effect: "submit order".into(), + fingerprint: "f".repeat(64), + expires_at_ms: current_time_ms() + 60_000, + }, + &[], + ) + .unwrap(); + let commit = json!({ + "method": "browser_commit", + "params": {"staged_token": handles.staged_token} + }); + + assert_eq!( + runtime.request_queue_scope(&connection, &commit), + RequestLockScope::Tab(3) + ); + assert_eq!( + runtime.resolved_request_lock_scope( + Some(task_id), + RpcMethod::BrowserCommit, + &commit["params"], + ), + RequestLockScope::Tab(3) + ); + assert_eq!( + request_lock_scope(RpcMethod::BrowserHandoff, &json!({"tab_id": 3})), + RequestLockScope::Tab(3) + ); + assert_eq!( + runtime.request_queue_scope( + &connection, + &json!({"method": "browser_commit", "params": {"staged_token": "missing"}}), + ), + RequestLockScope::Task + ); } #[test] @@ -2479,9 +2600,9 @@ mod tests { } #[test] - fn handoff_timeout_keeps_global_blackout_active() { + fn handoff_timeout_keeps_only_its_tab_blackout_active() { let (_temp, runtime, connection) = connected_runtime(Arc::new(TimeoutNative)); - own_tab(&runtime, &connection, 7); + let task_id = own_tab(&runtime, &connection, 7); let response = runtime.handle( &connection, json!({ @@ -2513,10 +2634,11 @@ mod tests { }), ); assert_eq!(blocked["error"]["code"], "handoff_blackout"); - runtime.handoff.restore(false); + assert!(runtime.handoff.observation_gate(task_id, Some(4)).is_ok()); + runtime.handoff.clear(); } #[test] - fn rejected_handoff_releases_global_blackout() { + fn rejected_handoff_releases_scoped_blackout() { let (_temp, runtime, connection) = connected_runtime(Arc::new(RejectedHandoffNative)); own_tab(&runtime, &connection, 7); let response = runtime.handle( diff --git a/host-rs/crates/agenttab-host/src/server.rs b/host-rs/crates/agenttab-host/src/server.rs index 31292b1..cdd8be0 100644 --- a/host-rs/crates/agenttab-host/src/server.rs +++ b/host-rs/crates/agenttab-host/src/server.rs @@ -1,6 +1,6 @@ -use crate::runtime::{request_lock_scope, RequestLockScope, Runtime}; +use crate::runtime::{RequestLockScope, Runtime}; use agenttab_protocol::{ - ConnectionInit, Outcome, ResumeCapabilityConfirm, RpcError, RpcMethod, RpcResponse, + ConnectionInit, Outcome, ResumeCapabilityConfirm, RpcError, RpcResponse, CLIENT_TO_HOST_MAX_BYTES, HOST_TO_CLIENT_MAX_BYTES, }; #[cfg(all(test, unix))] @@ -522,17 +522,6 @@ impl PendingResponse { } } -fn request_queue_scope(request: &Value) -> RequestLockScope { - let Some(method) = request - .get("method") - .cloned() - .and_then(|value| serde_json::from_value::(value).ok()) - else { - return RequestLockScope::Global; - }; - request_lock_scope(method, request.get("params").unwrap_or(&Value::Null)) -} - async fn handle_connection(runtime: Arc, mut stream: S) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, @@ -686,8 +675,9 @@ where Some((bootstrap_queue.clone(), ticket)) }; request_queues.retain(|_, queue| Arc::strong_count(queue) > 1); + let queue_scope = runtime.request_queue_scope(&connection, &request); let queue = request_queues - .entry(request_queue_scope(&request)) + .entry(queue_scope) .or_insert_with(|| Arc::new(OrderedQueue::default())) .clone(); let ticket = queue.issue(); diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index 3ab8e17..7c0559d 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -1134,11 +1134,6 @@ impl NativeTab { "native tab id, window id, and URL must be present".into(), )); } - if self.task_id.is_some() && self.group_id.is_none() { - return Err(ProtocolError::InvalidNativeMessage( - "task-owned native tabs must have a visible group".into(), - )); - } Ok(()) } } @@ -2155,6 +2150,32 @@ mod tests { assert!(NativeCloseTask::parse(unknown).is_err()); } + #[test] + fn native_inventory_accepts_owned_tabs_without_a_cosmetic_group() { + let task_id = Uuid::new_v4(); + let hello = NativeHello::parse(json!({ + "protocol": NATIVE_PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "hello", + "extension_version": "2.0.0-rc.1", + "inventory": [{ + "tab_id": 7, + "window_id": 3, + "group_id": null, + "url": "https://example.test/", + "page_revision": 1, + "task_id": task_id, + }], + "paused": false, + "handoff": {"active": false}, + "staged_commits": [], + })) + .unwrap(); + + assert_eq!(hello.inventory[0].task_id, Some(task_id)); + assert_eq!(hello.inventory[0].group_id, None); + } + #[test] fn framing_is_little_endian_and_bounded_before_allocation() { let mut bytes = Vec::new(); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index c860ecf..2ebe03d 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -21,7 +21,7 @@ import { } from "./routes"; import { MutationScheduler, NotStartedError } from "./scheduler"; import { IdempotentStartup, StartupOperationQueue } from "./startup"; -import { mutateState, readState } from "./storage"; +import { mutateState, readState, reconcileBrowserSession } from "./storage"; import { isRecord } from "./type-guards"; const RUNTIME_INSTANCE_ID = crypto.randomUUID(); @@ -43,6 +43,7 @@ const PRE_DISPATCH_ERRORS: Record = { no_active_tab: true, permissions_required: true, stale_revision: true, + task_closed: true, stale_ref: true, paused: true, developer_mode_required: true, @@ -102,7 +103,7 @@ browser = new StandardBrowserRuntime( }, ); const handoff = new HandoffController(scheduler, revisions, ownership, emit); -handoff.setScrubber(() => browser.scrubForHandoff()); +handoff.setScrubber((tabId) => browser.detach(tabId)); async function automationEnabled(): Promise { if (automationCleanupPending) return false; @@ -372,12 +373,6 @@ async function dispatch(command: NativeDispatchCommand): Promise await browser.abandonNativeStage(command.task_id, params.native_token, params.tab_id), ); } - if ((await readState()).handoff.active) { - throw Object.assign(new Error("Automation is disabled while credential handoff is active"), { - code: "handoff_blackout", - recovery: "Wait for the human to finish or cancel the active handoff.", - }); - } if ( command.method !== "browser_open" && command.method !== "browser_tabs" && @@ -387,14 +382,22 @@ async function dispatch(command: NativeDispatchCommand): Promise throw automationRequired(); } if (command.method === "browser_open") { - return completed(command.request_id, await scheduler.enqueueGlobal(() => ownership.open(command.task_id, params))); + return completed( + command.request_id, + await scheduler.enqueueTaskLifecycle(command.task_id, () => ownership.open(command.task_id, params)), + ); } if (command.method === "browser_tabs") { - const result = await scheduler.enqueueGlobal(() => ownership.inventory()); + const result = await scheduler.enqueueTaskLifecycle(command.task_id, () => ownership.inventory()); + const state = await readState(); + const handoffTabId = state.handoff.active && state.handoff.taskId === command.task_id + ? state.handoff.tabId + : null; return completed(command.request_id, { tabs: result - .filter((tab) => tab.task_id === command.task_id) + .filter((tab) => tab.task_id === command.task_id && tab.tab_id !== handoffTabId) .map((tab) => ({ ...tab, ...automationRouteFields(typeof tab.url === "string" ? tab.url : undefined) })), + ...(handoffTabId === null ? {} : { handoff: { active: true, tab_id: handoffTabId } }), }); } if (command.method === "browser_handoff") { @@ -450,14 +453,10 @@ async function dispatch(command: NativeDispatchCommand): Promise return browser.snapshot(targetTabId, params); } const revalidate = async () => { - if (!scheduler.isAccepting()) { - throw scheduler.notStarted("AgentTab stopped the active browser wait"); - } + scheduler.assertTabAccepting(targetTabId, "AgentTab stopped the active browser wait"); await ownership.assertOwned(command.task_id, targetTabId); await assertCurrentOrigin(targetTabId, command.origin_policy); - if (!scheduler.isAccepting()) { - throw scheduler.notStarted("AgentTab stopped the active browser wait"); - } + scheduler.assertTabAccepting(targetTabId, "AgentTab stopped the active browser wait"); }; return browser.wait(targetTabId, params, revalidate); }); @@ -510,7 +509,9 @@ nativeBridge = new NativeBridge( ); async function initializeRuntime(): Promise { + await reconcileBrowserSession(); const state = await readState(); + scheduler.restoreClosedTasks(Object.keys(state.taskTombstones)); browser.restoreDebuggerCandidates(state.automationCleanup.tabIds); automationCleanupPending = automationCleanupPending || @@ -524,7 +525,8 @@ async function initializeRuntime(): Promise { automationRevocationGeneration, state.automationCleanup.generation, ); - scheduler.setInitialPaused(state.paused || state.handoff.active); + scheduler.setInitialPaused(state.paused); + if (state.handoff.active) await scheduler.blockTab(state.handoff.tabId); if (await automationEnabled()) { scheduler.restorePermissions(); } else { @@ -597,11 +599,7 @@ chrome.tabs.onUpdated.addListener((updatedTabId, changeInfo) => { await revisions.markNavigation(updatedTabId); } if ("groupId" in changeInfo) { - const revoked = await ownership.revokeIfMoved(updatedTabId); - if (revoked) { - await handoff.cancelForTab(updatedTabId); - await browser.detach(updatedTabId); - } + await ownership.revokeIfMoved(updatedTabId); } if (typeof changeInfo.url === "string" || changeInfo.status === "complete") { await ownership.publishInventory(); @@ -615,7 +613,6 @@ const reconcileOwnedTab = (tabId: number): void => { }); }; chrome.tabs.onAttached.addListener(reconcileOwnedTab); -chrome.tabs.onDetached.addListener(reconcileOwnedTab); chrome.tabGroups.onRemoved.addListener((group: { id?: number }) => { if (!Number.isInteger(group.id)) return; runAfterStart(async () => { diff --git a/packages/extension/src/handoff.ts b/packages/extension/src/handoff.ts index f886041..3018ab9 100644 --- a/packages/extension/src/handoff.ts +++ b/packages/extension/src/handoff.ts @@ -11,7 +11,7 @@ type OriginGuard = () => Promise; export class HandoffController { private transitionTail: Promise = Promise.resolve(); - private scrubber: (() => Promise) | null = null; + private scrubber: ((tabId: number) => Promise) | null = null; constructor( private readonly scheduler: MutationScheduler, @@ -20,7 +20,7 @@ export class HandoffController { private readonly emit: EventSink, ) { } - setScrubber(scrubber: () => Promise): void { + setScrubber(scrubber: (tabId: number) => Promise): void { this.scrubber = scrubber; } @@ -63,7 +63,7 @@ export class HandoffController { private async restoreNow(): Promise { const state = await readState(); if (!state.handoff.active) return; - const barrier = this.scheduler.pause(); + const barrier = this.scheduler.blockTab(state.handoff.tabId); await barrier; const restored = await readState(); if (!restored.handoff.active) return; @@ -122,7 +122,7 @@ export class HandoffController { timeoutMs: Number(timeoutMs), }; - const barrier = this.scheduler.pause(); + const barrier = this.scheduler.blockTab(numericTabId); let recorded = false; try { await mutateState((state) => { @@ -139,6 +139,7 @@ export class HandoffController { await this.ownership.assertOwned(taskId, numericTabId); await this.revisions.assertExpected(numericTabId, next.expectedRevision); if (originGuard) await originGuard(); + await this.scrubber?.(numericTabId); await this.ownership.setTaskState(taskId, "needs_user"); chrome.alarms.create(HANDOFF_ALARM, { when: startedAt + next.timeoutMs }); const tab = await chrome.tabs.update(numericTabId, { active: true }); @@ -177,8 +178,7 @@ export class HandoffController { }); await chrome.alarms.clear(HANDOFF_ALARM); } - const recovered = await readState(); - if (!recovered.paused && !recovered.handoff.active) this.scheduler.resume(); + this.scheduler.unblockTab(numericTabId); throw error; } } @@ -193,7 +193,7 @@ export class HandoffController { if (completed && !(await this.completionMatched(handoff))) { return { completed: false, reason: "The handoff completion condition has not been met" }; } - await this.scrubber?.(); + await this.scrubber?.(handoff.tabId); const eventId = crypto.randomUUID(); await mutateState((state) => { const active = state.handoff; @@ -225,8 +225,7 @@ export class HandoffController { }); await chrome.alarms.clear(HANDOFF_ALARM); await this.ownership.setTaskState(handoff.taskId, "working"); - const current = await readState(); - if (!current.paused && !current.handoff.active) this.scheduler.resume(); + this.scheduler.unblockTab(handoff.tabId); } private async cancelMatchingNow( @@ -234,7 +233,7 @@ export class HandoffController { ): Promise { const handoff = (await readState()).handoff; if (!handoff.active || !matches(handoff)) return false; - await this.scrubber?.(); + await this.scrubber?.(handoff.tabId); const eventId = crypto.randomUUID(); const pendingEventId = await mutateState((state) => { const active = state.handoff; @@ -259,19 +258,8 @@ export class HandoffController { } private async resumeNow(): Promise { - const state = await readState(); - if (state.handoff.active) { - throw Object.assign(new Error("Finish or cancel credential handoff before resuming"), { - code: "handoff_in_progress", - }); - } await this.ownership.reconcile(); await mutateState((next) => { - if (next.handoff.active) { - throw Object.assign(new Error("Finish or cancel credential handoff before resuming"), { - code: "handoff_in_progress", - }); - } next.paused = false; }); this.scheduler.resume(); diff --git a/packages/extension/src/native.ts b/packages/extension/src/native.ts index b948f05..7d85e15 100644 --- a/packages/extension/src/native.ts +++ b/packages/extension/src/native.ts @@ -329,7 +329,7 @@ export class NativeBridge { } const state = await readState(); if (this.port !== port) return; - if (!state.paused && !state.handoff.active) this.scheduler.resume(); + if (!state.paused) this.scheduler.resume(); await this.onReady(); } diff --git a/packages/extension/src/ownership.ts b/packages/extension/src/ownership.ts index 75e7513..a227ae4 100644 --- a/packages/extension/src/ownership.ts +++ b/packages/extension/src/ownership.ts @@ -5,6 +5,10 @@ import { RevisionTracker } from "./revisions"; const GROUP_COLORS: readonly TaskColor[] = ["purple", "cyan", "green", "yellow", "orange", "red", "pink", "blue"]; const NO_GROUP = -1; +const OWNERSHIP_REVOKED = { + code: "ownership_revoked", + message: "Tab ownership changed before this operation was dispatched", +}; type EventSink = (event: string, payload: Record) => void; @@ -36,7 +40,9 @@ function groupTitle(task: TaskRecord, developerMode: boolean): string { } export class OwnershipLedger { - private transitionTail: Promise = Promise.resolve(); + private globalTransitionTail: Promise = Promise.resolve(); + private readonly actorTransitionTails = new Map>(); + private readonly cosmeticTransitionTails = new Map>(); private inventoryTail: Promise = Promise.resolve(); constructor( @@ -46,15 +52,19 @@ export class OwnershipLedger { ) { } reconcile(): Promise { - return this.serialize(() => this.reconcileNow()); + return this.serializeGlobal(() => this.reconcileNow()); } assertOwned(taskId: string, tabId: number): Promise { - return this.serialize(() => this.assertOwnedNow(taskId, tabId)); + return this.serializeActors( + [this.taskActor(taskId), this.tabActor(tabId)], + () => this.assertOwnedNow(taskId, tabId), + ); } - assertOwnedTab(tabId: number): Promise { - return this.serialize(async () => { + async assertOwnedTab(tabId: number): Promise { + const taskId = await this.taskIdForTab(tabId); + return this.serializeActors(this.owningTabActors(tabId, taskId), async () => { const state = await readState(); const task = Object.values(state.tasks).find((candidate) => candidate.tabIds.includes(tabId)); if (!task) { @@ -67,33 +77,83 @@ export class OwnershipLedger { } open(taskId: string, params: Record): Promise> { - return this.serialize(() => this.openNow(taskId, params)); + return this.serializeActor(this.taskActor(taskId), () => this.openNow(taskId, params)); } adoptActive(taskId: string): Promise> { - return this.serialize(() => this.adoptActiveNow(taskId)); + return this.serializeActor(this.taskActor(taskId), () => this.adoptActiveNow(taskId)); } - adoptOwnedChild(tab: TabLike, sourceTabId?: number): Promise { - return this.serialize(() => this.adoptOwnedChildNow(tab, sourceTabId)); + async adoptOwnedChild(tab: TabLike, sourceTabId?: number): Promise { + const childTabId = Number.isInteger(tab.id) ? tab.id as number : -1; + const openerTabId = Number.isInteger(sourceTabId) + ? sourceTabId as number + : Number.isInteger(tab.openerTabId) + ? tab.openerTabId as number + : null; + const taskId = openerTabId === null ? null : await this.taskIdForTab(openerTabId); + const actors = [this.tabActor(childTabId)]; + if (openerTabId !== null) actors.push(this.tabActor(openerTabId)); + if (taskId !== null) actors.push(this.taskActor(taskId)); + return this.serializeActors( + actors, + () => this.adoptOwnedChildNow(tab, sourceTabId), + ); } - revokeIfMoved(tabId: number): Promise { - return this.serialize(() => this.revokeIfMovedNow(tabId)); + async revokeIfMoved(tabId: number): Promise { + const taskId = await this.taskIdForTab(tabId); + return this.serializeActors( + this.owningTabActors(tabId, taskId), + () => this.serializeCosmeticActors( + this.owningTabActors(tabId, taskId), + () => this.revokeIfMovedNow(tabId), + ), + ); } - revoke( + async revoke( tabId: number, event: "ownership_revoked" | "group_membership_changed" | "tab_removed", ): Promise { - return this.serialize(() => this.revokeNow(tabId, event)); + const taskId = await this.taskIdForTab(tabId); + return this.serializeActors( + this.owningTabActors(tabId, taskId), + () => this.serializeCosmeticActors( + this.owningTabActors(tabId, taskId), + () => this.revokeNow(tabId, event), + ), + ); } closeTask(taskId: string): Promise { - return this.serialize(() => this.closeTaskNow(taskId)); + return this.scheduler.enqueueTaskClose( + taskId, + () => this.closeTaskAfterDrainingTabs(taskId), + ); } - async setTaskState( + setTaskState( + taskId: string, + taskState: "working" | "needs_user" | "completed", + ): Promise { + return this.serializeActor( + this.taskActor(taskId), + async () => { + const task = (await readState()).tasks[taskId]; + const actors = [ + this.taskActor(taskId), + ...(task?.tabIds ?? []).map((tabId) => this.tabActor(tabId)), + ]; + return this.serializeCosmeticActors( + actors, + () => this.setTaskStateNow(taskId, taskState), + ); + }, + ); + } + + private async setTaskStateNow( taskId: string, taskState: "working" | "needs_user" | "completed", ): Promise { @@ -103,39 +163,55 @@ export class OwnershipLedger { task.state = taskState; task.updatedAt = Date.now(); return { + taskId: task.taskId, groupId: task.groupId, title: groupTitle(task, state.developerMode), color: task.color, }; }); - if (updated?.groupId !== null && updated?.groupId !== undefined) { - await chrome.tabGroups.update(updated.groupId, { + if (updated?.groupId === null || updated?.groupId === undefined) return; + const groupId = updated.groupId; + const current = (await readState()).tasks[updated.taskId]; + if (!current || current.groupId !== groupId) return; + if (await this.groupContainsOwnedTab(groupId, current.tabIds)) { + await chrome.tabGroups.update(groupId, { title: updated.title, color: updated.color, collapsed: false, }).catch(() => undefined); + } else { + await this.clearCosmeticGroup(updated.taskId, groupId); } } - async setDeveloperMode(enabled: boolean): Promise { + setDeveloperMode(enabled: boolean): Promise { + return this.serializeGlobal(() => this.setDeveloperModeNow(enabled)); + } + + private async setDeveloperModeNow(enabled: boolean): Promise { const tasks = await mutateState((state) => { state.developerMode = enabled; return Object.values(state.tasks).map((task) => ({ + taskId: task.taskId, groupId: task.groupId, + tabIds: [...task.tabIds], title: groupTitle(task, enabled), color: task.color, })); }); await Promise.all( - tasks - .filter((task): task is typeof task & { groupId: number } => task.groupId !== null) - .map((task) => - chrome.tabGroups.update(task.groupId, { + tasks.map(async (task) => { + if (task.groupId === null) return; + if (await this.groupContainsOwnedTab(task.groupId, task.tabIds)) { + await chrome.tabGroups.update(task.groupId, { title: task.title, color: task.color, collapsed: false, - }).catch(() => undefined), - ), + }).catch(() => undefined); + } else { + await this.clearCosmeticGroup(task.taskId, task.groupId); + } + }), ); } @@ -147,16 +223,15 @@ export class OwnershipLedger { ); const inventory: Array> = []; for (const task of Object.values(state.tasks)) { - if (task.groupId === null) continue; for (const tabId of task.tabIds) { const tab = byId.get(tabId); - if (!tab || tab.groupId !== task.groupId || !Number.isInteger(tab.windowId)) continue; + if (!tab || !Number.isInteger(tab.windowId)) continue; const url = tab.pendingUrl || tab.url; if (!url) continue; inventory.push({ tab_id: tabId, window_id: tab.windowId, - group_id: task.groupId, + group_id: Number.isInteger(tab.groupId) ? tab.groupId : NO_GROUP, url, page_revision: await this.revisions.ensure(tabId), task_id: task.taskId, @@ -173,7 +248,7 @@ export class OwnershipLedger { async taskIdForTab(tabId: number): Promise { const state = await readState(); const task = Object.values(state.tasks).find((candidate) => candidate.tabIds.includes(tabId)); - return task?.groupId === null ? null : task?.taskId ?? null; + return task?.taskId ?? null; } async hasOwnedGroup(groupId: number): Promise { @@ -187,24 +262,38 @@ export class OwnershipLedger { tabs.filter((tab) => Number.isInteger(tab.id)).map((tab) => [tab.id as number, tab]), ); const changedTasks: Array<{ taskId: string; count: number; revokedTabIds: number[] }> = []; + const cosmeticChanges: Array<{ taskId: string; count: number }> = []; await mutateState((state) => { for (const task of Object.values(state.tasks)) { - const revokedTabIds = task.tabIds.filter((tabId) => { - const tab = byId.get(tabId); - return !tab || task.groupId === null || tab.groupId !== task.groupId; - }); - if (revokedTabIds.length === 0) continue; - for (const tabId of revokedTabIds) this.scheduler.revokeTab(tabId); - task.tabIds = task.tabIds.filter((tabId) => !revokedTabIds.includes(tabId)); - if (task.tabIds.length === 0) task.groupId = null; + const revokedTabIds = task.tabIds.filter((tabId) => !byId.has(tabId)); + const retainedTabIds = task.tabIds.filter((tabId) => byId.has(tabId)); + const staleGroup = task.groupId !== null && !retainedTabIds.some( + (tabId) => byId.get(tabId)?.groupId === task.groupId, + ); + if (revokedTabIds.length === 0 && !staleGroup) continue; + for (const tabId of revokedTabIds) this.scheduler.blockTab(tabId, OWNERSHIP_REVOKED); + task.tabIds = retainedTabIds; + if (task.tabIds.length === 0 || staleGroup) task.groupId = null; task.updatedAt = Date.now(); - changedTasks.push({ taskId: task.taskId, count: task.tabIds.length, revokedTabIds }); + if (revokedTabIds.length > 0) { + changedTasks.push({ taskId: task.taskId, count: task.tabIds.length, revokedTabIds }); + } + if (staleGroup) cosmeticChanges.push({ taskId: task.taskId, count: task.tabIds.length }); } }); for (const changed of changedTasks) { - for (const tabId of changed.revokedTabIds) await this.revisions.remove(tabId); + for (const tabId of changed.revokedTabIds) { + await this.revisions.remove(tabId); + this.scheduler.retireTabWhenIdle(tabId); + } this.emit("ownership_revoked", { task_id: changed.taskId, tab_count: changed.count }); } + for (const changed of cosmeticChanges) { + this.emit("group_membership_changed", { + task_id: changed.taskId, + tab_count: changed.count, + }); + } await this.emitInventory(); return changedTasks.flatMap((changed) => changed.revokedTabIds); } @@ -213,15 +302,18 @@ export class OwnershipLedger { const state = await readState(); const task = state.tasks[taskId]; const ownerCount = Object.values(state.tasks).filter((candidate) => candidate.tabIds.includes(tabId)).length; - if (!task || !task.tabIds.includes(tabId) || task.groupId === null || ownerCount !== 1) { + if (!task || !task.tabIds.includes(tabId) || ownerCount !== 1) { throw Object.assign(new Error("Tab is not owned by this AgentTab task"), { code: "ownership_denied", }); } const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; - if (!tab || tab.groupId !== task.groupId) { - await this.revokeNow(tabId, "ownership_revoked"); - throw Object.assign(new Error("Tab left its AgentTab task group"), { + if (!tab) { + await this.serializeCosmeticActors( + [this.taskActor(taskId), this.tabActor(tabId)], + () => this.revokeNow(tabId, "ownership_revoked"), + ); + throw Object.assign(new Error("The task-owned tab no longer exists"), { code: "ownership_revoked", }); } @@ -229,7 +321,6 @@ export class OwnershipLedger { const currentTask = current.tasks[taskId]; if ( !currentTask || - currentTask.groupId !== task.groupId || !currentTask.tabIds.includes(tabId) || Object.values(current.tasks).filter((candidate) => candidate.tabIds.includes(tabId)).length !== 1 ) { @@ -241,6 +332,7 @@ export class OwnershipLedger { } private async openNow(taskId: string, params: Record): Promise> { + await this.assertTaskOpen(taskId); if (params.mode === "adopt_active") return this.adoptActiveNow(taskId); if (params.mode !== "create") { throw Object.assign(new Error("browser_open mode must be create or adopt_active"), { @@ -273,10 +365,10 @@ export class OwnershipLedger { tab = await this.createTabInDedicatedWindow(url); } else { let taskWindowId: number | undefined; - if (existingTask && existingTask.groupId !== null) { + if (existingTask) { for (const tabId of existingTask.tabIds) { const candidate = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; - if (candidate?.groupId === existingTask.groupId && Number.isInteger(candidate.windowId)) { + if (candidate && typeof candidate.windowId === "number" && Number.isInteger(candidate.windowId)) { taskWindowId = candidate.windowId; break; } @@ -339,14 +431,6 @@ export class OwnershipLedger { const parent = (await chrome.tabs.get(openerTabId).catch(() => null)) as TabLike | null; const child = (await chrome.tabs.get(childTabId).catch(() => null)) as TabLike | null; if (!parent || !child) return; - if ( - sourceTabId === undefined && - child.groupId !== undefined && - child.groupId !== NO_GROUP && - child.groupId !== ownedParent.groupId - ) { - return; - } if ( Number.isInteger(parent.windowId) && Number.isInteger(child.windowId) && @@ -372,12 +456,19 @@ export class OwnershipLedger { private async revokeIfMovedNow(tabId: number): Promise { const state = await readState(); - const task = Object.values(state.tasks).find((candidate) => candidate.tabIds.includes(tabId)); - if (!task) return false; - const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; - if (!tab || task.groupId === null || tab.groupId !== task.groupId) { - return this.revokeNow(tabId, "group_membership_changed"); + const current = Object.values(state.tasks).find((candidate) => candidate.tabIds.includes(tabId)); + if (!current) return false; + if ( + current.groupId !== null && + !(await this.groupContainsOwnedTab(current.groupId, current.tabIds)) + ) { + await this.clearCosmeticGroup(current.taskId, current.groupId); } + this.emit("group_membership_changed", { + task_id: current.taskId, + tab_count: current.tabIds.length, + }); + await this.emitInventory(); return false; } @@ -385,32 +476,87 @@ export class OwnershipLedger { tabId: number, event: "ownership_revoked" | "group_membership_changed" | "tab_removed", ): Promise { - this.scheduler.revokeTab(tabId); + this.scheduler.blockTab(tabId, OWNERSHIP_REVOKED); + const before = await readState(); + const ownerBefore = Object.values(before.tasks).find((task) => task.tabIds.includes(tabId)); + const retainedGroup = ownerBefore?.groupId === null || ownerBefore?.groupId === undefined + ? null + : (await this.groupContainsOwnedTab( + ownerBefore.groupId, + ownerBefore.tabIds.filter((ownedTabId) => ownedTabId !== tabId), + )) + ? ownerBefore.groupId + : null; const changed = await mutateState((state) => { for (const task of Object.values(state.tasks)) { if (!task.tabIds.includes(tabId)) continue; task.tabIds = task.tabIds.filter((ownedTabId) => ownedTabId !== tabId); - if (task.tabIds.length === 0) task.groupId = null; + if (task.tabIds.length === 0) { + task.groupId = null; + } else if (task.groupId === ownerBefore?.groupId) { + task.groupId = retainedGroup; + } task.updatedAt = Date.now(); return { taskId: task.taskId, count: task.tabIds.length }; } return null; }); - if (!changed) return false; + if (!changed) { + this.scheduler.retireTabWhenIdle(tabId); + return false; + } await this.revisions.remove(tabId); this.emit(event, { task_id: changed.taskId, tab_count: changed.count, }); await this.emitInventory(); + this.scheduler.retireTabWhenIdle(tabId); return true; } + private async closeTaskAfterDrainingTabs(taskId: string): Promise { + const drainedTabIds = new Set(); + while (true) { + const snapshotTabIds = [...((await readState()).tasks[taskId]?.tabIds ?? [])]; + const newlyDiscovered = snapshotTabIds.filter((tabId) => !drainedTabIds.has(tabId)); + const initialDrains = newlyDiscovered.map((tabId) => { + drainedTabIds.add(tabId); + return this.scheduler.blockTab(tabId, OWNERSHIP_REVOKED); + }); + await Promise.all(initialDrains); + + const attempt = await this.serializeActors( + [this.taskActor(taskId), ...snapshotTabIds.map((tabId) => this.tabActor(tabId))], + async () => { + const currentTabIds = [...((await readState()).tasks[taskId]?.tabIds ?? [])]; + const undrained = currentTabIds.filter((tabId) => !drainedTabIds.has(tabId)); + if (undrained.length > 0) { + const drains = undrained.map((tabId) => { + drainedTabIds.add(tabId); + return this.scheduler.blockTab(tabId, OWNERSHIP_REVOKED); + }); + return { kind: "drain" as const, drains }; + } + const closedTabIds = await this.serializeCosmeticActors( + [this.taskActor(taskId), ...currentTabIds.map((tabId) => this.tabActor(tabId))], + () => this.closeTaskNow(taskId), + ); + return { kind: "closed" as const, closedTabIds }; + }, + ); + if (attempt.kind === "closed") return attempt.closedTabIds; + // The task actor is released before waiting, so an active tab operation + // can finish ownership revalidation instead of deadlocking with close. + await Promise.all(attempt.drains); + } + } + private async closeTaskNow(taskId: string): Promise { - const existing = (await readState()).tasks[taskId]; - if (!existing) return []; - for (const tabId of existing.tabIds) this.scheduler.revokeTab(tabId); const tabIds = await mutateState((state) => { + if (state.taskTombstones[taskId] === undefined) { + state.taskTombstones[taskId] = Date.now(); + } const task = state.tasks[taskId]; if (!task) return []; const ownedTabIds = [...task.tabIds]; @@ -432,43 +578,78 @@ export class OwnershipLedger { } this.emit("tab_removed", { task_id: taskId, tab_count: 0 }); await this.emitInventory(); + for (const tabId of tabIds) this.scheduler.retireTabWhenIdle(tabId); return closedTabIds; } - private async grant(taskId: string, tabId: number, name: string): Promise { - const before = (await chrome.tabs.get(tabId)) as TabLike; + private grant(taskId: string, tabId: number, name: string): Promise { + return this.serializeCosmeticActors( + [this.taskActor(taskId), this.tabActor(tabId)], + () => this.grantNow(taskId, tabId, name), + ); + } + + private async grantNow(taskId: string, tabId: number, name: string): Promise { + await chrome.tabs.get(tabId); const state = await readState(); + if (state.taskTombstones[taskId] !== undefined) throw this.closedTaskError(); const currentOwner = Object.values(state.tasks).find((task) => task.tabIds.includes(tabId)); if (currentOwner && currentOwner.taskId !== taskId) { throw Object.assign(new Error("Tab is already owned by another AgentTab task"), { code: "ownership_denied", }); } - if (currentOwner && currentOwner.groupId === before.groupId && before.groupId !== NO_GROUP) { + if (currentOwner) { await this.revisions.ensure(tabId); return; } - if (currentOwner) await this.revokeNow(tabId, "ownership_revoked"); const refreshed = await readState(); const refreshedTask = refreshed.tasks[taskId]; - let groupId: number; - try { - if (refreshedTask?.groupId !== null && refreshedTask?.groupId !== undefined) { - groupId = await chrome.tabs.group({ tabIds: [tabId], groupId: refreshedTask.groupId }); - } else { - groupId = await chrome.tabs.group({ tabIds: [tabId] }); - } - const previewTask: TaskRecord = refreshedTask ?? { + let groupId: number | null = refreshedTask?.groupId ?? null; + if ( + refreshedTask && + groupId !== null && + !(await this.groupContainsOwnedTab(groupId, refreshedTask.tabIds)) + ) { + await this.clearCosmeticGroup(taskId, groupId); + groupId = null; + } + const previewTask: TaskRecord = refreshedTask + ? { ...refreshedTask, groupId } + : { taskId, name, - groupId, + groupId: null, tabIds: [], color: taskColor(taskId), state: "working", createdAt: Date.now(), updatedAt: Date.now(), }; + await this.revisions.ensure(tabId); + await mutateState((next) => { + if (next.taskTombstones[taskId] !== undefined) throw this.closedTaskError(); + const conflictingTask = Object.values(next.tasks).find( + (task) => task.taskId !== taskId && task.tabIds.includes(tabId), + ); + if (conflictingTask) { + throw Object.assign(new Error("Tab ownership changed while it was being adopted"), { + code: "ownership_denied", + }); + } + const task = next.tasks[taskId] ?? previewTask; + if (!task.tabIds.includes(tabId)) task.tabIds.push(tabId); + task.updatedAt = Date.now(); + next.tasks[taskId] = task; + }); + + try { + if (groupId !== null) { + groupId = await chrome.tabs.group({ tabIds: [tabId], groupId }); + } else { + groupId = await chrome.tabs.group({ tabIds: [tabId] }); + } await chrome.tabGroups.update(groupId, { title: groupTitle(previewTask, refreshed.developerMode), color: previewTask.color, @@ -476,32 +657,12 @@ export class OwnershipLedger { }); const grouped = (await chrome.tabs.get(tabId)) as TabLike; if (grouped.groupId !== groupId) throw new Error("Chrome did not preserve the requested tab group"); - await this.revisions.ensure(tabId); await mutateState((next) => { - const conflictingTask = Object.values(next.tasks).find( - (task) => task.taskId !== taskId && task.tabIds.includes(tabId), - ); - if (conflictingTask) { - throw Object.assign(new Error("Tab ownership changed while grouping"), { - code: "ownership_denied", - }); - } - const task = next.tasks[taskId] ?? previewTask; - task.groupId = groupId; - if (!task.tabIds.includes(tabId)) task.tabIds.push(tabId); - task.updatedAt = Date.now(); - next.tasks[taskId] = task; - }); - } catch (error) { - if (before.groupId !== undefined && before.groupId >= 0) { - await chrome.tabs.group({ tabIds: [tabId], groupId: before.groupId }).catch(() => undefined); - } else { - await chrome.tabs.ungroup([tabId]).catch(() => undefined); - } - if (error instanceof Error && "code" in error) throw error; - throw Object.assign(new Error(`Could not visibly group AgentTab tab: ${String(error)}`), { - code: "grouping_failed", + const task = next.tasks[taskId]; + if (task?.tabIds.includes(tabId)) task.groupId = groupId; }); + } catch { + // Tab groups are a best-effort visual aid. The durable task ledger is authoritative. } this.emit("group_membership_changed", { task_id: taskId, @@ -510,6 +671,39 @@ export class OwnershipLedger { await this.emitInventory(); } + private async groupContainsOwnedTab(groupId: number, tabIds: readonly number[]): Promise { + for (const tabId of tabIds) { + const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; + if (tab?.groupId === groupId) return true; + } + return false; + } + + private async clearCosmeticGroup(taskId: string, expectedGroupId: number): Promise { + await mutateState((state) => { + const task = state.tasks[taskId]; + if (!task || task.groupId !== expectedGroupId) return; + task.groupId = null; + task.updatedAt = Date.now(); + }); + } + + private async assertTaskOpen(taskId: string): Promise { + if ((await readState()).taskTombstones[taskId] !== undefined) { + throw this.closedTaskError(); + } + } + + private closedTaskError(): Error & { code: string; recovery: string } { + return Object.assign( + new Error("This AgentTab task is closed and cannot own new tabs"), + { + code: "task_closed", + recovery: "Start a new AgentTab task instead of reusing a closed task capability.", + }, + ); + } + private async createTabInDedicatedWindow(url: string): Promise { let windowId: number | undefined; let tabId: number | undefined; @@ -584,7 +778,7 @@ export class OwnershipLedger { return { tab_id: tabId, window_id: tab.windowId, - group_id: tab.groupId, + group_id: Number.isInteger(tab.groupId) ? tab.groupId : NO_GROUP, url, page_revision: await this.revisions.current(tabId), tab_count: owner?.tabIds.length ?? 0, @@ -600,12 +794,77 @@ export class OwnershipLedger { await publication; } - private serialize(operation: () => Promise): Promise { - const next = this.transitionTail.then(operation); - this.transitionTail = next.then( + private taskActor(taskId: string): string { + return `task:${taskId}`; + } + + private tabActor(tabId: number): string { + return `tab:${tabId}`; + } + + private serializeActor(actor: string, operation: () => Promise): Promise { + return this.serializeActors([actor], operation); + } + + private owningTabActors(tabId: number, taskId: string | null): string[] { + return taskId === null + ? [this.tabActor(tabId)] + : [this.taskActor(taskId), this.tabActor(tabId)]; + } + + private serializeActors(actors: readonly string[], operation: () => Promise): Promise { + const uniqueActors = [...new Set(actors)].sort(); + const priorGlobal = this.globalTransitionTail; + const priorActors = uniqueActors.map( + (actor) => this.actorTransitionTails.get(actor) ?? Promise.resolve(), + ); + const next = priorGlobal.then(() => Promise.all(priorActors)).then(operation); + const tail = next.then( + () => undefined, + () => undefined, + ); + for (const actor of uniqueActors) this.actorTransitionTails.set(actor, tail); + void tail.then(() => { + for (const actor of uniqueActors) { + if (this.actorTransitionTails.get(actor) === tail) this.actorTransitionTails.delete(actor); + } + }); + return next; + } + + private serializeGlobal(operation: () => Promise): Promise { + const priorGlobal = this.globalTransitionTail; + const priorActors = [...this.actorTransitionTails.values()]; + const next = priorGlobal.then(() => Promise.all(priorActors)).then(operation); + this.globalTransitionTail = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + private serializeCosmeticActors( + actors: readonly string[], + operation: () => Promise, + ): Promise { + const uniqueActors = [...new Set(actors)].sort(); + const priorActors = uniqueActors.map( + (actor) => this.cosmeticTransitionTails.get(actor) ?? Promise.resolve(), + ); + const next = Promise.all(priorActors).then(operation); + const tail = next.then( () => undefined, () => undefined, ); + for (const actor of uniqueActors) this.cosmeticTransitionTails.set(actor, tail); + void tail.then(() => { + for (const actor of uniqueActors) { + if (this.cosmeticTransitionTails.get(actor) === tail) { + this.cosmeticTransitionTails.delete(actor); + } + } + }); return next; } + } diff --git a/packages/extension/src/protocol.ts b/packages/extension/src/protocol.ts index 1dc7f22..a654a14 100644 --- a/packages/extension/src/protocol.ts +++ b/packages/extension/src/protocol.ts @@ -793,7 +793,6 @@ function assertNativeTab(value: unknown): asserts value is NativeTab { if (!isIntegerInRange(tab.page_revision, 0)) commandError("page_revision must be a non-negative integer"); if (tab.task_id !== undefined && tab.task_id !== null) { assertUuid(tab.task_id, "task_id"); - if (tab.group_id < 0) commandError("task-owned native tabs must have a visible group"); } } diff --git a/packages/extension/src/scheduler.ts b/packages/extension/src/scheduler.ts index 949e678..6aa1666 100644 --- a/packages/extension/src/scheduler.ts +++ b/packages/extension/src/scheduler.ts @@ -20,10 +20,12 @@ export class MutationScheduler { message: "AgentTab is paused", }; private readonly tabTails = new Map>(); - private readonly taskTails = new Map>(); + private readonly taskLifecycleTails = new Map>(); private globalTail: Promise = Promise.resolve(); private readonly generations = new Map(); private readonly generationReasons = new Map(); + private readonly blockedTabs = new Map(); + private readonly closedTasks = new Set(); private readonly pending = new Set>(); setInitialPaused(paused: boolean): void { @@ -45,6 +47,20 @@ export class MutationScheduler { return this.accepting; } + restoreClosedTasks(taskIds: readonly string[]): void { + for (const taskId of taskIds) this.closedTasks.add(taskId); + } + + isTaskClosed(taskId: string): boolean { + return this.closedTasks.has(taskId); + } + + assertTabAccepting(tabId: number, pausedMessage?: string): void { + if (!this.accepting) throw this.notStarted(pausedMessage); + const blocked = this.blockedTabs.get(tabId); + if (blocked) throw new NotStartedError(blocked.code, blocked.message); + } + notStarted(message?: string): NotStartedError { if (!this.permissionsAvailable) { return new NotStartedError( @@ -58,17 +74,33 @@ export class MutationScheduler { ); } + taskClosed(): NotStartedError { + return new NotStartedError( + "task_closed", + "This AgentTab task is closed and cannot admit more browser work", + ); + } + enqueueTab(taskId: string, tabId: number, work: Work): Promise { - if (!this.accepting) return Promise.reject(this.notStarted()); + if (this.closedTasks.has(taskId)) return Promise.reject(this.taskClosed()); + try { + this.assertTabAccepting(tabId); + } catch (error) { + return Promise.reject(error); + } const admissionEpoch = this.admissionEpoch; const generation = this.generations.get(tabId) ?? 0; const priorGlobal = this.globalTail; - const priorTask = this.taskTails.get(taskId) ?? Promise.resolve(); const priorTab = this.tabTails.get(tabId) ?? Promise.resolve(); - const result = priorGlobal.then(() => Promise.all([priorTask, priorTab])).then(async () => { + const result = priorGlobal.then(() => priorTab).then(async () => { if (!this.accepting || this.admissionEpoch !== admissionEpoch) { throw this.notStarted("AgentTab paused before this mutation was dispatched"); } + if (this.closedTasks.has(taskId)) throw this.taskClosed(); + const currentBlock = this.blockedTabs.get(tabId); + if (currentBlock) { + throw new NotStartedError(currentBlock.code, currentBlock.message); + } if ((this.generations.get(tabId) ?? 0) !== generation) { const reason = this.generationReasons.get(tabId) ?? { code: "ownership_revoked", @@ -78,11 +110,48 @@ export class MutationScheduler { } return work(); }); - this.rememberTabTail(taskId, tabId, result); + this.rememberTabTail(tabId, result); this.track(result); return result; } + enqueueTaskLifecycle(taskId: string, work: Work): Promise { + if (this.closedTasks.has(taskId)) return Promise.reject(this.taskClosed()); + if (!this.accepting) return Promise.reject(this.notStarted()); + const admissionEpoch = this.admissionEpoch; + const priorTask = this.taskLifecycleTails.get(taskId) ?? Promise.resolve(); + const result = priorTask.then(async () => { + if (!this.accepting || this.admissionEpoch !== admissionEpoch) { + throw this.notStarted("AgentTab paused before this task operation was dispatched"); + } + if (this.closedTasks.has(taskId)) throw this.taskClosed(); + return work(); + }); + this.rememberTaskLifecycleTail(taskId, result); + this.track(result); + return result; + } + + enqueueTaskClose(taskId: string, work: Work): Promise { + this.closedTasks.add(taskId); + const priorTask = this.taskLifecycleTails.get(taskId) ?? Promise.resolve(); + const result = priorTask.then(work); + this.rememberTaskLifecycleTail(taskId, result); + this.track(result); + return result; + } + + private rememberTaskLifecycleTail(taskId: string, result: Promise): void { + const tail = result.then( + () => undefined, + () => undefined, + ); + this.taskLifecycleTails.set(taskId, tail); + void tail.then(() => { + if (this.taskLifecycleTails.get(taskId) === tail) this.taskLifecycleTails.delete(taskId); + }); + } + enqueueGlobal(work: Work): Promise { if (!this.accepting) return Promise.reject(this.notStarted()); const admissionEpoch = this.admissionEpoch; @@ -104,6 +173,13 @@ export class MutationScheduler { readAfterWrites(tabId: number | undefined, work: Work): Promise { if (!this.accepting) return Promise.reject(this.notStarted()); + if (tabId !== undefined) { + try { + this.assertTabAccepting(tabId); + } catch (error) { + return Promise.reject(error); + } + } const admissionEpoch = this.admissionEpoch; const priorGlobal = this.globalTail; if (tabId === undefined) { @@ -126,9 +202,13 @@ export class MutationScheduler { if (!this.accepting || this.admissionEpoch !== admissionEpoch) { throw this.notStarted("AgentTab paused before this observation was dispatched"); } + const currentBlock = this.blockedTabs.get(tabId); + if (currentBlock) { + throw new NotStartedError(currentBlock.code, currentBlock.message); + } return work(); }); - this.rememberTabTail(undefined, tabId, result); + this.rememberTabTail(tabId, result); this.track(result); return result; } @@ -147,6 +227,47 @@ export class MutationScheduler { }); } + blockTab( + tabId: number, + reason = { + code: "handoff_blackout", + message: "Automation is disabled while the human controls this tab", + }, + ): Promise { + this.blockedTabs.set(tabId, reason); + this.generationReasons.set(tabId, reason); + this.generations.set(tabId, (this.generations.get(tabId) ?? 0) + 1); + const tail = this.tabTails.get(tabId); + return tail ? tail.then(() => undefined) : Promise.resolve(); + } + + unblockTab(tabId: number): void { + this.blockedTabs.delete(tabId); + if (!this.tabTails.has(tabId)) { + this.generations.delete(tabId); + this.generationReasons.delete(tabId); + } + } + + isTabBlocked(tabId: number): boolean { + return this.blockedTabs.has(tabId); + } + + retireTabWhenIdle(tabId: number): void { + const tail = this.tabTails.get(tabId); + const retire = () => { + this.tabTails.delete(tabId); + this.blockedTabs.delete(tabId); + this.generations.delete(tabId); + this.generationReasons.delete(tabId); + }; + if (!tail) { + retire(); + return; + } + void tail.then(retire); + } + revokePermissions(): void { this.permissionsAvailable = false; this.accepting = false; @@ -194,21 +315,19 @@ export class MutationScheduler { }; } - private rememberTabTail(taskId: string | undefined, tabId: number, result: Promise): void { + private rememberTabTail(tabId: number, result: Promise): void { const tail = result.then( () => undefined, () => undefined, ); this.tabTails.set(tabId, tail); - if (taskId !== undefined) this.taskTails.set(taskId, tail); void tail.then(() => { if (this.tabTails.get(tabId) === tail) { this.tabTails.delete(tabId); - this.generations.delete(tabId); - this.generationReasons.delete(tabId); - } - if (taskId !== undefined && this.taskTails.get(taskId) === tail) { - this.taskTails.delete(taskId); + if (!this.blockedTabs.has(tabId)) { + this.generations.delete(tabId); + this.generationReasons.delete(tabId); + } } }); } diff --git a/packages/extension/src/storage.ts b/packages/extension/src/storage.ts index 621415a..5890803 100644 --- a/packages/extension/src/storage.ts +++ b/packages/extension/src/storage.ts @@ -1,6 +1,7 @@ import type { StagedCommit } from "./protocol"; export const STATE_KEY = "agenttabStateV1"; +export const BROWSER_SESSION_EPOCH_KEY = "agenttabBrowserSessionEpochV1"; const LEGACY_TASKS_KEY = "chromeBridgeTaskSessions"; const LEGACY_PREFERENCES_KEY = "chromeBridgePreferences"; const SCHEMA_VERSION = 1; @@ -51,6 +52,7 @@ export interface AutomationCleanupRecord { export interface ExtensionState { schemaVersion: typeof SCHEMA_VERSION; + browserSessionEpoch: string | null; paused: boolean; developerMode: boolean; showAgentPointer: boolean; @@ -58,16 +60,27 @@ export interface ExtensionState { revisions: Record; handoff: HandoffRecord; stagedCommits: Record; + // Closed task capabilities are retained deliberately: pruning without a host + // retirement acknowledgement would let a delayed capability recreate ownership. + taskTombstones: Record; automationCleanup: AutomationCleanupRecord; } +export interface BrowserSessionReconciliation { + changed: boolean; + revokedTabIds: number[]; + revokedTaskIds: string[]; +} + let mutationQueue: Promise = Promise.resolve(); let initialization: Promise | null = null; let initializedState: ExtensionState | null = null; +let browserSessionInitialization: Promise | null = null; function defaultState(): ExtensionState { return { schemaVersion: SCHEMA_VERSION, + browserSessionEpoch: null, paused: false, developerMode: false, showAgentPointer: true, @@ -75,6 +88,7 @@ function defaultState(): ExtensionState { revisions: {}, handoff: { active: false }, stagedCommits: {}, + taskTombstones: {}, automationCleanup: { pending: false, tabIds: [], @@ -131,6 +145,9 @@ function parseState(value: unknown): ExtensionState | null { const revisionsValue = objectValue(raw.revisions); const handoffValue = objectValue(raw.handoff); const commitsValue = objectValue(raw.stagedCommits); + const tombstonesValue = raw.taskTombstones === undefined + ? {} + : objectValue(raw.taskTombstones); const cleanupValue = raw.automationCleanup === undefined ? { pending: false, @@ -139,7 +156,16 @@ function parseState(value: unknown): ExtensionState | null { epoch: 0, } : objectValue(raw.automationCleanup); - if (!tasksValue || !revisionsValue || !handoffValue || !commitsValue || !cleanupValue) return null; + if (!tasksValue || !revisionsValue || !handoffValue || !commitsValue || !tombstonesValue || !cleanupValue) { + return null; + } + if ( + raw.browserSessionEpoch !== undefined && + raw.browserSessionEpoch !== null && + (typeof raw.browserSessionEpoch !== "string" || raw.browserSessionEpoch.length === 0) + ) { + return null; + } if ( typeof cleanupValue.pending !== "boolean" || !Array.isArray(cleanupValue.tabIds) || @@ -153,7 +179,6 @@ function parseState(value: unknown): ExtensionState | null { const tasks: Record = {}; const assignedTabIds = new Set(); - const assignedGroupIds = new Map(); for (const [taskId, candidate] of Object.entries(tasksValue)) { const task = objectValue(candidate); if ( @@ -173,14 +198,11 @@ function parseState(value: unknown): ExtensionState | null { const tabIds = task.tabIds as number[]; if ( new Set(tabIds).size !== tabIds.length || - (task.groupId === null && tabIds.length > 0) || - tabIds.some((tabId) => assignedTabIds.has(tabId)) || - (task.groupId !== null && assignedGroupIds.has(task.groupId as number)) + tabIds.some((tabId) => assignedTabIds.has(tabId)) ) { return null; } for (const tabId of tabIds) assignedTabIds.add(tabId); - if (task.groupId !== null) assignedGroupIds.set(task.groupId as number, taskId); tasks[taskId] = task as unknown as TaskRecord; } @@ -249,8 +271,17 @@ function parseState(value: unknown): ExtensionState | null { stagedCommits[token] = commit as unknown as StagedCommit; } + const taskTombstones: Record = {}; + for (const [taskId, closedAt] of Object.entries(tombstonesValue)) { + if (taskId.length === 0 || !finiteInteger(closedAt)) return null; + taskTombstones[taskId] = closedAt; + } + return { schemaVersion: SCHEMA_VERSION, + browserSessionEpoch: typeof raw.browserSessionEpoch === "string" + ? raw.browserSessionEpoch + : null, paused: raw.paused, developerMode: raw.developerMode, showAgentPointer: raw.showAgentPointer, @@ -258,6 +289,7 @@ function parseState(value: unknown): ExtensionState | null { revisions, handoff: handoffValue as unknown as HandoffRecord, stagedCommits, + taskTombstones, automationCleanup: cleanupValue as unknown as AutomationCleanupRecord, }; } @@ -269,7 +301,6 @@ function legacyTasks(value: unknown): Record { const now = Date.now(); const migrated: Record = {}; const assignedTabIds = new Set(); - const assignedGroupIds = new Set(); for (const [taskId, candidate] of Object.entries(raw)) { const session = objectValue(candidate); if ( @@ -283,14 +314,11 @@ function legacyTasks(value: unknown): Record { const tabIds = session.tabIds as number[]; const groupId = finiteInteger(session.groupId) ? session.groupId : null; if ( - (groupId === null && tabIds.length > 0) || - tabIds.some((tabId) => assignedTabIds.has(tabId)) || - (groupId !== null && assignedGroupIds.has(groupId)) + tabIds.some((tabId) => assignedTabIds.has(tabId)) ) { throw new Error("Legacy AgentTab task state has ambiguous ownership"); } for (const tabId of tabIds) assignedTabIds.add(tabId); - if (groupId !== null) assignedGroupIds.add(groupId); migrated[taskId] = { taskId, name: typeof session.name === "string" ? session.name : "Imported browser task", @@ -367,6 +395,66 @@ export async function readState(): Promise { return initializeState(); } +async function currentBrowserSessionEpoch(): Promise { + if (!browserSessionInitialization) { + browserSessionInitialization = (async () => { + if (!chrome.storage?.session) { + throw new Error("AgentTab requires chrome.storage.session for browser-session ownership"); + } + const stored = await chrome.storage.session.get(BROWSER_SESSION_EPOCH_KEY); + const existing = stored[BROWSER_SESSION_EPOCH_KEY]; + if (typeof existing === "string" && existing.length > 0) return existing; + const created = crypto.randomUUID(); + await chrome.storage.session.set({ [BROWSER_SESSION_EPOCH_KEY]: created }); + const verified = (await chrome.storage.session.get(BROWSER_SESSION_EPOCH_KEY))[ + BROWSER_SESSION_EPOCH_KEY + ]; + if (verified !== created) { + throw new Error("AgentTab browser-session ownership marker read-back failed"); + } + return created; + })().catch((error) => { + browserSessionInitialization = null; + throw error; + }); + } + return browserSessionInitialization; +} + +export async function reconcileBrowserSession(): Promise { + const epoch = await currentBrowserSessionEpoch(); + return mutateState((state) => { + if (state.browserSessionEpoch === epoch) { + return { changed: false, revokedTabIds: [], revokedTaskIds: [] }; + } + + const revokedTabIds = new Set(); + const revokedTaskIds: string[] = []; + const now = Date.now(); + for (const task of Object.values(state.tasks)) { + if (task.tabIds.length > 0) revokedTaskIds.push(task.taskId); + for (const tabId of task.tabIds) revokedTabIds.add(tabId); + if (task.tabIds.length > 0 || task.groupId !== null || task.state === "needs_user") { + task.tabIds = []; + task.groupId = null; + if (task.state === "needs_user") task.state = "working"; + task.updatedAt = now; + } + } + state.browserSessionEpoch = epoch; + state.revisions = {}; + state.stagedCommits = {}; + state.handoff = { active: false }; + state.automationCleanup.pending = false; + state.automationCleanup.tabIds = []; + return { + changed: true, + revokedTabIds: [...revokedTabIds], + revokedTaskIds, + }; + }); +} + export function mutateState( mutator: (state: ExtensionState) => T | Promise, ): Promise { @@ -398,5 +486,6 @@ export function mutateState( export function resetStateForTest(): void { initialization = null; initializedState = null; + browserSessionInitialization = null; mutationQueue = Promise.resolve(); } diff --git a/packages/extension/test/extension.test.ts b/packages/extension/test/extension.test.ts index da6a015..dde45f0 100644 --- a/packages/extension/test/extension.test.ts +++ b/packages/extension/test/extension.test.ts @@ -8,7 +8,14 @@ import { RevisionTracker } from "../src/revisions"; import { automationRoute, normalizeRestrictedOriginError } from "../src/routes"; import { MutationScheduler } from "../src/scheduler"; import { IdempotentStartup, StartupOperationQueue } from "../src/startup"; -import { mutateState, readState, resetStateForTest, STATE_KEY } from "../src/storage"; +import { + BROWSER_SESSION_EPOCH_KEY, + mutateState, + readState, + reconcileBrowserSession, + resetStateForTest, + STATE_KEY, +} from "../src/storage"; import { isRecord } from "../src/type-guards"; const LEGACY_TASKS_KEY = "chromeBridgeTaskSessions"; @@ -64,12 +71,15 @@ class MockNativePort { } let persisted: Record; +let sessionPersisted: Record; let debuggerCalls: string[]; let tabStore: Map; let removedTabIds: number[]; let failGrouping: boolean; let nextTabId: number; let nextGroupId: number; +let tabGroupRequests: Array<{ tabIds: number[]; groupId?: number }>; +let tabGroupUpdates: Array<{ groupId: number; changes: Record }>; let scriptResult: unknown; let scriptingCallCount: number; let alarmCreates: Array<{ name: string; when: number }>; @@ -310,12 +320,15 @@ function emitDebuggerDetach(tabId: number): void { function installChromeMock(): void { persisted = {}; + sessionPersisted = {}; debuggerCalls = []; tabStore = new Map(); removedTabIds = []; failGrouping = false; nextTabId = 100; nextGroupId = 50; + tabGroupRequests = []; + tabGroupUpdates = []; scriptResult = true; scriptingCallCount = 0; alarmCreates = []; @@ -368,6 +381,20 @@ function installChromeMock(): void { Object.assign(globalThis, { chrome: { storage: { + session: { + async get(keys: string | string[] | null) { + if (keys === null) return clone(sessionPersisted); + const selected = Array.isArray(keys) ? keys : [keys]; + return Object.fromEntries( + selected + .filter((key) => key in sessionPersisted) + .map((key) => [key, clone(sessionPersisted[key])]), + ); + }, + async set(values: Record) { + Object.assign(sessionPersisted, clone(values)); + }, + }, local: { async get(keys: string | string[] | null) { storageGetCount += 1; @@ -516,6 +543,10 @@ function installChromeMock(): void { }, async group(options: { tabIds: number | number[]; groupId?: number }) { if (failGrouping) throw new Error("grouping unavailable"); + tabGroupRequests.push({ + tabIds: Array.isArray(options.tabIds) ? [...options.tabIds] : [options.tabIds], + ...(options.groupId === undefined ? {} : { groupId: options.groupId }), + }); const groupId = options.groupId ?? nextGroupId++; for (const tabId of Array.isArray(options.tabIds) ? options.tabIds : [options.tabIds]) { const tab = tabStore.get(tabId); @@ -594,7 +625,9 @@ function installChromeMock(): void { }, }, tabGroups: { - async update() { }, + async update(groupId: number, changes: Record) { + tabGroupUpdates.push({ groupId, changes: clone(changes) }); + }, onRemoved: { addListener(listener: TabGroupRemovedListener) { tabGroupRemovedListeners.push(listener); @@ -999,7 +1032,7 @@ describe("mutation scheduler", () => { order.push("first-end"); }); const second = scheduler.enqueueTab("task-a", 1, async () => { order.push("second"); }); - const other = scheduler.enqueueTab("task-b", 2, async () => { order.push("other"); }); + const other = scheduler.enqueueTab("task-a", 2, async () => { order.push("other"); }); await other; expect(order).toEqual(["first-start", "other"]); firstGate.resolve(); @@ -1095,6 +1128,80 @@ describe("mutation scheduler", () => { expect(internal.generationReasons.size).toBe(0); }); + test("scopes a handoff barrier to one tab without blocking sibling task work", async () => { + const scheduler = new MutationScheduler(); + const activeGate = Promise.withResolvers(); + const activeStarted = Promise.withResolvers(); + const active = scheduler.enqueueTab(TASK_A, 51, async () => { + activeStarted.resolve(); + await activeGate.promise; + }); + const queued = scheduler.enqueueTab(TASK_A, 51, async () => "must not run"); + const queuedOutcome = queued.then( + () => null, + (error: unknown) => error, + ); + await activeStarted.promise; + + const blocked = scheduler.blockTab(51); + expect(await scheduler.enqueueTab(TASK_A, 52, async () => "sibling")).toBe("sibling"); + expect(await scheduler.enqueueTab(TASK_B, 53, async () => "other task")).toBe("other task"); + await expect(scheduler.enqueueTab(TASK_A, 51, async () => "blocked")).rejects.toMatchObject({ + code: "handoff_blackout", + }); + + activeGate.resolve(); + await Promise.all([active, blocked]); + expect(await queuedOutcome).toMatchObject({ code: "handoff_blackout" }); + scheduler.unblockTab(51); + expect(await scheduler.enqueueTab(TASK_A, 51, async () => "resumed")).toBe("resumed"); + }); + + test("isolates task lifecycle lanes and cleans completed actor state", async () => { + const scheduler = new MutationScheduler(); + const firstGate = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + const first = scheduler.enqueueTaskLifecycle(TASK_A, async () => { + firstStarted.resolve(); + await firstGate.promise; + }); + await firstStarted.promise; + expect(await scheduler.enqueueTaskLifecycle(TASK_B, async () => "independent")).toBe( + "independent", + ); + expect(await scheduler.enqueueTab(TASK_A, 61, async () => "tab-independent")).toBe( + "tab-independent", + ); + firstGate.resolve(); + await first; + await Promise.resolve(); + + const lanes = scheduler as unknown as { + tabTails: Map>; + taskLifecycleTails: Map>; + }; + expect(lanes.tabTails.size).toBe(0); + expect(lanes.taskLifecycleTails.size).toBe(0); + }); + + test("lets an active wait observe a new exact-tab handoff barrier", async () => { + const scheduler = new MutationScheduler(); + const probe = Promise.withResolvers(); + const started = Promise.withResolvers(); + const waiting = scheduler.readAfterWrites(71, async () => { + started.resolve(); + await probe.promise; + scheduler.assertTabAccepting(71); + }); + await started.promise; + + const blocked = scheduler.blockTab(71); + probe.resolve(); + + await expect(waiting).rejects.toMatchObject({ code: "handoff_blackout" }); + await blocked; + }); + test("navigation rejects an admitted mutation before it starts", async () => { const scheduler = new MutationScheduler(); const firstGate = Promise.withResolvers(); @@ -1207,6 +1314,83 @@ describe("mutation scheduler", () => { }); describe("durable extension state", () => { + test("preserves ownership across a service-worker restart in the same browser session", async () => { + sessionPersisted[BROWSER_SESSION_EPOCH_KEY] = "same-browser-session"; + await seedTask(TASK_A, [40], 8); + await mutateState((state) => { + state.browserSessionEpoch = "same-browser-session"; + }); + resetStateForTest(); + + expect(await reconcileBrowserSession()).toEqual({ + changed: false, + revokedTabIds: [], + revokedTaskIds: [], + }); + expect((await readState()).tasks[TASK_A]).toMatchObject({ groupId: 8, tabIds: [40] }); + }); + + test("revokes persisted numeric tab bindings when the browser-session epoch changes", async () => { + sessionPersisted[BROWSER_SESSION_EPOCH_KEY] = "current-browser-session"; + await seedTask(TASK_A, [41], 9); + await mutateState((state) => { + state.browserSessionEpoch = "previous-browser-session"; + state.revisions["41"] = { floor: 7, current: 7 }; + state.tasks[TASK_A].state = "needs_user"; + state.handoff = { + active: true, + taskId: TASK_A, + tabId: 41, + expectedRevision: 7, + prompt: "Old browser handoff", + completion: { kind: "manual_done" }, + startedAtMs: 1, + timeoutMs: 60_000, + }; + state.automationCleanup.pending = true; + state.automationCleanup.tabIds = [41]; + }); + // Chrome may reuse 41 for an unrelated tab after a full browser restart. + tabStore.set(41, { + id: 41, + windowId: 3, + groupId: 22, + url: "https://unrelated.example/", + }); + + expect(await reconcileBrowserSession()).toEqual({ + changed: true, + revokedTabIds: [41], + revokedTaskIds: [TASK_A], + }); + + const state = await readState(); + expect(state.browserSessionEpoch).toBe("current-browser-session"); + expect(state.tasks[TASK_A]).toMatchObject({ + groupId: null, + tabIds: [], + state: "working", + }); + expect(state.revisions).toEqual({}); + expect(state.handoff).toEqual({ active: false }); + expect(state.automationCleanup).toMatchObject({ pending: false, tabIds: [] }); + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + await expect(ownership.assertOwned(TASK_A, 41)).rejects.toMatchObject({ + code: "ownership_denied", + }); + + resetStateForTest(); + expect(await reconcileBrowserSession()).toEqual({ + changed: false, + revokedTabIds: [], + revokedTaskIds: [], + }); + }); + test("migrates legacy task ownership and verifies the persisted replacement", async () => { persisted[LEGACY_TASKS_KEY] = { "018f47b8-2f80-7c20-9c77-f8a38c9e621e": { @@ -1960,7 +2144,7 @@ describe("page revision monotonicity", () => { } }); - test("stops a browser wait after the user moves its tab out of the task group", async () => { + test("keeps a browser wait authorized after a cosmetic task-group move", async () => { await seedTask(TASK_A, [61], 5); scriptResult = false; const scheduler = new MutationScheduler(); @@ -1983,8 +2167,9 @@ describe("page revision monotonicity", () => { const tab = tabStore.get(61); if (!tab) throw new Error("missing task tab"); tab.groupId = 9; + scriptResult = true; - await expect(waiting).rejects.toMatchObject({ code: "ownership_revoked" }); + await expect(waiting).resolves.toMatchObject({ matched: true }); expect(ownershipChecks).toBeGreaterThan(2); }); test("stops a page-content wait before probing a newly restricted route", async () => { @@ -2558,17 +2743,37 @@ describe("page revision monotonicity", () => { }); describe("ownership and task isolation", () => { - test("removes a newly created tab when visible grouping fails", async () => { + test("does not retain actor state when a duplicate revocation finds no owner", async () => { + const scheduler = new MutationScheduler(); + const ownership = new OwnershipLedger(scheduler, new RevisionTracker(), () => undefined); + + expect(await ownership.revoke(404, "tab_removed")).toBe(false); + + const actorState = scheduler as unknown as { + blockedTabs: Map; + generations: Map; + }; + expect(actorState.blockedTabs.size).toBe(0); + expect(actorState.generations.size).toBe(0); + }); + + test("keeps authoritative ownership when cosmetic grouping fails", async () => { const scheduler = new MutationScheduler(); const revisions = new RevisionTracker(); const ownership = new OwnershipLedger(scheduler, revisions, () => undefined); failGrouping = true; - await expect(ownership.open(TASK_A, { mode: "create", url: "https://example.test/new" })) - .rejects.toMatchObject({ code: "grouping_failed" }); + const opened = await ownership.open(TASK_A, { + mode: "create", + url: "https://example.test/new", + }); - expect(removedTabIds).toEqual([100]); - expect((await readState()).tasks).toEqual({}); + expect(opened).toMatchObject({ tab_id: 100, group_id: -1, tab_count: 1 }); + expect(removedTabIds).toEqual([]); + expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([100]); + resetStateForTest(); + expect((await readState()).tasks[TASK_A]).toMatchObject({ groupId: null, tabIds: [100] }); + expect(await ownership.assertOwned(TASK_A, 100)).toMatchObject({ taskId: TASK_A }); }); test("creates an unfocused normal window only for an empty task", async () => { @@ -2647,6 +2852,92 @@ describe("ownership and task isolation", () => { expect(tabStore.get(90)).toMatchObject({ windowId: 2, active: true }); }); + test("runs independent task ownership actors concurrently and cleans their lanes", async () => { + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + const blockedStarted = Promise.withResolvers(); + const blockedGate = Promise.withResolvers(); + const tabs = chrome.tabs as unknown as { + create(options: Record): Promise; + }; + const originalCreate = tabs.create.bind(tabs); + tabs.create = async (options) => { + if (options.url === "https://blocked.example.test/") { + blockedStarted.resolve(); + await blockedGate.promise; + } + return originalCreate(options); + }; + + const blockedOpen = ownership.open(TASK_A, { + mode: "create", + url: "https://blocked.example.test/", + }); + await blockedStarted.promise; + const independentOpen = await ownership.open(TASK_B, { + mode: "create", + url: "https://independent.example.test/", + }); + + expect(independentOpen).toMatchObject({ tab_id: 100 }); + blockedGate.resolve(); + await expect(blockedOpen).resolves.toMatchObject({ tab_id: 101 }); + await flushPromiseQueue(); + const actors = ownership as unknown as { actorTransitionTails: Map }; + expect(actors.actorTransitionTails.size).toBe(0); + }); + + test("makes global ownership reconciliation a barrier across task actors", async () => { + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + const blockedStarted = Promise.withResolvers(); + const blockedGate = Promise.withResolvers(); + const tabs = chrome.tabs as unknown as { + create(options: Record): Promise; + }; + const originalCreate = tabs.create.bind(tabs); + tabs.create = async (options) => { + if (options.url === "https://barrier.example.test/") { + blockedStarted.resolve(); + await blockedGate.promise; + } + return originalCreate(options); + }; + + const blockedOpen = ownership.open(TASK_A, { + mode: "create", + url: "https://barrier.example.test/", + }); + await blockedStarted.promise; + let reconciled = false; + const reconciliation = ownership.reconcile().then((result) => { + reconciled = true; + return result; + }); + let laterTaskOpened = false; + const laterOpen = ownership.open(TASK_B, { + mode: "create", + url: "https://after-barrier.example.test/", + }).then((result) => { + laterTaskOpened = true; + return result; + }); + await flushPromiseQueue(); + expect(reconciled).toBe(false); + expect(laterTaskOpened).toBe(false); + + blockedGate.resolve(); + await blockedOpen; + await reconciliation; + await expect(laterOpen).resolves.toMatchObject({ tab_id: 101 }); + }); + test("publishes a loading tab's pending URL instead of its previous URL", async () => { await seedTask(TASK_A, [31], 7); @@ -2687,7 +2978,7 @@ describe("ownership and task isolation", () => { expect(storageSetCount).toBe(initialSets); }); - test("reconciliation revokes a moved tab and its queued mutation", async () => { + test("reconciliation preserves ownership and ordering after a cosmetic group move", async () => { await seedTask(TASK_A, [11, 12]); const moved = tabStore.get(12); if (!moved) throw new Error("missing test tab"); @@ -2703,20 +2994,118 @@ describe("ownership and task isolation", () => { activeStarted.resolve(); await activeGate.promise; }); - const queued = scheduler.enqueueTab(TASK_A, 12, async () => "must not run"); - const queuedOutcome = queued.then( - () => null, - (error: unknown) => error, - ); + const queued = scheduler.enqueueTab(TASK_A, 12, async () => "ran after active"); await activeStarted.promise; await ownership.reconcile(); activeGate.resolve(); await active; - expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([11]); - expect(await queuedOutcome).toMatchObject({ code: "ownership_revoked" }); - expect(events).toContain("ownership_revoked"); + expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([11, 12]); + expect((await readState()).tasks[TASK_A]?.groupId).toBe(5); + expect(await queued).toBe("ran after active"); + expect(events).not.toContain("ownership_revoked"); + expect(await ownership.assertOwned(TASK_A, 12)).toMatchObject({ taskId: TASK_A }); + }); + + test("clears a stale cosmetic group before status updates or new-tab grouping", async () => { + await seedTask(TASK_A, [11], 5); + const moved = tabStore.get(11); + if (!moved) throw new Error("missing task tab"); + moved.groupId = 9; + tabStore.set(90, { + id: 90, + windowId: 1, + groupId: 5, + url: "https://user-only.example/", + }); + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + + await ownership.revokeIfMoved(11); + expect((await readState()).tasks[TASK_A]?.groupId).toBeNull(); + + await ownership.setTaskState(TASK_A, "completed"); + expect(tabGroupUpdates).toEqual([]); + const opened = await ownership.open(TASK_A, { + mode: "create", + url: "https://example.test/new-task-tab", + }); + + expect(opened).toMatchObject({ tab_id: 100, group_id: 50 }); + expect(tabGroupRequests.at(-1)).toEqual({ tabIds: [100] }); + expect(tabStore.get(90)?.groupId).toBe(5); + expect(tabGroupUpdates).toContainEqual(expect.objectContaining({ groupId: 50 })); + }); + + test("serializes task status updates with concurrent last-tab revocations", async () => { + await seedTask(TASK_A, [11, 12], 5); + await seedTask(TASK_B, [13], 6); + tabStore.set(90, { + id: 90, + windowId: 1, + groupId: 5, + url: "https://user-only.example/", + }); + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + const updateStarted = Promise.withResolvers(); + const updateGate = Promise.withResolvers(); + const tabGroups = chrome.tabGroups as unknown as { + update(groupId: number, changes: Record): Promise; + }; + const originalUpdate = tabGroups.update.bind(tabGroups); + let updateObservedOwnedBinding = false; + tabGroups.update = async (groupId, changes) => { + if (groupId === 5) { + updateStarted.resolve(); + await updateGate.promise; + const task = (await readState()).tasks[TASK_A]; + updateObservedOwnedBinding = task?.groupId === groupId && task.tabIds.length > 0; + } + await originalUpdate(groupId, changes); + }; + + const statusUpdate = ownership.setTaskState(TASK_A, "completed"); + await updateStarted.promise; + await ownership.setTaskState(TASK_B, "completed"); + expect(tabGroupUpdates).toEqual([ + expect.objectContaining({ groupId: 6 }), + ]); + const firstRevocation = ownership.revoke(11, "ownership_revoked"); + const lastRevocation = ownership.revoke(12, "ownership_revoked"); + await flushPromiseQueue(); + + expect((await readState()).tasks[TASK_A]).toMatchObject({ + groupId: 5, + tabIds: [11, 12], + }); + updateGate.resolve(); + await Promise.all([statusUpdate, firstRevocation, lastRevocation]); + + expect(updateObservedOwnedBinding).toBe(true); + expect(tabGroupUpdates).toEqual([ + expect.objectContaining({ groupId: 6 }), + expect.objectContaining({ groupId: 5 }), + ]); + expect((await readState()).tasks[TASK_A]).toMatchObject({ + groupId: null, + tabIds: [], + }); + expect(tabStore.get(90)?.groupId).toBe(5); + await flushPromiseQueue(); + const actorState = ownership as unknown as { + actorTransitionTails: Map; + cosmeticTransitionTails: Map; + }; + expect(actorState.actorTransitionTails.size).toBe(0); + expect(actorState.cosmeticTransitionTails.size).toBe(0); }); test("inherits opener ownership across popup windows without changing the active Chrome window", async () => { @@ -2765,6 +3154,56 @@ describe("ownership and task isolation", () => { expect((await readState()).tasks[TASK_A]).toBeUndefined(); }); + test("orders popup child adoption with concurrent opener revocation", async () => { + await seedTask(TASK_A, [21]); + tabStore.set(22, { + id: 22, + windowId: 1, + groupId: -1, + openerTabId: 21, + url: "https://example.test/child", + }); + const ownership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + const adoptionValidated = Promise.withResolvers(); + const adoptionGate = Promise.withResolvers(); + const tabs = chrome.tabs as unknown as { + get(tabId: number): Promise; + }; + const originalGet = tabs.get.bind(tabs); + let openerGetCount = 0; + tabs.get = async (tabId) => { + if (tabId === 21 && (openerGetCount += 1) === 2) { + adoptionValidated.resolve(); + await adoptionGate.promise; + } + return originalGet(tabId); + }; + + const adoption = ownership.adoptOwnedChild({ id: 22, openerTabId: 21 }); + await adoptionValidated.promise; + let revocationFinished = false; + const revocation = ownership.revoke(21, "ownership_revoked").then((result) => { + revocationFinished = true; + return result; + }); + await flushPromiseQueue(); + + expect(revocationFinished).toBe(false); + expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([21]); + adoptionGate.resolve(); + await adoption; + await expect(revocation).resolves.toBe(true); + + expect((await readState()).tasks[TASK_A]).toMatchObject({ + groupId: 5, + tabIds: [22], + }); + }); + test("keeps ownership deleted when one Chrome tab is already unavailable", async () => { await seedTask(TASK_A, [21, 22]); const scheduler = new MutationScheduler(); @@ -2844,7 +3283,7 @@ describe("ownership and task isolation", () => { expect(tabStore.get(22)).toMatchObject({ windowId: 1, groupId: 5, active: false }); }); - test("preserves a child in a foreign tab group despite its owned opener", async () => { + test("inherits an owned opener even when the child starts in a foreign cosmetic group", async () => { await seedTask(TASK_A, [21]); tabStore.set(22, { id: 22, @@ -2862,8 +3301,8 @@ describe("ownership and task isolation", () => { await ownership.adoptOwnedChild(tabStore.get(22) ?? {}); - expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([21]); - expect(tabStore.get(22)).toMatchObject({ windowId: 1, groupId: 9, active: false }); + expect((await readState()).tasks[TASK_A]?.tabIds).toEqual([21, 22]); + expect(tabStore.get(22)).toMatchObject({ windowId: 1, groupId: 5, active: false }); expect(removedTabIds).toEqual([]); }); @@ -2884,19 +3323,185 @@ describe("ownership and task isolation", () => { ); await activeStarted.promise; - await ownership.closeTask(TASK_A); + const closing = ownership.closeTask(TASK_A); + await Promise.resolve(); + expect(removedTabIds).toEqual([]); activeGate.resolve(); await active; + await closing; + + expect(await queuedOutcome).toMatchObject({ code: "task_closed" }); + expect(removedTabIds).toEqual([23]); + expect((await readState()).tasks[TASK_A]).toBeUndefined(); + const actorState = scheduler as unknown as { + blockedTabs: Map; + generations: Map; + }; + expect(actorState.blockedTabs.size).toBe(0); + expect(actorState.generations.size).toBe(0); + }); + + test("drains active wait revalidation before taking task ownership actors", async () => { + await seedTask(TASK_A, [23]); + const scheduler = new MutationScheduler(); + const ownership = new OwnershipLedger(scheduler, new RevisionTracker(), () => undefined); + const revalidationStarted = Promise.withResolvers(); + const revalidationGate = Promise.withResolvers(); + const activeWait = scheduler.readAfterWrites(23, async () => { + scheduler.assertTabAccepting(23); + revalidationStarted.resolve(); + await revalidationGate.promise; + await ownership.assertOwned(TASK_A, 23); + scheduler.assertTabAccepting(23, "AgentTab stopped the active browser wait"); + }); + const activeOutcome = activeWait.then( + () => null, + (error: unknown) => error, + ); + await revalidationStarted.promise; + + const closing = ownership.closeTask(TASK_A); + await waitForCondition(() => scheduler.isTabBlocked(23)); + revalidationGate.resolve(); + const closeSettled = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 250)), + ]); - expect(await queuedOutcome).toMatchObject({ code: "ownership_revoked" }); + expect(closeSettled).toBe(true); + expect(await activeOutcome).toMatchObject({ code: "ownership_revoked" }); expect(removedTabIds).toEqual([23]); expect((await readState()).tasks[TASK_A]).toBeUndefined(); }); + + test("drains an active tab-close ownership callback without actor inversion", async () => { + await seedTask(TASK_A, [25]); + const scheduler = new MutationScheduler(); + const ownership = new OwnershipLedger(scheduler, new RevisionTracker(), () => undefined); + const actionStarted = Promise.withResolvers(); + const actionGate = Promise.withResolvers(); + const activeAction = scheduler.enqueueTab(TASK_A, 25, async () => { + actionStarted.resolve(); + await actionGate.promise; + tabStore.delete(25); + return ownership.revoke(25, "tab_removed"); + }); + await actionStarted.promise; + + const closing = ownership.closeTask(TASK_A); + await waitForCondition(() => scheduler.isTabBlocked(25)); + actionGate.resolve(); + const closeSettled = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 250)), + ]); + + expect(closeSettled).toBe(true); + expect(await activeAction).toBe(true); + expect((await readState()).tasks[TASK_A]).toBeUndefined(); + expect((await readState()).taskTombstones[TASK_A]).toBeDefined(); + }); + + test("holds closing tab lanes through physical removal against cross-task adoption", async () => { + await seedTask(TASK_A, [24]); + const activeTab = tabStore.get(24); + if (!activeTab) throw new Error("missing active task tab"); + activeTab.active = true; + const scheduler = new MutationScheduler(); + const ownership = new OwnershipLedger(scheduler, new RevisionTracker(), () => undefined); + const removalStarted = Promise.withResolvers(); + const removalGate = Promise.withResolvers(); + tabRemovalProbe = async () => { + removalStarted.resolve(); + await removalGate.promise; + }; + const tabs = chrome.tabs as unknown as { + get(tabId: number): Promise; + }; + const originalGet = tabs.get.bind(tabs); + tabs.get = async (tabId) => { + if (!tabStore.has(tabId)) throw new Error("No tab with id"); + return originalGet(tabId); + }; + + const closing = ownership.closeTask(TASK_A); + await removalStarted.promise; + let adoptionFinished = false; + const adoptionOutcome = ownership.adoptActive(TASK_B).then( + (value) => { + adoptionFinished = true; + return { ok: true as const, value }; + }, + (error: unknown) => { + adoptionFinished = true; + return { ok: false as const, error }; + }, + ); + await flushPromiseQueue(); + const adoptedBeforeRemoval = adoptionFinished; + const taskBeforeRemoval = (await readState()).tasks[TASK_B]; + removalGate.resolve(); + + await expect(closing).resolves.toEqual([24]); + const adoption = await adoptionOutcome; + expect(adoptedBeforeRemoval).toBe(false); + expect(taskBeforeRemoval).toBeUndefined(); + expect(adoption.ok).toBe(false); + expect((await readState()).tasks[TASK_B]).toBeUndefined(); + expect(tabStore.has(24)).toBe(false); + }); + + test("tombstones a task before queued lifecycle work can recreate it", async () => { + const scheduler = new MutationScheduler(); + const ownership = new OwnershipLedger(scheduler, new RevisionTracker(), () => undefined); + const activeGate = Promise.withResolvers(); + const activeStarted = Promise.withResolvers(); + const active = scheduler.enqueueTaskLifecycle(TASK_A, async () => { + activeStarted.resolve(); + await activeGate.promise; + }); + await activeStarted.promise; + const queuedOpen = scheduler.enqueueTaskLifecycle(TASK_A, () => ownership.open(TASK_A, { + mode: "create", + url: "https://example.test/must-not-reopen", + })); + const queuedOutcome = queuedOpen.then( + () => null, + (error: unknown) => error, + ); + + const closing = ownership.closeTask(TASK_A); + expect(scheduler.isTaskClosed(TASK_A)).toBe(true); + await expect(scheduler.enqueueTaskLifecycle(TASK_A, async () => undefined)).rejects.toMatchObject({ + code: "task_closed", + }); + activeGate.resolve(); + await active; + await closing; + + expect(await queuedOutcome).toMatchObject({ code: "task_closed" }); + expect(nextTabId).toBe(100); + expect((await readState()).tasks[TASK_A]).toBeUndefined(); + expect(typeof (await readState()).taskTombstones[TASK_A]).toBe("number"); + + resetStateForTest(); + const restartedOwnership = new OwnershipLedger( + new MutationScheduler(), + new RevisionTracker(), + () => undefined, + ); + await expect(restartedOwnership.open(TASK_A, { + mode: "create", + url: "https://example.test/still-closed", + })).rejects.toMatchObject({ code: "task_closed" }); + expect(nextTabId).toBe(100); + }); }); describe("handoff and pause barriers", () => { - test("keeps the global pause active until the completion condition matches", async () => { + test("keeps only the handed-off tab blocked until the completion condition matches", async () => { await seedTask(TASK_A, [31]); + await seedTask(TASK_B, [32], 6); const scheduler = new MutationScheduler(); const revisions = new RevisionTracker(); const events: string[] = []; @@ -2917,19 +3522,29 @@ describe("handoff and pause barriers", () => { timeout_ms: 60_000, }); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(31)).toBe(true); + expect(scheduler.isTabBlocked(32)).toBe(false); + expect(await scheduler.enqueueTab(TASK_B, 32, async () => "unrelated")).toBe("unrelated"); + await handoff.pause(); expect(scheduler.isAccepting()).toBe(false); + await handoff.resume(); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(31)).toBe(true); expect((await readState()).handoff.active).toBe(true); expect((await readState()).tasks[TASK_A]?.state).toBe("needs_user"); expect(await handoff.finish(true)).toMatchObject({ completed: false, reason: "The handoff completion condition has not been met", }); - expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(31)).toBe(true); expect((await readState()).handoff.active).toBe(true); scriptResult = true; expect(await handoff.finish(true)).toEqual({ completed: true }); - expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(31)).toBe(true); const pendingHandoff = (await readState()).handoff; if (!pendingHandoff.active || !pendingHandoff.pendingClearEventId || !clearEventId) { throw new Error("handoff completion must await a native acknowledgment"); @@ -2937,6 +3552,7 @@ describe("handoff and pause barriers", () => { expect(clearEventId).toBe(pendingHandoff.pendingClearEventId); await handoff.acknowledgeEvent("handoff_changed", clearEventId); expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(31)).toBe(false); expect((await readState()).handoff).toEqual({ active: false }); expect((await readState()).tasks[TASK_A]?.state).toBe("working"); expect(events.filter((event) => event === "handoff_changed")).toHaveLength(2); @@ -2952,9 +3568,9 @@ describe("handoff and pause barriers", () => { const handoff = new HandoffController(scheduler, revisions, ownership, (event, payload, eventId) => { events.push({ event, payload, eventId }); }); - let scrubCalls = 0; - handoff.setScrubber(async () => { - scrubCalls += 1; + const scrubbedTabs: number[] = []; + handoff.setScrubber(async (tabId) => { + scrubbedTabs.push(tabId); }); const firstRevision = await revisions.ensure(33); await handoff.begin(TASK_A, { @@ -2968,7 +3584,8 @@ describe("handoff and pause barriers", () => { expect(await handoff.cancelForTab(33)).toBe(true); const firstPending = (await readState()).handoff; expect((await readState()).tasks[TASK_A]?.state).toBe("needs_user"); - expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(33)).toBe(true); const firstClearEventId = events.at(-1)?.eventId; if (!firstPending.active || !firstPending.pendingClearEventId || !firstClearEventId) { throw new Error("tab cancellation did not create a pending handoff event"); @@ -2979,6 +3596,7 @@ describe("handoff and pause barriers", () => { expect((await readState()).handoff).toEqual({ active: false }); expect((await readState()).tasks[TASK_A]?.state).toBe("working"); expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(33)).toBe(false); const secondRevision = await revisions.ensure(34); await handoff.begin(TASK_B, { @@ -2992,7 +3610,8 @@ describe("handoff and pause barriers", () => { expect(await handoff.cancelForTask(TASK_B)).toBe(true); const secondPending = (await readState()).handoff; expect((await readState()).tasks[TASK_B]?.state).toBe("needs_user"); - expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(34)).toBe(true); const secondClearEventId = events.at(-1)?.eventId; if (!secondPending.active || !secondPending.pendingClearEventId || !secondClearEventId) { throw new Error("task cancellation did not create a pending handoff event"); @@ -3003,11 +3622,12 @@ describe("handoff and pause barriers", () => { expect((await readState()).handoff).toEqual({ active: false }); expect((await readState()).tasks[TASK_B]?.state).toBe("working"); expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(34)).toBe(false); expect(events.filter(({ event, payload, eventId }) => event === "handoff_changed" && payload.active === false && typeof eventId === "string" )).toHaveLength(2); expect(alarmClears.filter((name) => name === HANDOFF_ALARM)).toHaveLength(4); - expect(scrubCalls).toBe(2); + expect(scrubbedTabs).toEqual([33, 33, 34, 34]); }); test("clears a restored handoff for a tab revoked during initial reconciliation", async () => { @@ -3019,9 +3639,9 @@ describe("handoff and pause barriers", () => { const handoff = new HandoffController(scheduler, revisions, ownership, (event, _payload, eventId) => { if (event === "handoff_changed" && eventId) clearEventIds.push(eventId); }); - let scrubCalls = 0; - handoff.setScrubber(async () => { - scrubCalls += 1; + const scrubbedTabs: number[] = []; + handoff.setScrubber(async (tabId) => { + scrubbedTabs.push(tabId); }); const pageRevision = await revisions.ensure(35); await handoff.begin(TASK_A, { @@ -3038,7 +3658,8 @@ describe("handoff and pause barriers", () => { expect(revokedTabIds).toEqual([35]); const pending = (await readState()).handoff; - expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isAccepting()).toBe(true); + expect(scheduler.isTabBlocked(35)).toBe(true); const clearEventId = clearEventIds.at(-1); if (!pending.active || !pending.pendingClearEventId || !clearEventId) { throw new Error("startup reconciliation did not create a pending handoff event"); @@ -3049,7 +3670,8 @@ describe("handoff and pause barriers", () => { await handoff.acknowledgeEvent("handoff_changed", clearEventId); expect((await readState()).handoff).toEqual({ active: false }); expect(scheduler.isAccepting()).toBe(true); - expect(scrubCalls).toBe(1); + expect(scheduler.isTabBlocked(35)).toBe(false); + expect(scrubbedTabs).toEqual([35, 35]); expect(alarmClears).toContain(HANDOFF_ALARM); }); @@ -3094,11 +3716,59 @@ describe("handoff and pause barriers", () => { expect(state.paused).toBe(true); expect(state.tasks[TASK_A]?.state).toBe("working"); expect(scheduler.isAccepting()).toBe(false); + expect(scheduler.isTabBlocked(32)).toBe(false); expect(alarmClears).toContain(HANDOFF_ALARM); }); }); describe("native bridge transport", () => { + test("reopens unrelated admission without clearing a restored handoff tab", async () => { + const scheduler = new MutationScheduler(); + const revisions = new RevisionTracker(); + const ownership = new OwnershipLedger(scheduler, revisions, () => undefined); + await seedTask(TASK_A, [44], 12); + const pageRevision = await revisions.ensure(44); + await mutateState((state) => { + state.handoff = { + active: true, + taskId: TASK_A, + tabId: 44, + expectedRevision: pageRevision, + prompt: "Restored handoff", + completion: { kind: "manual_done" }, + startedAtMs: Date.now(), + timeoutMs: 60_000, + }; + }); + await scheduler.blockTab(44); + scheduler.disconnect(); + const port = new MockNativePort(); + nativePort = port; + const bridge = new NativeBridge(scheduler, ownership, async () => { + throw new Error("command handler must not run"); + }); + + await bridge.connect(); + expect(port.posted[0]).toMatchObject({ + kind: "hello", + handoff: { active: true, task_id: TASK_A, tab_id: 44 }, + }); + port.receive({ + protocol: "agenttab.native", + version: 1, + kind: "ready", + host_version: "0.2.0", + state: "ready", + }); + + await waitForCondition(() => scheduler.isAccepting()); + expect(scheduler.isTabBlocked(44)).toBe(true); + expect(await scheduler.enqueueTab(TASK_B, 45, async () => "unrelated")).toBe("unrelated"); + await expect(scheduler.enqueueTab(TASK_A, 44, async () => "blocked")).rejects.toMatchObject({ + code: "handoff_blackout", + }); + }); + test("reconciles hello, resets backoff only after ready, and pauses on disconnect", async () => { const scheduler = new MutationScheduler(); const revisions = new RevisionTracker(); @@ -4128,6 +4798,18 @@ describe("extension entrypoint admission boundaries", () => { test("enforces popup sender, permission, pause, handoff, commit, developer, and cleanup gates", async () => { const port = new MockNativePort(); nativePort = port; + sessionPersisted[BROWSER_SESSION_EPOCH_KEY] = "entrypoint-browser-session"; + await seedTask(TASK_A, [77], 5); + await mutateState((state) => { + state.browserSessionEpoch = "stale-browser-session"; + state.revisions["77"] = { floor: 4, current: 4 }; + }); + tabStore.set(77, { + id: 77, + windowId: 1, + groupId: 42, + url: "https://unrelated-after-restart.example/", + }); // The background entrypoint must load after the Chrome mock is installed. await import("../src/background"); await waitForCondition(() => @@ -4138,6 +4820,8 @@ describe("extension entrypoint admission boundaries", () => { (message as Record).kind === "hello", ), ); + expect(port.posted[0]).toMatchObject({ kind: "hello", inventory: [] }); + expect((await readState()).tasks[TASK_A]).toMatchObject({ groupId: null, tabIds: [] }); port.receive({ protocol: "agenttab.native", version: 1, @@ -4364,7 +5048,35 @@ describe("extension entrypoint admission boundaries", () => { outcome: "not_started", error: { code: "handoff_blackout" }, }); - expect(debuggerCommands).toHaveLength(deniedBeforePermission); + expect(await sendNativeCommand( + "018f47b8-2f80-7c20-9c77-f8a38c9e6500", + TASK_A, + "browser_tabs", + {}, + )).toMatchObject({ + outcome: "completed", + result: { tabs: [], handoff: { active: true, tab_id: 100 } }, + }); + await seedTask(TASK_B, [99], 49); + const scriptingBeforeUnrelatedAction = scriptingCallCount; + const unrelatedAction = await sendNativeCommand( + "018f47b8-2f80-7c20-9c77-f8a38c9e6501", + TASK_B, + "browser_act", + { + tab_id: 99, + expected_page_revision: 1, + actions: [{ kind: "scroll", delta_x: 0, delta_y: 1 }], + }, + ); + expect(unrelatedAction).toMatchObject({ outcome: "completed" }); + await mutateState((state) => { + delete state.tasks[TASK_B]; + delete state.revisions["99"]; + }); + tabStore.delete(99); + expect(scriptingCallCount).toBe(scriptingBeforeUnrelatedAction + 1); + const debuggerCommandsAfterUnrelatedAction = debuggerCommands.length; expect(await sendPopupMessage({ kind: "handoff_finish", completed: true })).toEqual({ completed: true }); const pendingHandoff = (await readState()).handoff; if (!pendingHandoff.active || !pendingHandoff.pendingClearEventId) { @@ -4393,7 +5105,7 @@ describe("extension entrypoint admission boundaries", () => { outcome: "not_started", error: { code: "developer_mode_required" }, }); - expect(debuggerCommands).toHaveLength(deniedBeforePermission); + expect(debuggerCommands).toHaveLength(debuggerCommandsAfterUnrelatedAction); expect(await sendPopupMessage({ kind: "developer_mode", enabled: true })).toEqual({ enabled: true }); const developerEnabled = await sendNativeCommand( "018f47b8-2f80-7c20-9c77-f8a38c9e6228", @@ -4612,6 +5324,15 @@ describe("extension entrypoint admission boundaries", () => { outcome: "not_started", error: { code: "ownership_denied" }, }); + expect(await sendNativeCommand( + "018f47b8-2f80-7c20-9c77-f8a38c9e6502", + TASK_A, + "browser_open", + { mode: "create", url: "https://example.test/must-stay-closed" }, + )).toMatchObject({ + outcome: "not_started", + error: { code: "task_closed" }, + }); expect(taskDeletedBeforeRemove).toBe(true); expect(await sendPopupMessage({ kind: "automation_revocation_state" })).toEqual({ generation: 0 }); debuggerAttachedTabIds.add(101); @@ -4624,7 +5345,7 @@ describe("extension entrypoint admission boundaries", () => { for (const listener of permissionRemovedListeners) listener({ permissions: ["scripting"] }); const tabsDeniedSynchronously = await sendNativeCommand( "018f47b8-2f80-7c20-9c77-f8a38c9e6236", - TASK_A, + TASK_B, "browser_tabs", {}, ); @@ -4633,8 +5354,9 @@ describe("extension entrypoint admission boundaries", () => { error: { code: "permissions_required" }, }); await waitForCondition( - () => debuggerCalls.filter((call) => call === "detach").length === detachCountBeforeRevocation + 1, + () => debuggerCalls.filter((call) => call === "detach").length > detachCountBeforeRevocation, ); + expect(debuggerCalls.filter((call) => call === "detach")).toHaveLength(detachCountBeforeRevocation + 1); expect(await sendPopupMessage({ kind: "automation_revocation_state" })).toEqual({ generation: 0 }); const cleanupAlarm = alarmCreates.find((alarm) => alarm.name === "agenttabAutomationCleanup"); expect(cleanupAlarm).toBeDefined(); @@ -4653,7 +5375,7 @@ describe("extension entrypoint admission boundaries", () => { }); const deniedDuringRegrantCleanup = await sendNativeCommand( "018f47b8-2f80-7c20-9c77-f8a38c9e6301", - TASK_A, + TASK_B, "browser_tabs", {}, ); @@ -4678,7 +5400,7 @@ describe("extension entrypoint admission boundaries", () => { }); expect(await sendNativeCommand( "018f47b8-2f80-7c20-9c77-f8a38c9e6302", - TASK_A, + TASK_B, "browser_tabs", {}, )).toMatchObject({ outcome: "completed" }); @@ -4797,11 +5519,27 @@ describe("extension entrypoint admission boundaries", () => { if (!movedTab) throw new Error("task tab for move test is unavailable"); movedTab.groupId = 99; for (const listener of tabUpdatedListeners) listener(101, { groupId: 99 }); - await waitForCondition( - () => debuggerCalls.filter((call) => call === "detach").length === detachCountBeforeMove + 1, - ); - expect(debuggerAttachedTabIds.has(101)).toBe(false); - expect((await readState()).automationCleanup.tabIds).toEqual([]); - expect((await readState()).tasks[TASK_B]).toMatchObject({ groupId: null, tabIds: [] }); + for ( + let attempt = 0; + attempt < 100 && (await readState()).tasks[TASK_B]?.groupId !== null; + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(debuggerCalls.filter((call) => call === "detach")).toHaveLength(detachCountBeforeMove); + expect(debuggerAttachedTabIds.has(101)).toBe(true); + expect((await readState()).automationCleanup.tabIds).toEqual([101]); + expect((await readState()).tasks[TASK_B]).toMatchObject({ groupId: null, tabIds: [101] }); + expect(await sendPopupMessage({ kind: "close_task", task_id: TASK_B })).toEqual({ closed: true }); + expect((await readState()).taskTombstones[TASK_B]).toBeDefined(); + expect(await sendNativeCommand( + "018f47b8-2f80-7c20-9c77-f8a38c9e6503", + TASK_B, + "browser_open", + { mode: "create", url: "https://example.test/popup-closed" }, + )).toMatchObject({ + outcome: "not_started", + error: { code: "task_closed" }, + }); }); });