openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
648 lines (610 loc) • 20.4 kB
JavaScript
import {
createNativeBootstrapController,
discardRetiredCopilotState,
prepareRetiredCopilotState,
requestRelayEnsure,
} from "./modules/native-bootstrap.js";
import { createPopupMessageHandler } from "./modules/popup-background.js";
import { createRelayCommandHandler } from "./modules/relay-command-handler.js";
import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js";
// OpenClaw extension service worker.
//
// Thin transport between the OpenClaw extension relay (loopback WebSocket) and
// chrome.debugger. All CDP target synthesis lives server-side in the relay
// bridge; this worker owns tab eligibility/access and forwards allowed frames.
// The OpenClaw tab group is the ACL in selected mode and an ownership marker
// in all-tabs mode.
import {
ACCESS_MODE_SELECTED,
createPairingConfigStore,
directLoopbackRelayPort,
reconnectDelayMs,
toRelayTabInfo,
} from "./modules/relay-core.js";
import { createRelayDebugger } from "./modules/relay-debugger.js";
import { isTabSelected } from "./modules/relay-tab-groups.js";
import { registerTabAccessEvents } from "./modules/tab-access-events.js";
import { createTabAccessPolicy } from "./modules/tab-access.js";
const BADGE = {
off: { text: "", color: "#000000" },
connecting: { text: "…", color: "#F59E0B" },
on: { text: "ON", color: "#0F9D58" },
error: { text: "!", color: "#B91C1C" },
};
const RELAY_ENSURE_MIN_INTERVAL_MS = 60_000;
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
const RELAY_AUTH_TIMEOUT_MS = 10_000;
/** @type {WebSocket|null} */
let relayWs = null;
let relayState = "off"; // off | connecting | on | error
let reconnectAttempt = 0;
let reconnectTimer = null;
let relayOpeningDeadlineAt = 0;
let relayOpeningDeadlineTimer = null;
let relayAuthenticatedSocket = null;
let relaySocketOwner = null;
let relayStatusHint = "";
let lastRelayEnsureAtMs = 0;
let reconciledPairingInvalidationRevision = 0;
let relayConnectionGeneration = 0;
let relayConnectionsSuspended = false;
let nativeBootstrap = null;
// Start blocked: no runtime path may outrun the retired-state storage read.
let retiredCopilotCustodyBlocked = true;
/** Debounce handle for tab-list refreshes. */
let tabsSyncTimer = null;
let accessMutationChain = Promise.resolve();
const pairingConfigStore = createPairingConfigStore(chrome.storage.local);
const tabAccessPolicy = createTabAccessPolicy({
isSelectedTab: isTabSelected,
getGroupColor: async () => (await getConfig()).groupColor,
});
const relayDebugger = createRelayDebugger({ policy: tabAccessPolicy, requireAutomationAllowed });
const { attachments, detach: detachDebugger } = relayDebugger;
const tabAccessReady = (async () => {
const retiredState = await prepareRetiredCopilotState();
retiredCopilotCustodyBlocked = retiredState.blocked;
const config = await pairingConfigStore.read();
await tabAccessPolicy.initialize(
config.accessMode,
Boolean(config.relayUrl) && !retiredCopilotCustodyBlocked,
);
if (retiredCopilotCustodyBlocked) {
tabAccessPolicy.setEnabled(false);
await detachAllDebuggerSessions();
}
})();
const custodyError = () =>
new Error(
"Automation is paused to protect a pre-upgrade copilot session. Open Settings to disconnect before reconnecting.",
);
async function requireAutomationAllowed() {
await tabAccessReady;
if (retiredCopilotCustodyBlocked) {
throw custodyError();
}
}
function retireRelayOwner(owner) {
void owner?.retire().catch((error) => {
console.warn("Relay debugger retirement failed", error);
});
}
function closeRelaySocket(code, reason) {
clearRelayOpeningDeadline();
const socket = relayWs;
if (!socket) {
return;
}
retireRelayOwner(relaySocketOwner);
relaySocketOwner = null;
relayWs = null;
if (relayAuthenticatedSocket === socket) {
relayAuthenticatedSocket = null;
}
socket.close(code, reason);
}
function suspendRelayConnections() {
relayConnectionsSuspended = true;
relayConnectionGeneration += 1;
}
function resumeRelayConnections() {
relayConnectionsSuspended = false;
relayConnectionGeneration += 1;
}
async function reconcilePairingInvalidation() {
if (reconciledPairingInvalidationRevision === pairingConfigStore.invalidationRevision) {
return;
}
reconciledPairingInvalidationRevision = pairingConfigStore.invalidationRevision;
await syncTabsToRelay();
closeRelaySocket();
setBadge("off");
await detachAllDebuggerSessions();
}
function setBadge(kind) {
relayState = kind;
const cfg = BADGE[kind] ?? BADGE.off;
void chrome.action.setBadgeText({ text: cfg.text });
void chrome.action.setBadgeBackgroundColor({ color: cfg.color });
}
async function getConfig() {
await tabAccessReady;
const config = await pairingConfigStore.read();
if (retiredCopilotCustodyBlocked || !config.relayUrl) {
tabAccessPolicy.setEnabled(false);
}
if (config.pairingStatusHint) {
relayStatusHint = config.pairingStatusHint;
}
return config;
}
function runAccessMutation(task) {
const pending = accessMutationChain.then(task, task);
accessMutationChain = pending.catch(() => undefined);
return pending;
}
// ---------------------------------------------------------------------------
// Tab group management (selected-mode ACL; all-mode ownership marker)
// ---------------------------------------------------------------------------
async function focusWindowForTab(tab) {
if (typeof tab.windowId === "number") {
await chrome.windows.update(tab.windowId, { focused: true });
}
}
async function removeTabFromOpenClawGroup(tabId) {
try {
await chrome.tabs.ungroup([tabId]);
} catch {
// tab may already be gone
}
}
function scheduleTabsSync() {
if (tabsSyncTimer) {
return;
}
tabsSyncTimer = setTimeout(() => {
tabsSyncTimer = null;
void syncTabsToRelay();
}, 150);
}
async function syncTabsToRelay() {
if (retiredCopilotCustodyBlocked) {
return;
}
const socket = relayWs;
if (!socket || socket.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== socket) {
return;
}
const generations = [...attachments].filter(([, record]) => !record.retired);
let accessible;
let inventoryRevision;
// A handoff can overtake even a completed read before this caller resumes.
// Publish and retire attachments only from the current inventory generation.
do {
inventoryRevision = tabAccessPolicy.discoveryRevision;
accessible = await tabAccessPolicy.listAccessibleTabs();
} while (inventoryRevision !== tabAccessPolicy.discoveryRevision);
if (
relayWs !== socket ||
relayAuthenticatedSocket !== socket ||
socket.readyState !== WebSocket.OPEN
) {
return;
}
const accessibleIds = new Set(accessible.map((tab) => tab.id));
for (const [tabId, generation] of generations) {
if (!accessibleIds.has(tabId) && attachments.get(tabId) === generation) {
void detachDebugger(tabId).catch((error) =>
console.warn("Debugger access cleanup failed", error),
);
}
}
// A creation is authorized internally, but discovery must wait for handoff.
const tabs = accessible.filter((tab) => tabAccessPolicy.canPublishTab(tab.id));
send({ type: "tabs", tabs: tabs.map(toRelayTabInfo) }, socket);
}
// ---------------------------------------------------------------------------
// chrome.debugger transport
// ---------------------------------------------------------------------------
async function detachAllDebuggerSessions() {
await relayDebugger.detachAll(retiredCopilotCustodyBlocked);
}
async function reconcileAccessMode(nextMode, { transitioning = false } = {}) {
await tabAccessReady;
const previousMode = tabAccessPolicy.mode;
const mode = tabAccessPolicy.setMode(nextMode);
if (mode === previousMode) {
if (transitioning) {
tabAccessPolicy.endTransition();
}
return mode;
}
const generations = [...attachments].filter(([, record]) => !record.retired);
await Promise.allSettled([...attachments.values()].map((record) => record.pending));
if (mode === ACCESS_MODE_SELECTED) {
const selectedIds = new Set(
(
await tabAccessPolicy.listAccessibleTabs({
allowDuringTransition: transitioning,
})
).map((tab) => tab.id),
);
await Promise.allSettled(
generations
.filter(
([tabId, generation]) => !selectedIds.has(tabId) && attachments.get(tabId) === generation,
)
.map(([tabId]) => detachDebugger(tabId)),
);
}
if (transitioning) {
tabAccessPolicy.endTransition();
}
for (const [tabId, generation] of generations) {
const epoch = tabAccessPolicy.capture(tabId);
const state = await tabAccessPolicy.inspectTab(tabId, epoch);
if (attachments.get(tabId) !== generation || !tabAccessPolicy.epochIsCurrent(tabId, epoch)) {
// A post-transition tab event owns the newer revision. Keep this
// attachment fail-closed until that handler reconciles it.
continue;
}
if (!state.accessible) {
await detachDebugger(tabId);
} else {
generation.epoch = epoch;
}
}
await syncTabsToRelay();
return mode;
}
async function pauseTab(tabId) {
// Pause records controlled-document revocation before detach retires that document.
const pausing = tabAccessPolicy.pause(tabId);
const detaching = detachDebugger(tabId);
let storageError = null;
try {
await pausing;
} catch (error) {
storageError = error;
}
await detaching;
await syncTabsToRelay();
if (storageError) {
throw storageError instanceof Error
? storageError
: new Error("Could not persist the tab pause.");
}
}
// ---------------------------------------------------------------------------
// Relay connection
// ---------------------------------------------------------------------------
function send(message, socket = relayWs) {
if (
!retiredCopilotCustodyBlocked &&
socket &&
relayWs === socket &&
socket.readyState === WebSocket.OPEN &&
relayAuthenticatedSocket === socket
) {
socket.send(JSON.stringify(message));
}
}
function clearRelayOpeningDeadline() {
relayOpeningDeadlineAt = 0;
if (relayOpeningDeadlineTimer) {
clearTimeout(relayOpeningDeadlineTimer);
relayOpeningDeadlineTimer = null;
}
void chrome.alarms.clear(RELAY_OPENING_DEADLINE_ALARM);
}
function armRelayOpeningDeadline() {
clearRelayOpeningDeadline();
relayOpeningDeadlineAt = Date.now() + RELAY_AUTH_TIMEOUT_MS;
relayOpeningDeadlineTimer = setTimeout(handleRelayOpeningDeadline, RELAY_AUTH_TIMEOUT_MS);
chrome.alarms.create(RELAY_OPENING_DEADLINE_ALARM, { when: relayOpeningDeadlineAt });
}
function failRelayAuthentication(ws, error) {
if (relayWs !== ws) {
return;
}
relayStatusHint =
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
try {
closeRelaySocket(
4001,
error instanceof Error ? error.message.slice(0, 120) : "authentication failed",
);
} catch {
ws.close();
}
setBadge("error");
scheduleReconnect();
}
async function sendHello(socket) {
let accessible;
let inventoryRevision;
do {
inventoryRevision = tabAccessPolicy.discoveryRevision;
accessible = await tabAccessPolicy.listAccessibleTabs();
} while (inventoryRevision !== tabAccessPolicy.discoveryRevision);
const uaMatch = /Chrom(?:e|ium)\/[\d.]+/.exec(navigator.userAgent);
send(
{
type: "hello",
userAgent: navigator.userAgent,
browserVersion: uaMatch ? uaMatch[0] : "Chrome/unknown",
extensionVersion: chrome.runtime.getManifest().version,
tabs: accessible.filter((tab) => tabAccessPolicy.canPublishTab(tab.id)).map(toRelayTabInfo),
},
socket,
);
}
async function connectRelay(isConnectionAllowed = () => true) {
await tabAccessReady;
if (retiredCopilotCustodyBlocked) {
tabAccessPolicy.setEnabled(false);
closeRelaySocket();
setBadge("off");
return;
}
const connectionGeneration = relayConnectionGeneration;
const connectionIsCurrent = () =>
!relayConnectionsSuspended &&
connectionGeneration === relayConnectionGeneration &&
isConnectionAllowed();
const { relayUrl, token } = await getConfig();
if (!connectionIsCurrent()) {
return;
}
await reconcilePairingInvalidation();
if (!connectionIsCurrent()) {
return;
}
if (!relayUrl || !token) {
clearRelayOpeningDeadline();
setBadge("off");
return;
}
if (
relayWs &&
(relayWs.readyState === WebSocket.OPEN || relayWs.readyState === WebSocket.CONNECTING)
) {
return;
}
// Pair revocation can race either awaited config step above. Keep the final
// cancellation check adjacent to socket creation so a stale pair cannot reconnect.
if (!connectionIsCurrent()) {
return;
}
closeRelaySocket();
void maybeEnsureRelayDaemon(relayUrl, connectionIsCurrent).catch(() => {});
setBadge("connecting");
let ws;
const owner = relayDebugger.createOwner(
() =>
connectionIsCurrent() &&
relayWs === ws &&
relayAuthenticatedSocket === ws &&
ws.readyState === WebSocket.OPEN,
);
const handleRelayCommand = createRelayCommandHandler({
send: (message) => send(message, ws),
isCurrent: owner.isCurrent,
attachDebugger: owner.attach,
detachDebugger: owner.detach,
createTab: (message, operation) => tabAccessPolicy.createTab(message, operation),
focusWindowForTab,
scheduleTabsSync,
captureDebugger: owner.capture,
captureAccess: (tabId, method) => tabAccessPolicy.capture(tabId, method),
requireAccessibleTab: owner.requireTab,
requireNavigatedTab: (tabId, epoch) => owner.requireTab(tabId, epoch, true),
navigateTab: (tabId, epoch, params, isCurrent, sendCommand) => {
const generation = attachments.get(tabId);
return tabAccessPolicy.navigateTab(
tabId,
epoch,
params,
() => (attachments.get(tabId) === generation ? generation?.epoch : undefined),
isCurrent,
sendCommand,
);
},
});
try {
ws = openAuthenticatedRelaySocket({
relayUrl,
token,
isCurrent: (socket) => relayWs === socket,
onAuthenticated: async (socket) => {
relayAuthenticatedSocket = socket;
relayStatusHint = "";
clearRelayOpeningDeadline();
reconnectAttempt = 0;
setBadge("on");
await sendHello(socket);
},
onApplicationMessage: (_socket, msg) => {
void handleRelayCommand(msg);
},
onAuthenticationFailure: (socket, error) => failRelayAuthentication(socket, error),
onClose: (socket, authenticated) => {
retireRelayOwner(owner);
if (relayWs !== socket) {
return;
}
clearRelayOpeningDeadline();
relaySocketOwner = null;
relayWs = null;
if (authenticated) {
relayAuthenticatedSocket = null;
} else if (!relayStatusHint) {
relayStatusHint =
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
}
setBadge("error");
scheduleReconnect();
},
});
} catch {
setBadge("error");
scheduleReconnect();
return;
}
relayWs = ws;
relayAuthenticatedSocket = null;
relaySocketOwner = owner;
armRelayOpeningDeadline();
// onclose follows onerror and drives the reconnect, so no error handler needed.
}
function handleRelayOpeningDeadline() {
const ws = relayWs;
if (!ws) {
clearRelayOpeningDeadline();
return;
}
if (relayAuthenticatedSocket === ws) {
clearRelayOpeningDeadline();
return;
}
if (relayOpeningDeadlineAt === 0 || Date.now() < relayOpeningDeadlineAt) {
return;
}
// Clear ownership before close so a delayed close/open event from this
// socket cannot mutate the replacement connection's badge or deadline.
try {
closeRelaySocket(4001, "relay authentication timed out");
} catch {
// The socket may have changed state while the alarm event was queued.
}
setBadge("error");
relayStatusHint = "Relay authentication v2 timed out. Make sure OpenClaw is up to date.";
scheduleReconnect();
}
/**
* On a reconnect cycle against a direct loopback relay URL, ask the native
* host (rate-limited) to spawn the standalone relay daemon so the extension
* has something to connect to without a running Gateway.
*/
async function maybeEnsureRelayDaemon(relayUrl, connectionIsCurrent) {
const relayPort = directLoopbackRelayPort(relayUrl);
if (reconnectAttempt === 0 || relayPort === null) {
return;
}
const { disabled } = await nativeBootstrap.status();
// Opt-out or pair revocation can win the storage read above.
if (disabled || retiredCopilotCustodyBlocked || !connectionIsCurrent()) {
return;
}
const now = Date.now();
if (now - lastRelayEnsureAtMs < RELAY_ENSURE_MIN_INTERVAL_MS) {
return;
}
lastRelayEnsureAtMs = now;
await requestRelayEnsure(relayPort, chrome);
}
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
const delay = reconnectDelayMs(reconnectAttempt);
reconnectAttempt += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void startAutomation();
}, delay);
}
async function startAutomation() {
await tabAccessReady;
if (retiredCopilotCustodyBlocked) {
return;
}
await nativeBootstrap.attempt();
await connectRelay();
}
// ---------------------------------------------------------------------------
// Popup messaging + lifecycle
// ---------------------------------------------------------------------------
const handlePopupMessage = createPopupMessageHandler({
pairingConfigStore,
policy: tabAccessPolicy,
accessReady: tabAccessReady,
getConfig,
getRelayState: () => relayState,
getRelayStatusHint: () => relayStatusHint,
getNativeBootstrapStatus: async () => {
await tabAccessReady;
if (!retiredCopilotCustodyBlocked) {
await nativeBootstrap.attempt();
}
return await nativeBootstrap.status();
},
enableNativeBootstrap: async (enabled) => {
await requireAutomationAllowed();
return enabled ? await nativeBootstrap.enable() : await nativeBootstrap.disableSynchronously();
},
onManualPairing: () => nativeBootstrap.enable({ attemptNow: false }),
onUnpairStart: () => nativeBootstrap.disableSynchronously(),
isRetiredCopilotCustodyBlocked: () => retiredCopilotCustodyBlocked,
requireAutomationAllowed,
discardRetiredCopilotCustody: async () => {
retiredCopilotCustodyBlocked = true;
tabAccessPolicy.setEnabled(false);
tabAccessPolicy.invalidateAll();
await discardRetiredCopilotState();
retiredCopilotCustodyBlocked = false;
},
resetRelayState: () => {
relayStatusHint = "";
reconnectAttempt = 0;
},
suspendRelayConnections,
resumeRelayConnections,
reconcilePairingInvalidation,
reconcileAccessMode,
runAccessMutation,
detachAllDebuggerSessions,
syncTabsToRelay,
closeRelaySocket,
connectRelay,
setBadge,
detachDebugger,
removeTabFromOpenClawGroup,
addTabToOpenClawGroup: (tabId) => tabAccessPolicy.addTabToGroup(tabId),
scheduleTabsSync,
pauseTab,
});
nativeBootstrap = createNativeBootstrapController({
getPairing: getConfig,
applyPairing: async (request) => await handlePopupMessage.applyPairing(request),
});
chrome.runtime.onMessage.addListener((msg, _sender, reply) => handlePopupMessage(msg, reply));
registerTabAccessEvents({
accessReady: tabAccessReady,
policy: tabAccessPolicy,
attachments,
nativeDetached: relayDebugger.nativeDetached,
send,
scheduleTabsSync,
detachDebugger,
pauseTab,
removeTabFromOpenClawGroup,
runAccessMutation,
});
// Watchdog: MV3 can stop this worker; the alarm revives it and re-connects.
chrome.alarms.create(RELAY_WATCHDOG_ALARM, { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === RELAY_WATCHDOG_ALARM) {
void startAutomation();
} else if (alarm.name === RELAY_OPENING_DEADLINE_ALARM) {
handleRelayOpeningDeadline();
}
});
chrome.runtime.onStartup.addListener(() => {
void startAutomation();
});
chrome.runtime.onInstalled.addListener(() => {
void startAutomation();
});
void startAutomation();