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 <cameron.beeley@gmail.com>

* 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 <cameron.beeley@gmail.com>

* fix(browser): enforce copilot lifecycle boundaries

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* style(browser): format copilot sources

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* fix(browser): preserve copilot consent revocation

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* refactor(browser): split copilot custody owners

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* test(browser): normalize websocket array buffers

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* chore(protocol): regenerate Swift gateway models

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* refactor(browser): model copilot runtime entrypoints

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* fix(browser): honor extension build boundaries

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* test(gateway): assert targeted chat delivery

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* test(gateway): cover targeted delivery calls

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* fix(browser): declare copilot build dependencies

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* fix(ci): clear browser copilot gate failures

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* test(ci): cover copilot lint exclusion

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* fix(browser): gate copilot on relay custody

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

* test(browser): bound copilot relay frames

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>

---------

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-18 01:00:23 +01:00
committed by GitHub
parent c6d4559811
commit ec8f6e5e03
115 changed files with 7431 additions and 83 deletions
+1
View File
@@ -32,6 +32,7 @@
"dist/", "dist/",
"docs/_layouts/", "docs/_layouts/",
"extensions/diffs/assets/viewer-runtime.js", "extensions/diffs/assets/viewer-runtime.js",
"extensions/browser/chrome-extension/modules/copilot-runtime.js",
"**/*.json", "**/*.json",
"node_modules/", "node_modules/",
"patches/", "patches/",
+1
View File
@@ -211,6 +211,7 @@
"dist-runtime/", "dist-runtime/",
"docs/_layouts/", "docs/_layouts/",
"**/a2ui.bundle.js", "**/a2ui.bundle.js",
"extensions/browser/chrome-extension/modules/copilot-runtime.js",
"extensions/diffs/assets/viewer-runtime.js", "extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js", "extensions/diffs-language-pack/assets/viewer-runtime.js",
"node_modules/", "node_modules/",
@@ -13746,6 +13746,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
public let devicefamily: String? public let devicefamily: String?
public let clientid: String? public let clientid: String?
public let clientmode: String? public let clientmode: String?
public let browserorigin: String?
public let role: String? public let role: String?
public let roles: [String]? public let roles: [String]?
public let scopes: [String]? public let scopes: [String]?
@@ -13763,6 +13764,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
devicefamily: String? = nil, devicefamily: String? = nil,
clientid: String? = nil, clientid: String? = nil,
clientmode: String? = nil, clientmode: String? = nil,
browserorigin: String? = nil,
role: String? = nil, role: String? = nil,
roles: [String]? = nil, roles: [String]? = nil,
scopes: [String]? = nil, scopes: [String]? = nil,
@@ -13779,6 +13781,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
self.devicefamily = devicefamily self.devicefamily = devicefamily
self.clientid = clientid self.clientid = clientid
self.clientmode = clientmode self.clientmode = clientmode
self.browserorigin = browserorigin
self.role = role self.role = role
self.roles = roles self.roles = roles
self.scopes = scopes self.scopes = scopes
@@ -13797,6 +13800,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
case devicefamily = "deviceFamily" case devicefamily = "deviceFamily"
case clientid = "clientId" case clientid = "clientId"
case clientmode = "clientMode" case clientmode = "clientMode"
case browserorigin = "browserOrigin"
case role case role
case roles case roles
case scopes case scopes
@@ -13989,6 +13993,7 @@ public struct ChatSendParams: Codable, Sendable {
public let originatingaccountid: String? public let originatingaccountid: String?
public let originatingthreadid: String? public let originatingthreadid: String?
public let attachments: [AnyCodable]? public let attachments: [AnyCodable]?
public let toolbindings: [String: AnyCodable]?
public let timeoutms: Int? public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]? public let systeminputprovenance: [String: AnyCodable]?
public let systemprovenancereceipt: String? public let systemprovenancereceipt: String?
@@ -14011,6 +14016,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingaccountid: String? = nil, originatingaccountid: String? = nil,
originatingthreadid: String? = nil, originatingthreadid: String? = nil,
attachments: [AnyCodable]? = nil, attachments: [AnyCodable]? = nil,
toolbindings: [String: AnyCodable]? = nil,
timeoutms: Int? = nil, timeoutms: Int? = nil,
systeminputprovenance: [String: AnyCodable]? = nil, systeminputprovenance: [String: AnyCodable]? = nil,
systemprovenancereceipt: String? = nil, systemprovenancereceipt: String? = nil,
@@ -14032,6 +14038,7 @@ public struct ChatSendParams: Codable, Sendable {
self.originatingaccountid = originatingaccountid self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid self.originatingthreadid = originatingthreadid
self.attachments = attachments self.attachments = attachments
self.toolbindings = toolbindings
self.timeoutms = timeoutms self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt self.systemprovenancereceipt = systemprovenancereceipt
@@ -14054,6 +14061,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingaccountid: String? = nil, originatingaccountid: String? = nil,
originatingthreadid: String? = nil, originatingthreadid: String? = nil,
attachments: [AnyCodable]? = nil, attachments: [AnyCodable]? = nil,
toolbindings: [String: AnyCodable]? = nil,
timeoutms: Int? = nil, timeoutms: Int? = nil,
systeminputprovenance: [String: AnyCodable]? = nil, systeminputprovenance: [String: AnyCodable]? = nil,
systemprovenancereceipt: String? = nil, systemprovenancereceipt: String? = nil,
@@ -14076,6 +14084,7 @@ public struct ChatSendParams: Codable, Sendable {
originatingaccountid: originatingaccountid, originatingaccountid: originatingaccountid,
originatingthreadid: originatingthreadid, originatingthreadid: originatingthreadid,
attachments: attachments, attachments: attachments,
toolbindings: toolbindings,
timeoutms: timeoutms, timeoutms: timeoutms,
systeminputprovenance: systeminputprovenance, systeminputprovenance: systeminputprovenance,
systemprovenancereceipt: systemprovenancereceipt, systemprovenancereceipt: systemprovenancereceipt,
@@ -14099,6 +14108,7 @@ public struct ChatSendParams: Codable, Sendable {
case originatingaccountid = "originatingAccountId" case originatingaccountid = "originatingAccountId"
case originatingthreadid = "originatingThreadId" case originatingthreadid = "originatingThreadId"
case attachments case attachments
case toolbindings = "toolBindings"
case timeoutms = "timeoutMs" case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance" case systeminputprovenance = "systemInputProvenance"
case systemprovenancereceipt = "systemProvenanceReceipt" case systemprovenancereceipt = "systemProvenanceReceipt"
+4
View File
@@ -576,6 +576,10 @@ const config = {
// Chrome manifest/package scripts load these without TypeScript imports. // Chrome manifest/package scripts load these without TypeScript imports.
"chrome-extension/background.js!", "chrome-extension/background.js!",
"chrome-extension/popup.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!", "scripts/copy-chrome-extension.mjs!",
]), ]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([ [`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([
+1
View File
@@ -9630,6 +9630,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: How it works - H2: How it works
- H2: Install and pair - H2: Install and pair
- H2: Use it - H2: Use it
- H3: Tab copilot side panel
- H2: Remote / cross-machine - H2: Remote / cross-machine
- H2: Diagnostics - H2: Diagnostics
- H2: Security model - H2: Security model
+55
View File
@@ -91,12 +91,64 @@ openclaw config set browser.defaultProfile chrome
- Revoke: click the button again, drag the tab out of the group, or dismiss - 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. 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 <requestId>
```
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 ## Remote / cross-machine
Chrome does not have to run on the Gateway host. Three topologies work: 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 - **Same host** (Gateway + Chrome on one machine): pair on that machine with
`openclaw browser extension pair`. The relay is loopback-only. `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 - **Direct to a remote Gateway** (Chrome on your laptop, Gateway on a VPS, and
**nothing else on the laptop**): on the Gateway, run **nothing else on the laptop**): on the Gateway, run
`openclaw browser extension pair --gateway-url wss://your-gateway.example.com`. `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 bundled extension carries it in the WebSocket subprotocol list instead.
- The agent can only see and drive tabs in the **OpenClaw tab group**. Your - The agent can only see and drive tabs in the **OpenClaw tab group**. Your
other tabs stay private. 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 - Compared with the `user` (Chrome MCP) profile, which exposes your whole
signed-in browser once you approve the remote-debugging prompt, the extension 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. keeps the shared surface scoped to a tab group you control at a glance.
@@ -1,3 +1,4 @@
import { createCopilotController } from "./modules/copilot-background.js";
// OpenClaw extension service worker. // OpenClaw extension service worker.
// //
// Thin transport between the OpenClaw extension relay (loopback WebSocket) and // Thin transport between the OpenClaw extension relay (loopback WebSocket) and
@@ -20,16 +21,29 @@ const BADGE = {
on: { text: "ON", color: "#0F9D58" }, on: { text: "ON", color: "#0F9D58" },
error: { text: "!", color: "#B91C1C" }, 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} */ /** @type {WebSocket|null} */
let relayWs = null; let relayWs = null;
let relayState = "off"; // off | connecting | on | error let relayState = "off"; // off | connecting | on | error
let copilot = null;
let reconnectAttempt = 0; let reconnectAttempt = 0;
let reconnectTimer = null; let reconnectTimer = null;
/** Tab ids with an active chrome.debugger attachment. */ /** Tab ids with an active chrome.debugger attachment. */
const attachedTabs = new Set(); 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). */ /** In-flight attach promises per tab id (coalesces concurrent attaches). */
const attachingTabs = new Map(); 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. */ /** Debounce handle for tab-list refreshes. */
let tabsSyncTimer = null; let tabsSyncTimer = null;
@@ -38,6 +52,10 @@ function setBadge(kind) {
const cfg = BADGE[kind] ?? BADGE.off; const cfg = BADGE[kind] ?? BADGE.off;
void chrome.action.setBadgeText({ text: cfg.text }); void chrome.action.setBadgeText({ text: cfg.text });
void chrome.action.setBadgeBackgroundColor({ color: cfg.color }); 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() { 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) // Tab group management (the consent boundary)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -100,6 +127,18 @@ async function isTabShared(tabId) {
return shared.some((tab) => tab.id === 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() { function scheduleTabsSync() {
if (tabsSyncTimer) { if (tabsSyncTimer) {
return; return;
@@ -131,9 +170,7 @@ async function syncTabsToRelay() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function attachDebugger(tabId) { async function attachDebugger(tabId) {
if (!(await isTabShared(tabId))) { await copilotCustodyReady;
throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`);
}
// Coalesce concurrent attaches for one tab. Two relay attach commands (or an // Coalesce concurrent attaches for one tab. Two relay attach commands (or an
// auto-attach racing an explicit share) would otherwise both call // auto-attach racing an explicit share) would otherwise both call
// chrome.debugger.attach and the second throws "Another debugger is already // chrome.debugger.attach and the second throws "Another debugger is already
@@ -142,7 +179,21 @@ async function attachDebugger(tabId) {
if (inFlight) { if (inFlight) {
return await 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 () => { 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)) { if (!attachedTabs.has(tabId)) {
try { try {
await chrome.debugger.attach({ tabId }, "1.3"); await chrome.debugger.attach({ tabId }, "1.3");
@@ -152,9 +203,21 @@ async function attachDebugger(tabId) {
throw err; throw err;
} }
} }
try {
assertAccess();
} catch (error) {
await detachDebugger(tabId);
throw error;
}
attachedTabs.add(tabId); attachedTabs.add(tabId);
} }
const targets = await chrome.debugger.getTargets(); 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); const target = targets.find((candidate) => candidate.tabId === tabId && candidate.attached);
return { targetId: target?.id ?? `tab-${tabId}` }; return { targetId: target?.id ?? `tab-${tabId}` };
})(); })();
@@ -167,6 +230,8 @@ async function attachDebugger(tabId) {
} }
async function detachDebugger(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); attachedTabs.delete(tabId);
try { try {
await chrome.debugger.detach({ tabId }); 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) => { chrome.debugger.onEvent.addListener((source, method, params) => {
if (typeof source.tabId !== "number") { if (typeof source.tabId !== "number") {
return; return;
@@ -329,6 +422,19 @@ async function connectRelay() {
// onclose follows onerror and drives the reconnect, so no error handler needed. // 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() { function scheduleReconnect() {
if (reconnectTimer) { if (reconnectTimer) {
return; return;
@@ -372,15 +478,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
reconnectAttempt = 0; reconnectAttempt = 0;
relayWs?.close(); relayWs?.close();
relayWs = null; relayWs = null;
await chrome.storage.local.set({ gatewayUrl: parsed.gatewayUrl ?? "" });
await connectRelay(); await connectRelay();
await copilot.refreshConfig();
sendResponse({ ok: true }); sendResponse({ ok: true });
return; return;
} }
case "unpair": { case "unpair": {
await chrome.storage.local.remove(["relayUrl", "token"]); await chrome.storage.local.remove(["relayUrl", "gatewayUrl", "token"]);
relayWs?.close(); relayWs?.close();
relayWs = null; relayWs = null;
setBadge("off"); setBadge("off");
await copilot.refreshConfig();
sendResponse({ ok: true }); sendResponse({ ok: true });
return; return;
} }
@@ -400,12 +509,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
scheduleTabsSync(); scheduleTabsSync();
sendResponse({ ok: true, shared: true }); sendResponse({ ok: true, shared: true });
} }
await copilot.onConsentChanged();
return; return;
} }
case "isTabShared": { case "isTabShared": {
sendResponse({ shared: await isTabShared(msg.tabId) }); sendResponse({ shared: await isTabShared(msg.tabId) });
return; return;
} }
case "prepareCopilotPanel": {
const options = await copilot.preparePanel(msg.tabId);
sendResponse({ ok: true, ...options });
return;
}
default: default:
sendResponse({ ok: false, error: "unknown message" }); sendResponse({ ok: false, error: "unknown message" });
} }
@@ -414,20 +529,44 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}); });
chrome.tabs.onRemoved.addListener((tabId) => { chrome.tabs.onRemoved.addListener((tabId) => {
copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1);
attachedTabs.delete(tabId); attachedTabs.delete(tabId);
copilotDeniedTabs.delete(tabId);
scheduleTabsSync(); 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. // Watchdog: MV3 can stop this worker; the alarm revives it and re-connects.
chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 }); chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => { chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "openclaw-relay-watchdog") { if (alarm.name === "openclaw-relay-watchdog") {
void connectRelay(); void connectRelay();
void copilot.drainAborts();
void copilot.drainArchives();
void copilot.drainStaleScopes();
} }
}); });
chrome.runtime.onStartup.addListener(() => void connectRelay()); chrome.runtime.onStartup.addListener(() => void connectRelay());
chrome.runtime.onInstalled.addListener(() => void connectRelay()); chrome.runtime.onInstalled.addListener(() => void connectRelay());
void connectRelay(); void connectRelay();
void copilotReady;
@@ -9,7 +9,7 @@
"48": "icons/icon48.png", "48": "icons/icon48.png",
"128": "icons/icon128.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" }, "background": { "service_worker": "background.js", "type": "module" },
"action": { "action": {
"default_title": "OpenClaw", "default_title": "OpenClaw",
@@ -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<CopilotPanelBindingRegistry, "resolve">,
): Promise<number>;
export function archiveCopilotSession(
gateway: {
request(method: string, params: Record<string, unknown>): Promise<unknown>;
},
entry: CopilotArchiveEntry,
): Promise<void>;
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;
@@ -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 };
@@ -0,0 +1,15 @@
import type { CopilotSessionRegistry } from "./copilot-session-registry.js";
export function createCopilotController(options: Record<string, unknown>): {
initializeCustody(): Promise<void>;
initialize(): Promise<void>;
preparePanel(tabId: number): Promise<{ path: string }>;
onConsentChanged(changedTabId?: number, options?: { revoked?: boolean }): Promise<void>;
onRelayStatus(status: { ready: boolean; label?: string }): Promise<void>;
onTabRemoved(tabId: number): Promise<void>;
refreshConfig(): Promise<void>;
drainAborts(gatewayScope?: string | null): Promise<void>;
drainArchives(gatewayScope?: string | null): Promise<void>;
drainStaleScopes(): Promise<void>;
registry: CopilotSessionRegistry;
};
@@ -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,
};
}
@@ -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<string, unknown> = {}) {
const values = { ...initial };
return {
get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))),
set: vi.fn(async (update: Record<string, unknown>) => {
Object.assign(values, update);
}),
};
}
describe("browser copilot background", () => {
it("serializes config refreshes so a stale pairing cannot outlive unpair", async () => {
let resolveInitial: ((config: Record<string, string>) => void) | undefined;
const getConfig = vi
.fn()
.mockImplementationOnce(
async () =>
await new Promise<Record<string, string>>((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<void>((resolve) => {
releaseRequest = resolve;
});
const request = vi.fn(async () => {
await requestGate;
return { ok: true };
});
let reportRecoveryStatus: ((status: Record<string, unknown>) => 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<string, unknown>) => void) | undefined;
let releaseAbort: (() => void) | undefined;
const abortGate = new Promise<void>((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<string, unknown>) => 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<string, unknown>) => 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 }],
]);
});
});
@@ -0,0 +1,47 @@
type StorageArea = {
get(keys: string[]): Promise<Record<string, unknown>>;
set(update: Record<string, unknown>): Promise<void>;
};
type CopilotIdentity = {
deviceId: string;
publicKey: string;
sign(payload: string): Promise<string>;
};
type TokenParams = {
clientId: string;
deviceId: string;
role: string;
};
type StoredToken = {
token: string;
scopes: string[];
};
export function loadOrCreateCopilotIdentity(
storage: StorageArea,
gatewayScope: string,
): Promise<CopilotIdentity>;
export function createCopilotTokenStore(
storage: StorageArea,
gatewayScope: string,
): {
load: (params: TokenParams) => Promise<StoredToken | null>;
store: (params: TokenParams & StoredToken) => Promise<void>;
clear: (params: TokenParams) => Promise<void>;
};
export function resolveCopilotClose(context: {
connectFailure?: {
error?: {
details?: { code?: string; pauseReconnect?: boolean };
};
};
}): {
retry: boolean;
notify: boolean;
pendingError: unknown;
};
@@ -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,
};
}
@@ -0,0 +1,21 @@
type StorageArea = {
get(keys: string[]): Promise<Record<string, unknown>>;
set(update: Record<string, unknown>): Promise<void>;
};
export function isDefinitiveGatewayRejection(error: unknown): boolean;
export function waitForCopilotGatewayReady(
client: CopilotGatewayClient,
gatewayScope: string,
): Promise<void>;
export class CopilotGatewayClient {
constructor(options?: { storage?: StorageArea; WebSocketImpl?: typeof WebSocket });
ready: boolean;
hello: Record<string, unknown> | null;
onEvent(listener: (event: unknown) => void): () => void;
onStatus(listener: (status: Record<string, unknown>) => void): () => void;
start(url: string): void;
stop(): void;
request(method: string, params: unknown, options?: unknown): Promise<unknown>;
}
@@ -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);
}
}
}
@@ -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<string, unknown> = {};
return {
async get(keys: string[]) {
return Object.fromEntries(keys.map((key) => [key, values[key]]));
},
async set(update: Record<string, unknown>) {
Object.assign(values, update);
},
};
}
function controllableStorageArea() {
const values: Record<string, unknown> = {};
let nextWrite: { release: Promise<void>; 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<string, unknown>) => {
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<void>((resolve) => {
markStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
nextWrite = { release, started: () => markStarted?.() };
return { started, release: () => releaseWrite?.() };
},
};
}
class FakeWebSocket {
static OPEN = 1;
static instances: FakeWebSocket[] = [];
readyState = 0;
sent: Array<Record<string, unknown>> = [];
private listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
constructor() {
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
this.readyState = FakeWebSocket.OPEN;
this.emit("open", {});
});
}
addEventListener(name: string, listener: (event: Record<string, unknown>) => 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<string, unknown>);
}
close(code = 1000, reason = "") {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
queueMicrotask(() => this.emit("close", { code, reason }));
}
message(frame: Record<string, unknown>) {
this.emit("message", { data: JSON.stringify(frame) });
}
private emit(name: string, event: Record<string, unknown>) {
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<string, unknown> = {};
let releaseClear: (() => void) | undefined;
let markClearStarted: (() => void) | undefined;
const clearStarted = new Promise<void>((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<string, unknown>) {
if (blockNextSet) {
blockNextSet = false;
markClearStarted?.();
await new Promise<void>((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);
});
});
@@ -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<string, unknown> & {
gateway: CopilotGatewayClient;
registry: CopilotSessionRegistry;
},
): {
abortEntry: (entry: CopilotSessionEntry) => Promise<boolean>;
clearAbortRetry: () => void;
drainAborts: (gatewayScope?: string | null) => Promise<void>;
drainArchives: (gatewayScope?: string | null) => Promise<void>;
drainStaleScopes: () => Promise<void>;
reconcileGatewayReady: (
status: Record<string, unknown>,
statusRevision: number,
gatewayScope: string | null,
revocation: Promise<unknown>,
) => Promise<void>;
scheduleAbortRetry: (gatewayScope?: string | null) => void;
scheduleStaleRecovery: () => void;
};
@@ -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,
};
}
@@ -0,0 +1,5 @@
export function createCopilotRelayCustodyController(options: Record<string, unknown>): {
currentPanelStatus(): { state: string; label: string; requestId?: string };
isOperational(): boolean;
onStatus(status: { ready: boolean; label?: string }): Promise<void>;
};
@@ -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 };
}
@@ -0,0 +1,29 @@
export const GATEWAY_CLIENT_CAPS: Record<string, string>;
export const GATEWAY_CLIENT_IDS: Record<string, string>;
export const GATEWAY_CLIENT_MODES: Record<string, string>;
export const MIN_CLIENT_PROTOCOL_VERSION: number;
export const PROTOCOL_VERSION: number;
export const ed25519Utils: {
randomSecretKey(): Uint8Array;
};
export function getPublicKeyAsync(secretKey: Uint8Array): Promise<Uint8Array>;
export function signAsync(message: Uint8Array, secretKey: Uint8Array): Promise<Uint8Array>;
export class GatewayProtocolRequestError extends Error {
constructor(error: Record<string, unknown>);
}
export class GatewayProtocolClient {
constructor(options: Record<string, unknown>);
start(): void;
stop(): void;
request(method: string, params: unknown, options?: unknown): Promise<unknown>;
}
export class GatewayBrowserDeviceAuthLifecycle {
constructor(options: Record<string, unknown>);
buildPlan(options: Record<string, unknown>): Promise<Record<string, unknown>>;
acceptHello(hello: unknown, plan: unknown): Promise<void>;
clearStoredToken(plan: unknown): Promise<void>;
}
File diff suppressed because one or more lines are too long
@@ -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<void>;
bind(tabId: number): Promise<string>;
resolve(token: string): Promise<number | null>;
remove(tabId: number): Promise<void>;
}
export class CopilotSessionRegistry {
constructor(storage?: unknown);
initialize(existingTabIds: Set<number>): Promise<string>;
get(tabId: number, gatewayScope: string): CopilotSessionEntry | null;
list(): CopilotSessionEntry[];
gatewayScopes(): string[];
pendingArchives(gatewayScope: string): CopilotArchiveEntry[];
put(
tabId: number,
entry: Omit<CopilotSessionEntry, "tabId" | "browserInstanceId">,
): Promise<CopilotSessionEntry>;
updateBinding(tabId: number, gatewayScope: string, binding: BrowserCopilotBinding): Promise<void>;
confirmSession(
tabId: number,
gatewayScope: string,
sessionId?: string,
): Promise<CopilotSessionEntry | null>;
markSessionCreationPending(
tabId: number,
gatewayScope: string,
): Promise<CopilotSessionEntry | null>;
discardProvisionalSession(tabId: number, gatewayScope: string): Promise<boolean>;
startRun(tabId: number, gatewayScope: string, runId: string): Promise<CopilotSessionEntry | null>;
queueAbort(tabId: number, gatewayScope: string): Promise<CopilotSessionEntry | null>;
queueActiveAborts(gatewayScope: string): Promise<void>;
pendingAborts(gatewayScope: string): CopilotSessionEntry[];
finishRun(gatewayScope: string, sessionKey: string, runId: string): Promise<boolean>;
closeTab(tabId: number): Promise<CopilotSessionEntry | null>;
closeScope(gatewayScope: string): Promise<void>;
closeInactiveScope(gatewayScope: string): Promise<void>;
resolveArchive(gatewayScope: string, sessionKey: string): Promise<void>;
}
@@ -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 });
}
}
@@ -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<string, unknown> = {}) {
const values = { ...initial };
const setCalls: Record<string, unknown>[] = [];
return {
setCalls,
values,
async get(keys: string[]) {
return Object.fromEntries(keys.map((key) => [key, values[key]]));
},
async set(update: Record<string, unknown>) {
setCalls.push(update);
Object.assign(values, update);
},
};
}
function storage(localInitial: Record<string, unknown> = {}, 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();
});
});
@@ -0,0 +1,18 @@
import type { CopilotSessionEntry, CopilotSessionRegistry } from "./copilot-session-registry.js";
export function createCopilotSessionController(
options: Record<string, unknown> & {
registry: CopilotSessionRegistry;
},
): {
ensureSession: (
tabId: number,
options?: { hydrateHistory?: boolean },
) => Promise<CopilotSessionEntry | null>;
sendMessage: (
tabId: number,
port: unknown,
portRevision: number,
text: string,
) => Promise<unknown>;
};
@@ -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 };
}
@@ -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;
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const fenced = [];
rendered = rendered.replace(/```(?:[a-z0-9_-]+)?\n?([\s\S]*?)```/gi, (_match, code) => {
fenced.push(`<pre><code>${code.trim()}</code></pre>`);
return `<F${fenced.length - 1}>`;
});
rendered = rendered.replace(/`([^`]+)`/g, "<code>$1</code>");
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
rendered = rendered.replace(/\n/g, "<br>");
return rendered.replace(/<F(\d+)>/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");
}
@@ -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("<img src=x> **safe**")).toBe(
"&lt;img src=x&gt; <strong>safe</strong>",
);
});
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("");
});
});
@@ -2,7 +2,11 @@
// it can load unbundled in Chrome). Kept in sync with relay-core.js. // it can load unbundled in Chrome). Kept in sync with relay-core.js.
export const OPENCLAW_TAB_GROUP_TITLE: string; 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[]; export function buildRelayWsProtocols(token: string): string[];
@@ -21,8 +21,9 @@ const CHROME_GROUP_COLORS = {
/** /**
* Parse a pairing string printed by `openclaw browser extension pair`. * Parse a pairing string printed by `openclaw browser extension pair`.
* Shape: ws://127.0.0.1:<port>/extension#<token> * Shape: ws://127.0.0.1:<port>/extension?gateway=<url>#<token>
* Returns { relayUrl, token } or null when malformed. * 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) { export function parsePairingString(raw) {
const trimmed = String(raw ?? "").trim(); const trimmed = String(raw ?? "").trim();
@@ -47,7 +48,16 @@ export function parsePairingString(raw) {
if (!parsed.pathname.endsWith("/extension")) { if (!parsed.pathname.endsWith("/extension")) {
return null; 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. */ /** Build WebSocket subprotocols without putting the relay secret in the request URL. */
@@ -39,6 +39,16 @@ describe("parsePairingString", () => {
expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull(); expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull();
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", () => { describe("reconnectDelayMs", () => {
@@ -65,6 +65,10 @@
background: #3a3a3c; background: #3a3a3c;
color: #f2f2f7; color: #f2f2f7;
} }
button:disabled {
cursor: default;
opacity: 0.5;
}
.hidden { .hidden {
display: none; display: none;
} }
@@ -86,6 +90,7 @@
</div> </div>
<div id="connectedSection" class="hidden"> <div id="connectedSection" class="hidden">
<p id="statusLine"></p> <p id="statusLine"></p>
<button id="copilotButton" disabled>Open tab copilot</button>
<button id="shareButton"></button> <button id="shareButton"></button>
<button id="unpairButton" class="secondary">Unpair</button> <button id="unpairButton" class="secondary">Unpair</button>
</div> </div>
@@ -7,6 +7,7 @@ const pairingInput = document.getElementById("pairingString");
const pairButton = document.getElementById("pairButton"); const pairButton = document.getElementById("pairButton");
const unpairButton = document.getElementById("unpairButton"); const unpairButton = document.getElementById("unpairButton");
const shareButton = document.getElementById("shareButton"); const shareButton = document.getElementById("shareButton");
const copilotButton = document.getElementById("copilotButton");
const statusLine = document.getElementById("statusLine"); const statusLine = document.getElementById("statusLine");
const errorLine = document.getElementById("error"); const errorLine = document.getElementById("error");
@@ -35,8 +36,13 @@ async function refresh() {
const tab = await activeTab(); const tab = await activeTab();
if (tab?.id === undefined) { if (tab?.id === undefined) {
shareButton.classList.add("hidden"); shareButton.classList.add("hidden");
copilotButton.disabled = true;
return; 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 }); const { shared } = await chrome.runtime.sendMessage({ type: "isTabShared", tabId: tab.id });
shareButton.classList.remove("hidden"); shareButton.classList.remove("hidden");
shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw"; shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw";
@@ -70,9 +76,21 @@ async function onToggleShare() {
await refresh(); 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()); pairButton.addEventListener("click", () => void onPair());
unpairButton.addEventListener("click", () => void onUnpair()); unpairButton.addEventListener("click", () => void onUnpair());
shareButton.addEventListener("click", () => void onToggleShare()); shareButton.addEventListener("click", () => void onToggleShare());
copilotButton.addEventListener("click", () => void onOpenCopilot());
void refresh(); void refresh();
setInterval(() => void refresh(), 2000); setInterval(() => void refresh(), 2000);
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Copilot</title>
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<header class="topbar">
<div class="mark" aria-hidden="true">OC</div>
<div class="identity">
<div class="eyebrow">TAB COPILOT</div>
<div id="tab-title" class="tab-title">Resolving tab…</div>
</div>
<div id="status-dot" class="status-dot" title="Connecting"></div>
</header>
<div class="scope-strip">
<span class="scope-icon" aria-hidden="true"></span>
<span id="tab-origin">This panel is bound to one Chrome tab</span>
</div>
<main id="conversation" aria-live="polite">
<section id="gate" class="gate" data-testid="denial-card">
<div class="gate-kicker">SECURE BINDING</div>
<h1 id="gate-title">Preparing this tab</h1>
<p id="gate-detail">Chrome is proving which tab owns this panel.</p>
<code id="request-id" class="request-id hidden"></code>
<button id="gate-action" class="primary hidden" type="button">Share this tab</button>
</section>
<section id="messages" class="messages hidden" aria-label="Conversation"></section>
</main>
<footer class="composer-shell">
<div id="session-note" class="session-note">No session until this tab is shared.</div>
<div class="composer">
<textarea
id="message-input"
rows="1"
maxlength="12000"
placeholder="Ask about this tab…"
aria-label="Message"
disabled
></textarea>
<button id="send-button" class="send" type="button" aria-label="Send message" disabled>
<span aria-hidden="true"></span>
</button>
</div>
<div class="binding-note">
Page text stays out of prompts. Browser actions stay on this tab.
</div>
</footer>
<script src="sidepanel.js" type="module"></script>
</body>
</html>
@@ -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();
+36
View File
@@ -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 () => { it("derives group chat type for browser media scope", async () => {
const { api, registerTool } = createApi(); const { api, registerTool } = createApi();
registerBrowserPlugin(api); registerBrowserPlugin(api);
+4
View File
@@ -6,12 +6,15 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@noble/ed25519": "3.1.0",
"express": "5.2.1", "express": "5.2.1",
"esbuild": "0.28.1",
"playwright-core": "1.61.1", "playwright-core": "1.61.1",
"typebox": "1.3.3", "typebox": "1.3.3",
"ws": "8.21.0" "ws": "8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@openclaw/gateway-client": "workspace:*",
"@openclaw/plugin-sdk": "workspace:*", "@openclaw/plugin-sdk": "workspace:*",
"undici": "8.6.0" "undici": "8.6.0"
}, },
@@ -20,6 +23,7 @@
"./index.ts" "./index.ts"
], ],
"assetScripts": { "assetScripts": {
"build": "node scripts/build-copilot-runtime.mjs",
"copy": "node scripts/copy-chrome-extension.mjs" "copy": "node scripts/copy-chrome-extension.mjs"
} }
} }
+16 -1
View File
@@ -18,6 +18,7 @@ import {
BROWSER_REQUEST_GATEWAY_METHOD, BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE, BROWSER_REQUEST_GATEWAY_SCOPE,
} from "./src/browser-gateway-contract.js"; } from "./src/browser-gateway-contract.js";
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
import { describeBrowserTool } from "./src/browser-tool-description.js"; import { describeBrowserTool } from "./src/browser-tool-description.js";
import { BrowserToolSchema } from "./src/browser-tool.schema.js"; import { BrowserToolSchema } from "./src/browser-tool.schema.js";
import { import {
@@ -72,7 +73,15 @@ function createLazyBrowserTool(opts?: {
channel?: string; channel?: string;
chatType?: string; chatType?: string;
}; };
runToolBinding?: unknown;
}): AnyAgentTool { }): 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 targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint = const hostHint =
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed."; opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
@@ -83,7 +92,9 @@ function createLazyBrowserTool(opts?: {
parameters: BrowserToolSchema, parameters: BrowserToolSchema,
execute: async (toolCallId, args, signal, onUpdate) => { execute: async (toolCallId, args, signal, onUpdate) => {
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule(); 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); return await tool.execute(toolCallId, args, signal, onUpdate);
}, },
}; };
@@ -104,6 +115,7 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
channel?: string; channel?: string;
chatType?: string; chatType?: string;
}; };
runToolBinding?: unknown;
} { } {
const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel; const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel;
const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey); const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey);
@@ -132,6 +144,9 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
}, },
} }
: {}), : {}),
...(ctx.toolBindings && Object.hasOwn(ctx.toolBindings, "browser")
? { runToolBinding: ctx.toolBindings.browser }
: {}),
}; };
} }
@@ -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"),
});
@@ -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";
@@ -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",
});
});
});
@@ -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<string, unknown>;
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<string, unknown>, targetId: string): Record<string, unknown> {
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<string, unknown>, 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<string, unknown>,
binding: BrowserTabToolBinding,
): Record<string, unknown> {
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<string, unknown>, binding.targetId)
: bound.request;
return {
...bound,
target: binding.target,
...(binding.node ? { node: binding.node } : {}),
profile: binding.profile,
...(request ? { request } : {}),
};
}
@@ -306,6 +306,7 @@ export async function executeTabsAction(params: {
profile?: string; profile?: string;
timeoutMs?: number; timeoutMs?: number;
proxyRequest: BrowserProxyRequest | null; proxyRequest: BrowserProxyRequest | null;
targetId?: string;
}): Promise<AgentToolResult<unknown>> { }): Promise<AgentToolResult<unknown>> {
const { baseUrl, profile, timeoutMs, proxyRequest } = params; const { baseUrl, profile, timeoutMs, proxyRequest } = params;
if (proxyRequest) { if (proxyRequest) {
@@ -315,10 +316,16 @@ export async function executeTabsAction(params: {
profile, profile,
timeoutMs, 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); 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); return formatTabsToolResult(tabs);
} }
+13 -1
View File
@@ -5,6 +5,7 @@
* maps high-level actions onto browser control client calls. * maps high-level actions onto browser control client calls.
*/ */
import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js"; import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js";
import { applyBrowserTabToolBinding, parseBrowserTabToolBinding } from "./browser-tool-binding.js";
import { describeBrowserTool } from "./browser-tool-description.js"; import { describeBrowserTool } from "./browser-tool-description.js";
import { import {
executeActAction, executeActAction,
@@ -403,6 +404,7 @@ export function createBrowserTool(opts?: {
channel?: string; channel?: string;
chatType?: string; chatType?: string;
}; };
runToolBinding?: unknown;
}): AnyAgentTool { }): AnyAgentTool {
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint = const hostHint =
@@ -413,7 +415,16 @@ export function createBrowserTool(opts?: {
description: describeBrowserTool({ targetDefault, hostHint }), description: describeBrowserTool({ targetDefault, hostHint }),
parameters: BrowserToolSchema, parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; 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<string, unknown>, bindingResult.binding)
: (args as Record<string, unknown>);
const action = readStringParam(params, "action", { required: true }); const action = readStringParam(params, "action", { required: true });
const profile = readStringParam(params, "profile"); const profile = readStringParam(params, "profile");
const requestedNode = readStringParam(params, "node"); const requestedNode = readStringParam(params, "node");
@@ -617,6 +628,7 @@ export function createBrowserTool(opts?: {
profile, profile,
timeoutMs: toolTimeoutMs, timeoutMs: toolTimeoutMs,
proxyRequest, proxyRequest,
targetId: bindingResult?.ok ? bindingResult.binding.targetId : undefined,
}); });
case "open": { case "open": {
const targetUrl = readTargetUrlParam(params); const targetUrl = readTargetUrlParam(params);
@@ -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://<certificate-host>[:port]");
}
return `ws://127.0.0.1:${params.gatewayPort}`;
}
@@ -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://<certificate-host>",
);
expect(
resolveLocalPairingGatewayUrl({
configuredRemote: "wss://gateway.example",
gatewayPort: 18789,
tlsEnabled: true,
}),
).toBe("wss://gateway.example");
});
});
@@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
import type { Command } from "commander"; import type { Command } from "commander";
import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js"; import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js";
import { isLoopbackHost } from "../gateway/net.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 type { BrowserParentOpts } from "./browser-cli-shared.js";
import { import {
danger, danger,
@@ -82,14 +84,24 @@ function buildPairingString(gatewayUrl?: string): {
// Remote: the extension connects straight to this gateway over wss:// — no // Remote: the extension connects straight to this gateway over wss:// — no
// node host on the browser machine. The gateway route self-validates the // node host on the browser machine. The gateway route self-validates the
// same host-local secret. // same host-local secret.
const relayUrl = new URL(buildRemoteGatewayRelayUrl(gateway));
relayUrl.searchParams.set("gateway", gateway);
return { return {
pairing: `${buildRemoteGatewayRelayUrl(gateway)}#${token}`, pairing: `${relayUrl.toString()}#${token}`,
relayPort, relayPort,
remote: true, 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 { return {
pairing: `ws://127.0.0.1:${relayPort}/extension#${token}`, pairing: `${relayUrl.toString()}#${token}`,
relayPort, relayPort,
remote: false, remote: false,
}; };
@@ -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();
});
});
@@ -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<string>;
};
export type GatewayBrowserDeviceTokenRecord = {
token: string;
scopes: string[];
};
type MaybePromise<T> = T | Promise<T>;
export type GatewayBrowserDeviceTokenStore = {
load: (params: {
clientId: string;
deviceId: string;
role: string;
}) => MaybePromise<GatewayBrowserDeviceTokenRecord | null>;
store: (params: {
clientId: string;
deviceId: string;
role: string;
token: string;
scopes: string[];
}) => MaybePromise<void>;
clear: (params: { clientId: string; deviceId: string; role: string }) => MaybePromise<void>;
};
export type GatewayBrowserDeviceAuthPlan = {
clientId: string;
role: string;
identity: GatewayBrowserDeviceIdentity | null;
selectedAuth: GatewayConnectAuthSelection;
scopes: string[];
device?: NonNullable<ConnectParams["device"]>;
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<GatewayBrowserDeviceIdentity | null>;
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<GatewayBrowserDeviceAuthPlan> {
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<HelloOk, "auth">,
plan: GatewayBrowserDeviceAuthPlan,
): Promise<void> {
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<void> {
if (!plan.identity) {
return;
}
await this.deps.tokenStore.clear({
clientId: plan.clientId,
deviceId: plan.identity.deviceId,
role: plan.role,
});
}
}
+1
View File
@@ -1,6 +1,7 @@
// Browser-safe gateway client surface. Keep Node transport/TLS dependencies out // Browser-safe gateway client surface. Keep Node transport/TLS dependencies out
// of this entry so browser consumers share the wire engine without polyfills. // of this entry so browser consumers share the wire engine without polyfills.
export * from "./device-auth.js"; export * from "./device-auth.js";
export * from "./browser-device-auth.js";
export * from "./connect-auth.js"; export * from "./connect-auth.js";
export * from "./protocol-client.js"; export * from "./protocol-client.js";
export * from "./reconnect-policy.js"; export * from "./reconnect-policy.js";
+1
View File
@@ -1,6 +1,7 @@
// Public gateway-client package surface: connection client, device auth, // Public gateway-client package surface: connection client, device auth,
// readiness helpers, event-loop readiness, and timeout utilities. // readiness helpers, event-loop readiness, and timeout utilities.
export * from "./client.js"; export * from "./client.js";
export * from "./browser-device-auth.js";
export * from "./connect-auth.js"; export * from "./connect-auth.js";
export * from "./device-auth.js"; export * from "./device-auth.js";
export * from "./event-loop-ready.js"; export * from "./event-loop-ready.js";
@@ -16,6 +16,7 @@ function normalizeOptionalLowercaseString(raw?: string | null): string | undefin
export const GATEWAY_CLIENT_IDS = { export const GATEWAY_CLIENT_IDS = {
WEBCHAT_UI: "webchat-ui", WEBCHAT_UI: "webchat-ui",
CONTROL_UI: "openclaw-control-ui", CONTROL_UI: "openclaw-control-ui",
BROWSER_COPILOT: "openclaw-browser-copilot",
TUI: "openclaw-tui", TUI: "openclaw-tui",
WEBCHAT: "webchat", WEBCHAT: "webchat",
CLI: "cli", CLI: "cli",
@@ -79,6 +80,8 @@ export const GATEWAY_CLIENT_CAPS = {
APPROVALS: "approvals", APPROVALS: "approvals",
EXEC_APPROVALS: "exec-approvals", EXEC_APPROVALS: "exec-approvals",
INLINE_WIDGETS: "inline-widgets", INLINE_WIDGETS: "inline-widgets",
RUN_TOOL_BINDINGS: "run-tool-bindings",
SESSION_SCOPED_EVENTS: "session-scoped-events",
PLUGIN_APPROVALS: "plugin-approvals", PLUGIN_APPROVALS: "plugin-approvals",
TASK_SUGGESTIONS: "task-suggestions", TASK_SUGGESTIONS: "task-suggestions",
TERMINAL_OFFSET_SEQ: "terminal-offset-seq", TERMINAL_OFFSET_SEQ: "terminal-offset-seq",
@@ -54,6 +54,7 @@ export const DevicePairRequestedEventSchema = closedObject({
deviceFamily: Type.Optional(NonEmptyString), deviceFamily: Type.Optional(NonEmptyString),
clientId: Type.Optional(NonEmptyString), clientId: Type.Optional(NonEmptyString),
clientMode: Type.Optional(NonEmptyString), clientMode: Type.Optional(NonEmptyString),
browserOrigin: Type.Optional(NonEmptyString),
role: Type.Optional(NonEmptyString), role: Type.Optional(NonEmptyString),
roles: Type.Optional(Type.Array(NonEmptyString)), roles: Type.Optional(Type.Array(NonEmptyString)),
scopes: Type.Optional(Type.Array(NonEmptyString)), scopes: Type.Optional(Type.Array(NonEmptyString)),
@@ -85,6 +85,14 @@ export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
/** Attachment envelope shared by chat.send and session creation's initial turn. */ /** Attachment envelope shared by chat.send and session creation's initial turn. */
export const ChatAttachmentsSchema = Type.Array(Type.Unknown()); 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<typeof RunToolBindingsSchema>;
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */ /** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
export const ChatSendParamsSchema = closedObject({ export const ChatSendParamsSchema = closedObject({
sessionKey: ChatSendSessionKeyString, sessionKey: ChatSendSessionKeyString,
@@ -103,6 +111,7 @@ export const ChatSendParamsSchema = closedObject({
originatingAccountId: Type.Optional(Type.String()), originatingAccountId: Type.Optional(Type.String()),
originatingThreadId: Type.Optional(Type.String()), originatingThreadId: Type.Optional(Type.String()),
attachments: Type.Optional(ChatAttachmentsSchema), attachments: Type.Optional(ChatAttachmentsSchema),
toolBindings: Type.Optional(RunToolBindingsSchema),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
systemInputProvenance: Type.Optional(InputProvenanceSchema), systemInputProvenance: Type.Optional(InputProvenanceSchema),
systemProvenanceReceipt: Type.Optional(Type.String()), systemProvenanceReceipt: Type.Optional(Type.String()),
+9
View File
@@ -488,6 +488,12 @@ importers:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: 1.29.0 specifier: 1.29.0
version: 1.29.0(zod@4.4.3) 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: express:
specifier: 5.2.1 specifier: 5.2.1
version: 5.2.1 version: 5.2.1
@@ -501,6 +507,9 @@ importers:
specifier: 8.21.0 specifier: 8.21.0
version: 8.21.0 version: 8.21.0
devDependencies: devDependencies:
'@openclaw/gateway-client':
specifier: workspace:*
version: link:../../packages/gateway-client
'@openclaw/plugin-sdk': '@openclaw/plugin-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/plugin-sdk version: link:../../packages/plugin-sdk
+4
View File
@@ -281,6 +281,8 @@ type OpenClawCodingToolsOptions = {
messageChannel?: string; messageChannel?: string;
/** Capabilities declared by the gateway client that originated this run. */ /** Capabilities declared by the gateway client that originated this run. */
clientCaps?: string[]; clientCaps?: string[];
/** Out-of-band plugin bindings attached by the run initiator. */
toolBindings?: Readonly<Record<string, unknown>>;
/** Normalized conversation kind when the caller already has channel metadata. */ /** Normalized conversation kind when the caller already has channel metadata. */
chatType?: ChatType; chatType?: ChatType;
/** Specific ingress provider used only for transport tool availability. */ /** Specific ingress provider used only for transport tool availability. */
@@ -876,6 +878,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
requesterAgentIdOverride: agentId, requesterAgentIdOverride: agentId,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
clientCaps: options?.clientCaps, clientCaps: options?.clientCaps,
toolBindings: options?.toolBindings,
authProfileStore: options?.authProfileStore, authProfileStore: options?.authProfileStore,
}, },
resolvedConfig: options?.config, resolvedConfig: options?.config,
@@ -965,6 +968,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
sandboxed: Boolean(sandbox), sandboxed: Boolean(sandbox),
config: options?.config, config: options?.config,
clientCaps: options?.clientCaps, clientCaps: options?.clientCaps,
toolBindings: options?.toolBindings,
pluginToolAllowlist, pluginToolAllowlist,
pluginToolDenylist, pluginToolDenylist,
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
@@ -205,6 +205,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
...buildEmbeddedAttemptToolRunContext({ ...attempt, trace: params.runTrace }), ...buildEmbeddedAttemptToolRunContext({ ...attempt, trace: params.runTrace }),
messageChannel: attempt.messageChannel, messageChannel: attempt.messageChannel,
clientCaps: attempt.clientCaps, clientCaps: attempt.clientCaps,
toolBindings: attempt.toolBindings,
chatType: attempt.chatType, chatType: attempt.chatType,
exec: { exec: {
...attempt.execOverrides, ...attempt.execOverrides,
@@ -81,6 +81,8 @@ export type RunEmbeddedAgentParams = {
messageProvider?: string; messageProvider?: string;
/** Capabilities declared by the gateway client that originated this run. */ /** Capabilities declared by the gateway client that originated this run. */
clientCaps?: string[]; clientCaps?: string[];
/** Out-of-band plugin bindings attached by the run initiator. */
toolBindings?: Readonly<Record<string, unknown>>;
chatType?: ChatType; chatType?: ChatType;
agentAccountId?: string; agentAccountId?: string;
/** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */ /** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */
@@ -47,6 +47,7 @@ export type OpenClawPluginToolOptions = {
allowHostBrowserControl?: boolean; allowHostBrowserControl?: boolean;
sandboxed?: boolean; sandboxed?: boolean;
allowGatewaySubagentBinding?: boolean; allowGatewaySubagentBinding?: boolean;
toolBindings?: Readonly<Record<string, unknown>>;
}; };
/** Resolves plugin-tool context inputs from runtime options and config state. */ /** Resolves plugin-tool context inputs from runtime options and config state. */
@@ -97,6 +98,7 @@ export function resolveOpenClawPluginToolInputs(params: {
agentId: sessionAgentId, agentId: sessionAgentId,
sessionKey: options?.agentSessionKey, sessionKey: options?.agentSessionKey,
sessionId: options?.sessionId, sessionId: options?.sessionId,
toolBindings: options?.toolBindings,
activeModel, activeModel,
browser: { browser: {
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
+1
View File
@@ -107,6 +107,7 @@ export function createOpenClawTools(
sandboxBrowserBridgeUrl?: string; sandboxBrowserBridgeUrl?: string;
allowHostBrowserControl?: boolean; allowHostBrowserControl?: boolean;
agentSessionKey?: string; agentSessionKey?: string;
toolBindings?: Readonly<Record<string, unknown>>;
/** /**
* The actual live run session key. When the tool is constructed with a sandbox/policy * 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 * session key, this allows `session_status({sessionKey:"current"})` to resolve to
@@ -106,6 +106,7 @@ export function buildEmbeddedRunBaseParams(params: {
silentReplyPromptMode: params.run.silentReplyPromptMode, silentReplyPromptMode: params.run.silentReplyPromptMode,
sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode, sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode,
clientCaps: params.run.clientCaps, clientCaps: params.run.clientCaps,
toolBindings: params.run.toolBindings,
taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode, taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode,
provider: params.provider, provider: params.provider,
model: params.model, model: params.model,
@@ -76,4 +76,19 @@ describe("buildEmbeddedRunBaseParams runtime config", () => {
expect(resolved.config).toBe(resolvedRunConfig); 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);
});
}); });
+1
View File
@@ -1597,6 +1597,7 @@ export async function runPreparedReply(
runtimePolicySessionKey, runtimePolicySessionKey,
messageProvider, messageProvider,
clientCaps: ctx.GatewayClientCaps, clientCaps: ctx.GatewayClientCaps,
toolBindings: ctx.GatewayRunToolBindings,
chatType: replyRoute.chatType, chatType: replyRoute.chatType,
agentAccountId: replyRoute.accountId, agentAccountId: replyRoute.accountId,
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
@@ -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", () => { it("separates runs with different parent policy provenance", () => {
const first = createQueueTestRun({ prompt: "first" }); const first = createQueueTestRun({ prompt: "first" });
first.run.spawnedBy = "agent:main:telegram:group:first"; first.run.spawnedBy = "agent:main:telegram:group:first";
+2
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js";
import { stableStringify } from "../../../agents/stable-stringify.js";
import { normalizeChatType } from "../../../channels/chat-type.js"; import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveStorePath } from "../../../config/sessions.js"; import { resolveStorePath } from "../../../config/sessions.js";
import { loadSessionEntry } from "../../../config/sessions/session-accessor.js"; import { loadSessionEntry } from "../../../config/sessions/session-accessor.js";
@@ -198,6 +199,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "", normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "",
execution.messageProvider ?? "", execution.messageProvider ?? "",
JSON.stringify([...new Set(execution.clientCaps ?? [])].toSorted()), JSON.stringify([...new Set(execution.clientCaps ?? [])].toSorted()),
stableStringify(execution.toolBindings ?? null),
execution.chatType ?? "", execution.chatType ?? "",
execution.agentAccountId ?? "", execution.agentAccountId ?? "",
execution.groupId ?? "", execution.groupId ?? "",
+1
View File
@@ -128,6 +128,7 @@ export type FollowupRun = {
runtimePolicySessionKey?: string; runtimePolicySessionKey?: string;
messageProvider?: string; messageProvider?: string;
clientCaps?: string[]; clientCaps?: string[];
toolBindings?: Readonly<Record<string, unknown>>;
chatType?: ChatType; chatType?: ChatType;
agentAccountId?: string; agentAccountId?: string;
groupId?: string; groupId?: string;
+2
View File
@@ -302,6 +302,8 @@ export type MsgContext = {
GatewayClientScopes?: string[]; GatewayClientScopes?: string[];
/** Gateway client capabilities when the message originates from the gateway. */ /** Gateway client capabilities when the message originates from the gateway. */
GatewayClientCaps?: string[]; GatewayClientCaps?: string[];
/** Run-scoped plugin tool bindings; never rendered into prompt text. */
GatewayRunToolBindings?: Readonly<Record<string, unknown>>;
/** Gateway device id allowed to review approvals initiated by this turn. */ /** Gateway device id allowed to review approvals initiated by this turn. */
ApprovalReviewerDeviceId?: string; ApprovalReviewerDeviceId?: string;
/** Thread identifier (Telegram topic id or Matrix thread event id). */ /** Thread identifier (Telegram topic id or Matrix thread event id). */
+1
View File
@@ -562,6 +562,7 @@ describe("abortChatRunsForProvider", () => {
state: "aborted", state: "aborted",
stopReason: "auth-revoked", stopReason: "auth-revoked",
}), }),
{ sessionKeys: [sessionKey] },
); );
}); });
}); });
+8 -7
View File
@@ -392,7 +392,11 @@ export type ChatAbortOps = {
) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined; ) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>; agentRunSeq: Map<string, number>;
getRuntimeConfig?: () => OpenClawConfig; 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; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
}; };
@@ -484,12 +488,9 @@ function broadcastChatAborted(
} }
: undefined, : undefined,
}; };
ops.broadcast("chat", payload); const deliverySessionKeys = resolveChatAbortDeliverySessionKeys(ops, sessionKey, payloadAgentId);
for (const deliverySessionKey of resolveChatAbortDeliverySessionKeys( ops.broadcast("chat", payload, { sessionKeys: deliverySessionKeys });
ops, for (const deliverySessionKey of deliverySessionKeys) {
sessionKey,
payloadAgentId,
)) {
ops.nodeSendToSession(deliverySessionKey, "chat", payload); ops.nodeSendToSession(deliverySessionKey, "chat", payload);
} }
} }
+55 -1
View File
@@ -6,6 +6,11 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
import { beforeAll, beforeEach, describe, expect, it, test, vi } from "vitest"; 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 type { RequestFrame } from "../../packages/gateway-protocol/src/index.js";
import { import {
onDiagnosticEvent, onDiagnosticEvent,
@@ -24,6 +29,10 @@ import {
} from "./node-command-policy.js"; } from "./node-command-policy.js";
import type { SerializedEventPayload } from "./node-registry.js"; import type { SerializedEventPayload } from "./node-registry.js";
import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createGatewayBroadcaster } from "./server-broadcast.js";
import {
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
} from "./server-chat-state.js";
import { createChatRunRegistry } from "./server-chat.js"; import { createChatRunRegistry } from "./server-chat.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js"; import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js";
@@ -412,6 +421,47 @@ describe("gateway broadcaster", () => {
expect(workerSocket.send).not.toHaveBeenCalled(); 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", () => { it("filters approval and pairing events by scope", () => {
const approvalsSocket: TestSocket = { const approvalsSocket: TestSocket = {
bufferedAmount: 0, bufferedAmount: 0,
@@ -861,7 +911,11 @@ describe("node subscription manager", () => {
}; };
const parseSpy = vi.spyOn(JSON, "parse"); const parseSpy = vi.spyOn(JSON, "parse");
try { try {
const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() }); const runtime = createGatewayNodeSessionRuntime({
broadcast: vi.fn(),
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
});
runtime.nodeRegistry.register( runtime.nodeRegistry.register(
makeGatewayWsClient("conn-node-a", socket, { makeGatewayWsClient("conn-node-a", socket, {
role: "node", role: "node",
+11 -1
View File
@@ -1,7 +1,7 @@
// Browser origin tests document same-origin, private-network, loopback, forwarded // Browser origin tests document same-origin, private-network, loopback, forwarded
// host, and explicit allowlist decisions for gateway browser surfaces. // host, and explicit allowlist decisions for gateway browser surfaces.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkBrowserOrigin } from "./origin-check.js"; import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "./origin-check.js";
describe("checkBrowserOrigin", () => { describe("checkBrowserOrigin", () => {
it.each([ it.each([
@@ -173,4 +173,14 @@ describe("checkBrowserOrigin", () => {
reason: "origin missing or invalid", 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();
});
}); });
+10 -1
View File
@@ -16,7 +16,7 @@ type OriginCheckResult =
function parseOrigin( function parseOrigin(
originRaw?: string, originRaw?: string,
): { origin: string; host: string; hostname: string } | null { ): { origin: string; protocol: string; host: string; hostname: string } | null {
const trimmed = (originRaw ?? "").trim(); const trimmed = (originRaw ?? "").trim();
if (!trimmed || trimmed === "null") { if (!trimmed || trimmed === "null") {
return null; return null;
@@ -35,6 +35,7 @@ function parseOrigin(
const origin = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin; const origin = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin;
return { return {
origin: normalizeLowercaseStringOrEmpty(origin), origin: normalizeLowercaseStringOrEmpty(origin),
protocol: normalizeLowercaseStringOrEmpty(url.protocol),
host: normalizeLowercaseStringOrEmpty(url.host), host: normalizeLowercaseStringOrEmpty(url.host),
hostname: normalizeLowercaseStringOrEmpty(url.hostname), 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. */ /** Validate a browser Origin against explicit allowlist, same-host, and local dev rules. */
export function checkBrowserOrigin(params: { export function checkBrowserOrigin(params: {
requestHost?: string; requestHost?: string;
+2
View File
@@ -8,6 +8,8 @@ type GatewayBroadcastStateVersion = {
/** Options for gateway websocket broadcasts. */ /** Options for gateway websocket broadcasts. */
export type GatewayBroadcastOpts = { export type GatewayBroadcastOpts = {
dropIfSlow?: boolean; dropIfSlow?: boolean;
/** Canonical subscription keys for session-scoped delivery. */
sessionKeys?: readonly string[];
stateVersion?: GatewayBroadcastStateVersion; stateVersion?: GatewayBroadcastStateVersion;
}; };
+27 -1
View File
@@ -1,6 +1,11 @@
import {
GATEWAY_CLIENT_CAPS,
hasGatewayClientCap,
} from "../../packages/gateway-protocol/src/client-info.js";
// Gateway WebSocket broadcaster. // Gateway WebSocket broadcaster.
// Applies event scope guards and slow-consumer handling before sending frames. // Applies event scope guards and slow-consumer handling before sending frames.
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
import { isBrowserCopilotClient } from "../utils/message-channel.js";
import { import {
ADMIN_SCOPE, ADMIN_SCOPE,
APPROVALS_SCOPE, APPROVALS_SCOPE,
@@ -17,6 +22,7 @@ import type {
GatewayPluginEventBroadcastFn, GatewayPluginEventBroadcastFn,
GatewayPluginEventScope, GatewayPluginEventScope,
} from "./server-broadcast-types.js"; } from "./server-broadcast-types.js";
import type { SessionMessageSubscriberRegistry } from "./server-chat-state.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js"; import type { GatewayWsClient } from "./server/ws-types.js";
import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js"; import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
@@ -73,6 +79,10 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
// (e.g. reconfiguring wake-word triggers). // (e.g. reconfiguring wake-word triggers).
const NODE_ALLOWED_EVENTS = new Set<string>(["voicewake.changed", "voicewake.routing.changed"]); const NODE_ALLOWED_EVENTS = new Set<string>(["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 { function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string {
// Serialize one field through JSON.stringify so embedded values keep JSON // Serialize one field through JSON.stringify so embedded values keep JSON
// escaping, then splice it into the shared per-client frame body. // escaping, then splice it into the shared per-client frame body.
@@ -134,7 +144,10 @@ function hasEventScope(
return required.some((scope) => scopes.includes(scope)); return required.some((scope) => scopes.includes(scope));
} }
export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) { export function createGatewayBroadcaster(params: {
clients: Set<GatewayWsClient>;
sessionMessageSubscribers?: SessionMessageSubscriberRegistry;
}) {
const clientSeq = new WeakMap<GatewayWsClient, number>(); const clientSeq = new WeakMap<GatewayWsClient, number>();
const reportedSlowPayloadClients = new WeakSet<GatewayWsClient>(); const reportedSlowPayloadClients = new WeakSet<GatewayWsClient>();
@@ -191,6 +204,19 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
if (!hasEventScope(c, event, explicitPluginScope)) { if (!hasEventScope(c, event, explicitPluginScope)) {
continue; 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 nextSeq = (clientSeq.get(c) ?? 0) + 1;
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES; const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
if (!slow) { if (!slow) {
@@ -2092,6 +2092,9 @@ describe("agent event handler", () => {
expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual( expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual(
new Set(["conn-run"]), 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", () => { it("projects tool-search bridge calls like native channel verbose tool events", () => {
+49 -31
View File
@@ -36,6 +36,7 @@ import {
resolveMergedAssistantText, resolveMergedAssistantText,
shouldSuppressAssistantEventForLiveChat, shouldSuppressAssistantEventForLiveChat,
} from "./live-chat-projector.js"; } from "./live-chat-projector.js";
import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js";
import { isChatAbortMarkerCurrent } from "./server-chat-state.js"; import { isChatAbortMarkerCurrent } from "./server-chat-state.js";
import type { import type {
BufferedAgentEvent, BufferedAgentEvent,
@@ -203,11 +204,7 @@ function normalizeHeartbeatChatFinalText(params: {
*/ */
const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000; const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000;
export type ChatEventBroadcast = ( export type ChatEventBroadcast = GatewayBroadcastFn;
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void; export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
@@ -305,12 +302,7 @@ function resolveBroadcastDelta(params: {
export type AgentEventHandlerOptions = { export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast; broadcast: ChatEventBroadcast;
broadcastToConnIds: ( broadcastToConnIds: GatewayBroadcastToConnIdsFn;
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: { dropIfSlow?: boolean },
) => void;
nodeSendToSession: NodeSendToSession; nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>; agentRunSeq: Map<string, number>;
chatRunState: ChatRunState; chatRunState: ChatRunState;
@@ -1039,13 +1031,19 @@ export function createAgentEventHandler({
) => { ) => {
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
if (opts?.controlUiVisible ?? true) { if (opts?.controlUiVisible ?? true) {
broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow }); broadcast("chat", payload, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: [deliverySessionKey],
});
sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId); sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId);
return; return;
} }
const recipients = sessionMessageSubscribers.get(deliverySessionKey); const recipients = sessionMessageSubscribers.get(deliverySessionKey);
if (recipients.size > 0) { 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 }, opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean },
) => { ) => {
if (opts?.controlUiVisible ?? true) { if (opts?.controlUiVisible ?? true) {
broadcast("agent", payload); broadcast("agent", payload, {
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, opts?.agentId)]
: undefined,
});
if (sessionKey) { if (sessionKey) {
sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId); sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId);
} }
@@ -1131,11 +1133,13 @@ export function createAgentEventHandler({
if (!sessionKey) { if (!sessionKey) {
return; return;
} }
const recipients = sessionMessageSubscribers.get( const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
resolveSessionDeliveryKey(sessionKey, opts?.agentId), const recipients = sessionMessageSubscribers.get(deliverySessionKey);
);
if (recipients.size > 0) { 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; : agentPayload;
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) { if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
flushBufferedAgentDeltaIfNeeded(clientRunId); flushBufferedAgentDeltaIfNeeded(clientRunId);
broadcast("agent", { broadcast(
runId: eventRunId, "agent",
stream: "error", {
ts: Date.now(), runId: eventRunId,
sessionKey, stream: "error",
...(spawnedBy && { spawnedBy }), ts: Date.now(),
...(isHeartbeat !== undefined && { isHeartbeat }), sessionKey,
data: { ...(spawnedBy && { spawnedBy }),
reason: "seq gap", ...(isHeartbeat !== undefined && { isHeartbeat }),
expected: last + 1, data: {
received: evt.seq, reason: "seq gap",
expected: last + 1,
received: evt.seq,
},
}, },
}); {
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
: undefined,
},
);
} }
agentRunSeq.set(evt.runId, evt.seq); agentRunSeq.set(evt.runId, evt.seq);
if (evt.stream === "assistant") { if (evt.stream === "assistant") {
@@ -1438,7 +1450,8 @@ export function createAgentEventHandler({
// Always broadcast tool events to registered WS recipients with // Always broadcast tool events to registered WS recipients with
// tool-events capability, regardless of verboseLevel. The verbose // tool-events capability, regardless of verboseLevel. The verbose
// setting only controls whether tool details are sent as channel // 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); const runToolRecipients = toolEventRecipients.get(evt.runId);
if ( if (
isControlUiVisible && isControlUiVisible &&
@@ -1455,6 +1468,11 @@ export function createAgentEventHandler({
} }
: agentPayload, : agentPayload,
runToolRecipients, runToolRecipients,
{
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
: undefined,
},
); );
} }
if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
+2
View File
@@ -698,6 +698,7 @@ describe("createGatewayCloseHandler", () => {
expect(broadcast).toHaveBeenCalledWith( expect(broadcast).toHaveBeenCalledWith(
"chat", "chat",
expect.objectContaining({ runId: "run-1", state: "aborted", stopReason: "restart" }), expect.objectContaining({ runId: "run-1", state: "aborted", stopReason: "restart" }),
{ sessionKeys: ["session-1"] },
); );
expect(nodeSendToSession).toHaveBeenCalledWith( expect(nodeSendToSession).toHaveBeenCalledWith(
"session-1", "session-1",
@@ -711,6 +712,7 @@ describe("createGatewayCloseHandler", () => {
state: "aborted", state: "aborted",
stopReason: "restart", stopReason: "restart",
}), }),
{ sessionKeys: ["session-1"] },
); );
expect(nodeSendToSession).toHaveBeenCalledWith( expect(nodeSendToSession).toHaveBeenCalledWith(
"session-1", "session-1",
+21 -3
View File
@@ -79,7 +79,13 @@ export function broadcastChatFinal(params: {
state: "final" as const, state: "final" as const,
message: projectChatDisplayMessage(params.message), 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({ sendGlobalAwareNodeChatPayload({
context: params.context, context: params.context,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
@@ -114,7 +120,13 @@ export function broadcastSideResult(params: {
...(payloadAgentId ? { agentId: payloadAgentId } : {}), ...(payloadAgentId ? { agentId: payloadAgentId } : {}),
seq, 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({ sendGlobalAwareNodeChatPayload({
context: params.context, context: params.context,
sessionKey: params.payload.sessionKey, 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({ sendGlobalAwareNodeChatPayload({
context: params.context, context: params.context,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
@@ -58,6 +58,7 @@ describe("createChatSendDispatchErrorLifecycle", () => {
expect(broadcast).toHaveBeenCalledWith( expect(broadcast).toHaveBeenCalledWith(
"chat", "chat",
expect.objectContaining({ runId: "run-1", state: "final" }), expect.objectContaining({ runId: "run-1", state: "final" }),
{ sessionKeys: ["agent:main:main"] },
); );
expect(cleanupAdmittedRun).toHaveBeenCalledOnce(); expect(cleanupAdmittedRun).toHaveBeenCalledOnce();
expect(removeChatRun).toHaveBeenCalledWith("run-1", "run-1", "agent:main:main"); expect(removeChatRun).toHaveBeenCalledWith("run-1", "run-1", "agent:main:main");
@@ -1,5 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeChatSendRequest } from "./chat-send-request.js"; import { normalizeChatSendRequest } from "./chat-send-request.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
function copilotClient(caps: string[] = []): NonNullable<GatewayRequestHandlerOptions["client"]> {
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<GatewayRequestHandlerOptions["client"]>;
}
function validParams(overrides: Record<string, unknown> = {}) { function validParams(overrides: Record<string, unknown> = {}) {
return { return {
@@ -77,4 +96,50 @@ describe("normalizeChatSendRequest", () => {
error: "system provenance fields require admin scope", 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" });
});
}); });
@@ -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 { QueueMode } from "../../auto-reply/reply/queue/types.js";
import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { InputProvenance } from "../../sessions/input-provenance.js";
import { normalizeInputProvenance } 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 { isChatStopCommandText } from "../chat-abort.js";
import type { ChatAttachment } from "../chat-attachments.js"; import type { ChatAttachment } from "../chat-attachments.js";
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js"; import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
@@ -47,6 +47,7 @@ type ChatSendRequestParams = {
fileName?: string; fileName?: string;
content?: unknown; content?: unknown;
}>; }>;
toolBindings?: Record<string, unknown>;
timeoutMs?: number; timeoutMs?: number;
systemInputProvenance?: InputProvenance; systemInputProvenance?: InputProvenance;
systemProvenanceReceipt?: string; systemProvenanceReceipt?: string;
@@ -65,6 +66,7 @@ export type NormalizedChatSendRequest = {
systemInputProvenance?: InputProvenance; systemInputProvenance?: InputProvenance;
systemProvenanceReceipt?: string; systemProvenanceReceipt?: string;
suppressCommandInterpretation: boolean; suppressCommandInterpretation: boolean;
toolBindings?: Readonly<Record<string, unknown>>;
stopCommand: boolean; stopCommand: boolean;
turnKind: "btw" | "main"; turnKind: "btw" | "main";
normalizedAttachments: ChatAttachment[]; normalizedAttachments: ChatAttachment[];
@@ -82,7 +84,8 @@ export function normalizeChatSendRequest(params: {
client: GatewayRequestHandlerOptions["client"]; client: GatewayRequestHandlerOptions["client"];
}): NormalizeChatSendRequestResult { }): NormalizeChatSendRequestResult {
const chatSendReceivedAtMs = performance.now(); const chatSendReceivedAtMs = performance.now();
const clientInfo = params.client?.connect?.client; const client = params.client;
const clientInfo = client?.connect?.client;
const supportsTaskSuggestions = const supportsTaskSuggestions =
isOperatorUiClient(clientInfo) && isOperatorUiClient(clientInfo) &&
params.client?.connect?.scopes?.includes("operator.admin") === true && params.client?.connect?.scopes?.includes("operator.admin") === true &&
@@ -135,6 +138,27 @@ export function normalizeChatSendRequest(params: {
const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance); const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance);
const systemProvenanceReceipt = systemReceiptResult.receipt; const systemProvenanceReceipt = systemReceiptResult.receipt;
const stopCommand = !suppressCommandInterpretation && isChatStopCommandText(inboundMessage); 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 = const turnKind =
!suppressCommandInterpretation && isBtwRequestText(inboundMessage) ? "btw" : "main"; !suppressCommandInterpretation && isBtwRequestText(inboundMessage) ? "btw" : "main";
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments); const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments);
@@ -155,6 +179,7 @@ export function normalizeChatSendRequest(params: {
systemInputProvenance, systemInputProvenance,
systemProvenanceReceipt, systemProvenanceReceipt,
suppressCommandInterpretation, suppressCommandInterpretation,
toolBindings: p.toolBindings,
stopCommand, stopCommand,
turnKind, turnKind,
normalizedAttachments, normalizedAttachments,
@@ -69,6 +69,7 @@ describe("prepareChatSendUserTurn", () => {
suppressCommandInterpretation: false, suppressCommandInterpretation: false,
systemInputProvenance: { kind: "internal_system", sourceTool: "test" }, systemInputProvenance: { kind: "internal_system", sourceTool: "test" },
systemProvenanceReceipt: "[System receipt]", systemProvenanceReceipt: "[System receipt]",
toolBindings: { browser: { kind: "tab", targetId: "target-1" } },
}, },
session: { session: {
agentId: "main", agentId: "main",
@@ -104,6 +105,7 @@ describe("prepareChatSendUserTurn", () => {
body: "/status", body: "/status",
}, },
InputProvenance: { kind: "internal_system", sourceTool: "test" }, InputProvenance: { kind: "internal_system", sourceTool: "test" },
GatewayRunToolBindings: { browser: { kind: "tab", targetId: "target-1" } },
OriginatingChannel: "discord", OriginatingChannel: "discord",
OriginatingTo: "channel:1", OriginatingTo: "channel:1",
AccountId: "account-1", AccountId: "account-1",
@@ -112,6 +112,7 @@ function buildChatSendMessageContext(params: {
suppressCommandInterpretation: boolean; suppressCommandInterpretation: boolean;
systemInputProvenance?: InputProvenance; systemInputProvenance?: InputProvenance;
systemProvenanceReceipt?: string; systemProvenanceReceipt?: string;
toolBindings?: Readonly<Record<string, unknown>>;
}) { }) {
const commandBody = params.parsedMessage; const commandBody = params.parsedMessage;
const commandSource = const commandSource =
@@ -175,6 +176,7 @@ function buildChatSendMessageContext(params: {
: {}), : {}),
GatewayClientScopes: params.client?.connect?.scopes ?? [], GatewayClientScopes: params.client?.connect?.scopes ?? [],
GatewayClientCaps: params.client?.connect?.caps ?? [], GatewayClientCaps: params.client?.connect?.caps ?? [],
GatewayRunToolBindings: params.toolBindings,
}; };
if (params.mediaPathOffloadPaths.length > 0) { if (params.mediaPathOffloadPaths.length > 0) {
// Pre-staged offloads must use the channel media fields and marker so the // Pre-staged offloads must use the channel media fields and marker so the
@@ -203,6 +205,7 @@ export function prepareChatSendUserTurn(params: {
| "suppressCommandInterpretation" | "suppressCommandInterpretation"
| "systemInputProvenance" | "systemInputProvenance"
| "systemProvenanceReceipt" | "systemProvenanceReceipt"
| "toolBindings"
>; >;
session: Pick<PreparedChatSendSession, "agentId" | "clientRunId" | "sessionKey">; session: Pick<PreparedChatSendSession, "agentId" | "clientRunId" | "sessionKey">;
admission: Pick<AdmittedChatSend, "originatingRoute">; admission: Pick<AdmittedChatSend, "originatingRoute">;
@@ -259,6 +262,7 @@ export function prepareChatSendUserTurn(params: {
suppressCommandInterpretation: request.suppressCommandInterpretation, suppressCommandInterpretation: request.suppressCommandInterpretation,
systemInputProvenance: request.systemInputProvenance, systemInputProvenance: request.systemInputProvenance,
systemProvenanceReceipt: request.systemProvenanceReceipt, systemProvenanceReceipt: request.systemProvenanceReceipt,
toolBindings: request.toolBindings,
}); });
const mediaPathOffloadsIncludeImages = attachments.mediaPathOffloadTypes.some((type) => const mediaPathOffloadsIncludeImages = attachments.mediaPathOffloadTypes.some((type) =>
type.startsWith("image/"), type.startsWith("image/"),
@@ -175,6 +175,7 @@ describe("chat.send error broadcast", () => {
], ],
}), }),
}), }),
{ sessionKeys: ["agent:main:main"] },
); );
}); });
@@ -211,6 +212,7 @@ describe("chat.send error broadcast", () => {
agentId: "main", agentId: "main",
state: "error", state: "error",
}), }),
{ sessionKeys: ["agent:main:global", "global"] },
); );
expect(ctx.nodeSendToSession).toHaveBeenCalledWith( expect(ctx.nodeSendToSession).toHaveBeenCalledWith(
"agent:main:global", "agent:main:global",
+3 -1
View File
@@ -1711,7 +1711,9 @@ export const chatHandlers: GatewayRequestHandlers = {
state: "final" as const, state: "final" as const,
message, message,
}; };
context.broadcast("chat", chatPayload); context.broadcast("chat", chatPayload, {
sessionKeys: sessionKey === "global" && agentId ? [`agent:${agentId}:global`] : [sessionKey],
});
sendGlobalAwareNodeChatPayload({ sendGlobalAwareNodeChatPayload({
context, context,
sessionKey, sessionKey,
@@ -1270,6 +1270,7 @@ describe("models.authLogout", () => {
state: "aborted", state: "aborted",
stopReason: "auth-revoked", stopReason: "auth-revoked",
}), }),
{ sessionKeys: [openrouterRun.sessionKey] },
); );
const [, payload] = firstRespondCall(opts) ?? []; const [, payload] = firstRespondCall(opts) ?? [];
expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]); expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]);
@@ -64,6 +64,8 @@ export type GatewayClient = {
connect: ConnectParams; connect: ConnectParams;
connId?: string; connId?: string;
clientIp?: string; clientIp?: string;
/** Client id verified against the server-approved device pairing record. */
pairedClientId?: string;
pluginSurfaceUrls?: Record<string, string>; pluginSurfaceUrls?: Record<string, string>;
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>; pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>; pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
+7 -5
View File
@@ -5,9 +5,9 @@ import {
type NodeRegistryOptions, type NodeRegistryOptions,
type SerializedEventPayload, type SerializedEventPayload,
} from "./node-registry.js"; } from "./node-registry.js";
import { import type {
createSessionEventSubscriberRegistry, SessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry, SessionMessageSubscriberRegistry,
} from "./server-chat-state.js"; } from "./server-chat-state.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
import { hasConnectedTalkNode } from "./server-talk-nodes.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js";
@@ -20,6 +20,8 @@ export function createGatewayNodeSessionRuntime(params: {
listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"]; listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"];
nodePluginToolsEnabled?: boolean; nodePluginToolsEnabled?: boolean;
nodeSkillsEnabled?: boolean; nodeSkillsEnabled?: boolean;
sessionEventSubscribers: SessionEventSubscriberRegistry;
sessionMessageSubscribers: SessionMessageSubscriberRegistry;
}) { }) {
const nodeRegistry = new NodeRegistry({ const nodeRegistry = new NodeRegistry({
listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands, listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands,
@@ -28,8 +30,8 @@ export function createGatewayNodeSessionRuntime(params: {
}); });
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>(); const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const nodeSubscriptions = createNodeSubscriptionManager(); const nodeSubscriptions = createNodeSubscriptionManager();
const sessionEventSubscribers = createSessionEventSubscriberRegistry(); const sessionEventSubscribers = params.sessionEventSubscribers;
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); const sessionMessageSubscribers = params.sessionMessageSubscribers;
const nodeSendEvent = (opts: { const nodeSendEvent = (opts: {
nodeId: string; nodeId: string;
event: string; event: string;
+9 -1
View File
@@ -40,6 +40,8 @@ import {
type ChatRunEntry, type ChatRunEntry,
type ChatRunRegistration, type ChatRunRegistration,
createChatRunState, createChatRunState,
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
createToolEventRecipientRegistry, createToolEventRecipientRegistry,
} from "./server-chat-state.js"; } from "./server-chat-state.js";
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
@@ -149,6 +151,8 @@ export async function createGatewayRuntimeState(params: {
chatAbortControllers: Map<string, ChatAbortControllerEntry>; chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatQueuedTurns: Map<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>; chatQueuedTurns: Map<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>;
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>; toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
sessionEventSubscribers: ReturnType<typeof createSessionEventSubscriberRegistry>;
sessionMessageSubscribers: ReturnType<typeof createSessionMessageSubscriberRegistry>;
getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined; getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined;
getMcpAppSandboxPort: () => number | undefined; getMcpAppSandboxPort: () => number | undefined;
}> { }> {
@@ -163,7 +167,9 @@ export async function createGatewayRuntimeState(params: {
const resolvePluginRouteRegistry = () => const resolvePluginRouteRegistry = () =>
params.getPluginRouteRegistry?.() ?? params.pluginRegistry; params.getPluginRouteRegistry?.() ?? params.pluginRegistry;
const clients = new Set<GatewayWsClient>(); const clients = new Set<GatewayWsClient>();
const gatewayBroadcaster = createGatewayBroadcaster({ clients }); const sessionEventSubscribers = createSessionEventSubscriberRegistry();
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry();
const gatewayBroadcaster = createGatewayBroadcaster({ clients, sessionMessageSubscribers });
let loadedHooksRequestHandler: HooksRequestHandler | null = null; let loadedHooksRequestHandler: HooksRequestHandler | null = null;
const handleHooksRequest: HooksRequestHandler = async (req, res) => { const handleHooksRequest: HooksRequestHandler = async (req, res) => {
@@ -478,6 +484,8 @@ export async function createGatewayRuntimeState(params: {
chatAbortControllers, chatAbortControllers,
chatQueuedTurns, chatQueuedTurns,
toolEventRecipients, toolEventRecipients,
sessionEventSubscribers,
sessionMessageSubscribers,
getWorkerIngressEndpoint: () => getWorkerIngressEndpoint: () =>
workerIngressPort === undefined workerIngressPort === undefined
? undefined ? undefined
@@ -4036,6 +4036,7 @@ describe("gateway server chat", () => {
expect(broadcast).not.toHaveBeenCalledWith( expect(broadcast).not.toHaveBeenCalledWith(
"chat", "chat",
expect.objectContaining({ runId: "idem-queued-followup", state: "final" }), expect.objectContaining({ runId: "idem-queued-followup", state: "final" }),
expect.anything(),
); );
dispatchRelease.resolve(); dispatchRelease.resolve();
await waitForFast(() => { await waitForFast(() => {
@@ -4046,6 +4047,7 @@ describe("gateway server chat", () => {
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
state: "final", state: "final",
}), }),
{ sessionKeys: ["agent:main:main"] },
); );
}, FAST_WAIT_OPTS); }, FAST_WAIT_OPTS);
const finalEvents = broadcast.mock.calls.filter( const finalEvents = broadcast.mock.calls.filter(
@@ -4465,6 +4467,7 @@ describe("gateway server chat", () => {
]), ]),
}), }),
}), }),
{ sessionKeys: ["agent:main:main"] },
); );
}, },
{ timeout: 2_000, interval: 5 }, { timeout: 2_000, interval: 5 },
+4 -2
View File
@@ -1150,6 +1150,8 @@ export async function startGatewayServer(
chatAbortControllers, chatAbortControllers,
chatQueuedTurns, chatQueuedTurns,
toolEventRecipients, toolEventRecipients,
sessionEventSubscribers,
sessionMessageSubscribers,
getWorkerIngressEndpoint, getWorkerIngressEndpoint,
getMcpAppSandboxPort, getMcpAppSandboxPort,
} = await startupTrace.measure("runtime.state", () => } = await startupTrace.measure("runtime.state", () =>
@@ -1192,8 +1194,6 @@ export async function startGatewayServer(
const { const {
nodeRegistry, nodeRegistry,
nodePresenceTimers, nodePresenceTimers,
sessionEventSubscribers,
sessionMessageSubscribers,
nodeSendToSession, nodeSendToSession,
nodeSendToAllSubscribed, nodeSendToAllSubscribed,
nodeSubscribe, nodeSubscribe,
@@ -1203,6 +1203,8 @@ export async function startGatewayServer(
hasTalkNodeConnected, hasTalkNodeConnected,
} = createGatewayNodeSessionRuntime({ } = createGatewayNodeSessionRuntime({
broadcast, broadcast,
sessionEventSubscribers,
sessionMessageSubscribers,
listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands, listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands,
nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false, nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false,
nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.skills?.enabled !== false, nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.skills?.enabled !== false,
@@ -1,8 +1,10 @@
// Gateway WebSocket connect admission validates protocol, role, and browser origin. // Gateway WebSocket connect admission validates protocol, role, and browser origin.
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import { import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
hasGatewayClientCap,
} from "../../../../packages/gateway-protocol/src/client-info.js"; } from "../../../../packages/gateway-protocol/src/client-info.js";
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { import {
@@ -19,8 +21,12 @@ import {
GATEWAY_STARTUP_PENDING_CLOSE_CAUSE, GATEWAY_STARTUP_PENDING_CLOSE_CAUSE,
GATEWAY_STARTUP_RETRY_AFTER_MS, GATEWAY_STARTUP_RETRY_AFTER_MS,
} from "../../../../packages/gateway-protocol/src/startup-unavailable.js"; } from "../../../../packages/gateway-protocol/src/startup-unavailable.js";
import { isBrowserOperatorUiClient, isOperatorUiClient } from "../../../utils/message-channel.js"; import {
import { checkBrowserOrigin } from "../../origin-check.js"; isBrowserCopilotClient,
isBrowserOperatorUiClient,
isOperatorUiClient,
} from "../../../utils/message-channel.js";
import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "../../origin-check.js";
import { parseGatewayRole } from "../../role-policy.js"; import { parseGatewayRole } from "../../role-policy.js";
import { formatForLog } from "../../ws-log.js"; import { formatForLog } from "../../ws-log.js";
import { truncateCloseReason } from "../close-reason.js"; import { truncateCloseReason } from "../close-reason.js";
@@ -143,7 +149,42 @@ export async function admitGatewayConnect(context: GatewayConnectPhaseContext) {
connectParams.role = role; connectParams.role = role;
connectParams.scopes = scopes; 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 isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
const isWebchat = isWebchatConnect(connectParams); const isWebchat = isWebchatConnect(connectParams);
const isNativeAppUi = 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.MACOS_APP ||
connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP || connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP ||
connectParams.client.id === GATEWAY_CLIENT_IDS.ANDROID_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 = const hostHeaderOriginFallbackEnabled =
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
const originCheck = checkBrowserOrigin({ const originCheck = checkBrowserOrigin({
@@ -26,8 +26,10 @@ import {
resolveBootstrapProfileScopesForRoles, resolveBootstrapProfileScopesForRoles,
} from "../../../shared/device-bootstrap-profile.js"; } from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { isBrowserCopilotClient } from "../../../utils/message-channel.js";
import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js"; import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js";
import { shouldAutoApproveNodePairingFromTrustedCidrs } from "../../node-pairing-auto-approve.js"; import { shouldAutoApproveNodePairingFromTrustedCidrs } from "../../node-pairing-auto-approve.js";
import { normalizeChromeExtensionOrigin } from "../../origin-check.js";
import { truncateCloseReason } from "../close-reason.js"; import { truncateCloseReason } from "../close-reason.js";
import { import {
isControlUiOperatorBootstrapProfile, isControlUiOperatorBootstrapProfile,
@@ -50,8 +52,16 @@ export async function authorizeGatewayConnectDevice(
context: GatewayConnectPhaseContext, context: GatewayConnectPhaseContext,
state: AuthenticatedGatewayConnect, state: AuthenticatedGatewayConnect,
): Promise<DeviceAuthorizedGatewayConnect | undefined> { ): Promise<DeviceAuthorizedGatewayConnect | undefined> {
const { connId, buildRequestContext, close, send, setHandshakeState, setCloseCause, logGateway } = const {
context.handler; connId,
buildRequestContext,
close,
send,
setHandshakeState,
setCloseCause,
logGateway,
requestOrigin,
} = context.handler;
const { const {
frame, frame,
connectParams, connectParams,
@@ -77,6 +87,11 @@ export async function authorizeGatewayConnectDevice(
skipControlUiPairingForDevice, skipControlUiPairingForDevice,
} = state; } = state;
let hasServerApprovedDeviceTokenBaseline = false; let hasServerApprovedDeviceTokenBaseline = false;
let pairedClientId: string | undefined;
let pairedBrowserOrigin: string | undefined;
const browserCopilotOrigin = isBrowserCopilotClient(connectParams.client)
? normalizeChromeExtensionOrigin(requestOrigin)
: undefined;
if (device && devicePublicKey) { if (device && devicePublicKey) {
const formatAuditList = (items: string[] | undefined): string => { const formatAuditList = (items: string[] | undefined): string => {
const normalized = normalizeSortedUniqueTrimmedStringList(items); const normalized = normalizeSortedUniqueTrimmedStringList(items);
@@ -97,6 +112,7 @@ export async function authorizeGatewayConnectDevice(
deviceFamily: connectParams.client.deviceFamily, deviceFamily: connectParams.client.deviceFamily,
clientId: connectParams.client.id, clientId: connectParams.client.id,
clientMode: connectParams.client.mode, clientMode: connectParams.client.mode,
...(browserCopilotOrigin ? { browserOrigin: browserCopilotOrigin } : {}),
role, role,
scopes, scopes,
remoteIp: reportedClientIp, remoteIp: reportedClientIp,
@@ -430,6 +446,11 @@ export async function authorizeGatewayConnectDevice(
if (!ok) { if (!ok) {
return undefined; return undefined;
} }
const approvedDevice = await getPairedDevice(device.id);
pairedClientId =
approvedDevice?.publicKey === devicePublicKey ? approvedDevice.clientId : undefined;
pairedBrowserOrigin =
approvedDevice?.publicKey === devicePublicKey ? approvedDevice.browserOrigin : undefined;
hasServerApprovedDeviceTokenBaseline = true; hasServerApprovedDeviceTokenBaseline = true;
} else if ( } else if (
skipControlUiPairingForDevice || skipControlUiPairingForDevice ||
@@ -438,6 +459,8 @@ export async function authorizeGatewayConnectDevice(
hasServerApprovedDeviceTokenBaseline = true; hasServerApprovedDeviceTokenBaseline = true;
} }
} else { } else {
pairedClientId = paired.clientId;
pairedBrowserOrigin = paired.browserOrigin;
hasServerApprovedDeviceTokenBaseline = true; hasServerApprovedDeviceTokenBaseline = true;
const existingDevice = await authorizeExistingGatewayDevice({ const existingDevice = await authorizeExistingGatewayDevice({
context, 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({ const { deviceToken, bootstrapDeviceTokens } = await issueGatewayConnectDeviceTokens({
state: { ...state, scopes, handoffBootstrapProfile }, state: { ...state, scopes, handoffBootstrapProfile },
scopes, scopes,
@@ -14,7 +14,10 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { loadNodeHostConfig } from "../../../node-host/config.js"; import { loadNodeHostConfig } from "../../../node-host/config.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.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 { resolveRuntimeServiceVersion } from "../../../version.js";
import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
import { APPROVALS_SCOPE } from "../../method-scopes.js"; import { APPROVALS_SCOPE } from "../../method-scopes.js";
@@ -214,6 +217,9 @@ export async function attachAuthenticatedGatewayConnect(
connId, connId,
connectionKind: "gateway", connectionKind: "gateway",
isDeviceTokenAuth: authMethod === "device-token", isDeviceTokenAuth: authMethod === "device-token",
pairedClientId: isBrowserCopilotClient(connectParams.client)
? connectParams.client.id
: undefined,
usesSharedGatewayAuth: sessionUsesSharedGatewayAuth, usesSharedGatewayAuth: sessionUsesSharedGatewayAuth,
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration, sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
presenceKey, presenceKey,

Some files were not shown because too many files have changed in this diff Show More