From dc0f1ac6d2f873b6f4aa6babe618618ae38a05cf Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:50:54 -0700 Subject: [PATCH 1/8] Integrate private credential broker with current runtime --- CHANGELOG.md | 4 +- README.md | 7 +- docs/adr/0001-agenttab-runtime.md | 11 +- docs/commands.md | 2 +- docs/launch/chrome-web-store.md | 6 +- docs/launch/directory-listings.md | 4 +- docs/launch/reddit-posts.md | 10 +- docs/launch/show-hn.md | 6 +- docs/mcp.md | 14 +- docs/roadmap.md | 2 +- docs/security.md | 17 +- docs/setup.md | 36 +- .../crates/agenttab-host/src/credentials.rs | 784 ++++++++++++++++++ .../crates/agenttab-host/src/guardrails.rs | 71 ++ host-rs/crates/agenttab-host/src/journal.rs | 42 +- host-rs/crates/agenttab-host/src/lib.rs | 1 + host-rs/crates/agenttab-host/src/runtime.rs | 275 +++++- host-rs/crates/agenttab-protocol/src/lib.rs | 75 ++ packages/extension/src/background.ts | 15 +- packages/extension/src/browser.ts | 101 ++- packages/extension/src/protocol.ts | 35 + packages/extension/test/extension.test.ts | 26 + packages/mcp/package.json | 2 +- packages/mcp/src/server.ts | 6 + packages/mcp/test/server.test.ts | 10 +- packages/omp/src/index.ts | 22 + packages/omp/src/pi-schema.ts | 16 + packages/omp/src/render.ts | 27 +- packages/omp/test/extension.test.ts | 5 +- packages/sdk-python/agenttab/client.py | 1 + packages/sdk-typescript/src/index.ts | 19 + .../rpc/v1/browser-credentials.schema.json | 40 + 32 files changed, 1630 insertions(+), 62 deletions(-) create mode 100644 host-rs/crates/agenttab-host/src/credentials.rs create mode 100644 schemas/rpc/v1/browser-credentials.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddd9a4..61fc90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ # Changelog ## Unreleased -- Replaced the Chrome Bridge v1 runtime with the AgentTab 2.0 release candidate: a Rust production host over OS-native local IPC, seven task-scoped Standard methods, explicit resumable capabilities, a developer-only eighth method, TypeScript and Python SDKs, MCP and OMP adapters, a transactional installer, and a minimal extension. Consequential controls now use a two-party Commit flow: `browser_act` stages an exact effect, the popup approves the durable review record without executing it, and the requesting task must consume its private one-use token through `browser_commit`. +- Replaced the Chrome Bridge v1 runtime with the AgentTab 2.0 release candidate: a Rust production host over OS-native local IPC, eight task-scoped Standard methods, explicit resumable capabilities, a developer-only ninth method, TypeScript and Python SDKs, MCP and OMP adapters, a transactional installer, and a minimal extension. Consequential controls now use a two-party Commit flow: `browser_act` stages an exact effect, the popup approves the durable review record without executing it, and the requesting task must consume its private one-use token through `browser_commit`. - Fixed OMP adapter compatibility with providers that reject top-level union tool schemas. `browser_open` and `browser_snapshot` now expose provider-compatible object schemas while retaining strict runtime validation for their mode-specific parameters. - Added a focus-safe GPT-Control browser driver backed by AgentTab task ownership and durable resume capabilities. It creates background task tabs, fills and sends prompts through Standard actions and Commit review, uploads through a bounded file-input selector, captures the latest assistant message without activating Chrome, persists session state owner-only, and closes the underlying task explicitly. +- Added a disabled-by-default, host-managed 1Password credential broker. `browser_credentials` derives the current origin from task-owned host state, accepts at most three matching Login items and attempts, and uses short-lived one-use tokens to fill username, password, or one-time-code refs without exposing values through Core RPC, MCP, OMP, Pi, responses, or audit output. More than three matches, provider failures, exhausted attempts, and unsupported verification return `needs_user`. The extension's private fill path blocks capture before injecting selected values and never submits the form. +- Fixed credential-broker activation against upgraded local state and GUI-launched browser hosts. Legacy `staged_commits` columns are migrated before dependent indexes are created, and policy can pin an absolute 1Password CLI executable when the browser process does not inherit the user's shell `PATH`. - Added task-owned background window creation to Standard `browser_open` through `placement: "new_window"`. It is operation-specific rather than a general window-control grant: only an otherwise empty task can request it, the extension creates an unfocused normal window, `background: false` is rejected, ownership is derived from persisted task state, and a failed visible group grant removes the new tab. Focus, state changes, and closure of unrelated windows remain unavailable. - Added explicit restricted-origin routing for task tabs. `browser_open` and `browser_tabs` now report `automation_route: "full" | "tab_only"`; Chrome system pages, extension pages, DevTools, and the Chrome Web Store retain task-owned explicit navigation, reload, close, and bounded waits, while page inspection, interaction, and raw CDP fail before execution with the stable `browser_restricted_origin` / `not_started` result and non-retry recovery. History movement remains available without managed origin constraints and fails closed when constraints are configured because Chrome does not expose its destination for pre-navigation authorization. - Migrated the Python MCP server to SDK 2.0 and the `2026-07-28` protocol while preserving legacy stdio compatibility, scoped tool registration, resources, annotations, per-request HTTP bridge tokens, and streamable HTTP configuration. diff --git a/README.md b/README.md index 5bfe428..ea32947 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > Give an agent a tab, not the keys to your browser. -AgentTab lets an agent work in your existing signed-in Chrome profile without giving it unrestricted control of the profile. Each connection receives a task-owned browser workspace. The agent can create tabs, inspect and act in those tabs, wait for page state, and ask for help. Passwords, passkeys, 2FA, CAPTCHA, payment secrets, and other human-only input belong to **Your Turn**. Recognizable consequential actions are staged for **Commit** instead of being performed immediately. +AgentTab lets an agent work in your existing signed-in Chrome profile without giving it unrestricted control of the profile. Each connection receives a task-owned browser workspace. The agent can create tabs, inspect and act in those tabs, wait for page state, and ask for help. A disabled-by-default 1Password broker can fill a matching login or one-time code without exposing its value to the agent; passkeys, security keys, CAPTCHA, payment secrets, account recovery, and unsupported verification remain **Your Turn**. Recognizable consequential actions are staged for **Commit** instead of being performed immediately. ## Release status @@ -24,7 +24,7 @@ The command has no path, token, or shell-specific argument and is suitable for P 1. An agent calls `browser_open` with `mode: "create"`. AgentTab creates a background tab for that task and returns its task, tab, window, page-revision, and automation-route identifiers. `placement: "new_window"` may create the task's first tab in a separate unfocused normal window. 2. On a normal web origin, the agent calls `browser_snapshot`, works from revisioned accessibility references, then calls `browser_act` with the expected page revision. It cannot act on unrelated tabs. -3. If a site requires human-only input, the agent calls `browser_handoff`. AgentTab focuses that tab, pauses automation, and blocks browser observation until the declared completion condition or **I'm done**. +3. If managed policy enables 1Password and an ordinary sign-in page has at most three origin-matching Login items, the agent can request a short-lived opaque token and ask the host to fill named field refs. Credential values travel only from `op` to the host and extension, never through Core RPC or the adapter. Every other human-only input uses `browser_handoff`, which focuses that tab, pauses automation, and blocks browser observation until the declared completion condition or **I'm done**. 4. If AgentTab recognizes a send, publish, purchase, delete, upload, authorization, or permission-grant control, `browser_act` can return `commit_required`. The extension shows the staged effect in its popup. A human must approve it there before the agent can call `browser_commit` with the one-use staged token. 5. The task can list only its own tabs with `browser_tabs`. A separate client gets a separate task unless it proves its durable resume capability. @@ -41,7 +41,7 @@ Commit is a two-party, best-effort semantic barrier, not proof that a page has n ## Tool surface -Standard mode exposes exactly seven MCP tools: +Standard mode exposes exactly eight MCP tools: | Tool | Purpose | |---|---| @@ -52,6 +52,7 @@ Standard mode exposes exactly seven MCP tools: | `browser_tabs` | List only tabs owned by the current task, including each tab's automation route. | | `browser_handoff` | Give the user control for human-only input. | | `browser_commit` | Execute one staged consequential action. | +| `browser_credentials` | Prepare and fill an origin-matching 1Password login through opaque, short-lived host tokens when managed policy explicitly enables it. | Developer mode adds one tool, `browser_developer`. It is absent from Standard discovery. It requires both the persistent Developer mode control in the AgentTab popup and `AGENTTAB_DEVELOPER=1` in the adapter environment. Treat it as an explicit expansion of the normal boundary. diff --git a/docs/adr/0001-agenttab-runtime.md b/docs/adr/0001-agenttab-runtime.md index 904bb03..022cd07 100644 --- a/docs/adr/0001-agenttab-runtime.md +++ b/docs/adr/0001-agenttab-runtime.md @@ -26,7 +26,7 @@ A task workspace is visible in Chrome. Task-owned tabs are grouped for display, ### Your Turn -**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. +**Your Turn** is the human-only input boundary. AgentTab MUST hand control to the user for passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, and any credential workflow that returns `needs_user`. A disabled-by-default managed 1Password broker MAY fill an origin-matching Login item through the private host-to-extension path, but MUST NOT expose the value to an agent or submit the form. 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. @@ -47,7 +47,7 @@ Task ownership is an execution and coordination boundary. It is not cookie, iden Standard mode MUST NOT expose: - raw cookies or browser storage -- passwords, passkeys, payment secrets, or human-only input +- credential values, saved-item lists, passkeys, payment secrets, or human-only input; an explicitly enabled managed broker MAY fill one origin-matching Login item without returning its value - arbitrary JavaScript execution - raw Chrome DevTools Protocol access - coordinate-based actions @@ -80,7 +80,7 @@ TCP and bearer-token access are not Standard transport. They exist only behind t AgentTab Core RPC and the host-to-extension native protocol are separately versioned. They MUST NOT silently downgrade across an incompatible version. -MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not alternate hosts. The public Standard surface has exactly seven tools: +MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not alternate hosts. The public Standard surface has exactly eight tools: 1. `browser_open` 2. `browser_snapshot` @@ -89,8 +89,11 @@ MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not a 5. `browser_tabs` 6. `browser_handoff` 7. `browser_commit` +8. `browser_credentials` -`browser_developer` is the eighth tool and is absent unless Developer mode is enabled. +`browser_credentials` is disabled by managed policy unless explicitly enabled. It MUST derive the page origin and task ownership in the host, enforce a candidate and attempt limit no greater than three, use one-use short-lived tokens, and keep credential values out of Core RPC, adapters, responses, and audit output. + +`browser_developer` is the ninth tool and is absent unless Developer mode is enabled. Core RPC schemas are normative. Unknown fields and methods fail closed. Every mutation requires a UUIDv7 `idempotency_key`. Existing-page mutations also require the authoritative `tab_id` and expected `page_revision`. `browser_commit` is bound by its staged record rather than caller-supplied tab or revision. diff --git a/docs/commands.md b/docs/commands.md index c9f73c0..130a1a9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -82,7 +82,7 @@ The extension layer is a diagnostic label around the status check. It does not r agenttab mcp ``` -Starts the AgentTab MCP server over stdin and stdout. It accepts no CLI options. The packaged `agenttab-mcp` binary starts the same server and also accepts no CLI options. Both expose seven Standard tools by default. Use the configured `agenttab` wrapper after a local installation, or the `agenttab-mcp` package binary only after that package is published. +Starts the AgentTab MCP server over stdin and stdout. It accepts no CLI options. The packaged `agenttab-mcp` binary starts the same server and also accepts no CLI options. Both expose eight Standard tools by default. Use the configured `agenttab` wrapper after a local installation, or the `agenttab-mcp` package binary only after that package is published. The installer writes supported MCP client entries as an absolute local AgentTab wrapper plus `mcp`. For a manual configuration, use `agenttab mcp` only when that wrapper is on the client's `PATH`. The exact stdio configuration and protocol behavior are in [MCP](mcp.md). diff --git a/docs/launch/chrome-web-store.md b/docs/launch/chrome-web-store.md index 1544993..69d6b81 100644 --- a/docs/launch/chrome-web-store.md +++ b/docs/launch/chrome-web-store.md @@ -28,11 +28,11 @@ An agent starts with a task workspace, not general access to every tab. AgentTab The runtime consists of one minimal MV3 extension, a local Rust host, and per-user operating-system-native IPC. The extension uses Chrome Native Messaging to reach the local host. Client adapters, including MCP, connect to the host through a user-owned Unix socket on macOS and Linux or a current-user named pipe on Windows. AgentTab has no cloud relay, remote browser session, telemetry service, or routine network control plane. -Standard MCP access exposes exactly seven tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, and `browser_commit`. `browser_developer` is available only after a persistent, explicit Developer mode opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. +Standard MCP access exposes exactly eight tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. `browser_developer` is available only after a persistent, explicit Developer mode opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. ### 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. +The optional local 1Password broker can fill one of at most three origin-matching Login items directly into a selected field without revealing the value to the agent. **Your Turn** remains the path for passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, or a broker result that needs the user. 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. **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. @@ -68,7 +68,7 @@ These notes are for a controlled reviewer package only. They are not public inst 5. Demonstrate Your Turn with a harmless test page. Verify that observations from every task return `needs_user` during the handoff and that the agent resumes only after Done or the declared completion condition. 6. Demonstrate Commit with a controlled test control labelled as a send, upload, delete, authorization, or permission action. Verify that no side effect occurs before the human popup approves the staged action with the requesting agent's one-use token. Do not use a real message, purchase, upload, deletion, or authorization. 7. Demonstrate Pause and Resume, including that queued work does not start after Pause and that task status remains visible after recovery. -8. Verify that Standard discovery exposes exactly the seven Standard tools and that the Developer-only tool is absent until the reviewer explicitly enables Developer mode. +8. Verify that Standard discovery exposes exactly the eight Standard tools, that `browser_credentials` returns a disabled-policy result before any provider call, and that the Developer-only tool is absent until the reviewer explicitly enables Developer mode. ## Privacy declaration draft diff --git a/docs/launch/directory-listings.md b/docs/launch/directory-listings.md index 5c983d1..20be5a4 100644 --- a/docs/launch/directory-listings.md +++ b/docs/launch/directory-listings.md @@ -30,10 +30,10 @@ Each agent begins with a task workspace. AgentTab creates or visibly adopts a ta 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. +The Standard MCP surface has exactly eight tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. 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. +The optional local 1Password broker can fill one of at most three origin-matching Login items directly into a selected field without revealing the value to the agent. **Your Turn** remains the human handoff state for passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, or a broker result that needs the user. During handoff, AgentTab applies an observation blackout across every task, so standard observations return `needs_user`; 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..a3b37dd 100644 --- a/docs/launch/reddit-posts.md +++ b/docs/launch/reddit-posts.md @@ -16,14 +16,14 @@ The starting point is a task workspace rather than general browser control. An a 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 Standard MCP surface is intentionally small: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is disabled until managed policy enables the local 1Password broker and never returns a credential value. 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. A disabled-by-default local 1Password broker can fill one of at most three origin-matching Login items without returning a value to the agent. **Your Turn** handles passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, and broker results that need the user. 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. 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 global handoff blackout, Commit's best-effort semantics, and the eight-tool MCP default. ## Local-first and privacy community variant @@ -39,11 +39,11 @@ AgentTab is a **Local browser runtime for AI agents**. It gives each agent a vis 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**. A disabled-by-default local 1Password broker can fill one of at most three origin-matching Login items without revealing a value to the agent. If a task reaches a passkey, security key, CAPTCHA, payment secret, account recovery, unsupported verification, or broker result that needs the user, 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. 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. -The Standard MCP interface has seven tools and excludes raw cookies, browser storage, arbitrary scripts, raw CDP, and raw network access. A separate Developer-only tool requires a persistent explicit opt-in. +The Standard MCP interface has eight tools and excludes raw cookies, browser storage, arbitrary scripts, raw CDP, raw network access, and credential-value responses. A separate Developer-only tool requires a persistent explicit opt-in. The extension also declares the `` host permission for the defined `chrome.scripting` text, HTML, selector, wait, and scroll paths in task-owned pages. It is needed across the sites a person directs an agent to use, not to expose raw browser-data or browser-control APIs in Standard mode. The important caveat is that task ownership is coordination, not a security container around an identity. The profile remains the profile the person is signed into. Prompt injection, misleading content, and a poorly trusted local agent remain meaningful risks. diff --git a/docs/launch/show-hn.md b/docs/launch/show-hn.md index 3ae696f..5ae1075 100644 --- a/docs/launch/show-hn.md +++ b/docs/launch/show-hn.md @@ -20,11 +20,11 @@ AgentTab's promise is: **Give an agent a tab, not the keys to your browser.** It The runtime is one minimal MV3 extension plus a local Rust host. The extension uses Chrome Native Messaging, and local clients such as MCP adapters use per-user operating-system-native IPC: 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 network control plane. -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. +Standard MCP access is deliberately small: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is disabled until managed policy enables the local 1Password broker and never returns a credential value. 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. +A disabled-by-default local 1Password broker can fill one of at most three origin-matching Login items without returning a value to the agent. **Your Turn** handles passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, and broker results that need the user. 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. 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. -I am preparing the v2 design for controlled review, not public use. I would eventually welcome feedback on the task-workspace boundary, the Your Turn blackout, the best-effort Commit model, and whether the seven-tool MCP surface is the right default. There is no stable install path or launch link in this draft. +I am preparing the v2 design for controlled review, not public use. I would eventually welcome feedback on the task-workspace boundary, the Your Turn blackout, the best-effort Commit model, and whether the eight-tool MCP surface is the right default. There is no stable install path or launch link in this draft. diff --git a/docs/mcp.md b/docs/mcp.md index 650cb97..692de59 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -46,7 +46,7 @@ The package coordinate for the direct stdio binary is `agenttab-mcp`; its execut The same `agenttab mcp` entry is the manual shape for Claude Desktop, Cursor, Windsurf, and another stdio-MCP client. Do not put a profile path, port, token, or `AGENTTAB_SOCKET` override in routine client configuration. -For OMP and Pi, the release's public package coordinate is `@getagenttab/omp`. The package advertises both `omp.extensions` and `pi.extensions`, each pointing to the same built adapter. OMP receives native Zod schemas, approval metadata, and discoverable strict tools; Pi receives native TypeBox schemas. Both runtimes register the same seven Standard tools and render the same browser operation cards. Cards identify task and tab ownership, separate intent, policy decision, execution, and observation, surface browser effects and recovery instructions, and keep sensitive inputs out of collapsed output. Expanded results remain bounded and redact tokens, credentials, cookies, authorization, passwords, and secrets. The installer adds the local built extension path to OMP `config.extension` and Pi `packages`; the executable path remains local and does not assume a public registry install. +For OMP and Pi, the release's public package coordinate is `@getagenttab/omp`. The package advertises both `omp.extensions` and `pi.extensions`, each pointing to the same built adapter. OMP receives native Zod schemas, approval metadata, and discoverable strict tools; Pi receives native TypeBox schemas. Both runtimes register the same eight Standard tools and render the same browser operation cards. Cards identify task and tab ownership, separate intent, policy decision, execution, and observation, surface browser effects and recovery instructions, and keep sensitive inputs out of collapsed output. Expanded results remain bounded and redact tokens, credentials, cookies, authorization, passwords, and secrets. The installer adds the local built extension path to OMP configuration transactionally. ## Connection and durable resume @@ -58,7 +58,7 @@ OMP and Pi automatically use the harness session ID as their stable private conv For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. Each hashes the supplied conversation scope into the owner-only filename. See [Commands](commands.md#adapter-environment) for environment variables and [Core RPC connection schema](../schemas/rpc/v1/connection.schema.json) for the connection envelope. -## Seven Standard tools +## Eight Standard tools | Tool | Required input and behavior | |---|---| @@ -69,6 +69,7 @@ For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. | `browser_tabs` | Takes an empty object and lists only the current task's tabs, including each tab's `automation_route`. | | `browser_handoff` | Requires a task tab, expected page revision, prompt, completion condition, and optional timeout. Completion can be navigation, manual completion, a URL, or a selector. It remains available on a `tab_only` route because AgentTab blocks agent observation while the human controls the tab, but selector completion requires the `full` route. | | `browser_commit` | Requires the staged token returned by a prior `commit_required` action and executes that one staged operation. On a `tab_only` route, only a staged close can execute; page-dependent staged actions require the `full` route. | +| `browser_credentials` | `prepare` requires a task tab and expected page revision, then returns an opaque short-lived token only when managed policy enables 1Password and one through three Login items match the host-derived current origin. `fill` consumes that token and selected username, password, or one-time-code field refs without returning any value. `next` advances to another bounded candidate. It never submits the form. | Every existing-page mutation carries its expected page revision. If navigation or document replacement makes that revision stale, AgentTab rejects the operation rather than selecting a new target. @@ -98,11 +99,13 @@ The stdio MCP reader dispatches requests concurrently, while its writer serializ Raw TypeScript and Python SDK clients raise `AgentTabTransportError` for an ambiguous timeout, connection close, or transport failure. The error carries the method and, for mutations, the exact generated or caller-supplied idempotency key. A caller may reconnect and explicitly retry the same method and parameters with that key; the SDK never replays the request automatically. MCP and OMP adapters likewise return the failed invocation, discard a cached client only when its transport is closed, and reconnect on the next invocation. -### Your Turn handoff +### Credentials and 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. +When managed policy enables 1Password, call `browser_credentials` on an ordinary sign-in page before requesting manual password entry. `prepare` derives the current origin from host-owned tab state. `fill` accepts only accessibility refs and returns filled-field booleans; credential material never crosses Core RPC. Submit separately through `browser_act`, inspect the result, and use `next` only after the site rejects the current candidate. -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. +Call `browser_handoff` when credential preparation returns `needs_user`, the bounded candidates fail, or the site requires a passkey, security key, CAPTCHA, payment secret, account recovery, or unsupported verification. 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. + +The agent must not attempt snapshots, page reads, or mutations during a handoff. It should report the handoff prompt to the user and wait for the terminal tool result or an explicit user completion. ### Staged Commit @@ -138,5 +141,6 @@ Commit reduces recognizable risk only. It requires both the popup's human approv - [Wait parameters](../schemas/rpc/v1/browser-wait.schema.json) - [Handoff parameters](../schemas/rpc/v1/browser-handoff.schema.json) - [Commit parameters](../schemas/rpc/v1/browser-commit.schema.json) +- [Credential parameters](../schemas/rpc/v1/browser-credentials.schema.json) - [Commands](commands.md) - [Setup](setup.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3f99e9b..1c9fd95 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,7 +7,7 @@ AgentTab v2 is currently `2.0.0-rc.1`, a local prerelease. This page records lau | Gate | Current source state | Required evidence before promotion | | --- | --- | --- | | Product identity | Source identifies AgentTab, `dev.agenttab.host`, `agenttab`, and AgentTab Core RPC v1. | Exact-head identity and forbidden-surface gates. | -| Standard boundary | Source schemas define seven Standard browser tools; Developer mode adds `browser_developer`. | Schema, adapter discovery, and real-extension checks. | +| Standard boundary | Source schemas define eight Standard browser tools, including a disabled-by-default host-managed credential broker; Developer mode adds `browser_developer`. | Schema, adapter discovery, fake-provider credential isolation, and real-extension checks. | | Task safety | Source implements server-bound tasks, visible groups, revisions, Pause, handoff blackout, and staged Commit records. | Controlled browser fixtures covering restart, revocation, stale revisions, and one-use Commit. | | Rust runtime | Source contains the Rust host, native bridge, same-user IPC, SQLite journal, and local audit. | Exact-head Rust, IPC, Linux, macOS, and Windows gates. | | Installer | Source contains a transactional Node-compatible installer and advanced loopback proxy. | Clean user-home and clean-machine install proof using the packaged signed bytes. | diff --git a/docs/security.md b/docs/security.md index 952377e..0c7f3f4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,7 +4,7 @@ AgentTab is a **local browser runtime for AI agents**. It gives an agent task-ow ## Trust boundary and local attacker model -A task may use the profile already signed in to websites. An agent can therefore act with the permissions of the currently signed-in user on a task-owned tab. AgentTab does not expose raw cookies, storage, saved passwords, or password-manager contents through Standard mode, but that does not make the signed-in session untrusted or isolated. +A task may use the profile already signed in to websites. An agent can therefore act with the permissions of the currently signed-in user on a task-owned tab. AgentTab does not expose raw cookies, storage, saved-password lists, or credential values through Standard mode. When managed policy explicitly enables the 1Password broker, the host may obtain one origin-matching Login item and send selected values directly to the owned page through the extension. That narrow path does not make the signed-in session untrusted or isolated. The host authenticates its local IPC peers as the current OS user. This prevents a different local user from connecting through the user socket or named pipe. It does **not** distinguish benign and malicious processes running as that same user. A local attacker that can execute as the account, read AgentTab state, control the browser, or modify the extension/host installation is outside this protection. Use an OS account and a browser profile appropriate for the work, protect the account, and treat local malware as a full compromise. @@ -12,7 +12,7 @@ Task-owned groups constrain ordinary AgentTab execution. They do not prevent a p ## Standard and Developer mode -Standard mode exposes exactly these seven MCP tools: +Standard mode exposes exactly these eight MCP tools: - `browser_open` - `browser_snapshot` @@ -21,12 +21,21 @@ Standard mode exposes exactly these seven MCP tools: - `browser_tabs` - `browser_handoff` - `browser_commit` +- `browser_credentials` Developer mode additionally exposes `browser_developer`. It is disabled by default in the host's local managed policy and is visibly marked on a task. Developer mode is intentionally a broader trust decision. -Standard mode has no raw CDP method, arbitrary JavaScript API, raw cookie or storage API, coordinate action, generic browser-global mutation API, or password-value API. The narrow exception is `browser_open` with `placement: "new_window"`, which creates one unfocused normal window for the first tab of an empty task. Internally, the extension uses Chrome debugging APIs for task-scoped accessibility snapshots and precise ref-based actions. That implementation detail is not a Standard-mode escape hatch. +Standard mode has no raw CDP method, arbitrary JavaScript API, raw cookie or storage API, coordinate action, generic browser-global mutation API, or credential-value API. The narrow exceptions are `browser_open` with `placement: "new_window"`, which creates one unfocused normal window for the first tab of an empty task, and policy-gated `browser_credentials`, which passes an origin-matched login field from 1Password directly to a selected field ref without returning the value. Internally, the extension uses Chrome debugging APIs for task-scoped accessibility snapshots and precise ref-based actions. Those implementation details are not a Standard-mode escape hatch. -Sensitive password, passkey, one-time-code, payment, and similar fields require `browser_handoff`. The human enters the value in Chrome; the value is not placed in an AgentTab request. +Passkeys, security keys, CAPTCHA, payment secrets, account recovery, and unsupported verification require `browser_handoff`. The human completes the step in Chrome; no secret is placed in an AgentTab request. + +## 1Password credential broker + +The broker is disabled unless `policy.json` sets `one_password.enabled` to `true`. The host derives the current origin from its verified task-tab inventory, queries only 1Password Login items, and accepts an item only when a URL on that item matches the current host or a recognized authentication subdomain of the same registrable domain. Item titles and unrelated fields do not authorize a match. + +`one_password.max_candidates` and `one_password.max_attempts` are each restricted to `1..3`. More than the configured candidate limit returns `needs_user` without selecting or exposing an item. A prepared attempt receives a random, short-lived token bound to the task, tab, origin, candidate position, and attempt count. Tokens are one-use for fill or advance, expire after five minutes, and are invalidated by navigation. + +The host invokes the absolute `one_password.executable` when configured, or `op` from its process `PATH` otherwise. JSON output is captured in memory. The broker selects only username, password, and one-time-code fields, then sends them through the private host-to-extension credential method. Core RPC, MCP, OMP, Pi, and audit responses contain only candidate counts, booleans, attempt counters, and opaque tokens. Credential values are neither serialized into an adapter request nor written to audit output. A 1Password authentication timeout, no match, unsupported item, provider failure, exhausted attempts, or ambiguous candidate set returns `needs_user`. ## Permissions diff --git a/docs/setup.md b/docs/setup.md index 57c8080..ed45074 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -8,8 +8,8 @@ This guide distinguishes the contributor source path from the future signed RC a - Chrome must be version 127 or later for the current extension manifest. - AgentTab runs in the existing signed-in Chrome profile. It is task-scoped browser control, not a separate profile, cookie jar, or identity boundary. -- Keep page content untrusted. Use **Your Turn** for passwords, passkeys, 2FA, CAPTCHA, payment secrets, and other human-only input. Review a staged **Commit** before performing it. -- A future installation needs an AgentTab extension and the `dev.agenttab.host` native host. Standard mode does not require a TCP listener, a bearer token, or a Python process. +- Keep page content untrusted. A managed, disabled-by-default 1Password broker may fill an origin-matching Login item without exposing its value to the agent. Use **Your Turn** for passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, and any credential result that requests the user. Review a staged **Commit** before performing it. +- A future installation needs an AgentTab extension and the `dev.agenttab.host` native host. Standard mode does not require a TCP listener, a bearer token, or a Python process. Credential filling additionally requires 1Password CLI with desktop-app integration and biometric unlock available to the current OS user. The product boundary and residual Commit risk are described in the [runtime ADR](adr/0001-agenttab-runtime.md) and [Security](security.md). @@ -86,6 +86,38 @@ Normal local adapter traffic uses one of these OS-native endpoints: On Unix, AgentTab requires its state and runtime directories to be current-user owned and mode `0700`; its socket and host lock are mode `0600`. The host authenticates local peers with OS credentials. On Windows, the named-pipe DACL is limited to the current user SID and `SYSTEM`. `AGENTTAB_SOCKET` and `AGENTTAB_PIPE_NAME` are adapter overrides for configured local endpoints, not normal setup switches. +## Optional 1Password credential broker + +Credential filling is off by default. Enable it only in the host's owner-only +`~/.agenttab/policy.json`: + +```json +{ + "one_password": { + "enabled": true, + "executable": "/opt/homebrew/bin/op", + "max_candidates": 3, + "max_attempts": 3, + "auth_timeout_ms": 45000 + } +} +``` + +The limits must be integers from one through three. `auth_timeout_ms` must be +between 5000 and 120000. An optional `account` selects one configured +1Password account without putting a secret in policy. Set `executable` to the +absolute `op` path when a GUI-launched browser does not inherit the shell +`PATH`; relative paths are rejected. Policy changes apply when the native host +next starts, so reload the extension after editing this file. + +Install and sign in to 1Password CLI separately, enable its desktop-app +integration, and keep biometric unlock available. AgentTab never stores a +1Password session token. If 1Password needs approval, its Touch ID or Apple +Watch prompt is the only expected human interaction. More than the configured +number of origin-matching Login items, unavailable CLI state, or an unsupported +verification step returns control to the user instead of trying additional +credentials. + ## Migration from Chrome Bridge v1.0.1 AgentTab v2 is side-by-side and recoverable: diff --git a/host-rs/crates/agenttab-host/src/credentials.rs b/host-rs/crates/agenttab-host/src/credentials.rs new file mode 100644 index 0000000..a1b0d08 --- /dev/null +++ b/host-rs/crates/agenttab-host/src/credentials.rs @@ -0,0 +1,784 @@ +use crate::guardrails::OnePasswordPolicy; +use parking_lot::Mutex; +use rand::RngCore; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use url::Url; +use uuid::Uuid; + +const ATTEMPT_TTL: Duration = Duration::from_secs(5 * 60); +const MAX_PROVIDER_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PROVIDER_ERROR_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone)] +struct Candidate { + id: String, + title: String, + urls: Vec, +} + +#[derive(Debug)] +struct Attempt { + task_id: Uuid, + tab_id: u64, + host: String, + candidates: Vec, + current: usize, + used: usize, + current_started: bool, + expires_at: Instant, +} + +pub struct CredentialMaterial { + pub username: Option, + pub password: Option, + pub otp: Option, +} + +impl std::fmt::Debug for CredentialMaterial { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CredentialMaterial") + .field("has_username", &self.username.is_some()) + .field("has_password", &self.password.is_some()) + .field("has_otp", &self.otp.is_some()) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NeedsUserReason { + NoMatch, + Ambiguous, + Exhausted, + ProviderTimeout, + ProviderUnavailable, + UnsupportedCredential, +} + +impl NeedsUserReason { + pub fn status(self) -> &'static str { + match self { + Self::NoMatch => "no_match", + Self::Ambiguous => "ambiguous", + Self::Exhausted => "attempts_exhausted", + Self::ProviderTimeout => "provider_timeout", + Self::ProviderUnavailable => "provider_unavailable", + Self::UnsupportedCredential => "unsupported_credential", + } + } + + pub fn recovery(self) -> &'static str { + match self { + Self::NoMatch => "Sign in manually or save a matching Login item in 1Password.", + Self::Ambiguous => "Choose the intended 1Password item manually; AgentTab will not guess among more than three matches.", + Self::Exhausted => "Sign in manually or remove stale matching Login items before retrying.", + Self::ProviderTimeout => "Approve the 1Password biometric prompt, then prepare credentials again.", + Self::ProviderUnavailable => "Unlock 1Password, verify its CLI app integration, and verify one_password.executable is available to the native host before retrying.", + Self::UnsupportedCredential => "Use the site's passkey or security-key flow with 1Password, or sign in manually.", + } + } +} + +#[derive(Debug)] +pub enum PrepareResult { + Ready { + credential_token: String, + candidate_count: usize, + expires_at_ms: u128, + }, + NeedsUser { + reason: NeedsUserReason, + candidate_count: usize, + }, +} + +#[derive(Debug)] +pub enum SelectResult { + Ready { + material: CredentialMaterial, + attempt_number: usize, + remaining_attempts: usize, + }, + NeedsUser { + reason: NeedsUserReason, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum BrokerError { + #[error("credential token is invalid or expired")] + InvalidToken, + #[error("credential token is bound to another task, tab, or origin")] + BindingMismatch, + #[error("the next credential can only be selected after the current one was tried")] + NextBeforeFill, +} + +trait CredentialProvider: Send + Sync { + fn candidates(&self, host: &str) -> Result, ProviderError>; + fn material( + &self, + candidate: &Candidate, + host: &str, + include_otp: bool, + ) -> Result; +} + +#[derive(Debug, Clone, Copy)] +enum ProviderError { + Timeout, + Unavailable, +} + +pub struct CredentialBroker { + policy: OnePasswordPolicy, + provider: Arc, + attempts: Mutex>, +} + +impl std::fmt::Debug for CredentialBroker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CredentialBroker") + .field("enabled", &self.policy.enabled) + .field("attempt_count", &self.attempts.lock().len()) + .finish() + } +} + +impl CredentialBroker { + pub fn new(policy: OnePasswordPolicy) -> Self { + let provider = Arc::new(OnePasswordProvider::new(policy.clone())); + Self { + policy, + provider, + attempts: Mutex::new(HashMap::new()), + } + } + + #[cfg(test)] + fn with_provider(policy: OnePasswordPolicy, provider: Arc) -> Self { + Self { + policy, + provider, + attempts: Mutex::new(HashMap::new()), + } + } + + pub fn prepare(&self, task_id: Uuid, tab_id: u64, host: &str) -> PrepareResult { + self.prune_expired(); + let candidates = match self.provider.candidates(host) { + Ok(candidates) => candidates, + Err(ProviderError::Timeout) => { + return PrepareResult::NeedsUser { + reason: NeedsUserReason::ProviderTimeout, + candidate_count: 0, + } + } + Err(ProviderError::Unavailable) => { + return PrepareResult::NeedsUser { + reason: NeedsUserReason::ProviderUnavailable, + candidate_count: 0, + } + } + }; + let candidate_count = candidates.len(); + if candidate_count == 0 { + return PrepareResult::NeedsUser { + reason: NeedsUserReason::NoMatch, + candidate_count, + }; + } + if candidate_count > self.policy.max_candidates { + return PrepareResult::NeedsUser { + reason: NeedsUserReason::Ambiguous, + candidate_count, + }; + } + + let credential_token = random_token(); + let expires_at_ms = now_ms() + ATTEMPT_TTL.as_millis(); + self.attempts.lock().insert( + credential_token.clone(), + Attempt { + task_id, + tab_id, + host: host.to_string(), + candidates, + current: 0, + used: 0, + current_started: false, + expires_at: Instant::now() + ATTEMPT_TTL, + }, + ); + PrepareResult::Ready { + credential_token, + candidate_count, + expires_at_ms, + } + } + + #[allow(clippy::too_many_arguments)] + pub fn select( + &self, + credential_token: &str, + task_id: Uuid, + tab_id: u64, + host: &str, + next: bool, + include_otp: bool, + ) -> Result { + self.prune_expired(); + let (candidate, attempt_number, remaining_attempts) = { + let mut attempts = self.attempts.lock(); + let attempt = attempts + .get_mut(credential_token) + .ok_or(BrokerError::InvalidToken)?; + if attempt.task_id != task_id || attempt.tab_id != tab_id || attempt.host != host { + return Err(BrokerError::BindingMismatch); + } + if next { + if !attempt.current_started { + return Err(BrokerError::NextBeforeFill); + } + attempt.current += 1; + attempt.current_started = false; + } + if attempt.current >= attempt.candidates.len() + || attempt.used >= self.policy.max_attempts + { + return Ok(SelectResult::NeedsUser { + reason: NeedsUserReason::Exhausted, + }); + } + if !attempt.current_started { + attempt.current_started = true; + attempt.used += 1; + } + ( + attempt.candidates[attempt.current].clone(), + attempt.used, + self.policy.max_attempts.saturating_sub(attempt.used), + ) + }; + + let material = match self.provider.material(&candidate, host, include_otp) { + Ok(material) => material, + Err(ProviderError::Timeout) => { + return Ok(SelectResult::NeedsUser { + reason: NeedsUserReason::ProviderTimeout, + }) + } + Err(ProviderError::Unavailable) => { + return Ok(SelectResult::NeedsUser { + reason: NeedsUserReason::ProviderUnavailable, + }) + } + }; + if material.username.is_none() && material.password.is_none() && material.otp.is_none() { + return Ok(SelectResult::NeedsUser { + reason: NeedsUserReason::UnsupportedCredential, + }); + } + Ok(SelectResult::Ready { + material, + attempt_number, + remaining_attempts, + }) + } + + fn prune_expired(&self) { + self.attempts + .lock() + .retain(|_, attempt| attempt.expires_at > Instant::now()); + } +} + +#[derive(Debug)] +struct OnePasswordProvider { + executable: PathBuf, + account: Option, + timeout: Duration, +} + +impl OnePasswordProvider { + fn new(policy: OnePasswordPolicy) -> Self { + Self { + executable: policy.executable.unwrap_or_else(|| PathBuf::from("op")), + account: policy.account, + timeout: Duration::from_millis(policy.auth_timeout_ms), + } + } + + fn run_json(&self, arguments: &[&str]) -> Result { + let mut owned = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + if let Some(account) = &self.account { + owned.push("--account".into()); + owned.push(account.clone()); + } + let output = run_op(&self.executable, &owned, self.timeout)?; + serde_json::from_slice(&output).map_err(|_| ProviderError::Unavailable) + } + + fn run_text(&self, arguments: &[&str]) -> Result { + let mut owned = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + if let Some(account) = &self.account { + owned.push("--account".into()); + owned.push(account.clone()); + } + let output = run_op(&self.executable, &owned, self.timeout)?; + String::from_utf8(output) + .map(|value| value.trim().to_string()) + .map_err(|_| ProviderError::Unavailable) + } +} + +impl CredentialProvider for OnePasswordProvider { + fn candidates(&self, host: &str) -> Result, ProviderError> { + let value = self.run_json(&[ + "item", + "list", + "--categories", + "Login", + "--long", + "--format", + "json", + ])?; + let items = value.as_array().ok_or(ProviderError::Unavailable)?; + let mut candidates = items + .iter() + .filter_map(parse_candidate) + .filter(|candidate| candidate_matches_host(candidate, host)) + .collect::>(); + candidates.sort_by(|left, right| left.title.cmp(&right.title).then(left.id.cmp(&right.id))); + candidates.dedup_by(|left, right| left.id == right.id); + Ok(candidates) + } + + fn material( + &self, + candidate: &Candidate, + host: &str, + include_otp: bool, + ) -> Result { + let value = + self.run_json(&["item", "get", &candidate.id, "--reveal", "--format", "json"])?; + let detail_urls = extract_urls(&value); + if !detail_urls.is_empty() && !detail_urls.iter().any(|url| host_matches_url(host, url)) { + return Err(ProviderError::Unavailable); + } + let mut username = None; + let mut password = None; + if let Some(fields) = value.get("fields").and_then(Value::as_array) { + for field in fields { + let Some(field_value) = field.get("value").and_then(Value::as_str) else { + continue; + }; + let purpose = field.get("purpose").and_then(Value::as_str).unwrap_or(""); + let id = field.get("id").and_then(Value::as_str).unwrap_or(""); + let label = field.get("label").and_then(Value::as_str).unwrap_or(""); + let field_type = field.get("type").and_then(Value::as_str).unwrap_or(""); + if username.is_none() + && (purpose.eq_ignore_ascii_case("USERNAME") + || id.eq_ignore_ascii_case("username") + || is_username_label(label)) + { + username = Some(field_value.to_string()); + } else if password.is_none() + && (purpose.eq_ignore_ascii_case("PASSWORD") + || id.eq_ignore_ascii_case("password") + || (field_type.eq_ignore_ascii_case("CONCEALED") + && label.eq_ignore_ascii_case("password"))) + { + password = Some(field_value.to_string()); + } + } + } + let otp = if include_otp { + self.run_text(&["item", "get", &candidate.id, "--otp"]) + .ok() + .filter(|value| !value.is_empty()) + } else { + None + }; + Ok(CredentialMaterial { + username, + password, + otp, + }) + } +} + +fn run_op( + executable: &Path, + arguments: &[String], + timeout: Duration, +) -> Result, ProviderError> { + let mut child = Command::new(executable) + .args(arguments) + .env("OP_BIOMETRIC_UNLOCK_ENABLED", "true") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| ProviderError::Unavailable)?; + let stdout = child.stdout.take().ok_or(ProviderError::Unavailable)?; + let stderr = child.stderr.take().ok_or(ProviderError::Unavailable)?; + let stdout_reader = thread::spawn(move || read_bounded(stdout, MAX_PROVIDER_OUTPUT_BYTES)); + let stderr_reader = thread::spawn(move || read_bounded(stderr, MAX_PROVIDER_ERROR_BYTES)); + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(ProviderError::Timeout); + } + Err(_) => return Err(ProviderError::Unavailable), + } + }; + let stdout = stdout_reader + .join() + .map_err(|_| ProviderError::Unavailable)??; + let _ = stderr_reader.join(); + if !status.success() { + return Err(ProviderError::Unavailable); + } + Ok(stdout) +} + +fn read_bounded(mut reader: impl Read, maximum: usize) -> Result, ProviderError> { + let mut kept = Vec::new(); + let mut buffer = [0_u8; 8192]; + let mut overflow = false; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| ProviderError::Unavailable)?; + if count == 0 { + break; + } + let remaining = maximum.saturating_sub(kept.len()); + kept.extend_from_slice(&buffer[..count.min(remaining)]); + overflow |= count > remaining; + } + if overflow { + Err(ProviderError::Unavailable) + } else { + Ok(kept) + } +} + +fn parse_candidate(value: &Value) -> Option { + let id = value + .get("id") + .or_else(|| value.get("ID")) + .and_then(Value::as_str)? + .to_string(); + let title = value + .get("title") + .or_else(|| value.get("Title")) + .and_then(Value::as_str)? + .to_string(); + Some(Candidate { + id, + title, + urls: extract_urls(value), + }) +} + +fn extract_urls(value: &Value) -> Vec { + let mut urls = Vec::new(); + for key in ["urls", "URLs", "website", "websites"] { + if let Some(candidate) = value.get(key) { + collect_url_values(candidate, &mut urls); + } + } + if let Some(fields) = value.get("fields").and_then(Value::as_array) { + for field in fields { + if field + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind.eq_ignore_ascii_case("URL")) + { + if let Some(url) = field.get("value").and_then(Value::as_str) { + urls.push(url.to_string()); + } + } + } + } + urls.sort(); + urls.dedup(); + urls +} + +fn collect_url_values(value: &Value, urls: &mut Vec) { + match value { + Value::String(url) => urls.push(url.clone()), + Value::Array(values) => values + .iter() + .for_each(|value| collect_url_values(value, urls)), + Value::Object(object) => { + for key in ["href", "url", "value", "primary"] { + if let Some(value) = object.get(key) { + collect_url_values(value, urls); + } + } + } + _ => {} + } +} + +fn candidate_matches_host(candidate: &Candidate, host: &str) -> bool { + candidate.urls.iter().any(|url| host_matches_url(host, url)) +} + +fn host_matches_url(host: &str, value: &str) -> bool { + let Ok(url) = Url::parse(value) else { + return false; + }; + let Some(candidate_host) = url.host_str() else { + return false; + }; + same_host_family(host, candidate_host) +} + +fn same_host_family(left: &str, right: &str) -> bool { + let normalize = |host: &str| { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let mut labels = host.split('.').collect::>(); + while labels.len() > 2 + && matches!( + labels.first().copied(), + Some("www" | "login" | "auth" | "signin" | "account" | "accounts" | "id" | "sso") + ) + { + labels.remove(0); + } + labels.join(".") + }; + normalize(left) == normalize(right) +} + +fn is_username_label(label: &str) -> bool { + matches!( + label.to_ascii_lowercase().as_str(), + "username" | "email" | "email address" | "login" + ) +} + +fn random_token() -> String { + let mut bytes = [0_u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct FakeProvider { + candidates: Vec, + material: CredentialMaterial, + } + + impl CredentialProvider for FakeProvider { + fn candidates(&self, _host: &str) -> Result, ProviderError> { + Ok(self.candidates.clone()) + } + + fn material( + &self, + _candidate: &Candidate, + _host: &str, + _include_otp: bool, + ) -> Result { + Ok(CredentialMaterial { + username: self.material.username.clone(), + password: self.material.password.clone(), + otp: self.material.otp.clone(), + }) + } + } + + fn candidate(id: &str) -> Candidate { + Candidate { + id: id.into(), + title: "Example".into(), + urls: vec!["https://example.com".into()], + } + } + + #[cfg(unix)] + #[test] + fn configured_one_password_executable_is_used_without_process_path() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("op-fixture"); + std::fs::write(&executable, "#!/bin/sh\nprintf '[]'\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let provider = OnePasswordProvider::new(OnePasswordPolicy { + enabled: true, + executable: Some(executable), + ..OnePasswordPolicy::default() + }); + + assert!(provider.candidates("example.com").unwrap().is_empty()); + } + + #[test] + fn refuses_more_than_three_matching_items_without_exposing_identity() { + let provider = Arc::new(FakeProvider { + candidates: vec![ + candidate("a"), + candidate("b"), + candidate("c"), + candidate("d"), + ], + material: CredentialMaterial { + username: None, + password: None, + otp: None, + }, + }); + let policy = OnePasswordPolicy { + enabled: true, + ..OnePasswordPolicy::default() + }; + let broker = CredentialBroker::with_provider(policy, provider); + assert!(matches!( + broker.prepare(Uuid::now_v7(), 7, "example.com"), + PrepareResult::NeedsUser { + reason: NeedsUserReason::Ambiguous, + candidate_count: 4 + } + )); + } + + #[test] + fn token_is_bound_and_attempts_are_candidate_bounded() { + let provider = Arc::new(FakeProvider { + candidates: vec![candidate("a"), candidate("b")], + material: CredentialMaterial { + username: Some("person".into()), + password: Some("secret".into()), + otp: None, + }, + }); + let policy = OnePasswordPolicy { + enabled: true, + ..OnePasswordPolicy::default() + }; + let broker = CredentialBroker::with_provider(policy, provider); + let task_id = Uuid::now_v7(); + let PrepareResult::Ready { + credential_token, .. + } = broker.prepare(task_id, 7, "example.com") + else { + panic!("expected ready credential token"); + }; + assert!(matches!( + broker.select( + &credential_token, + Uuid::now_v7(), + 7, + "example.com", + false, + false + ), + Err(BrokerError::BindingMismatch) + )); + assert!(matches!( + broker.select(&credential_token, task_id, 7, "example.com", false, false), + Ok(SelectResult::Ready { + attempt_number: 1, + remaining_attempts: 2, + .. + }) + )); + assert!(matches!( + broker.select(&credential_token, task_id, 7, "example.com", true, false), + Ok(SelectResult::Ready { + attempt_number: 2, + remaining_attempts: 1, + .. + }) + )); + assert!(matches!( + broker.select(&credential_token, task_id, 7, "example.com", true, false), + Ok(SelectResult::NeedsUser { + reason: NeedsUserReason::Exhausted + }) + )); + } + + #[test] + fn host_matching_accepts_known_auth_subdomains_without_title_fallback() { + assert!(host_matches_url( + "login.example.com", + "https://example.com/signin" + )); + assert!(host_matches_url( + "auth.example.com", + "https://www.example.com/signin" + )); + assert!(!host_matches_url( + "secure.example.com", + "https://example.com/signin" + )); + assert!(!host_matches_url( + "example.com", + "https://unrelated.example/signin" + )); + assert!(!candidate_matches_host( + &Candidate { + id: "title-only".into(), + title: "Example".into(), + urls: Vec::new(), + }, + "example.com", + )); + } + + #[test] + fn credential_debug_output_never_contains_material() { + let material = CredentialMaterial { + username: Some("private-user".into()), + password: Some("private-password".into()), + otp: Some("123456".into()), + }; + let debug = format!("{material:?}"); + assert!(!debug.contains("private-user")); + assert!(!debug.contains("private-password")); + assert!(!debug.contains("123456")); + assert!(debug.contains("has_username: true")); + assert!(debug.contains("has_password: true")); + assert!(debug.contains("has_otp: true")); + } +} diff --git a/host-rs/crates/agenttab-host/src/guardrails.rs b/host-rs/crates/agenttab-host/src/guardrails.rs index 6c9f2ce..fef1b9f 100644 --- a/host-rs/crates/agenttab-host/src/guardrails.rs +++ b/host-rs/crates/agenttab-host/src/guardrails.rs @@ -27,8 +27,33 @@ pub enum GuardrailLoadError { pattern: String, source: regex::Error, }, + #[error("invalid 1Password policy: {0}")] + InvalidOnePasswordPolicy(String), } +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct OnePasswordPolicy { + pub enabled: bool, + pub account: Option, + pub executable: Option, + pub max_candidates: usize, + pub max_attempts: usize, + pub auth_timeout_ms: u64, +} + +impl Default for OnePasswordPolicy { + fn default() -> Self { + Self { + enabled: false, + account: None, + executable: None, + max_candidates: 3, + max_attempts: 3, + auth_timeout_ms: 45_000, + } + } +} #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] struct Policy { @@ -39,6 +64,7 @@ struct Policy { dlp_allowed_roots: Vec, dlp_max_file_bytes: u64, redact_patterns: Vec, + one_password: OnePasswordPolicy, } impl Default for Policy { @@ -50,6 +76,7 @@ impl Default for Policy { allowed_origins: Vec::new(), dlp_allowed_roots: Vec::new(), dlp_max_file_bytes: DEFAULT_DLP_MAX_FILE_BYTES, + one_password: OnePasswordPolicy::default(), redact_patterns: Vec::new(), } } @@ -82,6 +109,39 @@ impl Guardrails { } fn from_policy(policy: Policy) -> Result { + let credentials = &policy.one_password; + if !(1..=3).contains(&credentials.max_candidates) { + return Err(GuardrailLoadError::InvalidOnePasswordPolicy( + "max_candidates must be between 1 and 3".into(), + )); + } + if !(1..=credentials.max_candidates).contains(&credentials.max_attempts) { + return Err(GuardrailLoadError::InvalidOnePasswordPolicy(format!( + "max_attempts must be between 1 and {}", + credentials.max_candidates + ))); + } + if !(5_000..=120_000).contains(&credentials.auth_timeout_ms) { + return Err(GuardrailLoadError::InvalidOnePasswordPolicy( + "auth_timeout_ms must be between 5000 and 120000".into(), + )); + } + if credentials.account.as_ref().is_some_and(|account| { + account.is_empty() || account.len() > 256 || account.contains('\0') + }) { + return Err(GuardrailLoadError::InvalidOnePasswordPolicy( + "account must contain between 1 and 256 non-NUL bytes".into(), + )); + } + if credentials + .executable + .as_ref() + .is_some_and(|executable| !executable.is_absolute()) + { + return Err(GuardrailLoadError::InvalidOnePasswordPolicy( + "executable must be an absolute path".into(), + )); + } let mut patterns = vec![ r"(?i)\bbearer\s+[a-z0-9._~+/=-]{8,}\b".to_string(), r"\b\d{3}-\d{2}-\d{4}\b".to_string(), @@ -107,6 +167,10 @@ impl Guardrails { self.policy.audit_enabled } + pub fn one_password_policy(&self) -> &OnePasswordPolicy { + &self.policy.one_password + } + pub fn authorize(&self, method: RpcMethod, params: &MethodParams) -> Result<(), RpcError> { if method == RpcMethod::BrowserDeveloper && !self.policy.developer_enabled { return Err(RpcError::new( @@ -115,6 +179,13 @@ impl Guardrails { ) .with_recovery("Enable Developer mode in AgentTab's managed local policy.")); } + if method == RpcMethod::BrowserCredentials && !self.policy.one_password.enabled { + return Err(RpcError::new( + "credential_provider_disabled", + "1Password credential filling is disabled by AgentTab policy", + ) + .with_recovery("Enable one_password in AgentTab's managed local policy.")); + } match params { MethodParams::Open(BrowserOpenParams::Create { url: Some(url), .. }) => { diff --git a/host-rs/crates/agenttab-host/src/journal.rs b/host-rs/crates/agenttab-host/src/journal.rs index 7de400c..44aacbb 100644 --- a/host-rs/crates/agenttab-host/src/journal.rs +++ b/host-rs/crates/agenttab-host/src/journal.rs @@ -241,9 +241,7 @@ impl Journal { FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS idx_staged_commits_native_token - ON staged_commits(native_token); - CREATE UNIQUE INDEX IF NOT EXISTS idx_staged_commits_review_handle - ON staged_commits(review_handle_hash);", + ON staged_commits(native_token);", )?; ensure_column( &connection, @@ -1461,6 +1459,44 @@ mod tests { } } + #[test] + fn opens_legacy_staged_commit_schema_before_creating_new_indexes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + Connection::open(&path) + .unwrap() + .execute_batch( + "CREATE TABLE staged_commits ( + token_hash BLOB PRIMARY KEY, + task_id TEXT NOT NULL, + native_token TEXT NOT NULL, + tab_id INTEGER NOT NULL, + page_revision INTEGER NOT NULL, + effect TEXT NOT NULL, + fingerprint TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)) + );", + ) + .unwrap(); + + let journal = Journal::open(&path).unwrap(); + let task = journal.create_task(None).unwrap(); + journal + .reconcile_inventory(&[owned_tab(task.task_id, 1)]) + .unwrap(); + let staged = NativeStagedCommit { + native_token: "legacy-schema-native-token".into(), + task_id: task.task_id, + tab_id: 7, + page_revision: 1, + effect: "legacy schema migration".into(), + fingerprint: "a".repeat(64), + expires_at_ms: now_ms() + 60_000, + }; + journal.store_staged_commit(&staged, &[]).unwrap(); + } + #[test] fn resume_capability_rotation_allows_one_pending_successor_and_rolls_back_failed_delivery() { let temp = tempfile::tempdir().unwrap(); diff --git a/host-rs/crates/agenttab-host/src/lib.rs b/host-rs/crates/agenttab-host/src/lib.rs index dfda362..8c034cb 100644 --- a/host-rs/crates/agenttab-host/src/lib.rs +++ b/host-rs/crates/agenttab-host/src/lib.rs @@ -1,4 +1,5 @@ pub mod audit; +pub mod credentials; pub mod guardrails; pub mod handoff; pub mod journal; diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs index a23470a..336adb1 100644 --- a/host-rs/crates/agenttab-host/src/runtime.rs +++ b/host-rs/crates/agenttab-host/src/runtime.rs @@ -1,4 +1,7 @@ use crate::audit::{canonicalize, now_ms, AuditEntry, AuditLog}; +use crate::credentials::{ + BrokerError, CredentialBroker, NeedsUserReason, PrepareResult, SelectResult, +}; use crate::guardrails::{GuardrailLoadError, Guardrails}; use crate::handoff::HandoffState; use crate::journal::{ @@ -10,11 +13,12 @@ use crate::native::{NativeError, NativeEventResult, NativeEventSink, NativeTrans use crate::paths::AgentTabPaths; use crate::task::ConnectionContext; use agenttab_protocol::{ - BrowserAction, BrowserCommitParams, BrowserHandoffParams, BrowserSnapshotParams, - BrowserWaitParams, ConnectionAck, ConnectionInit, MethodParams, NativeEventPayload, - NativeHandoff, NativePopupCommitEvent, NativeResponse, NativeStagedCommit, NativeTab, Outcome, - ResumeCapabilityConfirm, ResumeCapabilityConfirmed, RpcError, RpcMethod, RpcRequest, - RpcResponse, TaskBinding, WaitCondition, HOST_TO_CLIENT_MAX_BYTES, PROTOCOL_VERSION, + BrowserAction, BrowserCommitParams, BrowserCredentialsParams, BrowserHandoffParams, + BrowserSnapshotParams, BrowserWaitParams, ConnectionAck, ConnectionInit, MethodParams, + NativeEventPayload, NativeHandoff, NativePopupCommitEvent, NativeResponse, NativeStagedCommit, + NativeTab, Outcome, ResumeCapabilityConfirm, ResumeCapabilityConfirmed, RpcError, RpcMethod, + RpcRequest, RpcResponse, TaskBinding, WaitCondition, HOST_TO_CLIENT_MAX_BYTES, + PROTOCOL_VERSION, }; use parking_lot::{Mutex, RwLock}; use serde_json::{json, Value}; @@ -24,6 +28,7 @@ use std::path::PathBuf; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use thiserror::Error; +use url::Url; use uuid::Uuid; const RESPONSE_TASK_BINDING_RESERVE: usize = 512; const CLOSE_TASK_TIMEOUT: Duration = Duration::from_secs(5); @@ -163,6 +168,7 @@ pub struct Runtime { audit: Arc, native: Arc, handoff: Arc, + credentials: Arc, task_locks: Mutex>>>, global_gate: RwLock<()>, tab_urls: Arc>>, @@ -202,12 +208,16 @@ impl Runtime { &paths.audit_log, guardrails.audit_enabled(), )?); + let credentials = Arc::new(CredentialBroker::new( + guardrails.one_password_policy().clone(), + )); let runtime = Arc::new(Self { lifecycle, journal, guardrails, audit, native, + credentials, handoff, task_locks: Mutex::new(HashMap::new()), global_gate: RwLock::new(()), @@ -1098,6 +1108,9 @@ impl Runtime { return RpcResponse::failure(request_id, Outcome::NotStarted, error); } } + if let MethodParams::Credentials(credentials) = params { + return self.dispatch_credentials(connection_id, task_id, request_id, credentials); + } let mut committed_uploads = Vec::new(); let mut committed_native_token = None; @@ -1349,6 +1362,201 @@ impl Runtime { } } + fn dispatch_credentials( + &self, + connection_id: Uuid, + task_id: Uuid, + request_id: &str, + params: &BrowserCredentialsParams, + ) -> RpcResponse { + let (tab_id, expected_page_revision) = match params { + BrowserCredentialsParams::Prepare { + tab_id, + expected_page_revision, + } + | BrowserCredentialsParams::Fill { + tab_id, + expected_page_revision, + .. + } + | BrowserCredentialsParams::Next { + tab_id, + expected_page_revision, + .. + } => (*tab_id, *expected_page_revision), + }; + let host = match self.credential_host(tab_id) { + Ok(host) => host, + Err(error) => { + return RpcResponse::failure(request_id, Outcome::NotStarted, error); + } + }; + if matches!(params, BrowserCredentialsParams::Prepare { .. }) { + return match self.credentials.prepare(task_id, tab_id, &host) { + PrepareResult::Ready { + credential_token, + candidate_count, + expires_at_ms, + } => RpcResponse::success( + request_id, + Outcome::Completed, + json!({ + "status": "ready", + "credential_token": credential_token, + "candidate_count": candidate_count, + "expires_at_ms": expires_at_ms, + }), + ), + PrepareResult::NeedsUser { + reason, + candidate_count, + } => credential_needs_user(request_id, reason, Some(candidate_count)), + }; + } + + let (credential_token, username_ref, password_ref, otp_ref, next) = match params { + BrowserCredentialsParams::Fill { + credential_token, + username_ref, + password_ref, + otp_ref, + .. + } => (credential_token, username_ref, password_ref, otp_ref, false), + BrowserCredentialsParams::Next { + credential_token, + username_ref, + password_ref, + otp_ref, + .. + } => (credential_token, username_ref, password_ref, otp_ref, true), + BrowserCredentialsParams::Prepare { .. } => unreachable!(), + }; + let selection = match self.credentials.select( + credential_token, + task_id, + tab_id, + &host, + next, + otp_ref.is_some(), + ) { + Ok(selection) => selection, + Err(error) => { + return RpcResponse::failure( + request_id, + Outcome::NotStarted, + credential_broker_error(error), + ); + } + }; + let SelectResult::Ready { + mut material, + attempt_number, + remaining_attempts, + } = selection + else { + let SelectResult::NeedsUser { reason } = selection else { + unreachable!() + }; + return credential_needs_user(request_id, reason, None); + }; + if (username_ref.is_some() && material.username.is_none()) + || (password_ref.is_some() && material.password.is_none()) + || (otp_ref.is_some() && material.otp.is_none()) + { + return credential_needs_user(request_id, NeedsUserReason::UnsupportedCredential, None); + } + let mut fields = Vec::new(); + if let (Some(r#ref), Some(value)) = (username_ref, material.username.take()) { + fields.push(json!({"kind": "username", "ref": r#ref, "value": value})); + } + if let (Some(r#ref), Some(value)) = (password_ref, material.password.take()) { + fields.push(json!({"kind": "password", "ref": r#ref, "value": value})); + } + if let (Some(r#ref), Some(value)) = (otp_ref, material.otp.take()) { + fields.push(json!({"kind": "otp", "ref": r#ref, "value": value})); + } + let native_params = json!({ + "tab_id": tab_id, + "expected_page_revision": expected_page_revision, + "fields": fields, + }); + let origin_policy = self.guardrails.native_origin_policy(tab_id); + let native = match self.native.dispatch( + connection_id, + task_id, + "browser_credentials_fill", + native_params, + origin_policy, + Duration::from_secs(30), + ) { + Ok(native) => native, + Err(error) => return native_failure(request_id, error), + }; + match (native.outcome, native.result, native.error) { + (Outcome::Completed, Some(result), None) => { + let page_revision = result + .get("page_revision") + .and_then(Value::as_u64) + .unwrap_or(expected_page_revision); + RpcResponse::success( + request_id, + Outcome::Completed, + json!({ + "status": "filled", + "attempt_number": attempt_number, + "remaining_attempts": remaining_attempts, + "filled_username": username_ref.is_some(), + "filled_password": password_ref.is_some(), + "filled_otp": otp_ref.is_some(), + "page_revision": page_revision, + }), + ) + } + (outcome, None, Some(error)) => { + RpcResponse::failure(request_id, outcome, self.guardrails.redact_error(error)) + } + _ => RpcResponse::failure( + request_id, + Outcome::Unknown, + RpcError::new( + "invalid_native_response", + "Credential filling did not return exactly one result branch", + ), + ), + } + } + + fn credential_host(&self, tab_id: u64) -> Result { + let tab_url = self.tab_urls.read().get(&tab_id).cloned().ok_or_else(|| { + RpcError::new( + "tab_origin_unverified", + "AgentTab has no verified current origin for this tab", + ) + })?; + let url = Url::parse(&tab_url).map_err(|_| { + RpcError::new( + "credential_origin_unsupported", + "Credentials can only be filled on a verified HTTP or HTTPS origin", + ) + })?; + let host = url.host_str().ok_or_else(|| { + RpcError::new( + "credential_origin_unsupported", + "Credentials can only be filled on a URL with a hostname", + ) + })?; + let secure = url.scheme() == "https" + || (url.scheme() == "http" && matches!(host, "localhost" | "127.0.0.1" | "::1")); + if !secure { + return Err(RpcError::new( + "credential_origin_insecure", + "AgentTab will not fill credentials on an insecure remote origin", + ) + .with_recovery("Use HTTPS or sign in manually after verifying the site.")); + } + Ok(host.trim_end_matches('.').to_ascii_lowercase()) + } + fn attach_task_binding( &self, connection: &ConnectionContext, @@ -1371,6 +1579,42 @@ impl Runtime { } } +fn credential_needs_user( + request_id: &str, + reason: NeedsUserReason, + candidate_count: Option, +) -> RpcResponse { + let mut result = json!({ + "status": reason.status(), + "recovery": reason.recovery(), + }); + if let Some(candidate_count) = candidate_count { + result + .as_object_mut() + .expect("credential response is an object") + .insert("candidate_count".into(), json!(candidate_count)); + } + RpcResponse::success(request_id, Outcome::NeedsUser, result) +} + +fn credential_broker_error(error: BrokerError) -> RpcError { + let (code, recovery) = match error { + BrokerError::InvalidToken => ( + "credential_token_invalid", + "Prepare credentials again to obtain a fresh bound token.", + ), + BrokerError::BindingMismatch => ( + "credential_binding_mismatch", + "Prepare credentials again on the current task, tab, and origin.", + ), + BrokerError::NextBeforeFill => ( + "credential_next_before_fill", + "Fill the current candidate before requesting the next one.", + ), + }; + RpcError::new(code, error.to_string()).with_recovery(recovery) +} + fn requested_tab(params: &MethodParams) -> Option<(u64, Option)> { match params { MethodParams::Snapshot( @@ -1382,6 +1626,22 @@ 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::Credentials( + BrowserCredentialsParams::Prepare { + tab_id, + expected_page_revision, + } + | BrowserCredentialsParams::Fill { + tab_id, + expected_page_revision, + .. + } + | BrowserCredentialsParams::Next { + tab_id, + expected_page_revision, + .. + }, + ) => Some((*tab_id, Some(*expected_page_revision))), MethodParams::Open(_) | MethodParams::Tabs(_) | MethodParams::Commit(_) @@ -1552,7 +1812,10 @@ pub(crate) enum RequestLockScope { 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::BrowserCredentials ) { if let Some(tab_id) = params.get("tab_id").and_then(Value::as_u64) { return RequestLockScope::Tab(tab_id); diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index 3053f06..1b3124f 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -175,6 +175,7 @@ pub enum RpcMethod { BrowserTabs, BrowserHandoff, BrowserCommit, + BrowserCredentials, #[serde(rename = "agenttab.status")] AgenttabStatus, #[serde(rename = "agenttab.close")] @@ -190,6 +191,7 @@ impl RpcMethod { | Self::BrowserAct | Self::BrowserHandoff | Self::BrowserCommit + | Self::BrowserCredentials | Self::BrowserDeveloper ) } @@ -285,6 +287,7 @@ pub enum MethodParams { Wait(BrowserWaitParams), Tabs(BrowserTabsParams), Handoff(BrowserHandoffParams), + Credentials(BrowserCredentialsParams), Commit(BrowserCommitParams), Status(BrowserTabsParams), Close(BrowserTabsParams), @@ -300,6 +303,7 @@ impl MethodParams { RpcMethod::BrowserWait => Self::Wait(decode_params(method, value)?), RpcMethod::BrowserTabs => Self::Tabs(decode_params(method, value)?), RpcMethod::BrowserHandoff => Self::Handoff(decode_params(method, value)?), + RpcMethod::BrowserCredentials => Self::Credentials(decode_params(method, value)?), RpcMethod::BrowserCommit => Self::Commit(decode_params(method, value)?), RpcMethod::AgenttabStatus => Self::Status(decode_params(method, value)?), RpcMethod::AgenttabClose => Self::Close(decode_params(method, value)?), @@ -317,6 +321,7 @@ impl MethodParams { Self::Wait(value) => serde_json::to_value(value), Self::Tabs(value) => serde_json::to_value(value), Self::Handoff(value) => serde_json::to_value(value), + Self::Credentials(value) => serde_json::to_value(value), Self::Commit(value) => serde_json::to_value(value), Self::Status(value) => serde_json::to_value(value), Self::Close(value) => serde_json::to_value(value), @@ -539,6 +544,37 @@ pub enum HandoffCompletion { Selector { value: String }, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserCredentialsParams { + Prepare { + tab_id: u64, + expected_page_revision: u64, + }, + Fill { + tab_id: u64, + expected_page_revision: u64, + credential_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + username_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + password_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + otp_ref: Option, + }, + Next { + tab_id: u64, + expected_page_revision: u64, + credential_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + username_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + password_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + otp_ref: Option, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserCommitParams { @@ -730,6 +766,45 @@ impl MethodParams { HandoffCompletion::Navigation | HandoffCompletion::ManualDone => {} } } + Self::Credentials(BrowserCredentialsParams::Prepare { .. }) => {} + Self::Credentials( + BrowserCredentialsParams::Fill { + credential_token, + username_ref, + password_ref, + otp_ref, + .. + } + | BrowserCredentialsParams::Next { + credential_token, + username_ref, + password_ref, + otp_ref, + .. + }, + ) => { + require_len( + method, + credential_token, + 32, + MAX_STAGED_TOKEN_CHARS, + "credential_token", + )?; + require( + method, + username_ref.is_some() || password_ref.is_some() || otp_ref.is_some(), + "at least one credential field ref is required", + )?; + for (name, value) in [ + ("username_ref", username_ref), + ("password_ref", password_ref), + ("otp_ref", otp_ref), + ] { + if let Some(value) = value { + require_len(method, value, 1, MAX_REF_CHARS, name)?; + } + } + } Self::Commit(params) => { require_len( method, diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 5620397..d0c22d5 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -361,7 +361,7 @@ async function dispatch(command: NativeDispatchCommand): Promise } } const params = command.params; - const mutating = command.method === "browser_open" || command.method === "browser_act" || command.method === "browser_commit" || command.method === "browser_handoff" || command.method === "browser_developer"; + const mutating = command.method === "browser_open" || command.method === "browser_act" || command.method === "browser_commit" || command.method === "browser_handoff" || command.method === "browser_developer" || command.method === "browser_credentials_fill"; try { if (command.method === "commit_review_bind") { return completed(command.request_id, await browser.bindReview(command.task_id, params)); @@ -441,6 +441,19 @@ async function dispatch(command: NativeDispatchCommand): Promise }); return completed(command.request_id, result); } + if (command.method === "browser_credentials_fill") { + const targetTabId = tabId(params); + const result = await scheduler.enqueueTab(command.task_id, targetTabId, async () => { + await ownership.assertOwned(command.task_id, targetTabId); + await assertCurrentOrigin(targetTabId, command.origin_policy); + return browser.fillCredentials( + targetTabId, + params.expected_page_revision, + params.fields, + ); + }); + return completed(command.request_id, result); + } const targetTabId = tabId(params); if (command.method === "browser_snapshot" || command.method === "browser_wait") { const result = await scheduler.readAfterWrites(targetTabId, async () => { diff --git a/packages/extension/src/browser.ts b/packages/extension/src/browser.ts index cfeb4af..cf55a26 100644 --- a/packages/extension/src/browser.ts +++ b/packages/extension/src/browser.ts @@ -172,16 +172,8 @@ export class StandardBrowserRuntime { ) { chrome.debugger.onDetach.addListener((source: { tabId?: number }) => { if (source.tabId === undefined) return; - const expected = this.expectedDetaches.get(source.tabId) ?? 0; - if (expected > 0) { - if (expected === 1) this.expectedDetaches.delete(source.tabId); - else this.expectedDetaches.set(source.tabId, expected - 1); - return; - } - const session = this.sessions.get(source.tabId); - if (session?.idleTimer) clearTimeout(session.idleTimer); - this.sessions.delete(source.tabId); - void this.invalidateStagedDialogs(source.tabId); + if (this.consumeExpectedDetach(source.tabId)) return; + this.invalidateDetachedSession(source.tabId); }); chrome.debugger.onEvent.addListener( (source, method: string, rawParams?: object) => { @@ -345,10 +337,18 @@ export class StandardBrowserRuntime { this.debuggerCandidates.delete(tabId); } - private consumeExpectedDetach(tabId: number): void { + private consumeExpectedDetach(tabId: number): boolean { const pendingExpected = this.expectedDetaches.get(tabId) ?? 0; - if (pendingExpected <= 1) this.expectedDetaches.delete(tabId); + if (pendingExpected === 0) return false; + if (pendingExpected === 1) this.expectedDetaches.delete(tabId); else this.expectedDetaches.set(tabId, pendingExpected - 1); + return true; + } + + private invalidateDetachedSession(tabId: number, session = this.sessions.get(tabId)): void { + if (session?.idleTimer) clearTimeout(session.idleTimer); + if (session && this.sessions.get(tabId) === session) this.sessions.delete(tabId); + void this.invalidateStagedDialogs(tabId); } private async requireFullAutomationRoute(tabId: number, operation: string): Promise { @@ -512,6 +512,64 @@ export class StandardBrowserRuntime { }; } + async fillCredentials( + tabId: number, + expectedRevision: unknown, + fields: unknown, + ): Promise> { + await this.authorizeDebuggerUse(tabId); + const pageRevision = await this.revisions.assertExpected(tabId, expectedRevision); + await this.requireFullAutomationRoute(tabId, "fill credentials"); + if (!Array.isArray(fields) || fields.length === 0 || fields.length > 3) { + throw Object.assign(new Error("Credential fields must contain between 1 and 3 entries"), { + code: "invalid_request", + }); + } + for (const field of fields) { + if ( + !isRecord(field) || + typeof field.kind !== "string" || + typeof field.ref !== "string" || + typeof field.value !== "string" + ) { + throw Object.assign(new Error("Credential field is malformed"), { code: "invalid_request" }); + } + await this.revisions.assertExpected(tabId, pageRevision); + const backendNodeId = this.backendNodeId(pageRevision, field.ref); + await this.callOnNode( + tabId, + backendNodeId, + `function(value,kind){ + const input=this; + const isInput=input instanceof HTMLInputElement; + const isTextarea=input instanceof HTMLTextAreaElement; + if(!isInput&&!isTextarea)throw new Error("Credential target is not an input"); + const type=(isInput?input.type:"text").toLowerCase(); + const autocomplete=(input.getAttribute("autocomplete")||"").toLowerCase(); + const valid=kind==="password" + ? isInput&&(type==="password"||autocomplete.includes("password")) + : kind==="otp" + ? isInput&&(autocomplete==="one-time-code"||["text","tel","number"].includes(type)) + : ["text","email","tel"].includes(type)||isTextarea; + if(!valid)throw new Error("Credential target kind does not match the requested field"); + input.focus(); + const prototype=isInput?HTMLInputElement.prototype:HTMLTextAreaElement.prototype; + const setter=Object.getOwnPropertyDescriptor(prototype,"value")&&Object.getOwnPropertyDescriptor(prototype,"value").set; + if(!setter)throw new Error("Credential target has no value setter"); + setter.call(input,value); + input.dispatchEvent(new Event("input",{bubbles:true,composed:true})); + input.dispatchEvent(new Event("change",{bubbles:true,composed:true})); + }`, + [{ value: field.value }, { value: field.kind }], + ); + } + return { + tab_id: tabId, + page_revision: await this.revisions.current(tabId), + filled_fields: fields.length, + }; + } + async bindReview(taskId: string, params: Record): Promise> { const nativeToken = params.native_token; const reviewHandle = params.review_handle; @@ -1630,14 +1688,31 @@ export class StandardBrowserRuntime { }, DEBUGGER_IDLE_MS); } - private async send(tabId: number, method: string, params: Record): Promise> { + private async send( + tabId: number, + method: string, + params: Record, + recovered = false, + ): Promise> { const session = await this.acquireDebuggerBusyLease(tabId); + let detached = false; try { await this.authorizeDebuggerUse(tabId); const result: unknown = await chrome.debugger.sendCommand({ tabId }, method, params); return isRecord(result) ? result : {}; + } catch (error) { + if ( + recovered || + !(error instanceof Error && /debugger is not attached to the tab/i.test(error.message)) + ) { + throw error; + } + detached = true; + this.invalidateDetachedSession(tabId, session); } finally { this.releaseDebuggerBusyLease(tabId, session); } + if (detached) return this.send(tabId, method, params, true); + throw new Error("unreachable debugger recovery state"); } } diff --git a/packages/extension/src/protocol.ts b/packages/extension/src/protocol.ts index b1235c4..a8c390f 100644 --- a/packages/extension/src/protocol.ts +++ b/packages/extension/src/protocol.ts @@ -16,6 +16,7 @@ export type NativeMethod = | "browser_commit" | "browser_developer" | "agenttab.close" + | "browser_credentials_fill" | "commit_review_bind" | "commit_review_abandon"; @@ -29,6 +30,7 @@ const CORE_METHODS: Record = { browser_commit: true, browser_developer: true, "agenttab.close": true, + browser_credentials_fill: true, commit_review_bind: true, commit_review_abandon: true, }; @@ -462,6 +464,37 @@ function assertCommitReviewParams( return params; } +function assertCredentialFillParams(value: unknown): Record { + const params = assertExactObject( + value, + ["tab_id", "expected_page_revision", "fields"], + [], + "browser_credentials_fill parameters", + ); + assertTabId(params.tab_id); + assertRevision(params.expected_page_revision); + if (!Array.isArray(params.fields) || params.fields.length === 0 || params.fields.length > 3) { + commandError("credential fields must contain between 1 and 3 entries"); + } + const refs = new Set(); + const kinds = new Set(); + for (const field of params.fields) { + const parsed = assertExactObject(field, ["kind", "ref", "value"], [], "credential field"); + const kind = assertBoundedString(parsed.kind, "credential field kind", 1, 8); + if (kind !== "username" && kind !== "password" && kind !== "otp") { + commandError("credential field kind must be username, password, or otp"); + } + const ref = assertBoundedString(parsed.ref, "credential field ref", 1, 256); + assertBoundedString(parsed.value, "credential field value", 0, 65_536); + if (refs.has(ref) || kinds.has(kind)) { + commandError("credential field refs and kinds must be unique"); + } + refs.add(ref); + kinds.add(kind); + } + return params; +} + function validateParams(method: NativeMethod, value: unknown): Record { switch (method) { case "browser_open": { @@ -511,6 +544,8 @@ function validateParams(method: NativeMethod, value: unknown): Record { expect(forgotten).toEqual([65]); }); + test("reattaches once when Chrome reports a stale debugger session", async () => { + let detachedFailure = true; + debuggerCommandOverride = (method) => { + if (method === "DOM.getDocument" && detachedFailure) { + detachedFailure = false; + debuggerAttachedTabIds.delete(65); + throw new Error("Debugger is not attached to the tab with id: 65"); + } + return undefined; + }; + const runtime = new StandardBrowserRuntime( + new RevisionTracker(), + async () => undefined, + () => undefined, + async () => undefined, + ); + + await expect(runtime.snapshot(65, { mode: "accessibility" })).resolves.toMatchObject({ + tab_id: 65, + mode: "accessibility", + }); + expect(debuggerCalls.filter((call) => call === "attach")).toHaveLength(2); + + await runtime.detach(65); + }); + test("detaches a restored debugger candidate during ownership revocation", async () => { debuggerAttachedTabIds.add(66); const forgotten: number[] = []; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7f338c3..5aa59ca 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "agenttab-mcp", "version": "2.0.0-rc.1", - "description": "Seven-tool MCP adapter for AgentTab", + "description": "Eight-tool MCP adapter for AgentTab", "type": "module", "bin": { "agenttab-mcp": "dist/server.mjs" diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 6905d94..c86f575 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -16,6 +16,7 @@ import actSchema from "../../../schemas/rpc/v1/browser-act.schema.json" with { t import waitSchema from "../../../schemas/rpc/v1/browser-wait.schema.json" with { type: "json" }; import tabsSchema from "../../../schemas/rpc/v1/browser-tabs.schema.json" with { type: "json" }; import handoffSchema from "../../../schemas/rpc/v1/browser-handoff.schema.json" with { type: "json" }; +import credentialsSchema from "../../../schemas/rpc/v1/browser-credentials.schema.json" with { type: "json" }; import commitSchema from "../../../schemas/rpc/v1/browser-commit.schema.json" with { type: "json" }; import developerSchema from "../../../schemas/rpc/v1/browser-developer.schema.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" }; @@ -179,6 +180,11 @@ export const STANDARD_TOOLS: readonly Tool[] = [ description: "Pause all agent actions and give the user control for credentials, MFA, CAPTCHA, or other human-only input.", inputSchema: schema(handoffSchema), }, + { + name: "browser_credentials", + description: "Use an origin-matching 1Password Login without exposing values; more than three matches require the user.", + inputSchema: schema(credentialsSchema), + }, { name: "browser_commit", description: "Execute one previously staged consequential action after semantic review.", diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index e9f0dbf..73426cb 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -19,7 +19,7 @@ import { } from "../src/server"; describe("AgentTab MCP surface", () => { - test("Standard mode exposes exactly seven Core RPC tools", () => { + test("Standard mode exposes exactly eight Core RPC tools", () => { expect(STANDARD_TOOLS.map((tool) => tool.name)).toEqual([ "browser_open", "browser_snapshot", @@ -27,9 +27,10 @@ describe("AgentTab MCP surface", () => { "browser_wait", "browser_tabs", "browser_handoff", + "browser_credentials", "browser_commit", ]); - expect(listedTools(false)).toHaveLength(7); + expect(listedTools(false)).toHaveLength(8); expect(listedTools(false).some((tool) => tool.name === DEVELOPER_TOOL.name)).toBe(false); }); @@ -48,6 +49,11 @@ describe("AgentTab MCP surface", () => { expect(schema).toContain('"maximum":1000000'); }); + test("browser_credentials fills only and cannot bypass Browser Act submission review", () => { + const credentialTool = STANDARD_TOOLS.find((tool) => tool.name === "browser_credentials")!; + expect(JSON.stringify(credentialTool.inputSchema)).not.toContain("submit_ref"); + }); + test("browser_act advertises no press action", () => { const actionTool = STANDARD_TOOLS.find((tool) => tool.name === "browser_act")!; expect(actionTool.inputSchema).not.toHaveProperty("$defs.press"); diff --git a/packages/omp/src/index.ts b/packages/omp/src/index.ts index e62cd2c..0bfefe6 100644 --- a/packages/omp/src/index.ts +++ b/packages/omp/src/index.ts @@ -244,6 +244,28 @@ const DEFINITIONS: ReadonlyArray<{ timeout_ms: z.number().int().min(1000).max(900_000).optional(), }).strict(), }, + { + name: "browser_credentials", + label: "Browser Credentials", + description: "Use a URL-matching 1Password Login without exposing its values. Prepare first; more than three matches require the user. Fill only, then submit through Browser Act.", + approval: "write", + schema: (z) => z.union([ + z.object({ + action: z.literal("prepare"), + tab_id: z.number().int().min(0), + expected_page_revision: z.number().int().min(0), + }).strict(), + z.object({ + action: z.enum(["fill", "next"]), + tab_id: z.number().int().min(0), + expected_page_revision: z.number().int().min(0), + credential_token: z.string().min(32).max(256), + username_ref: z.string().min(1).max(256).optional(), + password_ref: z.string().min(1).max(256).optional(), + otp_ref: z.string().min(1).max(256).optional(), + }).strict(), + ]), + }, { name: "browser_commit", label: "Browser Commit", diff --git a/packages/omp/src/pi-schema.ts b/packages/omp/src/pi-schema.ts index 2a5d5c3..b93efb4 100644 --- a/packages/omp/src/pi-schema.ts +++ b/packages/omp/src/pi-schema.ts @@ -104,6 +104,22 @@ const schemas: Record = { ]), timeout_ms: Type.Optional(Type.Integer({ minimum: 1000, maximum: 900_000 })), }), + browser_credentials: Type.Union([ + object({ + action: Type.Literal("prepare"), + tab_id: Type.Integer({ minimum: 0 }), + expected_page_revision: Type.Integer({ minimum: 0 }), + }), + object({ + action: stringEnum(["fill", "next"]), + tab_id: Type.Integer({ minimum: 0 }), + expected_page_revision: Type.Integer({ minimum: 0 }), + credential_token: Type.String({ minLength: 32, maxLength: 256 }), + username_ref: Type.Optional(ref()), + password_ref: Type.Optional(ref()), + otp_ref: Type.Optional(ref()), + }), + ]), browser_commit: object({ staged_token: Type.String({ minLength: 32, maxLength: 256 }) }), browser_developer: object({ action: Type.String({ minLength: 1, maxLength: 128 }), diff --git a/packages/omp/src/render.ts b/packages/omp/src/render.ts index f7e82c9..8e96cee 100644 --- a/packages/omp/src/render.ts +++ b/packages/omp/src/render.ts @@ -228,6 +228,17 @@ function describeCall(method: ToolMethod, args: Record): { titl meta: humanize(fieldString(completion, "kind") ?? "manual completion"), }; } + case "browser_credentials": { + const action = fieldString(args, "action") ?? "prepare"; + return { + title: action === "prepare" + ? "Prepare 1Password credentials" + : action === "next" + ? "Try next 1Password login" + : "Fill 1Password login", + meta: action === "prepare" ? "origin-bound" : "values stay hidden", + }; + } case "browser_commit": return { title: "Commit staged action", meta: "approved one-use token" }; case "browser_developer": @@ -338,6 +349,15 @@ function summarizeResult( : countSummary(Array.isArray(result.tabs) ? result.tabs : [], "task tab"); case "browser_handoff": return status === "awaiting_user" ? "Waiting for user" : "User handoff completed"; + case "browser_credentials": { + const credentialStatus = fieldString(result, "status"); + if (credentialStatus === "ready") { + const count = fieldNumber(result, "candidate_count") ?? 0; + return `${count} matching login${count === 1 ? "" : "s"} ready`; + } + if (credentialStatus === "filled") return "Credentials filled"; + return credentialStatus ? humanize(credentialStatus) : "Credential action completed"; + } case "browser_commit": return "Staged action executed"; case "browser_developer": @@ -354,7 +374,12 @@ function resultStatus( const outcome = fieldString(toRecord(details._agenttab), "outcome") ?? fieldString(details, "outcome"); if (outcome === "unknown") return "uncertain"; if (method === "browser_handoff" && outcome === "needs_user") return "awaiting_user"; - if (options.isPartial === true) return method === "browser_handoff" ? "awaiting_user" : "running"; + if (method === "browser_credentials" && outcome === "needs_user") return "awaiting_user"; + if (options.isPartial === true) { + return method === "browser_handoff" || method === "browser_credentials" + ? "awaiting_user" + : "running"; + } if (result.isError === true) return "blocked"; if (typeof details.staged_token === "string" || details.awaiting_human_approval === true) { return "awaiting_approval"; diff --git a/packages/omp/test/extension.test.ts b/packages/omp/test/extension.test.ts index 12c082f..dce638c 100644 --- a/packages/omp/test/extension.test.ts +++ b/packages/omp/test/extension.test.ts @@ -82,7 +82,7 @@ async function executeTool( return value as Record; } -test("Standard OMP mode registers exactly seven Core RPC tools", () => { +test("Standard OMP mode registers exactly eight Core RPC tools", () => { expect(register(false).tools.map((tool) => tool.name)).toEqual([ "browser_open", "browser_snapshot", @@ -90,6 +90,7 @@ test("Standard OMP mode registers exactly seven Core RPC tools", () => { "browser_wait", "browser_tabs", "browser_handoff", + "browser_credentials", "browser_commit", ]); }); @@ -117,6 +118,7 @@ test("developer mode adds only browser_developer", () => { "browser_wait", "browser_tabs", "browser_handoff", + "browser_credentials", "browser_commit", "browser_developer", ]); @@ -682,6 +684,7 @@ test("Pi mode registers the same tools with TypeBox schemas and Pi metadata", () "browser_wait", "browser_tabs", "browser_handoff", + "browser_credentials", "browser_commit", ]); for (const tool of tools) { diff --git a/packages/sdk-python/agenttab/client.py b/packages/sdk-python/agenttab/client.py index cf7efc0..6462ca0 100644 --- a/packages/sdk-python/agenttab/client.py +++ b/packages/sdk-python/agenttab/client.py @@ -29,6 +29,7 @@ "browser_open", "browser_act", "browser_handoff", + "browser_credentials", "browser_commit", "browser_developer", } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index a828c55..44e0493 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -81,6 +81,22 @@ export interface BrowserHandoffParams { timeout_ms?: number; } +export type BrowserCredentialsParams = + | { + action: "prepare"; + tab_id: number; + expected_page_revision: number; + } + | { + action: "fill" | "next"; + tab_id: number; + expected_page_revision: number; + credential_token: string; + username_ref?: string; + password_ref?: string; + otp_ref?: string; + }; + export interface BrowserCommitParams { staged_token: string; } @@ -97,6 +113,7 @@ export interface MethodParams { browser_wait: BrowserWaitParams; browser_tabs: Record; browser_handoff: BrowserHandoffParams; + browser_credentials: BrowserCredentialsParams; browser_commit: BrowserCommitParams; browser_developer: BrowserDeveloperParams; "agenttab.status": Record; @@ -108,6 +125,7 @@ export type MutationMethod = | "browser_open" | "browser_act" | "browser_handoff" + | "browser_credentials" | "browser_commit" | "browser_developer"; @@ -400,6 +418,7 @@ const MUTATIONS = new Set([ "browser_open", "browser_act", "browser_handoff", + "browser_credentials", "browser_commit", "browser_developer", ]); diff --git a/schemas/rpc/v1/browser-credentials.schema.json b/schemas/rpc/v1/browser-credentials.schema.json new file mode 100644 index 0000000..ef644dc --- /dev/null +++ b/schemas/rpc/v1/browser-credentials.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agenttab.dev/schemas/rpc/v1/browser-credentials.schema.json", + "title": "browser_credentials parameters", + "$defs": { + "ref": { "type": "string", "minLength": 1, "maxLength": 256 }, + "target": { + "type": "object", + "required": ["action", "tab_id", "expected_page_revision", "credential_token"], + "properties": { + "action": { "enum": ["fill", "next"] }, + "tab_id": { "type": "integer", "minimum": 0 }, + "expected_page_revision": { "type": "integer", "minimum": 0 }, + "credential_token": { "type": "string", "minLength": 32, "maxLength": 256 }, + "username_ref": { "$ref": "#/$defs/ref" }, + "password_ref": { "$ref": "#/$defs/ref" }, + "otp_ref": { "$ref": "#/$defs/ref" } + }, + "anyOf": [ + { "required": ["username_ref"] }, + { "required": ["password_ref"] }, + { "required": ["otp_ref"] } + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "required": ["action", "tab_id", "expected_page_revision"], + "properties": { + "action": { "const": "prepare" }, + "tab_id": { "type": "integer", "minimum": 0 }, + "expected_page_revision": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + { "$ref": "#/$defs/target" } + ] +} From ecc31bd5ec50475765fdb4adb65f6b93bc6aa800 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:04:46 -0700 Subject: [PATCH 2/8] Align credential transport deadlines --- packages/sdk-python/agenttab/__init__.py | 2 ++ packages/sdk-python/agenttab/client.py | 3 +++ packages/sdk-python/tests/test_client.py | 8 ++++++++ packages/sdk-typescript/src/index.ts | 3 +++ packages/sdk-typescript/test/client.test.ts | 5 +++++ 5 files changed, 21 insertions(+) diff --git a/packages/sdk-python/agenttab/__init__.py b/packages/sdk-python/agenttab/__init__.py index 3a40673..9caf4d5 100644 --- a/packages/sdk-python/agenttab/__init__.py +++ b/packages/sdk-python/agenttab/__init__.py @@ -3,6 +3,7 @@ AgentTabError, AgentTabTransportError, CLIENT_TO_HOST_MAX_BYTES, + DEFAULT_BROWSER_CREDENTIALS_TIMEOUT, DEFAULT_BROWSER_HANDOFF_TIMEOUT, DEFAULT_BROWSER_WAIT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, @@ -22,6 +23,7 @@ "AgentTabError", "AgentTabTransportError", "CLIENT_TO_HOST_MAX_BYTES", + "DEFAULT_BROWSER_CREDENTIALS_TIMEOUT", "DEFAULT_BROWSER_HANDOFF_TIMEOUT", "DEFAULT_BROWSER_WAIT_TIMEOUT", "DEFAULT_REQUEST_TIMEOUT", diff --git a/packages/sdk-python/agenttab/client.py b/packages/sdk-python/agenttab/client.py index 6462ca0..ac1cd5b 100644 --- a/packages/sdk-python/agenttab/client.py +++ b/packages/sdk-python/agenttab/client.py @@ -22,6 +22,7 @@ DEFAULT_REQUEST_TIMEOUT = 30.0 DEFAULT_BROWSER_WAIT_TIMEOUT = 30.0 DEFAULT_BROWSER_HANDOFF_TIMEOUT = 300.0 +DEFAULT_BROWSER_CREDENTIALS_TIMEOUT = 120.0 # Core reserves five seconds after a long operation's declared timeout. Keep a # second, bounded five-second margin for its response to cross the transports. LONG_OPERATION_TRANSPORT_GRACE = 10.0 @@ -46,6 +47,8 @@ def resolve_transport_timeout( default_timeout = DEFAULT_BROWSER_WAIT_TIMEOUT elif method == "browser_handoff": default_timeout = DEFAULT_BROWSER_HANDOFF_TIMEOUT + elif method == "browser_credentials": + default_timeout = DEFAULT_BROWSER_CREDENTIALS_TIMEOUT else: return request_timeout requested_timeout_ms = params.get("timeout_ms") diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index c25c870..928add1 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -17,6 +17,7 @@ AgentTabClient, AgentTabError, AgentTabTransportError, + DEFAULT_BROWSER_CREDENTIALS_TIMEOUT, DEFAULT_BROWSER_HANDOFF_TIMEOUT, DEFAULT_BROWSER_WAIT_TIMEOUT, LONG_OPERATION_TRANSPORT_GRACE, @@ -106,6 +107,13 @@ def test_long_operation_transport_deadlines_follow_protocol_timeouts(self) -> No ), DEFAULT_BROWSER_HANDOFF_TIMEOUT + LONG_OPERATION_TRANSPORT_GRACE, ) + self.assertEqual( + resolve_transport_timeout( + "browser_credentials", + {"action": "prepare", "tab_id": 1, "expected_page_revision": 1}, + ), + DEFAULT_BROWSER_CREDENTIALS_TIMEOUT + LONG_OPERATION_TRANSPORT_GRACE, + ) self.assertEqual(resolve_transport_timeout("browser_tabs", {}, 45), 45) self.assertEqual( resolve_transport_timeout( diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 44e0493..370a274 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -15,6 +15,7 @@ export const STANDARD_ACTION_VALUE_MAX_CHARS = 2048; export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export const DEFAULT_BROWSER_WAIT_TIMEOUT_MS = 30_000; export const DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS = 300_000; +export const DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS = 120_000; // Core gives long-running extension operations five seconds to return after their // declared timeout. Keep a second, bounded five-second margin for the response to // cross the native and Core transports before the client classifies it as unknown. @@ -432,6 +433,8 @@ function longOperationTimeoutMs( defaultTimeoutMs = DEFAULT_BROWSER_WAIT_TIMEOUT_MS; } else if (method === "browser_handoff") { defaultTimeoutMs = DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS; + } else if (method === "browser_credentials") { + defaultTimeoutMs = DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS; } else { return undefined; } diff --git a/packages/sdk-typescript/test/client.test.ts b/packages/sdk-typescript/test/client.test.ts index 74e913e..bd09181 100644 --- a/packages/sdk-typescript/test/client.test.ts +++ b/packages/sdk-typescript/test/client.test.ts @@ -7,6 +7,7 @@ import { AgentTabClient, AgentTabError, AgentTabTransportError, + DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS, DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS, DEFAULT_BROWSER_WAIT_TIMEOUT_MS, FrameDecoder, @@ -111,6 +112,10 @@ describe("Core RPC transport deadlines", () => { completion: { kind: "manual_done" }, }, )).toBe(DEFAULT_BROWSER_HANDOFF_TIMEOUT_MS + LONG_OPERATION_TRANSPORT_GRACE_MS); + expect(resolveTransportTimeoutMs( + "browser_credentials", + { action: "prepare", tab_id: 1, expected_page_revision: 1 }, + )).toBe(DEFAULT_BROWSER_CREDENTIALS_TIMEOUT_MS + LONG_OPERATION_TRANSPORT_GRACE_MS); expect(resolveTransportTimeoutMs("browser_tabs", {}, 45_000)).toBe(45_000); expect(resolveTransportTimeoutMs( "browser_wait", From 746495a9f19694a68bf30f2869cc59f06677fd02 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:22:29 -0700 Subject: [PATCH 3/8] feat: finalize AgentTab task lifecycle --- CHANGELOG.md | 3 +- README.md | 4 +- docs/adr/0001-agenttab-runtime.md | 7 +- docs/commands.md | 2 +- docs/launch/chrome-web-store.md | 4 +- docs/launch/directory-listings.md | 2 +- docs/launch/reddit-posts.md | 2 +- docs/mcp.md | 5 +- docs/roadmap.md | 2 +- host-rs/crates/agenttab-host/src/native.rs | 86 +++++++++++- host-rs/crates/agenttab-host/src/runtime.rs | 135 ++++++++++++++++-- host-rs/crates/agenttab-protocol/src/lib.rs | 136 +++++++++++++++++++ packages/extension/src/background.ts | 45 ++++-- packages/extension/src/ownership.ts | 143 +++++++++++++++++++- packages/extension/src/popup.html | 11 ++ packages/extension/src/popup.ts | 28 +++- packages/extension/src/protocol.ts | 45 +++++- packages/extension/src/storage.ts | 21 ++- packages/extension/test/extension.test.ts | 116 ++++++++++++++++ packages/installer/src/cli.ts | 31 +++++ packages/mcp/src/server.ts | 14 +- packages/mcp/test/server.test.ts | 52 ++++++- packages/omp/src/index.ts | 94 ++++++++++++- packages/omp/src/pi-schema.ts | 7 + packages/omp/src/render.ts | 13 ++ packages/omp/src/tool-method.ts | 4 +- packages/omp/test/extension.test.ts | 140 ++++++++++++++++++- packages/sdk-python/agenttab/client.py | 30 +++- packages/sdk-python/tests/test_client.py | 44 ++++++ packages/sdk-typescript/src/index.ts | 29 ++++ packages/sdk-typescript/test/client.test.ts | 90 ++++++++++++ schemas/rpc/v1/agenttab-finish.schema.json | 16 +++ schemas/rpc/v1/request.schema.json | 5 + 33 files changed, 1316 insertions(+), 50 deletions(-) create mode 100644 schemas/rpc/v1/agenttab-finish.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 61fc90c..70d904a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog ## Unreleased -- Replaced the Chrome Bridge v1 runtime with the AgentTab 2.0 release candidate: a Rust production host over OS-native local IPC, eight task-scoped Standard methods, explicit resumable capabilities, a developer-only ninth method, TypeScript and Python SDKs, MCP and OMP adapters, a transactional installer, and a minimal extension. Consequential controls now use a two-party Commit flow: `browser_act` stages an exact effect, the popup approves the durable review record without executing it, and the requesting task must consume its private one-use token through `browser_commit`. +- Replaced the Chrome Bridge v1 runtime with the AgentTab 2.0 release candidate: a Rust production host over OS-native local IPC, nine task-scoped Standard methods, explicit resumable capabilities, a developer-only tenth method, TypeScript and Python SDKs, MCP and OMP adapters, a transactional installer, and a minimal extension. Consequential controls now use a two-party Commit flow: `browser_act` stages an exact effect, the popup approves the durable review record without executing it, and the requesting task must consume its private one-use token through `browser_commit`. +- Added explicit `browser_finish` lifecycle finalization across Core RPC, the extension, TypeScript and Python SDKs, CLI, MCP, OMP, and Pi. Automatic cleanup tracks tab provenance, closes task-created tabs, retains adopted tabs, ungroups retained tabs, and releases ownership; popup policy can require confirmation or retain all tabs, while active handoff, Commit review, and in-flight work defer cleanup without destroying resumability. - Fixed OMP adapter compatibility with providers that reject top-level union tool schemas. `browser_open` and `browser_snapshot` now expose provider-compatible object schemas while retaining strict runtime validation for their mode-specific parameters. - Added a focus-safe GPT-Control browser driver backed by AgentTab task ownership and durable resume capabilities. It creates background task tabs, fills and sends prompts through Standard actions and Commit review, uploads through a bounded file-input selector, captures the latest assistant message without activating Chrome, persists session state owner-only, and closes the underlying task explicitly. - Added a disabled-by-default, host-managed 1Password credential broker. `browser_credentials` derives the current origin from task-owned host state, accepts at most three matching Login items and attempts, and uses short-lived one-use tokens to fill username, password, or one-time-code refs without exposing values through Core RPC, MCP, OMP, Pi, responses, or audit output. More than three matches, provider failures, exhausted attempts, and unsupported verification return `needs_user`. The extension's private fill path blocks capture before injecting selected values and never submits the form. diff --git a/README.md b/README.md index ea32947..2b3a1dd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The command has no path, token, or shell-specific argument and is suitable for P 3. If managed policy enables 1Password and an ordinary sign-in page has at most three origin-matching Login items, the agent can request a short-lived opaque token and ask the host to fill named field refs. Credential values travel only from `op` to the host and extension, never through Core RPC or the adapter. Every other human-only input uses `browser_handoff`, which focuses that tab, pauses automation, and blocks browser observation until the declared completion condition or **I'm done**. 4. If AgentTab recognizes a send, publish, purchase, delete, upload, authorization, or permission-grant control, `browser_act` can return `commit_required`. The extension shows the staged effect in its popup. A human must approve it there before the agent can call `browser_commit` with the one-use staged token. 5. The task can list only its own tabs with `browser_tabs`. A separate client gets a separate task unless it proves its durable resume capability. +6. When browser work is complete, the agent calls `browser_finish`. Automatic cleanup closes tabs created by the task, preserves tabs adopted from the user's existing browser state, ungroups retained tabs, and releases task ownership. The popup setting can instead require confirmation or retain every tab. Chrome does not expose page scripting or debugger access on browser-restricted origins such as `chrome://`, `chrome-extension://`, `devtools://`, and the Chrome Web Store. AgentTab reports these task tabs with `automation_route: "tab_only"`. Explicit navigation, reload, close, load or URL waits, and human-only `browser_handoff` remain available. History movement is also available when managed origin constraints are absent; with constraints, AgentTab rejects it because Chrome does not expose the destination for authorization before navigation. Download waits require the `full` route because exact task-tab attribution comes from tab-scoped debugger events, not browser-global download state. Page snapshots, element actions, page-content waits, and raw Developer-mode CDP fail immediately with `browser_restricted_origin` and `outcome: "not_started"` before AgentTab attempts the blocked route. Use a focus-safe OS accessibility driver bound to the exact browser window when native UI work is required. @@ -41,7 +42,7 @@ Commit is a two-party, best-effort semantic barrier, not proof that a page has n ## Tool surface -Standard mode exposes exactly eight MCP tools: +Standard mode exposes exactly nine tools: | Tool | Purpose | |---|---| @@ -53,6 +54,7 @@ Standard mode exposes exactly eight MCP tools: | `browser_handoff` | Give the user control for human-only input. | | `browser_commit` | Execute one staged consequential action. | | `browser_credentials` | Prepare and fill an origin-matching 1Password login through opaque, short-lived host tokens when managed policy explicitly enables it. | +| `browser_finish` | Finish the task, apply its cleanup policy, return closed and retained tab receipts, and release ownership. | Developer mode adds one tool, `browser_developer`. It is absent from Standard discovery. It requires both the persistent Developer mode control in the AgentTab popup and `AGENTTAB_DEVELOPER=1` in the adapter environment. Treat it as an explicit expansion of the normal boundary. diff --git a/docs/adr/0001-agenttab-runtime.md b/docs/adr/0001-agenttab-runtime.md index 022cd07..7dd7759 100644 --- a/docs/adr/0001-agenttab-runtime.md +++ b/docs/adr/0001-agenttab-runtime.md @@ -80,7 +80,7 @@ TCP and bearer-token access are not Standard transport. They exist only behind t AgentTab Core RPC and the host-to-extension native protocol are separately versioned. They MUST NOT silently downgrade across an incompatible version. -MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not alternate hosts. The public Standard surface has exactly eight tools: +MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not alternate hosts. The public Standard surface has exactly nine tools: 1. `browser_open` 2. `browser_snapshot` @@ -90,10 +90,13 @@ MCP, OMP, CLI, TypeScript, and Python are adapters over Core RPC. They are not a 6. `browser_handoff` 7. `browser_commit` 8. `browser_credentials` +9. `browser_finish` `browser_credentials` is disabled by managed policy unless explicitly enabled. It MUST derive the page origin and task ownership in the host, enforce a candidate and attempt limit no greater than three, use one-use short-lived tokens, and keep credential values out of Core RPC, adapters, responses, and audit output. -`browser_developer` is the ninth tool and is absent unless Developer mode is enabled. +`browser_finish` applies the task's cleanup policy, closes task-created tabs unless retained, preserves adopted tabs by default, ungroups retained tabs, and releases task ownership. Active handoff, staged Commit review, or another in-flight task operation MUST defer finalization rather than destroy resumability. + +`browser_developer` is the tenth tool and is absent unless Developer mode is enabled. Core RPC schemas are normative. Unknown fields and methods fail closed. Every mutation requires a UUIDv7 `idempotency_key`. Existing-page mutations also require the authoritative `tab_id` and expected `page_revision`. `browser_commit` is bound by its staged record rather than caller-supplied tab or revision. diff --git a/docs/commands.md b/docs/commands.md index 130a1a9..d223454 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -82,7 +82,7 @@ The extension layer is a diagnostic label around the status check. It does not r agenttab mcp ``` -Starts the AgentTab MCP server over stdin and stdout. It accepts no CLI options. The packaged `agenttab-mcp` binary starts the same server and also accepts no CLI options. Both expose eight Standard tools by default. Use the configured `agenttab` wrapper after a local installation, or the `agenttab-mcp` package binary only after that package is published. +Starts the AgentTab MCP server over stdin and stdout. It accepts no CLI options. The packaged `agenttab-mcp` binary starts the same server and also accepts no CLI options. Both expose nine Standard tools by default. Use the configured `agenttab` wrapper after a local installation, or the `agenttab-mcp` package binary only after that package is published. The installer writes supported MCP client entries as an absolute local AgentTab wrapper plus `mcp`. For a manual configuration, use `agenttab mcp` only when that wrapper is on the client's `PATH`. The exact stdio configuration and protocol behavior are in [MCP](mcp.md). diff --git a/docs/launch/chrome-web-store.md b/docs/launch/chrome-web-store.md index 69d6b81..24fcc2b 100644 --- a/docs/launch/chrome-web-store.md +++ b/docs/launch/chrome-web-store.md @@ -28,7 +28,7 @@ An agent starts with a task workspace, not general access to every tab. AgentTab The runtime consists of one minimal MV3 extension, a local Rust host, and per-user operating-system-native IPC. The extension uses Chrome Native Messaging to reach the local host. Client adapters, including MCP, connect to the host through a user-owned Unix socket on macOS and Linux or a current-user named pipe on Windows. AgentTab has no cloud relay, remote browser session, telemetry service, or routine network control plane. -Standard MCP access exposes exactly eight tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. `browser_developer` is available only after a persistent, explicit Developer mode opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. +Standard MCP access exposes exactly nine tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, `browser_credentials`, and `browser_finish`. The finalization tool applies provenance-aware cleanup: task-created tabs close by default, adopted tabs are retained, retained tabs are ungrouped, and task ownership is released. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. `browser_developer` is available only after a persistent, explicit Developer mode opt-in. Standard mode does not expose raw cookie, storage, arbitrary script, CDP, or network APIs. ### Human controls @@ -68,7 +68,7 @@ These notes are for a controlled reviewer package only. They are not public inst 5. Demonstrate Your Turn with a harmless test page. Verify that observations from every task return `needs_user` during the handoff and that the agent resumes only after Done or the declared completion condition. 6. Demonstrate Commit with a controlled test control labelled as a send, upload, delete, authorization, or permission action. Verify that no side effect occurs before the human popup approves the staged action with the requesting agent's one-use token. Do not use a real message, purchase, upload, deletion, or authorization. 7. Demonstrate Pause and Resume, including that queued work does not start after Pause and that task status remains visible after recovery. -8. Verify that Standard discovery exposes exactly the eight Standard tools, that `browser_credentials` returns a disabled-policy result before any provider call, and that the Developer-only tool is absent until the reviewer explicitly enables Developer mode. +8. Verify that Standard discovery exposes exactly the nine Standard tools, that `browser_finish` retains an adopted tab while releasing its task ownership, that `browser_credentials` returns a disabled-policy result before any provider call, and that the Developer-only tool is absent until the reviewer explicitly enables Developer mode. ## Privacy declaration draft diff --git a/docs/launch/directory-listings.md b/docs/launch/directory-listings.md index 20be5a4..e6c0d8c 100644 --- a/docs/launch/directory-listings.md +++ b/docs/launch/directory-listings.md @@ -30,7 +30,7 @@ Each agent begins with a task workspace. AgentTab creates or visibly adopts a ta 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 eight tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, and `browser_credentials`. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. 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. +The Standard MCP surface has exactly nine tools: `browser_open`, `browser_snapshot`, `browser_act`, `browser_wait`, `browser_tabs`, `browser_handoff`, `browser_commit`, `browser_credentials`, and `browser_finish`. The finalization tool applies provenance-aware cleanup: task-created tabs close by default, adopted tabs are retained, retained tabs are ungrouped, and task ownership is released. The credential tool is inert unless managed policy explicitly enables the local 1Password broker, and it never returns a credential value. 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. The optional local 1Password broker can fill one of at most three origin-matching Login items directly into a selected field without revealing the value to the agent. **Your Turn** remains the human handoff state for passkeys, security keys, CAPTCHA, payment secrets, account recovery, unsupported verification, or a broker result that needs the user. During handoff, AgentTab applies an observation blackout across every task, so standard observations return `needs_user`; it does not capture human keystrokes. diff --git a/docs/launch/reddit-posts.md b/docs/launch/reddit-posts.md index a3b37dd..d29e6d4 100644 --- a/docs/launch/reddit-posts.md +++ b/docs/launch/reddit-posts.md @@ -43,7 +43,7 @@ The handoff model is called **Your Turn**. A disabled-by-default local 1Password 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. -The Standard MCP interface has eight tools and excludes raw cookies, browser storage, arbitrary scripts, raw CDP, raw network access, and credential-value responses. A separate Developer-only tool requires a persistent explicit opt-in. +The Standard MCP interface has nine tools, including explicit provenance-aware task finalization, and excludes raw cookies, browser storage, arbitrary scripts, raw CDP, raw network access, and credential-value responses. A separate Developer-only tool requires a persistent explicit opt-in. The extension also declares the `` host permission for the defined `chrome.scripting` text, HTML, selector, wait, and scroll paths in task-owned pages. It is needed across the sites a person directs an agent to use, not to expose raw browser-data or browser-control APIs in Standard mode. The important caveat is that task ownership is coordination, not a security container around an identity. The profile remains the profile the person is signed into. Prompt injection, misleading content, and a poorly trusted local agent remain meaningful risks. diff --git a/docs/mcp.md b/docs/mcp.md index 692de59..d9f8f15 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -46,7 +46,7 @@ The package coordinate for the direct stdio binary is `agenttab-mcp`; its execut The same `agenttab mcp` entry is the manual shape for Claude Desktop, Cursor, Windsurf, and another stdio-MCP client. Do not put a profile path, port, token, or `AGENTTAB_SOCKET` override in routine client configuration. -For OMP and Pi, the release's public package coordinate is `@getagenttab/omp`. The package advertises both `omp.extensions` and `pi.extensions`, each pointing to the same built adapter. OMP receives native Zod schemas, approval metadata, and discoverable strict tools; Pi receives native TypeBox schemas. Both runtimes register the same eight Standard tools and render the same browser operation cards. Cards identify task and tab ownership, separate intent, policy decision, execution, and observation, surface browser effects and recovery instructions, and keep sensitive inputs out of collapsed output. Expanded results remain bounded and redact tokens, credentials, cookies, authorization, passwords, and secrets. The installer adds the local built extension path to OMP configuration transactionally. +For OMP and Pi, the release's public package coordinate is `@getagenttab/omp`. The package advertises both `omp.extensions` and `pi.extensions`, each pointing to the same built adapter. OMP receives native Zod schemas, approval metadata, and discoverable strict tools; Pi receives native TypeBox schemas. Both runtimes register the same nine Standard tools and render the same browser operation cards. Cards identify task and tab ownership, separate intent, policy decision, execution, and observation, surface browser effects and recovery instructions, and keep sensitive inputs out of collapsed output. Expanded results remain bounded and redact tokens, credentials, cookies, authorization, passwords, and secrets. The installer adds the local built extension path to OMP and Pi configuration without replacing unrelated extensions. ## Connection and durable resume @@ -58,7 +58,7 @@ OMP and Pi automatically use the harness session ID as their stable private conv For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. Each hashes the supplied conversation scope into the owner-only filename. See [Commands](commands.md#adapter-environment) for environment variables and [Core RPC connection schema](../schemas/rpc/v1/connection.schema.json) for the connection envelope. -## Eight Standard tools +## Nine Standard tools | Tool | Required input and behavior | |---|---| @@ -70,6 +70,7 @@ For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. | `browser_handoff` | Requires a task tab, expected page revision, prompt, completion condition, and optional timeout. Completion can be navigation, manual completion, a URL, or a selector. It remains available on a `tab_only` route because AgentTab blocks agent observation while the human controls the tab, but selector completion requires the `full` route. | | `browser_commit` | Requires the staged token returned by a prior `commit_required` action and executes that one staged operation. On a `tab_only` route, only a staged close can execute; page-dependent staged actions require the `full` route. | | `browser_credentials` | `prepare` requires a task tab and expected page revision, then returns an opaque short-lived token only when managed policy enables 1Password and one through three Login items match the host-derived current origin. `fill` consumes that token and selected username, password, or one-time-code field refs without returning any value. `next` advances to another bounded candidate. It never submits the form. | +| `browser_finish` | Accepts `disposition: "auto" | "close" | "keep"` and optional task-owned `keep_tab_ids`. Automatic mode follows the popup cleanup policy: close task-created tabs while retaining adopted tabs, ask for confirmation, or retain all tabs. Successful finalization ungroups retained tabs, releases ownership, closes the Core connection, and returns closed and retained tab IDs. Active handoff, staged Commit review, and other in-flight work defer finalization without destroying resumability. | Every existing-page mutation carries its expected page revision. If navigation or document replacement makes that revision stale, AgentTab rejects the operation rather than selecting a new target. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c9fd95..a57b548 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,7 +7,7 @@ AgentTab v2 is currently `2.0.0-rc.1`, a local prerelease. This page records lau | Gate | Current source state | Required evidence before promotion | | --- | --- | --- | | Product identity | Source identifies AgentTab, `dev.agenttab.host`, `agenttab`, and AgentTab Core RPC v1. | Exact-head identity and forbidden-surface gates. | -| Standard boundary | Source schemas define eight Standard browser tools, including a disabled-by-default host-managed credential broker; Developer mode adds `browser_developer`. | Schema, adapter discovery, fake-provider credential isolation, and real-extension checks. | +| Standard boundary | Source schemas define nine Standard browser tools, including explicit provenance-aware task finalization and a disabled-by-default host-managed credential broker; Developer mode adds `browser_developer`. | Schema, adapter discovery, cleanup provenance, fake-provider credential isolation, and real-extension checks. | | Task safety | Source implements server-bound tasks, visible groups, revisions, Pause, handoff blackout, and staged Commit records. | Controlled browser fixtures covering restart, revocation, stale revisions, and one-use Commit. | | Rust runtime | Source contains the Rust host, native bridge, same-user IPC, SQLite journal, and local audit. | Exact-head Rust, IPC, Linux, macOS, and Windows gates. | | Installer | Source contains a transactional Node-compatible installer and advanced loopback proxy. | Clean user-home and clean-machine install proof using the packaged signed bytes. | diff --git a/host-rs/crates/agenttab-host/src/native.rs b/host-rs/crates/agenttab-host/src/native.rs index 878f519..0432dee 100644 --- a/host-rs/crates/agenttab-host/src/native.rs +++ b/host-rs/crates/agenttab-host/src/native.rs @@ -1,13 +1,15 @@ use crate::handoff::HandoffState; use crate::lifecycle::Lifecycle; use agenttab_protocol::{ - native_close_task, native_command, native_event_ack, native_event_ack_result, native_ready, - read_frame, write_frame, NativeDisconnectEvent, NativeDisconnectRecovery, NativeEvent, - NativeEventName, NativeEventPayload, NativeHandoff, NativeHello, NativeOriginPolicy, - NativeResponse, NativeStagedCommit, NativeTab, Outcome, ProtocolError, RpcError, RuntimeState, + native_close_task, native_command, native_event_ack, native_event_ack_result, + native_finish_task, native_ready, read_frame, write_frame, FinishDisposition, + NativeDisconnectEvent, NativeDisconnectRecovery, NativeEvent, NativeEventName, + NativeEventPayload, NativeHandoff, NativeHello, NativeOriginPolicy, NativeResponse, + NativeStagedCommit, NativeTab, Outcome, ProtocolError, RpcError, RuntimeState, EXTENSION_TO_HOST_MAX_BYTES, HOST_TO_EXTENSION_MAX_BYTES, NATIVE_PROTOCOL, PROTOCOL_VERSION, }; use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::io::{Read, Write}; @@ -38,6 +40,17 @@ pub struct NativeEventResult { pub result: Option, pub error: Option, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct NativeTaskFinish { + pub task_id: Uuid, + pub finished: bool, + pub closed_tab_ids: Vec, + pub retained_tab_ids: Vec, + #[serde(default)] + pub deferred: Option, +} impl NativeEventResult { pub fn completed(result: Value) -> Self { Self { @@ -83,6 +96,17 @@ pub trait NativeTransport: Send + Sync { "native close_task lifecycle command is unsupported".into(), )) } + fn finish_task( + &self, + _task_id: Uuid, + _disposition: FinishDisposition, + _keep_tab_ids: &[u64], + _timeout: Duration, + ) -> Result { + Err(NativeError::Protocol( + "native finish_task lifecycle command is unsupported".into(), + )) + } fn cancel_connection(&self, _connection_id: Uuid) {} fn set_event_sink(&self, _sink: Arc) {} } @@ -431,6 +455,60 @@ impl NativeTransport for StdioNative { Ok(()) } + fn finish_task( + &self, + task_id: Uuid, + disposition: FinishDisposition, + keep_tab_ids: &[u64], + timeout: Duration, + ) -> Result { + if self.disconnected.load(Ordering::Acquire) { + return Err(NativeError::Disconnected); + } + let request_id = Uuid::new_v4(); + let (sender, receiver) = mpsc::sync_channel(1); + self.pending + .lock() + .insert(request_id, (Uuid::nil(), task_id, sender)); + if let Err(error) = self.write_value(&native_finish_task( + request_id, + task_id, + disposition, + keep_tab_ids, + )) { + self.pending.lock().remove(&request_id); + return Err(NativeError::Transport(error.to_string())); + } + let response = match receiver.recv_timeout(timeout) { + Ok(result) => result?, + Err(mpsc::RecvTimeoutError::Timeout) => { + self.pending.lock().remove(&request_id); + return Err(NativeError::Timeout); + } + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(NativeError::Disconnected), + }; + if response.outcome != Outcome::Completed { + return Err(NativeError::Protocol( + "native finish_task cleanup was not confirmed by the extension".into(), + )); + } + let result: NativeTaskFinish = serde_json::from_value( + response.result.ok_or_else(|| NativeError::Protocol( + "native finish_task response omitted its result".into(), + ))?, + ) + .map_err(|error| NativeError::Protocol(error.to_string()))?; + if result.task_id != task_id || + (result.finished && result.deferred.is_some()) || + (!result.finished && result.deferred.is_none()) + { + return Err(NativeError::Protocol( + "native finish_task result is internally inconsistent".into(), + )); + } + Ok(result) + } + fn cancel_connection(&self, connection_id: Uuid) { let senders = { let mut pending = self.pending.lock(); diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs index 336adb1..dd8eae7 100644 --- a/host-rs/crates/agenttab-host/src/runtime.rs +++ b/host-rs/crates/agenttab-host/src/runtime.rs @@ -13,12 +13,12 @@ use crate::native::{NativeError, NativeEventResult, NativeEventSink, NativeTrans use crate::paths::AgentTabPaths; use crate::task::ConnectionContext; use agenttab_protocol::{ - BrowserAction, BrowserCommitParams, BrowserCredentialsParams, BrowserHandoffParams, - BrowserSnapshotParams, BrowserWaitParams, ConnectionAck, ConnectionInit, MethodParams, - NativeEventPayload, NativeHandoff, NativePopupCommitEvent, NativeResponse, NativeStagedCommit, - NativeTab, Outcome, ResumeCapabilityConfirm, ResumeCapabilityConfirmed, RpcError, RpcMethod, - RpcRequest, RpcResponse, TaskBinding, WaitCondition, HOST_TO_CLIENT_MAX_BYTES, - PROTOCOL_VERSION, + AgenttabFinishParams, BrowserAction, BrowserCommitParams, BrowserCredentialsParams, + BrowserHandoffParams, BrowserSnapshotParams, BrowserWaitParams, ConnectionAck, + ConnectionInit, MethodParams, NativeEventPayload, NativeHandoff, NativePopupCommitEvent, + NativeResponse, NativeStagedCommit, NativeTab, Outcome, ResumeCapabilityConfirm, + ResumeCapabilityConfirmed, RpcError, RpcMethod, RpcRequest, RpcResponse, TaskBinding, + WaitCondition, HOST_TO_CLIENT_MAX_BYTES, PROTOCOL_VERSION, }; use parking_lot::{Mutex, RwLock}; use serde_json::{json, Value}; @@ -519,6 +519,57 @@ impl Runtime { false, ); } + if request.method == RpcMethod::AgenttabFinish { + let task_id = connection.task_id().ok().flatten(); + let response = match (task_id, params) { + (Some(task_id), MethodParams::Finish(AgenttabFinishParams { + disposition, + keep_tab_ids, + })) => self + .native + .finish_task(task_id, disposition, &keep_tab_ids, CLOSE_TASK_TIMEOUT) + .map_err(|error| JournalError::NativeTaskCleanup(error.to_string())) + .and_then(|result| { + if result.finished { + self.journal.close_task(task_id)?; + connection.cancel(); + self.native.cancel_connection(connection.connection_id); + } + Ok(result) + }) + .map(|result| RpcResponse::success( + request.request_id.clone(), + if result.finished { Outcome::Completed } else { Outcome::NeedsUser }, + serde_json::to_value(result).expect("native task finish result serializes"), + )) + .unwrap_or_else(|error| RpcResponse::failure( + request.request_id.clone(), + Outcome::Unknown, + journal_rpc_error(error), + )), + (None, MethodParams::Finish(_)) => RpcResponse::success( + request.request_id.clone(), + Outcome::Completed, + json!({ + "finished": false, + "closed_tab_ids": [], + "retained_tab_ids": [], + }), + ), + _ => unreachable!("protocol parser returns method-matched parameters"), + }; + return self.audited_value( + connection, + task_id, + &request, + ¶ms_value, + response, + started_at_ms, + started, + false, + false, + ); + } if request.method == RpcMethod::AgenttabClose { let task_id = connection.task_id().ok().flatten(); let response = match task_id { @@ -1646,6 +1697,7 @@ fn requested_tab(params: &MethodParams) -> Option<(u64, Option)> { | MethodParams::Tabs(_) | MethodParams::Commit(_) | MethodParams::Status(_) + | MethodParams::Finish(_) | MethodParams::Close(_) | MethodParams::Developer(_) => None, } @@ -1912,10 +1964,11 @@ fn enforce_response_limit(response: RpcResponse) -> RpcResponse { #[cfg(test)] mod tests { use super::*; + use crate::native::NativeTaskFinish; use agenttab_protocol::{ - ConnectKind, NativeInventoryEvent, NativeOriginPolicy, NativeResponse, NativeResponseKind, - NativeStagedCommit, NativeTab, ResumeCapabilityConfirm, ResumeCapabilityConfirmKind, - RPC_PROTOCOL, + ConnectKind, FinishDisposition, NativeInventoryEvent, NativeOriginPolicy, NativeResponse, + NativeResponseKind, NativeStagedCommit, NativeTab, ResumeCapabilityConfirm, + ResumeCapabilityConfirmKind, RPC_PROTOCOL, }; use parking_lot::Mutex; use serde_json::json; @@ -2101,6 +2154,22 @@ mod tests { } Ok(()) } + fn finish_task( + &self, + task_id: Uuid, + _disposition: FinishDisposition, + keep_tab_ids: &[u64], + _timeout: Duration, + ) -> Result { + self.closed_tasks.lock().push(task_id); + Ok(NativeTaskFinish { + task_id, + finished: true, + closed_tab_ids: vec![31, 32], + retained_tab_ids: keep_tab_ids.to_vec(), + deferred: None, + }) + } } #[derive(Debug)] struct TimeoutNative; @@ -2407,6 +2476,54 @@ mod tests { assert_eq!(rejected["error"]["code"], "connection_cancelled"); } + #[test] + fn agenttab_finish_finalizes_the_task_with_cleanup_receipts() { + let native = FakeNative::normal(); + let (_temp, runtime, connection) = connected_runtime(native.clone()); + let opened = runtime.handle( + &connection, + json!({ + "protocol": RPC_PROTOCOL, + "version": PROTOCOL_VERSION, + "request_id": "tabs", + "method": "browser_tabs", + "params": {} + }), + ); + let capability = opened["task"]["resume_capability"] + .as_str() + .unwrap() + .to_string(); + let task_id = opened["task"]["task_id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + confirm_new_capability(&runtime, &connection, capability.clone()); + + let finished = runtime.handle( + &connection, + json!({ + "protocol": RPC_PROTOCOL, + "version": PROTOCOL_VERSION, + "request_id": "finish", + "method": "agenttab.finish", + "params": { + "disposition": "auto", + "keep_tab_ids": [41] + } + }), + ); + + assert_eq!(finished["outcome"], "completed"); + assert_eq!(finished["result"]["finished"], true); + assert_eq!(finished["result"]["closed_tab_ids"], json!([31, 32])); + assert_eq!(finished["result"]["retained_tab_ids"], json!([41])); + assert_eq!(*native.closed_tasks.lock(), vec![task_id]); + assert!(connection.is_cancelled()); + assert!(runtime.journal.resume_task(&capability).unwrap().is_none()); + } + fn confirm_new_capability( runtime: &Runtime, connection: &ConnectionContext, diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index 1b3124f..c3117bd 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -17,6 +17,7 @@ const MAX_SELECTOR_CHARS: usize = 2_048; const MAX_REF_CHARS: usize = 256; const MAX_ACTIONS: usize = 64; const MAX_ACTION_TEXT_CHARS: usize = 2_048; +const MAX_KEEP_TAB_IDS: usize = 256; const MAX_ACTION_VALUE_CHARS: usize = 2_048; const MAX_UPLOAD_FILES: usize = 4; const MAX_UPLOAD_PATH_CHARS: usize = 512; @@ -47,6 +48,10 @@ pub const RPC_SCHEMA_ASSETS: &[(&str, &str)] = &[ "status", include_str!("../../../../schemas/rpc/v1/status.schema.json"), ), + ( + "agenttab_finish", + include_str!("../../../../schemas/rpc/v1/agenttab-finish.schema.json"), + ), ( "browser_open", include_str!("../../../../schemas/rpc/v1/browser-open.schema.json"), @@ -178,6 +183,8 @@ pub enum RpcMethod { BrowserCredentials, #[serde(rename = "agenttab.status")] AgenttabStatus, + #[serde(rename = "agenttab.finish")] + AgenttabFinish, #[serde(rename = "agenttab.close")] AgenttabClose, BrowserDeveloper, @@ -290,6 +297,7 @@ pub enum MethodParams { Credentials(BrowserCredentialsParams), Commit(BrowserCommitParams), Status(BrowserTabsParams), + Finish(AgenttabFinishParams), Close(BrowserTabsParams), Developer(BrowserDeveloperParams), } @@ -306,6 +314,7 @@ impl MethodParams { RpcMethod::BrowserCredentials => Self::Credentials(decode_params(method, value)?), RpcMethod::BrowserCommit => Self::Commit(decode_params(method, value)?), RpcMethod::AgenttabStatus => Self::Status(decode_params(method, value)?), + RpcMethod::AgenttabFinish => Self::Finish(decode_params(method, value)?), RpcMethod::AgenttabClose => Self::Close(decode_params(method, value)?), RpcMethod::BrowserDeveloper => Self::Developer(decode_params(method, value)?), }; @@ -324,6 +333,7 @@ impl MethodParams { Self::Credentials(value) => serde_json::to_value(value), Self::Commit(value) => serde_json::to_value(value), Self::Status(value) => serde_json::to_value(value), + Self::Finish(value) => serde_json::to_value(value), Self::Close(value) => serde_json::to_value(value), Self::Developer(value) => serde_json::to_value(value), } @@ -520,6 +530,27 @@ pub enum WaitCondition { #[serde(deny_unknown_fields)] pub struct BrowserTabsParams {} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinishDisposition { + Auto, + Close, + Keep, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgenttabFinishParams { + #[serde(default = "default_finish_disposition")] + pub disposition: FinishDisposition, + #[serde(default)] + pub keep_tab_ids: Vec, +} + +fn default_finish_disposition() -> FinishDisposition { + FinishDisposition::Auto +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BrowserHandoffParams { @@ -814,6 +845,21 @@ impl MethodParams { "staged_token", )?; } + Self::Finish(params) => { + require( + method, + params.keep_tab_ids.len() <= MAX_KEEP_TAB_IDS, + format!("keep_tab_ids must contain at most {MAX_KEEP_TAB_IDS} items"), + )?; + for (index, tab_id) in params.keep_tab_ids.iter().enumerate() { + require(method, *tab_id > 0, "keep_tab_ids must contain positive tab IDs")?; + require( + method, + !params.keep_tab_ids[..index].contains(tab_id), + "keep_tab_ids must not contain duplicates", + )?; + } + } Self::Developer(params) => { require_len(method, ¶ms.action, 1, 128, "action")?; validate_developer_params(method, ¶ms.params)?; @@ -1397,6 +1443,38 @@ pub enum NativeCloseTaskKind { CloseTask, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeFinishTask { + pub protocol: String, + pub version: u16, + pub kind: NativeFinishTaskKind, + pub request_id: Uuid, + pub task_id: Uuid, + pub disposition: FinishDisposition, + #[serde(default)] + pub keep_tab_ids: Vec, +} + +impl NativeFinishTask { + pub fn parse(value: Value) -> Result { + let command: Self = serde_json::from_value(value)?; + if command.protocol != NATIVE_PROTOCOL || command.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedProtocol { + protocol: command.protocol.clone(), + version: command.version, + }); + } + Ok(command) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NativeFinishTaskKind { + FinishTask, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NativeResponse { @@ -1748,6 +1826,23 @@ pub fn native_close_task(request_id: Uuid, task_id: Uuid) -> Value { }) } +pub fn native_finish_task( + request_id: Uuid, + task_id: Uuid, + disposition: FinishDisposition, + keep_tab_ids: &[u64], +) -> Value { + serde_json::json!({ + "protocol": NATIVE_PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "finish_task", + "request_id": request_id, + "task_id": task_id, + "disposition": disposition, + "keep_tab_ids": keep_tab_ids, + }) +} + pub fn native_event_ack(event: NativeEventName, event_id: &str) -> Value { serde_json::json!({ "protocol": NATIVE_PROTOCOL, @@ -2168,6 +2263,26 @@ mod tests { let (_, close) = RpcRequest::parse(request("agenttab.close", json!({}), false)).unwrap(); assert!(matches!(close, MethodParams::Close(_))); + + let (_, finish) = RpcRequest::parse(request( + "agenttab.finish", + json!({"disposition": "auto", "keep_tab_ids": [7, 9]}), + false, + )) + .unwrap(); + assert!(matches!( + finish, + MethodParams::Finish(AgenttabFinishParams { + disposition: FinishDisposition::Auto, + keep_tab_ids, + }) if keep_tab_ids == vec![7, 9] + )); + assert!(RpcRequest::parse(request( + "agenttab.finish", + json!({"disposition": "auto", "keep_tab_ids": [7, 7]}), + false, + )) + .is_err()); } #[test] @@ -2356,6 +2471,27 @@ mod tests { assert!(NativeCloseTask::parse(unknown).is_err()); } + #[test] + fn native_finish_task_is_strict_and_versioned() { + let request_id = Uuid::new_v4(); + let task_id = Uuid::new_v4(); + let message = native_finish_task( + request_id, + task_id, + FinishDisposition::Auto, + &[17], + ); + let parsed = NativeFinishTask::parse(message.clone()).unwrap(); + assert_eq!(parsed.request_id, request_id); + assert_eq!(parsed.task_id, task_id); + assert_eq!(parsed.disposition, FinishDisposition::Auto); + assert_eq!(parsed.keep_tab_ids, vec![17]); + + let mut unknown = message; + unknown["unexpected"] = json!(true); + assert!(NativeFinishTask::parse(unknown).is_err()); + } + #[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 d0c22d5..9e7d65e 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -343,14 +343,22 @@ function errorOutcome(error: unknown, mutating: boolean, code: string): Outcome } async function dispatch(command: NativeDispatchCommand): Promise { - if (command.kind === "close_task") { + if (command.kind === "close_task" || command.kind === "finish_task") { try { - const closedTabIds = await ownership.closeTask(command.task_id); - await handoff.cancelForTask(command.task_id); - return completed(command.request_id, { - task_id: command.task_id, - closed_tab_ids: closedTabIds, - }); + const result = command.kind === "close_task" + ? { + task_id: command.task_id, + closed_tab_ids: await ownership.closeTask(command.task_id), + } + : await ownership.finishTask( + command.task_id, + command.disposition, + command.keep_tab_ids, + ); + if (command.kind === "close_task" || result.finished === true) { + await handoff.cancelForTask(command.task_id); + } + return completed(command.request_id, result); } catch (error) { return failed( command.request_id, @@ -691,6 +699,7 @@ async function handlePopupMessage(message: Record): Promise ({ task_id: task.taskId, name: task.name, @@ -716,6 +725,16 @@ async function handlePopupMessage(message: Record): Promise { + state.cleanupPolicy = policy; + }); + return { policy }; + } if (message.kind === "pause") { await handoff.pause(); return { paused: true }; @@ -760,10 +779,14 @@ async function handlePopupMessage(message: Record): Promise this.closeTaskNow(taskId)); } + finishTask( + taskId: string, + disposition: "auto" | "close" | "keep", + keepTabIds: readonly number[] = [], + ): Promise> { + return this.serialize(() => this.finishTaskNow(taskId, disposition, keepTabIds)); + } + async setTaskState( taskId: string, taskState: "working" | "needs_user" | "completed", @@ -196,6 +204,7 @@ export class OwnershipLedger { if (revokedTabIds.length === 0) continue; for (const tabId of revokedTabIds) this.scheduler.revokeTab(tabId); task.tabIds = task.tabIds.filter((tabId) => !revokedTabIds.includes(tabId)); + task.createdTabIds = task.createdTabIds.filter((tabId) => !revokedTabIds.includes(tabId)); if (task.tabIds.length === 0) task.groupId = null; task.updatedAt = Date.now(); changedTasks.push({ taskId: task.taskId, count: task.tabIds.length, revokedTabIds }); @@ -287,7 +296,7 @@ export class OwnershipLedger { const createdTabId = tab.id; if (createdTabId === undefined) throw new Error("Chrome did not return a created tab ID"); try { - await this.grant(taskId, createdTabId, `Task ${taskId.slice(0, 8)}`); + await this.grant(taskId, createdTabId, `Task ${taskId.slice(0, 8)}`, true); } catch (error) { await chrome.tabs.remove(createdTabId).catch(() => undefined); throw error; @@ -303,7 +312,7 @@ export class OwnershipLedger { }); } const tabId = tab.id as number; - await this.grant(taskId, tabId, `Adopted ${taskId.slice(0, 8)}`); + await this.grant(taskId, tabId, `Adopted ${taskId.slice(0, 8)}`, false); if (chrome.action?.setBadgeText) { await chrome.action.setBadgeBackgroundColor({ tabId, color: "#6d5dfc" }).catch(() => undefined); await chrome.action.setBadgeText({ tabId, text: "✦" }).catch(() => undefined); @@ -364,7 +373,7 @@ export class OwnershipLedger { await chrome.tabs.update(activeDestination.id as number, { active: true }).catch(() => undefined); } } - await this.grant(ownedParent.taskId, childTabId, ownedParent.name); + await this.grant(ownedParent.taskId, childTabId, ownedParent.name, true); } catch { // A popup or Chrome grouping race is not authority to close a user tab. } @@ -390,6 +399,7 @@ export class OwnershipLedger { for (const task of Object.values(state.tasks)) { if (!task.tabIds.includes(tabId)) continue; task.tabIds = task.tabIds.filter((ownedTabId) => ownedTabId !== tabId); + task.createdTabIds = task.createdTabIds.filter((createdTabId) => createdTabId !== tabId); if (task.tabIds.length === 0) task.groupId = null; task.updatedAt = Date.now(); return { taskId: task.taskId, count: task.tabIds.length }; @@ -406,6 +416,121 @@ export class OwnershipLedger { return true; } + private async finishTaskNow( + taskId: string, + disposition: "auto" | "close" | "keep", + keepTabIds: readonly number[], + ): Promise> { + const state = await readState(); + const task = state.tasks[taskId]; + if (!task) { + return { task_id: taskId, finished: true, closed_tab_ids: [], retained_tab_ids: [] }; + } + if (state.handoff.active && state.handoff.taskId === taskId) { + return { + task_id: taskId, + finished: false, + closed_tab_ids: [], + retained_tab_ids: [...task.tabIds], + deferred: "handoff_active", + }; + } + if (Object.values(state.stagedCommits).some((staged) => staged.task_id === taskId)) { + return { + task_id: taskId, + finished: false, + closed_tab_ids: [], + retained_tab_ids: [...task.tabIds], + deferred: "commit_review_active", + }; + } + if (disposition === "auto" && state.cleanupPolicy === "ask") { + await this.setTaskState(taskId, "completed"); + return { + task_id: taskId, + finished: false, + closed_tab_ids: [], + retained_tab_ids: [...task.tabIds], + deferred: "user_confirmation", + }; + } + const invalidKeepTabId = keepTabIds.find((tabId) => !task.tabIds.includes(tabId)); + if (invalidKeepTabId !== undefined) { + throw Object.assign(new Error(`Tab ${invalidKeepTabId} is not owned by task ${taskId}`), { + code: "ownership_denied", + }); + } + const retainAll = disposition === "keep" || ( + disposition === "auto" && state.cleanupPolicy === "keep" + ); + const requestedRetainedTabIds = new Set( + retainAll + ? task.tabIds + : task.tabIds.filter((tabId) => ( + keepTabIds.includes(tabId) || + (disposition === "auto" && !task.createdTabIds.includes(tabId)) + )), + ); + const retainedTabIds = new Set(); + const closedCandidates: number[] = []; + for (const tabId of task.tabIds) { + const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; + if (!tab) continue; + if ( + requestedRetainedTabIds.has(tabId) || + task.groupId === null || + tab.groupId !== task.groupId + ) { + retainedTabIds.add(tabId); + } else { + closedCandidates.push(tabId); + } + } + for (const tabId of task.tabIds) this.scheduler.revokeTab(tabId); + await mutateState((next) => { + delete next.tasks[taskId]; + for (const [token, staged] of Object.entries(next.stagedCommits)) { + if (staged.task_id === taskId) delete next.stagedCommits[token]; + } + }); + for (const tabId of task.tabIds) await this.revisions.remove(tabId); + for (const tabId of retainedTabIds) { + const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; + if (tab && task.groupId !== null && tab.groupId === task.groupId) { + await chrome.tabs.ungroup([tabId]).catch(() => undefined); + } + if (chrome.action?.setBadgeText) { + await chrome.action.setBadgeText({ tabId, text: "" }).catch(() => undefined); + } + } + const closedTabIds: number[] = []; + for (const tabId of closedCandidates) { + const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; + if (!tab) continue; + if (task.groupId === null || tab.groupId !== task.groupId) { + retainedTabIds.add(tabId); + if (chrome.action?.setBadgeText) { + await chrome.action.setBadgeText({ tabId, text: "" }).catch(() => undefined); + } + continue; + } + try { + await chrome.tabs.remove(tabId); + closedTabIds.push(tabId); + } catch { + // Ownership is already durably removed; a missing/closed tab is safe. + } + } + this.emit("tab_removed", { task_id: taskId, tab_count: 0 }); + await this.emitInventory(); + return { + task_id: taskId, + finished: true, + closed_tab_ids: closedTabIds, + retained_tab_ids: task.tabIds.filter((tabId) => retainedTabIds.has(tabId)), + }; + } + private async closeTaskNow(taskId: string): Promise { const existing = (await readState()).tasks[taskId]; if (!existing) return []; @@ -423,6 +548,8 @@ export class OwnershipLedger { for (const tabId of tabIds) await this.revisions.remove(tabId); const closedTabIds: number[] = []; for (const tabId of tabIds) { + const tab = (await chrome.tabs.get(tabId).catch(() => null)) as TabLike | null; + if (!tab || existing.groupId === null || tab.groupId !== existing.groupId) continue; try { await chrome.tabs.remove(tabId); closedTabIds.push(tabId); @@ -435,7 +562,7 @@ export class OwnershipLedger { return closedTabIds; } - private async grant(taskId: string, tabId: number, name: string): Promise { + private async grant(taskId: string, tabId: number, name: string, created: boolean): Promise { const before = (await chrome.tabs.get(tabId)) as TabLike; const state = await readState(); const currentOwner = Object.values(state.tasks).find((task) => task.tabIds.includes(tabId)); @@ -445,6 +572,12 @@ export class OwnershipLedger { }); } if (currentOwner && currentOwner.groupId === before.groupId && before.groupId !== NO_GROUP) { + if (created && !currentOwner.createdTabIds.includes(tabId)) { + await mutateState((next) => { + const task = next.tasks[taskId]; + if (task && !task.createdTabIds.includes(tabId)) task.createdTabIds.push(tabId); + }); + } await this.revisions.ensure(tabId); return; } @@ -464,6 +597,7 @@ export class OwnershipLedger { name, groupId, tabIds: [], + createdTabIds: [], color: taskColor(taskId), state: "working", createdAt: Date.now(), @@ -489,6 +623,7 @@ export class OwnershipLedger { const task = next.tasks[taskId] ?? previewTask; task.groupId = groupId; if (!task.tabIds.includes(tabId)) task.tabIds.push(tabId); + if (created && !task.createdTabIds.includes(tabId)) task.createdTabIds.push(tabId); task.updatedAt = Date.now(); next.tasks[taskId] = task; }); diff --git a/packages/extension/src/popup.html b/packages/extension/src/popup.html index 1e76e90..0ca960f 100644 --- a/packages/extension/src/popup.html +++ b/packages/extension/src/popup.html @@ -87,6 +87,17 @@

Task groups

+