Files
openclaw/extensions/browser/chrome-extension/modules/copilot-background-shared.js
Peter Steinberger ec8f6e5e03 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>
2026-07-18 01:00:23 +01:00

127 lines
3.7 KiB
JavaScript

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 };