From ec8f6e5e0358063bddc38f1847cdd82c610cebd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 01:00:23 +0100 Subject: [PATCH] feat(browser): add secure per-tab copilot panel (#109817) * feat(browser): add copilot security contracts * fix(gateway): expose verified client identity to handlers * feat(browser): add secure per-tab copilot panel Co-authored-by: Cameron Beeley * refactor(browser): separate copilot gateway hint custody * fix(browser): preserve legacy pairing parse shape * fix(browser): harden copilot lifecycle custody Co-authored-by: Cameron Beeley * fix(browser): enforce copilot lifecycle boundaries Co-authored-by: Cameron Beeley * style(browser): format copilot sources Co-authored-by: Cameron Beeley * fix(browser): preserve copilot consent revocation Co-authored-by: Cameron Beeley * refactor(browser): split copilot custody owners Co-authored-by: Cameron Beeley * test(browser): normalize websocket array buffers Co-authored-by: Cameron Beeley * chore(protocol): regenerate Swift gateway models Co-authored-by: Cameron Beeley * refactor(browser): model copilot runtime entrypoints Co-authored-by: Cameron Beeley * fix(browser): honor extension build boundaries Co-authored-by: Cameron Beeley * test(gateway): assert targeted chat delivery Co-authored-by: Cameron Beeley * test(gateway): cover targeted delivery calls Co-authored-by: Cameron Beeley * fix(browser): declare copilot build dependencies Co-authored-by: Cameron Beeley * fix(ci): clear browser copilot gate failures Co-authored-by: Cameron Beeley * test(ci): cover copilot lint exclusion Co-authored-by: Cameron Beeley * fix(browser): gate copilot on relay custody Co-authored-by: Cameron Beeley * test(browser): bound copilot relay frames Co-authored-by: Cameron Beeley --------- Co-authored-by: Cameron Beeley --- .oxfmtrc.jsonc | 1 + .oxlintrc.json | 1 + .../OpenClawProtocol/GatewayModels.swift | 10 + config/knip.config.ts | 4 + docs/docs_map.md | 1 + docs/tools/chrome-extension.md | 55 + .../browser/chrome-extension/background.js | 153 ++- .../browser/chrome-extension/manifest.json | 2 +- .../modules/copilot-background-shared.d.ts | 34 + .../modules/copilot-background-shared.js | 126 ++ .../modules/copilot-background.d.ts | 15 + .../modules/copilot-background.js | 730 ++++++++++++ .../modules/copilot-background.test.ts | 733 ++++++++++++ .../modules/copilot-gateway-lifecycle.d.ts | 47 + .../modules/copilot-gateway-lifecycle.js | 131 +++ .../modules/copilot-gateway.d.ts | 21 + .../modules/copilot-gateway.js | 258 +++++ .../modules/copilot-gateway.test.ts | 318 +++++ .../modules/copilot-recovery.d.ts | 23 + .../modules/copilot-recovery.js | 269 +++++ .../modules/copilot-relay-custody.d.ts | 5 + .../modules/copilot-relay-custody.js | 89 ++ .../modules/copilot-runtime.d.ts | 29 + .../modules/copilot-runtime.js | 1 + .../modules/copilot-session-registry.d.ts | 65 ++ .../modules/copilot-session-registry.js | 345 ++++++ .../modules/copilot-session-registry.test.ts | 229 ++++ .../modules/copilot-session.d.ts | 18 + .../modules/copilot-session.js | 314 +++++ .../chrome-extension/modules/panel-core.d.ts | 41 + .../chrome-extension/modules/panel-core.js | 139 +++ .../modules/panel-core.test.ts | 93 ++ .../chrome-extension/modules/relay-core.d.ts | 6 +- .../chrome-extension/modules/relay-core.js | 16 +- .../modules/relay-core.test.ts | 10 + .../browser/chrome-extension/popup.html | 5 + extensions/browser/chrome-extension/popup.js | 18 + .../browser/chrome-extension/sidepanel.css | 356 ++++++ .../chrome-extension/sidepanel.e2e.test.ts | 1020 +++++++++++++++++ .../browser/chrome-extension/sidepanel.html | 57 + .../browser/chrome-extension/sidepanel.js | 254 ++++ extensions/browser/index.test.ts | 36 + extensions/browser/package.json | 4 + extensions/browser/plugin-registration.ts | 17 +- .../browser/scripts/build-copilot-runtime.mjs | 20 + .../browser/scripts/copilot-runtime-entry.ts | 14 + .../browser/src/browser-tool-binding.test.ts | 50 + .../browser/src/browser-tool-binding.ts | 115 ++ .../browser/src/browser-tool.actions.ts | 11 +- extensions/browser/src/browser-tool.ts | 14 +- .../src/cli/browser-cli-extension-pairing.ts | 13 + .../src/cli/browser-cli-extension.test.ts | 23 + .../browser/src/cli/browser-cli-extension.ts | 16 +- .../src/browser-device-auth.test.ts | 85 ++ .../gateway-client/src/browser-device-auth.ts | 177 +++ packages/gateway-client/src/browser.ts | 1 + packages/gateway-client/src/index.ts | 1 + packages/gateway-protocol/src/client-info.ts | 3 + .../gateway-protocol/src/schema/devices.ts | 1 + .../gateway-protocol/src/schema/logs-chat.ts | 9 + pnpm-lock.yaml | 9 + src/agents/agent-tools.ts | 4 + .../run/attempt-tool-base-prepare.ts | 1 + .../embedded-agent-runner/run/params.ts | 2 + src/agents/openclaw-tools.plugin-context.ts | 2 + src/agents/openclaw-tools.ts | 1 + .../reply/agent-runner-run-params.ts | 1 + .../reply/agent-runner-runtime-config.test.ts | 15 + src/auto-reply/reply/get-reply-run.ts | 1 + .../reply/queue/drain.client-caps.test.ts | 22 + src/auto-reply/reply/queue/drain.ts | 2 + src/auto-reply/reply/queue/types.ts | 1 + src/auto-reply/templating.ts | 2 + src/gateway/chat-abort.test.ts | 1 + src/gateway/chat-abort.ts | 15 +- src/gateway/gateway-misc.test.ts | 56 +- src/gateway/origin-check.test.ts | 12 +- src/gateway/origin-check.ts | 11 +- src/gateway/server-broadcast-types.ts | 2 + src/gateway/server-broadcast.ts | 28 +- src/gateway/server-chat.agent-events.test.ts | 3 + src/gateway/server-chat.ts | 80 +- src/gateway/server-close.test.ts | 2 + src/gateway/server-methods/chat-broadcast.ts | 24 +- .../chat-send-dispatch-errors.test.ts | 1 + .../server-methods/chat-send-request.test.ts | 65 ++ .../server-methods/chat-send-request.ts | 29 +- .../chat-send-user-turn.test.ts | 2 + .../server-methods/chat-send-user-turn.ts | 4 + .../chat.error-broadcast.test.ts | 2 + src/gateway/server-methods/chat.ts | 4 +- .../server-methods/models-auth-status.test.ts | 1 + src/gateway/server-methods/shared-types.ts | 2 + src/gateway/server-node-session-runtime.ts | 12 +- src/gateway/server-runtime-state.ts | 10 +- .../server.chat.gateway-server-chat-b.test.ts | 3 + src/gateway/server.impl.ts | 6 +- .../server/ws-connection/connect-admission.ts | 55 +- .../ws-connection/connect-device-pairing.ts | 47 +- .../server/ws-connection/connect-session.ts | 8 +- src/gateway/server/ws-types.ts | 2 + src/gateway/session-message-events.test.ts | 251 ++++ src/gateway/talk-realtime-relay.test.ts | 3 + src/gateway/test-helpers.server.ts | 4 + src/infra/device-pairing-store.ts | 4 + src/infra/device-pairing.ts | 9 + src/infra/device-pairing.types.ts | 2 + src/plugins/tool-types.ts | 2 + src/state/openclaw-state-db.generated.d.ts | 2 + src/state/openclaw-state-db.ts | 2 + src/state/openclaw-state-schema.generated.ts | 2 + src/state/openclaw-state-schema.sql | 2 + src/utils/message-channel.test.ts | 12 + src/utils/message-channel.ts | 16 +- test/scripts/oxlint-config.test.ts | 1 + 115 files changed, 7431 insertions(+), 83 deletions(-) create mode 100644 extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-background-shared.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-background.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-background.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-background.test.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-gateway.test.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-recovery.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-recovery.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-relay-custody.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-runtime.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-runtime.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.js create mode 100644 extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-session.d.ts create mode 100644 extensions/browser/chrome-extension/modules/copilot-session.js create mode 100644 extensions/browser/chrome-extension/modules/panel-core.d.ts create mode 100644 extensions/browser/chrome-extension/modules/panel-core.js create mode 100644 extensions/browser/chrome-extension/modules/panel-core.test.ts create mode 100644 extensions/browser/chrome-extension/sidepanel.css create mode 100644 extensions/browser/chrome-extension/sidepanel.e2e.test.ts create mode 100644 extensions/browser/chrome-extension/sidepanel.html create mode 100644 extensions/browser/chrome-extension/sidepanel.js create mode 100644 extensions/browser/scripts/build-copilot-runtime.mjs create mode 100644 extensions/browser/scripts/copilot-runtime-entry.ts create mode 100644 extensions/browser/src/browser-tool-binding.test.ts create mode 100644 extensions/browser/src/browser-tool-binding.ts create mode 100644 extensions/browser/src/cli/browser-cli-extension-pairing.ts create mode 100644 extensions/browser/src/cli/browser-cli-extension.test.ts create mode 100644 packages/gateway-client/src/browser-device-auth.test.ts create mode 100644 packages/gateway-client/src/browser-device-auth.ts diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index de4d378b555f..75ad194c3e17 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -32,6 +32,7 @@ "dist/", "docs/_layouts/", "extensions/diffs/assets/viewer-runtime.js", + "extensions/browser/chrome-extension/modules/copilot-runtime.js", "**/*.json", "node_modules/", "patches/", diff --git a/.oxlintrc.json b/.oxlintrc.json index 2b2ef5f5b2ff..63057d70159c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -211,6 +211,7 @@ "dist-runtime/", "docs/_layouts/", "**/a2ui.bundle.js", + "extensions/browser/chrome-extension/modules/copilot-runtime.js", "extensions/diffs/assets/viewer-runtime.js", "extensions/diffs-language-pack/assets/viewer-runtime.js", "node_modules/", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 352f95c66e6a..3a7c23ab2265 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -13746,6 +13746,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { public let devicefamily: String? public let clientid: String? public let clientmode: String? + public let browserorigin: String? public let role: String? public let roles: [String]? public let scopes: [String]? @@ -13763,6 +13764,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { devicefamily: String? = nil, clientid: String? = nil, clientmode: String? = nil, + browserorigin: String? = nil, role: String? = nil, roles: [String]? = nil, scopes: [String]? = nil, @@ -13779,6 +13781,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { self.devicefamily = devicefamily self.clientid = clientid self.clientmode = clientmode + self.browserorigin = browserorigin self.role = role self.roles = roles self.scopes = scopes @@ -13797,6 +13800,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { case devicefamily = "deviceFamily" case clientid = "clientId" case clientmode = "clientMode" + case browserorigin = "browserOrigin" case role case roles case scopes @@ -13989,6 +13993,7 @@ public struct ChatSendParams: Codable, Sendable { public let originatingaccountid: String? public let originatingthreadid: String? public let attachments: [AnyCodable]? + public let toolbindings: [String: AnyCodable]? public let timeoutms: Int? public let systeminputprovenance: [String: AnyCodable]? public let systemprovenancereceipt: String? @@ -14011,6 +14016,7 @@ public struct ChatSendParams: Codable, Sendable { originatingaccountid: String? = nil, originatingthreadid: String? = nil, attachments: [AnyCodable]? = nil, + toolbindings: [String: AnyCodable]? = nil, timeoutms: Int? = nil, systeminputprovenance: [String: AnyCodable]? = nil, systemprovenancereceipt: String? = nil, @@ -14032,6 +14038,7 @@ public struct ChatSendParams: Codable, Sendable { self.originatingaccountid = originatingaccountid self.originatingthreadid = originatingthreadid self.attachments = attachments + self.toolbindings = toolbindings self.timeoutms = timeoutms self.systeminputprovenance = systeminputprovenance self.systemprovenancereceipt = systemprovenancereceipt @@ -14054,6 +14061,7 @@ public struct ChatSendParams: Codable, Sendable { originatingaccountid: String? = nil, originatingthreadid: String? = nil, attachments: [AnyCodable]? = nil, + toolbindings: [String: AnyCodable]? = nil, timeoutms: Int? = nil, systeminputprovenance: [String: AnyCodable]? = nil, systemprovenancereceipt: String? = nil, @@ -14076,6 +14084,7 @@ public struct ChatSendParams: Codable, Sendable { originatingaccountid: originatingaccountid, originatingthreadid: originatingthreadid, attachments: attachments, + toolbindings: toolbindings, timeoutms: timeoutms, systeminputprovenance: systeminputprovenance, systemprovenancereceipt: systemprovenancereceipt, @@ -14099,6 +14108,7 @@ public struct ChatSendParams: Codable, Sendable { case originatingaccountid = "originatingAccountId" case originatingthreadid = "originatingThreadId" case attachments + case toolbindings = "toolBindings" case timeoutms = "timeoutMs" case systeminputprovenance = "systemInputProvenance" case systemprovenancereceipt = "systemProvenanceReceipt" diff --git a/config/knip.config.ts b/config/knip.config.ts index c774b2067a8f..3b36a0c0c8ee 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -576,6 +576,10 @@ const config = { // Chrome manifest/package scripts load these without TypeScript imports. "chrome-extension/background.js!", "chrome-extension/popup.js!", + "chrome-extension/sidepanel.js!", + "scripts/build-copilot-runtime.mjs!", + // esbuild receives this browser bootstrap by an assembled path. + "scripts/copilot-runtime-entry.ts!", "scripts/copy-chrome-extension.mjs!", ]), [`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([ diff --git a/docs/docs_map.md b/docs/docs_map.md index 2ded9b97f891..80ccfa2d5ce7 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -9630,6 +9630,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: How it works - H2: Install and pair - H2: Use it + - H3: Tab copilot side panel - H2: Remote / cross-machine - H2: Diagnostics - H2: Security model diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 9e379fed11e6..de6cb9cfaa6f 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -91,12 +91,64 @@ openclaw config set browser.defaultProfile chrome - Revoke: click the button again, drag the tab out of the group, or dismiss Chrome's debugging banner. The agent loses access to that tab immediately. +### Tab copilot side panel + +After pairing the extension, click **Open tab copilot** in its toolbar popup. +OpenClaw configures `sidepanel.html` for that exact Chrome tab; the manifest has +no global side-panel path. Each tab therefore gets a separate panel document, +Gateway session, message subscription, and typed browser-tool binding. + +The panel does not place the page URL, title, DOM, or visible text in your +message. It sends only the text you type. Browser actions carry a separate +Gateway-authenticated binding containing the Chrome tab and CDP target, and the +browser tool rejects attempts to replace that target or use browser-wide +actions. Replies stay in the panel (`deliver: false`); they do not inherit a +Telegram, Discord, or other channel route. + +The copilot is a dedicated paired Gateway device with `operator.read` and +`operator.write` scopes. On first use, inspect and approve its request: + +```bash +openclaw devices list +openclaw devices approve +``` + +The extension retains that device identity and the Gateway-issued device token, +scoped to the canonical Gateway endpoint that issued them. Pairing a different +Gateway creates separate identity, token, and session custody; credentials and +sessions are never reused across endpoints. The extension does not persist the +Gateway shared secret. A panel can subscribe only to its own tab sessions, and +the Gateway filters those events before delivery. + +If the Gateway connection drops during a run, the extension keeps durable +custody of that run ID. On reconnect it aborts the unresolved run before +re-enabling any panel, then reloads transcript history. This fail-closed step +prevents browser actions from continuing unseen across a delivery gap. + +Closing a tab immediately removes its live subscription, aborts any visible +run, and marks that tab's session archived. If the Gateway is temporarily +offline, the extension persists the pending archive and retries only when that +same Gateway endpoint reconnects; it never sends an archive request to a +different Gateway. After a browser crash, the next launch archives sessions +left by the previous browser instance. Archived sessions reject new work, while +their transcripts remain available in session history. Browser-copilot keys are +thread sessions, so normal age and entry-count maintenance preserves them. The +per-agent session disk budget still applies (default `2gb`) and may evict the +oldest sessions under pressure; see [session maintenance](/reference/session-management-compaction#store-maintenance-and-disk-controls). + +The side panel currently requires either a Gateway-hosted extension relay or a +direct remote Gateway relay. A loopback relay on a browser node cannot yet +provide the node route required by the typed tab binding, so the panel denies +that topology instead of falling back to browser-wide routing. + ## Remote / cross-machine Chrome does not have to run on the Gateway host. Three topologies work: - **Same host** (Gateway + Chrome on one machine): pair on that machine with `openclaw browser extension pair`. The relay is loopback-only. + If the local Gateway uses TLS, pass its certificate hostname explicitly with + `--gateway-url wss://gateway-host.example`; pairing never substitutes a loopback IP. - **Direct to a remote Gateway** (Chrome on your laptop, Gateway on a VPS, and **nothing else on the laptop**): on the Gateway, run `openclaw browser extension pair --gateway-url wss://your-gateway.example.com`. @@ -134,6 +186,9 @@ extension popup shows **Connected**. the bundled extension carries it in the WebSocket subprotocol list instead. - The agent can only see and drive tabs in the **OpenClaw tab group**. Your other tabs stay private. +- Side-panel runs are scoped twice: Gateway delivery uses a per-session + allowlist, and browser tools enforce the Chrome tab/target binding carried + outside the prompt. - Compared with the `user` (Chrome MCP) profile, which exposes your whole signed-in browser once you approve the remote-debugging prompt, the extension keeps the shared surface scoped to a tab group you control at a glance. diff --git a/extensions/browser/chrome-extension/background.js b/extensions/browser/chrome-extension/background.js index c8200b12e7b1..5c3cae05522b 100644 --- a/extensions/browser/chrome-extension/background.js +++ b/extensions/browser/chrome-extension/background.js @@ -1,3 +1,4 @@ +import { createCopilotController } from "./modules/copilot-background.js"; // OpenClaw extension service worker. // // Thin transport between the OpenClaw extension relay (loopback WebSocket) and @@ -20,16 +21,29 @@ const BADGE = { on: { text: "ON", color: "#0F9D58" }, error: { text: "!", color: "#B91C1C" }, }; +const COPILOT_RELAY_LABEL = { + off: "Browser relay disconnected", + connecting: "Connecting to browser relay", + on: "Browser relay connected", + error: "Browser relay reconnecting", +}; /** @type {WebSocket|null} */ let relayWs = null; let relayState = "off"; // off | connecting | on | error +let copilot = null; let reconnectAttempt = 0; let reconnectTimer = null; /** Tab ids with an active chrome.debugger attachment. */ const attachedTabs = new Set(); +/** Tabs denied to every relay attach while copilot run cleanup is pending. */ +const copilotDeniedTabs = new Set(); +/** Monotonic revocation epochs invalidate attaches already in flight. */ +const copilotAccessRevisions = new Map(); /** In-flight attach promises per tab id (coalesces concurrent attaches). */ const attachingTabs = new Map(); +/** Latest revocation task per tab; restoration waits for its exact epoch. */ +const copilotRevocations = new Map(); /** Debounce handle for tab-list refreshes. */ let tabsSyncTimer = null; @@ -38,6 +52,10 @@ function setBadge(kind) { const cfg = BADGE[kind] ?? BADGE.off; void chrome.action.setBadgeText({ text: cfg.text }); void chrome.action.setBadgeBackgroundColor({ color: cfg.color }); + void copilot?.onRelayStatus({ + ready: kind === "on", + label: COPILOT_RELAY_LABEL[kind] ?? COPILOT_RELAY_LABEL.off, + }); } async function getConfig() { @@ -49,6 +67,15 @@ async function getConfig() { }; } +async function getCopilotConfig() { + const config = await getConfig(); + const stored = await chrome.storage.local.get(["gatewayUrl"]); + return { + ...config, + gatewayUrl: typeof stored.gatewayUrl === "string" ? stored.gatewayUrl : "", + }; +} + // --------------------------------------------------------------------------- // Tab group management (the consent boundary) // --------------------------------------------------------------------------- @@ -100,6 +127,18 @@ async function isTabShared(tabId) { return shared.some((tab) => tab.id === tabId); } +async function isOpenClawGroupId(groupId) { + if (!Number.isInteger(groupId) || groupId < 0) { + return false; + } + try { + const group = await chrome.tabGroups.get(groupId); + return group.title === OPENCLAW_TAB_GROUP_TITLE; + } catch { + return false; + } +} + function scheduleTabsSync() { if (tabsSyncTimer) { return; @@ -131,9 +170,7 @@ async function syncTabsToRelay() { // --------------------------------------------------------------------------- async function attachDebugger(tabId) { - if (!(await isTabShared(tabId))) { - throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`); - } + await copilotCustodyReady; // Coalesce concurrent attaches for one tab. Two relay attach commands (or an // auto-attach racing an explicit share) would otherwise both call // chrome.debugger.attach and the second throws "Another debugger is already @@ -142,7 +179,21 @@ async function attachDebugger(tabId) { if (inFlight) { return await inFlight; } + const accessRevision = copilotAccessRevisions.get(tabId) ?? 0; + const assertAccess = () => { + if ( + copilotDeniedTabs.has(tabId) || + (copilotAccessRevisions.get(tabId) ?? 0) !== accessRevision + ) { + throw new Error(`tab ${tabId} is blocked until its copilot run stops`); + } + }; const attach = (async () => { + assertAccess(); + if (!(await isTabShared(tabId))) { + throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`); + } + assertAccess(); if (!attachedTabs.has(tabId)) { try { await chrome.debugger.attach({ tabId }, "1.3"); @@ -152,9 +203,21 @@ async function attachDebugger(tabId) { throw err; } } + try { + assertAccess(); + } catch (error) { + await detachDebugger(tabId); + throw error; + } attachedTabs.add(tabId); } const targets = await chrome.debugger.getTargets(); + try { + assertAccess(); + } catch (error) { + await detachDebugger(tabId); + throw error; + } const target = targets.find((candidate) => candidate.tabId === tabId && candidate.attached); return { targetId: target?.id ?? `tab-${tabId}` }; })(); @@ -167,6 +230,8 @@ async function attachDebugger(tabId) { } async function detachDebugger(tabId) { + // Always call Chrome: an attach can complete before attachedTabs records it. + // The unconditional detach closes that revocation race. attachedTabs.delete(tabId); try { await chrome.debugger.detach({ tabId }); @@ -175,6 +240,34 @@ async function detachDebugger(tabId) { } } +async function revokeCopilotDebugger(tabId) { + copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1); + copilotDeniedTabs.add(tabId); + const previous = copilotRevocations.get(tabId) ?? Promise.resolve(); + const revocation = previous + .catch(() => undefined) + .then(async () => { + await Promise.allSettled([attachingTabs.get(tabId)]); + await detachDebugger(tabId); + }); + copilotRevocations.set(tabId, revocation); + try { + await revocation; + } finally { + if (copilotRevocations.get(tabId) === revocation) { + copilotRevocations.delete(tabId); + } + } +} + +async function restoreCopilotDebugger(tabId) { + const accessRevision = copilotAccessRevisions.get(tabId) ?? 0; + await copilotRevocations.get(tabId); + if ((copilotAccessRevisions.get(tabId) ?? 0) === accessRevision) { + copilotDeniedTabs.delete(tabId); + } +} + chrome.debugger.onEvent.addListener((source, method, params) => { if (typeof source.tabId !== "number") { return; @@ -329,6 +422,19 @@ async function connectRelay() { // onclose follows onerror and drives the reconnect, so no error handler needed. } +copilot = createCopilotController({ + getConfig: getCopilotConfig, + isTabShared, + addTabToOpenClawGroup, + attachDebugger, + detachDebugger, + revokeDebugger: revokeCopilotDebugger, + restoreDebugger: restoreCopilotDebugger, + scheduleTabsSync, +}); +const copilotCustodyReady = copilot.initializeCustody(); +const copilotReady = copilot.initialize(); + function scheduleReconnect() { if (reconnectTimer) { return; @@ -372,15 +478,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { reconnectAttempt = 0; relayWs?.close(); relayWs = null; + await chrome.storage.local.set({ gatewayUrl: parsed.gatewayUrl ?? "" }); await connectRelay(); + await copilot.refreshConfig(); sendResponse({ ok: true }); return; } case "unpair": { - await chrome.storage.local.remove(["relayUrl", "token"]); + await chrome.storage.local.remove(["relayUrl", "gatewayUrl", "token"]); relayWs?.close(); relayWs = null; setBadge("off"); + await copilot.refreshConfig(); sendResponse({ ok: true }); return; } @@ -400,12 +509,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { scheduleTabsSync(); sendResponse({ ok: true, shared: true }); } + await copilot.onConsentChanged(); return; } case "isTabShared": { sendResponse({ shared: await isTabShared(msg.tabId) }); return; } + case "prepareCopilotPanel": { + const options = await copilot.preparePanel(msg.tabId); + sendResponse({ ok: true, ...options }); + return; + } default: sendResponse({ ok: false, error: "unknown message" }); } @@ -414,20 +529,44 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }); chrome.tabs.onRemoved.addListener((tabId) => { + copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1); attachedTabs.delete(tabId); + copilotDeniedTabs.delete(tabId); scheduleTabsSync(); + void copilot.onTabRemoved(tabId); +}); +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + scheduleTabsSync(); + if (typeof changeInfo.groupId !== "number") { + void copilot.onConsentChanged(tabId); + return; + } + // changeInfo.groupId is the event-time membership snapshot. Preserve a + // revocation even if a later event re-shares the tab before async cleanup. + void isOpenClawGroupId(changeInfo.groupId).then((shared) => + copilot.onConsentChanged(tabId, { revoked: !shared }), + ); +}); +chrome.tabGroups.onUpdated.addListener(() => { + scheduleTabsSync(); + void copilot.onConsentChanged(); +}); +chrome.tabGroups.onRemoved.addListener(() => { + scheduleTabsSync(); + void copilot.onConsentChanged(); }); -chrome.tabs.onUpdated.addListener(() => scheduleTabsSync()); -chrome.tabGroups.onUpdated.addListener(() => scheduleTabsSync()); -chrome.tabGroups.onRemoved.addListener(() => scheduleTabsSync()); // Watchdog: MV3 can stop this worker; the alarm revives it and re-connects. chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "openclaw-relay-watchdog") { void connectRelay(); + void copilot.drainAborts(); + void copilot.drainArchives(); + void copilot.drainStaleScopes(); } }); chrome.runtime.onStartup.addListener(() => void connectRelay()); chrome.runtime.onInstalled.addListener(() => void connectRelay()); void connectRelay(); +void copilotReady; diff --git a/extensions/browser/chrome-extension/manifest.json b/extensions/browser/chrome-extension/manifest.json index 28842a197af0..b5d5ff349098 100644 --- a/extensions/browser/chrome-extension/manifest.json +++ b/extensions/browser/chrome-extension/manifest.json @@ -9,7 +9,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms"], + "permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms", "sidePanel"], "background": { "service_worker": "background.js", "type": "module" }, "action": { "default_title": "OpenClaw", diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts b/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts new file mode 100644 index 000000000000..657af7514647 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-background-shared.d.ts @@ -0,0 +1,34 @@ +import type { + CopilotArchiveEntry, + CopilotPanelBindingRegistry, +} from "./copilot-session-registry.js"; +import type { BrowserCopilotBinding } from "./panel-core.js"; + +export const PANEL_PATH: string; + +export function resolveSidePanelTabId( + chromeApi: unknown, + port: unknown, + panelBindings: Pick, +): Promise; + +export function archiveCopilotSession( + gateway: { + request(method: string, params: Record): Promise; + }, + entry: CopilotArchiveEntry, +): Promise; + +export function selectCopilotPanelState(options: { + paired: boolean; + shared: boolean; + abortPending: boolean; + gatewayState: string; +}): string; + +export function sessionKeyFromEvent(event: unknown): string | null; +export function resolveBindingTarget(config: { + relayUrl: string; + gatewayUrl: string; +}): BrowserCopilotBinding["target"]; +export function safeTabLabel(tab: { url?: string }): string; diff --git a/extensions/browser/chrome-extension/modules/copilot-background-shared.js b/extensions/browser/chrome-extension/modules/copilot-background-shared.js new file mode 100644 index 000000000000..2ec8fe9d5559 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-background-shared.js @@ -0,0 +1,126 @@ +const PANEL_PATH = "sidepanel.html"; + +function parsePanelBindingUrl(chromeApi, raw) { + let url; + try { + url = new URL(raw); + } catch { + return null; + } + const token = url.searchParams.get("binding"); + if ( + url.protocol !== "chrome-extension:" || + url.host !== chromeApi.runtime.id || + !url.pathname.endsWith(`/${PANEL_PATH}`) || + !token || + [...url.searchParams].length !== 1 || + url.hash + ) { + return null; + } + return { token, url: url.toString() }; +} + +export async function resolveSidePanelTabId(chromeApi, port, panelBindings) { + const binding = parsePanelBindingUrl(chromeApi, port.sender?.url); + if (!binding) { + throw new Error("Copilot is available only in a tab-specific side panel."); + } + const tabId = await panelBindings.resolve(binding.token); + if (!Number.isInteger(tabId) || tabId < 0) { + throw new Error("This panel does not hold a live tab binding."); + } + const contexts = await chromeApi.runtime.getContexts({ + contextTypes: ["SIDE_PANEL"], + }); + const documentId = port.sender?.documentId; + // Chrome reports tabId=-1 for SIDE_PANEL contexts. The unguessable URL maps + // to the tab; this live-context check prevents a normal extension page from claiming it. + const context = contexts.find( + (candidate) => + candidate.contextType === "SIDE_PANEL" && + candidate.documentUrl === binding.url && + (typeof documentId !== "string" || candidate.documentId === documentId), + ); + if (!context) { + throw new Error("Chrome did not bind this panel to a tab."); + } + return tabId; +} + +export async function archiveCopilotSession(gateway, entry) { + if (entry.ensureCreated) { + // The worker may have stopped after persisting creation intent but before + // sending it. sessions.create adopts the same key, making cleanup idempotent. + await gateway.request("sessions.create", { + key: entry.sessionKey, + label: "Browser copilot", + }); + } + try { + await gateway.request("sessions.messages.unsubscribe", { key: entry.sessionKey }); + } catch { + // The allowlist is connection-local. A closed socket already stopped delivery. + } + try { + await gateway.request("sessions.abort", { key: entry.sessionKey }); + } catch { + // Archive is authoritative; it will reject while a run is still active and retry later. + } + await gateway.request("sessions.patch", { key: entry.sessionKey, archived: true }); +} + +export function selectCopilotPanelState({ paired, shared, abortPending, gatewayState }) { + if (!paired) { + return "needs-pairing"; + } + if (!shared) { + return "needs-sharing"; + } + return abortPending ? "reconciling" : gatewayState; +} + +export function sessionKeyFromEvent(event) { + const payload = event?.payload; + if (!payload || typeof payload !== "object") { + return null; + } + return typeof payload.sessionKey === "string" ? payload.sessionKey : null; +} + +function isLoopbackUrl(raw) { + try { + const host = new URL(raw).hostname.toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "[::1]"; + } catch { + return false; + } +} + +export function resolveBindingTarget(config) { + try { + const relay = new URL(config.relayUrl); + if (relay.pathname.endsWith("/browser/extension")) { + return "host"; + } + if (isLoopbackUrl(config.relayUrl) && isLoopbackUrl(config.gatewayUrl)) { + return "host"; + } + } catch { + // Fall through to the explicit topology denial below. + } + throw new Error( + "Copilot needs a direct Gateway relay. Browser-node routing is not yet supported.", + ); +} + +export function safeTabLabel(tab) { + try { + const url = new URL(tab.url ?? ""); + return url.hostname || url.protocol.replace(":", "") || "Browser tab"; + } catch { + return "Browser tab"; + } +} + +export { PANEL_PATH }; diff --git a/extensions/browser/chrome-extension/modules/copilot-background.d.ts b/extensions/browser/chrome-extension/modules/copilot-background.d.ts new file mode 100644 index 000000000000..959c6d40d615 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-background.d.ts @@ -0,0 +1,15 @@ +import type { CopilotSessionRegistry } from "./copilot-session-registry.js"; + +export function createCopilotController(options: Record): { + initializeCustody(): Promise; + initialize(): Promise; + preparePanel(tabId: number): Promise<{ path: string }>; + onConsentChanged(changedTabId?: number, options?: { revoked?: boolean }): Promise; + onRelayStatus(status: { ready: boolean; label?: string }): Promise; + onTabRemoved(tabId: number): Promise; + refreshConfig(): Promise; + drainAborts(gatewayScope?: string | null): Promise; + drainArchives(gatewayScope?: string | null): Promise; + drainStaleScopes(): Promise; + registry: CopilotSessionRegistry; +}; diff --git a/extensions/browser/chrome-extension/modules/copilot-background.js b/extensions/browser/chrome-extension/modules/copilot-background.js new file mode 100644 index 000000000000..610a1779a1bd --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-background.js @@ -0,0 +1,730 @@ +import { + PANEL_PATH, + resolveBindingTarget, + resolveSidePanelTabId, + safeTabLabel, + selectCopilotPanelState, + sessionKeyFromEvent, +} from "./copilot-background-shared.js"; +import { CopilotGatewayClient } from "./copilot-gateway.js"; +import { createCopilotRecoveryController } from "./copilot-recovery.js"; +import { createCopilotRelayCustodyController } from "./copilot-relay-custody.js"; +import { CopilotPanelBindingRegistry, CopilotSessionRegistry } from "./copilot-session-registry.js"; +import { createCopilotSessionController } from "./copilot-session.js"; +import { gatewayUrlFromPairing } from "./panel-core.js"; + +const PANEL_PORT = "openclaw-copilot-panel"; + +/** Background-owned session custody for all tab-specific panel documents. */ +export function createCopilotController({ + chromeApi = chrome, + getConfig, + isTabShared, + addTabToOpenClawGroup, + attachDebugger, + revokeDebugger, + restoreDebugger, + scheduleTabsSync, + gateway = new CopilotGatewayClient(), + recoveryGatewayFactory = () => new CopilotGatewayClient(), +}) { + const registry = new CopilotSessionRegistry(chromeApi.storage); + const panelBindings = new CopilotPanelBindingRegistry(chromeApi.storage.session); + const portsByTab = new Map(); + const subscribedKeys = new Set(); + const sendsByTab = new Set(); + const ensureByTab = new Map(); + const suspendByTab = new Map(); + const tabRevisions = new Map(); + const portRevisions = new Map(); + const consentRevisions = new Map(); + const consentByTab = new Map(); + const historyTimers = new Map(); + let gatewayStatus = { state: "off", label: "Pair the extension first" }; + let currentConfig = null; + let gatewayRevision = 0; + let gatewayStatusRevision = 0; + let reconciledGatewayStatusRevision = 0; + let lastReadyStatus = null; + let custodyInitialized = null; + let initialized = null; + let lifecycleChain = Promise.resolve(); + let pendingGatewayRevocation = Promise.resolve(); + let configTransitioning = false; + + const { + abortEntry, + clearAbortRetry, + drainAborts, + drainArchives, + drainStaleScopes, + reconcileGatewayReady, + scheduleAbortRetry, + scheduleStaleRecovery, + } = createCopilotRecoveryController({ + gateway, + recoveryGatewayFactory, + registry, + subscribedKeys, + sendsByTab, + currentGatewayScope, + getGatewayStatus: () => gatewayStatus, + getGatewayStatusRevision: () => gatewayStatusRevision, + getLastReadyStatus: () => lastReadyStatus, + isConfigTransitioning: () => configTransitioning, + setReconciledGatewayStatus: (status, revision) => { + gatewayStatus = status; + reconciledGatewayStatusRevision = revision; + }, + restoreDebuggerIfReleased, + broadcastTab, + broadcastStatus, + refreshPanelState, + runLifecycle, + }); + + const relayCustody = createCopilotRelayCustodyController({ + appendGatewayRevocation: (revocation) => { + const previousRevocation = pendingGatewayRevocation; + pendingGatewayRevocation = Promise.allSettled([previousRevocation, revocation]).then( + () => undefined, + ); + }, + broadcastStatus, + currentGatewayScope, + drainAborts, + getGatewayStatus: () => gatewayStatus, + invalidateGatewayEpoch: () => { + gatewayRevision += 1; + }, + markGatewayAbortError: () => { + reconciledGatewayStatusRevision = 0; + gatewayStatus = { state: "error", label: "Could not stop the previous tab run" }; + }, + registry, + revokeActiveBindings, + runLifecycle, + }); + + const { ensureSession, sendMessage } = createCopilotSessionController({ + chromeApi, + gateway, + registry, + ensureByTab, + tabRevisions, + portsByTab, + portRevisions, + sendsByTab, + currentGatewayScope, + getGatewayRevision: () => gatewayRevision, + getCurrentConfig: () => currentConfig, + isConfigTransitioning: () => configTransitioning, + currentReadyEpoch, + readyEpochIsCurrent, + isTabShared, + attachDebugger, + revokeDebugger, + restoreDebuggerIfReleased, + subscribe, + unsubscribeTab, + suspendTab, + hydrate, + refreshPanelState, + drainArchives, + scheduleAbortRetry, + }); + + async function initializeCustody() { + if (custodyInitialized) { + return await custodyInitialized; + } + custodyInitialized = (async () => { + const tabs = await chromeApi.tabs.query({}); + await registry.initialize( + new Set(tabs.map((tab) => tab.id).filter((tabId) => typeof tabId === "number")), + ); + await panelBindings.initialize(); + const activeScopes = new Set( + registry + .list() + .filter((entry) => entry.activeRunId) + .map((entry) => entry.gatewayScope), + ); + // MV3 can discard process memory mid-run. Rebuild the debugger deny set + // from durable run custody before relay attachments can resume. + await Promise.allSettled([...activeScopes].map((scope) => revokeActiveBindings(scope))); + })(); + return await custodyInitialized; + } + + async function initialize() { + if (initialized) { + return await initialized; + } + initialized = (async () => { + await initializeCustody(); + await refreshConfig(); + })(); + return await initialized; + } + + function post(port, message) { + try { + port.postMessage(message); + } catch { + // Panel closed between the state read and delivery. + } + } + + function broadcastTab(tabId, message) { + for (const port of portsByTab.get(tabId) ?? []) { + post(port, message); + } + } + + function broadcastStatus(options) { + for (const tabId of portsByTab.keys()) { + void refreshPanelState(tabId, options); + } + } + + function currentGatewayScope() { + return typeof currentConfig?.gatewayUrl === "string" ? currentConfig.gatewayUrl : null; + } + + function currentPanelStatus() { + return relayCustody.currentPanelStatus(); + } + + async function restoreDebuggerIfReleased(tabId) { + if (registry.list().some((entry) => entry.tabId === tabId && entry.activeRunId)) { + return; + } + await restoreDebugger(tabId); + } + + function currentReadyEpoch() { + const gatewayScope = currentGatewayScope(); + if ( + !gatewayScope || + configTransitioning || + !relayCustody.isOperational() || + !gateway.ready || + gatewayStatus.state !== "ready" || + reconciledGatewayStatusRevision !== gatewayStatusRevision + ) { + return null; + } + return { + gatewayScope, + configRevision: gatewayRevision, + statusRevision: gatewayStatusRevision, + }; + } + + function readyEpochIsCurrent(epoch) { + return ( + epoch?.gatewayScope === currentGatewayScope() && + epoch.configRevision === gatewayRevision && + epoch.statusRevision === gatewayStatusRevision && + reconciledGatewayStatusRevision === epoch.statusRevision && + !configTransitioning && + relayCustody.isOperational() && + gateway.ready && + gatewayStatus.state === "ready" + ); + } + + async function applyConfig() { + const nextConfig = await getConfig(); + const nextGatewayScope = gatewayUrlFromPairing(nextConfig.relayUrl, nextConfig.gatewayUrl); + const previousGatewayScope = currentGatewayScope(); + if (!previousGatewayScope) { + const staleScopes = registry.gatewayScopes().filter((scope) => scope !== nextGatewayScope); + if (staleScopes.length > 0) { + for (const staleScope of staleScopes) { + await registry.closeInactiveScope(staleScope); + } + scheduleStaleRecovery(); + } + } + if (previousGatewayScope && previousGatewayScope !== nextGatewayScope) { + configTransitioning = true; + clearAbortRetry(); + lastReadyStatus = null; + gatewayStatusRevision += 1; + reconciledGatewayStatusRevision = 0; + gatewayRevision += 1; + gatewayStatus = { state: "connecting", label: "Changing Gateway" }; + broadcastStatus(); + let needsStaleRecovery = false; + try { + await revokeActiveBindings(previousGatewayScope); + await Promise.allSettled([...ensureByTab.values()].map((entry) => entry.promise)); + await drainAborts(previousGatewayScope); + const hasPendingAborts = registry.pendingAborts(previousGatewayScope).length > 0; + if (hasPendingAborts) { + await registry.closeInactiveScope(previousGatewayScope); + } else { + await registry.closeScope(previousGatewayScope); + } + await drainArchives(previousGatewayScope); + needsStaleRecovery = + hasPendingAborts || registry.pendingArchives(previousGatewayScope).length > 0; + } catch { + // The next Gateway may start, but old-scope custody remains denied and + // the recovery client owns cleanup. Never strand the controller mid-switch. + needsStaleRecovery = true; + } finally { + gateway.stop(); + sendsByTab.clear(); + subscribedKeys.clear(); + configTransitioning = false; + } + if (needsStaleRecovery) { + scheduleStaleRecovery(); + } + } + currentConfig = { ...nextConfig, gatewayUrl: nextGatewayScope }; + configTransitioning = false; + if (!currentConfig.relayUrl || !nextGatewayScope) { + gateway.stop(); + gatewayStatus = { + state: "off", + label: currentConfig.relayUrl + ? "Pair again to add the Gateway endpoint" + : "Pair the extension first", + }; + broadcastStatus(); + return; + } + try { + resolveBindingTarget(currentConfig); + } catch (error) { + clearAbortRetry(); + lastReadyStatus = null; + gatewayStatusRevision += 1; + await registry.closeScope(nextGatewayScope); + await drainArchives(nextGatewayScope); + gateway.stop(); + gatewayStatus = { state: "denied", label: error.message }; + broadcastStatus(); + return; + } + gateway.start(nextGatewayScope); + } + + function runLifecycle(task) { + const pending = lifecycleChain.then(task); + lifecycleChain = pending.catch(() => undefined); + return pending; + } + + function refreshConfig() { + // Config changes and stale-scope recovery share one owner. Otherwise a + // scope can become current while a recovery client is still destroying it. + return runLifecycle(applyConfig); + } + + async function refreshPanelState( + tabId, + { shared: knownShared, ensureSetup = false, hydrateHistory = false, suspended = false } = {}, + ) { + let tab; + try { + tab = await chromeApi.tabs.get(tabId); + } catch { + return; + } + const shared = typeof knownShared === "boolean" ? knownShared : await isTabShared(tabId); + const entry = registry.get(tabId, currentGatewayScope()); + const panelStatus = currentPanelStatus(); + const state = selectCopilotPanelState({ + paired: Boolean(currentConfig?.relayUrl), + shared, + abortPending: Boolean(entry?.abortPending), + gatewayState: panelStatus.state, + }); + const panelState = { + type: "panel.state", + state, + label: + state === "needs-sharing" + ? "Share this tab before the copilot can act" + : state === "reconciling" + ? "Stopping the previous tab run" + : panelStatus.label, + requestId: panelStatus.requestId, + tab: { + title: typeof tab.title === "string" ? tab.title : "", + url: typeof tab.url === "string" ? tab.url : "", + label: safeTabLabel(tab), + }, + sessionKey: entry?.sessionKey, + }; + if (!shared) { + broadcastTab(tabId, panelState); + if (!suspended) { + await suspendTab(tabId, { detachInactive: true }); + } + return; + } + if (state !== "ready") { + broadcastTab(tabId, panelState); + return; + } + const needsSetup = + ensureSetup || !entry || !subscribedKeys.has(entry.sessionKey) || !entry.binding; + if (!needsSetup) { + broadcastTab(tabId, panelState); + return; + } + broadcastTab(tabId, { + ...panelState, + state: "connecting", + label: "Preparing this tab", + }); + try { + const prepared = await ensureSession(tabId, { hydrateHistory }); + if (prepared) { + await refreshPanelState(tabId, { shared: await isTabShared(tabId) }); + } + } catch (error) { + broadcastTab(tabId, { + ...panelState, + state: "error", + label: error?.message || "Could not prepare this tab", + }); + } + } + + async function subscribe(entry) { + if (subscribedKeys.has(entry.sessionKey)) { + return; + } + await gateway.request("sessions.messages.subscribe", { key: entry.sessionKey }); + subscribedKeys.add(entry.sessionKey); + } + + async function unsubscribeTab(tabId, gatewayScope = currentGatewayScope()) { + const entry = registry.get(tabId, gatewayScope); + if (!entry || !subscribedKeys.delete(entry.sessionKey)) { + return; + } + try { + await gateway.request("sessions.messages.unsubscribe", { key: entry.sessionKey }); + } catch { + // Socket closure also clears the server-owned allowlist. + } + } + + async function suspendTab(tabId, { expectedPortRevision, detachInactive = false } = {}) { + if (expectedPortRevision !== undefined && portRevisions.get(tabId) !== expectedPortRevision) { + return; + } + const gatewayScope = currentGatewayScope(); + const entry = registry.get(tabId, gatewayScope); + // Revoke local delivery and CDP access before any fallible Gateway RPC. + const unsubscribing = unsubscribeTab(tabId, gatewayScope); + const detaching = entry?.activeRunId + ? revokeDebugger(tabId) + : detachInactive + ? revokeDebugger(tabId).then(() => restoreDebuggerIfReleased(tabId)) + : Promise.resolve(); + const queued = await registry.queueAbort(tabId, gatewayScope); + sendsByTab.delete(tabId); + await Promise.allSettled([unsubscribing, detaching]); + if (queued && gateway.ready) { + await abortEntry(queued); + } + } + + function scheduleSuspend(tabId, portRevision) { + const pending = suspendTab(tabId, { expectedPortRevision: portRevision }).finally(() => { + if (suspendByTab.get(tabId) === pending) { + suspendByTab.delete(tabId); + } + }); + suspendByTab.set(tabId, pending); + return pending; + } + + async function hydrate(tabId, entry = registry.get(tabId, currentGatewayScope())) { + if (!entry || !portsByTab.has(tabId)) { + return; + } + try { + const history = await gateway.request("chat.history", { + sessionKey: entry.sessionKey, + limit: 200, + }); + broadcastTab(tabId, { + type: "panel.history", + sessionKey: entry.sessionKey, + messages: Array.isArray(history?.messages) ? history.messages : [], + }); + } catch (error) { + broadcastTab(tabId, { type: "panel.error", message: error.message }); + } + } + + function scheduleHydrate(tabId) { + if (historyTimers.has(tabId)) { + return; + } + historyTimers.set( + tabId, + setTimeout(() => { + historyTimers.delete(tabId); + void hydrate(tabId); + }, 100), + ); + } + + async function shareTab(tabId) { + await addTabToOpenClawGroup(tabId); + scheduleTabsSync(); + await refreshPanelState(tabId); + } + + async function onTabRemoved(tabId) { + tabRevisions.set(tabId, (tabRevisions.get(tabId) ?? 0) + 1); + consentRevisions.set(tabId, (consentRevisions.get(tabId) ?? 0) + 1); + await initialize(); + portsByTab.delete(tabId); + portRevisions.set(tabId, (portRevisions.get(tabId) ?? 0) + 1); + sendsByTab.delete(tabId); + const timer = historyTimers.get(tabId); + if (timer) { + clearTimeout(timer); + historyTimers.delete(tabId); + } + try { + await ensureByTab.get(tabId)?.promise; + } catch { + // Closing the tab still owns cleanup when a concurrent session setup failed. + } + await registry.closeTab(tabId); + await panelBindings.remove(tabId); + await drainArchives(currentGatewayScope()); + } + + async function onConsentChanged(changedTabId, { revoked = false } = {}) { + await initialize(); + const tabIds = + typeof changedTabId === "number" + ? portsByTab.has(changedTabId) || + registry.list().some((entry) => entry.tabId === changedTabId) + ? [changedTabId] + : [] + : [...new Set([...portsByTab.keys(), ...registry.list().map((entry) => entry.tabId)])]; + await Promise.all( + tabIds.map((tabId) => { + const revision = (consentRevisions.get(tabId) ?? 0) + 1; + consentRevisions.set(tabId, revision); + const previous = consentByTab.get(tabId) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(async () => { + // Event-time revocation is sticky even if a later update observes + // the tab re-shared. CDP must detach for the revoked interval. + if (revoked) { + await suspendTab(tabId, { detachInactive: true }); + } + if (consentRevisions.get(tabId) !== revision) { + return; + } + let shared = false; + try { + shared = await isTabShared(tabId); + } catch { + // Missing tab state is treated as revoked consent. + } + if (!shared) { + await suspendTab(tabId, { detachInactive: true }); + } + if (consentRevisions.get(tabId) !== revision) { + return; + } + try { + shared = await isTabShared(tabId); + } catch { + shared = false; + } + if (consentRevisions.get(tabId) !== revision) { + return; + } + if (shared) { + await restoreDebuggerIfReleased(tabId); + } + await refreshPanelState(tabId, { shared, suspended: !shared }); + }) + .finally(() => { + if (consentByTab.get(tabId) === pending) { + consentByTab.delete(tabId); + } + }); + consentByTab.set(tabId, pending); + return pending; + }), + ); + } + + async function preparePanel(tabId) { + if (!Number.isInteger(tabId)) { + throw new Error("No active tab."); + } + await chromeApi.tabs.get(tabId); + const binding = await panelBindings.bind(tabId); + return { path: `${PANEL_PATH}?binding=${encodeURIComponent(binding)}` }; + } + + async function connectPort(port) { + await initialize(); + let tabId; + try { + tabId = await resolveSidePanelTabId(chromeApi, port, panelBindings); + } catch (error) { + post(port, { type: "panel.state", state: "denied", label: error.message }); + port.disconnect(); + return; + } + const ports = portsByTab.get(tabId) ?? new Set(); + ports.add(port); + portsByTab.set(tabId, ports); + const portRevision = (portRevisions.get(tabId) ?? 0) + 1; + portRevisions.set(tabId, portRevision); + port.onMessage.addListener((message) => { + void (async () => { + try { + if (message?.type === "panel.send") { + await sendMessage(tabId, port, portRevision, message.message); + } else if (message?.type === "panel.share") { + await shareTab(tabId); + } else if (message?.type === "panel.refresh") { + await refreshPanelState(tabId); + } + } catch (error) { + post(port, { type: "panel.error", message: error.message }); + } + })(); + }); + port.onDisconnect.addListener(() => { + ports.delete(port); + if (ports.size === 0) { + portsByTab.delete(tabId); + const disconnectedRevision = (portRevisions.get(tabId) ?? 0) + 1; + portRevisions.set(tabId, disconnectedRevision); + void scheduleSuspend(tabId, disconnectedRevision); + } + }); + await suspendByTab.get(tabId); + await refreshPanelState(tabId, { ensureSetup: true, hydrateHistory: true }); + } + + async function revokeActiveBindings(gatewayScope) { + const activeEntries = registry + .list() + .filter((entry) => entry.gatewayScope === gatewayScope && entry.activeRunId); + await Promise.allSettled([ + registry.queueActiveAborts(gatewayScope), + ...activeEntries.map((entry) => revokeDebugger(entry.tabId)), + ]); + } + + gateway.onStatus((status) => { + const statusRevision = ++gatewayStatusRevision; + // A new connection epoch owns its own abort retry timer. + clearAbortRetry(); + subscribedKeys.clear(); + if (status.state === "ready") { + const gatewayScope = currentGatewayScope(); + lastReadyStatus = status; + gatewayStatus = { state: "connecting", label: "Reconciling previous tab runs" }; + broadcastStatus(); + void runLifecycle(() => + reconcileGatewayReady(status, statusRevision, gatewayScope, pendingGatewayRevocation), + ).catch(() => { + if (gatewayScope === currentGatewayScope() && statusRevision === gatewayStatusRevision) { + gatewayStatus = { state: "error", label: "Could not reconcile previous tab runs" }; + broadcastStatus(); + } + }); + return; + } + reconciledGatewayStatusRevision = 0; + const gatewayScope = currentGatewayScope(); + if (gatewayScope) { + pendingGatewayRevocation = revokeActiveBindings(gatewayScope); + } else { + pendingGatewayRevocation = Promise.resolve(); + } + lastReadyStatus = null; + gatewayStatus = status; + broadcastStatus(); + }); + + gateway.onEvent((event) => { + const sessionKey = sessionKeyFromEvent(event); + if (!sessionKey) { + return; + } + for (const [tabId, ports] of portsByTab) { + const entry = registry.get(tabId, currentGatewayScope()); + if (entry?.sessionKey !== sessionKey || !subscribedKeys.has(sessionKey)) { + continue; + } + for (const port of ports) { + post(port, { type: "panel.event", event }); + } + const state = event.payload?.state; + if (event.event === "session.message") { + scheduleHydrate(tabId); + } + if ( + event.event === "chat" && + (state === "final" || state === "aborted" || state === "error") + ) { + const runId = event.payload?.runId; + if (typeof runId === "string" && entry.activeRunId === runId) { + const gatewayScope = currentGatewayScope(); + sendsByTab.delete(tabId); + scheduleHydrate(tabId); + if (gatewayScope) { + void registry + .finishRun(gatewayScope, entry.sessionKey, runId) + .then(async (finished) => { + if (finished) { + await restoreDebuggerIfReleased(tabId); + void refreshPanelState(tabId); + } + void drainArchives(gatewayScope); + }); + continue; + } + } + void drainArchives(); + } + } + }); + + chromeApi.runtime.onConnect.addListener((port) => { + if (port.name === PANEL_PORT) { + void connectPort(port); + } + }); + + return { + initializeCustody, + initialize, + preparePanel, + onConsentChanged, + onRelayStatus: (status) => relayCustody.onStatus(status), + onTabRemoved, + refreshConfig, + drainAborts, + drainArchives, + drainStaleScopes, + registry, + }; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-background.test.ts b/extensions/browser/chrome-extension/modules/copilot-background.test.ts new file mode 100644 index 000000000000..8c820a457311 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-background.test.ts @@ -0,0 +1,733 @@ +import { describe, expect, it, vi } from "vitest"; +import { + archiveCopilotSession, + resolveSidePanelTabId, + selectCopilotPanelState, +} from "./copilot-background-shared.js"; +import { createCopilotController } from "./copilot-background.js"; + +function eventHook() { + return { addListener: vi.fn() }; +} + +function storageArea(initial: Record = {}) { + const values = { ...initial }; + return { + get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))), + set: vi.fn(async (update: Record) => { + Object.assign(values, update); + }), + }; +} + +describe("browser copilot background", () => { + it("serializes config refreshes so a stale pairing cannot outlive unpair", async () => { + let resolveInitial: ((config: Record) => void) | undefined; + const getConfig = vi + .fn() + .mockImplementationOnce( + async () => + await new Promise>((resolve) => { + resolveInitial = resolve; + }), + ) + .mockResolvedValue({ relayUrl: "", gatewayUrl: "" }); + const gateway = { + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const storage = { local: storageArea(), session: storageArea() }; + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => []) }, + storage, + } as never, + getConfig, + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(), + restoreDebugger: vi.fn(), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + + const initializing = controller.initialize(); + await vi.waitFor(() => expect(getConfig).toHaveBeenCalledTimes(1)); + const unpairing = controller.refreshConfig(); + await Promise.resolve(); + expect(getConfig).toHaveBeenCalledTimes(1); + resolveInitial?.({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: "ws://127.0.0.1:18789", + }); + await Promise.all([initializing, unpairing]); + + expect(gateway.start).toHaveBeenCalledTimes(1); + const lastStop = Math.max(...gateway.stop.mock.invocationCallOrder); + expect(gateway.start.mock.invocationCallOrder[0]).toBeLessThan(lastStop); + }); + + it("serializes stale-scope destruction with config changes", async () => { + const oldScope = "ws://127.0.0.1:18789/"; + const newScope = "ws://127.0.0.1:28789/"; + let releaseRequest: (() => void) | undefined; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + const request = vi.fn(async () => { + await requestGate; + return { ok: true }; + }); + let reportRecoveryStatus: ((status: Record) => void) | undefined; + const recoveryGateway = { + onStatus: vi.fn((listener) => { + reportRecoveryStatus = listener; + return vi.fn(); + }), + request, + start: vi.fn(() => reportRecoveryStatus?.({ state: "ready" })), + stop: vi.fn(), + }; + const gateway = { + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const getConfig = vi + .fn() + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:28792/browser/extension", + gatewayUrl: newScope, + }) + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: oldScope, + }); + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 14 }]) }, + storage: { + local: storageArea({ + copilotSessionRegistryV1: { + sessions: { + 14: { + tabId: 14, + browserInstanceId: "browser-instance", + gatewayScope: oldScope, + sessionKey: "session-old", + activeRunId: "run-old", + }, + }, + pendingArchives: [], + }, + }), + session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), + }, + } as never, + getConfig, + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(async () => undefined), + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + recoveryGatewayFactory: () => recoveryGateway as never, + }); + await controller.initialize(); + + const recovery = controller.drainStaleScopes(); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + const reconfigure = controller.refreshConfig(); + await Promise.resolve(); + expect(getConfig).toHaveBeenCalledTimes(1); + + releaseRequest?.(); + await Promise.all([recovery, reconfigure]); + expect(getConfig).toHaveBeenCalledTimes(2); + expect(gateway.start).toHaveBeenLastCalledWith(oldScope); + }); + + it("gives the new Gateway epoch its own abort retry", async () => { + vi.useFakeTimers(); + try { + const oldScope = "ws://127.0.0.1:18789/"; + const newScope = "ws://127.0.0.1:28789/"; + const request = vi + .fn() + .mockRejectedValueOnce(new Error("old Gateway unavailable")) + .mockRejectedValueOnce(new Error("new Gateway unavailable")) + .mockResolvedValue({ ok: true }); + const gateway = { + ready: true, + onEvent: vi.fn(), + onStatus: vi.fn(), + request, + start: vi.fn(), + stop: vi.fn(), + }; + const getConfig = vi + .fn() + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: oldScope, + }) + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:28792/browser/extension", + gatewayUrl: newScope, + }); + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 1 }, { id: 2 }]) }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig, + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(async () => undefined), + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + await controller.registry.put(1, { gatewayScope: oldScope, sessionKey: "session-old" }); + await controller.registry.startRun(1, oldScope, "run-old"); + + await controller.refreshConfig(); + await controller.registry.put(2, { gatewayScope: newScope, sessionKey: "session-new" }); + await controller.registry.startRun(2, newScope, "run-new"); + await controller.registry.queueAbort(2, newScope); + await controller.drainAborts(newScope); + expect(request).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(250); + expect(request).toHaveBeenCalledTimes(3); + expect(controller.registry.pendingAborts(newScope)).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it("finishes ready reconciliation before switching Gateway clients", async () => { + const oldScope = "ws://127.0.0.1:18789/"; + const newScope = "ws://127.0.0.1:28789/"; + let reportStatus: ((status: Record) => void) | undefined; + let releaseAbort: (() => void) | undefined; + const abortGate = new Promise((resolve) => { + releaseAbort = resolve; + }); + const request = vi.fn(async () => { + await abortGate; + return { ok: true }; + }); + const gateway = { + ready: true, + onEvent: vi.fn(), + onStatus: vi.fn((listener) => { + reportStatus = listener; + }), + request, + start: vi.fn(), + stop: vi.fn(), + }; + const getConfig = vi + .fn() + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: oldScope, + }) + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:28792/browser/extension", + gatewayUrl: newScope, + }); + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 1 }]) }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig, + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(async () => undefined), + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + await controller.registry.put(1, { gatewayScope: oldScope, sessionKey: "session-old" }); + await controller.registry.startRun(1, oldScope, "run-old"); + + reportStatus?.({ state: "ready", label: "Connected" }); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + const reconfigure = controller.refreshConfig(); + await Promise.resolve(); + expect(getConfig).toHaveBeenCalledTimes(1); + + releaseAbort?.(); + await reconfigure; + expect(getConfig).toHaveBeenCalledTimes(2); + expect(gateway.start).toHaveBeenLastCalledWith(newScope); + }); + + it("does not strand the controller when old-scope storage cleanup fails", async () => { + const oldScope = "ws://127.0.0.1:18789/"; + const newScope = "ws://127.0.0.1:28789/"; + const gateway = { + ready: true, + onEvent: vi.fn(), + onStatus: vi.fn(), + request: vi.fn(async () => ({ ok: true })), + start: vi.fn(), + stop: vi.fn(), + }; + const getConfig = vi + .fn() + .mockResolvedValueOnce({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: oldScope, + }) + .mockResolvedValue({ + relayUrl: "ws://127.0.0.1:28792/browser/extension", + gatewayUrl: newScope, + }); + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => []) }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig, + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(async () => undefined), + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + vi.spyOn(controller.registry, "closeScope").mockRejectedValueOnce( + new Error("storage unavailable"), + ); + + await expect(controller.refreshConfig()).resolves.toBeUndefined(); + expect(gateway.stop).toHaveBeenCalled(); + expect(gateway.start).toHaveBeenLastCalledWith(newScope); + await expect(controller.refreshConfig()).resolves.toBeUndefined(); + }); + + it("processes an observed revocation before a later re-share", async () => { + const gatewayScope = "ws://127.0.0.1:18789/"; + const revokeDebugger = vi.fn(async () => undefined); + const gateway = { + ready: false, + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { + query: vi.fn(async () => [{ id: 12 }]), + get: vi.fn(async () => ({ id: 12, title: "Fixture", url: "https://example.test" })), + }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig: vi.fn(async () => ({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: gatewayScope, + })), + isTabShared: vi.fn(async () => true), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger, + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + await controller.registry.put(12, { gatewayScope, sessionKey: "session-12" }); + await controller.registry.startRun(12, gatewayScope, "run-12"); + + const revoked = controller.onConsentChanged(12, { revoked: true }); + const reshared = controller.onConsentChanged(12); + await Promise.all([revoked, reshared]); + + expect(revokeDebugger).toHaveBeenCalledWith(12); + expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ + expect.objectContaining({ activeRunId: "run-12", abortPending: true }), + ]); + }); + + it("keeps ordinary active runs visible and gates only abort reconciliation", () => { + expect( + selectCopilotPanelState({ + paired: true, + shared: true, + abortPending: false, + gatewayState: "ready", + }), + ).toBe("ready"); + expect( + selectCopilotPanelState({ + paired: true, + shared: true, + abortPending: true, + gatewayState: "ready", + }), + ).toBe("reconciling"); + }); + + it("revokes an active debugger binding as soon as the Gateway disconnects", async () => { + let reportStatus: ((status: Record) => void) | undefined; + const revokeDebugger = vi.fn(async () => undefined); + const gateway = { + ready: false, + onEvent: vi.fn(), + onStatus: vi.fn((listener) => { + reportStatus = listener; + }), + start: vi.fn(), + stop: vi.fn(), + }; + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 12 }]) }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig: vi.fn(async () => ({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: "ws://127.0.0.1:18789", + })), + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger, + restoreDebugger: vi.fn(), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + const gatewayScope = "ws://127.0.0.1:18789/"; + await controller.registry.put(12, { + gatewayScope, + sessionKey: "session-12", + }); + await controller.registry.startRun(12, gatewayScope, "run-12"); + + reportStatus?.({ state: "connecting", label: "Gateway reconnecting" }); + + await vi.waitFor(() => expect(revokeDebugger).toHaveBeenCalledWith(12)); + expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ + expect.objectContaining({ activeRunId: "run-12", abortPending: true }), + ]); + }); + + it("revokes and aborts active custody when the browser relay disconnects", async () => { + const gatewayScope = "ws://127.0.0.1:18789/"; + const revokeDebugger = vi.fn(async () => undefined); + const request = vi.fn(async () => ({ ok: true })); + const gateway = { + ready: true, + onEvent: vi.fn(), + onStatus: vi.fn(), + request, + start: vi.fn(), + stop: vi.fn(), + }; + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 12 }]) }, + storage: { local: storageArea(), session: storageArea() }, + } as never, + getConfig: vi.fn(async () => ({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: gatewayScope, + })), + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger, + restoreDebugger: vi.fn(async () => undefined), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + await controller.initialize(); + await controller.onRelayStatus({ ready: true, label: "Browser relay connected" }); + await controller.registry.put(12, { gatewayScope, sessionKey: "session-12" }); + await controller.registry.startRun(12, gatewayScope, "run-12"); + + await controller.onRelayStatus({ ready: false, label: "Browser relay reconnecting" }); + + expect(revokeDebugger).toHaveBeenCalledWith(12); + expect(request).toHaveBeenCalledWith("sessions.abort", { + key: "session-12", + runId: "run-12", + }); + expect(controller.registry.pendingAborts(gatewayScope)).toEqual([]); + }); + + it("restores durable debugger denial before a suspended worker reconnects", async () => { + const gatewayScope = "ws://127.0.0.1:18789/"; + const revokeDebugger = vi.fn(async () => undefined); + const gateway = { + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 12 }]) }, + storage: { + local: storageArea({ + copilotSessionRegistryV1: { + sessions: { + 12: { + tabId: 12, + browserInstanceId: "browser-instance", + gatewayScope, + sessionKey: "session-12", + activeRunId: "run-12", + }, + }, + pendingArchives: [], + }, + }), + session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), + }, + } as never, + getConfig: vi.fn(async () => ({ + relayUrl: "ws://127.0.0.1:18792/browser/extension", + gatewayUrl: "ws://127.0.0.1:18789", + })), + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger, + restoreDebugger: vi.fn(), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + + await controller.initialize(); + + expect(revokeDebugger).toHaveBeenCalledWith(12); + expect(controller.registry.pendingAborts(gatewayScope)).toEqual([ + expect.objectContaining({ activeRunId: "run-12", abortPending: true }), + ]); + }); + + it("starts the configured Gateway while cleaning a persisted old scope separately", async () => { + const oldScope = "ws://127.0.0.1:18789/"; + const request = vi.fn(async () => ({ ok: true })); + let reportRecoveryStatus: ((status: Record) => void) | undefined; + const recoveryGateway = { + onStatus: vi.fn((listener) => { + reportRecoveryStatus = listener; + return vi.fn(); + }), + request, + start: vi.fn(() => reportRecoveryStatus?.({ state: "ready" })), + stop: vi.fn(), + }; + const gateway = { + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const restoreDebugger = vi.fn(async () => undefined); + const controller = createCopilotController({ + chromeApi: { + runtime: { onConnect: eventHook() }, + tabs: { query: vi.fn(async () => [{ id: 14 }]) }, + storage: { + local: storageArea({ + copilotSessionRegistryV1: { + sessions: { + 14: { + tabId: 14, + browserInstanceId: "browser-instance", + gatewayScope: oldScope, + sessionKey: "session-old", + activeRunId: "run-old", + }, + }, + pendingArchives: [], + }, + }), + session: storageArea({ copilotBrowserInstanceV1: "browser-instance" }), + }, + } as never, + getConfig: vi.fn(async () => ({ + relayUrl: "ws://127.0.0.1:28792/browser/extension", + gatewayUrl: "ws://127.0.0.1:28789", + })), + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(async () => undefined), + restoreDebugger, + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + recoveryGatewayFactory: () => recoveryGateway as never, + }); + + await controller.initialize(); + + expect(gateway.start).toHaveBeenCalledWith("ws://127.0.0.1:28789/"); + expect(recoveryGateway.start).not.toHaveBeenCalled(); + await controller.drainStaleScopes(); + expect(recoveryGateway.start).toHaveBeenCalledWith(oldScope); + expect(request.mock.calls).toEqual([ + ["sessions.abort", { key: "session-old", runId: "run-old" }], + ["sessions.messages.unsubscribe", { key: "session-old" }], + ["sessions.abort", { key: "session-old" }], + ["sessions.patch", { key: "session-old", archived: true }], + ]); + expect(restoreDebugger).toHaveBeenCalledWith(14); + expect(controller.registry.gatewayScopes()).toEqual([]); + }); + + it("accepts only capability-bound live side-panel contexts", async () => { + const chromeApi = { + runtime: { + id: "extension-id", + getContexts: vi.fn(async () => [ + { + contextType: "SIDE_PANEL", + documentId: "doc-a", + documentUrl: "chrome-extension://extension-id/sidepanel.html?binding=cap-a", + tabId: -1, + }, + ]), + }, + }; + const panelBindings = { resolve: vi.fn(async (token) => (token === "cap-a" ? 12 : null)) }; + await expect( + resolveSidePanelTabId( + chromeApi as never, + { + sender: { + documentId: "doc-a", + url: "chrome-extension://extension-id/sidepanel.html?binding=cap-a", + }, + } as never, + panelBindings as never, + ), + ).resolves.toBe(12); + await expect( + resolveSidePanelTabId( + chromeApi as never, + { + sender: { + url: "chrome-extension://extension-id/sidepanel.html?binding=forged", + }, + } as never, + panelBindings as never, + ), + ).rejects.toThrow("live tab binding"); + }); + + it("prepares a unique tab-specific panel path without a global option", async () => { + vi.spyOn(crypto, "randomUUID").mockReturnValue("44444444-4444-4444-8444-444444444444"); + const gateway = { + onEvent: vi.fn(), + onStatus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const chromeApi = { + runtime: { onConnect: eventHook() }, + tabs: { get: vi.fn(async () => ({ id: 44 })) }, + storage: { local: storageArea(), session: storageArea() }, + }; + const controller = createCopilotController({ + chromeApi: chromeApi as never, + getConfig: vi.fn(), + isTabShared: vi.fn(), + addTabToOpenClawGroup: vi.fn(), + attachDebugger: vi.fn(), + detachDebugger: vi.fn(), + revokeDebugger: vi.fn(), + restoreDebugger: vi.fn(), + scheduleTabsSync: vi.fn(), + gateway: gateway as never, + }); + + await expect(controller.preparePanel(44)).resolves.toEqual({ + path: "sidepanel.html?binding=44444444-4444-4444-8444-444444444444", + }); + }); + + it("stops delivery and archives after aborting active work", async () => { + const request = vi.fn(async () => ({ ok: true })); + await archiveCopilotSession( + { request } as never, + { sessionKey: "session-7", sessionId: "id-7" } as never, + ); + expect(request.mock.calls).toEqual([ + ["sessions.messages.unsubscribe", { key: "session-7" }], + ["sessions.abort", { key: "session-7" }], + ["sessions.patch", { key: "session-7", archived: true }], + ]); + }); + + it("still attempts the authoritative archive when unsubscribe and abort fail", async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new Error("socket allowlist already gone")) + .mockRejectedValueOnce(new Error("no active run")) + .mockResolvedValueOnce({ ok: true }); + await expect( + archiveCopilotSession( + { request } as never, + { sessionKey: "session-8", sessionId: "id-8" } as never, + ), + ).resolves.toBeUndefined(); + expect(request).toHaveBeenLastCalledWith("sessions.patch", { + key: "session-8", + archived: true, + }); + }); + + it("replays ambiguous session creation before archiving its key", async () => { + const request = vi.fn(async () => ({ ok: true })); + await archiveCopilotSession( + { request } as never, + { sessionKey: "session-pending", ensureCreated: true } as never, + ); + expect(request.mock.calls).toEqual([ + ["sessions.create", { key: "session-pending", label: "Browser copilot" }], + ["sessions.messages.unsubscribe", { key: "session-pending" }], + ["sessions.abort", { key: "session-pending" }], + ["sessions.patch", { key: "session-pending", archived: true }], + ]); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts new file mode 100644 index 000000000000..2adcbf02b773 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.d.ts @@ -0,0 +1,47 @@ +type StorageArea = { + get(keys: string[]): Promise>; + set(update: Record): Promise; +}; + +type CopilotIdentity = { + deviceId: string; + publicKey: string; + sign(payload: string): Promise; +}; + +type TokenParams = { + clientId: string; + deviceId: string; + role: string; +}; + +type StoredToken = { + token: string; + scopes: string[]; +}; + +export function loadOrCreateCopilotIdentity( + storage: StorageArea, + gatewayScope: string, +): Promise; + +export function createCopilotTokenStore( + storage: StorageArea, + gatewayScope: string, +): { + load: (params: TokenParams) => Promise; + store: (params: TokenParams & StoredToken) => Promise; + clear: (params: TokenParams) => Promise; +}; + +export function resolveCopilotClose(context: { + connectFailure?: { + error?: { + details?: { code?: string; pauseReconnect?: boolean }; + }; + }; +}): { + retry: boolean; + notify: boolean; + pendingError: unknown; +}; diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js new file mode 100644 index 000000000000..eb63299a13d5 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-gateway-lifecycle.js @@ -0,0 +1,131 @@ +import { ed25519Utils, getPublicKeyAsync, signAsync } from "./copilot-runtime.js"; + +const IDENTITIES_KEY = "copilotDeviceIdentitiesV1"; +const TOKENS_KEY = "copilotDeviceTokensV1"; +// Main and stale-scope clients share one Chrome storage map. Serialize the +// full read-modify-write or a late client can erase another scope's credential. +const credentialStorageTails = new WeakMap(); + +function withCredentialStorage(storage, operation) { + const previous = credentialStorageTails.get(storage) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + credentialStorageTails.set(storage, tail); + return result.finally(() => { + if (credentialStorageTails.get(storage) === tail) { + credentialStorageTails.delete(storage); + } + }); +} + +function toBase64Url(bytes) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(value) { + const padded = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +} + +async function sha256Hex(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function loadOrCreateCopilotIdentity(storage, gatewayScope) { + return await withCredentialStorage(storage, async () => { + const identities = (await storage.get([IDENTITIES_KEY]))[IDENTITIES_KEY]; + const stored = identities?.[gatewayScope]; + if ( + typeof stored?.deviceId === "string" && + typeof stored?.publicKey === "string" && + typeof stored?.secretKey === "string" + ) { + const secretKey = fromBase64Url(stored.secretKey); + return { + deviceId: stored.deviceId, + publicKey: stored.publicKey, + sign: async (payload) => + toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)), + }; + } + const secretKey = ed25519Utils.randomSecretKey(); + const publicKeyBytes = await getPublicKeyAsync(secretKey); + const identity = { + deviceId: await sha256Hex(publicKeyBytes), + publicKey: toBase64Url(publicKeyBytes), + secretKey: toBase64Url(secretKey), + }; + await storage.set({ + [IDENTITIES_KEY]: { + ...(identities && typeof identities === "object" ? identities : {}), + [gatewayScope]: identity, + }, + }); + return { + deviceId: identity.deviceId, + publicKey: identity.publicKey, + sign: async (payload) => + toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)), + }; + }); +} + +function tokenKey(gatewayScope, { clientId, deviceId, role }) { + return `${gatewayScope}\n${clientId}:${deviceId}:${role}`; +} + +export function createCopilotTokenStore(storage, gatewayScope) { + return { + async load(params) { + return await withCredentialStorage(storage, async () => { + const tokens = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; + const record = tokens?.[tokenKey(gatewayScope, params)]; + return typeof record?.token === "string" && Array.isArray(record.scopes) ? record : null; + }); + }, + async store(params) { + await withCredentialStorage(storage, async () => { + const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; + const tokens = current && typeof current === "object" ? { ...current } : {}; + tokens[tokenKey(gatewayScope, params)] = { + token: params.token, + scopes: [...params.scopes], + }; + await storage.set({ [TOKENS_KEY]: tokens }); + }); + }, + async clear(params) { + await withCredentialStorage(storage, async () => { + const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY]; + if (!current || typeof current !== "object") { + return; + } + const tokens = { ...current }; + delete tokens[tokenKey(gatewayScope, params)]; + await storage.set({ [TOKENS_KEY]: tokens }); + }); + }, + }; +} + +export function resolveCopilotClose(context) { + const details = context.connectFailure?.error?.details; + const tokenMismatch = details?.code === "AUTH_DEVICE_TOKEN_MISMATCH"; + return { + retry: + details?.code === "PAIRING_REQUIRED" || (!tokenMismatch && details?.pauseReconnect !== true), + notify: !context.connectFailure, + pendingError: context.connectFailure?.error, + }; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts b/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts new file mode 100644 index 000000000000..882aba2ce520 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-gateway.d.ts @@ -0,0 +1,21 @@ +type StorageArea = { + get(keys: string[]): Promise>; + set(update: Record): Promise; +}; + +export function isDefinitiveGatewayRejection(error: unknown): boolean; +export function waitForCopilotGatewayReady( + client: CopilotGatewayClient, + gatewayScope: string, +): Promise; + +export class CopilotGatewayClient { + constructor(options?: { storage?: StorageArea; WebSocketImpl?: typeof WebSocket }); + ready: boolean; + hello: Record | null; + onEvent(listener: (event: unknown) => void): () => void; + onStatus(listener: (status: Record) => void): () => void; + start(url: string): void; + stop(): void; + request(method: string, params: unknown, options?: unknown): Promise; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.js b/extensions/browser/chrome-extension/modules/copilot-gateway.js new file mode 100644 index 000000000000..2ea28a1fc943 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-gateway.js @@ -0,0 +1,258 @@ +import { + createCopilotTokenStore, + loadOrCreateCopilotIdentity, + resolveCopilotClose, +} from "./copilot-gateway-lifecycle.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, + GatewayBrowserDeviceAuthLifecycle, + GatewayProtocolClient, + GatewayProtocolRequestError, + MIN_CLIENT_PROTOCOL_VERSION, + PROTOCOL_VERSION, +} from "./copilot-runtime.js"; +import { normalizeGatewayUrl } from "./panel-core.js"; + +const CLIENT_ID = GATEWAY_CLIENT_IDS.BROWSER_COPILOT; +const CLIENT_MODE = GATEWAY_CLIENT_MODES.UI; +const ROLE = "operator"; +const SCOPES = ["operator.read", "operator.write"]; +export function isDefinitiveGatewayRejection(error) { + return error instanceof GatewayProtocolRequestError; +} + +export async function waitForCopilotGatewayReady(client, gatewayScope) { + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + unsubscribe(); + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } else { + resolve(); + } + }; + const unsubscribe = client.onStatus((status) => { + if (status.state === "ready") { + finish(); + } else if ( + status.state === "approval" || + status.state === "denied" || + status.state === "error" + ) { + finish(new Error(status.label || "Gateway recovery failed")); + } + }); + const timer = setTimeout(() => finish(new Error("Gateway recovery timed out")), 30_000); + client.start(gatewayScope); + }); +} + +function createBrowserSocket(url, handlers, WebSocketImpl) { + const socket = new WebSocketImpl(url); + socket.addEventListener("open", handlers.open); + socket.addEventListener("message", (event) => handlers.message(String(event.data))); + socket.addEventListener("close", (event) => handlers.close(event.code, event.reason)); + socket.addEventListener("error", () => handlers.error(new Error("Gateway WebSocket error"))); + return { + isOpen: () => socket.readyState === WebSocketImpl.OPEN, + send: (data) => socket.send(data), + close: (code, reason) => socket.close(code, reason), + }; +} + +/** Dedicated browser-copilot Gateway client. It never accepts or stores shared auth. */ +export class CopilotGatewayClient { + constructor({ storage = chrome.storage.local, WebSocketImpl = WebSocket } = {}) { + this.storage = storage; + this.WebSocketImpl = WebSocketImpl; + this.protocol = null; + this.url = null; + this.ready = false; + this.hello = null; + this.listeners = new Set(); + this.statusListeners = new Set(); + this.lifecycle = null; + this.tokenRecovery = null; + } + + onEvent(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onStatus(listener) { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + + start(url) { + const gatewayScope = normalizeGatewayUrl(url); + if (!gatewayScope) { + this.stop(); + this.#emitStatus({ state: "error", label: "Invalid Gateway endpoint" }); + return; + } + if (this.protocol && this.url === gatewayScope) { + return; + } + this.stop(); + this.url = gatewayScope; + const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ + loadIdentity: () => loadOrCreateCopilotIdentity(this.storage, gatewayScope), + tokenStore: createCopilotTokenStore(this.storage, gatewayScope), + }); + this.lifecycle = lifecycle; + this.#emitStatus({ state: "connecting", label: "Connecting to Gateway" }); + const protocol = new GatewayProtocolClient({ + createSocket: (handlers) => createBrowserSocket(gatewayScope, handlers, this.WebSocketImpl), + createRequestId: () => crypto.randomUUID(), + buildConnectPlan: ({ nonce }) => + lifecycle.buildPlan({ + client: { + id: CLIENT_ID, + version: chrome.runtime.getManifest().version, + platform: "chrome", + deviceFamily: "extension", + mode: CLIENT_MODE, + }, + role: ROLE, + defaultScopes: SCOPES, + nonce, + }), + buildConnectParams: (plan) => ({ + minProtocol: MIN_CLIENT_PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: CLIENT_ID, + version: chrome.runtime.getManifest().version, + platform: "chrome", + deviceFamily: "extension", + mode: CLIENT_MODE, + }, + role: ROLE, + scopes: plan.scopes, + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + auth: plan.auth, + device: plan.device, + userAgent: navigator.userAgent, + locale: navigator.language, + }), + onConnectHello: (hello, { plan }) => { + void lifecycle.acceptHello(hello, plan); + }, + onHello: (hello) => { + this.ready = true; + this.hello = hello; + this.#emitStatus({ state: "ready", label: "Gateway connected", hello }); + }, + onConnectFailure: (error, { plan }) => { + const details = error.details && typeof error.details === "object" ? error.details : {}; + if (details.code === "AUTH_DEVICE_TOKEN_MISMATCH") { + const cleared = lifecycle.clearStoredToken(plan); + void cleared.catch(() => undefined); + this.tokenRecovery = { gatewayScope, protocol, cleared }; + } + this.#emitStatus({ + state: details.code === "PAIRING_REQUIRED" ? "approval" : "error", + label: error.message, + requestId: typeof details.requestId === "string" ? details.requestId : undefined, + }); + return { + closeCode: 4008, + closeReason: "connect failed", + reconnectDelayMs: details.code === "PAIRING_REQUIRED" ? 2_000 : undefined, + stop: + details.code === "AUTH_DEVICE_TOKEN_MISMATCH" || + (details.pauseReconnect === true && details.code !== "PAIRING_REQUIRED"), + }; + }, + resolveClose: resolveCopilotClose, + onClose: (_context, decision) => { + if (this.protocol !== protocol) { + return; + } + this.ready = false; + this.hello = null; + if (!decision.retry) { + this.protocol = null; + this.lifecycle = null; + } + const recovery = this.tokenRecovery; + if (!decision.retry && recovery?.protocol === protocol) { + /** @param {unknown} error */ + const onClearRejected = (error) => { + if (this.tokenRecovery !== recovery) { + return; + } + this.tokenRecovery = null; + this.#emitStatus({ + state: "error", + label: + error instanceof Error + ? error.message + : "Could not clear the rejected device token", + }); + }; + void recovery.cleared.then(() => { + if ( + this.tokenRecovery !== recovery || + this.protocol || + this.url !== recovery.gatewayScope + ) { + return; + } + this.tokenRecovery = null; + this.start(recovery.gatewayScope); + }, onClearRejected); + } + if (decision.notify) { + this.#emitStatus({ state: "connecting", label: "Gateway reconnecting" }); + } + }, + onConnectError: (error) => + this.#emitStatus({ state: "error", label: error.message || "Gateway unavailable" }), + onEvent: (event) => { + for (const listener of this.listeners) { + listener(event); + } + }, + handshake: { mode: "require-challenge", timeoutMs: 5_000 }, + reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }, + requestTimeoutMs: 30_000, + }); + this.protocol = protocol; + protocol.start(); + } + + stop() { + this.ready = false; + this.hello = null; + this.tokenRecovery = null; + const protocol = this.protocol; + this.protocol = null; + protocol?.stop(); + this.lifecycle = null; + this.url = null; + } + + request(method, params, options) { + if (!this.ready || !this.protocol) { + return Promise.reject(new Error("Gateway is not ready")); + } + return this.protocol.request(method, params, options); + } + + #emitStatus(status) { + for (const listener of this.statusListeners) { + listener(status); + } + } +} diff --git a/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts b/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts new file mode 100644 index 000000000000..e6bf750b81b0 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-gateway.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createCopilotTokenStore, + loadOrCreateCopilotIdentity, + resolveCopilotClose, +} from "./copilot-gateway-lifecycle.js"; +import { CopilotGatewayClient, isDefinitiveGatewayRejection } from "./copilot-gateway.js"; +import { GatewayProtocolRequestError } from "./copilot-runtime.js"; + +function storageArea() { + const values: Record = {}; + return { + async get(keys: string[]) { + return Object.fromEntries(keys.map((key) => [key, values[key]])); + }, + async set(update: Record) { + Object.assign(values, update); + }, + }; +} + +function controllableStorageArea() { + const values: Record = {}; + let nextWrite: { release: Promise; started: () => void } | undefined; + const storage = { + get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))), + set: vi.fn(async (update: Record) => { + const blocked = nextWrite; + nextWrite = undefined; + if (blocked) { + blocked.started(); + await blocked.release; + } + Object.assign(values, update); + }), + }; + return { + storage, + blockNextWrite() { + let markStarted: (() => void) | undefined; + let releaseWrite: (() => void) | undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const release = new Promise((resolve) => { + releaseWrite = resolve; + }); + nextWrite = { release, started: () => markStarted?.() }; + return { started, release: () => releaseWrite?.() }; + }, + }; +} + +class FakeWebSocket { + static OPEN = 1; + static instances: FakeWebSocket[] = []; + + readyState = 0; + sent: Array> = []; + private listeners = new Map) => void>>(); + + constructor() { + FakeWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = FakeWebSocket.OPEN; + this.emit("open", {}); + }); + } + + addEventListener(name: string, listener: (event: Record) => void) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(listener); + this.listeners.set(name, listeners); + } + + send(data: string) { + this.sent.push(JSON.parse(data) as Record); + } + + close(code = 1000, reason = "") { + if (this.readyState === 3) { + return; + } + this.readyState = 3; + queueMicrotask(() => this.emit("close", { code, reason })); + } + + message(frame: Record) { + this.emit("message", { data: JSON.stringify(frame) }); + } + + private emit(name: string, event: Record) { + for (const listener of this.listeners.get(name) ?? []) { + listener(event); + } + } +} + +describe("browser copilot Gateway custody", () => { + it("scopes device identities and issued tokens to one Gateway", async () => { + const storage = storageArea(); + const gatewayA = "ws://127.0.0.1:18789/"; + const gatewayB = "ws://127.0.0.1:28789/"; + const identityA = await loadOrCreateCopilotIdentity(storage, gatewayA); + const identityAAgain = await loadOrCreateCopilotIdentity(storage, gatewayA); + const identityB = await loadOrCreateCopilotIdentity(storage, gatewayB); + + expect(identityAAgain.deviceId).toBe(identityA.deviceId); + expect(identityB.deviceId).not.toBe(identityA.deviceId); + + const tokenParams = { + clientId: "openclaw-browser-copilot", + deviceId: identityA.deviceId, + role: "operator", + }; + const tokenA = createCopilotTokenStore(storage, gatewayA); + const tokenB = createCopilotTokenStore(storage, gatewayB); + await tokenA.store({ ...tokenParams, token: "test-token", scopes: ["operator.read"] }); + + await expect(tokenA.load(tokenParams)).resolves.toEqual({ + token: "test-token", + scopes: ["operator.read"], + }); + await expect(tokenB.load(tokenParams)).resolves.toBeNull(); + }); + + it("serializes shared credential maps across concurrent Gateway clients", async () => { + const controlled = controllableStorageArea(); + const gatewayA = "ws://127.0.0.1:18789/"; + const gatewayB = "ws://127.0.0.1:28789/"; + + const identityWrite = controlled.blockNextWrite(); + const firstIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayA); + await identityWrite.started; + const secondIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayB); + identityWrite.release(); + const [identityA, identityB] = await Promise.all([firstIdentity, secondIdentity]); + await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayA)).resolves.toMatchObject({ + deviceId: identityA.deviceId, + }); + await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayB)).resolves.toMatchObject({ + deviceId: identityB.deviceId, + }); + + const tokenParams = (deviceId: string) => ({ + clientId: "openclaw-browser-copilot", + deviceId, + role: "operator", + }); + const tokenA = createCopilotTokenStore(controlled.storage, gatewayA); + const tokenB = createCopilotTokenStore(controlled.storage, gatewayB); + const storeGate = controlled.blockNextWrite(); + const firstStore = tokenA.store({ + ...tokenParams(identityA.deviceId), + token: "test-token-placeholder", + scopes: ["operator.read"], + }); + await storeGate.started; + const secondStore = tokenB.store({ + ...tokenParams(identityB.deviceId), + token: "test-token-placeholder", + scopes: ["operator.write"], + }); + storeGate.release(); + await Promise.all([firstStore, secondStore]); + await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toMatchObject({ + token: "test-token-placeholder", + }); + await expect(tokenB.load(tokenParams(identityB.deviceId))).resolves.toMatchObject({ + token: "test-token-placeholder", + }); + + const replacementWrite = controlled.blockNextWrite(); + const replacing = tokenA.store({ + ...tokenParams(identityA.deviceId), + token: "test-token-placeholder", + scopes: ["operator.read", "operator.write"], + }); + await replacementWrite.started; + const clearing = tokenA.clear(tokenParams(identityA.deviceId)); + replacementWrite.release(); + await Promise.all([replacing, clearing]); + await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toBeNull(); + }); + + it("keeps the pairing approval state when the failed socket closes", () => { + const error = { details: { code: "PAIRING_REQUIRED", pauseReconnect: true } }; + + expect(resolveCopilotClose({ connectFailure: { error } })).toEqual({ + retry: true, + notify: false, + pendingError: error, + }); + expect( + resolveCopilotClose({ + connectFailure: { error: { details: { pauseReconnect: true } } }, + }).retry, + ).toBe(false); + expect( + resolveCopilotClose({ + connectFailure: { + error: { + details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: false }, + }, + }, + }).retry, + ).toBe(false); + expect(resolveCopilotClose({})).toEqual({ + retry: true, + notify: true, + pendingError: undefined, + }); + }); + + it("clears a rejected device token before starting a fresh connection", async () => { + const values: Record = {}; + let releaseClear: (() => void) | undefined; + let markClearStarted: (() => void) | undefined; + const clearStarted = new Promise((resolve) => { + markClearStarted = resolve; + }); + let blockNextSet = false; + const storage = { + async get(keys: string[]) { + return Object.fromEntries(keys.map((key) => [key, values[key]])); + }, + async set(update: Record) { + if (blockNextSet) { + blockNextSet = false; + markClearStarted?.(); + await new Promise((resolve) => { + releaseClear = resolve; + }); + } + Object.assign(values, update); + }, + }; + const gatewayScope = "ws://127.0.0.1:18789/"; + const identity = await loadOrCreateCopilotIdentity(storage, gatewayScope); + const tokenStore = createCopilotTokenStore(storage, gatewayScope); + const tokenParams = { + clientId: "openclaw-browser-copilot", + deviceId: identity.deviceId, + role: "operator", + }; + await tokenStore.store({ + ...tokenParams, + token: "test-token", + scopes: ["operator.read", "operator.write"], + }); + blockNextSet = true; + FakeWebSocket.instances = []; + vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } }); + vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" }); + const client = new CopilotGatewayClient({ + storage, + WebSocketImpl: FakeWebSocket as never, + }); + + try { + client.start(gatewayScope); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + const first = FakeWebSocket.instances[0]; + first?.message({ + type: "event", + event: "connect.challenge", + payload: { nonce: "first-nonce" }, + }); + await vi.waitFor(() => expect(first?.sent).toHaveLength(1)); + const firstConnect = first?.sent[0] as { + id?: string; + params?: { auth?: { token?: string } }; + }; + expect(firstConnect.params?.auth?.token).toBe("test-token"); + first?.message({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "UNAVAILABLE", + message: "device token rejected", + details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: true }, + }, + }); + await clearStarted; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(FakeWebSocket.instances).toHaveLength(1); + + releaseClear?.(); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + const second = FakeWebSocket.instances[1]; + second?.message({ + type: "event", + event: "connect.challenge", + payload: { nonce: "second-nonce" }, + }); + await vi.waitFor(() => expect(second?.sent).toHaveLength(1)); + const secondConnect = second?.sent[0] as { params?: { auth?: { token?: string } } }; + expect(secondConnect.params?.auth?.token).toBeUndefined(); + await expect(tokenStore.load(tokenParams)).resolves.toBeNull(); + } finally { + releaseClear?.(); + client.stop(); + vi.unstubAllGlobals(); + } + }); + + it("distinguishes server rejection from ambiguous transport failure", () => { + expect( + isDefinitiveGatewayRejection( + new GatewayProtocolRequestError({ code: "INVALID_REQUEST", message: "fixture rejection" }), + ), + ).toBe(true); + expect(isDefinitiveGatewayRejection(new Error("fixture socket closed"))).toBe(false); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts b/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts new file mode 100644 index 000000000000..448c9debc32b --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-recovery.d.ts @@ -0,0 +1,23 @@ +import type { CopilotGatewayClient } from "./copilot-gateway.js"; +import type { CopilotSessionEntry, CopilotSessionRegistry } from "./copilot-session-registry.js"; + +export function createCopilotRecoveryController( + options: Record & { + gateway: CopilotGatewayClient; + registry: CopilotSessionRegistry; + }, +): { + abortEntry: (entry: CopilotSessionEntry) => Promise; + clearAbortRetry: () => void; + drainAborts: (gatewayScope?: string | null) => Promise; + drainArchives: (gatewayScope?: string | null) => Promise; + drainStaleScopes: () => Promise; + reconcileGatewayReady: ( + status: Record, + statusRevision: number, + gatewayScope: string | null, + revocation: Promise, + ) => Promise; + scheduleAbortRetry: (gatewayScope?: string | null) => void; + scheduleStaleRecovery: () => void; +}; diff --git a/extensions/browser/chrome-extension/modules/copilot-recovery.js b/extensions/browser/chrome-extension/modules/copilot-recovery.js new file mode 100644 index 000000000000..10d251e20cda --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-recovery.js @@ -0,0 +1,269 @@ +import { archiveCopilotSession } from "./copilot-background-shared.js"; +import { waitForCopilotGatewayReady } from "./copilot-gateway.js"; + +/** Gateway cleanup owner. All destructive scope recovery runs through the lifecycle queue. */ +export function createCopilotRecoveryController({ + gateway, + recoveryGatewayFactory, + registry, + subscribedKeys, + sendsByTab, + currentGatewayScope, + getGatewayStatus, + getGatewayStatusRevision, + getLastReadyStatus, + isConfigTransitioning, + setReconciledGatewayStatus, + restoreDebuggerIfReleased, + broadcastTab, + broadcastStatus, + refreshPanelState, + runLifecycle, +}) { + let abortRetryTimer = null; + let abortRetryDelayMs = 250; + let staleRecovery = null; + let staleRecoveryRetryTimer = null; + + async function drainArchives(gatewayScope = currentGatewayScope()) { + if (!gateway.ready || !gatewayScope) { + return; + } + for (const entry of registry.pendingArchives(gatewayScope)) { + try { + await archiveCopilotSession(gateway, entry); + subscribedKeys.delete(entry.sessionKey); + await registry.resolveArchive(gatewayScope, entry.sessionKey); + if (typeof entry.tabId === "number") { + await restoreDebuggerIfReleased(entry.tabId); + } + } catch { + // The watchdog retries after reconnect or after an active run reaches terminal state. + } + } + } + + async function abortEntry(entry) { + try { + await gateway.request("sessions.abort", { + key: entry.sessionKey, + runId: entry.activeRunId, + }); + } catch { + scheduleAbortRetry(entry.gatewayScope); + return false; + } + sendsByTab.delete(entry.tabId); + const finished = await registry.finishRun( + entry.gatewayScope, + entry.sessionKey, + entry.activeRunId, + ); + if (finished) { + await restoreDebuggerIfReleased(entry.tabId); + broadcastTab(entry.tabId, { type: "panel.turn-reset" }); + void refreshPanelState(entry.tabId); + } + return true; + } + + async function drainAborts(gatewayScope = currentGatewayScope()) { + if (!gateway.ready || !gatewayScope) { + return; + } + for (const entry of registry.pendingAborts(gatewayScope)) { + await abortEntry(entry); + } + } + + function clearAbortRetry() { + if (abortRetryTimer) { + clearTimeout(abortRetryTimer); + abortRetryTimer = null; + } + abortRetryDelayMs = 250; + } + + function scheduleAbortRetry(gatewayScope = currentGatewayScope()) { + const statusRevision = getGatewayStatusRevision(); + if ( + abortRetryTimer || + !gateway.ready || + !gatewayScope || + isConfigTransitioning() || + currentGatewayScope() !== gatewayScope + ) { + return; + } + const delayMs = abortRetryDelayMs; + abortRetryTimer = setTimeout(() => { + abortRetryTimer = null; + void (async () => { + if ( + currentGatewayScope() !== gatewayScope || + getGatewayStatusRevision() !== statusRevision || + !gateway.ready + ) { + return; + } + await drainAborts(gatewayScope); + if ( + currentGatewayScope() !== gatewayScope || + getGatewayStatusRevision() !== statusRevision || + !gateway.ready + ) { + return; + } + if (registry.pendingAborts(gatewayScope).length > 0) { + abortRetryDelayMs = Math.min(abortRetryDelayMs * 2, 5_000); + scheduleAbortRetry(); + } else { + abortRetryDelayMs = 250; + const readyStatus = getLastReadyStatus(); + if (getGatewayStatus().state === "error" && readyStatus) { + setReconciledGatewayStatus(readyStatus, statusRevision); + broadcastStatus({ ensureSetup: true, hydrateHistory: true }); + } + } + })(); + }, delayMs); + } + + async function reconcileGatewayReady(status, statusRevision, gatewayScope, revocation) { + await revocation; + if ( + !gatewayScope || + statusRevision !== getGatewayStatusRevision() || + isConfigTransitioning() || + !gateway.ready || + currentGatewayScope() !== gatewayScope + ) { + return; + } + // A connection gap loses terminal events. Abort durable active custody + // before panels can send again. + await registry.queueActiveAborts(gatewayScope); + await drainAborts(gatewayScope); + await drainArchives(gatewayScope); + if ( + statusRevision !== getGatewayStatusRevision() || + isConfigTransitioning() || + !gateway.ready || + currentGatewayScope() !== gatewayScope + ) { + return; + } + const hasPendingAborts = registry.pendingAborts(gatewayScope).length > 0; + setReconciledGatewayStatus( + hasPendingAborts ? { state: "error", label: "Could not stop the previous tab run" } : status, + hasPendingAborts ? 0 : statusRevision, + ); + broadcastStatus(hasPendingAborts ? undefined : { ensureSetup: true, hydrateHistory: true }); + } + + function scheduleStaleRecovery() { + if (staleRecoveryRetryTimer) { + return; + } + staleRecoveryRetryTimer = setTimeout(() => { + staleRecoveryRetryTimer = null; + void drainStaleScopes(); + }, 5_000); + } + + function drainStaleScopes() { + if (staleRecovery) { + return staleRecovery; + } + if (staleRecoveryRetryTimer) { + clearTimeout(staleRecoveryRetryTimer); + staleRecoveryRetryTimer = null; + } + let retry = false; + const pending = runLifecycle(async () => { + const currentScope = currentGatewayScope(); + const staleScopes = registry.gatewayScopes().filter((scope) => scope !== currentScope); + for (const staleScope of staleScopes) { + if (await recoverPersistedScope(staleScope)) { + continue; + } + await registry.closeInactiveScope(staleScope); + retry = true; + } + if (gateway.ready && getGatewayStatus().state === "ready") { + broadcastStatus({ ensureSetup: true, hydrateHistory: true }); + } + }).catch(() => { + retry = true; + }); + staleRecovery = pending; + void pending.then(() => { + if (staleRecovery === pending) { + staleRecovery = null; + } + if (retry) { + scheduleStaleRecovery(); + } + }); + return pending; + } + + async function recoverPersistedScope(gatewayScope) { + const scopedEntries = registry.list().filter((entry) => entry.gatewayScope === gatewayScope); + const needsGateway = + registry.pendingArchives(gatewayScope).length > 0 || + scopedEntries.some( + (entry) => !entry.provisional || entry.creationPending || entry.activeRunId, + ); + if (!needsGateway) { + await registry.closeScope(gatewayScope); + return true; + } + const recoveryGateway = recoveryGatewayFactory(); + try { + await waitForCopilotGatewayReady(recoveryGateway, gatewayScope); + await registry.queueActiveAborts(gatewayScope); + for (const entry of registry.pendingAborts(gatewayScope)) { + await recoveryGateway.request("sessions.abort", { + key: entry.sessionKey, + runId: entry.activeRunId, + }); + const finished = await registry.finishRun( + entry.gatewayScope, + entry.sessionKey, + entry.activeRunId, + ); + if (finished) { + await restoreDebuggerIfReleased(entry.tabId); + } + } + await registry.closeScope(gatewayScope); + for (const entry of registry.pendingArchives(gatewayScope)) { + await archiveCopilotSession(recoveryGateway, entry); + await registry.resolveArchive(gatewayScope, entry.sessionKey); + if (typeof entry.tabId === "number") { + await restoreDebuggerIfReleased(entry.tabId); + } + } + return ( + registry.pendingAborts(gatewayScope).length === 0 && + registry.pendingArchives(gatewayScope).length === 0 + ); + } catch { + return false; + } finally { + recoveryGateway.stop(); + } + } + + return { + abortEntry, + clearAbortRetry, + drainAborts, + drainArchives, + drainStaleScopes, + reconcileGatewayReady, + scheduleAbortRetry, + scheduleStaleRecovery, + }; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts b/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts new file mode 100644 index 000000000000..2c3090bcbb2c --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-relay-custody.d.ts @@ -0,0 +1,5 @@ +export function createCopilotRelayCustodyController(options: Record): { + currentPanelStatus(): { state: string; label: string; requestId?: string }; + isOperational(): boolean; + onStatus(status: { ready: boolean; label?: string }): Promise; +}; diff --git a/extensions/browser/chrome-extension/modules/copilot-relay-custody.js b/extensions/browser/chrome-extension/modules/copilot-relay-custody.js new file mode 100644 index 000000000000..46ab7b31ea55 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-relay-custody.js @@ -0,0 +1,89 @@ +/** Relay/run boundary owner for the tab-bound copilot. */ +export function createCopilotRelayCustodyController({ + appendGatewayRevocation, + broadcastStatus, + currentGatewayScope, + drainAborts, + getGatewayStatus, + invalidateGatewayEpoch, + markGatewayAbortError, + registry, + revokeActiveBindings, + runLifecycle, +}) { + let ready = false; + let label = "Connecting to browser relay"; + let statusRevision = 0; + let reconciledStatusRevision = 0; + let pendingRevocation = Promise.resolve(); + + function isOperational() { + return ready && reconciledStatusRevision === statusRevision; + } + + function currentPanelStatus() { + const gatewayStatus = getGatewayStatus(); + return gatewayStatus.state === "ready" && !isOperational() + ? { state: "connecting", label } + : gatewayStatus; + } + + async function onStatus(status) { + const nextReady = status.ready === true; + const readinessChanged = ready !== nextReady; + ready = nextReady; + label = status.label || "Browser relay reconnecting"; + if (!readinessChanged) { + broadcastStatus(); + return; + } + // Relay availability is part of the run epoch. Reconcile debugger/run + // custody before a reconnected tool route can admit panel work again. + invalidateGatewayEpoch(); + const revision = ++statusRevision; + if (nextReady) { + broadcastStatus(); + await pendingRevocation; + await runLifecycle(async () => { + const gatewayScope = currentGatewayScope(); + if (revision === statusRevision && ready && gatewayScope) { + await drainAborts(gatewayScope); + } + }); + if (revision !== statusRevision || !ready) { + return; + } + reconciledStatusRevision = revision; + const gatewayScope = currentGatewayScope(); + if ( + gatewayScope && + registry.pendingAborts(gatewayScope).length > 0 && + getGatewayStatus().state === "ready" + ) { + markGatewayAbortError(); + broadcastStatus(); + return; + } + broadcastStatus({ ensureSetup: true, hydrateHistory: true }); + return; + } + reconciledStatusRevision = 0; + broadcastStatus(); + const gatewayScope = currentGatewayScope(); + if (!gatewayScope) { + return; + } + const revocation = revokeActiveBindings(gatewayScope); + appendGatewayRevocation(revocation); + const cleanup = runLifecycle(async () => { + await revocation; + if (gatewayScope === currentGatewayScope() && !ready) { + await drainAborts(gatewayScope); + } + }); + pendingRevocation = cleanup.catch(() => undefined); + await pendingRevocation; + } + + return { currentPanelStatus, isOperational, onStatus }; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts b/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts new file mode 100644 index 000000000000..7a3bfc70d6fc --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-runtime.d.ts @@ -0,0 +1,29 @@ +export const GATEWAY_CLIENT_CAPS: Record; +export const GATEWAY_CLIENT_IDS: Record; +export const GATEWAY_CLIENT_MODES: Record; +export const MIN_CLIENT_PROTOCOL_VERSION: number; +export const PROTOCOL_VERSION: number; + +export const ed25519Utils: { + randomSecretKey(): Uint8Array; +}; +export function getPublicKeyAsync(secretKey: Uint8Array): Promise; +export function signAsync(message: Uint8Array, secretKey: Uint8Array): Promise; + +export class GatewayProtocolRequestError extends Error { + constructor(error: Record); +} + +export class GatewayProtocolClient { + constructor(options: Record); + start(): void; + stop(): void; + request(method: string, params: unknown, options?: unknown): Promise; +} + +export class GatewayBrowserDeviceAuthLifecycle { + constructor(options: Record); + buildPlan(options: Record): Promise>; + acceptHello(hello: unknown, plan: unknown): Promise; + clearStoredToken(plan: unknown): Promise; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.js b/extensions/browser/chrome-extension/modules/copilot-runtime.js new file mode 100644 index 000000000000..2f1b8db34e2b --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-runtime.js @@ -0,0 +1 @@ +function fe(t){if(typeof t!="string")return"";let e=t.trim();return e?e.replace(/[A-Z]/g,n=>String.fromCharCode(n.charCodeAt(0)+32)):""}function ye(t){let e=t.scopes.join(","),n=t.token??"",o=fe(t.platform),i=fe(t.deviceFamily);return["v3",t.deviceId,t.clientId,t.clientMode,t.role,e,String(t.signedAtMs),n,t.nonce,o,i].join("|")}var g={AUTH_REQUIRED:"AUTH_REQUIRED",AUTH_UNAUTHORIZED:"AUTH_UNAUTHORIZED",AUTH_TOKEN_MISSING:"AUTH_TOKEN_MISSING",AUTH_TOKEN_MISMATCH:"AUTH_TOKEN_MISMATCH",AUTH_TOKEN_NOT_CONFIGURED:"AUTH_TOKEN_NOT_CONFIGURED",AUTH_PASSWORD_MISSING:"AUTH_PASSWORD_MISSING",AUTH_PASSWORD_MISMATCH:"AUTH_PASSWORD_MISMATCH",AUTH_PASSWORD_NOT_CONFIGURED:"AUTH_PASSWORD_NOT_CONFIGURED",AUTH_BOOTSTRAP_TOKEN_INVALID:"AUTH_BOOTSTRAP_TOKEN_INVALID",AUTH_DEVICE_TOKEN_MISMATCH:"AUTH_DEVICE_TOKEN_MISMATCH",AUTH_SCOPE_MISMATCH:"AUTH_SCOPE_MISMATCH",AUTH_RATE_LIMITED:"AUTH_RATE_LIMITED",AUTH_TAILSCALE_IDENTITY_MISSING:"AUTH_TAILSCALE_IDENTITY_MISSING",AUTH_TAILSCALE_PROXY_MISSING:"AUTH_TAILSCALE_PROXY_MISSING",AUTH_TAILSCALE_WHOIS_FAILED:"AUTH_TAILSCALE_WHOIS_FAILED",AUTH_TAILSCALE_IDENTITY_MISMATCH:"AUTH_TAILSCALE_IDENTITY_MISMATCH",CONTROL_UI_ORIGIN_NOT_ALLOWED:"CONTROL_UI_ORIGIN_NOT_ALLOWED",PROTOCOL_MISMATCH:"PROTOCOL_MISMATCH",CONTROL_UI_DEVICE_IDENTITY_REQUIRED:"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",DEVICE_IDENTITY_REQUIRED:"DEVICE_IDENTITY_REQUIRED",DEVICE_AUTH_INVALID:"DEVICE_AUTH_INVALID",DEVICE_AUTH_DEVICE_ID_MISMATCH:"DEVICE_AUTH_DEVICE_ID_MISMATCH",DEVICE_AUTH_SIGNATURE_EXPIRED:"DEVICE_AUTH_SIGNATURE_EXPIRED",DEVICE_AUTH_NONCE_REQUIRED:"DEVICE_AUTH_NONCE_REQUIRED",DEVICE_AUTH_NONCE_MISMATCH:"DEVICE_AUTH_NONCE_MISMATCH",DEVICE_AUTH_SIGNATURE_INVALID:"DEVICE_AUTH_SIGNATURE_INVALID",DEVICE_AUTH_PUBLIC_KEY_INVALID:"DEVICE_AUTH_PUBLIC_KEY_INVALID",PAIRING_REQUIRED:"PAIRING_REQUIRED",CLIENT_VERSION_MISMATCH:"CLIENT_VERSION_MISMATCH"};function b(t){return typeof t=="string"&&t.trim()||void 0}function he(t){let e=b(t.token),n=b(t.bootstrapToken),o=b(t.deviceToken),i=b(t.password),r=b(t.storedToken),s={storedToken:r,storedScopes:t.storedScopes};if(t.preferBootstrapToken&&n)return{authBootstrapToken:n,authPassword:i,...s};let l=t.pendingDeviceTokenRetry===!0&&!o&&!!(e&&r&&t.trustedDeviceTokenRetry),d=o??(l||!(e||i)&&(!n||r)?r:void 0),c=!!(d&&!o&&r)&&d===r,u=e??d,p=!e&&!d&&!i?n:void 0;return{authToken:u,authBootstrapToken:p,authDeviceToken:l?r:void 0,authPassword:i,authApprovalRuntimeToken:b(t.approvalRuntimeToken),authAgentRuntimeIdentityToken:b(t.agentRuntimeIdentityToken),signatureToken:u??p,resolvedDeviceToken:d,usingStoredDeviceToken:c,...s}}function Z(t){let e={token:t.authToken,bootstrapToken:t.authBootstrapToken,deviceToken:t.authDeviceToken??t.resolvedDeviceToken,password:t.authPassword,approvalRuntimeToken:t.authApprovalRuntimeToken,agentRuntimeIdentityToken:t.authAgentRuntimeIdentityToken};return Object.values(e).some(Boolean)?e:void 0}function Te(t){return t.requestedScopes??(t.usingStoredDeviceToken&&t.storedScopes?.length?t.storedScopes:[...t.defaultScopes])}var Q=class{constructor(e){this.deps=e}async buildPlan(e){let n=await this.deps.loadIdentity(),o=n?await this.deps.tokenStore.load({clientId:e.client.id,deviceId:n.deviceId,role:e.role}):null,i=o?.token,r=he({token:e.token,bootstrapToken:e.bootstrapToken,password:e.password,storedToken:i,storedScopes:o?.scopes,pendingDeviceTokenRetry:e.pendingDeviceTokenRetry,trustedDeviceTokenRetry:e.trustedDeviceTokenRetry,preferBootstrapToken:e.preferBootstrapToken}),{usingStoredDeviceToken:s}=r,l=Te({requestedScopes:r.authBootstrapToken&&e.bootstrapScopes?[...e.bootstrapScopes]:void 0,usingStoredDeviceToken:s,storedScopes:r.storedScopes,defaultScopes:e.defaultScopes});if(!n)return{clientId:e.client.id,role:e.role,identity:n,selectedAuth:r,scopes:l,auth:Z(r)};let d=this.deps.nowMs?.()??Date.now(),c=e.nonce??"",{authBootstrapToken:u,signatureToken:p}=r,f=null;u?f=u:p&&(f=p);let h=ye({deviceId:n.deviceId,clientId:e.client.id,clientMode:e.client.mode,role:e.role,scopes:l,signedAtMs:d,token:f,nonce:c,platform:e.client.platform,deviceFamily:e.client.deviceFamily});return{clientId:e.client.id,role:e.role,identity:n,selectedAuth:r,scopes:l,auth:Z(r),device:{id:n.deviceId,publicKey:n.publicKey,signature:await n.sign(h),signedAt:d,nonce:c}}}async acceptHello(e,n){let o=e.auth?.deviceToken?.trim();!o||!n.identity||await this.deps.tokenStore.store({clientId:n.clientId,deviceId:n.identity.deviceId,role:e.auth?.role??n.role,token:o,scopes:e.auth?.scopes??[]})}async clearStoredToken(e){e.identity&&await this.deps.tokenStore.clear({clientId:e.clientId,deviceId:e.identity.deviceId,role:e.role})}};function $(t){return!!t&&typeof t=="object"&&!Array.isArray(t)}function B(t){return typeof t=="string"&&t.length>0}function me(t){return typeof t=="number"&&Number.isInteger(t)&&t>=0}function Ke(t){return!$(t)||!B(t.code)||!B(t.message)||t.retryable!==void 0&&typeof t.retryable!="boolean"?!1:t.retryAfterMs===void 0||me(t.retryAfterMs)}function Ee(t){return!$(t)||t.type!=="event"||!B(t.event)?!1:t.seq===void 0||me(t.seq)}function Ae(t){return!$(t)||t.type!=="res"||!B(t.id)||typeof t.ok!="boolean"?!1:t.error===void 0||Ke(t.error)}function Xe(t,e){let n=Math.min(t.maxMs,t.initialMs*t.factor**Math.max(e-1,0)),o=n*t.jitter*Math.random();return Math.min(t.maxMs,Math.round(n+o))}async function ge(t,e,n={}){if(!Number.isFinite(t)||t<=0)return;let o=Math.min(Math.max(Math.floor(t),1),2147e6);await new Promise((i,r)=>{let s=!1,l=null,d=()=>e?.removeEventListener("abort",c),c=()=>{s||(s=!0,l&&clearTimeout(l),l=null,d(),r(new Error("aborted",{cause:e?.reason??new Error("aborted")})))};if(e?.addEventListener("abort",c,{once:!0}),e?.aborted){c();return}l=setTimeout(()=>{s=!0,d(),l=null,i()},o),n.ref===!1&&l.unref?.(),e?.aborted&&c()})}var F=class{constructor(e,n=Number.POSITIVE_INFINITY){this.policy=e;this.maxAttempts=n;this.attempts=0;this.initialMs=e.initialMs}reset(e=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=e,this.nextDelayOverrideMs=void 0}cancel(e=new Error("retry cancelled")){this.pendingAbort?.abort(e),this.pendingAbort=void 0}next(e){let n=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,n===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let o=Math.max(this.attempts,1),i=n??Xe({...this.policy,initialMs:this.initialMs},o);this.cancel();let r=new AbortController;return this.pendingAbort=r,{attempt:o,delayMs:i,signal:e?AbortSignal.any([r.signal,e]):r.signal}}},J={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},ze=t=>new Promise(e=>{setTimeout(e,t)});function V(t){return typeof t=="number"&&Number.isFinite(t)?t:void 0}function ee(t,e,n,o){let i=V(t);return i===void 0?e:Math.min(Math.max(i,n??Number.NEGATIVE_INFINITY),o??Number.POSITIVE_INFINITY)}function _e(t,e){return Math.max(1,Math.round(V(t)??e))}function q(t){let e=t===Number.POSITIVE_INFINITY?2147e6:V(t)??0;return Math.min(Math.max(Math.round(e),0),2147e6)}function je(t,e){if(t==="full")return"full";let n=V(t);return n===void 0?e:Math.min(Math.max(n,0),1)}function Ze(t=J,e){let n=_e(e?.attempts,t.attempts),o=q(ee(e?.minDelayMs,t.minDelayMs,0)),i=Math.max(o,q(ee(e?.maxDelayMs,t.maxDelayMs,0)));return{attempts:n,minDelayMs:o,maxDelayMs:i,jitter:je(e?.jitter,t.jitter)}}function Qe(t,e,n,o){if(e==="full")return n==="symmetric"?Math.max(0,Math.round(t*(.5+o()*.5))):Math.max(0,Math.ceil(t*(1+o())));if(e<=0)return n==="positive"?Math.ceil(t):t;let i=o(),r=n==="positive"?i*e:(i*2-1)*e,s=t*(1+r);return Math.max(0,n==="positive"?Math.ceil(s):Math.round(s))}function $e(t,e="Non-Error thrown"){if(t instanceof Error)return t;if(typeof t=="string")return new Error(t);let n=new Error(e,{cause:t});return(typeof t=="object"&&t!==null||typeof t=="function")&&Object.assign(n,t),n}function Je(t={}){let e=t.sleep??ze,n=t.random??Math.random,o=t.createFailure??(i=>$e(i.at(-1)??new Error("Retry failed")));return async function(r,s=3,l=300){let d=[];if(typeof s=="number"){let E=_e(s,J.attempts);for(let A=0;A0?u.maxDelayMs:Number.POSITIVE_INFINITY,m=c.retryAfterMaxDelayMs===void 0?h:Math.max(f,q(ee(c.retryAfterMaxDelayMs,h,0))),C=c.random??n,S=c.sleep??e,D=c.shouldRetry??(()=>!0);for(let E=1;E<=p;E+=1)try{return await r()}catch(A){if(d.push(A),E>=p||!D(A,E))break;let N={attempt:E,maxAttempts:p,err:A,label:c.label},x=c.retryAfterMs?.(A),O=typeof x=="number"&&Number.isFinite(x),L=typeof c.delayMs=="function"?c.delayMs(N):c.delayMs,ue=L===void 0?void 0:q(L),Ve=O?Math.max(x,f):ue===void 0?f*2**(E-1):Math.max(ue,f),j=O?m:h,v=Math.min(Ve,j),pe=O&&(x??0)<=j,Ye=u.jitter==="full"&&!O||pe;v=Qe(v,u.jitter,Ye?"positive":"symmetric",C),v=Math.min(Math.max(v,f),j),await c.onRetry?.({...N,delayMs:v}),v>0&&await S(v)}throw o(d)}}var xt=Je();var G=class extends Error{constructor(e){super(e.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=e.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=e.details,this.retryable=e.retryable===!0,this.retryAfterMs=e.retryAfterMs}},te=class{constructor(e){this.opts=e;this.socket=null;this.pending=new Map;this.listeners=new Set;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new F({initialMs:e.reconnect.initialMs,maxMs:e.reconnect.maxMs,factor:e.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(e=>e.unbounded)}start(){this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect()}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSupervisor.reset();let e=this.socket;e&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket:e,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),e?.close()}request(e,n,o){let i=this.socket;if(!i?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof e!="string"||e.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let r=this.opts.createRequestId(),s=o?.timeoutMs===null?void 0:o?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((l,d)=>{let c,u={resolve:h=>l(h),reject:d,expectFinal:o?.expectFinal===!0,acceptedNotified:!1,onAccepted:o?.onAccepted,unbounded:s===void 0,method:e,startedAtMs:this.nowMs()},p=()=>{this.pending.delete(r),c&&clearTimeout(c),this.finishRequestTiming(r,u,!1,"CLIENT_ABORTED"),d(this.opts.createRequestAbortError?.(e)??new Error(`gateway request aborted for ${e}`))},f=()=>{c&&clearTimeout(c),o?.signal?.removeEventListener("abort",p)};if(o?.signal?.aborted){d(this.opts.createRequestAbortError?.(e)??new Error(`gateway request aborted for ${e}`));return}u.cleanup=f,s!==void 0&&s>=0&&(c=setTimeout(()=>{this.pending.delete(r),o?.signal?.removeEventListener("abort",p),this.finishRequestTiming(r,u,!1,"CLIENT_TIMEOUT"),d(this.opts.createRequestTimeoutError?.(e,s)??new Error(`gateway request timed out after ${s}ms: ${e}`))},s),c.unref?.()),o?.signal?.addEventListener("abort",p,{once:!0}),this.pending.set(r,u);try{i.send(JSON.stringify({type:"req",id:r,method:e,params:n}))}catch(h){this.pending.delete(r),f(),this.finishRequestTiming(r,u,!1,"CLIENT_SEND_ERROR"),d(h instanceof Error?h:new Error(String(h)))}})}addEventListener(e){return this.listeners.add(e),()=>this.listeners.delete(e)}closeSocket(e,n){this.socket?.close(e,n)}resetReconnectBackoff(e){this.reconnectSupervisor.reset(e)}recordTiming(e,n,o,i){let r=this.nowMs(),s=this.connectTiming;!s||s.generation!==n||(s.hasChallenge||=e==="challenge",s.usedFallback||=e==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase:e,generation:n,durationMs:Math.max(0,r-s.startedAtMs),phaseDurationMs:Math.max(0,r-s.lastAtMs),hasChallenge:s.hasChallenge,usedFallback:s.usedFallback,plan:o,detail:i})),s.lastAtMs=r,(e==="hello"||e==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let e=this.generation+1;this.connectNonce=null,this.connectSent=!1,this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let n;try{n=this.opts.createSocket({open:()=>this.handleOpen(n,e),message:i=>this.handleMessage(n,e,i),close:(i,r)=>this.handleClose(n,e,i,r),error:i=>this.handleSocketError(n,e,i)})}catch(i){let r=i instanceof Error?i:new Error(String(i));if(this.opts.onSocketFactoryError?.(r),this.opts.onConnectError?.(r),this.opts.rethrowSocketFactoryError?.(r))throw r;return}this.generation=e,this.socket=n;let o=this.nowMs();this.connectTiming={generation:e,startedAtMs:o,lastAtMs:o,hasChallenge:!1,usedFallback:!1}}handleOpen(e,n){if(this.isActive(e,n)){if(this.socketOpened=!0,this.recordTiming("socket-open",n),this.connectNonce){this.sendConnect(e,n);return}this.armHandshakeTimer(e,n)}}armHandshakeTimer(e,n){this.clearHandshakeTimer();let o=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(e,n)||this.connectSent||!e.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",n),this.sendConnect(e,n);return}let i=Date.now()-o,r=new Error(this.opts.handshake.timeoutMessage?.(i)??`gateway connect challenge timeout after ${i}ms`);this.opts.onConnectError?.(r),e.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(e,n){if(!this.isActive(e,n)||!e.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer();let o;try{o=this.opts.buildConnectPlan({nonce:this.connectNonce,generation:n})}catch(i){this.handleConnectPlanError(e,n,i);return}if(o instanceof Promise){o.then(i=>this.sendConnectPlan(e,n,i)).catch(i=>this.handleConnectPlanError(e,n,i));return}this.sendConnectPlan(e,n,o)}handleConnectPlanError(e,n,o){if(!this.isActive(e,n))return;let i=o instanceof Error?o:new Error(String(o)),r=this.opts.onConnectPlanError?.(i)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(r.error??i),r.stop&&(this.stopped=!0),e.close(r.closeCode,r.closeReason)}sendConnectPlan(e,n,o){if(!this.isActive(e,n)||!e.isOpen())return;let i={generation:n,nonce:this.connectNonce,plan:o};this.recordTiming("connect-plan-ready",n,o),this.recordTiming("request-sent",n,o),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(o)).then(r=>{this.isActive(e,n)&&(this.helloReceived=!0,this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",n,o),this.opts.onConnectHello?.(r,i),this.invoke("hello",()=>this.opts.onHello?.(r)))}).catch(r=>{if(!this.isActive(e,n))return;let s=r instanceof G?r:new G({message:String(r)}),l=this.opts.onConnectFailure?.(s,i)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:s,reconnectDelayMs:l.reconnectDelayMs},l.stop&&(this.stopped=!0),e.close(l.closeCode,l.closeReason)})}handleMessage(e,n,o){if(!this.isActive(e,n))return;let i;try{i=JSON.parse(o)}catch(r){this.opts.onParseError?.(r);return}if(Ee(i)){if(this.opts.onActivity?.(),i.event==="connect.challenge"){let s=i.payload,l=typeof s?.nonce=="string"?s.nonce.trim():"";if(!l){if(this.opts.handshake.mode==="require-challenge"){let d=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(d),e.close(1008,"connect challenge missing nonce")}return}this.connectNonce=l,this.recordTiming("challenge",n),this.sendConnect(e,n);return}let r=typeof i.seq=="number"?i.seq:null;if(r!==null){if(this.lastSeq!==null&&r>this.lastSeq+1){let s=this.lastSeq+1;this.invoke("gap",()=>this.opts.onGap?.({expected:s,received:r}))}this.lastSeq=r}this.invoke("event",()=>this.opts.onEvent?.(i));for(let s of this.listeners)this.invoke("event listener",()=>s(i));return}Ae(i)&&(this.opts.onActivity?.(),this.handleResponse(i))}handleResponse(e){let n=this.pending.get(e.id);if(!n)return;let o=e.payload?.status;if(n.expectFinal&&o==="accepted"){n.acceptedNotified||(n.acceptedNotified=!0,this.invoke("accepted",()=>n.onAccepted?.(e.payload)));return}if(this.pending.delete(e.id),n.cleanup?.(),e.ok){this.finishRequestTiming(e.id,n,!0),n.resolve(e.payload);return}this.finishRequestTiming(e.id,n,!1,e.error?.code),n.reject(this.opts.createRequestError?.(e.error??{})??new G(e.error??{}))}handleClose(e,n,o,i){if(this.socket!==e){if(this.stoppedSocket?.socket===e){let l={...this.stoppedSocket.context,code:o,reason:i};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(l,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let r={...this.closeContext(),code:o,reason:i,generation:n};this.connectFailure=void 0;let s=this.opts.resolveClose(r);this.flushRequests(s.pendingError??r.connectFailure?.error??new Error(`gateway closed (${o}): ${i}`)),this.invoke("close",()=>this.opts.onClose?.(r,s)),s.retry&&!this.stopped&&this.scheduleReconnect(s.reconnectDelayMs??r.connectFailure?.reconnectDelayMs)}handleSocketError(e,n,o){!this.isActive(e,n)||this.connectSent||this.opts.onConnectError?.(o)}flushRequests(e){for(let[n,o]of this.pending)this.finishRequestTiming(n,o,!1,"CLIENT_CLOSED"),o.cleanup?.(),o.reject(e);this.pending.clear()}finishRequestTiming(e,n,o,i){let r=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id:e,method:n.method,ok:o,durationMs:Math.max(0,r-n.startedAtMs),startedAtMs:n.startedAtMs,endedAtMs:r,errorCode:i}))}scheduleReconnect(e){e!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=e);let n=this.reconnectSupervisor.next();n&&ge(n.delayMs,n.signal).then(()=>this.connect(),()=>{})}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(e,n){return!this.stopped&&this.socket===e&&this.generation===n}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer&&(clearTimeout(this.handshakeTimer),this.handshakeTimer=null)}invoke(e,n){try{n()}catch(o){this.opts.onCallbackError?.(e,o)}}};var Bt=new Set([g.AUTH_TOKEN_MISSING,g.AUTH_BOOTSTRAP_TOKEN_INVALID,g.AUTH_PASSWORD_MISSING,g.AUTH_PASSWORD_MISMATCH,g.AUTH_RATE_LIMITED,g.AUTH_DEVICE_TOKEN_MISMATCH,g.AUTH_SCOPE_MISMATCH,g.PAIRING_REQUIRED,g.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,g.DEVICE_IDENTITY_REQUIRED]);var Re={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var Ie={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},et={APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},Vt=new Set(Object.values(Re)),Yt=new Set(Object.values(Ie));var tt=4,nt=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var Pe=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:W,n:Y,Gx:Ce,Gy:Se,a:ne,d:oe,h:ot}=Pe,U=32,rt=(...t)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...t)},T=(t="")=>{let e=new Error(t);throw rt(e,T),e},it=t=>typeof t=="bigint",st=t=>typeof t=="string",at=t=>t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in t&&t.BYTES_PER_ELEMENT===1,R=(t,e,n="")=>{let o=at(t),i=t?.length,r=e!==void 0;if(!o||r&&i!==e){let s=n&&`"${n}" `,l=r?` of length ${e}`:"",d=o?`length=${i}`:`type=${typeof t}`,c=s+"expected Uint8Array"+l+", got "+d;throw o?new RangeError(c):new TypeError(c)}return t},z=t=>new Uint8Array(t),ce=t=>Uint8Array.from(t),De=(t,e)=>t.toString(16).padStart(e,"0"),Ne=t=>Array.from(R(t)).map(e=>De(e,2)).join(""),I={_0:48,_9:57,A:65,F:70,a:97,f:102},ve=t=>{if(t>=I._0&&t<=I._9)return t-I._0;if(t>=I.A&&t<=I.F)return t-(I.A-10);if(t>=I.a&&t<=I.f)return t-(I.a-10)},xe=t=>{let e="hex invalid";if(!st(t))return T(e);let n=t.length,o=n/2;if(n%2)return T(e);let i=z(o);for(let r=0,s=0;rglobalThis?.crypto,ct=()=>Oe()?.subtle??T("crypto.subtle must be defined, consider polyfill"),H=(...t)=>{let e=0;for(let i of t)e+=R(i).length;let n=z(e),o=0;return t.forEach(i=>{n.set(i,o),o+=i.length}),n},dt=(t=U)=>Oe().getRandomValues(z(t)),K=BigInt,w=(t,e,n,o="bad number: out of range")=>{if(!it(t))throw new TypeError(o);if(e<=t&&t{let n=t%e;return n>=0n?n:e+n},be=(1n<<255n)-1n,a=t=>{t<0n&&T("negative coordinate");let e=(t>>255n)*19n+(t&be);return e=(e>>255n)*19n+(e&be),e%W},Ge=t=>y(t,Y),lt=(t,e)=>{(t===0n||e<=0n)&&T("no inverse n="+t+" mod="+e);let n=y(t,e),o=e,i=0n,r=1n,s=1n,l=0n;for(;n!==0n;){let d=o/n,c=o%n,u=i-s*d,p=r-l*d;o=n,n=c,i=s,r=l,s=u,l=p}return o===1n?y(i,e):T("no inverse")},Ue=t=>{let e=At[t];return typeof e!="function"&&T("hashes."+t+" not set"),e},qe=t=>R(t,64,"digest");var re=t=>t instanceof k?t:T("Point expected"),ie=2n**256n,k=class t{static BASE;static ZERO;X;Y;Z;T;constructor(e,n,o,i){let r=ie;this.X=w(e,0n,r),this.Y=w(n,0n,r),this.Z=w(o,1n,r),this.T=w(i,0n,r),Object.freeze(this)}static CURVE(){return Pe}static fromAffine(e){return new t(e.x,e.y,1n,a(e.x*e.y))}static fromBytes(e,n=!1){let o=oe,i=ce(R(e,U)),r=e[31];i[31]=r&-129;let s=Le(i);w(s,0n,n?ie:W);let d=a(s*s),c=y(d-1n),u=a(o*d+1n),{isValid:p,value:f}=pt(c,u);p||T("bad point: y not sqrt");let h=(f&1n)===1n,m=(r&128)!==0;return!n&&f===0n&&m&&T("bad point: x==0, isLastByteOdd"),m!==h&&(f=y(-f)),new t(f,s,1n,a(f*s))}static fromHex(e,n){return t.fromBytes(xe(e),n)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let e=ne,n=oe,o=this;if(o.is0())return T("bad point: ZERO");let{X:i,Y:r,Z:s,T:l}=o,d=a(i*i),c=a(r*r),u=a(s*s),p=a(u*u),f=a(d*e),h=a(u*(f+c)),m=y(p+a(n*a(d*c)));if(h!==m)return T("bad point: equation left != right (1)");let C=a(i*r),S=a(s*l);return C!==S?T("bad point: equation left != right (2)"):this}equals(e){let{X:n,Y:o,Z:i}=this,{X:r,Y:s,Z:l}=re(e),d=a(n*l),c=a(r*i),u=a(o*l),p=a(s*i);return d===c&&u===p}is0(){return this.equals(M)}negate(){return new t(y(-this.X),this.Y,this.Z,y(-this.T))}double(){let{X:e,Y:n,Z:o}=this,i=ne,r=a(e*e),s=a(n*n),l=a(2n*o*o),d=a(i*r),c=y(e+n),u=y(a(c*c)-r-s),p=y(d+s),f=y(p-l),h=y(d-s),m=a(u*f),C=a(p*h),S=a(u*h),D=a(f*p);return new t(m,C,D,S)}add(e){let{X:n,Y:o,Z:i,T:r}=this,{X:s,Y:l,Z:d,T:c}=re(e),u=ne,p=oe,f=a(n*s),h=a(o*l),m=a(a(r*p)*c),C=a(i*d),S=y(a(y(n+o)*y(s+l))-f-h),D=y(C-m),E=y(C+m),A=y(h-a(u*f)),N=a(S*D),x=a(E*A),O=a(S*A),L=a(D*E);return new t(N,x,L,O)}subtract(e){return this.add(re(e).negate())}multiply(e,n=!0){if(!n&&e===0n||(w(e,1n,Y),!n&&this.is0()))return M;if(e===1n)return this;if(this.equals(P))return Ct(e).p;let o=M,i=P;for(let r=this;e>0n;r=r.double(),e>>=1n)e&1n?o=o.add(r):n&&(i=i.add(r));return o}multiplyUnsafe(e){return this.multiply(e,!1)}toAffine(){let{X:e,Y:n,Z:o}=this;if(this.equals(M))return{x:0n,y:1n};let i=lt(o,W);a(o*i)!==1n&&T("invalid inverse");let r=a(e*i),s=a(n*i);return{x:r,y:s}}toBytes(){let{x:e,y:n}=this.toAffine(),o=He(n);return o[31]|=e&1n?128:0,o}toHex(){return Ne(this.toBytes())}clearCofactor(){return this.multiply(K(ot),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let e=this.multiply(Y/2n,!1).double();return Y%2n&&(e=e.add(this)),e.is0()}},P=new k(Ce,Se,1n,y(Ce*Se)),M=new k(0n,1n,1n,0n);k.BASE=P;k.ZERO=M;var He=t=>xe(De(w(t,0n,ie),64)).reverse(),Le=t=>K("0x"+Ne(ce(R(t)).reverse())),_=(t,e)=>{let n=t;for(;e-- >0n;)n=a(n*n);return n},ut=t=>{let e=a(t*t),n=a(e*t),o=a(_(n,2n)*n),i=a(_(o,1n)*t),r=a(_(i,5n)*i),s=a(_(r,10n)*r),l=a(_(s,20n)*s),d=a(_(l,40n)*l),c=a(_(d,80n)*d),u=a(_(c,80n)*d),p=a(_(u,10n)*r);return{pow_p_5_8:a(_(p,2n)*t),b2:n}},we=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,pt=(t,e)=>{let n=a(e*a(e*e)),o=a(a(n*n)*e),i=ut(a(t*o)).pow_p_5_8,r=a(t*a(n*i)),s=a(e*a(r*r)),l=r,d=a(r*we),c=s===t,u=s===y(-t),p=s===y(-t*we);return c&&(r=l),(u||p)&&(r=d),(y(r)&1n)===1n&&(r=y(-r)),{isValid:c||u,value:r}},se=t=>Ge(Le(t)),de=(...t)=>Promise.resolve(Ue("sha512Async")(H(...t))).then(qe),ft=(...t)=>qe(Ue("sha512")(H(...t))),Be=t=>{let e=ce(t),n=e.slice(0,32);n[0]&=248,n[31]&=127,n[31]|=64;let o=e.slice(32,64),i=se(n),r=P.multiply(i),s=r.toBytes();return{head:n,prefix:o,scalar:i,point:r,pointBytes:s}},le=t=>de(R(t,U)).then(Be),yt=t=>Be(ft(R(t,U))),ht=t=>le(t).then(e=>e.pointBytes);var Tt=t=>de(t.hashable).then(t.finish);var mt=(t,e,n)=>{let{pointBytes:o,scalar:i}=t,r=se(e),s=P.multiply(r).toBytes();return{hashable:H(s,o,n),finish:c=>{let u=Ge(r+se(c)*i);return R(H(s,He(u)),64)}}},Et=async(t,e)=>{let n=R(t),o=await le(e),i=await de(o.prefix,n);return Tt(mt(o,i,n))};var At={sha512Async:async t=>{let e=ct(),n=H(t);return z(await e.digest("SHA-512",n.buffer))},sha512:void 0},gt=t=>(t=t===void 0?dt(U):t,R(t,U));var _t=Object.freeze({getExtendedPublicKeyAsync:le,getExtendedPublicKey:yt,randomSecretKey:gt}),X=8,Rt=256,Fe=Math.ceil(Rt/X)+1,ae=2**(X-1),It=()=>{let t=[],e=P,n=e;for(let o=0;o{let n=e.negate();return t?n:e},Ct=t=>{let e=Me||(Me=It()),n=M,o=P,i=2**X,r=i,s=K(i-1),l=K(X);for(let d=0;d>=l,c>ae&&(c-=r,t+=1n);let u=d*ae,p=u,f=u+Math.abs(c)-1,h=d%2!==0,m=c<0;c===0?o=o.add(ke(h,e[p])):n=n.add(ke(m,e[f]))}return t!==0n&&T("invalid wnaf"),{p:n,f:o}};export{et as GATEWAY_CLIENT_CAPS,Re as GATEWAY_CLIENT_IDS,Ie as GATEWAY_CLIENT_MODES,Q as GatewayBrowserDeviceAuthLifecycle,te as GatewayProtocolClient,G as GatewayProtocolRequestError,nt as MIN_CLIENT_PROTOCOL_VERSION,tt as PROTOCOL_VERSION,_t as ed25519Utils,ht as getPublicKeyAsync,Et as signAsync}; diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts b/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts new file mode 100644 index 000000000000..aa108ecea0cc --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session-registry.d.ts @@ -0,0 +1,65 @@ +import type { BrowserCopilotBinding } from "./panel-core.js"; + +export type CopilotSessionEntry = { + tabId: number; + browserInstanceId: string; + gatewayScope: string; + sessionKey: string; + sessionId?: string; + binding?: BrowserCopilotBinding; + createdAt?: number; + provisional?: boolean; + creationPending?: boolean; + activeRunId?: string; + abortPending?: boolean; +}; + +export type CopilotArchiveEntry = { + tabId?: number; + gatewayScope: string; + sessionKey: string; + sessionId?: string; + ensureCreated?: boolean; + queuedAt: number; +}; + +export class CopilotPanelBindingRegistry { + constructor(storage?: unknown); + initialize(): Promise; + bind(tabId: number): Promise; + resolve(token: string): Promise; + remove(tabId: number): Promise; +} + +export class CopilotSessionRegistry { + constructor(storage?: unknown); + initialize(existingTabIds: Set): Promise; + get(tabId: number, gatewayScope: string): CopilotSessionEntry | null; + list(): CopilotSessionEntry[]; + gatewayScopes(): string[]; + pendingArchives(gatewayScope: string): CopilotArchiveEntry[]; + put( + tabId: number, + entry: Omit, + ): Promise; + updateBinding(tabId: number, gatewayScope: string, binding: BrowserCopilotBinding): Promise; + confirmSession( + tabId: number, + gatewayScope: string, + sessionId?: string, + ): Promise; + markSessionCreationPending( + tabId: number, + gatewayScope: string, + ): Promise; + discardProvisionalSession(tabId: number, gatewayScope: string): Promise; + startRun(tabId: number, gatewayScope: string, runId: string): Promise; + queueAbort(tabId: number, gatewayScope: string): Promise; + queueActiveAborts(gatewayScope: string): Promise; + pendingAborts(gatewayScope: string): CopilotSessionEntry[]; + finishRun(gatewayScope: string, sessionKey: string, runId: string): Promise; + closeTab(tabId: number): Promise; + closeScope(gatewayScope: string): Promise; + closeInactiveScope(gatewayScope: string): Promise; + resolveArchive(gatewayScope: string, sessionKey: string): Promise; +} diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.js b/extensions/browser/chrome-extension/modules/copilot-session-registry.js new file mode 100644 index 000000000000..3475d2063e2f --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session-registry.js @@ -0,0 +1,345 @@ +const LOCAL_KEY = "copilotSessionRegistryV1"; +const INSTANCE_KEY = "copilotBrowserInstanceV1"; +const PANEL_BINDINGS_KEY = "copilotPanelBindingsV1"; + +function emptyState() { + return { sessions: {}, pendingArchives: [] }; +} + +/** Browser-instance-only capabilities bind same-path panel documents to tabs. */ +export class CopilotPanelBindingRegistry { + constructor(storage = chrome.storage.session) { + this.storage = storage; + this.byTab = {}; + this.ready = null; + this.writeChain = Promise.resolve(); + } + + async initialize() { + if (!this.ready) { + this.ready = (async () => { + const stored = (await this.storage.get([PANEL_BINDINGS_KEY]))[PANEL_BINDINGS_KEY]; + this.byTab = stored && typeof stored === "object" ? stored : {}; + })(); + } + await this.ready; + } + + async bind(tabId) { + await this.initialize(); + let token; + this.writeChain = this.writeChain.then(async () => { + const current = this.byTab[String(tabId)]; + if (typeof current === "string" && current) { + token = current; + return; + } + token = crypto.randomUUID(); + this.byTab[String(tabId)] = token; + await this.storage.set({ [PANEL_BINDINGS_KEY]: this.byTab }); + }); + await this.writeChain; + return token; + } + + async resolve(token) { + await this.initialize(); + for (const [rawTabId, candidate] of Object.entries(this.byTab)) { + if (candidate === token) { + return Number(rawTabId); + } + } + return null; + } + + async remove(tabId) { + await this.initialize(); + await this.#mutate(() => { + delete this.byTab[String(tabId)]; + }); + } + + async #mutate(run) { + this.writeChain = this.writeChain.then(async () => { + run(); + await this.storage.set({ [PANEL_BINDINGS_KEY]: this.byTab }); + }); + await this.writeChain; + } +} + +/** Durable registry: worker suspension preserves bindings; browser restart archives orphans. */ +export class CopilotSessionRegistry { + constructor(storage = chrome.storage) { + this.storage = storage; + this.state = emptyState(); + this.instanceId = null; + this.ready = null; + this.writeChain = Promise.resolve(); + } + + async initialize(existingTabIds) { + if (this.ready) { + return await this.ready; + } + this.ready = this.#initialize(existingTabIds); + return await this.ready; + } + + async #initialize(existingTabIds) { + const sessionStored = await this.storage.session.get([INSTANCE_KEY]); + this.instanceId = sessionStored[INSTANCE_KEY]; + if (typeof this.instanceId !== "string" || !this.instanceId) { + this.instanceId = crypto.randomUUID(); + await this.storage.session.set({ [INSTANCE_KEY]: this.instanceId }); + } + const localStored = await this.storage.local.get([LOCAL_KEY]); + const candidate = localStored[LOCAL_KEY]; + this.state = + candidate && typeof candidate === "object" + ? { + sessions: + candidate.sessions && typeof candidate.sessions === "object" + ? candidate.sessions + : {}, + pendingArchives: Array.isArray(candidate.pendingArchives) + ? candidate.pendingArchives + : [], + } + : emptyState(); + for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { + const tabId = Number(rawTabId); + if (entry?.browserInstanceId === this.instanceId && existingTabIds.has(tabId)) { + continue; + } + this.#queueArchive(entry); + delete this.state.sessions[rawTabId]; + } + await this.#persist(); + return this.instanceId; + } + + get(tabId, gatewayScope) { + const entry = this.state.sessions[String(tabId)] ?? null; + return entry?.gatewayScope === gatewayScope ? entry : null; + } + + list() { + return Object.values(this.state.sessions); + } + + gatewayScopes() { + return [ + ...new Set([ + ...this.list().map((entry) => entry.gatewayScope), + ...this.state.pendingArchives.map((entry) => entry.gatewayScope), + ]), + ].filter((scope) => typeof scope === "string" && scope); + } + + pendingArchives(gatewayScope) { + return this.state.pendingArchives.filter((entry) => entry.gatewayScope === gatewayScope); + } + + async put(tabId, entry) { + await this.#mutate(() => { + const current = this.state.sessions[String(tabId)]; + if (current && current.gatewayScope !== entry.gatewayScope) { + // The write chain transfers old-scope custody to the durable archive + // queue before replacement, so concurrent recovery cannot lose it. + this.#queueArchive(current); + } + this.state.sessions[String(tabId)] = { + ...entry, + tabId, + browserInstanceId: this.instanceId, + }; + }); + return this.get(tabId, entry.gatewayScope); + } + + async updateBinding(tabId, gatewayScope, binding) { + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (current) { + current.binding = { ...binding }; + } + }); + } + + async confirmSession(tabId, gatewayScope, sessionId) { + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (!current) { + return; + } + if (typeof sessionId === "string" && sessionId) { + current.sessionId = sessionId; + } + delete current.provisional; + delete current.creationPending; + }); + return this.get(tabId, gatewayScope); + } + + async markSessionCreationPending(tabId, gatewayScope) { + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (current?.provisional) { + current.creationPending = true; + } + }); + return this.get(tabId, gatewayScope); + } + + async discardProvisionalSession(tabId, gatewayScope) { + let discarded = false; + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (!current?.provisional) { + return; + } + delete this.state.sessions[String(tabId)]; + discarded = true; + }); + return discarded; + } + + async startRun(tabId, gatewayScope, runId) { + let started = null; + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (!current || current.activeRunId) { + return; + } + current.activeRunId = runId; + current.abortPending = false; + started = current; + }); + return started; + } + + async queueAbort(tabId, gatewayScope) { + let queued = null; + await this.#mutate(() => { + const current = this.get(tabId, gatewayScope); + if (!current?.activeRunId) { + return; + } + current.abortPending = true; + queued = current; + }); + return queued; + } + + async queueActiveAborts(gatewayScope) { + await this.#mutate(() => { + for (const entry of Object.values(this.state.sessions)) { + if (entry?.gatewayScope === gatewayScope && entry.activeRunId) { + entry.abortPending = true; + } + } + }); + } + + pendingAborts(gatewayScope) { + return this.list().filter( + (entry) => entry.gatewayScope === gatewayScope && entry.activeRunId && entry.abortPending, + ); + } + + async finishRun(gatewayScope, sessionKey, runId) { + let finished = false; + await this.#mutate(() => { + const current = this.list().find( + (entry) => entry.gatewayScope === gatewayScope && entry.sessionKey === sessionKey, + ); + if (!current || current.activeRunId !== runId) { + return; + } + delete current.activeRunId; + delete current.abortPending; + finished = true; + }); + return finished; + } + + async closeTab(tabId) { + let closed = null; + await this.#mutate(() => { + closed = this.state.sessions[String(tabId)] ?? null; + if (closed) { + this.#queueArchive(closed); + delete this.state.sessions[String(tabId)]; + } + }); + return closed; + } + + async closeScope(gatewayScope) { + await this.#mutate(() => { + for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { + if (entry?.gatewayScope !== gatewayScope) { + continue; + } + this.#queueArchive(entry); + delete this.state.sessions[rawTabId]; + } + }); + } + + async closeInactiveScope(gatewayScope) { + await this.#mutate(() => { + for (const [rawTabId, entry] of Object.entries(this.state.sessions)) { + if (entry?.gatewayScope !== gatewayScope || entry.activeRunId) { + continue; + } + this.#queueArchive(entry); + delete this.state.sessions[rawTabId]; + } + }); + } + + async resolveArchive(gatewayScope, sessionKey) { + await this.#mutate(() => { + this.state.pendingArchives = this.state.pendingArchives.filter( + (entry) => entry.gatewayScope !== gatewayScope || entry.sessionKey !== sessionKey, + ); + }); + } + + #queueArchive(entry) { + if (!entry?.sessionKey || !entry?.gatewayScope) { + return; + } + if (entry.provisional && entry.creationPending !== true) { + return; + } + const existing = this.state.pendingArchives.some( + (candidate) => + candidate.gatewayScope === entry.gatewayScope && candidate.sessionKey === entry.sessionKey, + ); + if (!existing) { + this.state.pendingArchives.push({ + gatewayScope: entry.gatewayScope, + sessionKey: entry.sessionKey, + sessionId: entry.sessionId, + tabId: entry.tabId, + ensureCreated: entry.provisional === true, + queuedAt: Date.now(), + }); + } + } + + async #mutate(run) { + this.writeChain = this.writeChain.then(async () => { + run(); + await this.#persist(); + }); + await this.writeChain; + } + + async #persist() { + await this.storage.local.set({ [LOCAL_KEY]: this.state }); + } +} diff --git a/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts b/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts new file mode 100644 index 000000000000..963938766b8c --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session-registry.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { CopilotPanelBindingRegistry, CopilotSessionRegistry } from "./copilot-session-registry.js"; + +const GATEWAY_SCOPE = "ws://127.0.0.1:18789/"; + +function storageArea(initial: Record = {}) { + const values = { ...initial }; + const setCalls: Record[] = []; + return { + setCalls, + values, + async get(keys: string[]) { + return Object.fromEntries(keys.map((key) => [key, values[key]])); + }, + async set(update: Record) { + setCalls.push(update); + Object.assign(values, update); + }, + }; +} + +function storage(localInitial: Record = {}, sessionInitial = {}) { + return { local: storageArea(localInitial), session: storageArea(sessionInitial) }; +} + +describe("CopilotSessionRegistry", () => { + it("archives prior-browser and missing-tab sessions during recovery", async () => { + const mock = storage( + { + copilotSessionRegistryV1: { + sessions: { + 1: { + browserInstanceId: "old", + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-old", + sessionId: "id-old", + }, + 2: { + browserInstanceId: "current", + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-closed", + sessionId: "id-closed", + }, + 3: { + browserInstanceId: "current", + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-live", + sessionId: "id-live", + }, + }, + pendingArchives: [], + }, + }, + { copilotBrowserInstanceV1: "current" }, + ); + const registry = new CopilotSessionRegistry(mock as never); + + await registry.initialize(new Set([1, 3])); + + expect(registry.get(1, GATEWAY_SCOPE)).toBeNull(); + expect(registry.get(2, GATEWAY_SCOPE)).toBeNull(); + expect(registry.get(3, GATEWAY_SCOPE)?.sessionKey).toBe("session-live"); + expect(registry.pendingArchives(GATEWAY_SCOPE).map((entry) => entry.sessionKey)).toEqual([ + "session-old", + "session-closed", + ]); + }); + + it("moves a closed tab to the durable archive queue exactly once", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + await registry.initialize(new Set([8])); + await registry.put(8, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-8", + sessionId: "id-8", + }); + + await registry.closeTab(8); + await registry.closeTab(8); + + expect(registry.get(8, GATEWAY_SCOPE)).toBeNull(); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([ + expect.objectContaining({ sessionKey: "session-8", tabId: 8 }), + ]); + await registry.resolveArchive(GATEWAY_SCOPE, "session-8"); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); + }); + + it("keeps a provisional session key until Gateway creation is confirmed", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + await registry.initialize(new Set([11])); + await registry.put(11, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-provisional", + provisional: true, + }); + + expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ + provisional: true, + sessionKey: "session-provisional", + }); + await registry.markSessionCreationPending(11, GATEWAY_SCOPE); + expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ creationPending: true }); + await registry.confirmSession(11, GATEWAY_SCOPE, "id-provisional"); + expect(registry.get(11, GATEWAY_SCOPE)).toMatchObject({ + sessionId: "id-provisional", + sessionKey: "session-provisional", + }); + expect(registry.get(11, GATEWAY_SCOPE)).not.toHaveProperty("provisional"); + expect(registry.get(11, GATEWAY_SCOPE)).not.toHaveProperty("creationPending"); + }); + + it("archives a provisional key only after its creation RPC can have reached Gateway", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + await registry.initialize(new Set([11, 12])); + await registry.put(11, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-not-attempted", + provisional: true, + creationPending: false, + }); + await registry.closeTab(11); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); + + await registry.put(12, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-attempted", + provisional: true, + creationPending: false, + }); + await registry.markSessionCreationPending(12, GATEWAY_SCOPE); + await registry.closeTab(12); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([ + expect.objectContaining({ + sessionKey: "session-attempted", + tabId: 12, + ensureCreated: true, + }), + ]); + }); + + it("drops a definitively rejected provisional session without archiving it", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + await registry.initialize(new Set([13])); + await registry.put(13, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-rejected", + provisional: true, + creationPending: true, + }); + + await expect(registry.discardProvisionalSession(13, GATEWAY_SCOPE)).resolves.toBe(true); + expect(registry.get(13, GATEWAY_SCOPE)).toBeNull(); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); + }); + + it("never reuses or drains session custody across Gateways", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + const otherGateway = "ws://127.0.0.1:28789/"; + await registry.initialize(new Set([9])); + await registry.put(9, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-a", + }); + await registry.put(9, { + gatewayScope: otherGateway, + sessionKey: "session-b", + }); + + expect(registry.get(9, GATEWAY_SCOPE)).toBeNull(); + expect(registry.get(9, otherGateway)?.sessionKey).toBe("session-b"); + expect(registry.pendingArchives(otherGateway)).toEqual([]); + expect(registry.pendingArchives(GATEWAY_SCOPE).map((entry) => entry.sessionKey)).toEqual([ + "session-a", + ]); + await registry.resolveArchive(otherGateway, "session-a"); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toHaveLength(1); + await registry.resolveArchive(GATEWAY_SCOPE, "session-a"); + expect(registry.pendingArchives(GATEWAY_SCOPE)).toEqual([]); + }); + + it("persists active-run cancellation until the owning Gateway resolves it", async () => { + const mock = storage(); + const registry = new CopilotSessionRegistry(mock as never); + await registry.initialize(new Set([10])); + await registry.put(10, { + gatewayScope: GATEWAY_SCOPE, + sessionKey: "session-10", + }); + + await expect(registry.startRun(10, GATEWAY_SCOPE, "run-10")).resolves.toMatchObject({ + activeRunId: "run-10", + }); + await registry.queueActiveAborts(GATEWAY_SCOPE); + expect(registry.pendingAborts(GATEWAY_SCOPE)).toEqual([ + expect.objectContaining({ + abortPending: true, + activeRunId: "run-10", + sessionKey: "session-10", + }), + ]); + await expect(registry.finishRun(GATEWAY_SCOPE, "session-10", "stale-run")).resolves.toBe(false); + expect(registry.pendingAborts(GATEWAY_SCOPE)).toHaveLength(1); + await expect(registry.finishRun(GATEWAY_SCOPE, "session-10", "run-10")).resolves.toBe(true); + expect(registry.pendingAborts(GATEWAY_SCOPE)).toEqual([]); + }); +}); + +describe("CopilotPanelBindingRegistry", () => { + it("mints one browser-instance capability per tab and removes it on close", async () => { + const area = storageArea(); + const bindings = new CopilotPanelBindingRegistry(area as never); + + const [first, second] = await Promise.all([bindings.bind(7), bindings.bind(7)]); + + expect(first).toBe(second); + expect(area.setCalls).toHaveLength(1); + await expect(bindings.bind(7)).resolves.toBe(first); + expect(area.setCalls).toHaveLength(1); + await expect(bindings.resolve(first)).resolves.toBe(7); + await bindings.remove(7); + await expect(bindings.resolve(first)).resolves.toBeNull(); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/copilot-session.d.ts b/extensions/browser/chrome-extension/modules/copilot-session.d.ts new file mode 100644 index 000000000000..910e2939fd6c --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session.d.ts @@ -0,0 +1,18 @@ +import type { CopilotSessionEntry, CopilotSessionRegistry } from "./copilot-session-registry.js"; + +export function createCopilotSessionController( + options: Record & { + registry: CopilotSessionRegistry; + }, +): { + ensureSession: ( + tabId: number, + options?: { hydrateHistory?: boolean }, + ) => Promise; + sendMessage: ( + tabId: number, + port: unknown, + portRevision: number, + text: string, + ) => Promise; +}; diff --git a/extensions/browser/chrome-extension/modules/copilot-session.js b/extensions/browser/chrome-extension/modules/copilot-session.js new file mode 100644 index 000000000000..4d245936766d --- /dev/null +++ b/extensions/browser/chrome-extension/modules/copilot-session.js @@ -0,0 +1,314 @@ +import { resolveBindingTarget } from "./copilot-background-shared.js"; +import { isDefinitiveGatewayRejection } from "./copilot-gateway.js"; +import { buildCopilotChatSendParams, deriveTabSessionKey } from "./panel-core.js"; + +/** Session/run owner for one tab-bound panel. */ +export function createCopilotSessionController({ + chromeApi, + gateway, + registry, + ensureByTab, + tabRevisions, + portsByTab, + portRevisions, + sendsByTab, + currentGatewayScope, + getGatewayRevision, + getCurrentConfig, + isConfigTransitioning, + currentReadyEpoch, + readyEpochIsCurrent, + isTabShared, + attachDebugger, + revokeDebugger, + restoreDebuggerIfReleased, + subscribe, + unsubscribeTab, + suspendTab, + hydrate, + refreshPanelState, + drainArchives, + scheduleAbortRetry, +}) { + function sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) { + return ( + (tabRevisions.get(tabId) ?? 0) === tabRevision && + !isConfigTransitioning() && + getGatewayRevision() === configRevision && + currentGatewayScope() === gatewayScope + ); + } + + async function sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope) { + if ( + !sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) || + !portsByTab.has(tabId) + ) { + return false; + } + try { + const shared = await isTabShared(tabId); + return ( + shared && + portsByTab.has(tabId) && + sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope) + ); + } catch { + return false; + } + } + + async function suspendUnauthorizedSetup(tabId) { + let shared = false; + try { + shared = await isTabShared(tabId); + } catch { + // Missing or unreadable tab state is not authorized to retain CDP access. + } + await suspendTab(tabId, { detachInactive: !shared }); + if (portsByTab.has(tabId)) { + void refreshPanelState(tabId); + } + } + + async function ensureSessionInner( + tabId, + tabRevision, + configRevision, + gatewayScope, + hydrateHistory, + ) { + if (!gateway.ready || !(await isTabShared(tabId))) { + return null; + } + const staleActiveSession = registry + .list() + .find( + (entry) => + entry.tabId === tabId && entry.gatewayScope !== gatewayScope && entry.activeRunId, + ); + if (staleActiveSession) { + throw new Error("This tab is still stopping a run from its previous Gateway."); + } + const { targetId } = await attachDebugger(tabId); + if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { + return null; + } + if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { + await suspendUnauthorizedSetup(tabId); + return null; + } + const binding = { + kind: "tab", + tabId, + target: resolveBindingTarget(getCurrentConfig()), + profile: "chrome", + targetId, + }; + let entry = registry.get(tabId, gatewayScope); + if (entry) { + await registry.updateBinding(tabId, gatewayScope, binding); + entry = registry.get(tabId, gatewayScope); + } else { + const mainSessionKey = gateway.hello?.snapshot?.sessionDefaults?.mainSessionKey; + const sessionKey = deriveTabSessionKey(mainSessionKey, crypto.randomUUID()); + if (!sessionKey) { + throw new Error("Gateway did not provide a main session key."); + } + entry = await registry.put(tabId, { + gatewayScope, + sessionKey, + binding, + createdAt: Date.now(), + provisional: true, + creationPending: false, + }); + } + if (entry?.provisional) { + if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { + await registry.closeTab(tabId); + await drainArchives(gatewayScope); + return null; + } + // Persist the generated key before the RPC. Retrying sessions.create with + // that key adopts a commit whose response was lost instead of leaking it. + entry = await registry.markSessionCreationPending(tabId, gatewayScope); + if (!entry) { + return null; + } + let created; + try { + created = await gateway.request("sessions.create", { + key: entry.sessionKey, + label: "Browser copilot", + }); + } catch (error) { + if (isDefinitiveGatewayRejection(error)) { + await registry.discardProvisionalSession(tabId, gatewayScope); + } + throw error; + } + entry = await registry.confirmSession(tabId, gatewayScope, created?.sessionId); + if (!entry) { + return null; + } + try { + await chromeApi.tabs.get(tabId); + } catch { + await registry.closeTab(tabId); + await drainArchives(gatewayScope); + return null; + } + } + if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { + await registry.closeTab(tabId); + await drainArchives(gatewayScope); + return null; + } + if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { + await suspendUnauthorizedSetup(tabId); + return null; + } + await subscribe(entry); + if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { + await unsubscribeTab(tabId, gatewayScope); + await registry.closeTab(tabId); + await drainArchives(gatewayScope); + return null; + } + if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { + await suspendUnauthorizedSetup(tabId); + return null; + } + if (hydrateHistory) { + await hydrate(tabId, entry); + } + if (!sessionSetupIsCurrent(tabId, tabRevision, configRevision, gatewayScope)) { + await unsubscribeTab(tabId, gatewayScope); + await registry.closeTab(tabId); + await drainArchives(gatewayScope); + return null; + } + if (!(await sessionSetupIsAuthorized(tabId, tabRevision, configRevision, gatewayScope))) { + await suspendUnauthorizedSetup(tabId); + return null; + } + return entry; + } + + async function ensureSession(tabId, { hydrateHistory = true } = {}) { + const current = ensureByTab.get(tabId); + if (current) { + current.hydrateHistory ||= hydrateHistory; + return await current.promise; + } + const readyEpoch = currentReadyEpoch(); + if (!readyEpoch) { + return null; + } + const gatewayScope = readyEpoch.gatewayScope; + const tabRevision = tabRevisions.get(tabId) ?? 0; + const configRevision = readyEpoch.configRevision; + const request = { hydrateHistory, promise: null }; + const pending = ensureSessionInner(tabId, tabRevision, configRevision, gatewayScope, false) + .then(async (entry) => { + if (entry && request.hydrateHistory) { + await hydrate(tabId, entry); + } + return entry; + }) + .finally(() => { + if (ensureByTab.get(tabId) === request) { + ensureByTab.delete(tabId); + } + }); + request.promise = pending; + ensureByTab.set(tabId, request); + return await pending; + } + + function panelOwnsSend(tabId, port, portRevision) { + return portRevisions.get(tabId) === portRevision && portsByTab.get(tabId)?.has(port) === true; + } + + async function sendMessage(tabId, port, portRevision, text) { + if (!panelOwnsSend(tabId, port, portRevision)) { + throw new Error("This panel is no longer attached to the tab."); + } + if (sendsByTab.has(tabId)) { + throw new Error("Wait for the current turn to finish."); + } + const readyEpoch = currentReadyEpoch(); + if (!readyEpoch) { + throw new Error("Gateway is still reconciling this tab."); + } + if (!(await isTabShared(tabId))) { + throw new Error("This tab is not shared with OpenClaw."); + } + const entry = await ensureSession(tabId, { hydrateHistory: false }); + if (!entry) { + throw new Error("This tab no longer exists."); + } + if (!readyEpochIsCurrent(readyEpoch) || entry.gatewayScope !== readyEpoch.gatewayScope) { + throw new Error("Gateway connection changed while preparing this tab."); + } + const params = buildCopilotChatSendParams({ + binding: entry.binding, + message: text, + sessionId: entry.sessionId, + sessionKey: entry.sessionKey, + }); + if (!readyEpochIsCurrent(readyEpoch)) { + throw new Error("Gateway connection changed while preparing this tab."); + } + const started = await registry.startRun(tabId, entry.gatewayScope, params.idempotencyKey); + if (!started) { + throw new Error("Wait for the current turn to finish."); + } + let submitted = false; + try { + const stillShared = await isTabShared(tabId); + const stillOwnsPanel = panelOwnsSend(tabId, port, portRevision); + const stillOwnsGateway = readyEpochIsCurrent(readyEpoch); + if (!stillShared || !stillOwnsPanel || !stillOwnsGateway) { + if (!stillShared || !stillOwnsPanel) { + await suspendTab(tabId, { detachInactive: !stillShared }); + } + throw new Error( + !stillShared + ? "This tab is not shared with OpenClaw." + : !stillOwnsPanel + ? "This panel is no longer attached to the tab." + : "Gateway connection changed while preparing this tab.", + ); + } + sendsByTab.add(tabId); + submitted = true; + return await gateway.request("chat.send", params); + } catch (error) { + sendsByTab.delete(tabId); + if (!submitted || isDefinitiveGatewayRejection(error)) { + const finished = await registry.finishRun( + entry.gatewayScope, + entry.sessionKey, + params.idempotencyKey, + ); + if (finished) { + await restoreDebuggerIfReleased(tabId); + } + } else { + await revokeDebugger(tabId); + const queued = await registry.queueAbort(tabId, entry.gatewayScope); + if (queued) { + scheduleAbortRetry(); + } else { + await restoreDebuggerIfReleased(tabId); + } + } + await refreshPanelState(tabId); + throw error; + } + } + + return { ensureSession, sendMessage }; +} diff --git a/extensions/browser/chrome-extension/modules/panel-core.d.ts b/extensions/browser/chrome-extension/modules/panel-core.d.ts new file mode 100644 index 000000000000..fdfc1ca612ab --- /dev/null +++ b/extensions/browser/chrome-extension/modules/panel-core.d.ts @@ -0,0 +1,41 @@ +export type BrowserCopilotBinding = { + kind: "tab"; + tabId: number; + target: "host"; + profile: string; + targetId: string; +}; + +export type ChatStream = { + runId: string | null; + full: string; + segmentStart: number; +}; + +export function deriveTabSessionKey(mainSessionKey: unknown, sessionId: unknown): string | null; +export function gatewayUrlFromPairing( + relayUrl: unknown, + explicitGatewayUrl: unknown, +): string | null; +export function normalizeGatewayUrl(raw: unknown): string | null; +export function buildCopilotChatSendParams(params: { + binding: BrowserCopilotBinding; + message: string; + sessionId?: string; + sessionKey: string; +}): { + sessionKey: string; + sessionId?: string; + message: string; + idempotencyKey: string; + deliver: false; + toolBindings: { browser: BrowserCopilotBinding }; +}; +export function createChatStream(): ChatStream; +export function resetChatStream(stream: ChatStream): void; +export function applyChatDelta( + stream: ChatStream, + payload: unknown, +): { text: string; newBubble: boolean } | null; +export function renderMarkdownLite(text: unknown): string; +export function readMessageText(message: unknown): string; diff --git a/extensions/browser/chrome-extension/modules/panel-core.js b/extensions/browser/chrome-extension/modules/panel-core.js new file mode 100644 index 000000000000..56008ec2b611 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/panel-core.js @@ -0,0 +1,139 @@ +// Chrome-free browser-copilot helpers. Kept small so the session/binding and +// rendering invariants run in the normal extension Vitest lane. + +/** Mint an isolated child thread without exposing the tab id in Gateway state. */ +export function deriveTabSessionKey(mainSessionKey, sessionId) { + if (typeof mainSessionKey !== "string" || !mainSessionKey.trim()) { + return null; + } + if (typeof sessionId !== "string" || !/^[0-9a-f-]{36}$/i.test(sessionId)) { + return null; + } + const threadIndex = mainSessionKey.indexOf(":thread:"); + const base = threadIndex === -1 ? mainSessionKey : mainSessionKey.slice(0, threadIndex); + return `${base}:thread:browser-copilot-${sessionId.toLowerCase()}`; +} + +/** Derive the direct Gateway endpoint embedded by the pairing command. */ +export function gatewayUrlFromPairing(relayUrl, explicitGatewayUrl) { + if (typeof explicitGatewayUrl === "string" && explicitGatewayUrl.trim()) { + return normalizeGatewayUrl(explicitGatewayUrl); + } + let parsed; + try { + parsed = new URL(String(relayUrl ?? "")); + } catch { + return null; + } + const suffix = "/browser/extension"; + if (!parsed.pathname.endsWith(suffix)) { + return null; + } + parsed.pathname = parsed.pathname.slice(0, -suffix.length) || "/"; + parsed.search = ""; + parsed.hash = ""; + return normalizeGatewayUrl(parsed.toString()); +} + +export function normalizeGatewayUrl(raw) { + let parsed; + try { + parsed = new URL(String(raw ?? "").trim()); + } catch { + return null; + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + return null; + } + const host = parsed.hostname.toLowerCase(); + const loopback = host === "localhost" || host === "127.0.0.1" || host === "[::1]"; + if (parsed.protocol !== "wss:" && !(parsed.protocol === "ws:" && loopback)) { + return null; + } + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString(); +} + +/** The panel supplies text only; Chrome-owned state supplies every routing fact. */ +export function buildCopilotChatSendParams({ binding, message, sessionId, sessionKey }) { + const text = typeof message === "string" ? message.trim() : ""; + if (!text) { + throw new Error("Message required."); + } + return { + sessionKey, + sessionId, + message: text, + idempotencyKey: crypto.randomUUID(), + deliver: false, + toolBindings: { browser: { ...binding } }, + }; +} + +export function createChatStream() { + return { runId: null, full: "", segmentStart: 0 }; +} + +export function resetChatStream(stream) { + stream.runId = null; + stream.full = ""; + stream.segmentStart = 0; +} + +/** Apply one cumulative/incremental chat event without duplicating text. */ +export function applyChatDelta(stream, payload) { + if (!payload || typeof payload !== "object") { + return null; + } + let newBubble = false; + if (payload.runId !== stream.runId) { + stream.runId = payload.runId ?? null; + stream.full = ""; + stream.segmentStart = 0; + newBubble = true; + } + const first = payload.message?.content?.[0]; + const snapshot = typeof first?.text === "string" ? first.text : null; + const deltaText = typeof payload.deltaText === "string" ? payload.deltaText : ""; + const next = snapshot ?? (payload.replace === true ? deltaText : stream.full + deltaText); + if (!next.startsWith(stream.full)) { + const currentSegment = stream.full.slice(stream.segmentStart); + stream.segmentStart = 0; + if (!(currentSegment && next.startsWith(currentSegment))) { + newBubble = true; + } + } + stream.full = next; + const text = stream.full.slice(stream.segmentStart); + return text ? { text, newBubble } : null; +} + +/** Escape first; then add only the small formatting subset the panel owns. */ +export function renderMarkdownLite(text) { + let rendered = String(text ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); + const fenced = []; + rendered = rendered.replace(/```(?:[a-z0-9_-]+)?\n?([\s\S]*?)```/gi, (_match, code) => { + fenced.push(`
${code.trim()}
`); + return ``; + }); + rendered = rendered.replace(/`([^`]+)`/g, "$1"); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); + rendered = rendered.replace(/\n/g, "
"); + return rendered.replace(//g, (_match, index) => fenced[Number(index)]); +} + +export function readMessageText(message) { + if (typeof message?.content === "string") { + return message.content; + } + if (!Array.isArray(message?.content)) { + return ""; + } + return message.content + .map((part) => (typeof part?.text === "string" ? part.text : "")) + .filter(Boolean) + .join("\n"); +} diff --git a/extensions/browser/chrome-extension/modules/panel-core.test.ts b/extensions/browser/chrome-extension/modules/panel-core.test.ts new file mode 100644 index 000000000000..3cf91087c5ec --- /dev/null +++ b/extensions/browser/chrome-extension/modules/panel-core.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyChatDelta, + buildCopilotChatSendParams, + createChatStream, + deriveTabSessionKey, + gatewayUrlFromPairing, + normalizeGatewayUrl, + readMessageText, + renderMarkdownLite, +} from "./panel-core.js"; + +describe("browser copilot panel contracts", () => { + it("mints isolated thread keys without exposing reusable tab ids", () => { + const first = deriveTabSessionKey("agent:main:main", "11111111-1111-4111-8111-111111111111"); + const second = deriveTabSessionKey( + "agent:main:main:thread:old", + "22222222-2222-4222-8222-222222222222", + ); + expect(first).toBe( + "agent:main:main:thread:browser-copilot-11111111-1111-4111-8111-111111111111", + ); + expect(second).toBe( + "agent:main:main:thread:browser-copilot-22222222-2222-4222-8222-222222222222", + ); + expect(first).not.toBe(second); + expect(deriveTabSessionKey("agent:main:main", "tab-7")).toBeNull(); + }); + + it("derives only secure remote or loopback Gateway endpoints", () => { + expect(gatewayUrlFromPairing("wss://gateway.example/base/browser/extension", undefined)).toBe( + "wss://gateway.example/base", + ); + expect(gatewayUrlFromPairing("ws://127.0.0.1:18792/extension", "ws://127.0.0.1:18789")).toBe( + "ws://127.0.0.1:18789/", + ); + expect(normalizeGatewayUrl("ws://gateway.example")).toBeNull(); + const credentialed = new URL("wss://gateway.example"); + credentialed.username = "fixture-user"; + credentialed.password = "test-password"; + expect(normalizeGatewayUrl(credentialed.toString())).toBeNull(); + }); + + it("builds a local-only delivery with the trusted browser binding", () => { + vi.spyOn(crypto, "randomUUID").mockReturnValue("33333333-3333-4333-8333-333333333333"); + const binding = { + kind: "tab", + tabId: 7, + target: "host", + profile: "chrome", + targetId: "target-7", + } as const; + expect( + buildCopilotChatSendParams({ + binding, + message: " inspect this ", + sessionId: "session-7", + sessionKey: "agent:main:main:thread:browser-copilot-x", + }), + ).toEqual({ + sessionKey: "agent:main:main:thread:browser-copilot-x", + sessionId: "session-7", + message: "inspect this", + idempotencyKey: "33333333-3333-4333-8333-333333333333", + deliver: false, + toolBindings: { browser: binding }, + }); + }); + + it("renders cumulative deltas once and escapes page-controlled markup", () => { + const stream = createChatStream(); + expect(applyChatDelta(stream, { runId: "run", deltaText: "Hello" })).toEqual({ + text: "Hello", + newBubble: true, + }); + expect( + applyChatDelta(stream, { + runId: "run", + message: { content: [{ text: "Hello world" }] }, + }), + ).toEqual({ text: "Hello world", newBubble: false }); + expect(renderMarkdownLite(" **safe**")).toBe( + "<img src=x> safe", + ); + }); + + it("projects only visible text from history content", () => { + expect(readMessageText({ content: [{ type: "text", text: "one" }, { text: "two" }] })).toBe( + "one\ntwo", + ); + expect(readMessageText({ content: [{ type: "image", data: "secret" }] })).toBe(""); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/relay-core.d.ts b/extensions/browser/chrome-extension/modules/relay-core.d.ts index 79ab4cf0d080..ba0d91c644af 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.d.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.d.ts @@ -2,7 +2,11 @@ // it can load unbundled in Chrome). Kept in sync with relay-core.js. export const OPENCLAW_TAB_GROUP_TITLE: string; -export function parsePairingString(raw: unknown): { relayUrl: string; token: string } | null; +export function parsePairingString(raw: unknown): { + relayUrl: string; + token: string; + gatewayUrl?: string; +} | null; export function buildRelayWsProtocols(token: string): string[]; diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js index f23222811c4a..fad74deb01c1 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.js +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -21,8 +21,9 @@ const CHROME_GROUP_COLORS = { /** * Parse a pairing string printed by `openclaw browser extension pair`. - * Shape: ws://127.0.0.1:/extension# - * Returns { relayUrl, token } or null when malformed. + * Shape: ws://127.0.0.1:/extension?gateway=# + * The additive gateway hint is not a credential; old extensions safely pass + * it through to the relay while new extensions remove it before connecting. */ export function parsePairingString(raw) { const trimmed = String(raw ?? "").trim(); @@ -47,7 +48,16 @@ export function parsePairingString(raw) { if (!parsed.pathname.endsWith("/extension")) { return null; } - return { relayUrl, token }; + const gatewayUrl = parsed.searchParams.get("gateway")?.trim() || undefined; + parsed.searchParams.delete("gateway"); + if ([...parsed.searchParams].length > 0) { + return null; + } + return { + relayUrl: parsed.toString(), + token, + ...(gatewayUrl ? { gatewayUrl } : {}), + }; } /** Build WebSocket subprotocols without putting the relay secret in the request URL. */ diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts index 0c71177dc410..aa91b10c3bd8 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.test.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -39,6 +39,16 @@ describe("parsePairingString", () => { expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull(); expect(parsePairingString("ws://127.0.0.1/extension")).toBeNull(); }); + + it("extracts the additive direct Gateway hint without passing it to the relay", () => { + const gatewayUrl = "wss://gateway.example.com/base"; + const pairing = `ws://127.0.0.1:18797/extension?gateway=${encodeURIComponent(gatewayUrl)}#tok`; + expect(parsePairingString(pairing)).toEqual({ + relayUrl: "ws://127.0.0.1:18797/extension", + token: "tok", + gatewayUrl, + }); + }); }); describe("reconnectDelayMs", () => { diff --git a/extensions/browser/chrome-extension/popup.html b/extensions/browser/chrome-extension/popup.html index 12cfc0cd188b..f3d74753fd1d 100644 --- a/extensions/browser/chrome-extension/popup.html +++ b/extensions/browser/chrome-extension/popup.html @@ -65,6 +65,10 @@ background: #3a3a3c; color: #f2f2f7; } + button:disabled { + cursor: default; + opacity: 0.5; + } .hidden { display: none; } @@ -86,6 +90,7 @@ diff --git a/extensions/browser/chrome-extension/popup.js b/extensions/browser/chrome-extension/popup.js index bb6ed00c0147..65fa7e2959a4 100644 --- a/extensions/browser/chrome-extension/popup.js +++ b/extensions/browser/chrome-extension/popup.js @@ -7,6 +7,7 @@ const pairingInput = document.getElementById("pairingString"); const pairButton = document.getElementById("pairButton"); const unpairButton = document.getElementById("unpairButton"); const shareButton = document.getElementById("shareButton"); +const copilotButton = document.getElementById("copilotButton"); const statusLine = document.getElementById("statusLine"); const errorLine = document.getElementById("error"); @@ -35,8 +36,13 @@ async function refresh() { const tab = await activeTab(); if (tab?.id === undefined) { shareButton.classList.add("hidden"); + copilotButton.disabled = true; return; } + const panel = await chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: tab.id }); + copilotButton.disabled = !panel?.ok; + copilotButton.dataset.tabId = String(tab.id); + copilotButton.dataset.path = panel?.path ?? ""; const { shared } = await chrome.runtime.sendMessage({ type: "isTabShared", tabId: tab.id }); shareButton.classList.remove("hidden"); shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw"; @@ -70,9 +76,21 @@ async function onToggleShare() { await refresh(); } +async function onOpenCopilot() { + const tabId = Number.parseInt(copilotButton.dataset.tabId ?? "", 10); + const path = copilotButton.dataset.path; + if (!Number.isInteger(tabId) || !path) { + return; + } + await chrome.sidePanel.setOptions({ tabId, path, enabled: true }); + await chrome.sidePanel.open({ tabId }); + window.close(); +} + pairButton.addEventListener("click", () => void onPair()); unpairButton.addEventListener("click", () => void onUnpair()); shareButton.addEventListener("click", () => void onToggleShare()); +copilotButton.addEventListener("click", () => void onOpenCopilot()); void refresh(); setInterval(() => void refresh(), 2000); diff --git a/extensions/browser/chrome-extension/sidepanel.css b/extensions/browser/chrome-extension/sidepanel.css new file mode 100644 index 000000000000..4557ff5cd9a1 --- /dev/null +++ b/extensions/browser/chrome-extension/sidepanel.css @@ -0,0 +1,356 @@ +:root { + color-scheme: dark; + font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; + background: #141312; + color: #f4efe8; + --ink: #f4efe8; + --muted: #9f978d; + --line: #34302c; + --panel: #1d1b19; + --panel-raised: #25221f; + --orange: #ff6437; + --orange-soft: #3a2119; + --green: #54d18b; + --red: #ff6b64; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 280px; + height: 100vh; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + background: #141312; +} + +button, +textarea { + font: inherit; +} + +.topbar { + height: 62px; + display: flex; + align-items: center; + gap: 11px; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: #181614; +} + +.mark { + width: 35px; + height: 35px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid #75402f; + border-radius: 10px 4px 10px 4px; + background: var(--orange-soft); + color: #ffad8f; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; + font-weight: 800; + letter-spacing: -0.05em; +} + +.identity { + min-width: 0; + flex: 1; +} + +.eyebrow, +.gate-kicker { + color: var(--orange); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.15em; +} + +.tab-title { + margin-top: 3px; + overflow: hidden; + color: var(--ink); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 50%; + background: #706960; + box-shadow: 0 0 0 3px #27231f; +} + +.status-dot.ready { + background: var(--green); + box-shadow: 0 0 0 3px #183226; +} + +.status-dot.error, +.status-dot.denied { + background: var(--red); + box-shadow: 0 0 0 3px #381d1b; +} + +.scope-strip { + min-height: 34px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + overflow: hidden; + border-bottom: 1px solid #292622; + background: #191715; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 10px; + white-space: nowrap; +} + +.scope-strip span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.scope-icon { + color: var(--orange); +} + +#conversation { + min-height: 0; + overflow-y: auto; + scrollbar-color: #4b4540 transparent; +} + +.gate { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 34px 24px 46px; +} + +.gate::before { + content: ""; + width: 44px; + height: 3px; + margin-bottom: 18px; + background: var(--orange); +} + +.gate h1 { + max-width: 310px; + margin: 8px 0 10px; + font-size: clamp(21px, 7vw, 29px); + font-weight: 650; + letter-spacing: -0.035em; + line-height: 1.08; +} + +.gate p { + max-width: 330px; + margin: 0 0 18px; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.request-id { + display: block; + max-width: 100%; + margin: 0 0 16px; + padding: 7px 9px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + color: #c8c0b7; + font-size: 10px; + text-overflow: ellipsis; +} + +.primary { + border: 1px solid #ff835f; + border-radius: 7px; + padding: 9px 13px; + background: var(--orange); + color: #1a0d09; + font-size: 12px; + font-weight: 750; + cursor: pointer; +} + +.messages { + min-height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 11px; + padding: 18px 14px 22px; +} + +.message { + max-width: 88%; + padding: 10px 12px; + border: 1px solid #322e2a; + border-radius: 5px 13px 13px 13px; + background: var(--panel); + color: #e9e3dc; + font-size: 13px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.message.user { + align-self: flex-end; + border-color: #69402f; + border-radius: 13px 5px 13px 13px; + background: var(--orange-soft); + color: #ffe7de; +} + +.message.system { + align-self: center; + border: 0; + background: transparent; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 10px; + text-align: center; +} + +.message.streaming::after { + content: ""; + display: inline-block; + width: 6px; + height: 13px; + margin-left: 3px; + vertical-align: -2px; + background: var(--orange); + animation: blink 900ms step-end infinite; +} + +.message pre { + margin: 8px 0 2px; + padding: 9px; + overflow-x: auto; + border: 1px solid #393531; + border-radius: 6px; + background: #11100f; +} + +.message code { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.9em; +} + +.message :not(pre) > code { + padding: 1px 4px; + border-radius: 4px; + background: #0f0e0d; +} + +.composer-shell { + padding: 10px 12px 12px; + border-top: 1px solid var(--line); + background: #181614; +} + +.session-note, +.binding-note { + overflow: hidden; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-note { + margin: 0 2px 7px; +} + +.binding-note { + margin: 7px 2px 0; + color: #766f68; +} + +.composer { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 7px 7px 7px 10px; + border: 1px solid #3b3631; + border-radius: 11px; + background: var(--panel-raised); +} + +.composer:focus-within { + border-color: #86503b; + box-shadow: 0 0 0 2px #3a2119; +} + +textarea { + min-height: 24px; + max-height: 130px; + flex: 1; + resize: none; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 13px; + line-height: 1.45; +} + +textarea::placeholder { + color: #797168; +} + +.send { + width: 31px; + height: 31px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 0; + border-radius: 8px; + background: var(--orange); + color: #1b0c08; + font-size: 18px; + font-weight: 900; + cursor: pointer; +} + +button:disabled, +textarea:disabled { + cursor: default; + opacity: 0.4; +} + +.hidden { + display: none !important; +} + +@keyframes blink { + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .message.streaming::after { + animation: none; + } +} diff --git a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts new file mode 100644 index 000000000000..b4101866fc80 --- /dev/null +++ b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts @@ -0,0 +1,1020 @@ +import fs from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + chromium, + type BrowserContext, + type CDPSession, + type Page, + type Worker, +} from "playwright-core"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocketServer, type RawData, type WebSocket } from "ws"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, +} from "../../../packages/gateway-protocol/src/client-info.js"; +import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js"; +import { useAutoCleanupTempDirTracker } from "../test-support.js"; + +declare const chrome: { + runtime: { + sendMessage(message: Record): Promise; + getContexts(filter: { contextTypes: string[] }): Promise< + Array<{ + contextType: string; + documentId?: string; + documentUrl: string; + tabId: number; + }> + >; + }; + sidePanel: { + setOptions(options: { tabId: number; enabled: boolean }): Promise; + }; + tabs: { + getCurrent(): Promise<{ id?: number }>; + ungroup(tabIds: number[]): Promise; + }; +}; + +const runE2E = process.env.OPENCLAW_BROWSER_COPILOT_E2E === "1"; +const extensionDir = path.dirname(fileURLToPath(import.meta.url)); + +type RequestFrame = { + id: string; + method: string; + params?: Record; + type: "req"; +}; + +type GatewayHarness = { + archived: Set; + chatSends: Array>; + connectParams: Array>; + histories: Map>>; + port: number; + requests: RequestFrame[]; + close: () => Promise; + disconnectClients: () => void; + failNextAbort: () => void; + holdNextSubscription: () => () => void; +}; + +type RelayHarness = { + readonly connectionCount: number; + hellos: Array>; + port: number; + close: () => Promise; + setAvailable: (available: boolean) => void; +}; + +type TargetInfo = { targetId: string; type: string; url: string }; + +type PanelTarget = { + allText: (selector: string) => Promise; + click: (selector: string) => Promise; + disabled: (selector: string) => Promise; + fill: (selector: string, value: string) => Promise; + hidden: (selector: string) => Promise; + screenshot: (targetPath: string) => Promise; + text: (selector: string) => Promise; + wakeBackground: () => Promise; +}; + +function isSidePanelTarget(target: TargetInfo): boolean { + try { + return new URL(target.url).pathname.endsWith("/sidepanel.html"); + } catch { + return false; + } +} + +function textValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } + return data instanceof ArrayBuffer + ? Buffer.from(new Uint8Array(data)).toString("utf8") + : data.toString("utf8"); +} + +const cleanups: Array<() => Promise> = []; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup().catch(() => undefined); + } +}); + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not bind a TCP port"); + } + return address.port; +} + +function sendResponse(socket: WebSocket, id: string, payload: unknown): void { + socket.send(JSON.stringify({ type: "res", id, ok: true, payload })); +} + +function sendError(socket: WebSocket, id: string, message: string): void { + socket.send( + JSON.stringify({ + type: "res", + id, + ok: false, + error: { code: "UNAVAILABLE", message, retryable: true }, + }), + ); +} + +async function createRelayHarness(): Promise { + const server = createServer(); + const port = await listen(server); + const wss = new WebSocketServer({ + noServer: true, + maxPayload: 1_000_000, + handleProtocols: (protocols) => protocols.values().next().value ?? false, + }); + const hellos: Array> = []; + let available = true; + let connectionCount = 0; + server.on("upgrade", (request, socket, head) => { + if (!available) { + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (client) => { + wss.emit("connection", client, request); + }); + }); + wss.on("connection", (socket) => { + connectionCount += 1; + socket.on("message", (data) => { + const message = JSON.parse(rawDataText(data)) as Record; + if (message.type === "hello") { + hellos.push(message); + } + }); + }); + return { + get connectionCount() { + return connectionCount; + }, + hellos, + port, + setAvailable: (nextAvailable) => { + available = nextAvailable; + if (!available) { + for (const client of wss.clients) { + client.terminate(); + } + } + }, + close: async () => { + for (const client of wss.clients) { + client.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} + +async function createGatewayHarness(): Promise { + const server = createServer(); + const port = await listen(server); + const wss = new WebSocketServer({ server }); + const histories = new Map>>(); + const archived = new Set(); + const requests: RequestFrame[] = []; + const connectParams: Array> = []; + const chatSends: Array> = []; + let heldSubscription: Promise | null = null; + let rejectNextAbort = false; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "browser-copilot-e2e-nonce" }, + }), + ); + socket.on("message", (data) => { + const frame = JSON.parse(rawDataText(data)) as RequestFrame; + requests.push(frame); + const params = frame.params ?? {}; + if (frame.method === "connect") { + connectParams.push(params); + sendResponse(socket, frame.id, { + type: "hello-ok", + protocol: PROTOCOL_VERSION, + server: { version: "e2e", connId: "browser-copilot-e2e" }, + features: { methods: [], events: ["chat"] }, + snapshot: { + sessionDefaults: { + defaultAgentId: "main", + mainKey: "main", + mainSessionKey: "agent:main:main", + }, + }, + auth: { + deviceToken: "test-device-token", + role: "operator", + scopes: ["operator.read", "operator.write"], + }, + policy: { + maxPayload: 1_000_000, + maxBufferedBytes: 1_000_000, + tickIntervalMs: 60_000, + }, + }); + return; + } + const key = textValue(params.key) || textValue(params.sessionKey); + if (frame.method === "sessions.create") { + histories.set(key, []); + sendResponse(socket, frame.id, { ok: true, key, sessionId: `id-${histories.size}` }); + return; + } + if (frame.method === "chat.history") { + sendResponse(socket, frame.id, { messages: histories.get(key) ?? [] }); + return; + } + if (frame.method === "sessions.messages.subscribe" && heldSubscription) { + const pending = heldSubscription; + heldSubscription = null; + void pending.then(() => sendResponse(socket, frame.id, { ok: true })); + return; + } + if (frame.method === "chat.send") { + chatSends.push(params); + const message = textValue(params.message); + const history = histories.get(key) ?? []; + history.push({ role: "user", content: [{ type: "text", text: message }] }); + const runId = textValue(params.idempotencyKey); + if (message === "ambiguous linger marker") { + histories.set(key, history); + socket.terminate(); + return; + } + if (message.endsWith("linger marker")) { + histories.set(key, history); + sendResponse(socket, frame.id, { runId, status: "started" }); + return; + } + const reply = `Isolated reply: ${message}`; + history.push({ role: "assistant", content: [{ type: "text", text: reply }] }); + histories.set(key, history); + sendResponse(socket, frame.id, { runId, status: "started" }); + socket.send( + JSON.stringify({ + type: "event", + event: "chat", + payload: { sessionKey: key, runId, state: "delta", deltaText: reply }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "chat", + payload: { sessionKey: key, runId, state: "final" }, + }), + ); + return; + } + if (frame.method === "sessions.abort" && rejectNextAbort) { + rejectNextAbort = false; + sendError(socket, frame.id, "fixture abort retry"); + return; + } + if (frame.method === "sessions.patch" && params.archived === true) { + archived.add(key); + } + sendResponse(socket, frame.id, { ok: true }); + }); + }); + + return { + archived, + chatSends, + connectParams, + histories, + port, + requests, + disconnectClients: () => { + for (const client of wss.clients) { + client.terminate(); + } + }, + failNextAbort: () => { + rejectNextAbort = true; + }, + holdNextSubscription: () => { + let release: () => void = () => void 0; + heldSubscription = new Promise((resolve) => { + release = resolve; + }); + return release; + }, + close: async () => { + for (const client of wss.clients) { + client.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} + +async function createFixtureServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const server = createServer((request, response) => { + const name = request.url === "/beta" ? "Beta" : "Alpha"; + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end( + `Fixture ${name}

${name} workspace

Sanitized local fixture.

`, + ); + }); + const port = await listen(server); + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => + await new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +async function copyExtension(): Promise { + const target = tempDirs.make("openclaw-copilot-extension-"); + await fs.cp(extensionDir, target, { + recursive: true, + filter: (source) => !source.endsWith(".test.ts"), + }); + await fs.writeFile( + path.join(target, "e2e-launcher.html"), + '', + ); + await fs.writeFile( + path.join(target, "e2e-launcher.js"), + `const tab = await chrome.tabs.getCurrent(); + const panel = await chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: tab.id }); + if (!panel?.ok) throw new Error(panel?.error ?? "panel prepare failed"); + document.body.dataset.ready = "true"; + document.querySelector("#open").addEventListener("click", async () => { + try { + await chrome.sidePanel.setOptions({ tabId: tab.id, path: panel.path, enabled: true }); + await chrome.sidePanel.open({ tabId: tab.id }); + document.body.dataset.opened = "true"; + } catch (error) { + document.body.dataset.error = error instanceof Error ? error.message : String(error); + } + });\n`, + ); + return target; +} + +async function resolveChromiumExecutable(): Promise { + const override = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); + const candidates = [override, "/usr/bin/chromium-browser", "/usr/bin/chromium"].filter( + (candidate): candidate is string => Boolean(candidate), + ); + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + // Continue to Playwright's managed Chromium. + } + } + return undefined; +} + +async function waitForServiceWorker(context: BrowserContext): Promise { + return context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); +} + +async function restartServiceWorker( + browserCdp: CDPSession, + worker: Worker, + panel: PanelTarget, +): Promise { + const targets = (await browserCdp.send("Target.getTargets")) as { + targetInfos: TargetInfo[]; + }; + const target = targets.targetInfos.find( + (candidate) => candidate.type === "service_worker" && candidate.url === worker.url(), + ); + if (!target) { + throw new Error("Chromium did not expose the extension service worker target"); + } + const closed = (await browserCdp.send("Target.closeTarget", { + targetId: target.targetId, + })) as { success?: boolean }; + if (closed.success !== true) { + throw new Error("Chromium did not stop the extension service worker"); + } + // A real extension message wakes the terminated worker. The panel must then + // reconnect its long-lived port before it can become ready again. + await panel.wakeBackground(); +} + +function createPanelTarget(root: CDPSession, sessionId: string): PanelTarget { + let commandId = 0; + const pending = new Map< + number, + { reject: (error: Error) => void; resolve: (result: Record) => void } + >(); + root.on("Target.receivedMessageFromTarget", (event: { message: string; sessionId: string }) => { + if (event.sessionId !== sessionId) { + return; + } + const message = JSON.parse(event.message) as { + error?: { message?: string }; + id?: number; + result?: Record; + }; + if (typeof message.id !== "number") { + return; + } + const waiter = pending.get(message.id); + if (!waiter) { + return; + } + pending.delete(message.id); + if (message.error) { + waiter.reject(new Error(message.error.message ?? "CDP panel command failed")); + } else { + waiter.resolve(message.result ?? {}); + } + }); + + async function send(method: string, params: Record = {}) { + const id = ++commandId; + const result = new Promise>((resolve, reject) => { + pending.set(id, { resolve, reject }); + }); + await root.send("Target.sendMessageToTarget", { + sessionId, + message: JSON.stringify({ id, method, params }), + }); + return await result; + } + + async function evaluate(expression: string): Promise { + const result = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + const exception = result.exceptionDetails as { text?: string } | undefined; + if (exception) { + throw new Error(exception.text ?? "side-panel evaluation failed"); + } + return (result.result as { value?: T } | undefined)?.value as T; + } + + const selectorExpression = (selector: string) => JSON.stringify(selector); + return { + allText: async (selector) => + await evaluate( + `[...document.querySelectorAll(${selectorExpression(selector)})].map((node) => node.textContent ?? "")`, + ), + click: async (selector) => { + await evaluate(`document.querySelector(${selectorExpression(selector)})?.click()`); + }, + disabled: async (selector) => + await evaluate( + `Boolean(document.querySelector(${selectorExpression(selector)})?.disabled)`, + ), + fill: async (selector, value) => { + await evaluate(`(() => { + const input = document.querySelector(${selectorExpression(selector)}); + input.value = ${JSON.stringify(value)}; + input.dispatchEvent(new Event("input", { bubbles: true })); + })()`); + }, + hidden: async (selector) => + await evaluate( + `document.querySelector(${selectorExpression(selector)})?.classList.contains("hidden") === true`, + ), + screenshot: async (targetPath) => { + await send("Page.enable"); + const result = await send("Page.captureScreenshot", { format: "png", fromSurface: true }); + await fs.writeFile(targetPath, Buffer.from(String(result.data), "base64")); + }, + text: async (selector) => + await evaluate( + `document.querySelector(${selectorExpression(selector)})?.textContent ?? ""`, + ), + wakeBackground: async () => { + await evaluate( + `chrome.runtime.sendMessage({ type: "copilot.e2e.wake" }).catch(() => undefined)`, + ); + }, + }; +} + +async function openTabPanel(params: { + browserCdp: CDPSession; + extensionId: string; + page: Page; +}): Promise { + const prior = (await params.browserCdp.send("Target.getTargets")) as { + targetInfos: TargetInfo[]; + }; + const priorTargetIds = new Set(prior.targetInfos.map((target) => target.targetId)); + await params.page.goto(`chrome-extension://${params.extensionId}/e2e-launcher.html`); + await expect + .poll(async () => await params.page.locator("body").getAttribute("data-ready")) + .toBe("true"); + await params.page.locator("#open").click(); + await expect + .poll( + async () => + await params.page.locator("body").evaluate((body) => ({ + error: body.dataset.error, + opened: body.dataset.opened, + })), + { timeout: 5_000 }, + ) + .toEqual({ error: undefined, opened: "true" }); + await expect + .poll( + async () => { + const targets = (await params.browserCdp.send("Target.getTargets")) as { + targetInfos: TargetInfo[]; + }; + return targets.targetInfos.find( + (target) => !priorTargetIds.has(target.targetId) && isSidePanelTarget(target), + ); + }, + { timeout: 15_000 }, + ) + .toBeTruthy(); + const targets = (await params.browserCdp.send("Target.getTargets")) as { + targetInfos: TargetInfo[]; + }; + const target = targets.targetInfos.find( + (candidate) => !priorTargetIds.has(candidate.targetId) && isSidePanelTarget(candidate), + ); + if (!target) { + throw new Error("Chrome did not expose the tab-specific side-panel target"); + } + const attached = (await params.browserCdp.send("Target.attachToTarget", { + targetId: target.targetId, + flatten: false, + })) as { sessionId: string }; + return createPanelTarget(params.browserCdp, attached.sessionId); +} + +async function disableTabPanel(worker: Worker, tabId: number): Promise { + await worker.evaluate(async (boundTabId) => { + await chrome.sidePanel.setOptions({ tabId: boundTabId, enabled: false }); + }, tabId); + await expect + .poll( + async () => + await worker.evaluate(async () => { + const contexts = await chrome.runtime.getContexts({ contextTypes: ["SIDE_PANEL"] }); + return contexts.length; + }), + { timeout: 10_000 }, + ) + .toBe(0); +} + +async function unshareTab(worker: Worker, tabId: number): Promise { + await worker.evaluate(async (boundTabId) => { + await chrome.tabs.ungroup([boundTabId]); + }, tabId); +} + +describe.runIf(runE2E)("browser copilot Chromium side panel", () => { + it("isolates two tab sessions, enforces bindings, denies unshared use, and archives on close", async () => { + const gateway = await createGatewayHarness(); + cleanups.push(gateway.close); + const relay = await createRelayHarness(); + cleanups.push(relay.close); + const fixture = await createFixtureServer(); + cleanups.push(fixture.close); + const unpackedExtension = await copyExtension(); + const userDataDir = tempDirs.make("openclaw-copilot-profile-"); + const executablePath = await resolveChromiumExecutable(); + const context = await chromium.launchPersistentContext(userDataDir, { + ...(executablePath ? { executablePath } : { channel: "chromium" }), + headless: true, + args: [ + `--disable-extensions-except=${unpackedExtension}`, + `--load-extension=${unpackedExtension}`, + ], + }); + cleanups.push(async () => await context.close()); + const browser = context.browser(); + if (!browser) { + throw new Error("Chromium browser connection unavailable"); + } + const browserCdp = await browser.newBrowserCDPSession(); + const worker = await waitForServiceWorker(context); + const extensionId = new URL(worker.url()).hostname; + const alphaTab = context.pages()[0] ?? (await context.newPage()); + await alphaTab.goto(`chrome-extension://${extensionId}/e2e-launcher.html`); + await alphaTab.evaluate( + async ({ gatewayPort, relayPort }) => + await chrome.runtime.sendMessage({ + type: "pair", + pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=${encodeURIComponent(`ws://127.0.0.1:${gatewayPort}`)}#relay-e2e-token`, + groupColor: "#ff7020", + }), + { gatewayPort: gateway.port, relayPort: relay.port }, + ); + await expect.poll(() => gateway.connectParams.length, { timeout: 10_000 }).toBe(1); + await expect.poll(() => relay.connectionCount, { timeout: 10_000 }).toBe(1); + await expect.poll(() => relay.hellos.length, { timeout: 10_000 }).toBe(1); + + const artifactDir = + process.env.OPENCLAW_BROWSER_COPILOT_ARTIFACT_DIR ?? + path.join(os.tmpdir(), "openclaw-browser-copilot-artifacts"); + await fs.mkdir(artifactDir, { recursive: true }); + + const alphaPanel = await openTabPanel({ browserCdp, extensionId, page: alphaTab }); + const alphaContextProof = await alphaTab.evaluate(async () => { + const tab = await chrome.tabs.getCurrent(); + const contexts = await chrome.runtime.getContexts({ contextTypes: ["SIDE_PANEL"] }); + return { + currentTabId: tab?.id, + contexts: contexts.map((panelContext) => ({ + contextType: panelContext.contextType, + hasDocumentId: Boolean(panelContext.documentId), + pathname: new URL(panelContext.documentUrl).pathname, + queryKeys: [...new URL(panelContext.documentUrl).searchParams.keys()], + tabId: panelContext.tabId, + })), + }; + }); + expect(alphaContextProof).toEqual({ + currentTabId: expect.any(Number), + contexts: [ + { + contextType: "SIDE_PANEL", + hasDocumentId: true, + pathname: "/sidepanel.html", + queryKeys: ["binding"], + tabId: -1, + }, + ], + }); + await alphaTab.goto(`${fixture.baseUrl}/alpha`); + await expect + .poll( + async () => ({ + detail: await alphaPanel.text("#gate-detail"), + title: await alphaPanel.text("#gate-title"), + }), + { timeout: 10_000 }, + ) + .toEqual({ + detail: + "Sharing adds this tab to the OpenClaw group. The copilot can act here, but nowhere else.", + title: "Keep the boundary visible", + }); + expect(await alphaPanel.disabled("#message-input")).toBe(true); + await alphaPanel.screenshot(path.join(artifactDir, "before-unshared.png")); + await alphaPanel.click("#gate-action"); + await expect + .poll(async () => !(await alphaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + await alphaPanel.fill("#message-input", "alpha marker"); + await expect.poll(async () => !(await alphaPanel.disabled("#send-button"))).toBe(true); + await alphaPanel.click("#send-button"); + await expect + .poll( + async () => ({ + chatSends: gateway.chatSends.length, + users: await alphaPanel.allText(".message.user"), + }), + { timeout: 10_000 }, + ) + .toEqual({ chatSends: 1, users: ["alpha marker"] }); + await expect + .poll(async () => await alphaPanel.allText(".message.assistant"), { timeout: 10_000 }) + .toContain("Isolated reply: alpha marker"); + + const betaTab = await context.newPage(); + const betaPanel = await openTabPanel({ browserCdp, extensionId, page: betaTab }); + const betaTabId = await betaTab.evaluate(async () => (await chrome.tabs.getCurrent()).id); + if (typeof betaTabId !== "number") { + throw new Error("Chrome did not expose the beta tab id"); + } + await betaTab.goto(`${fixture.baseUrl}/beta`); + await expect + .poll(async () => await betaPanel.text("#gate-title")) + .toBe("Keep the boundary visible"); + await betaPanel.click("#gate-action"); + await expect + .poll(async () => !(await betaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + expect(await betaPanel.text("#messages")).not.toContain("alpha marker"); + await betaPanel.fill("#message-input", "beta marker"); + await expect.poll(async () => !(await betaPanel.disabled("#send-button"))).toBe(true); + await betaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(2); + await expect + .poll(async () => await betaPanel.allText(".message.assistant"), { timeout: 10_000 }) + .toContain("Isolated reply: beta marker"); + await betaPanel.screenshot(path.join(artifactDir, "after-isolated.png")); + + expect(gateway.chatSends).toHaveLength(2); + const [alphaSend, betaSend] = gateway.chatSends; + if (!alphaSend || !betaSend) { + throw new Error("expected one isolated send per tab"); + } + expect(alphaSend.sessionKey).not.toBe(betaSend.sessionKey); + for (const send of gateway.chatSends) { + expect(send.deliver).toBe(false); + expect(send).not.toHaveProperty("url"); + expect(send).not.toHaveProperty("title"); + expect(send).not.toHaveProperty("pageContent"); + expect(send.toolBindings).toEqual({ + browser: expect.objectContaining({ + kind: "tab", + profile: "chrome", + tabId: expect.any(Number), + target: "host", + targetId: expect.any(String), + }), + }); + } + expect(gateway.histories.get(textValue(alphaSend.sessionKey))).not.toEqual( + gateway.histories.get(textValue(betaSend.sessionKey)), + ); + expect(gateway.connectParams[0]).toEqual( + expect.objectContaining({ + client: expect.objectContaining({ id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }), + caps: expect.arrayContaining([ + GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, + GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS, + ]), + device: expect.objectContaining({ + id: expect.any(String), + publicKey: expect.any(String), + signature: expect.any(String), + }), + }), + ); + + await alphaTab.close(); + await expect + .poll(() => gateway.archived.has(textValue(alphaSend.sessionKey)), { timeout: 15_000 }) + .toBe(true); + const alphaLifecycle = gateway.requests + .filter((request) => textValue(request.params?.key) === alphaSend.sessionKey) + .map((request) => request.method); + expect(alphaLifecycle).toEqual( + expect.arrayContaining(["sessions.messages.unsubscribe", "sessions.abort", "sessions.patch"]), + ); + expect(gateway.histories.get(textValue(alphaSend.sessionKey))).toHaveLength(2); + const subscriptionsBeforeRace = gateway.requests.filter( + (request) => request.method === "sessions.messages.subscribe", + ).length; + const releaseSubscription = gateway.holdNextSubscription(); + const connectionsBeforeSetupRace = gateway.connectParams.length; + gateway.disconnectClients(); + await expect + .poll(() => gateway.connectParams.length, { timeout: 15_000 }) + .toBe(connectionsBeforeSetupRace + 1); + await expect + .poll( + () => + gateway.requests.filter((request) => request.method === "sessions.messages.subscribe") + .length, + { timeout: 10_000 }, + ) + .toBe(subscriptionsBeforeRace + 1); + expect(await betaPanel.disabled("#message-input")).toBe(true); + expect(await betaPanel.text("#gate-title")).toBe("Preparing this tab"); + await disableTabPanel(worker, betaTabId); + releaseSubscription(); + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + expect(gateway.chatSends).toHaveLength(2); + + let reopenedBetaPanel = await openTabPanel({ + browserCdp, + extensionId, + page: betaTab, + }); + await betaTab.goto(`${fixture.baseUrl}/beta`); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + + const subscriptionsBeforeConsentRace = gateway.requests.filter( + (request) => request.method === "sessions.messages.subscribe", + ).length; + const releaseConsentSubscription = gateway.holdNextSubscription(); + const connectionsBeforeConsentRace = gateway.connectParams.length; + gateway.disconnectClients(); + await expect + .poll(() => gateway.connectParams.length, { timeout: 15_000 }) + .toBe(connectionsBeforeConsentRace + 1); + await expect + .poll( + () => + gateway.requests.filter((request) => request.method === "sessions.messages.subscribe") + .length, + { timeout: 10_000 }, + ) + .toBe(subscriptionsBeforeConsentRace + 1); + expect(await reopenedBetaPanel.disabled("#message-input")).toBe(true); + await unshareTab(worker, betaTabId); + releaseConsentSubscription(); + await expect + .poll(async () => await reopenedBetaPanel.text("#gate-title"), { timeout: 10_000 }) + .toBe("Keep the boundary visible"); + expect(gateway.chatSends).toHaveLength(2); + await reopenedBetaPanel.click("#gate-action"); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + + await reopenedBetaPanel.fill("#message-input", "ambiguous linger marker"); + await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); + const connectionsBeforeAmbiguousSend = gateway.connectParams.length; + await reopenedBetaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(3); + const networkRunId = textValue(gateway.chatSends[2]?.idempotencyKey); + await expect + .poll(() => gateway.connectParams.length, { timeout: 15_000 }) + .toBe(connectionsBeforeAmbiguousSend + 1); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + expect(gateway.connectParams.at(-1)?.auth).toEqual( + expect.objectContaining({ token: expect.any(String) }), + ); + expect( + gateway.requests.some( + (request) => request.method === "sessions.abort" && request.params?.runId === networkRunId, + ), + ).toBe(true); + await reopenedBetaPanel.fill("#message-input", "after reconnect marker"); + await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); + await reopenedBetaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(4); + await expect + .poll(async () => await reopenedBetaPanel.allText(".message.assistant"), { + timeout: 10_000, + }) + .toContain("Isolated reply: after reconnect marker"); + + await reopenedBetaPanel.fill("#message-input", "panel linger marker"); + await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); + await reopenedBetaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(5); + const panelRunId = textValue(gateway.chatSends[4]?.idempotencyKey); + const historiesBeforeNavigation = gateway.requests.filter( + (request) => request.method === "chat.history", + ).length; + await betaTab.goto(`${fixture.baseUrl}/beta?during-run=1`); + await expect + .poll( + async () => ({ + gateHidden: await reopenedBetaPanel.hidden("#gate"), + messagesHidden: await reopenedBetaPanel.hidden("#messages"), + }), + { timeout: 10_000 }, + ) + .toEqual({ gateHidden: true, messagesHidden: false }); + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + expect(gateway.requests.filter((request) => request.method === "chat.history")).toHaveLength( + historiesBeforeNavigation, + ); + gateway.failNextAbort(); + await disableTabPanel(worker, betaTabId); + await expect + .poll( + () => ({ + aborts: gateway.requests.filter( + (request) => + request.method === "sessions.abort" && request.params?.runId === panelRunId, + ).length, + unsubscribed: gateway.requests.some( + (request) => + request.method === "sessions.messages.unsubscribe" && + request.params?.key === betaSend.sessionKey, + ), + }), + { timeout: 10_000 }, + ) + .toEqual({ aborts: 2, unsubscribed: true }); + + reopenedBetaPanel = await openTabPanel({ + browserCdp, + extensionId, + page: betaTab, + }); + await betaTab.goto(`${fixture.baseUrl}/beta`); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + await reopenedBetaPanel.fill("#message-input", "reopened marker"); + await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); + await reopenedBetaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(6); + await expect + .poll(async () => await reopenedBetaPanel.allText(".message.assistant"), { + timeout: 10_000, + }) + .toContain("Isolated reply: reopened marker"); + + await reopenedBetaPanel.fill("#message-input", "relay disconnect linger marker"); + await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true); + await reopenedBetaPanel.click("#send-button"); + await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(7); + const relayRunId = textValue(gateway.chatSends[6]?.idempotencyKey); + const relayConnectionsBeforeDrop = relay.connectionCount; + relay.setAvailable(false); + await expect + .poll( + async () => ({ + detail: await reopenedBetaPanel.text("#gate-detail"), + disabled: await reopenedBetaPanel.disabled("#message-input"), + title: await reopenedBetaPanel.text("#gate-title"), + }), + { timeout: 10_000 }, + ) + .toEqual({ + detail: "Browser relay reconnecting", + disabled: true, + title: "Preparing this tab", + }); + await expect + .poll( + () => + gateway.requests.some( + (request) => + request.method === "sessions.abort" && request.params?.runId === relayRunId, + ), + { timeout: 10_000 }, + ) + .toBe(true); + relay.setAvailable(true); + await expect + .poll(() => relay.connectionCount, { timeout: 15_000 }) + .toBeGreaterThan(relayConnectionsBeforeDrop); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + + const connectionsBeforeWorkerRestart = gateway.connectParams.length; + await restartServiceWorker(browserCdp, worker, reopenedBetaPanel); + await expect + .poll(() => gateway.connectParams.length, { timeout: 15_000 }) + .toBe(connectionsBeforeWorkerRestart + 1); + await expect + .poll(async () => !(await reopenedBetaPanel.disabled("#message-input")), { + timeout: 15_000, + }) + .toBe(true); + }, 75_000); +}); diff --git a/extensions/browser/chrome-extension/sidepanel.html b/extensions/browser/chrome-extension/sidepanel.html new file mode 100644 index 000000000000..fd9c4562fdf6 --- /dev/null +++ b/extensions/browser/chrome-extension/sidepanel.html @@ -0,0 +1,57 @@ + + + + + + OpenClaw Copilot + + + +
+ +
+
TAB COPILOT
+
Resolving tab…
+
+
+
+ +
+ + This panel is bound to one Chrome tab +
+ +
+
+
SECURE BINDING
+

Preparing this tab

+

Chrome is proving which tab owns this panel.

+ + +
+ +
+ +
+
No session until this tab is shared.
+
+ + +
+
+ Page text stays out of prompts. Browser actions stay on this tab. +
+
+ + + + diff --git a/extensions/browser/chrome-extension/sidepanel.js b/extensions/browser/chrome-extension/sidepanel.js new file mode 100644 index 000000000000..3d88e174960d --- /dev/null +++ b/extensions/browser/chrome-extension/sidepanel.js @@ -0,0 +1,254 @@ +import { + applyChatDelta, + createChatStream, + readMessageText, + renderMarkdownLite, + resetChatStream, +} from "./modules/panel-core.js"; + +const tabTitle = document.getElementById("tab-title"); +const tabOrigin = document.getElementById("tab-origin"); +const statusDot = document.getElementById("status-dot"); +const gate = document.getElementById("gate"); +const gateTitle = document.getElementById("gate-title"); +const gateDetail = document.getElementById("gate-detail"); +const gateAction = document.getElementById("gate-action"); +const requestId = document.getElementById("request-id"); +const messages = document.getElementById("messages"); +const sessionNote = document.getElementById("session-note"); +const input = document.getElementById("message-input"); +const sendButton = document.getElementById("send-button"); + +const stream = createChatStream(); +let streamingBubble = null; +let panelReady = false; +let sending = false; +let panelState = "connecting"; +let port = null; +let reconnectTimer = null; +let reconnectDelayMs = 250; + +function setComposerEnabled(enabled) { + panelReady = enabled; + input.disabled = !enabled || sending; + sendButton.disabled = !enabled || sending || !input.value.trim(); +} + +function setGate({ action = null, detail, title }) { + gate.classList.remove("hidden"); + messages.classList.add("hidden"); + gateTitle.textContent = title; + gateDetail.textContent = detail; + gateAction.classList.toggle("hidden", action !== "share"); + setComposerEnabled(false); +} + +function addBubble(role, text, streaming = false) { + const bubble = document.createElement("div"); + bubble.className = `message ${role}${streaming ? " streaming" : ""}`; + if (role === "system") { + bubble.textContent = text; + } else { + bubble.innerHTML = renderMarkdownLite(text); + } + messages.appendChild(bubble); + messages.parentElement.scrollTop = messages.parentElement.scrollHeight; + return bubble; +} + +function renderHistory(history) { + messages.replaceChildren(); + for (const message of history) { + if (message?.role !== "user" && message?.role !== "assistant") { + continue; + } + const text = readMessageText(message); + if (text) { + addBubble(message.role, text); + } + } + if (messages.childElementCount === 0) { + addBubble("system", "New tab conversation · page content is not added automatically"); + } +} + +function finalizeStream() { + streamingBubble?.classList.remove("streaming"); + streamingBubble = null; + resetChatStream(stream); + sending = false; + setComposerEnabled(panelReady); +} + +function handleChatEvent(payload) { + if (payload.state === "delta") { + const update = applyChatDelta(stream, payload); + if (!update) { + return; + } + if (!streamingBubble || update.newBubble) { + streamingBubble?.classList.remove("streaming"); + streamingBubble = addBubble("assistant", update.text, true); + } else { + streamingBubble.innerHTML = renderMarkdownLite(update.text); + streamingBubble.classList.add("streaming"); + } + return; + } + if (payload.state === "error") { + addBubble("system", payload.errorMessage || "The run failed."); + } + if (payload.state === "aborted") { + addBubble("system", "Run stopped because this tab was closed or unshared."); + } + if (payload.state === "final" || payload.state === "error" || payload.state === "aborted") { + finalizeStream(); + } +} + +function updateState(state) { + panelState = state.state; + statusDot.className = `status-dot ${state.state}`; + statusDot.title = state.label || state.state; + if (state.tab) { + tabTitle.textContent = state.tab.title || state.tab.label || "Untitled tab"; + tabOrigin.textContent = state.tab.label + ? `${state.tab.label} · Chrome-bound tab` + : "Chrome-bound tab"; + } + requestId.classList.toggle("hidden", !state.requestId); + requestId.textContent = state.requestId ? `request ${state.requestId}` : ""; + switch (state.state) { + case "ready": + gate.classList.add("hidden"); + messages.classList.remove("hidden"); + sessionNote.textContent = "Live only for this tab · transcript retained after archive"; + setComposerEnabled(true); + break; + case "needs-sharing": + sessionNote.textContent = "No session until this tab is shared."; + setGate({ + action: "share", + title: "Keep the boundary visible", + detail: + "Sharing adds this tab to the OpenClaw group. The copilot can act here, but nowhere else.", + }); + break; + case "needs-pairing": + setGate({ + title: "Pair the extension first", + detail: + "Open the OpenClaw toolbar popup and paste the output of openclaw browser extension pair.", + }); + break; + case "approval": + setGate({ + title: "Approve this copilot device", + detail: + "On the Gateway, run openclaw devices list, inspect this dedicated browser identity, then approve its current request.", + }); + break; + case "denied": + setGate({ title: "This panel was denied", detail: state.label }); + break; + case "error": + setGate({ title: "Gateway unavailable", detail: state.label }); + break; + default: + setGate({ title: "Preparing this tab", detail: state.label || "Connecting securely…" }); + } +} + +function handlePortMessage(message) { + reconnectDelayMs = 250; + if (message?.type === "panel.state") { + updateState(message); + } else if (message?.type === "panel.history") { + if (!sending) { + renderHistory(message.messages); + } + } else if (message?.type === "panel.event" && message.event?.event === "chat") { + handleChatEvent(message.event.payload ?? {}); + } else if (message?.type === "panel.turn-reset") { + if (sending) { + addBubble("system", "Previous run stopped after the Gateway reconnected."); + } + finalizeStream(); + } else if (message?.type === "panel.error") { + addBubble("system", message.message || "Request failed."); + sending = false; + setComposerEnabled(panelReady); + } +} + +function schedulePortReconnect() { + if (reconnectTimer || panelState === "denied") { + return; + } + const delayMs = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, 5_000); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + updateState({ state: "connecting", label: "Reconnecting to the extension background" }); + connectPanelPort(); + }, delayMs); +} + +function connectPanelPort() { + if (port) { + return; + } + let nextPort; + try { + nextPort = chrome.runtime.connect({ name: "openclaw-copilot-panel" }); + } catch { + schedulePortReconnect(); + return; + } + port = nextPort; + nextPort.onMessage.addListener((message) => { + if (port === nextPort) { + handlePortMessage(message); + } + }); + nextPort.onDisconnect.addListener(() => { + if (port !== nextPort) { + return; + } + port = null; + finalizeStream(); + if (panelState !== "denied") { + updateState({ state: "error", label: "Extension background disconnected." }); + schedulePortReconnect(); + } + }); + port?.postMessage({ type: "panel.refresh" }); +} + +async function send() { + const message = input.value.trim(); + if (!message || !panelReady || sending) { + return; + } + addBubble("user", message); + input.value = ""; + input.style.height = "auto"; + sending = true; + setComposerEnabled(true); + port?.postMessage({ type: "panel.send", message }); +} + +input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 130)}px`; + setComposerEnabled(panelReady); +}); +input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void send(); + } +}); +sendButton.addEventListener("click", () => void send()); +gateAction.addEventListener("click", () => port?.postMessage({ type: "panel.share" })); +connectPanelPort(); diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index 3c21357ce68d..e41d4c1f4236 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -231,6 +231,42 @@ describe("browser plugin", () => { }); }); + it("passes the browser-owned run binding into the tool layer", async () => { + const { api, registerTool } = createApi(); + registerBrowserPlugin(api); + const factory = mockCallArg(registerTool); + if (typeof factory !== "function") { + throw new Error("expected browser plugin to register a tool factory"); + } + const binding = { + kind: "tab", + tabId: 7, + target: "host", + profile: "chrome", + targetId: "target-7", + }; + const tool = factory({ toolBindings: { browser: binding } }); + if (!tool || Array.isArray(tool)) { + throw new Error("expected browser plugin to return a single tool"); + } + + await tool.execute("call-1", { action: "snapshot" }); + expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({ runToolBinding: binding }); + }); + + it("rejects malformed run bindings before creating the lazy browser tool", () => { + const { api, registerTool } = createApi(); + registerBrowserPlugin(api); + const factory = mockCallArg(registerTool); + if (typeof factory !== "function") { + throw new Error("expected browser plugin to register a tool factory"); + } + + expect(() => factory({ toolBindings: { browser: { kind: "tab" } } })).toThrow( + "invalid browser run binding", + ); + }); + it("derives group chat type for browser media scope", async () => { const { api, registerTool } = createApi(); registerBrowserPlugin(api); diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 78f255ceaaf8..ed4523c57bcb 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -6,12 +6,15 @@ "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", + "@noble/ed25519": "3.1.0", "express": "5.2.1", + "esbuild": "0.28.1", "playwright-core": "1.61.1", "typebox": "1.3.3", "ws": "8.21.0" }, "devDependencies": { + "@openclaw/gateway-client": "workspace:*", "@openclaw/plugin-sdk": "workspace:*", "undici": "8.6.0" }, @@ -20,6 +23,7 @@ "./index.ts" ], "assetScripts": { + "build": "node scripts/build-copilot-runtime.mjs", "copy": "node scripts/copy-chrome-extension.mjs" } } diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index c13c84dc5cba..b7bc957b6281 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -18,6 +18,7 @@ import { BROWSER_REQUEST_GATEWAY_METHOD, BROWSER_REQUEST_GATEWAY_SCOPE, } from "./src/browser-gateway-contract.js"; +import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js"; import { describeBrowserTool } from "./src/browser-tool-description.js"; import { BrowserToolSchema } from "./src/browser-tool.schema.js"; import { @@ -72,7 +73,15 @@ function createLazyBrowserTool(opts?: { channel?: string; chatType?: string; }; + runToolBinding?: unknown; }): AnyAgentTool { + const bindingResult = + opts?.runToolBinding === undefined + ? undefined + : parseBrowserTabToolBinding(opts.runToolBinding); + if (bindingResult && !bindingResult.ok) { + throw new Error(`invalid browser run binding: ${bindingResult.error}`); + } const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; const hostHint = opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed."; @@ -83,7 +92,9 @@ function createLazyBrowserTool(opts?: { parameters: BrowserToolSchema, execute: async (toolCallId, args, signal, onUpdate) => { const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule(); - const tool = createBrowserTool(opts); + const tool = createBrowserTool( + bindingResult?.ok ? { ...opts, runToolBinding: bindingResult.binding } : opts, + ); return await tool.execute(toolCallId, args, signal, onUpdate); }, }; @@ -104,6 +115,7 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): { channel?: string; chatType?: string; }; + runToolBinding?: unknown; } { const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel; const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey); @@ -132,6 +144,9 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): { }, } : {}), + ...(ctx.toolBindings && Object.hasOwn(ctx.toolBindings, "browser") + ? { runToolBinding: ctx.toolBindings.browser } + : {}), }; } diff --git a/extensions/browser/scripts/build-copilot-runtime.mjs b/extensions/browser/scripts/build-copilot-runtime.mjs new file mode 100644 index 000000000000..31165e6a2b8e --- /dev/null +++ b/extensions/browser/scripts/build-copilot-runtime.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(pluginDir, "../.."); +const outfile = path.join(pluginDir, "chrome-extension", "modules", "copilot-runtime.js"); + +await build({ + entryPoints: [path.join(pluginDir, "scripts", "copilot-runtime-entry.ts")], + outfile, + bundle: true, + format: "esm", + legalComments: "inline", + minify: true, + platform: "browser", + target: "chrome125", + tsconfig: path.join(repoRoot, "tsconfig.json"), +}); diff --git a/extensions/browser/scripts/copilot-runtime-entry.ts b/extensions/browser/scripts/copilot-runtime-entry.ts new file mode 100644 index 000000000000..11fa825c459e --- /dev/null +++ b/extensions/browser/scripts/copilot-runtime-entry.ts @@ -0,0 +1,14 @@ +// Browser copilot runtime bundle entry. Keep this list narrow: the extension +// consumes the canonical Gateway auth/wire engines plus the Ed25519 primitive +// needed below Chrome's native WebCrypto support floor. +export { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, + GatewayBrowserDeviceAuthLifecycle, + GatewayProtocolClient, + GatewayProtocolRequestError, + MIN_CLIENT_PROTOCOL_VERSION, + PROTOCOL_VERSION, +} from "@openclaw/gateway-client/browser"; +export { getPublicKeyAsync, signAsync, utils as ed25519Utils } from "@noble/ed25519"; diff --git a/extensions/browser/src/browser-tool-binding.test.ts b/extensions/browser/src/browser-tool-binding.test.ts new file mode 100644 index 000000000000..2d8c23e617d0 --- /dev/null +++ b/extensions/browser/src/browser-tool-binding.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { applyBrowserTabToolBinding, parseBrowserTabToolBinding } from "./browser-tool-binding.js"; + +const binding = { + kind: "tab" as const, + tabId: 17, + target: "node" as const, + node: "desktop", + profile: "chrome", + targetId: "target-a", +}; + +describe("browser tab tool binding", () => { + it("pins route and nested act targets to the trusted tab", () => { + expect( + applyBrowserTabToolBinding( + { action: "act", request: { kind: "batch", actions: [{ kind: "click" }] } }, + binding, + ), + ).toMatchObject({ + target: "node", + node: "desktop", + profile: "chrome", + targetId: "target-a", + request: { + targetId: "target-a", + actions: [{ kind: "click", targetId: "target-a" }], + }, + }); + }); + + it("rejects route, tab, and browser-wide action escapes", () => { + expect(() => + applyBrowserTabToolBinding({ action: "snapshot", targetId: "target-b" }, binding), + ).toThrow("cannot override its run-bound tab target"); + expect(() => + applyBrowserTabToolBinding({ action: "snapshot", node: "other" }, binding), + ).toThrow("cannot override its run-bound node"); + expect(() => applyBrowserTabToolBinding({ action: "open" }, binding)).toThrow( + "unavailable in a tab-bound run", + ); + }); + + it("fails closed on malformed bindings", () => { + expect(parseBrowserTabToolBinding({ kind: "tab", tabId: 1, target: "host" })).toEqual({ + ok: false, + error: "browser tool binding requires target, profile, and targetId", + }); + }); +}); diff --git a/extensions/browser/src/browser-tool-binding.ts b/extensions/browser/src/browser-tool-binding.ts new file mode 100644 index 000000000000..a9cf73d45d7f --- /dev/null +++ b/extensions/browser/src/browser-tool-binding.ts @@ -0,0 +1,115 @@ +type BrowserTabToolBinding = { + kind: "tab"; + tabId: number; + target: "host" | "node"; + node?: string; + profile: string; + targetId: string; +}; + +type BindingResult = { ok: true; binding: BrowserTabToolBinding } | { ok: false; error: string }; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** Validate the plugin-owned run binding before any browser route is resolved. */ +export function parseBrowserTabToolBinding(value: unknown): BindingResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: "browser tool binding must be an object" }; + } + const record = value as Record; + const target = record.target === "host" || record.target === "node" ? record.target : undefined; + const node = nonEmptyString(record.node); + const profile = nonEmptyString(record.profile); + const targetId = nonEmptyString(record.targetId); + if (record.kind !== "tab") { + return { ok: false, error: 'browser tool binding kind must be "tab"' }; + } + if (!Number.isSafeInteger(record.tabId) || Number(record.tabId) < 0) { + return { ok: false, error: "browser tool binding tabId must be a non-negative integer" }; + } + if (!target || !profile || !targetId || (target === "node" && !node)) { + return { ok: false, error: "browser tool binding requires target, profile, and targetId" }; + } + if (target === "host" && node) { + return { ok: false, error: "browser host binding cannot include node" }; + } + return { + ok: true, + binding: { + kind: "tab", + tabId: Number(record.tabId), + target, + ...(node ? { node } : {}), + profile, + targetId, + }, + }; +} + +const TAB_BOUND_ACTIONS = new Set([ + "act", + "close", + "console", + "dialog", + "download", + "focus", + "navigate", + "pdf", + "screenshot", + "snapshot", + "tabs", + "upload", + "waitfordownload", +]); + +function bindTargetId(record: Record, targetId: string): Record { + const requestedTargetId = nonEmptyString(record.targetId); + if (requestedTargetId && requestedTargetId !== targetId) { + throw new Error("browser action cannot override its run-bound tab target"); + } + const actions = Array.isArray(record.actions) + ? record.actions.map((action) => + action && typeof action === "object" && !Array.isArray(action) + ? bindTargetId(action as Record, targetId) + : action, + ) + : record.actions; + return { ...record, targetId, ...(actions ? { actions } : {}) }; +} + +/** Pin model-supplied browser arguments to the trusted tab route for this run. */ +export function applyBrowserTabToolBinding( + input: Record, + binding: BrowserTabToolBinding, +): Record { + const action = nonEmptyString(input.action); + if (!action || !TAB_BOUND_ACTIONS.has(action)) { + throw new Error(`browser action ${JSON.stringify(action)} is unavailable in a tab-bound run`); + } + const requestedTarget = nonEmptyString(input.target); + const requestedNode = nonEmptyString(input.node); + const requestedProfile = nonEmptyString(input.profile); + if (requestedTarget && requestedTarget !== binding.target) { + throw new Error("browser action cannot override its run-bound target"); + } + if (requestedNode && requestedNode !== binding.node) { + throw new Error("browser action cannot override its run-bound node"); + } + if (requestedProfile && requestedProfile !== binding.profile) { + throw new Error("browser action cannot override its run-bound profile"); + } + const bound = bindTargetId(input, binding.targetId); + const request = + bound.request && typeof bound.request === "object" && !Array.isArray(bound.request) + ? bindTargetId(bound.request as Record, binding.targetId) + : bound.request; + return { + ...bound, + target: binding.target, + ...(binding.node ? { node: binding.node } : {}), + profile: binding.profile, + ...(request ? { request } : {}), + }; +} diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index 51dc0a0c197e..edff10e9f266 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -306,6 +306,7 @@ export async function executeTabsAction(params: { profile?: string; timeoutMs?: number; proxyRequest: BrowserProxyRequest | null; + targetId?: string; }): Promise> { const { baseUrl, profile, timeoutMs, proxyRequest } = params; if (proxyRequest) { @@ -315,10 +316,16 @@ export async function executeTabsAction(params: { profile, timeoutMs, }); - const tabs = (result as { tabs?: unknown[] }).tabs ?? []; + const tabs = ((result as { tabs?: unknown[] }).tabs ?? []).filter( + (tab) => + !params.targetId || + readStringValue((tab as { targetId?: unknown } | undefined)?.targetId) === params.targetId, + ); return formatTabsToolResult(tabs); } - const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs }); + const tabs = (await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs })).filter( + (tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId, + ); return formatTabsToolResult(tabs); } diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 241fc9994448..e9b9cd9a0e51 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -5,6 +5,7 @@ * maps high-level actions onto browser control client calls. */ import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js"; +import { applyBrowserTabToolBinding, parseBrowserTabToolBinding } from "./browser-tool-binding.js"; import { describeBrowserTool } from "./browser-tool-description.js"; import { executeActAction, @@ -403,6 +404,7 @@ export function createBrowserTool(opts?: { channel?: string; chatType?: string; }; + runToolBinding?: unknown; }): AnyAgentTool { const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; const hostHint = @@ -413,7 +415,16 @@ export function createBrowserTool(opts?: { description: describeBrowserTool({ targetDefault, hostHint }), parameters: BrowserToolSchema, execute: async (_toolCallId, args) => { - const params = args as Record; + const bindingResult = + opts?.runToolBinding === undefined + ? undefined + : parseBrowserTabToolBinding(opts.runToolBinding); + if (bindingResult && !bindingResult.ok) { + throw new Error(`invalid browser run binding: ${bindingResult.error}`); + } + const params = bindingResult?.ok + ? applyBrowserTabToolBinding(args as Record, bindingResult.binding) + : (args as Record); const action = readStringParam(params, "action", { required: true }); const profile = readStringParam(params, "profile"); const requestedNode = readStringParam(params, "node"); @@ -617,6 +628,7 @@ export function createBrowserTool(opts?: { profile, timeoutMs: toolTimeoutMs, proxyRequest, + targetId: bindingResult?.ok ? bindingResult.binding.targetId : undefined, }); case "open": { const targetUrl = readTargetUrlParam(params); diff --git a/extensions/browser/src/cli/browser-cli-extension-pairing.ts b/extensions/browser/src/cli/browser-cli-extension-pairing.ts new file mode 100644 index 000000000000..326e44077516 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli-extension-pairing.ts @@ -0,0 +1,13 @@ +export function resolveLocalPairingGatewayUrl(params: { + configuredRemote?: string; + gatewayPort: number; + tlsEnabled: boolean; +}): string { + if (params.configuredRemote) { + return params.configuredRemote; + } + if (params.tlsEnabled) { + throw new Error("Gateway TLS pairing requires --gateway-url wss://[:port]"); + } + return `ws://127.0.0.1:${params.gatewayPort}`; +} diff --git a/extensions/browser/src/cli/browser-cli-extension.test.ts b/extensions/browser/src/cli/browser-cli-extension.test.ts new file mode 100644 index 000000000000..6c311a7b4687 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli-extension.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js"; + +describe("browser extension pairing Gateway URL", () => { + it("uses loopback only for a plaintext local Gateway", () => { + expect(resolveLocalPairingGatewayUrl({ gatewayPort: 18789, tlsEnabled: false })).toBe( + "ws://127.0.0.1:18789", + ); + }); + + it("requires the certificate hostname for a TLS Gateway", () => { + expect(() => resolveLocalPairingGatewayUrl({ gatewayPort: 18789, tlsEnabled: true })).toThrow( + "--gateway-url wss://", + ); + expect( + resolveLocalPairingGatewayUrl({ + configuredRemote: "wss://gateway.example", + gatewayPort: 18789, + tlsEnabled: true, + }), + ).toBe("wss://gateway.example"); + }); +}); diff --git a/extensions/browser/src/cli/browser-cli-extension.ts b/extensions/browser/src/cli/browser-cli-extension.ts index e0a0986014e8..78bbdd71cd67 100644 --- a/extensions/browser/src/cli/browser-cli-extension.ts +++ b/extensions/browser/src/cli/browser-cli-extension.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; import type { Command } from "commander"; import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js"; import { isLoopbackHost } from "../gateway/net.js"; +import { resolveGatewayPort } from "../sdk-config.js"; +import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; import { danger, @@ -82,14 +84,24 @@ function buildPairingString(gatewayUrl?: string): { // Remote: the extension connects straight to this gateway over wss:// — no // node host on the browser machine. The gateway route self-validates the // same host-local secret. + const relayUrl = new URL(buildRemoteGatewayRelayUrl(gateway)); + relayUrl.searchParams.set("gateway", gateway); return { - pairing: `${buildRemoteGatewayRelayUrl(gateway)}#${token}`, + pairing: `${relayUrl.toString()}#${token}`, relayPort, remote: true, }; } + const configuredRemote = cfg.gateway?.mode === "remote" ? cfg.gateway.remote?.url?.trim() : ""; + const directGatewayUrl = resolveLocalPairingGatewayUrl({ + configuredRemote, + gatewayPort: resolveGatewayPort(cfg), + tlsEnabled: cfg.gateway?.tls?.enabled === true, + }); + const relayUrl = new URL(`ws://127.0.0.1:${relayPort}/extension`); + relayUrl.searchParams.set("gateway", directGatewayUrl); return { - pairing: `ws://127.0.0.1:${relayPort}/extension#${token}`, + pairing: `${relayUrl.toString()}#${token}`, relayPort, remote: false, }; diff --git a/packages/gateway-client/src/browser-device-auth.test.ts b/packages/gateway-client/src/browser-device-auth.test.ts new file mode 100644 index 000000000000..69056495aa03 --- /dev/null +++ b/packages/gateway-client/src/browser-device-auth.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { GatewayBrowserDeviceAuthLifecycle } from "./browser-device-auth.js"; + +const client = { + id: "openclaw-browser-copilot" as const, + version: "test", + platform: "Chrome", + deviceFamily: "Extension", + mode: "ui" as const, +}; + +describe("GatewayBrowserDeviceAuthLifecycle", () => { + it("signs v3 device proof and reuses only an issued device token", async () => { + const sign = vi.fn(async () => "signature"); + const store = vi.fn(); + const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ + loadIdentity: async () => ({ deviceId: "device", publicKey: "public", sign }), + tokenStore: { + load: () => ({ token: "test-token-placeholder", scopes: ["operator.read"] }), + store, + clear: vi.fn(), + }, + nowMs: () => 123, + }); + + const plan = await lifecycle.buildPlan({ + client, + role: "operator", + defaultScopes: ["operator.read", "operator.write"], + nonce: "nonce", + }); + + expect(plan.auth).toEqual({ + token: "test-token-placeholder", + bootstrapToken: undefined, + deviceToken: "test-token-placeholder", + password: undefined, + approvalRuntimeToken: undefined, + agentRuntimeIdentityToken: undefined, + }); + expect(plan.scopes).toEqual(["operator.read"]); + expect(sign).toHaveBeenCalledWith( + "v3|device|openclaw-browser-copilot|ui|operator|operator.read|123|test-token-placeholder|nonce|chrome|extension", + ); + + await lifecycle.acceptHello( + { auth: { deviceToken: "test-auth-token", role: "operator", scopes: ["operator.write"] } }, + plan, + ); + expect(store).toHaveBeenCalledWith({ + clientId: "openclaw-browser-copilot", + deviceId: "device", + role: "operator", + token: "test-auth-token", + scopes: ["operator.write"], + }); + }); + + it("never persists bootstrap or shared-secret credentials", async () => { + const store = vi.fn(); + const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ + loadIdentity: async () => ({ + deviceId: "device", + publicKey: "public", + sign: async () => "signature", + }), + tokenStore: { load: () => null, store, clear: vi.fn() }, + }); + const plan = await lifecycle.buildPlan({ + client, + role: "operator", + defaultScopes: ["operator.read"], + bootstrapScopes: ["operator.read", "operator.write"], + bootstrapToken: "test-bootstrap-token", + password: "test-password", + preferBootstrapToken: true, + nonce: "nonce", + }); + + expect(plan.auth?.bootstrapToken).toBe("test-bootstrap-token"); + expect(plan.auth?.password).toBe("test-password"); + await lifecycle.acceptHello({ auth: { role: "operator", scopes: [] } }, plan); + expect(store).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gateway-client/src/browser-device-auth.ts b/packages/gateway-client/src/browser-device-auth.ts new file mode 100644 index 000000000000..d79435fcf625 --- /dev/null +++ b/packages/gateway-client/src/browser-device-auth.ts @@ -0,0 +1,177 @@ +import type { ConnectParams, HelloOk } from "@openclaw/gateway-protocol"; +import { + buildGatewayConnectAuth, + resolveGatewayConnectScopes, + selectGatewayConnectAuth, +} from "./connect-auth.js"; +import type { GatewayConnectAuthSelection } from "./connect-auth.js"; +import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; + +export type GatewayBrowserDeviceIdentity = { + deviceId: string; + publicKey: string; + sign: (payload: string) => Promise; +}; + +export type GatewayBrowserDeviceTokenRecord = { + token: string; + scopes: string[]; +}; + +type MaybePromise = T | Promise; + +export type GatewayBrowserDeviceTokenStore = { + load: (params: { + clientId: string; + deviceId: string; + role: string; + }) => MaybePromise; + store: (params: { + clientId: string; + deviceId: string; + role: string; + token: string; + scopes: string[]; + }) => MaybePromise; + clear: (params: { clientId: string; deviceId: string; role: string }) => MaybePromise; +}; + +export type GatewayBrowserDeviceAuthPlan = { + clientId: string; + role: string; + identity: GatewayBrowserDeviceIdentity | null; + selectedAuth: GatewayConnectAuthSelection; + scopes: string[]; + device?: NonNullable; + auth?: ConnectParams["auth"]; +}; + +/** Browser-safe device pairing and issued-token lifecycle shared by first-party UI clients. */ +export class GatewayBrowserDeviceAuthLifecycle { + constructor( + private readonly deps: { + loadIdentity: () => Promise; + tokenStore: GatewayBrowserDeviceTokenStore; + nowMs?: () => number; + }, + ) {} + + async buildPlan(params: { + client: ConnectParams["client"]; + role: string; + defaultScopes: readonly string[]; + bootstrapScopes?: readonly string[]; + token?: string; + bootstrapToken?: string; + password?: string; + pendingDeviceTokenRetry?: boolean; + trustedDeviceTokenRetry?: boolean; + preferBootstrapToken?: boolean; + nonce: string | null; + }): Promise { + const identity = await this.deps.loadIdentity(); + const stored = identity + ? await this.deps.tokenStore.load({ + clientId: params.client.id, + deviceId: identity.deviceId, + role: params.role, + }) + : null; + const storedValue = stored?.token; + const selectedAuth = selectGatewayConnectAuth({ + token: params.token, + bootstrapToken: params.bootstrapToken, + password: params.password, + storedToken: storedValue, + storedScopes: stored?.scopes, + pendingDeviceTokenRetry: params.pendingDeviceTokenRetry, + trustedDeviceTokenRetry: params.trustedDeviceTokenRetry, + preferBootstrapToken: params.preferBootstrapToken, + }); + const { usingStoredDeviceToken } = selectedAuth; + const scopes = resolveGatewayConnectScopes({ + requestedScopes: selectedAuth.authBootstrapToken + ? params.bootstrapScopes + ? [...params.bootstrapScopes] + : undefined + : undefined, + usingStoredDeviceToken, + storedScopes: selectedAuth.storedScopes, + defaultScopes: params.defaultScopes, + }); + if (!identity) { + return { + clientId: params.client.id, + role: params.role, + identity, + selectedAuth, + scopes, + auth: buildGatewayConnectAuth(selectedAuth), + }; + } + const signedAtMs = this.deps.nowMs?.() ?? Date.now(); + const nonce = params.nonce ?? ""; + const { authBootstrapToken: primary, signatureToken: signed } = selectedAuth; + let token: string | null = null; + if (primary) { + token = primary; + } else if (signed) { + token = signed; + } + const payload = buildDeviceAuthPayloadV3({ + deviceId: identity.deviceId, + clientId: params.client.id, + clientMode: params.client.mode, + role: params.role, + scopes, + signedAtMs, + token, + nonce, + platform: params.client.platform, + deviceFamily: params.client.deviceFamily, + }); + return { + clientId: params.client.id, + role: params.role, + identity, + selectedAuth, + scopes, + auth: buildGatewayConnectAuth(selectedAuth), + device: { + id: identity.deviceId, + publicKey: identity.publicKey, + signature: await identity.sign(payload), + signedAt: signedAtMs, + nonce, + }, + }; + } + + async acceptHello( + hello: Pick, + plan: GatewayBrowserDeviceAuthPlan, + ): Promise { + const token = hello.auth?.deviceToken?.trim(); + if (!token || !plan.identity) { + return; + } + await this.deps.tokenStore.store({ + clientId: plan.clientId, + deviceId: plan.identity.deviceId, + role: hello.auth?.role ?? plan.role, + token, + scopes: hello.auth?.scopes ?? [], + }); + } + + async clearStoredToken(plan: GatewayBrowserDeviceAuthPlan): Promise { + if (!plan.identity) { + return; + } + await this.deps.tokenStore.clear({ + clientId: plan.clientId, + deviceId: plan.identity.deviceId, + role: plan.role, + }); + } +} diff --git a/packages/gateway-client/src/browser.ts b/packages/gateway-client/src/browser.ts index f1133b1f8308..b1e199351f16 100644 --- a/packages/gateway-client/src/browser.ts +++ b/packages/gateway-client/src/browser.ts @@ -1,6 +1,7 @@ // Browser-safe gateway client surface. Keep Node transport/TLS dependencies out // of this entry so browser consumers share the wire engine without polyfills. export * from "./device-auth.js"; +export * from "./browser-device-auth.js"; export * from "./connect-auth.js"; export * from "./protocol-client.js"; export * from "./reconnect-policy.js"; diff --git a/packages/gateway-client/src/index.ts b/packages/gateway-client/src/index.ts index bcee97f3e817..ca92b16642d9 100644 --- a/packages/gateway-client/src/index.ts +++ b/packages/gateway-client/src/index.ts @@ -1,6 +1,7 @@ // Public gateway-client package surface: connection client, device auth, // readiness helpers, event-loop readiness, and timeout utilities. export * from "./client.js"; +export * from "./browser-device-auth.js"; export * from "./connect-auth.js"; export * from "./device-auth.js"; export * from "./event-loop-ready.js"; diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index 29dcf2de7f47..f8344122f1f3 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -16,6 +16,7 @@ function normalizeOptionalLowercaseString(raw?: string | null): string | undefin export const GATEWAY_CLIENT_IDS = { WEBCHAT_UI: "webchat-ui", CONTROL_UI: "openclaw-control-ui", + BROWSER_COPILOT: "openclaw-browser-copilot", TUI: "openclaw-tui", WEBCHAT: "webchat", CLI: "cli", @@ -79,6 +80,8 @@ export const GATEWAY_CLIENT_CAPS = { APPROVALS: "approvals", EXEC_APPROVALS: "exec-approvals", INLINE_WIDGETS: "inline-widgets", + RUN_TOOL_BINDINGS: "run-tool-bindings", + SESSION_SCOPED_EVENTS: "session-scoped-events", PLUGIN_APPROVALS: "plugin-approvals", TASK_SUGGESTIONS: "task-suggestions", TERMINAL_OFFSET_SEQ: "terminal-offset-seq", diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index f19090092c3d..99be458c9195 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -54,6 +54,7 @@ export const DevicePairRequestedEventSchema = closedObject({ deviceFamily: Type.Optional(NonEmptyString), clientId: Type.Optional(NonEmptyString), clientMode: Type.Optional(NonEmptyString), + browserOrigin: Type.Optional(NonEmptyString), role: Type.Optional(NonEmptyString), roles: Type.Optional(Type.Array(NonEmptyString)), scopes: Type.Optional(Type.Array(NonEmptyString)), diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index b596ad244cd7..1efbd30f0efe 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -85,6 +85,14 @@ export type ChatMessageGetResult = Static; /** Attachment envelope shared by chat.send and session creation's initial turn. */ export const ChatAttachmentsSchema = Type.Array(Type.Unknown()); +/** Opaque, out-of-band plugin bindings carried separately from model input. */ +export const RunToolBindingsSchema = Type.Record( + Type.String({ minLength: 1, maxLength: 128 }), + Type.Unknown(), + { maxProperties: 16 }, +); +export type RunToolBindings = Static; + /** User-to-agent send request; idempotency key lets clients safely retry transport failures. */ export const ChatSendParamsSchema = closedObject({ sessionKey: ChatSendSessionKeyString, @@ -103,6 +111,7 @@ export const ChatSendParamsSchema = closedObject({ originatingAccountId: Type.Optional(Type.String()), originatingThreadId: Type.Optional(Type.String()), attachments: Type.Optional(ChatAttachmentsSchema), + toolBindings: Type.Optional(RunToolBindingsSchema), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), systemInputProvenance: Type.Optional(InputProvenanceSchema), systemProvenanceReceipt: Type.Optional(Type.String()), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55a2a903c7a9..89c30b76c162 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,6 +488,12 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.4.3) + '@noble/ed25519': + specifier: 3.1.0 + version: 3.1.0 + esbuild: + specifier: 0.28.1 + version: 0.28.1 express: specifier: 5.2.1 version: 5.2.1 @@ -501,6 +507,9 @@ importers: specifier: 8.21.0 version: 8.21.0 devDependencies: + '@openclaw/gateway-client': + specifier: workspace:* + version: link:../../packages/gateway-client '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 69fc4ffbe7dc..f69e1a55987a 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -281,6 +281,8 @@ type OpenClawCodingToolsOptions = { messageChannel?: string; /** Capabilities declared by the gateway client that originated this run. */ clientCaps?: string[]; + /** Out-of-band plugin bindings attached by the run initiator. */ + toolBindings?: Readonly>; /** Normalized conversation kind when the caller already has channel metadata. */ chatType?: ChatType; /** Specific ingress provider used only for transport tool availability. */ @@ -876,6 +878,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) requesterAgentIdOverride: agentId, allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, clientCaps: options?.clientCaps, + toolBindings: options?.toolBindings, authProfileStore: options?.authProfileStore, }, resolvedConfig: options?.config, @@ -965,6 +968,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) sandboxed: Boolean(sandbox), config: options?.config, clientCaps: options?.clientCaps, + toolBindings: options?.toolBindings, pluginToolAllowlist, pluginToolDenylist, cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index da415b44a1dc..24968bcf6f38 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -205,6 +205,7 @@ export function prepareEmbeddedAttemptToolBase(params: { ...buildEmbeddedAttemptToolRunContext({ ...attempt, trace: params.runTrace }), messageChannel: attempt.messageChannel, clientCaps: attempt.clientCaps, + toolBindings: attempt.toolBindings, chatType: attempt.chatType, exec: { ...attempt.execOverrides, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index da5430074ac8..93559fd78878 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -81,6 +81,8 @@ export type RunEmbeddedAgentParams = { messageProvider?: string; /** Capabilities declared by the gateway client that originated this run. */ clientCaps?: string[]; + /** Out-of-band plugin bindings attached by the run initiator. */ + toolBindings?: Readonly>; chatType?: ChatType; agentAccountId?: string; /** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */ diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index 5646db95e027..98e093d7e681 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -47,6 +47,7 @@ export type OpenClawPluginToolOptions = { allowHostBrowserControl?: boolean; sandboxed?: boolean; allowGatewaySubagentBinding?: boolean; + toolBindings?: Readonly>; }; /** Resolves plugin-tool context inputs from runtime options and config state. */ @@ -97,6 +98,7 @@ export function resolveOpenClawPluginToolInputs(params: { agentId: sessionAgentId, sessionKey: options?.agentSessionKey, sessionId: options?.sessionId, + toolBindings: options?.toolBindings, activeModel, browser: { sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 12d138018922..b355c0395075 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -107,6 +107,7 @@ export function createOpenClawTools( sandboxBrowserBridgeUrl?: string; allowHostBrowserControl?: boolean; agentSessionKey?: string; + toolBindings?: Readonly>; /** * The actual live run session key. When the tool is constructed with a sandbox/policy * session key, this allows `session_status({sessionKey:"current"})` to resolve to diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index fbb6a67d71df..1100c33d8f1b 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -106,6 +106,7 @@ export function buildEmbeddedRunBaseParams(params: { silentReplyPromptMode: params.run.silentReplyPromptMode, sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode, clientCaps: params.run.clientCaps, + toolBindings: params.run.toolBindings, taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode, provider: params.provider, model: params.model, diff --git a/src/auto-reply/reply/agent-runner-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-runtime-config.test.ts index 227bea548cf5..972020000687 100644 --- a/src/auto-reply/reply/agent-runner-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-runtime-config.test.ts @@ -76,4 +76,19 @@ describe("buildEmbeddedRunBaseParams runtime config", () => { expect(resolved.config).toBe(resolvedRunConfig); }); + + it("carries out-of-band tool bindings into the embedded run", () => { + const run = makeRun({}); + run.toolBindings = { browser: { kind: "tab", targetId: "target-1" } }; + + const resolved = buildEmbeddedRunBaseParams({ + run, + provider: "openai", + model: "gpt-4.1-mini", + runId: "run-1", + authProfile: {}, + }); + + expect(resolved.toolBindings).toEqual(run.toolBindings); + }); }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 2241032638e2..188621e022da 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1597,6 +1597,7 @@ export async function runPreparedReply( runtimePolicySessionKey, messageProvider, clientCaps: ctx.GatewayClientCaps, + toolBindings: ctx.GatewayRunToolBindings, chatType: replyRoute.chatType, agentAccountId: replyRoute.accountId, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, diff --git a/src/auto-reply/reply/queue/drain.client-caps.test.ts b/src/auto-reply/reply/queue/drain.client-caps.test.ts index 521a2d8adcbf..6c64f3746a53 100644 --- a/src/auto-reply/reply/queue/drain.client-caps.test.ts +++ b/src/auto-reply/reply/queue/drain.client-caps.test.ts @@ -24,6 +24,28 @@ describe("followup delivery context", () => { ); }); + it("never collect-batches runs bound to different tool targets", () => { + const first = createQueueTestRun({ prompt: "first" }); + first.run.toolBindings = { browser: { kind: "tab", targetId: "tab-a" } }; + const second = createQueueTestRun({ prompt: "second" }); + second.run.toolBindings = { browser: { kind: "tab", targetId: "tab-b" } }; + + expect(resolveFollowupDeliveryContextKey(first)).not.toBe( + resolveFollowupDeliveryContextKey(second), + ); + }); + + it("canonicalizes equivalent tool bindings", () => { + const first = createQueueTestRun({ prompt: "first" }); + first.run.toolBindings = { browser: { targetId: "tab-a", kind: "tab" } }; + const second = createQueueTestRun({ prompt: "second" }); + second.run.toolBindings = { browser: { kind: "tab", targetId: "tab-a" } }; + + expect(resolveFollowupDeliveryContextKey(first)).toBe( + resolveFollowupDeliveryContextKey(second), + ); + }); + it("separates runs with different parent policy provenance", () => { const first = createQueueTestRun({ prompt: "first" }); first.run.spawnedBy = "agent:main:telegram:group:first"; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 08e1b2b1b002..a5c5fdaf773c 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js"; +import { stableStringify } from "../../../agents/stable-stringify.js"; import { normalizeChatType } from "../../../channels/chat-type.js"; import { resolveStorePath } from "../../../config/sessions.js"; import { loadSessionEntry } from "../../../config/sessions/session-accessor.js"; @@ -198,6 +199,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string { normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "", execution.messageProvider ?? "", JSON.stringify([...new Set(execution.clientCaps ?? [])].toSorted()), + stableStringify(execution.toolBindings ?? null), execution.chatType ?? "", execution.agentAccountId ?? "", execution.groupId ?? "", diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index c68cfe6f5298..8ced70f79170 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -128,6 +128,7 @@ export type FollowupRun = { runtimePolicySessionKey?: string; messageProvider?: string; clientCaps?: string[]; + toolBindings?: Readonly>; chatType?: ChatType; agentAccountId?: string; groupId?: string; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 277dccfa8781..1fb4f392390b 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -302,6 +302,8 @@ export type MsgContext = { GatewayClientScopes?: string[]; /** Gateway client capabilities when the message originates from the gateway. */ GatewayClientCaps?: string[]; + /** Run-scoped plugin tool bindings; never rendered into prompt text. */ + GatewayRunToolBindings?: Readonly>; /** Gateway device id allowed to review approvals initiated by this turn. */ ApprovalReviewerDeviceId?: string; /** Thread identifier (Telegram topic id or Matrix thread event id). */ diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 9a0373f2f8ff..5efd6831792d 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -562,6 +562,7 @@ describe("abortChatRunsForProvider", () => { state: "aborted", stopReason: "auth-revoked", }), + { sessionKeys: [sessionKey] }, ); }); }); diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index d1d4e3ce253a..a9b8e9d528a2 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -392,7 +392,11 @@ export type ChatAbortOps = { ) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined; agentRunSeq: Map; getRuntimeConfig?: () => OpenClawConfig; - broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; + broadcast: ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean; sessionKeys?: readonly string[] }, + ) => void; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; }; @@ -484,12 +488,9 @@ function broadcastChatAborted( } : undefined, }; - ops.broadcast("chat", payload); - for (const deliverySessionKey of resolveChatAbortDeliverySessionKeys( - ops, - sessionKey, - payloadAgentId, - )) { + const deliverySessionKeys = resolveChatAbortDeliverySessionKeys(ops, sessionKey, payloadAgentId); + ops.broadcast("chat", payload, { sessionKeys: deliverySessionKeys }); + for (const deliverySessionKey of deliverySessionKeys) { ops.nodeSendToSession(deliverySessionKey, "chat", payload); } } diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index d12239a6c419..13e18828ab0c 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -6,6 +6,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { beforeAll, beforeEach, describe, expect, it, test, vi } from "vitest"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; import type { RequestFrame } from "../../packages/gateway-protocol/src/index.js"; import { onDiagnosticEvent, @@ -24,6 +29,10 @@ import { } from "./node-command-policy.js"; import type { SerializedEventPayload } from "./node-registry.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; +import { + createSessionEventSubscriberRegistry, + createSessionMessageSubscriberRegistry, +} from "./server-chat-state.js"; import { createChatRunRegistry } from "./server-chat.js"; import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js"; @@ -412,6 +421,47 @@ describe("gateway broadcaster", () => { expect(workerSocket.send).not.toHaveBeenCalled(); }); + it("delivers scoped client events only for gateway-owned session subscriptions", () => { + const legacySocket = makeRecordingSocket(); + const firstSocket = makeRecordingSocket(); + const secondSocket = makeRecordingSocket(); + const legacy = makeOperatorWsClient("legacy", legacySocket, ["operator.read"]); + const first = makeOperatorWsClient("first", firstSocket, ["operator.read"]); + const second = makeOperatorWsClient("second", secondSocket, ["operator.read"]); + first.connect.caps = [ + GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + ]; + second.connect.caps = []; + second.connect.client = { + id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT, + version: "test", + platform: "chrome", + mode: GATEWAY_CLIENT_MODES.UI, + }; + const clients = new Set([legacy, first, second]); + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + sessionMessageSubscribers.subscribe(first.connId, "session-a"); + sessionMessageSubscribers.subscribe(second.connId, "session-b"); + const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ + clients, + sessionMessageSubscribers, + }); + + broadcast("chat", { sessionKey: "session-a" }, { sessionKeys: ["session-a"] }); + broadcast("agent", { stream: "lifecycle" }); + broadcastToConnIds("agent", { sessionKey: "session-a" }, new Set([first.connId]), { + sessionKeys: ["session-a"], + }); + broadcastToConnIds("agent", { sessionKey: "session-a" }, new Set([second.connId]), { + sessionKeys: ["session-a"], + }); + + expect(sentEvents(legacySocket)).toEqual(["chat", "agent"]); + expect(sentEvents(firstSocket)).toEqual(["chat", "agent"]); + expect(sentEvents(secondSocket)).toEqual([]); + }); + it("filters approval and pairing events by scope", () => { const approvalsSocket: TestSocket = { bufferedAmount: 0, @@ -861,7 +911,11 @@ describe("node subscription manager", () => { }; const parseSpy = vi.spyOn(JSON, "parse"); try { - const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() }); + const runtime = createGatewayNodeSessionRuntime({ + broadcast: vi.fn(), + sessionEventSubscribers: createSessionEventSubscriberRegistry(), + sessionMessageSubscribers: createSessionMessageSubscriberRegistry(), + }); runtime.nodeRegistry.register( makeGatewayWsClient("conn-node-a", socket, { role: "node", diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index ebe0cbe34db9..7add60b6e46d 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -1,7 +1,7 @@ // Browser origin tests document same-origin, private-network, loopback, forwarded // host, and explicit allowlist decisions for gateway browser surfaces. import { describe, expect, it } from "vitest"; -import { checkBrowserOrigin } from "./origin-check.js"; +import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "./origin-check.js"; describe("checkBrowserOrigin", () => { it.each([ @@ -173,4 +173,14 @@ describe("checkBrowserOrigin", () => { reason: "origin missing or invalid", }); }); + + it("recognizes only canonical Chrome extension origins", () => { + expect( + normalizeChromeExtensionOrigin("chrome-extension://abcdefghijklmnopabcdefghijklmnop"), + ).toBe("chrome-extension://abcdefghijklmnopabcdefghijklmnop"); + expect(normalizeChromeExtensionOrigin("chrome-extension://abc")).toBeUndefined(); + expect( + normalizeChromeExtensionOrigin("https://abcdefghijklmnopabcdefghijklmnop"), + ).toBeUndefined(); + }); }); diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index 1ccb8f826387..8cc37a447fd2 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -16,7 +16,7 @@ type OriginCheckResult = function parseOrigin( originRaw?: string, -): { origin: string; host: string; hostname: string } | null { +): { origin: string; protocol: string; host: string; hostname: string } | null { const trimmed = (originRaw ?? "").trim(); if (!trimmed || trimmed === "null") { return null; @@ -35,6 +35,7 @@ function parseOrigin( const origin = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin; return { origin: normalizeLowercaseStringOrEmpty(origin), + protocol: normalizeLowercaseStringOrEmpty(url.protocol), host: normalizeLowercaseStringOrEmpty(url.host), hostname: normalizeLowercaseStringOrEmpty(url.hostname), }; @@ -43,6 +44,14 @@ function parseOrigin( } } +/** Return a canonical Chrome extension origin for pairing-bound authorization. */ +export function normalizeChromeExtensionOrigin(originRaw?: string): string | undefined { + const parsed = parseOrigin(originRaw); + return parsed?.protocol === "chrome-extension:" && /^[a-p]{32}$/u.test(parsed.hostname) + ? parsed.origin + : undefined; +} + /** Validate a browser Origin against explicit allowlist, same-host, and local dev rules. */ export function checkBrowserOrigin(params: { requestHost?: string; diff --git a/src/gateway/server-broadcast-types.ts b/src/gateway/server-broadcast-types.ts index 8030837444a4..c7d980630e60 100644 --- a/src/gateway/server-broadcast-types.ts +++ b/src/gateway/server-broadcast-types.ts @@ -8,6 +8,8 @@ type GatewayBroadcastStateVersion = { /** Options for gateway websocket broadcasts. */ export type GatewayBroadcastOpts = { dropIfSlow?: boolean; + /** Canonical subscription keys for session-scoped delivery. */ + sessionKeys?: readonly string[]; stateVersion?: GatewayBroadcastStateVersion; }; diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 3272d3a2c2a7..498b6a441571 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -1,6 +1,11 @@ +import { + GATEWAY_CLIENT_CAPS, + hasGatewayClientCap, +} from "../../packages/gateway-protocol/src/client-info.js"; // Gateway WebSocket broadcaster. // Applies event scope guards and slow-consumer handling before sending frames. import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; +import { isBrowserCopilotClient } from "../utils/message-channel.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, @@ -17,6 +22,7 @@ import type { GatewayPluginEventBroadcastFn, GatewayPluginEventScope, } from "./server-broadcast-types.js"; +import type { SessionMessageSubscriberRegistry } from "./server-chat-state.js"; import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js"; @@ -73,6 +79,10 @@ const EVENT_SCOPE_GUARDS: Record = { // (e.g. reconfiguring wake-word triggers). const NODE_ALLOWED_EVENTS = new Set(["voicewake.changed", "voicewake.routing.changed"]); +// Opt-in scoped clients never receive session-bearing broadcasts without an +// authoritative registry key, including malformed/sessionless agent events. +const SESSION_SUBSCRIPTION_EVENTS = new Set(["agent", "chat", "chat.side_result"]); + function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string { // Serialize one field through JSON.stringify so embedded values keep JSON // escaping, then splice it into the shared per-client frame body. @@ -134,7 +144,10 @@ function hasEventScope( return required.some((scope) => scopes.includes(scope)); } -export function createGatewayBroadcaster(params: { clients: Set }) { +export function createGatewayBroadcaster(params: { + clients: Set; + sessionMessageSubscribers?: SessionMessageSubscriberRegistry; +}) { const clientSeq = new WeakMap(); const reportedSlowPayloadClients = new WeakSet(); @@ -191,6 +204,19 @@ export function createGatewayBroadcaster(params: { clients: Set if (!hasEventScope(c, event, explicitPluginScope)) { continue; } + if ( + (isBrowserCopilotClient(c.connect.client) || + hasGatewayClientCap(c.connect.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS)) && + SESSION_SUBSCRIPTION_EVENTS.has(event) && + (!opts?.sessionKeys?.length || + !opts.sessionKeys.some((sessionKey) => + params.sessionMessageSubscribers?.get(sessionKey).has(c.connId), + )) + ) { + // Scoped clients opt out of legacy broadcast fanout. The server-side + // subscription registry is the authority, so client filtering cannot leak a sibling tab. + continue; + } const nextSeq = (clientSeq.get(c) ?? 0) + 1; const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES; if (!slow) { diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index d1544b63b047..f7de4104dedd 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -2092,6 +2092,9 @@ describe("agent event handler", () => { expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual( new Set(["conn-run"]), ); + expect(requireMockArg(broadcastToConnIds, 0, 3, "run tool options")).toEqual({ + sessionKeys: ["session-1"], + }); }); it("projects tool-search bridge calls like native channel verbose tool events", () => { diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 5377be7ecf3c..ad8dca17f1c3 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -36,6 +36,7 @@ import { resolveMergedAssistantText, shouldSuppressAssistantEventForLiveChat, } from "./live-chat-projector.js"; +import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js"; import { isChatAbortMarkerCurrent } from "./server-chat-state.js"; import type { BufferedAgentEvent, @@ -203,11 +204,7 @@ function normalizeHeartbeatChatFinalText(params: { */ const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000; -export type ChatEventBroadcast = ( - event: string, - payload: unknown, - opts?: { dropIfSlow?: boolean }, -) => void; +export type ChatEventBroadcast = GatewayBroadcastFn; export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void; @@ -305,12 +302,7 @@ function resolveBroadcastDelta(params: { export type AgentEventHandlerOptions = { broadcast: ChatEventBroadcast; - broadcastToConnIds: ( - event: string, - payload: unknown, - connIds: ReadonlySet, - opts?: { dropIfSlow?: boolean }, - ) => void; + broadcastToConnIds: GatewayBroadcastToConnIdsFn; nodeSendToSession: NodeSendToSession; agentRunSeq: Map; chatRunState: ChatRunState; @@ -1039,13 +1031,19 @@ export function createAgentEventHandler({ ) => { const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); if (opts?.controlUiVisible ?? true) { - broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow }); + broadcast("chat", payload, { + dropIfSlow: opts?.dropIfSlow, + sessionKeys: [deliverySessionKey], + }); sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId); return; } const recipients = sessionMessageSubscribers.get(deliverySessionKey); if (recipients.size > 0) { - broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); + broadcastToConnIds("chat", payload, recipients, { + dropIfSlow: opts?.dropIfSlow, + sessionKeys: [deliverySessionKey], + }); } }; @@ -1122,7 +1120,11 @@ export function createAgentEventHandler({ opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean }, ) => { if (opts?.controlUiVisible ?? true) { - broadcast("agent", payload); + broadcast("agent", payload, { + sessionKeys: sessionKey + ? [resolveSessionDeliveryKey(sessionKey, opts?.agentId)] + : undefined, + }); if (sessionKey) { sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId); } @@ -1131,11 +1133,13 @@ export function createAgentEventHandler({ if (!sessionKey) { return; } - const recipients = sessionMessageSubscribers.get( - resolveSessionDeliveryKey(sessionKey, opts?.agentId), - ); + const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); + const recipients = sessionMessageSubscribers.get(deliverySessionKey); if (recipients.size > 0) { - broadcastToConnIds("agent", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); + broadcastToConnIds("agent", payload, recipients, { + dropIfSlow: opts?.dropIfSlow, + sessionKeys: [deliverySessionKey], + }); } }; @@ -1376,19 +1380,27 @@ export function createAgentEventHandler({ : agentPayload; if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) { flushBufferedAgentDeltaIfNeeded(clientRunId); - broadcast("agent", { - runId: eventRunId, - stream: "error", - ts: Date.now(), - sessionKey, - ...(spawnedBy && { spawnedBy }), - ...(isHeartbeat !== undefined && { isHeartbeat }), - data: { - reason: "seq gap", - expected: last + 1, - received: evt.seq, + broadcast( + "agent", + { + runId: eventRunId, + stream: "error", + ts: Date.now(), + sessionKey, + ...(spawnedBy && { spawnedBy }), + ...(isHeartbeat !== undefined && { isHeartbeat }), + data: { + reason: "seq gap", + expected: last + 1, + received: evt.seq, + }, }, - }); + { + sessionKeys: sessionKey + ? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)] + : undefined, + }, + ); } agentRunSeq.set(evt.runId, evt.seq); if (evt.stream === "assistant") { @@ -1438,7 +1450,8 @@ export function createAgentEventHandler({ // Always broadcast tool events to registered WS recipients with // tool-events capability, regardless of verboseLevel. The verbose // setting only controls whether tool details are sent as channel - // messages to messaging surfaces (Telegram, Discord, etc.). + // messages to messaging surfaces (Telegram, Discord, etc.). Carry the + // delivery key so scoped clients must also own the session subscription. const runToolRecipients = toolEventRecipients.get(evt.runId); if ( isControlUiVisible && @@ -1455,6 +1468,11 @@ export function createAgentEventHandler({ } : agentPayload, runToolRecipients, + { + sessionKeys: sessionKey + ? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)] + : undefined, + }, ); } if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 99a054dc7c05..225ca6240455 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -698,6 +698,7 @@ describe("createGatewayCloseHandler", () => { expect(broadcast).toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "run-1", state: "aborted", stopReason: "restart" }), + { sessionKeys: ["session-1"] }, ); expect(nodeSendToSession).toHaveBeenCalledWith( "session-1", @@ -711,6 +712,7 @@ describe("createGatewayCloseHandler", () => { state: "aborted", stopReason: "restart", }), + { sessionKeys: ["session-1"] }, ); expect(nodeSendToSession).toHaveBeenCalledWith( "session-1", diff --git a/src/gateway/server-methods/chat-broadcast.ts b/src/gateway/server-methods/chat-broadcast.ts index 0e9f41c04693..300be02523a0 100644 --- a/src/gateway/server-methods/chat-broadcast.ts +++ b/src/gateway/server-methods/chat-broadcast.ts @@ -79,7 +79,13 @@ export function broadcastChatFinal(params: { state: "final" as const, message: projectChatDisplayMessage(params.message), }; - params.context.broadcast("chat", payload); + params.context.broadcast("chat", payload, { + sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({ + cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig), + sessionKey: params.sessionKey, + agentId: payloadAgentId, + }), + }); sendGlobalAwareNodeChatPayload({ context: params.context, sessionKey: params.sessionKey, @@ -114,7 +120,13 @@ export function broadcastSideResult(params: { ...(payloadAgentId ? { agentId: payloadAgentId } : {}), seq, }; - params.context.broadcast("chat.side_result", payload); + params.context.broadcast("chat.side_result", payload, { + sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({ + cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig), + sessionKey: params.payload.sessionKey, + agentId: payloadAgentId, + }), + }); sendGlobalAwareNodeChatPayload({ context: params.context, sessionKey: params.payload.sessionKey, @@ -159,7 +171,13 @@ export function broadcastChatError(params: { } : {}), }; - params.context.broadcast("chat", payload); + params.context.broadcast("chat", payload, { + sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({ + cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig), + sessionKey: params.sessionKey, + agentId: payloadAgentId, + }), + }); sendGlobalAwareNodeChatPayload({ context: params.context, sessionKey: params.sessionKey, diff --git a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts index f5b89c502ed3..0f884ff9e5a1 100644 --- a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts +++ b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts @@ -58,6 +58,7 @@ describe("createChatSendDispatchErrorLifecycle", () => { expect(broadcast).toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "run-1", state: "final" }), + { sessionKeys: ["agent:main:main"] }, ); expect(cleanupAdmittedRun).toHaveBeenCalledOnce(); expect(removeChatRun).toHaveBeenCalledWith("run-1", "run-1", "agent:main:main"); diff --git a/src/gateway/server-methods/chat-send-request.test.ts b/src/gateway/server-methods/chat-send-request.test.ts index 06f58345c325..bd0edae6b93b 100644 --- a/src/gateway/server-methods/chat-send-request.test.ts +++ b/src/gateway/server-methods/chat-send-request.test.ts @@ -1,5 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeChatSendRequest } from "./chat-send-request.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +function copilotClient(caps: string[] = []): NonNullable { + return { + connId: "copilot", + pairedClientId: "openclaw-browser-copilot", + connect: { + role: "operator", + scopes: ["operator.read", "operator.write"], + caps, + client: { + id: "openclaw-browser-copilot", + version: "test", + platform: "chrome", + mode: "ui", + }, + }, + } as unknown as NonNullable; +} function validParams(overrides: Record = {}) { return { @@ -77,4 +96,50 @@ describe("normalizeChatSendRequest", () => { error: "system provenance fields require admin scope", }); }); + + it("requires capable copilot runs to carry explicit tool bindings", () => { + expect(normalizeChatSendRequest({ params: validParams(), client: copilotClient() })).toEqual({ + ok: false, + error: "browser copilot runs require an explicit browser tool binding", + }); + + expect( + normalizeChatSendRequest({ + params: validParams({ toolBindings: { unrelated: true } }), + client: copilotClient(["run-tool-bindings"]), + }), + ).toEqual({ + ok: false, + error: "browser copilot runs require an explicit browser tool binding", + }); + + const toolBindings = { browser: { kind: "tab", tabId: 1, targetId: "target" } }; + expect( + normalizeChatSendRequest({ + params: validParams({ toolBindings }), + client: copilotClient(), + }), + ).toEqual({ ok: false, error: "run tool bindings require client capability" }); + expect( + normalizeChatSendRequest({ + params: validParams({ toolBindings }), + client: copilotClient(["run-tool-bindings"]), + }), + ).toMatchObject({ ok: true, value: { p: { toolBindings } } }); + }); + + it("accepts tool bindings only from a server-paired copilot identity", () => { + const toolBindings = { browser: { kind: "tab", tabId: 1, targetId: "target" } }; + const unpaired = copilotClient(["run-tool-bindings"]); + unpaired.pairedClientId = undefined; + expect( + normalizeChatSendRequest({ params: validParams({ toolBindings }), client: unpaired }), + ).toEqual({ ok: false, error: "run tool bindings require a paired browser copilot" }); + + const otherClient = copilotClient(["run-tool-bindings"]); + otherClient.connect.client.id = "openclaw-control-ui"; + expect( + normalizeChatSendRequest({ params: validParams({ toolBindings }), client: otherClient }), + ).toEqual({ ok: false, error: "run tool bindings require a paired browser copilot" }); + }); }); diff --git a/src/gateway/server-methods/chat-send-request.ts b/src/gateway/server-methods/chat-send-request.ts index ce708e3694ca..3ab30162de73 100644 --- a/src/gateway/server-methods/chat-send-request.ts +++ b/src/gateway/server-methods/chat-send-request.ts @@ -13,7 +13,7 @@ import { isBtwRequestText } from "../../auto-reply/reply/btw-command.js"; import type { QueueMode } from "../../auto-reply/reply/queue/types.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import { normalizeInputProvenance } from "../../sessions/input-provenance.js"; -import { isOperatorUiClient } from "../../utils/message-channel.js"; +import { isBrowserCopilotClient, isOperatorUiClient } from "../../utils/message-channel.js"; import { isChatStopCommandText } from "../chat-abort.js"; import type { ChatAttachment } from "../chat-attachments.js"; import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js"; @@ -47,6 +47,7 @@ type ChatSendRequestParams = { fileName?: string; content?: unknown; }>; + toolBindings?: Record; timeoutMs?: number; systemInputProvenance?: InputProvenance; systemProvenanceReceipt?: string; @@ -65,6 +66,7 @@ export type NormalizedChatSendRequest = { systemInputProvenance?: InputProvenance; systemProvenanceReceipt?: string; suppressCommandInterpretation: boolean; + toolBindings?: Readonly>; stopCommand: boolean; turnKind: "btw" | "main"; normalizedAttachments: ChatAttachment[]; @@ -82,7 +84,8 @@ export function normalizeChatSendRequest(params: { client: GatewayRequestHandlerOptions["client"]; }): NormalizeChatSendRequestResult { const chatSendReceivedAtMs = performance.now(); - const clientInfo = params.client?.connect?.client; + const client = params.client; + const clientInfo = client?.connect?.client; const supportsTaskSuggestions = isOperatorUiClient(clientInfo) && params.client?.connect?.scopes?.includes("operator.admin") === true && @@ -135,6 +138,27 @@ export function normalizeChatSendRequest(params: { const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance); const systemProvenanceReceipt = systemReceiptResult.receipt; const stopCommand = !suppressCommandInterpretation && isChatStopCommandText(inboundMessage); + if (p.toolBindings) { + if ( + !client || + !isBrowserCopilotClient(clientInfo) || + client.pairedClientId !== clientInfo?.id + ) { + return { ok: false, error: "run tool bindings require a paired browser copilot" }; + } + if (!hasGatewayClientCap(client.connect.caps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS)) { + return { ok: false, error: "run tool bindings require client capability" }; + } + } + if ( + isBrowserCopilotClient(clientInfo) && + !stopCommand && + (!p.toolBindings || !Object.hasOwn(p.toolBindings, "browser")) + ) { + return { ok: false, error: "browser copilot runs require an explicit browser tool binding" }; + } + // The browser plugin owns the binding schema and validates it while tools are + // constructed, before model execution. Gateway owns only paired-client admission. const turnKind = !suppressCommandInterpretation && isBtwRequestText(inboundMessage) ? "btw" : "main"; const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments); @@ -155,6 +179,7 @@ export function normalizeChatSendRequest(params: { systemInputProvenance, systemProvenanceReceipt, suppressCommandInterpretation, + toolBindings: p.toolBindings, stopCommand, turnKind, normalizedAttachments, diff --git a/src/gateway/server-methods/chat-send-user-turn.test.ts b/src/gateway/server-methods/chat-send-user-turn.test.ts index f55663c1de17..71854b96dba5 100644 --- a/src/gateway/server-methods/chat-send-user-turn.test.ts +++ b/src/gateway/server-methods/chat-send-user-turn.test.ts @@ -69,6 +69,7 @@ describe("prepareChatSendUserTurn", () => { suppressCommandInterpretation: false, systemInputProvenance: { kind: "internal_system", sourceTool: "test" }, systemProvenanceReceipt: "[System receipt]", + toolBindings: { browser: { kind: "tab", targetId: "target-1" } }, }, session: { agentId: "main", @@ -104,6 +105,7 @@ describe("prepareChatSendUserTurn", () => { body: "/status", }, InputProvenance: { kind: "internal_system", sourceTool: "test" }, + GatewayRunToolBindings: { browser: { kind: "tab", targetId: "target-1" } }, OriginatingChannel: "discord", OriginatingTo: "channel:1", AccountId: "account-1", diff --git a/src/gateway/server-methods/chat-send-user-turn.ts b/src/gateway/server-methods/chat-send-user-turn.ts index 4583ba6f5e93..4a59971d2e86 100644 --- a/src/gateway/server-methods/chat-send-user-turn.ts +++ b/src/gateway/server-methods/chat-send-user-turn.ts @@ -112,6 +112,7 @@ function buildChatSendMessageContext(params: { suppressCommandInterpretation: boolean; systemInputProvenance?: InputProvenance; systemProvenanceReceipt?: string; + toolBindings?: Readonly>; }) { const commandBody = params.parsedMessage; const commandSource = @@ -175,6 +176,7 @@ function buildChatSendMessageContext(params: { : {}), GatewayClientScopes: params.client?.connect?.scopes ?? [], GatewayClientCaps: params.client?.connect?.caps ?? [], + GatewayRunToolBindings: params.toolBindings, }; if (params.mediaPathOffloadPaths.length > 0) { // Pre-staged offloads must use the channel media fields and marker so the @@ -203,6 +205,7 @@ export function prepareChatSendUserTurn(params: { | "suppressCommandInterpretation" | "systemInputProvenance" | "systemProvenanceReceipt" + | "toolBindings" >; session: Pick; admission: Pick; @@ -259,6 +262,7 @@ export function prepareChatSendUserTurn(params: { suppressCommandInterpretation: request.suppressCommandInterpretation, systemInputProvenance: request.systemInputProvenance, systemProvenanceReceipt: request.systemProvenanceReceipt, + toolBindings: request.toolBindings, }); const mediaPathOffloadsIncludeImages = attachments.mediaPathOffloadTypes.some((type) => type.startsWith("image/"), diff --git a/src/gateway/server-methods/chat.error-broadcast.test.ts b/src/gateway/server-methods/chat.error-broadcast.test.ts index 94a371435801..26c226923552 100644 --- a/src/gateway/server-methods/chat.error-broadcast.test.ts +++ b/src/gateway/server-methods/chat.error-broadcast.test.ts @@ -175,6 +175,7 @@ describe("chat.send error broadcast", () => { ], }), }), + { sessionKeys: ["agent:main:main"] }, ); }); @@ -211,6 +212,7 @@ describe("chat.send error broadcast", () => { agentId: "main", state: "error", }), + { sessionKeys: ["agent:main:global", "global"] }, ); expect(ctx.nodeSendToSession).toHaveBeenCalledWith( "agent:main:global", diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e36c8ad5ee7d..cecb0a9bc2ec 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1711,7 +1711,9 @@ export const chatHandlers: GatewayRequestHandlers = { state: "final" as const, message, }; - context.broadcast("chat", chatPayload); + context.broadcast("chat", chatPayload, { + sessionKeys: sessionKey === "global" && agentId ? [`agent:${agentId}:global`] : [sessionKey], + }); sendGlobalAwareNodeChatPayload({ context, sessionKey, diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 6f7773c6f5e1..210bcb6e35fc 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -1270,6 +1270,7 @@ describe("models.authLogout", () => { state: "aborted", stopReason: "auth-revoked", }), + { sessionKeys: [openrouterRun.sessionKey] }, ); const [, payload] = firstRespondCall(opts) ?? []; expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 9c168256462f..4151fbc02e97 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -64,6 +64,8 @@ export type GatewayClient = { connect: ConnectParams; connId?: string; clientIp?: string; + /** Client id verified against the server-approved device pairing record. */ + pairedClientId?: string; pluginSurfaceUrls?: Record; pluginNodeCapabilitySurfaces?: Record; pluginNodeCapabilities?: Record; diff --git a/src/gateway/server-node-session-runtime.ts b/src/gateway/server-node-session-runtime.ts index 47a7d97fdeca..919cbe1ccbf6 100644 --- a/src/gateway/server-node-session-runtime.ts +++ b/src/gateway/server-node-session-runtime.ts @@ -5,9 +5,9 @@ import { type NodeRegistryOptions, type SerializedEventPayload, } from "./node-registry.js"; -import { - createSessionEventSubscriberRegistry, - createSessionMessageSubscriberRegistry, +import type { + SessionEventSubscriberRegistry, + SessionMessageSubscriberRegistry, } from "./server-chat-state.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js"; @@ -20,6 +20,8 @@ export function createGatewayNodeSessionRuntime(params: { listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"]; nodePluginToolsEnabled?: boolean; nodeSkillsEnabled?: boolean; + sessionEventSubscribers: SessionEventSubscriberRegistry; + sessionMessageSubscribers: SessionMessageSubscriberRegistry; }) { const nodeRegistry = new NodeRegistry({ listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands, @@ -28,8 +30,8 @@ export function createGatewayNodeSessionRuntime(params: { }); const nodePresenceTimers = new Map>(); const nodeSubscriptions = createNodeSubscriptionManager(); - const sessionEventSubscribers = createSessionEventSubscriberRegistry(); - const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + const sessionEventSubscribers = params.sessionEventSubscribers; + const sessionMessageSubscribers = params.sessionMessageSubscribers; const nodeSendEvent = (opts: { nodeId: string; event: string; diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index ad8b101467c4..40fa9209c190 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -40,6 +40,8 @@ import { type ChatRunEntry, type ChatRunRegistration, createChatRunState, + createSessionEventSubscriberRegistry, + createSessionMessageSubscriberRegistry, createToolEventRecipientRegistry, } from "./server-chat-state.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; @@ -149,6 +151,8 @@ export async function createGatewayRuntimeState(params: { chatAbortControllers: Map; chatQueuedTurns: Map; toolEventRecipients: ReturnType; + sessionEventSubscribers: ReturnType; + sessionMessageSubscribers: ReturnType; getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined; getMcpAppSandboxPort: () => number | undefined; }> { @@ -163,7 +167,9 @@ export async function createGatewayRuntimeState(params: { const resolvePluginRouteRegistry = () => params.getPluginRouteRegistry?.() ?? params.pluginRegistry; const clients = new Set(); - const gatewayBroadcaster = createGatewayBroadcaster({ clients }); + const sessionEventSubscribers = createSessionEventSubscriberRegistry(); + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + const gatewayBroadcaster = createGatewayBroadcaster({ clients, sessionMessageSubscribers }); let loadedHooksRequestHandler: HooksRequestHandler | null = null; const handleHooksRequest: HooksRequestHandler = async (req, res) => { @@ -478,6 +484,8 @@ export async function createGatewayRuntimeState(params: { chatAbortControllers, chatQueuedTurns, toolEventRecipients, + sessionEventSubscribers, + sessionMessageSubscribers, getWorkerIngressEndpoint: () => workerIngressPort === undefined ? undefined diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 7618e6145f98..b09079db9f4a 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -4036,6 +4036,7 @@ describe("gateway server chat", () => { expect(broadcast).not.toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "idem-queued-followup", state: "final" }), + expect.anything(), ); dispatchRelease.resolve(); await waitForFast(() => { @@ -4046,6 +4047,7 @@ describe("gateway server chat", () => { sessionKey: "agent:main:main", state: "final", }), + { sessionKeys: ["agent:main:main"] }, ); }, FAST_WAIT_OPTS); const finalEvents = broadcast.mock.calls.filter( @@ -4465,6 +4467,7 @@ describe("gateway server chat", () => { ]), }), }), + { sessionKeys: ["agent:main:main"] }, ); }, { timeout: 2_000, interval: 5 }, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 0675debca868..aa0fad198d7e 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1150,6 +1150,8 @@ export async function startGatewayServer( chatAbortControllers, chatQueuedTurns, toolEventRecipients, + sessionEventSubscribers, + sessionMessageSubscribers, getWorkerIngressEndpoint, getMcpAppSandboxPort, } = await startupTrace.measure("runtime.state", () => @@ -1192,8 +1194,6 @@ export async function startGatewayServer( const { nodeRegistry, nodePresenceTimers, - sessionEventSubscribers, - sessionMessageSubscribers, nodeSendToSession, nodeSendToAllSubscribed, nodeSubscribe, @@ -1203,6 +1203,8 @@ export async function startGatewayServer( hasTalkNodeConnected, } = createGatewayNodeSessionRuntime({ broadcast, + sessionEventSubscribers, + sessionMessageSubscribers, listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands, nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false, nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.skills?.enabled !== false, diff --git a/src/gateway/server/ws-connection/connect-admission.ts b/src/gateway/server/ws-connection/connect-admission.ts index 5663e6067e4a..2d6f117cbd73 100644 --- a/src/gateway/server/ws-connection/connect-admission.ts +++ b/src/gateway/server/ws-connection/connect-admission.ts @@ -1,8 +1,10 @@ // Gateway WebSocket connect admission validates protocol, role, and browser origin. import type { IncomingMessage } from "node:http"; import { + GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, + hasGatewayClientCap, } from "../../../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { @@ -19,8 +21,12 @@ import { GATEWAY_STARTUP_PENDING_CLOSE_CAUSE, GATEWAY_STARTUP_RETRY_AFTER_MS, } from "../../../../packages/gateway-protocol/src/startup-unavailable.js"; -import { isBrowserOperatorUiClient, isOperatorUiClient } from "../../../utils/message-channel.js"; -import { checkBrowserOrigin } from "../../origin-check.js"; +import { + isBrowserCopilotClient, + isBrowserOperatorUiClient, + isOperatorUiClient, +} from "../../../utils/message-channel.js"; +import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "../../origin-check.js"; import { parseGatewayRole } from "../../role-policy.js"; import { formatForLog } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; @@ -143,7 +149,42 @@ export async function admitGatewayConnect(context: GatewayConnectPhaseContext) { connectParams.role = role; connectParams.scopes = scopes; - const isControlUi = isOperatorUiClient(connectParams.client); + const isBrowserCopilot = isBrowserCopilotClient(connectParams.client); + const browserCopilotOrigin = isBrowserCopilot + ? normalizeChromeExtensionOrigin(requestOrigin ?? undefined) + : undefined; + if ( + isBrowserCopilot && + (connectParams.client.mode !== GATEWAY_CLIENT_MODES.UI || + !hasGatewayClientCap(connectParams.caps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS) || + !hasGatewayClientCap(connectParams.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS)) + ) { + const message = + "browser copilot requires ui mode with run-tool-bindings and session-scoped-events capabilities"; + markHandshakeFailure("invalid-client", { + client: connectParams.client.id, + mode: connectParams.client.mode, + }); + sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message); + close(1008, truncateCloseReason(message)); + return undefined; + } + if (isBrowserCopilot && !browserCopilotOrigin) { + const message = "browser copilot requires a canonical Chrome extension origin"; + markHandshakeFailure("origin-mismatch", { + origin: requestOrigin ?? "n/a", + client: connectParams.client.id, + }); + sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message, { + details: { + code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + reason: "invalid browser copilot origin", + }, + }); + close(1008, truncateCloseReason(message)); + return undefined; + } + const isControlUi = isOperatorUiClient(connectParams.client) && !isBrowserCopilot; const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client); const isWebchat = isWebchatConnect(connectParams); const isNativeAppUi = @@ -151,7 +192,13 @@ export async function admitGatewayConnect(context: GatewayConnectPhaseContext) { (connectParams.client.id === GATEWAY_CLIENT_IDS.MACOS_APP || connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP || connectParams.client.id === GATEWAY_CLIENT_IDS.ANDROID_APP); - if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) { + // Extension origins cannot match the gateway host. Admission validates their + // canonical shape; device approval binds the exact origin before token issue. + const hasCopilotExtensionOrigin = Boolean(browserCopilotOrigin); + if ( + !hasCopilotExtensionOrigin && + (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) + ) { const hostHeaderOriginFallbackEnabled = configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; const originCheck = checkBrowserOrigin({ diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index a09275aa7f22..ad765242f2d8 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -26,8 +26,10 @@ import { resolveBootstrapProfileScopesForRoles, } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; +import { isBrowserCopilotClient } from "../../../utils/message-channel.js"; import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js"; import { shouldAutoApproveNodePairingFromTrustedCidrs } from "../../node-pairing-auto-approve.js"; +import { normalizeChromeExtensionOrigin } from "../../origin-check.js"; import { truncateCloseReason } from "../close-reason.js"; import { isControlUiOperatorBootstrapProfile, @@ -50,8 +52,16 @@ export async function authorizeGatewayConnectDevice( context: GatewayConnectPhaseContext, state: AuthenticatedGatewayConnect, ): Promise { - const { connId, buildRequestContext, close, send, setHandshakeState, setCloseCause, logGateway } = - context.handler; + const { + connId, + buildRequestContext, + close, + send, + setHandshakeState, + setCloseCause, + logGateway, + requestOrigin, + } = context.handler; const { frame, connectParams, @@ -77,6 +87,11 @@ export async function authorizeGatewayConnectDevice( skipControlUiPairingForDevice, } = state; let hasServerApprovedDeviceTokenBaseline = false; + let pairedClientId: string | undefined; + let pairedBrowserOrigin: string | undefined; + const browserCopilotOrigin = isBrowserCopilotClient(connectParams.client) + ? normalizeChromeExtensionOrigin(requestOrigin) + : undefined; if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { const normalized = normalizeSortedUniqueTrimmedStringList(items); @@ -97,6 +112,7 @@ export async function authorizeGatewayConnectDevice( deviceFamily: connectParams.client.deviceFamily, clientId: connectParams.client.id, clientMode: connectParams.client.mode, + ...(browserCopilotOrigin ? { browserOrigin: browserCopilotOrigin } : {}), role, scopes, remoteIp: reportedClientIp, @@ -430,6 +446,11 @@ export async function authorizeGatewayConnectDevice( if (!ok) { return undefined; } + const approvedDevice = await getPairedDevice(device.id); + pairedClientId = + approvedDevice?.publicKey === devicePublicKey ? approvedDevice.clientId : undefined; + pairedBrowserOrigin = + approvedDevice?.publicKey === devicePublicKey ? approvedDevice.browserOrigin : undefined; hasServerApprovedDeviceTokenBaseline = true; } else if ( skipControlUiPairingForDevice || @@ -438,6 +459,8 @@ export async function authorizeGatewayConnectDevice( hasServerApprovedDeviceTokenBaseline = true; } } else { + pairedClientId = paired.clientId; + pairedBrowserOrigin = paired.browserOrigin; hasServerApprovedDeviceTokenBaseline = true; const existingDevice = await authorizeExistingGatewayDevice({ context, @@ -456,6 +479,26 @@ export async function authorizeGatewayConnectDevice( } } + const browserCopilotIdentityMismatch = + pairedClientId !== connectParams.client.id && + (isBrowserCopilotClient(connectParams.client) || + isBrowserCopilotClient({ id: pairedClientId })); + const browserCopilotOriginMismatch = + isBrowserCopilotClient(connectParams.client) && + (!pairedBrowserOrigin || !browserCopilotOrigin || pairedBrowserOrigin !== browserCopilotOrigin); + if (browserCopilotIdentityMismatch || browserCopilotOriginMismatch) { + const message = "browser copilot requires a dedicated paired device identity"; + setHandshakeState("failed"); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.NOT_PAIRED, message), + }); + close(1008, truncateCloseReason(message)); + return undefined; + } + const { deviceToken, bootstrapDeviceTokens } = await issueGatewayConnectDeviceTokens({ state: { ...state, scopes, handoffBootstrapProfile }, scopes, diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index 1939f36e4a38..85e369178ff3 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -14,7 +14,10 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js" import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { loadNodeHostConfig } from "../../../node-host/config.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js"; -import { isEphemeralGatewayClient } from "../../../utils/message-channel.js"; +import { + isBrowserCopilotClient, + isEphemeralGatewayClient, +} from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; import { APPROVALS_SCOPE } from "../../method-scopes.js"; @@ -214,6 +217,9 @@ export async function attachAuthenticatedGatewayConnect( connId, connectionKind: "gateway", isDeviceTokenAuth: authMethod === "device-token", + pairedClientId: isBrowserCopilotClient(connectParams.client) + ? connectParams.client.id + : undefined, usesSharedGatewayAuth: sessionUsesSharedGatewayAuth, sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration, presenceKey, diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index 02ea5bfdf9e7..a460b29540b6 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -27,6 +27,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { connectionKind?: GatewayWsConnectionKind; worker?: WorkerConnectionIdentity; isDeviceTokenAuth?: boolean; + /** Client id verified against the server-approved device pairing record. */ + pairedClientId?: string; usesSharedGatewayAuth: boolean; sharedGatewaySessionGeneration?: string; presenceKey?: string; diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index 0b409eb9b87b..0f4bc41e9bf2 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -6,6 +6,11 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import type { RawData } from "ws"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; import { loadTranscriptEvents, persistSessionTranscriptTurn, @@ -24,6 +29,7 @@ import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { testState } from "./test-helpers.runtime-state.js"; import { connectOk, + connectReq, createGatewaySuiteHarness, installGatewayTestHooks, onceMessage, @@ -171,6 +177,251 @@ function expectRecordFields(value: unknown, expected: Record): } describe("session.message websocket events", () => { + test("enforces session-scoped chat delivery on real gateway connections", async () => { + const storePath = await createSessionStoreFile(); + await writeSessionStore({ + entries: { + main: { sessionId: "sess-main", updatedAt: Date.now() }, + other: { sessionId: "sess-other", updatedAt: Date.now() }, + }, + storePath, + }); + const copilotOrigin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + const copilotClient = { + id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT, + version: "test", + platform: "chrome", + deviceFamily: "extension", + mode: GATEWAY_CLIENT_MODES.UI, + }; + const copilotIdentityPath = path.join(path.dirname(storePath), "copilot-device.json"); + const unpairedWs = await harness.openWs({ origin: copilotOrigin }); + const pairingWs = await harness.openWs({ origin: copilotOrigin }); + const wrongOriginWs = await harness.openWs({ + origin: "chrome-extension://bcdefghijklmnopabcdefghijklmnopa", + }); + const mainWs = await harness.openWs({ origin: copilotOrigin }); + const otherWs = await harness.openWs(); + const legacyWs = await harness.openWs(); + try { + const scopedCaps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS]; + const unpaired = await connectReq(unpairedWs, { + scopes: ["operator.read", "operator.write"], + caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS], + client: copilotClient, + deviceIdentityPath: path.join(path.dirname(storePath), "unpaired-copilot-device.json"), + prePairDevice: false, + }); + expect(unpaired.ok).toBe(false); + expect(unpaired.error?.code).toBe("NOT_PAIRED"); + unpairedWs.close(); + + const pairedHello = await connectOk(pairingWs, { + scopes: ["operator.read", "operator.write"], + caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS], + client: copilotClient, + deviceIdentityPath: copilotIdentityPath, + prePairDevice: true, + browserOrigin: copilotOrigin, + }); + const deviceToken = (pairedHello as { auth?: { deviceToken?: string } }).auth?.deviceToken; + expect(deviceToken).toBeTruthy(); + pairingWs.close(); + const wrongOrigin = await connectReq(wrongOriginWs, { + scopes: ["operator.read", "operator.write"], + caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS], + client: copilotClient, + deviceIdentityPath: copilotIdentityPath, + deviceToken, + skipDefaultAuth: true, + }); + expect(wrongOrigin.ok).toBe(false); + expect(wrongOrigin.error?.code).toBe("NOT_PAIRED"); + expect(wrongOrigin.error?.message).toContain("dedicated paired device identity"); + wrongOriginWs.close(); + + await connectOk(mainWs, { + scopes: ["operator.read", "operator.write"], + caps: [...scopedCaps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS], + client: copilotClient, + deviceIdentityPath: copilotIdentityPath, + deviceToken, + skipDefaultAuth: true, + }); + await connectOk(otherWs, { scopes: ["operator.read"], caps: scopedCaps }); + await connectOk(legacyWs, { scopes: ["operator.read"] }); + await rpcReq(mainWs, "sessions.messages.subscribe", { key: "main" }); + await rpcReq(otherWs, "sessions.messages.subscribe", { key: "other" }); + + const mainEvent = onceMessage( + mainWs, + (message) => + message.type === "event" && + message.event === "chat" && + (message.payload as { sessionKey?: string } | undefined)?.sessionKey === + "agent:main:main", + ); + const legacyEvent = onceMessage( + legacyWs, + (message) => + message.type === "event" && + message.event === "chat" && + (message.payload as { sessionKey?: string } | undefined)?.sessionKey === + "agent:main:main", + ); + const otherReceived = onceMessage( + otherWs, + (message) => message.type === "event" && message.event === "chat", + 300, + ).then( + () => true, + () => false, + ); + + const send = await rpcReq(mainWs, "chat.send", { + sessionKey: "main", + message: "/status", + toolBindings: { + browser: { + kind: "tab", + tabId: 7, + target: "host", + profile: "chrome", + targetId: "target-7", + }, + }, + idempotencyKey: "scoped-delivery-proof", + }); + expect(send.ok, JSON.stringify(send)).toBe(true); + await expect(Promise.all([mainEvent, legacyEvent])).resolves.toHaveLength(2); + await expect(otherReceived).resolves.toBe(false); + } finally { + unpairedWs.close(); + pairingWs.close(); + wrongOriginWs.close(); + mainWs.close(); + otherWs.close(); + legacyWs.close(); + } + }); + + test("rejects client identity changes across a dedicated copilot pairing", async () => { + const storePath = await createSessionStoreFile(); + const copilotOrigin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + const identityPath = path.join(path.dirname(storePath), "other-client-device.json"); + const copilotIdentityPath = path.join(path.dirname(storePath), "copilot-paired-device.json"); + const controlWs = await harness.openWs({ origin: `http://127.0.0.1:${harness.port}` }); + const copilotWs = await harness.openWs({ + origin: copilotOrigin, + }); + const pairingWs = await harness.openWs({ + origin: copilotOrigin, + }); + const downgradeWs = await harness.openWs(); + const wrongModeWs = await harness.openWs({ + origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + }); + const webOriginWs = await harness.openWs({ origin: `http://127.0.0.1:${harness.port}` }); + const missingCapsWs = await harness.openWs({ + origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + }); + try { + const clientBase = { + version: "test", + platform: "chrome", + deviceFamily: "extension", + mode: GATEWAY_CLIENT_MODES.UI, + }; + const wrongMode = await connectReq(wrongModeWs, { + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + client: { + ...clientBase, + id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + deviceIdentityPath: path.join(path.dirname(storePath), "wrong-mode-device.json"), + prePairDevice: false, + scopes: ["operator.read", "operator.write"], + }); + expect(wrongMode.ok).toBe(false); + expect(wrongMode.error?.message).toContain("requires ui mode"); + wrongModeWs.close(); + + const missingCaps = await connectReq(missingCapsWs, { + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS], + client: { ...clientBase, id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }, + deviceIdentityPath: path.join(path.dirname(storePath), "missing-caps-device.json"), + prePairDevice: false, + scopes: ["operator.read", "operator.write"], + }); + expect(missingCaps.ok).toBe(false); + expect(missingCaps.error?.message).toContain("session-scoped-events"); + missingCapsWs.close(); + + const webOrigin = await connectReq(webOriginWs, { + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + client: { ...clientBase, id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }, + deviceIdentityPath: path.join(path.dirname(storePath), "web-origin-device.json"), + prePairDevice: false, + scopes: ["operator.read", "operator.write"], + }); + expect(webOrigin.ok).toBe(false); + expect(webOrigin.error?.message).toContain("canonical Chrome extension origin"); + webOriginWs.close(); + + await connectOk(controlWs, { + client: { ...clientBase, id: GATEWAY_CLIENT_IDS.CONTROL_UI }, + deviceIdentityPath: identityPath, + prePairDevice: true, + scopes: ["operator.read", "operator.write"], + }); + controlWs.close(); + + const response = await connectReq(copilotWs, { + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + client: { ...clientBase, id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }, + deviceIdentityPath: identityPath, + prePairDevice: false, + scopes: ["operator.read", "operator.write"], + }); + expect(response.ok).toBe(false); + expect(response.error?.code).toBe("NOT_PAIRED"); + expect(response.error?.message).toContain("dedicated paired device identity"); + + await connectOk(pairingWs, { + caps: [GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS], + client: { ...clientBase, id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT }, + deviceIdentityPath: copilotIdentityPath, + prePairDevice: true, + browserOrigin: copilotOrigin, + scopes: ["operator.read", "operator.write"], + }); + pairingWs.close(); + + const downgrade = await connectReq(downgradeWs, { + client: { + ...clientBase, + id: GATEWAY_CLIENT_IDS.TEST, + mode: GATEWAY_CLIENT_MODES.TEST, + }, + deviceIdentityPath: copilotIdentityPath, + prePairDevice: false, + scopes: ["operator.read", "operator.write"], + }); + expect(downgrade.ok).toBe(false); + expect(downgrade.error?.code).toBe("NOT_PAIRED"); + expect(downgrade.error?.message).toContain("dedicated paired device identity"); + } finally { + controlWs.close(); + copilotWs.close(); + pairingWs.close(); + downgradeWs.close(); + wrongModeWs.close(); + webOriginWs.close(); + missingCapsWs.close(); + } + }); + test("includes spawned session ownership metadata on lifecycle sessions.changed events", async () => { const storePath = await createSessionStoreFile(); await writeSessionStore({ diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index 332539ae5a3a..ab2d4bb24bea 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -1679,6 +1679,7 @@ describe("talk realtime gateway relay", () => { expect(broadcast).not.toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "run-1", state: "aborted" }), + expect.anything(), ); }); @@ -2135,6 +2136,7 @@ describe("talk realtime gateway relay", () => { expect(broadcast).not.toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "run-1", state: "aborted" }), + expect.anything(), ); }); @@ -2356,6 +2358,7 @@ describe("talk realtime gateway relay", () => { expect(broadcast).not.toHaveBeenCalledWith( "chat", expect.objectContaining({ runId: "run-1", state: "aborted" }), + expect.anything(), ); void submitTalkRealtimeRelayToolResult({ diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 1d81ddd9ed59..6c90935da8fd 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -954,6 +954,7 @@ type ConnectReqOptions = { deviceIdentityPath?: string; skipConnectChallengeNonce?: boolean; prePairDevice?: boolean; + browserOrigin?: string; timeoutMs?: number; }; @@ -998,6 +999,7 @@ async function prePairTestDevice(params: { client: ConnectReqClient; role: string; scopes: string[]; + browserOrigin?: string; }): Promise { const paired = await getPairedDevice(params.device.id); if ( @@ -1017,6 +1019,7 @@ async function prePairTestDevice(params: { scopes: params.scopes, clientId: params.client.id, clientMode: params.client.mode, + browserOrigin: params.browserOrigin, platform: params.client.platform, deviceFamily: params.client.deviceFamily, silent: false, @@ -1126,6 +1129,7 @@ export async function connectReq( client, role, scopes: requestedScopes, + browserOrigin: opts?.browserOrigin, }); } const isResponseForId = (o: unknown): boolean => { diff --git a/src/infra/device-pairing-store.ts b/src/infra/device-pairing-store.ts index 8202e9ed65c7..3c1cc887e41e 100644 --- a/src/infra/device-pairing-store.ts +++ b/src/infra/device-pairing-store.ts @@ -82,6 +82,7 @@ function toPendingRow(record: DevicePairingPendingRecord): DevicePairingPending device_family: record.deviceFamily ?? null, client_id: record.clientId ?? null, client_mode: record.clientMode ?? null, + browser_origin: record.browserOrigin ?? null, role: record.role ?? null, roles_json: toJsonColumn(record.roles), scopes_json: toJsonColumn(record.scopes), @@ -103,6 +104,7 @@ function fromPendingRow(row: DevicePairingPending): DevicePairingPendingRecord { ...optional("deviceFamily", row.device_family), ...optional("clientId", row.client_id), ...optional("clientMode", row.client_mode), + ...optional("browserOrigin", row.browser_origin), ...optional("role", row.role), ...optional("roles", fromJsonColumn(row.roles_json) ?? null), ...optional("scopes", fromJsonColumn(row.scopes_json) ?? null), @@ -124,6 +126,7 @@ function toPairedRow(device: PairedDevice): DevicePairingPaired { device_family: device.deviceFamily ?? null, client_id: device.clientId ?? null, client_mode: device.clientMode ?? null, + browser_origin: device.browserOrigin ?? null, role: device.role ?? null, roles_json: toJsonColumn(device.roles), scopes_json: toJsonColumn(device.scopes), @@ -154,6 +157,7 @@ function fromPairedRow(row: DevicePairingPaired): PairedDevice { ...optional("deviceFamily", row.device_family), ...optional("clientId", row.client_id), ...optional("clientMode", row.client_mode), + ...optional("browserOrigin", row.browser_origin), ...optional("role", row.role), ...optional("roles", fromJsonColumn(row.roles_json) ?? null), ...optional("scopes", fromJsonColumn(row.scopes_json) ?? null), diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 0581660f61a3..48127fa424d8 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -308,6 +308,9 @@ function samePendingApprovalSnapshot( if (existing.publicKey !== incoming.publicKey) { return false; } + if (existing.browserOrigin !== incoming.browserOrigin) { + return false; + } if (normalizeRole(existing.role) !== normalizeRole(incoming.role)) { return false; } @@ -340,6 +343,9 @@ function incomingApprovalCoveredByExisting( if (existing.publicKey !== incoming.publicKey) { return false; } + if (existing.browserOrigin !== incoming.browserOrigin) { + return false; + } if (normalizeRole(existing.role) !== normalizeRole(incoming.role)) { return false; } @@ -376,6 +382,7 @@ function refreshPendingDevicePairingRequest( deviceFamily: incoming.deviceFamily ?? existing.deviceFamily, clientId: incoming.clientId ?? existing.clientId, clientMode: incoming.clientMode ?? existing.clientMode, + browserOrigin: existing.browserOrigin, remoteIp: incoming.remoteIp ?? existing.remoteIp, // If either request is interactive, keep the pending request visible for approval. silent: Boolean(existing.silent && incoming.silent), @@ -421,6 +428,7 @@ function buildPendingDevicePairingRequest(params: { deviceFamily: params.req.deviceFamily, clientId: params.req.clientId, clientMode: params.req.clientMode, + browserOrigin: params.req.browserOrigin, role, roles: mergeRoles(params.req.roles, role), scopes: mergeScopes(params.req.scopes), @@ -524,6 +532,7 @@ function buildApprovedPairedDevice(params: { deviceFamily: params.pending.deviceFamily, clientId: params.pending.clientId, clientMode: params.pending.clientMode, + browserOrigin: params.pending.browserOrigin, role: params.pending.role, roles: params.roles, scopes: params.approvedScopes, diff --git a/src/infra/device-pairing.types.ts b/src/infra/device-pairing.types.ts index 0713a8a93bec..2c8c3213f0f7 100644 --- a/src/infra/device-pairing.types.ts +++ b/src/infra/device-pairing.types.ts @@ -14,6 +14,7 @@ export type DevicePairingPendingRequest = { deviceFamily?: string; clientId?: string; clientMode?: string; + browserOrigin?: string; role?: string; roles?: string[]; scopes?: string[]; @@ -121,6 +122,7 @@ export type PairedDevice = { deviceFamily?: string; clientId?: string; clientMode?: string; + browserOrigin?: string; role?: string; roles?: string[]; scopes?: string[]; diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index b2a37fd6556d..77d4a61d9e0e 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -27,6 +27,8 @@ export type OpenClawPluginToolContext = { sessionKey?: string; /** Ephemeral session UUID - regenerated on /new and /reset. Use for per-conversation isolation. */ sessionId?: string; + /** Out-of-band plugin-owned bindings attached by the current run initiator. */ + toolBindings?: Readonly>; /** * Runtime-supplied active model metadata for informational use, diagnostics, * and plugin-owned policy decisions. This is not a security boundary against diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 4ad23ff3d5c0..c73c07aa8331 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -455,6 +455,7 @@ export interface DevicePairingPaired { approved_at_ms: number; approved_scopes_json: string | null; approved_via: string | null; + browser_origin: string | null; client_id: string | null; client_mode: string | null; created_at_ms: number; @@ -476,6 +477,7 @@ export interface DevicePairingPaired { } export interface DevicePairingPending { + browser_origin: string | null; client_id: string | null; client_mode: string | null; device_family: string | null; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index ae77589be8a5..75b001256135 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -1367,7 +1367,9 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void { ensureColumn(db, "node_host_config", "installed_apps_sharing INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "apns_registrations", "relay_origin TEXT"); ensureColumn(db, "device_pairing_pending", "refreshed_at_ms INTEGER"); + ensureColumn(db, "device_pairing_pending", "browser_origin TEXT"); ensureColumn(db, "device_pairing_paired", "approved_via TEXT"); + ensureColumn(db, "device_pairing_paired", "browser_origin TEXT"); ensureColumn(db, "device_pairing_paired", "operator_label TEXT"); ensureColumn(db, "device_pairing_paired", "node_surface_json TEXT"); ensureColumn(db, "device_pairing_paired", "pending_node_surface_json TEXT"); diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 9793a2a39dc5..4cd61d4fca4c 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -390,6 +390,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_pending ( device_family TEXT, client_id TEXT, client_mode TEXT, + browser_origin TEXT, role TEXT, roles_json TEXT, scopes_json TEXT, @@ -412,6 +413,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_paired ( device_family TEXT, client_id TEXT, client_mode TEXT, + browser_origin TEXT, role TEXT, roles_json TEXT, scopes_json TEXT, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index f07d41e3b0d5..5920f5f492bb 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -385,6 +385,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_pending ( device_family TEXT, client_id TEXT, client_mode TEXT, + browser_origin TEXT, role TEXT, roles_json TEXT, scopes_json TEXT, @@ -407,6 +408,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_paired ( device_family TEXT, client_id TEXT, client_mode TEXT, + browser_origin TEXT, role TEXT, roles_json TEXT, scopes_json TEXT, diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 98b188ef200a..a3c4addc0503 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -5,10 +5,13 @@ import type { ChannelPlugin } from "../channels/plugins/types.public.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { + isBrowserCopilotClient, + isBrowserOperatorUiClient, isEphemeralGatewayClient, isInternalNonDeliveryChannel, isMarkdownCapableMessageChannel, isNativeApprovalChannel, + isOperatorUiClient, resolveGatewayMessageChannel, } from "./message-channel.js"; @@ -72,6 +75,15 @@ describe("message-channel", () => { } }); + it("classifies the browser copilot as a dedicated browser operator UI", () => { + const client = { id: "openclaw-browser-copilot", mode: "ui" }; + expect(isBrowserCopilotClient(client)).toBe(true); + expect(isBrowserOperatorUiClient(client)).toBe(true); + expect(isOperatorUiClient(client)).toBe(true); + expect(isBrowserCopilotClient({ id: "webchat", mode: "webchat" })).toBe(false); + expect(isBrowserCopilotClient({ id: "openclaw-browser-copilot", mode: "webchat" })).toBe(true); + }); + it("normalizes plugin aliases when registered", () => { setActivePluginRegistry( createTestRegistry([ diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index 7422c9ace0c6..6593858f77bd 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -64,13 +64,25 @@ export function isEphemeralGatewayClient(client?: GatewayClientInfoLike | null): /** Return whether a client is one of the operator UI clients. */ export function isOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); - return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || clientId === GATEWAY_CLIENT_NAMES.TUI; + return ( + clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || + clientId === GATEWAY_CLIENT_NAMES.BROWSER_COPILOT || + clientId === GATEWAY_CLIENT_NAMES.TUI + ); } /** Return whether a client is the browser Control UI. */ export function isBrowserOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); - return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI; + return ( + clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || + clientId === GATEWAY_CLIENT_NAMES.BROWSER_COPILOT + ); +} + +/** Return whether a client is the first-party browser side-panel copilot. */ +export function isBrowserCopilotClient(client?: GatewayClientInfoLike | null): boolean { + return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.BROWSER_COPILOT; } /** Return whether a raw channel id resolves to OpenClaw's internal channel. */ diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index 3ff372602f50..962dd9cff84c 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -146,6 +146,7 @@ describe("oxlint config", () => { "dist-runtime/", "docs/_layouts/", "**/a2ui.bundle.js", + "extensions/browser/chrome-extension/modules/copilot-runtime.js", "extensions/diffs/assets/viewer-runtime.js", "extensions/diffs-language-pack/assets/viewer-runtime.js", "node_modules/",