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/",
|
||||
"docs/_layouts/",
|
||||
"extensions/diffs/assets/viewer-runtime.js",
|
||||
"extensions/browser/chrome-extension/modules/copilot-runtime.js",
|
||||
"**/*.json",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"dist-runtime/",
|
||||
"docs/_layouts/",
|
||||
"**/a2ui.bundle.js",
|
||||
"extensions/browser/chrome-extension/modules/copilot-runtime.js",
|
||||
"extensions/diffs/assets/viewer-runtime.js",
|
||||
"extensions/diffs-language-pack/assets/viewer-runtime.js",
|
||||
"node_modules/",
|
||||
|
||||
@@ -13746,6 +13746,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let devicefamily: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let browserorigin: String?
|
||||
public let role: String?
|
||||
public let roles: [String]?
|
||||
public let scopes: [String]?
|
||||
@@ -13763,6 +13764,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
devicefamily: String? = nil,
|
||||
clientid: String? = nil,
|
||||
clientmode: String? = nil,
|
||||
browserorigin: String? = nil,
|
||||
role: String? = nil,
|
||||
roles: [String]? = nil,
|
||||
scopes: [String]? = nil,
|
||||
@@ -13779,6 +13781,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
self.devicefamily = devicefamily
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.browserorigin = browserorigin
|
||||
self.role = role
|
||||
self.roles = roles
|
||||
self.scopes = scopes
|
||||
@@ -13797,6 +13800,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case browserorigin = "browserOrigin"
|
||||
case role
|
||||
case roles
|
||||
case scopes
|
||||
@@ -13989,6 +13993,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
public let originatingaccountid: String?
|
||||
public let originatingthreadid: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let toolbindings: [String: AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let systeminputprovenance: [String: AnyCodable]?
|
||||
public let systemprovenancereceipt: String?
|
||||
@@ -14011,6 +14016,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingaccountid: String? = nil,
|
||||
originatingthreadid: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
toolbindings: [String: AnyCodable]? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
systeminputprovenance: [String: AnyCodable]? = nil,
|
||||
systemprovenancereceipt: String? = nil,
|
||||
@@ -14032,6 +14038,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.originatingaccountid = originatingaccountid
|
||||
self.originatingthreadid = originatingthreadid
|
||||
self.attachments = attachments
|
||||
self.toolbindings = toolbindings
|
||||
self.timeoutms = timeoutms
|
||||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
@@ -14054,6 +14061,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingaccountid: String? = nil,
|
||||
originatingthreadid: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
toolbindings: [String: AnyCodable]? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
systeminputprovenance: [String: AnyCodable]? = nil,
|
||||
systemprovenancereceipt: String? = nil,
|
||||
@@ -14076,6 +14084,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
originatingaccountid: originatingaccountid,
|
||||
originatingthreadid: originatingthreadid,
|
||||
attachments: attachments,
|
||||
toolbindings: toolbindings,
|
||||
timeoutms: timeoutms,
|
||||
systeminputprovenance: systeminputprovenance,
|
||||
systemprovenancereceipt: systemprovenancereceipt,
|
||||
@@ -14099,6 +14108,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case originatingaccountid = "originatingAccountId"
|
||||
case originatingthreadid = "originatingThreadId"
|
||||
case attachments
|
||||
case toolbindings = "toolBindings"
|
||||
case timeoutms = "timeoutMs"
|
||||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
|
||||
@@ -576,6 +576,10 @@ const config = {
|
||||
// Chrome manifest/package scripts load these without TypeScript imports.
|
||||
"chrome-extension/background.js!",
|
||||
"chrome-extension/popup.js!",
|
||||
"chrome-extension/sidepanel.js!",
|
||||
"scripts/build-copilot-runtime.mjs!",
|
||||
// esbuild receives this browser bootstrap by an assembled path.
|
||||
"scripts/copilot-runtime-entry.ts!",
|
||||
"scripts/copy-chrome-extension.mjs!",
|
||||
]),
|
||||
[`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([
|
||||
|
||||
@@ -9630,6 +9630,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: How it works
|
||||
- H2: Install and pair
|
||||
- H2: Use it
|
||||
- H3: Tab copilot side panel
|
||||
- H2: Remote / cross-machine
|
||||
- H2: Diagnostics
|
||||
- H2: Security model
|
||||
|
||||
@@ -91,12 +91,64 @@ openclaw config set browser.defaultProfile chrome
|
||||
- Revoke: click the button again, drag the tab out of the group, or dismiss
|
||||
Chrome's debugging banner. The agent loses access to that tab immediately.
|
||||
|
||||
### Tab copilot side panel
|
||||
|
||||
After pairing the extension, click **Open tab copilot** in its toolbar popup.
|
||||
OpenClaw configures `sidepanel.html` for that exact Chrome tab; the manifest has
|
||||
no global side-panel path. Each tab therefore gets a separate panel document,
|
||||
Gateway session, message subscription, and typed browser-tool binding.
|
||||
|
||||
The panel does not place the page URL, title, DOM, or visible text in your
|
||||
message. It sends only the text you type. Browser actions carry a separate
|
||||
Gateway-authenticated binding containing the Chrome tab and CDP target, and the
|
||||
browser tool rejects attempts to replace that target or use browser-wide
|
||||
actions. Replies stay in the panel (`deliver: false`); they do not inherit a
|
||||
Telegram, Discord, or other channel route.
|
||||
|
||||
The copilot is a dedicated paired Gateway device with `operator.read` and
|
||||
`operator.write` scopes. On first use, inspect and approve its request:
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve <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
|
||||
|
||||
Chrome does not have to run on the Gateway host. Three topologies work:
|
||||
|
||||
- **Same host** (Gateway + Chrome on one machine): pair on that machine with
|
||||
`openclaw browser extension pair`. The relay is loopback-only.
|
||||
If the local Gateway uses TLS, pass its certificate hostname explicitly with
|
||||
`--gateway-url wss://gateway-host.example`; pairing never substitutes a loopback IP.
|
||||
- **Direct to a remote Gateway** (Chrome on your laptop, Gateway on a VPS, and
|
||||
**nothing else on the laptop**): on the Gateway, run
|
||||
`openclaw browser extension pair --gateway-url wss://your-gateway.example.com`.
|
||||
@@ -134,6 +186,9 @@ extension popup shows **Connected**.
|
||||
the bundled extension carries it in the WebSocket subprotocol list instead.
|
||||
- The agent can only see and drive tabs in the **OpenClaw tab group**. Your
|
||||
other tabs stay private.
|
||||
- Side-panel runs are scoped twice: Gateway delivery uses a per-session
|
||||
allowlist, and browser tools enforce the Chrome tab/target binding carried
|
||||
outside the prompt.
|
||||
- Compared with the `user` (Chrome MCP) profile, which exposes your whole
|
||||
signed-in browser once you approve the remote-debugging prompt, the extension
|
||||
keeps the shared surface scoped to a tab group you control at a glance.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCopilotController } from "./modules/copilot-background.js";
|
||||
// OpenClaw extension service worker.
|
||||
//
|
||||
// Thin transport between the OpenClaw extension relay (loopback WebSocket) and
|
||||
@@ -20,16 +21,29 @@ const BADGE = {
|
||||
on: { text: "ON", color: "#0F9D58" },
|
||||
error: { text: "!", color: "#B91C1C" },
|
||||
};
|
||||
const COPILOT_RELAY_LABEL = {
|
||||
off: "Browser relay disconnected",
|
||||
connecting: "Connecting to browser relay",
|
||||
on: "Browser relay connected",
|
||||
error: "Browser relay reconnecting",
|
||||
};
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let relayWs = null;
|
||||
let relayState = "off"; // off | connecting | on | error
|
||||
let copilot = null;
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer = null;
|
||||
/** Tab ids with an active chrome.debugger attachment. */
|
||||
const attachedTabs = new Set();
|
||||
/** Tabs denied to every relay attach while copilot run cleanup is pending. */
|
||||
const copilotDeniedTabs = new Set();
|
||||
/** Monotonic revocation epochs invalidate attaches already in flight. */
|
||||
const copilotAccessRevisions = new Map();
|
||||
/** In-flight attach promises per tab id (coalesces concurrent attaches). */
|
||||
const attachingTabs = new Map();
|
||||
/** Latest revocation task per tab; restoration waits for its exact epoch. */
|
||||
const copilotRevocations = new Map();
|
||||
/** Debounce handle for tab-list refreshes. */
|
||||
let tabsSyncTimer = null;
|
||||
|
||||
@@ -38,6 +52,10 @@ function setBadge(kind) {
|
||||
const cfg = BADGE[kind] ?? BADGE.off;
|
||||
void chrome.action.setBadgeText({ text: cfg.text });
|
||||
void chrome.action.setBadgeBackgroundColor({ color: cfg.color });
|
||||
void copilot?.onRelayStatus({
|
||||
ready: kind === "on",
|
||||
label: COPILOT_RELAY_LABEL[kind] ?? COPILOT_RELAY_LABEL.off,
|
||||
});
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
@@ -49,6 +67,15 @@ async function getConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
async function getCopilotConfig() {
|
||||
const config = await getConfig();
|
||||
const stored = await chrome.storage.local.get(["gatewayUrl"]);
|
||||
return {
|
||||
...config,
|
||||
gatewayUrl: typeof stored.gatewayUrl === "string" ? stored.gatewayUrl : "",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab group management (the consent boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,6 +127,18 @@ async function isTabShared(tabId) {
|
||||
return shared.some((tab) => tab.id === tabId);
|
||||
}
|
||||
|
||||
async function isOpenClawGroupId(groupId) {
|
||||
if (!Number.isInteger(groupId) || groupId < 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(groupId);
|
||||
return group.title === OPENCLAW_TAB_GROUP_TITLE;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTabsSync() {
|
||||
if (tabsSyncTimer) {
|
||||
return;
|
||||
@@ -131,9 +170,7 @@ async function syncTabsToRelay() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function attachDebugger(tabId) {
|
||||
if (!(await isTabShared(tabId))) {
|
||||
throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`);
|
||||
}
|
||||
await copilotCustodyReady;
|
||||
// Coalesce concurrent attaches for one tab. Two relay attach commands (or an
|
||||
// auto-attach racing an explicit share) would otherwise both call
|
||||
// chrome.debugger.attach and the second throws "Another debugger is already
|
||||
@@ -142,7 +179,21 @@ async function attachDebugger(tabId) {
|
||||
if (inFlight) {
|
||||
return await inFlight;
|
||||
}
|
||||
const accessRevision = copilotAccessRevisions.get(tabId) ?? 0;
|
||||
const assertAccess = () => {
|
||||
if (
|
||||
copilotDeniedTabs.has(tabId) ||
|
||||
(copilotAccessRevisions.get(tabId) ?? 0) !== accessRevision
|
||||
) {
|
||||
throw new Error(`tab ${tabId} is blocked until its copilot run stops`);
|
||||
}
|
||||
};
|
||||
const attach = (async () => {
|
||||
assertAccess();
|
||||
if (!(await isTabShared(tabId))) {
|
||||
throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`);
|
||||
}
|
||||
assertAccess();
|
||||
if (!attachedTabs.has(tabId)) {
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, "1.3");
|
||||
@@ -152,9 +203,21 @@ async function attachDebugger(tabId) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertAccess();
|
||||
} catch (error) {
|
||||
await detachDebugger(tabId);
|
||||
throw error;
|
||||
}
|
||||
attachedTabs.add(tabId);
|
||||
}
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
try {
|
||||
assertAccess();
|
||||
} catch (error) {
|
||||
await detachDebugger(tabId);
|
||||
throw error;
|
||||
}
|
||||
const target = targets.find((candidate) => candidate.tabId === tabId && candidate.attached);
|
||||
return { targetId: target?.id ?? `tab-${tabId}` };
|
||||
})();
|
||||
@@ -167,6 +230,8 @@ async function attachDebugger(tabId) {
|
||||
}
|
||||
|
||||
async function detachDebugger(tabId) {
|
||||
// Always call Chrome: an attach can complete before attachedTabs records it.
|
||||
// The unconditional detach closes that revocation race.
|
||||
attachedTabs.delete(tabId);
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
@@ -175,6 +240,34 @@ async function detachDebugger(tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeCopilotDebugger(tabId) {
|
||||
copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
copilotDeniedTabs.add(tabId);
|
||||
const previous = copilotRevocations.get(tabId) ?? Promise.resolve();
|
||||
const revocation = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await Promise.allSettled([attachingTabs.get(tabId)]);
|
||||
await detachDebugger(tabId);
|
||||
});
|
||||
copilotRevocations.set(tabId, revocation);
|
||||
try {
|
||||
await revocation;
|
||||
} finally {
|
||||
if (copilotRevocations.get(tabId) === revocation) {
|
||||
copilotRevocations.delete(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCopilotDebugger(tabId) {
|
||||
const accessRevision = copilotAccessRevisions.get(tabId) ?? 0;
|
||||
await copilotRevocations.get(tabId);
|
||||
if ((copilotAccessRevisions.get(tabId) ?? 0) === accessRevision) {
|
||||
copilotDeniedTabs.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.debugger.onEvent.addListener((source, method, params) => {
|
||||
if (typeof source.tabId !== "number") {
|
||||
return;
|
||||
@@ -329,6 +422,19 @@ async function connectRelay() {
|
||||
// onclose follows onerror and drives the reconnect, so no error handler needed.
|
||||
}
|
||||
|
||||
copilot = createCopilotController({
|
||||
getConfig: getCopilotConfig,
|
||||
isTabShared,
|
||||
addTabToOpenClawGroup,
|
||||
attachDebugger,
|
||||
detachDebugger,
|
||||
revokeDebugger: revokeCopilotDebugger,
|
||||
restoreDebugger: restoreCopilotDebugger,
|
||||
scheduleTabsSync,
|
||||
});
|
||||
const copilotCustodyReady = copilot.initializeCustody();
|
||||
const copilotReady = copilot.initialize();
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
@@ -372,15 +478,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
reconnectAttempt = 0;
|
||||
relayWs?.close();
|
||||
relayWs = null;
|
||||
await chrome.storage.local.set({ gatewayUrl: parsed.gatewayUrl ?? "" });
|
||||
await connectRelay();
|
||||
await copilot.refreshConfig();
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
case "unpair": {
|
||||
await chrome.storage.local.remove(["relayUrl", "token"]);
|
||||
await chrome.storage.local.remove(["relayUrl", "gatewayUrl", "token"]);
|
||||
relayWs?.close();
|
||||
relayWs = null;
|
||||
setBadge("off");
|
||||
await copilot.refreshConfig();
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
@@ -400,12 +509,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
scheduleTabsSync();
|
||||
sendResponse({ ok: true, shared: true });
|
||||
}
|
||||
await copilot.onConsentChanged();
|
||||
return;
|
||||
}
|
||||
case "isTabShared": {
|
||||
sendResponse({ shared: await isTabShared(msg.tabId) });
|
||||
return;
|
||||
}
|
||||
case "prepareCopilotPanel": {
|
||||
const options = await copilot.preparePanel(msg.tabId);
|
||||
sendResponse({ ok: true, ...options });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
sendResponse({ ok: false, error: "unknown message" });
|
||||
}
|
||||
@@ -414,20 +529,44 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
copilotAccessRevisions.set(tabId, (copilotAccessRevisions.get(tabId) ?? 0) + 1);
|
||||
attachedTabs.delete(tabId);
|
||||
copilotDeniedTabs.delete(tabId);
|
||||
scheduleTabsSync();
|
||||
void copilot.onTabRemoved(tabId);
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
scheduleTabsSync();
|
||||
if (typeof changeInfo.groupId !== "number") {
|
||||
void copilot.onConsentChanged(tabId);
|
||||
return;
|
||||
}
|
||||
// changeInfo.groupId is the event-time membership snapshot. Preserve a
|
||||
// revocation even if a later event re-shares the tab before async cleanup.
|
||||
void isOpenClawGroupId(changeInfo.groupId).then((shared) =>
|
||||
copilot.onConsentChanged(tabId, { revoked: !shared }),
|
||||
);
|
||||
});
|
||||
chrome.tabGroups.onUpdated.addListener(() => {
|
||||
scheduleTabsSync();
|
||||
void copilot.onConsentChanged();
|
||||
});
|
||||
chrome.tabGroups.onRemoved.addListener(() => {
|
||||
scheduleTabsSync();
|
||||
void copilot.onConsentChanged();
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener(() => scheduleTabsSync());
|
||||
chrome.tabGroups.onUpdated.addListener(() => scheduleTabsSync());
|
||||
chrome.tabGroups.onRemoved.addListener(() => scheduleTabsSync());
|
||||
|
||||
// Watchdog: MV3 can stop this worker; the alarm revives it and re-connects.
|
||||
chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "openclaw-relay-watchdog") {
|
||||
void connectRelay();
|
||||
void copilot.drainAborts();
|
||||
void copilot.drainArchives();
|
||||
void copilot.drainStaleScopes();
|
||||
}
|
||||
});
|
||||
chrome.runtime.onStartup.addListener(() => void connectRelay());
|
||||
chrome.runtime.onInstalled.addListener(() => void connectRelay());
|
||||
void connectRelay();
|
||||
void copilotReady;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms"],
|
||||
"permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms", "sidePanel"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
"default_title": "OpenClaw",
|
||||
|
||||
@@ -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.
|
||||
|
||||
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[];
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ const CHROME_GROUP_COLORS = {
|
||||
|
||||
/**
|
||||
* Parse a pairing string printed by `openclaw browser extension pair`.
|
||||
* Shape: ws://127.0.0.1:<port>/extension#<token>
|
||||
* Returns { relayUrl, token } or null when malformed.
|
||||
* Shape: ws://127.0.0.1:<port>/extension?gateway=<url>#<token>
|
||||
* The additive gateway hint is not a credential; old extensions safely pass
|
||||
* it through to the relay while new extensions remove it before connecting.
|
||||
*/
|
||||
export function parsePairingString(raw) {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
@@ -47,7 +48,16 @@ export function parsePairingString(raw) {
|
||||
if (!parsed.pathname.endsWith("/extension")) {
|
||||
return null;
|
||||
}
|
||||
return { relayUrl, token };
|
||||
const gatewayUrl = parsed.searchParams.get("gateway")?.trim() || undefined;
|
||||
parsed.searchParams.delete("gateway");
|
||||
if ([...parsed.searchParams].length > 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
relayUrl: parsed.toString(),
|
||||
token,
|
||||
...(gatewayUrl ? { gatewayUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build WebSocket subprotocols without putting the relay secret in the request URL. */
|
||||
|
||||
@@ -39,6 +39,16 @@ describe("parsePairingString", () => {
|
||||
expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull();
|
||||
expect(parsePairingString("ws://127.0.0.1/extension")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts the additive direct Gateway hint without passing it to the relay", () => {
|
||||
const gatewayUrl = "wss://gateway.example.com/base";
|
||||
const pairing = `ws://127.0.0.1:18797/extension?gateway=${encodeURIComponent(gatewayUrl)}#tok`;
|
||||
expect(parsePairingString(pairing)).toEqual({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: "tok",
|
||||
gatewayUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnectDelayMs", () => {
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
background: #3a3a3c;
|
||||
color: #f2f2f7;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -86,6 +90,7 @@
|
||||
</div>
|
||||
<div id="connectedSection" class="hidden">
|
||||
<p id="statusLine"></p>
|
||||
<button id="copilotButton" disabled>Open tab copilot</button>
|
||||
<button id="shareButton"></button>
|
||||
<button id="unpairButton" class="secondary">Unpair</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ const pairingInput = document.getElementById("pairingString");
|
||||
const pairButton = document.getElementById("pairButton");
|
||||
const unpairButton = document.getElementById("unpairButton");
|
||||
const shareButton = document.getElementById("shareButton");
|
||||
const copilotButton = document.getElementById("copilotButton");
|
||||
const statusLine = document.getElementById("statusLine");
|
||||
const errorLine = document.getElementById("error");
|
||||
|
||||
@@ -35,8 +36,13 @@ async function refresh() {
|
||||
const tab = await activeTab();
|
||||
if (tab?.id === undefined) {
|
||||
shareButton.classList.add("hidden");
|
||||
copilotButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
const panel = await chrome.runtime.sendMessage({ type: "prepareCopilotPanel", tabId: tab.id });
|
||||
copilotButton.disabled = !panel?.ok;
|
||||
copilotButton.dataset.tabId = String(tab.id);
|
||||
copilotButton.dataset.path = panel?.path ?? "";
|
||||
const { shared } = await chrome.runtime.sendMessage({ type: "isTabShared", tabId: tab.id });
|
||||
shareButton.classList.remove("hidden");
|
||||
shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw";
|
||||
@@ -70,9 +76,21 @@ async function onToggleShare() {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onOpenCopilot() {
|
||||
const tabId = Number.parseInt(copilotButton.dataset.tabId ?? "", 10);
|
||||
const path = copilotButton.dataset.path;
|
||||
if (!Number.isInteger(tabId) || !path) {
|
||||
return;
|
||||
}
|
||||
await chrome.sidePanel.setOptions({ tabId, path, enabled: true });
|
||||
await chrome.sidePanel.open({ tabId });
|
||||
window.close();
|
||||
}
|
||||
|
||||
pairButton.addEventListener("click", () => void onPair());
|
||||
unpairButton.addEventListener("click", () => void onUnpair());
|
||||
shareButton.addEventListener("click", () => void onToggleShare());
|
||||
copilotButton.addEventListener("click", () => void onOpenCopilot());
|
||||
|
||||
void refresh();
|
||||
setInterval(() => void refresh(), 2000);
|
||||
|
||||
@@ -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 () => {
|
||||
const { api, registerTool } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@noble/ed25519": "3.1.0",
|
||||
"express": "5.2.1",
|
||||
"esbuild": "0.28.1",
|
||||
"playwright-core": "1.61.1",
|
||||
"typebox": "1.3.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/gateway-client": "workspace:*",
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"undici": "8.6.0"
|
||||
},
|
||||
@@ -20,6 +23,7 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"assetScripts": {
|
||||
"build": "node scripts/build-copilot-runtime.mjs",
|
||||
"copy": "node scripts/copy-chrome-extension.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
BROWSER_REQUEST_GATEWAY_SCOPE,
|
||||
} from "./src/browser-gateway-contract.js";
|
||||
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
|
||||
import { describeBrowserTool } from "./src/browser-tool-description.js";
|
||||
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
import {
|
||||
@@ -72,7 +73,15 @@ function createLazyBrowserTool(opts?: {
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
runToolBinding?: unknown;
|
||||
}): AnyAgentTool {
|
||||
const bindingResult =
|
||||
opts?.runToolBinding === undefined
|
||||
? undefined
|
||||
: parseBrowserTabToolBinding(opts.runToolBinding);
|
||||
if (bindingResult && !bindingResult.ok) {
|
||||
throw new Error(`invalid browser run binding: ${bindingResult.error}`);
|
||||
}
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
||||
@@ -83,7 +92,9 @@ function createLazyBrowserTool(opts?: {
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
|
||||
const tool = createBrowserTool(opts);
|
||||
const tool = createBrowserTool(
|
||||
bindingResult?.ok ? { ...opts, runToolBinding: bindingResult.binding } : opts,
|
||||
);
|
||||
return await tool.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
};
|
||||
@@ -104,6 +115,7 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
runToolBinding?: unknown;
|
||||
} {
|
||||
const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel;
|
||||
const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey);
|
||||
@@ -132,6 +144,9 @@ function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(ctx.toolBindings && Object.hasOwn(ctx.toolBindings, "browser")
|
||||
? { runToolBinding: ctx.toolBindings.browser }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
timeoutMs?: number;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
targetId?: string;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
|
||||
if (proxyRequest) {
|
||||
@@ -315,10 +316,16 @@ export async function executeTabsAction(params: {
|
||||
profile,
|
||||
timeoutMs,
|
||||
});
|
||||
const tabs = (result as { tabs?: unknown[] }).tabs ?? [];
|
||||
const tabs = ((result as { tabs?: unknown[] }).tabs ?? []).filter(
|
||||
(tab) =>
|
||||
!params.targetId ||
|
||||
readStringValue((tab as { targetId?: unknown } | undefined)?.targetId) === params.targetId,
|
||||
);
|
||||
return formatTabsToolResult(tabs);
|
||||
}
|
||||
const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs });
|
||||
const tabs = (await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs })).filter(
|
||||
(tab) => !params.targetId || readStringValue(tab.targetId) === params.targetId,
|
||||
);
|
||||
return formatTabsToolResult(tabs);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* maps high-level actions onto browser control client calls.
|
||||
*/
|
||||
import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js";
|
||||
import { applyBrowserTabToolBinding, parseBrowserTabToolBinding } from "./browser-tool-binding.js";
|
||||
import { describeBrowserTool } from "./browser-tool-description.js";
|
||||
import {
|
||||
executeActAction,
|
||||
@@ -403,6 +404,7 @@ export function createBrowserTool(opts?: {
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
runToolBinding?: unknown;
|
||||
}): AnyAgentTool {
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
@@ -413,7 +415,16 @@ export function createBrowserTool(opts?: {
|
||||
description: describeBrowserTool({ targetDefault, hostHint }),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<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 profile = readStringParam(params, "profile");
|
||||
const requestedNode = readStringParam(params, "node");
|
||||
@@ -617,6 +628,7 @@ export function createBrowserTool(opts?: {
|
||||
profile,
|
||||
timeoutMs: toolTimeoutMs,
|
||||
proxyRequest,
|
||||
targetId: bindingResult?.ok ? bindingResult.binding.targetId : undefined,
|
||||
});
|
||||
case "open": {
|
||||
const targetUrl = readTargetUrlParam(params);
|
||||
|
||||
@@ -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 { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { resolveGatewayPort } from "../sdk-config.js";
|
||||
import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import {
|
||||
danger,
|
||||
@@ -82,14 +84,24 @@ function buildPairingString(gatewayUrl?: string): {
|
||||
// Remote: the extension connects straight to this gateway over wss:// — no
|
||||
// node host on the browser machine. The gateway route self-validates the
|
||||
// same host-local secret.
|
||||
const relayUrl = new URL(buildRemoteGatewayRelayUrl(gateway));
|
||||
relayUrl.searchParams.set("gateway", gateway);
|
||||
return {
|
||||
pairing: `${buildRemoteGatewayRelayUrl(gateway)}#${token}`,
|
||||
pairing: `${relayUrl.toString()}#${token}`,
|
||||
relayPort,
|
||||
remote: true,
|
||||
};
|
||||
}
|
||||
const configuredRemote = cfg.gateway?.mode === "remote" ? cfg.gateway.remote?.url?.trim() : "";
|
||||
const directGatewayUrl = resolveLocalPairingGatewayUrl({
|
||||
configuredRemote,
|
||||
gatewayPort: resolveGatewayPort(cfg),
|
||||
tlsEnabled: cfg.gateway?.tls?.enabled === true,
|
||||
});
|
||||
const relayUrl = new URL(`ws://127.0.0.1:${relayPort}/extension`);
|
||||
relayUrl.searchParams.set("gateway", directGatewayUrl);
|
||||
return {
|
||||
pairing: `ws://127.0.0.1:${relayPort}/extension#${token}`,
|
||||
pairing: `${relayUrl.toString()}#${token}`,
|
||||
relayPort,
|
||||
remote: false,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// of this entry so browser consumers share the wire engine without polyfills.
|
||||
export * from "./device-auth.js";
|
||||
export * from "./browser-device-auth.js";
|
||||
export * from "./connect-auth.js";
|
||||
export * from "./protocol-client.js";
|
||||
export * from "./reconnect-policy.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Public gateway-client package surface: connection client, device auth,
|
||||
// readiness helpers, event-loop readiness, and timeout utilities.
|
||||
export * from "./client.js";
|
||||
export * from "./browser-device-auth.js";
|
||||
export * from "./connect-auth.js";
|
||||
export * from "./device-auth.js";
|
||||
export * from "./event-loop-ready.js";
|
||||
|
||||
@@ -16,6 +16,7 @@ function normalizeOptionalLowercaseString(raw?: string | null): string | undefin
|
||||
export const GATEWAY_CLIENT_IDS = {
|
||||
WEBCHAT_UI: "webchat-ui",
|
||||
CONTROL_UI: "openclaw-control-ui",
|
||||
BROWSER_COPILOT: "openclaw-browser-copilot",
|
||||
TUI: "openclaw-tui",
|
||||
WEBCHAT: "webchat",
|
||||
CLI: "cli",
|
||||
@@ -79,6 +80,8 @@ export const GATEWAY_CLIENT_CAPS = {
|
||||
APPROVALS: "approvals",
|
||||
EXEC_APPROVALS: "exec-approvals",
|
||||
INLINE_WIDGETS: "inline-widgets",
|
||||
RUN_TOOL_BINDINGS: "run-tool-bindings",
|
||||
SESSION_SCOPED_EVENTS: "session-scoped-events",
|
||||
PLUGIN_APPROVALS: "plugin-approvals",
|
||||
TASK_SUGGESTIONS: "task-suggestions",
|
||||
TERMINAL_OFFSET_SEQ: "terminal-offset-seq",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const DevicePairRequestedEventSchema = closedObject({
|
||||
deviceFamily: Type.Optional(NonEmptyString),
|
||||
clientId: Type.Optional(NonEmptyString),
|
||||
clientMode: Type.Optional(NonEmptyString),
|
||||
browserOrigin: Type.Optional(NonEmptyString),
|
||||
role: Type.Optional(NonEmptyString),
|
||||
roles: Type.Optional(Type.Array(NonEmptyString)),
|
||||
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||
|
||||
@@ -85,6 +85,14 @@ export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
|
||||
/** Attachment envelope shared by chat.send and session creation's initial turn. */
|
||||
export const ChatAttachmentsSchema = Type.Array(Type.Unknown());
|
||||
|
||||
/** Opaque, out-of-band plugin bindings carried separately from model input. */
|
||||
export const RunToolBindingsSchema = Type.Record(
|
||||
Type.String({ minLength: 1, maxLength: 128 }),
|
||||
Type.Unknown(),
|
||||
{ maxProperties: 16 },
|
||||
);
|
||||
export type RunToolBindings = Static<typeof RunToolBindingsSchema>;
|
||||
|
||||
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
|
||||
export const ChatSendParamsSchema = closedObject({
|
||||
sessionKey: ChatSendSessionKeyString,
|
||||
@@ -103,6 +111,7 @@ export const ChatSendParamsSchema = closedObject({
|
||||
originatingAccountId: Type.Optional(Type.String()),
|
||||
originatingThreadId: Type.Optional(Type.String()),
|
||||
attachments: Type.Optional(ChatAttachmentsSchema),
|
||||
toolBindings: Type.Optional(RunToolBindingsSchema),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
systemInputProvenance: Type.Optional(InputProvenanceSchema),
|
||||
systemProvenanceReceipt: Type.Optional(Type.String()),
|
||||
|
||||
Generated
+9
@@ -488,6 +488,12 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: 1.29.0
|
||||
version: 1.29.0(zod@4.4.3)
|
||||
'@noble/ed25519':
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
esbuild:
|
||||
specifier: 0.28.1
|
||||
version: 0.28.1
|
||||
express:
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
@@ -501,6 +507,9 @@ importers:
|
||||
specifier: 8.21.0
|
||||
version: 8.21.0
|
||||
devDependencies:
|
||||
'@openclaw/gateway-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/gateway-client
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
@@ -281,6 +281,8 @@ type OpenClawCodingToolsOptions = {
|
||||
messageChannel?: string;
|
||||
/** Capabilities declared by the gateway client that originated this run. */
|
||||
clientCaps?: string[];
|
||||
/** Out-of-band plugin bindings attached by the run initiator. */
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
/** Normalized conversation kind when the caller already has channel metadata. */
|
||||
chatType?: ChatType;
|
||||
/** Specific ingress provider used only for transport tool availability. */
|
||||
@@ -876,6 +878,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
requesterAgentIdOverride: agentId,
|
||||
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
|
||||
clientCaps: options?.clientCaps,
|
||||
toolBindings: options?.toolBindings,
|
||||
authProfileStore: options?.authProfileStore,
|
||||
},
|
||||
resolvedConfig: options?.config,
|
||||
@@ -965,6 +968,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
sandboxed: Boolean(sandbox),
|
||||
config: options?.config,
|
||||
clientCaps: options?.clientCaps,
|
||||
toolBindings: options?.toolBindings,
|
||||
pluginToolAllowlist,
|
||||
pluginToolDenylist,
|
||||
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
|
||||
|
||||
@@ -205,6 +205,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
...buildEmbeddedAttemptToolRunContext({ ...attempt, trace: params.runTrace }),
|
||||
messageChannel: attempt.messageChannel,
|
||||
clientCaps: attempt.clientCaps,
|
||||
toolBindings: attempt.toolBindings,
|
||||
chatType: attempt.chatType,
|
||||
exec: {
|
||||
...attempt.execOverrides,
|
||||
|
||||
@@ -81,6 +81,8 @@ export type RunEmbeddedAgentParams = {
|
||||
messageProvider?: string;
|
||||
/** Capabilities declared by the gateway client that originated this run. */
|
||||
clientCaps?: string[];
|
||||
/** Out-of-band plugin bindings attached by the run initiator. */
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
chatType?: ChatType;
|
||||
agentAccountId?: string;
|
||||
/** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */
|
||||
|
||||
@@ -47,6 +47,7 @@ export type OpenClawPluginToolOptions = {
|
||||
allowHostBrowserControl?: boolean;
|
||||
sandboxed?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Resolves plugin-tool context inputs from runtime options and config state. */
|
||||
@@ -97,6 +98,7 @@ export function resolveOpenClawPluginToolInputs(params: {
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
sessionId: options?.sessionId,
|
||||
toolBindings: options?.toolBindings,
|
||||
activeModel,
|
||||
browser: {
|
||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createOpenClawTools(
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -106,6 +106,7 @@ export function buildEmbeddedRunBaseParams(params: {
|
||||
silentReplyPromptMode: params.run.silentReplyPromptMode,
|
||||
sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode,
|
||||
clientCaps: params.run.clientCaps,
|
||||
toolBindings: params.run.toolBindings,
|
||||
taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
|
||||
@@ -76,4 +76,19 @@ describe("buildEmbeddedRunBaseParams runtime config", () => {
|
||||
|
||||
expect(resolved.config).toBe(resolvedRunConfig);
|
||||
});
|
||||
|
||||
it("carries out-of-band tool bindings into the embedded run", () => {
|
||||
const run = makeRun({});
|
||||
run.toolBindings = { browser: { kind: "tab", targetId: "target-1" } };
|
||||
|
||||
const resolved = buildEmbeddedRunBaseParams({
|
||||
run,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
runId: "run-1",
|
||||
authProfile: {},
|
||||
});
|
||||
|
||||
expect(resolved.toolBindings).toEqual(run.toolBindings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1597,6 +1597,7 @@ export async function runPreparedReply(
|
||||
runtimePolicySessionKey,
|
||||
messageProvider,
|
||||
clientCaps: ctx.GatewayClientCaps,
|
||||
toolBindings: ctx.GatewayRunToolBindings,
|
||||
chatType: replyRoute.chatType,
|
||||
agentAccountId: replyRoute.accountId,
|
||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||
|
||||
@@ -24,6 +24,28 @@ describe("followup delivery context", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("never collect-batches runs bound to different tool targets", () => {
|
||||
const first = createQueueTestRun({ prompt: "first" });
|
||||
first.run.toolBindings = { browser: { kind: "tab", targetId: "tab-a" } };
|
||||
const second = createQueueTestRun({ prompt: "second" });
|
||||
second.run.toolBindings = { browser: { kind: "tab", targetId: "tab-b" } };
|
||||
|
||||
expect(resolveFollowupDeliveryContextKey(first)).not.toBe(
|
||||
resolveFollowupDeliveryContextKey(second),
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes equivalent tool bindings", () => {
|
||||
const first = createQueueTestRun({ prompt: "first" });
|
||||
first.run.toolBindings = { browser: { targetId: "tab-a", kind: "tab" } };
|
||||
const second = createQueueTestRun({ prompt: "second" });
|
||||
second.run.toolBindings = { browser: { kind: "tab", targetId: "tab-a" } };
|
||||
|
||||
expect(resolveFollowupDeliveryContextKey(first)).toBe(
|
||||
resolveFollowupDeliveryContextKey(second),
|
||||
);
|
||||
});
|
||||
|
||||
it("separates runs with different parent policy provenance", () => {
|
||||
const first = createQueueTestRun({ prompt: "first" });
|
||||
first.run.spawnedBy = "agent:main:telegram:group:first";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js";
|
||||
import { stableStringify } from "../../../agents/stable-stringify.js";
|
||||
import { normalizeChatType } from "../../../channels/chat-type.js";
|
||||
import { resolveStorePath } from "../../../config/sessions.js";
|
||||
import { loadSessionEntry } from "../../../config/sessions/session-accessor.js";
|
||||
@@ -198,6 +199,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
|
||||
normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "",
|
||||
execution.messageProvider ?? "",
|
||||
JSON.stringify([...new Set(execution.clientCaps ?? [])].toSorted()),
|
||||
stableStringify(execution.toolBindings ?? null),
|
||||
execution.chatType ?? "",
|
||||
execution.agentAccountId ?? "",
|
||||
execution.groupId ?? "",
|
||||
|
||||
@@ -128,6 +128,7 @@ export type FollowupRun = {
|
||||
runtimePolicySessionKey?: string;
|
||||
messageProvider?: string;
|
||||
clientCaps?: string[];
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
chatType?: ChatType;
|
||||
agentAccountId?: string;
|
||||
groupId?: string;
|
||||
|
||||
@@ -302,6 +302,8 @@ export type MsgContext = {
|
||||
GatewayClientScopes?: string[];
|
||||
/** Gateway client capabilities when the message originates from the gateway. */
|
||||
GatewayClientCaps?: string[];
|
||||
/** Run-scoped plugin tool bindings; never rendered into prompt text. */
|
||||
GatewayRunToolBindings?: Readonly<Record<string, unknown>>;
|
||||
/** Gateway device id allowed to review approvals initiated by this turn. */
|
||||
ApprovalReviewerDeviceId?: string;
|
||||
/** Thread identifier (Telegram topic id or Matrix thread event id). */
|
||||
|
||||
@@ -562,6 +562,7 @@ describe("abortChatRunsForProvider", () => {
|
||||
state: "aborted",
|
||||
stopReason: "auth-revoked",
|
||||
}),
|
||||
{ sessionKeys: [sessionKey] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,7 +392,11 @@ export type ChatAbortOps = {
|
||||
) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined;
|
||||
agentRunSeq: Map<string, number>;
|
||||
getRuntimeConfig?: () => OpenClawConfig;
|
||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||
broadcast: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: { dropIfSlow?: boolean; sessionKeys?: readonly string[] },
|
||||
) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -484,12 +488,9 @@ function broadcastChatAborted(
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
ops.broadcast("chat", payload);
|
||||
for (const deliverySessionKey of resolveChatAbortDeliverySessionKeys(
|
||||
ops,
|
||||
sessionKey,
|
||||
payloadAgentId,
|
||||
)) {
|
||||
const deliverySessionKeys = resolveChatAbortDeliverySessionKeys(ops, sessionKey, payloadAgentId);
|
||||
ops.broadcast("chat", payload, { sessionKeys: deliverySessionKeys });
|
||||
for (const deliverySessionKey of deliverySessionKeys) {
|
||||
ops.nodeSendToSession(deliverySessionKey, "chat", payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { RequestFrame } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
onDiagnosticEvent,
|
||||
@@ -24,6 +29,10 @@ import {
|
||||
} from "./node-command-policy.js";
|
||||
import type { SerializedEventPayload } from "./node-registry.js";
|
||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||
import {
|
||||
createSessionEventSubscriberRegistry,
|
||||
createSessionMessageSubscriberRegistry,
|
||||
} from "./server-chat-state.js";
|
||||
import { createChatRunRegistry } from "./server-chat.js";
|
||||
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||
import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js";
|
||||
@@ -412,6 +421,47 @@ describe("gateway broadcaster", () => {
|
||||
expect(workerSocket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers scoped client events only for gateway-owned session subscriptions", () => {
|
||||
const legacySocket = makeRecordingSocket();
|
||||
const firstSocket = makeRecordingSocket();
|
||||
const secondSocket = makeRecordingSocket();
|
||||
const legacy = makeOperatorWsClient("legacy", legacySocket, ["operator.read"]);
|
||||
const first = makeOperatorWsClient("first", firstSocket, ["operator.read"]);
|
||||
const second = makeOperatorWsClient("second", secondSocket, ["operator.read"]);
|
||||
first.connect.caps = [
|
||||
GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS,
|
||||
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
|
||||
];
|
||||
second.connect.caps = [];
|
||||
second.connect.client = {
|
||||
id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT,
|
||||
version: "test",
|
||||
platform: "chrome",
|
||||
mode: GATEWAY_CLIENT_MODES.UI,
|
||||
};
|
||||
const clients = new Set([legacy, first, second]);
|
||||
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry();
|
||||
sessionMessageSubscribers.subscribe(first.connId, "session-a");
|
||||
sessionMessageSubscribers.subscribe(second.connId, "session-b");
|
||||
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({
|
||||
clients,
|
||||
sessionMessageSubscribers,
|
||||
});
|
||||
|
||||
broadcast("chat", { sessionKey: "session-a" }, { sessionKeys: ["session-a"] });
|
||||
broadcast("agent", { stream: "lifecycle" });
|
||||
broadcastToConnIds("agent", { sessionKey: "session-a" }, new Set([first.connId]), {
|
||||
sessionKeys: ["session-a"],
|
||||
});
|
||||
broadcastToConnIds("agent", { sessionKey: "session-a" }, new Set([second.connId]), {
|
||||
sessionKeys: ["session-a"],
|
||||
});
|
||||
|
||||
expect(sentEvents(legacySocket)).toEqual(["chat", "agent"]);
|
||||
expect(sentEvents(firstSocket)).toEqual(["chat", "agent"]);
|
||||
expect(sentEvents(secondSocket)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters approval and pairing events by scope", () => {
|
||||
const approvalsSocket: TestSocket = {
|
||||
bufferedAmount: 0,
|
||||
@@ -861,7 +911,11 @@ describe("node subscription manager", () => {
|
||||
};
|
||||
const parseSpy = vi.spyOn(JSON, "parse");
|
||||
try {
|
||||
const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() });
|
||||
const runtime = createGatewayNodeSessionRuntime({
|
||||
broadcast: vi.fn(),
|
||||
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
|
||||
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
|
||||
});
|
||||
runtime.nodeRegistry.register(
|
||||
makeGatewayWsClient("conn-node-a", socket, {
|
||||
role: "node",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Browser origin tests document same-origin, private-network, loopback, forwarded
|
||||
// host, and explicit allowlist decisions for gateway browser surfaces.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkBrowserOrigin } from "./origin-check.js";
|
||||
import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "./origin-check.js";
|
||||
|
||||
describe("checkBrowserOrigin", () => {
|
||||
it.each([
|
||||
@@ -173,4 +173,14 @@ describe("checkBrowserOrigin", () => {
|
||||
reason: "origin missing or invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes only canonical Chrome extension origins", () => {
|
||||
expect(
|
||||
normalizeChromeExtensionOrigin("chrome-extension://abcdefghijklmnopabcdefghijklmnop"),
|
||||
).toBe("chrome-extension://abcdefghijklmnopabcdefghijklmnop");
|
||||
expect(normalizeChromeExtensionOrigin("chrome-extension://abc")).toBeUndefined();
|
||||
expect(
|
||||
normalizeChromeExtensionOrigin("https://abcdefghijklmnopabcdefghijklmnop"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ type OriginCheckResult =
|
||||
|
||||
function parseOrigin(
|
||||
originRaw?: string,
|
||||
): { origin: string; host: string; hostname: string } | null {
|
||||
): { origin: string; protocol: string; host: string; hostname: string } | null {
|
||||
const trimmed = (originRaw ?? "").trim();
|
||||
if (!trimmed || trimmed === "null") {
|
||||
return null;
|
||||
@@ -35,6 +35,7 @@ function parseOrigin(
|
||||
const origin = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin;
|
||||
return {
|
||||
origin: normalizeLowercaseStringOrEmpty(origin),
|
||||
protocol: normalizeLowercaseStringOrEmpty(url.protocol),
|
||||
host: normalizeLowercaseStringOrEmpty(url.host),
|
||||
hostname: normalizeLowercaseStringOrEmpty(url.hostname),
|
||||
};
|
||||
@@ -43,6 +44,14 @@ function parseOrigin(
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a canonical Chrome extension origin for pairing-bound authorization. */
|
||||
export function normalizeChromeExtensionOrigin(originRaw?: string): string | undefined {
|
||||
const parsed = parseOrigin(originRaw);
|
||||
return parsed?.protocol === "chrome-extension:" && /^[a-p]{32}$/u.test(parsed.hostname)
|
||||
? parsed.origin
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Validate a browser Origin against explicit allowlist, same-host, and local dev rules. */
|
||||
export function checkBrowserOrigin(params: {
|
||||
requestHost?: string;
|
||||
|
||||
@@ -8,6 +8,8 @@ type GatewayBroadcastStateVersion = {
|
||||
/** Options for gateway websocket broadcasts. */
|
||||
export type GatewayBroadcastOpts = {
|
||||
dropIfSlow?: boolean;
|
||||
/** Canonical subscription keys for session-scoped delivery. */
|
||||
sessionKeys?: readonly string[];
|
||||
stateVersion?: GatewayBroadcastStateVersion;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
hasGatewayClientCap,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
// Gateway WebSocket broadcaster.
|
||||
// Applies event scope guards and slow-consumer handling before sending frames.
|
||||
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
|
||||
import { isBrowserCopilotClient } from "../utils/message-channel.js";
|
||||
import {
|
||||
ADMIN_SCOPE,
|
||||
APPROVALS_SCOPE,
|
||||
@@ -17,6 +22,7 @@ import type {
|
||||
GatewayPluginEventBroadcastFn,
|
||||
GatewayPluginEventScope,
|
||||
} from "./server-broadcast-types.js";
|
||||
import type { SessionMessageSubscriberRegistry } from "./server-chat-state.js";
|
||||
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
|
||||
@@ -73,6 +79,10 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
// (e.g. reconfiguring wake-word triggers).
|
||||
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 {
|
||||
// Serialize one field through JSON.stringify so embedded values keep JSON
|
||||
// escaping, then splice it into the shared per-client frame body.
|
||||
@@ -134,7 +144,10 @@ function hasEventScope(
|
||||
return required.some((scope) => scopes.includes(scope));
|
||||
}
|
||||
|
||||
export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
|
||||
export function createGatewayBroadcaster(params: {
|
||||
clients: Set<GatewayWsClient>;
|
||||
sessionMessageSubscribers?: SessionMessageSubscriberRegistry;
|
||||
}) {
|
||||
const clientSeq = new WeakMap<GatewayWsClient, number>();
|
||||
const reportedSlowPayloadClients = new WeakSet<GatewayWsClient>();
|
||||
|
||||
@@ -191,6 +204,19 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
|
||||
if (!hasEventScope(c, event, explicitPluginScope)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(isBrowserCopilotClient(c.connect.client) ||
|
||||
hasGatewayClientCap(c.connect.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS)) &&
|
||||
SESSION_SUBSCRIPTION_EVENTS.has(event) &&
|
||||
(!opts?.sessionKeys?.length ||
|
||||
!opts.sessionKeys.some((sessionKey) =>
|
||||
params.sessionMessageSubscribers?.get(sessionKey).has(c.connId),
|
||||
))
|
||||
) {
|
||||
// Scoped clients opt out of legacy broadcast fanout. The server-side
|
||||
// subscription registry is the authority, so client filtering cannot leak a sibling tab.
|
||||
continue;
|
||||
}
|
||||
const nextSeq = (clientSeq.get(c) ?? 0) + 1;
|
||||
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
|
||||
if (!slow) {
|
||||
|
||||
@@ -2092,6 +2092,9 @@ describe("agent event handler", () => {
|
||||
expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual(
|
||||
new Set(["conn-run"]),
|
||||
);
|
||||
expect(requireMockArg(broadcastToConnIds, 0, 3, "run tool options")).toEqual({
|
||||
sessionKeys: ["session-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("projects tool-search bridge calls like native channel verbose tool events", () => {
|
||||
|
||||
+49
-31
@@ -36,6 +36,7 @@ import {
|
||||
resolveMergedAssistantText,
|
||||
shouldSuppressAssistantEventForLiveChat,
|
||||
} from "./live-chat-projector.js";
|
||||
import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js";
|
||||
import { isChatAbortMarkerCurrent } from "./server-chat-state.js";
|
||||
import type {
|
||||
BufferedAgentEvent,
|
||||
@@ -203,11 +204,7 @@ function normalizeHeartbeatChatFinalText(params: {
|
||||
*/
|
||||
const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000;
|
||||
|
||||
export type ChatEventBroadcast = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: { dropIfSlow?: boolean },
|
||||
) => void;
|
||||
export type ChatEventBroadcast = GatewayBroadcastFn;
|
||||
|
||||
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
|
||||
|
||||
@@ -305,12 +302,7 @@ function resolveBroadcastDelta(params: {
|
||||
|
||||
export type AgentEventHandlerOptions = {
|
||||
broadcast: ChatEventBroadcast;
|
||||
broadcastToConnIds: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
connIds: ReadonlySet<string>,
|
||||
opts?: { dropIfSlow?: boolean },
|
||||
) => void;
|
||||
broadcastToConnIds: GatewayBroadcastToConnIdsFn;
|
||||
nodeSendToSession: NodeSendToSession;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatRunState: ChatRunState;
|
||||
@@ -1039,13 +1031,19 @@ export function createAgentEventHandler({
|
||||
) => {
|
||||
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
|
||||
if (opts?.controlUiVisible ?? true) {
|
||||
broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow });
|
||||
broadcast("chat", payload, {
|
||||
dropIfSlow: opts?.dropIfSlow,
|
||||
sessionKeys: [deliverySessionKey],
|
||||
});
|
||||
sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId);
|
||||
return;
|
||||
}
|
||||
const recipients = sessionMessageSubscribers.get(deliverySessionKey);
|
||||
if (recipients.size > 0) {
|
||||
broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow });
|
||||
broadcastToConnIds("chat", payload, recipients, {
|
||||
dropIfSlow: opts?.dropIfSlow,
|
||||
sessionKeys: [deliverySessionKey],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1122,7 +1120,11 @@ export function createAgentEventHandler({
|
||||
opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean },
|
||||
) => {
|
||||
if (opts?.controlUiVisible ?? true) {
|
||||
broadcast("agent", payload);
|
||||
broadcast("agent", payload, {
|
||||
sessionKeys: sessionKey
|
||||
? [resolveSessionDeliveryKey(sessionKey, opts?.agentId)]
|
||||
: undefined,
|
||||
});
|
||||
if (sessionKey) {
|
||||
sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId);
|
||||
}
|
||||
@@ -1131,11 +1133,13 @@ export function createAgentEventHandler({
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
const recipients = sessionMessageSubscribers.get(
|
||||
resolveSessionDeliveryKey(sessionKey, opts?.agentId),
|
||||
);
|
||||
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
|
||||
const recipients = sessionMessageSubscribers.get(deliverySessionKey);
|
||||
if (recipients.size > 0) {
|
||||
broadcastToConnIds("agent", payload, recipients, { dropIfSlow: opts?.dropIfSlow });
|
||||
broadcastToConnIds("agent", payload, recipients, {
|
||||
dropIfSlow: opts?.dropIfSlow,
|
||||
sessionKeys: [deliverySessionKey],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1376,19 +1380,27 @@ export function createAgentEventHandler({
|
||||
: agentPayload;
|
||||
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
|
||||
flushBufferedAgentDeltaIfNeeded(clientRunId);
|
||||
broadcast("agent", {
|
||||
runId: eventRunId,
|
||||
stream: "error",
|
||||
ts: Date.now(),
|
||||
sessionKey,
|
||||
...(spawnedBy && { spawnedBy }),
|
||||
...(isHeartbeat !== undefined && { isHeartbeat }),
|
||||
data: {
|
||||
reason: "seq gap",
|
||||
expected: last + 1,
|
||||
received: evt.seq,
|
||||
broadcast(
|
||||
"agent",
|
||||
{
|
||||
runId: eventRunId,
|
||||
stream: "error",
|
||||
ts: Date.now(),
|
||||
sessionKey,
|
||||
...(spawnedBy && { spawnedBy }),
|
||||
...(isHeartbeat !== undefined && { isHeartbeat }),
|
||||
data: {
|
||||
reason: "seq gap",
|
||||
expected: last + 1,
|
||||
received: evt.seq,
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
sessionKeys: sessionKey
|
||||
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
if (evt.stream === "assistant") {
|
||||
@@ -1438,7 +1450,8 @@ export function createAgentEventHandler({
|
||||
// Always broadcast tool events to registered WS recipients with
|
||||
// tool-events capability, regardless of verboseLevel. The verbose
|
||||
// setting only controls whether tool details are sent as channel
|
||||
// messages to messaging surfaces (Telegram, Discord, etc.).
|
||||
// messages to messaging surfaces (Telegram, Discord, etc.). Carry the
|
||||
// delivery key so scoped clients must also own the session subscription.
|
||||
const runToolRecipients = toolEventRecipients.get(evt.runId);
|
||||
if (
|
||||
isControlUiVisible &&
|
||||
@@ -1455,6 +1468,11 @@ export function createAgentEventHandler({
|
||||
}
|
||||
: agentPayload,
|
||||
runToolRecipients,
|
||||
{
|
||||
sessionKeys: sessionKey
|
||||
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
|
||||
|
||||
@@ -698,6 +698,7 @@ describe("createGatewayCloseHandler", () => {
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "run-1", state: "aborted", stopReason: "restart" }),
|
||||
{ sessionKeys: ["session-1"] },
|
||||
);
|
||||
expect(nodeSendToSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
@@ -711,6 +712,7 @@ describe("createGatewayCloseHandler", () => {
|
||||
state: "aborted",
|
||||
stopReason: "restart",
|
||||
}),
|
||||
{ sessionKeys: ["session-1"] },
|
||||
);
|
||||
expect(nodeSendToSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
|
||||
@@ -79,7 +79,13 @@ export function broadcastChatFinal(params: {
|
||||
state: "final" as const,
|
||||
message: projectChatDisplayMessage(params.message),
|
||||
};
|
||||
params.context.broadcast("chat", payload);
|
||||
params.context.broadcast("chat", payload, {
|
||||
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
|
||||
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: payloadAgentId,
|
||||
}),
|
||||
});
|
||||
sendGlobalAwareNodeChatPayload({
|
||||
context: params.context,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -114,7 +120,13 @@ export function broadcastSideResult(params: {
|
||||
...(payloadAgentId ? { agentId: payloadAgentId } : {}),
|
||||
seq,
|
||||
};
|
||||
params.context.broadcast("chat.side_result", payload);
|
||||
params.context.broadcast("chat.side_result", payload, {
|
||||
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
|
||||
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
|
||||
sessionKey: params.payload.sessionKey,
|
||||
agentId: payloadAgentId,
|
||||
}),
|
||||
});
|
||||
sendGlobalAwareNodeChatPayload({
|
||||
context: params.context,
|
||||
sessionKey: params.payload.sessionKey,
|
||||
@@ -159,7 +171,13 @@ export function broadcastChatError(params: {
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
params.context.broadcast("chat", payload);
|
||||
params.context.broadcast("chat", payload, {
|
||||
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
|
||||
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: payloadAgentId,
|
||||
}),
|
||||
});
|
||||
sendGlobalAwareNodeChatPayload({
|
||||
context: params.context,
|
||||
sessionKey: params.sessionKey,
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("createChatSendDispatchErrorLifecycle", () => {
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "run-1", state: "final" }),
|
||||
{ sessionKeys: ["agent:main:main"] },
|
||||
);
|
||||
expect(cleanupAdmittedRun).toHaveBeenCalledOnce();
|
||||
expect(removeChatRun).toHaveBeenCalledWith("run-1", "run-1", "agent:main:main");
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeChatSendRequest } from "./chat-send-request.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
function copilotClient(caps: string[] = []): NonNullable<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> = {}) {
|
||||
return {
|
||||
@@ -77,4 +96,50 @@ describe("normalizeChatSendRequest", () => {
|
||||
error: "system provenance fields require admin scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires capable copilot runs to carry explicit tool bindings", () => {
|
||||
expect(normalizeChatSendRequest({ params: validParams(), client: copilotClient() })).toEqual({
|
||||
ok: false,
|
||||
error: "browser copilot runs require an explicit browser tool binding",
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeChatSendRequest({
|
||||
params: validParams({ toolBindings: { unrelated: true } }),
|
||||
client: copilotClient(["run-tool-bindings"]),
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "browser copilot runs require an explicit browser tool binding",
|
||||
});
|
||||
|
||||
const toolBindings = { browser: { kind: "tab", tabId: 1, targetId: "target" } };
|
||||
expect(
|
||||
normalizeChatSendRequest({
|
||||
params: validParams({ toolBindings }),
|
||||
client: copilotClient(),
|
||||
}),
|
||||
).toEqual({ ok: false, error: "run tool bindings require client capability" });
|
||||
expect(
|
||||
normalizeChatSendRequest({
|
||||
params: validParams({ toolBindings }),
|
||||
client: copilotClient(["run-tool-bindings"]),
|
||||
}),
|
||||
).toMatchObject({ ok: true, value: { p: { toolBindings } } });
|
||||
});
|
||||
|
||||
it("accepts tool bindings only from a server-paired copilot identity", () => {
|
||||
const toolBindings = { browser: { kind: "tab", tabId: 1, targetId: "target" } };
|
||||
const unpaired = copilotClient(["run-tool-bindings"]);
|
||||
unpaired.pairedClientId = undefined;
|
||||
expect(
|
||||
normalizeChatSendRequest({ params: validParams({ toolBindings }), client: unpaired }),
|
||||
).toEqual({ ok: false, error: "run tool bindings require a paired browser copilot" });
|
||||
|
||||
const otherClient = copilotClient(["run-tool-bindings"]);
|
||||
otherClient.connect.client.id = "openclaw-control-ui";
|
||||
expect(
|
||||
normalizeChatSendRequest({ params: validParams({ toolBindings }), client: otherClient }),
|
||||
).toEqual({ ok: false, error: "run tool bindings require a paired browser copilot" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isBtwRequestText } from "../../auto-reply/reply/btw-command.js";
|
||||
import type { QueueMode } from "../../auto-reply/reply/queue/types.js";
|
||||
import type { InputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { normalizeInputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { isOperatorUiClient } from "../../utils/message-channel.js";
|
||||
import { isBrowserCopilotClient, isOperatorUiClient } from "../../utils/message-channel.js";
|
||||
import { isChatStopCommandText } from "../chat-abort.js";
|
||||
import type { ChatAttachment } from "../chat-attachments.js";
|
||||
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
|
||||
@@ -47,6 +47,7 @@ type ChatSendRequestParams = {
|
||||
fileName?: string;
|
||||
content?: unknown;
|
||||
}>;
|
||||
toolBindings?: Record<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
systemInputProvenance?: InputProvenance;
|
||||
systemProvenanceReceipt?: string;
|
||||
@@ -65,6 +66,7 @@ export type NormalizedChatSendRequest = {
|
||||
systemInputProvenance?: InputProvenance;
|
||||
systemProvenanceReceipt?: string;
|
||||
suppressCommandInterpretation: boolean;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
stopCommand: boolean;
|
||||
turnKind: "btw" | "main";
|
||||
normalizedAttachments: ChatAttachment[];
|
||||
@@ -82,7 +84,8 @@ export function normalizeChatSendRequest(params: {
|
||||
client: GatewayRequestHandlerOptions["client"];
|
||||
}): NormalizeChatSendRequestResult {
|
||||
const chatSendReceivedAtMs = performance.now();
|
||||
const clientInfo = params.client?.connect?.client;
|
||||
const client = params.client;
|
||||
const clientInfo = client?.connect?.client;
|
||||
const supportsTaskSuggestions =
|
||||
isOperatorUiClient(clientInfo) &&
|
||||
params.client?.connect?.scopes?.includes("operator.admin") === true &&
|
||||
@@ -135,6 +138,27 @@ export function normalizeChatSendRequest(params: {
|
||||
const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance);
|
||||
const systemProvenanceReceipt = systemReceiptResult.receipt;
|
||||
const stopCommand = !suppressCommandInterpretation && isChatStopCommandText(inboundMessage);
|
||||
if (p.toolBindings) {
|
||||
if (
|
||||
!client ||
|
||||
!isBrowserCopilotClient(clientInfo) ||
|
||||
client.pairedClientId !== clientInfo?.id
|
||||
) {
|
||||
return { ok: false, error: "run tool bindings require a paired browser copilot" };
|
||||
}
|
||||
if (!hasGatewayClientCap(client.connect.caps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS)) {
|
||||
return { ok: false, error: "run tool bindings require client capability" };
|
||||
}
|
||||
}
|
||||
if (
|
||||
isBrowserCopilotClient(clientInfo) &&
|
||||
!stopCommand &&
|
||||
(!p.toolBindings || !Object.hasOwn(p.toolBindings, "browser"))
|
||||
) {
|
||||
return { ok: false, error: "browser copilot runs require an explicit browser tool binding" };
|
||||
}
|
||||
// The browser plugin owns the binding schema and validates it while tools are
|
||||
// constructed, before model execution. Gateway owns only paired-client admission.
|
||||
const turnKind =
|
||||
!suppressCommandInterpretation && isBtwRequestText(inboundMessage) ? "btw" : "main";
|
||||
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments);
|
||||
@@ -155,6 +179,7 @@ export function normalizeChatSendRequest(params: {
|
||||
systemInputProvenance,
|
||||
systemProvenanceReceipt,
|
||||
suppressCommandInterpretation,
|
||||
toolBindings: p.toolBindings,
|
||||
stopCommand,
|
||||
turnKind,
|
||||
normalizedAttachments,
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("prepareChatSendUserTurn", () => {
|
||||
suppressCommandInterpretation: false,
|
||||
systemInputProvenance: { kind: "internal_system", sourceTool: "test" },
|
||||
systemProvenanceReceipt: "[System receipt]",
|
||||
toolBindings: { browser: { kind: "tab", targetId: "target-1" } },
|
||||
},
|
||||
session: {
|
||||
agentId: "main",
|
||||
@@ -104,6 +105,7 @@ describe("prepareChatSendUserTurn", () => {
|
||||
body: "/status",
|
||||
},
|
||||
InputProvenance: { kind: "internal_system", sourceTool: "test" },
|
||||
GatewayRunToolBindings: { browser: { kind: "tab", targetId: "target-1" } },
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:1",
|
||||
AccountId: "account-1",
|
||||
|
||||
@@ -112,6 +112,7 @@ function buildChatSendMessageContext(params: {
|
||||
suppressCommandInterpretation: boolean;
|
||||
systemInputProvenance?: InputProvenance;
|
||||
systemProvenanceReceipt?: string;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
}) {
|
||||
const commandBody = params.parsedMessage;
|
||||
const commandSource =
|
||||
@@ -175,6 +176,7 @@ function buildChatSendMessageContext(params: {
|
||||
: {}),
|
||||
GatewayClientScopes: params.client?.connect?.scopes ?? [],
|
||||
GatewayClientCaps: params.client?.connect?.caps ?? [],
|
||||
GatewayRunToolBindings: params.toolBindings,
|
||||
};
|
||||
if (params.mediaPathOffloadPaths.length > 0) {
|
||||
// Pre-staged offloads must use the channel media fields and marker so the
|
||||
@@ -203,6 +205,7 @@ export function prepareChatSendUserTurn(params: {
|
||||
| "suppressCommandInterpretation"
|
||||
| "systemInputProvenance"
|
||||
| "systemProvenanceReceipt"
|
||||
| "toolBindings"
|
||||
>;
|
||||
session: Pick<PreparedChatSendSession, "agentId" | "clientRunId" | "sessionKey">;
|
||||
admission: Pick<AdmittedChatSend, "originatingRoute">;
|
||||
@@ -259,6 +262,7 @@ export function prepareChatSendUserTurn(params: {
|
||||
suppressCommandInterpretation: request.suppressCommandInterpretation,
|
||||
systemInputProvenance: request.systemInputProvenance,
|
||||
systemProvenanceReceipt: request.systemProvenanceReceipt,
|
||||
toolBindings: request.toolBindings,
|
||||
});
|
||||
const mediaPathOffloadsIncludeImages = attachments.mediaPathOffloadTypes.some((type) =>
|
||||
type.startsWith("image/"),
|
||||
|
||||
@@ -175,6 +175,7 @@ describe("chat.send error broadcast", () => {
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{ sessionKeys: ["agent:main:main"] },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -211,6 +212,7 @@ describe("chat.send error broadcast", () => {
|
||||
agentId: "main",
|
||||
state: "error",
|
||||
}),
|
||||
{ sessionKeys: ["agent:main:global", "global"] },
|
||||
);
|
||||
expect(ctx.nodeSendToSession).toHaveBeenCalledWith(
|
||||
"agent:main:global",
|
||||
|
||||
@@ -1711,7 +1711,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
state: "final" as const,
|
||||
message,
|
||||
};
|
||||
context.broadcast("chat", chatPayload);
|
||||
context.broadcast("chat", chatPayload, {
|
||||
sessionKeys: sessionKey === "global" && agentId ? [`agent:${agentId}:global`] : [sessionKey],
|
||||
});
|
||||
sendGlobalAwareNodeChatPayload({
|
||||
context,
|
||||
sessionKey,
|
||||
|
||||
@@ -1270,6 +1270,7 @@ describe("models.authLogout", () => {
|
||||
state: "aborted",
|
||||
stopReason: "auth-revoked",
|
||||
}),
|
||||
{ sessionKeys: [openrouterRun.sessionKey] },
|
||||
);
|
||||
const [, payload] = firstRespondCall(opts) ?? [];
|
||||
expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]);
|
||||
|
||||
@@ -64,6 +64,8 @@ export type GatewayClient = {
|
||||
connect: ConnectParams;
|
||||
connId?: string;
|
||||
clientIp?: string;
|
||||
/** Client id verified against the server-approved device pairing record. */
|
||||
pairedClientId?: string;
|
||||
pluginSurfaceUrls?: Record<string, string>;
|
||||
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
||||
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
type NodeRegistryOptions,
|
||||
type SerializedEventPayload,
|
||||
} from "./node-registry.js";
|
||||
import {
|
||||
createSessionEventSubscriberRegistry,
|
||||
createSessionMessageSubscriberRegistry,
|
||||
import type {
|
||||
SessionEventSubscriberRegistry,
|
||||
SessionMessageSubscriberRegistry,
|
||||
} from "./server-chat-state.js";
|
||||
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
|
||||
import { hasConnectedTalkNode } from "./server-talk-nodes.js";
|
||||
@@ -20,6 +20,8 @@ export function createGatewayNodeSessionRuntime(params: {
|
||||
listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"];
|
||||
nodePluginToolsEnabled?: boolean;
|
||||
nodeSkillsEnabled?: boolean;
|
||||
sessionEventSubscribers: SessionEventSubscriberRegistry;
|
||||
sessionMessageSubscribers: SessionMessageSubscriberRegistry;
|
||||
}) {
|
||||
const nodeRegistry = new NodeRegistry({
|
||||
listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands,
|
||||
@@ -28,8 +30,8 @@ export function createGatewayNodeSessionRuntime(params: {
|
||||
});
|
||||
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
const nodeSubscriptions = createNodeSubscriptionManager();
|
||||
const sessionEventSubscribers = createSessionEventSubscriberRegistry();
|
||||
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry();
|
||||
const sessionEventSubscribers = params.sessionEventSubscribers;
|
||||
const sessionMessageSubscribers = params.sessionMessageSubscribers;
|
||||
const nodeSendEvent = (opts: {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
type ChatRunEntry,
|
||||
type ChatRunRegistration,
|
||||
createChatRunState,
|
||||
createSessionEventSubscriberRegistry,
|
||||
createSessionMessageSubscriberRegistry,
|
||||
createToolEventRecipientRegistry,
|
||||
} from "./server-chat-state.js";
|
||||
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
|
||||
@@ -149,6 +151,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
chatQueuedTurns: Map<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>;
|
||||
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
|
||||
sessionEventSubscribers: ReturnType<typeof createSessionEventSubscriberRegistry>;
|
||||
sessionMessageSubscribers: ReturnType<typeof createSessionMessageSubscriberRegistry>;
|
||||
getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined;
|
||||
getMcpAppSandboxPort: () => number | undefined;
|
||||
}> {
|
||||
@@ -163,7 +167,9 @@ export async function createGatewayRuntimeState(params: {
|
||||
const resolvePluginRouteRegistry = () =>
|
||||
params.getPluginRouteRegistry?.() ?? params.pluginRegistry;
|
||||
const clients = new Set<GatewayWsClient>();
|
||||
const gatewayBroadcaster = createGatewayBroadcaster({ clients });
|
||||
const sessionEventSubscribers = createSessionEventSubscriberRegistry();
|
||||
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry();
|
||||
const gatewayBroadcaster = createGatewayBroadcaster({ clients, sessionMessageSubscribers });
|
||||
|
||||
let loadedHooksRequestHandler: HooksRequestHandler | null = null;
|
||||
const handleHooksRequest: HooksRequestHandler = async (req, res) => {
|
||||
@@ -478,6 +484,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
chatAbortControllers,
|
||||
chatQueuedTurns,
|
||||
toolEventRecipients,
|
||||
sessionEventSubscribers,
|
||||
sessionMessageSubscribers,
|
||||
getWorkerIngressEndpoint: () =>
|
||||
workerIngressPort === undefined
|
||||
? undefined
|
||||
|
||||
@@ -4036,6 +4036,7 @@ describe("gateway server chat", () => {
|
||||
expect(broadcast).not.toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "idem-queued-followup", state: "final" }),
|
||||
expect.anything(),
|
||||
);
|
||||
dispatchRelease.resolve();
|
||||
await waitForFast(() => {
|
||||
@@ -4046,6 +4047,7 @@ describe("gateway server chat", () => {
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
}),
|
||||
{ sessionKeys: ["agent:main:main"] },
|
||||
);
|
||||
}, FAST_WAIT_OPTS);
|
||||
const finalEvents = broadcast.mock.calls.filter(
|
||||
@@ -4465,6 +4467,7 @@ describe("gateway server chat", () => {
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{ sessionKeys: ["agent:main:main"] },
|
||||
);
|
||||
},
|
||||
{ timeout: 2_000, interval: 5 },
|
||||
|
||||
@@ -1150,6 +1150,8 @@ export async function startGatewayServer(
|
||||
chatAbortControllers,
|
||||
chatQueuedTurns,
|
||||
toolEventRecipients,
|
||||
sessionEventSubscribers,
|
||||
sessionMessageSubscribers,
|
||||
getWorkerIngressEndpoint,
|
||||
getMcpAppSandboxPort,
|
||||
} = await startupTrace.measure("runtime.state", () =>
|
||||
@@ -1192,8 +1194,6 @@ export async function startGatewayServer(
|
||||
const {
|
||||
nodeRegistry,
|
||||
nodePresenceTimers,
|
||||
sessionEventSubscribers,
|
||||
sessionMessageSubscribers,
|
||||
nodeSendToSession,
|
||||
nodeSendToAllSubscribed,
|
||||
nodeSubscribe,
|
||||
@@ -1203,6 +1203,8 @@ export async function startGatewayServer(
|
||||
hasTalkNodeConnected,
|
||||
} = createGatewayNodeSessionRuntime({
|
||||
broadcast,
|
||||
sessionEventSubscribers,
|
||||
sessionMessageSubscribers,
|
||||
listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands,
|
||||
nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false,
|
||||
nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.skills?.enabled !== false,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Gateway WebSocket connect admission validates protocol, role, and browser origin.
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
hasGatewayClientCap,
|
||||
} from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
@@ -19,8 +21,12 @@ import {
|
||||
GATEWAY_STARTUP_PENDING_CLOSE_CAUSE,
|
||||
GATEWAY_STARTUP_RETRY_AFTER_MS,
|
||||
} from "../../../../packages/gateway-protocol/src/startup-unavailable.js";
|
||||
import { isBrowserOperatorUiClient, isOperatorUiClient } from "../../../utils/message-channel.js";
|
||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||
import {
|
||||
isBrowserCopilotClient,
|
||||
isBrowserOperatorUiClient,
|
||||
isOperatorUiClient,
|
||||
} from "../../../utils/message-channel.js";
|
||||
import { checkBrowserOrigin, normalizeChromeExtensionOrigin } from "../../origin-check.js";
|
||||
import { parseGatewayRole } from "../../role-policy.js";
|
||||
import { formatForLog } from "../../ws-log.js";
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
@@ -143,7 +149,42 @@ export async function admitGatewayConnect(context: GatewayConnectPhaseContext) {
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const isControlUi = isOperatorUiClient(connectParams.client);
|
||||
const isBrowserCopilot = isBrowserCopilotClient(connectParams.client);
|
||||
const browserCopilotOrigin = isBrowserCopilot
|
||||
? normalizeChromeExtensionOrigin(requestOrigin ?? undefined)
|
||||
: undefined;
|
||||
if (
|
||||
isBrowserCopilot &&
|
||||
(connectParams.client.mode !== GATEWAY_CLIENT_MODES.UI ||
|
||||
!hasGatewayClientCap(connectParams.caps, GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS) ||
|
||||
!hasGatewayClientCap(connectParams.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS))
|
||||
) {
|
||||
const message =
|
||||
"browser copilot requires ui mode with run-tool-bindings and session-scoped-events capabilities";
|
||||
markHandshakeFailure("invalid-client", {
|
||||
client: connectParams.client.id,
|
||||
mode: connectParams.client.mode,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message);
|
||||
close(1008, truncateCloseReason(message));
|
||||
return undefined;
|
||||
}
|
||||
if (isBrowserCopilot && !browserCopilotOrigin) {
|
||||
const message = "browser copilot requires a canonical Chrome extension origin";
|
||||
markHandshakeFailure("origin-mismatch", {
|
||||
origin: requestOrigin ?? "n/a",
|
||||
client: connectParams.client.id,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message, {
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
|
||||
reason: "invalid browser copilot origin",
|
||||
},
|
||||
});
|
||||
close(1008, truncateCloseReason(message));
|
||||
return undefined;
|
||||
}
|
||||
const isControlUi = isOperatorUiClient(connectParams.client) && !isBrowserCopilot;
|
||||
const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
|
||||
const isWebchat = isWebchatConnect(connectParams);
|
||||
const isNativeAppUi =
|
||||
@@ -151,7 +192,13 @@ export async function admitGatewayConnect(context: GatewayConnectPhaseContext) {
|
||||
(connectParams.client.id === GATEWAY_CLIENT_IDS.MACOS_APP ||
|
||||
connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP ||
|
||||
connectParams.client.id === GATEWAY_CLIENT_IDS.ANDROID_APP);
|
||||
if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) {
|
||||
// Extension origins cannot match the gateway host. Admission validates their
|
||||
// canonical shape; device approval binds the exact origin before token issue.
|
||||
const hasCopilotExtensionOrigin = Boolean(browserCopilotOrigin);
|
||||
if (
|
||||
!hasCopilotExtensionOrigin &&
|
||||
(enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat)
|
||||
) {
|
||||
const hostHeaderOriginFallbackEnabled =
|
||||
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
|
||||
const originCheck = checkBrowserOrigin({
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
resolveBootstrapProfileScopesForRoles,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import { isBrowserCopilotClient } from "../../../utils/message-channel.js";
|
||||
import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js";
|
||||
import { shouldAutoApproveNodePairingFromTrustedCidrs } from "../../node-pairing-auto-approve.js";
|
||||
import { normalizeChromeExtensionOrigin } from "../../origin-check.js";
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
import {
|
||||
isControlUiOperatorBootstrapProfile,
|
||||
@@ -50,8 +52,16 @@ export async function authorizeGatewayConnectDevice(
|
||||
context: GatewayConnectPhaseContext,
|
||||
state: AuthenticatedGatewayConnect,
|
||||
): Promise<DeviceAuthorizedGatewayConnect | undefined> {
|
||||
const { connId, buildRequestContext, close, send, setHandshakeState, setCloseCause, logGateway } =
|
||||
context.handler;
|
||||
const {
|
||||
connId,
|
||||
buildRequestContext,
|
||||
close,
|
||||
send,
|
||||
setHandshakeState,
|
||||
setCloseCause,
|
||||
logGateway,
|
||||
requestOrigin,
|
||||
} = context.handler;
|
||||
const {
|
||||
frame,
|
||||
connectParams,
|
||||
@@ -77,6 +87,11 @@ export async function authorizeGatewayConnectDevice(
|
||||
skipControlUiPairingForDevice,
|
||||
} = state;
|
||||
let hasServerApprovedDeviceTokenBaseline = false;
|
||||
let pairedClientId: string | undefined;
|
||||
let pairedBrowserOrigin: string | undefined;
|
||||
const browserCopilotOrigin = isBrowserCopilotClient(connectParams.client)
|
||||
? normalizeChromeExtensionOrigin(requestOrigin)
|
||||
: undefined;
|
||||
if (device && devicePublicKey) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
const normalized = normalizeSortedUniqueTrimmedStringList(items);
|
||||
@@ -97,6 +112,7 @@ export async function authorizeGatewayConnectDevice(
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
clientId: connectParams.client.id,
|
||||
clientMode: connectParams.client.mode,
|
||||
...(browserCopilotOrigin ? { browserOrigin: browserCopilotOrigin } : {}),
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: reportedClientIp,
|
||||
@@ -430,6 +446,11 @@ export async function authorizeGatewayConnectDevice(
|
||||
if (!ok) {
|
||||
return undefined;
|
||||
}
|
||||
const approvedDevice = await getPairedDevice(device.id);
|
||||
pairedClientId =
|
||||
approvedDevice?.publicKey === devicePublicKey ? approvedDevice.clientId : undefined;
|
||||
pairedBrowserOrigin =
|
||||
approvedDevice?.publicKey === devicePublicKey ? approvedDevice.browserOrigin : undefined;
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
} else if (
|
||||
skipControlUiPairingForDevice ||
|
||||
@@ -438,6 +459,8 @@ export async function authorizeGatewayConnectDevice(
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
}
|
||||
} else {
|
||||
pairedClientId = paired.clientId;
|
||||
pairedBrowserOrigin = paired.browserOrigin;
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
const existingDevice = await authorizeExistingGatewayDevice({
|
||||
context,
|
||||
@@ -456,6 +479,26 @@ export async function authorizeGatewayConnectDevice(
|
||||
}
|
||||
}
|
||||
|
||||
const browserCopilotIdentityMismatch =
|
||||
pairedClientId !== connectParams.client.id &&
|
||||
(isBrowserCopilotClient(connectParams.client) ||
|
||||
isBrowserCopilotClient({ id: pairedClientId }));
|
||||
const browserCopilotOriginMismatch =
|
||||
isBrowserCopilotClient(connectParams.client) &&
|
||||
(!pairedBrowserOrigin || !browserCopilotOrigin || pairedBrowserOrigin !== browserCopilotOrigin);
|
||||
if (browserCopilotIdentityMismatch || browserCopilotOriginMismatch) {
|
||||
const message = "browser copilot requires a dedicated paired device identity";
|
||||
setHandshakeState("failed");
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.NOT_PAIRED, message),
|
||||
});
|
||||
close(1008, truncateCloseReason(message));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { deviceToken, bootstrapDeviceTokens } = await issueGatewayConnectDeviceTokens({
|
||||
state: { ...state, scopes, handoffBootstrapProfile },
|
||||
scopes,
|
||||
|
||||
@@ -14,7 +14,10 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { loadNodeHostConfig } from "../../../node-host/config.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
|
||||
import { isEphemeralGatewayClient } from "../../../utils/message-channel.js";
|
||||
import {
|
||||
isBrowserCopilotClient,
|
||||
isEphemeralGatewayClient,
|
||||
} from "../../../utils/message-channel.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
|
||||
import { APPROVALS_SCOPE } from "../../method-scopes.js";
|
||||
@@ -214,6 +217,9 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
connId,
|
||||
connectionKind: "gateway",
|
||||
isDeviceTokenAuth: authMethod === "device-token",
|
||||
pairedClientId: isBrowserCopilotClient(connectParams.client)
|
||||
? connectParams.client.id
|
||||
: undefined,
|
||||
usesSharedGatewayAuth: sessionUsesSharedGatewayAuth,
|
||||
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
|
||||
presenceKey,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user