Files
openclaw/extensions/browser/chrome-extension/modules/popup-background.js
Peter Steinberger fada067277 feat(browser): add zero-click Chrome extension bootstrap (#121586)
* feat(browser): add zero-click extension bootstrap

Pre-register deterministic path-derived extension IDs and install a strict native messaging host.

Keep the popup and options UI minimal while removing the obsolete copilot and page-share flows.

* fix(browser): satisfy native bootstrap CI guards

* test(browser): isolate native bootstrap Chrome roots

* test(browser): flush native bootstrap profile before status

* test(browser): seed Linux native bootstrap identity

* fix(browser): preserve native bootstrap upgrade safety

Allow immutable root-owned package inputs while keeping mutable state, manifests, and launchers user-owned. Preserve all retired copilot keys whenever active or unrecognized recovery custody remains.

* fix(browser): preserve pending copilot custody

Retired cleanup now removes copilot state only when the durable registry is exactly empty. Any session, archive, malformed value, future shape, or read failure preserves every retired key.

* fix(browser): guard native bootstrap upgrades

Fail closed while retired copilot custody remains and make discard durable across partial failures.

Require exact launcher-embedded origins and repair full launcher drift without accepting mismatched registrations.

* fix(browser): remove stale layout export

* chore(release): leave changelog to release flow
2026-08-10 19:31:13 -07:00

304 lines
9.8 KiB
JavaScript

import {
ACCESS_MODE_ALL,
ACCESS_MODE_SELECTED,
nearestGroupColor,
parsePairingString,
} from "./relay-core.js";
import { isTabSelected } from "./relay-tab-groups.js";
function isValidTabId(value) {
return Number.isSafeInteger(value) && value >= 0;
}
function errorResponse(sendResponse, error) {
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
/** Own manual/native pairing transactions and compact popup/options messages. */
export function createPopupMessageHandler({
chromeApi = chrome,
pairingConfigStore,
policy,
accessReady,
getConfig,
getRelayState,
getRelayStatusHint,
getNativeBootstrapStatus,
enableNativeBootstrap,
onManualPairing,
onUnpairStart,
isRetiredCopilotCustodyBlocked,
requireAutomationAllowed,
discardRetiredCopilotCustody,
resetRelayState,
suspendRelayConnections,
resumeRelayConnections,
reconcilePairingInvalidation,
reconcileAccessMode,
runAccessMutation,
detachAllDebuggerSessions,
syncTabsToRelay,
clearRelayOpeningDeadline,
closeRelaySocket,
connectRelay,
setBadge,
attachingTabs,
detachDebugger,
removeTabFromOpenClawGroup,
addTabToOpenClawGroup,
scheduleTabsSync,
pauseTab,
}) {
let pairingGeneration = 0;
const assertPairingCurrent = (generation) => {
if (generation !== pairingGeneration) {
throw new Error("Pairing was superseded by a newer request.");
}
};
async function applyPairing({ pairing, pairingString, accessMode, source = "manual" }) {
await requireAutomationAllowed();
const parsed = pairing ?? parsePairingString(pairingString);
if (!parsed) {
return { ok: false, error: "Invalid pairing string." };
}
if (source === "native" && (await getConfig()).relayUrl) {
return { ok: false, existing: true };
}
if (source === "manual") {
await onManualPairing();
}
const generation = ++pairingGeneration;
suspendRelayConnections();
clearRelayOpeningDeadline();
closeRelaySocket();
await accessReady;
assertPairingCurrent(generation);
await runAccessMutation(async () => {
assertPairingCurrent(generation);
if (source === "native" && (await getConfig()).relayUrl) {
return;
}
suspendRelayConnections();
clearRelayOpeningDeadline();
closeRelaySocket();
const normalizedMode =
accessMode === ACCESS_MODE_SELECTED ? ACCESS_MODE_SELECTED : ACCESS_MODE_ALL;
const downgrading =
policy.mode === ACCESS_MODE_ALL && normalizedMode === ACCESS_MODE_SELECTED;
if (downgrading) {
policy.beginTransition();
}
try {
await pairingConfigStore.save(parsed, nearestGroupColor(), normalizedMode);
assertPairingCurrent(generation);
await reconcileAccessMode(normalizedMode, { transitioning: downgrading });
assertPairingCurrent(generation);
policy.setEnabled(true);
} catch (error) {
if (downgrading) {
policy.endTransition();
}
throw error;
}
resetRelayState();
assertPairingCurrent(generation);
resumeRelayConnections();
await connectRelay(() => generation === pairingGeneration);
if (generation !== pairingGeneration) {
clearRelayOpeningDeadline();
closeRelaySocket();
setBadge("off");
assertPairingCurrent(generation);
}
});
return { ok: true };
}
async function unpair() {
pairingGeneration += 1;
const disabledPersisted = onUnpairStart();
policy.setEnabled(false);
policy.invalidateAll();
suspendRelayConnections();
resetRelayState();
clearRelayOpeningDeadline();
closeRelaySocket();
setBadge("off");
await accessReady;
policy.setEnabled(false);
policy.invalidateAll();
clearRelayOpeningDeadline();
closeRelaySocket();
setBadge("off");
await runAccessMutation(async () => {
policy.setEnabled(false);
const detaching = detachAllDebuggerSessions();
await syncTabsToRelay();
await disabledPersisted;
await pairingConfigStore.clear();
await policy.clearDenied();
await detaching;
await discardRetiredCopilotCustody();
resetRelayState();
clearRelayOpeningDeadline();
closeRelaySocket();
setBadge("off");
});
return { ok: true };
}
const handler = (msg, reply) => {
let settled = false;
const sendResponse = (response) => {
if (!settled) {
settled = true;
reply(response);
}
};
void (async () => {
try {
switch (msg?.type) {
case "getStatus": {
await accessReady;
const retiredCopilotCustodyBlocked = isRetiredCopilotCustodyBlocked();
const nativeBootstrap = await getNativeBootstrapStatus();
const { relayUrl, accessMode } = await getConfig();
await reconcilePairingInvalidation();
const accessible = await policy.listAccessibleTabs();
const hint = getRelayStatusHint();
sendResponse({
paired: Boolean(relayUrl),
state: getRelayState(),
accessMode,
accessibleTabCount: accessible.length,
relayUrl: relayUrl ?? "",
nativeBootstrap,
retiredCopilotCustodyBlocked,
...(hint ? { hint } : {}),
});
return;
}
case "pair":
sendResponse(
await applyPairing({
pairingString: msg.pairingString,
accessMode: msg.accessMode,
source: "manual",
}),
);
return;
case "unpair":
sendResponse(await unpair());
return;
case "setNativeBootstrapEnabled":
if (typeof msg.enabled !== "boolean") {
sendResponse({ ok: false, error: "Invalid automatic setup setting." });
return;
}
sendResponse({ ok: true, result: await enableNativeBootstrap(msg.enabled) });
return;
case "setAccessMode": {
if (msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) {
sendResponse({ ok: false, error: "Invalid access mode." });
return;
}
await requireAutomationAllowed();
const restricting = msg.accessMode === ACCESS_MODE_SELECTED;
if (restricting) {
policy.beginTransition();
}
let storedMode;
try {
await accessReady;
storedMode = await runAccessMutation(async () => {
const mode = await pairingConfigStore.setAccessMode(msg.accessMode);
await reconcileAccessMode(mode, { transitioning: restricting });
return mode;
});
} catch (error) {
if (restricting) {
policy.endTransition();
}
throw error;
}
sendResponse({ ok: true, accessMode: storedMode });
return;
}
case "toggleTabAccess": {
const tabId = msg.tabId;
if (
!isValidTabId(tabId) ||
(msg.accessMode !== ACCESS_MODE_ALL && msg.accessMode !== ACCESS_MODE_SELECTED) ||
typeof msg.grant !== "boolean"
) {
sendResponse({ ok: false, error: "Invalid tab access action." });
return;
}
await accessReady;
await requireAutomationAllowed();
if (policy.mode !== msg.accessMode) {
sendResponse({ ok: false, error: "Browser access mode changed. Refresh and retry." });
return;
}
const revocation = policy.beginRevocation(tabId);
try {
await runAccessMutation(async () => {
if (policy.mode !== msg.accessMode) {
throw new Error("Browser access mode changed. Refresh and retry.");
}
if (policy.mode === ACCESS_MODE_ALL) {
if (msg.grant && policy.isDenied(tabId)) {
await policy.allow(tabId);
} else if (!msg.grant && !policy.isDenied(tabId)) {
await pauseTab(tabId);
}
} else {
const selected = await isTabSelected(await chromeApi.tabs.get(tabId));
if (!msg.grant && selected) {
policy.invalidateTab(tabId);
await Promise.allSettled([attachingTabs.get(tabId)]);
await detachDebugger(tabId);
await removeTabFromOpenClawGroup(tabId);
} else if (msg.grant && !selected) {
policy.invalidateTab(tabId);
await addTabToOpenClawGroup(tabId);
}
}
scheduleTabsSync();
await syncTabsToRelay();
});
} finally {
policy.endRevocation(revocation);
}
const state = await policy.inspectTab(tabId);
sendResponse({ ok: true, accessible: state.accessible, denied: state.denied });
return;
}
case "getTabAccess": {
await accessReady;
const state = await policy.inspectTab(msg.tabId);
sendResponse({
accessMode: policy.mode,
accessible: state.accessible,
eligible: state.eligible,
denied: state.denied,
});
return;
}
default:
sendResponse({ ok: false, error: "unknown message" });
}
} catch (error) {
errorResponse(sendResponse, error);
}
})();
return true;
};
handler.applyPairing = applyPairing;
handler.unpair = unpair;
return handler;
}