mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c6d4559811
commit
ec8f6e5e03
@@ -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/",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
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(
|
||||||
|
"<img src=x> <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();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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()),
|
||||||
|
|||||||
Generated
+9
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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 ?? "",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -562,6 +562,7 @@ describe("abortChatRunsForProvider", () => {
|
|||||||
state: "aborted",
|
state: "aborted",
|
||||||
stopReason: "auth-revoked",
|
stopReason: "auth-revoked",
|
||||||
}),
|
}),
|
||||||
|
{ sessionKeys: [sessionKey] },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user