Files
openclaw/extensions/browser/chrome-extension/modules/native-bootstrap.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

293 lines
9.2 KiB
JavaScript

import { ACCESS_MODE_ALL, parsePairingString } from "./relay-core.js";
const NATIVE_HOST_NAME = "ai.openclaw.browser_bootstrap";
const DISABLED_KEY = "nativeBootstrapDisabled";
const STATE_KEY = "nativeBootstrapState";
const FAILURE_KEY = "nativeBootstrapFailureCode";
const NATIVE_MESSAGE_TIMEOUT_MS = 30_000;
const NATIVE_MESSAGE_TIMEOUT = Symbol("native_message_timeout");
const RETRYABLE_HOST_ERRORS = [
"native messaging host not found",
"specified native messaging host not found",
"failed to start native messaging host",
];
const FAILURE_CODES = new Set([
"invalid_frame",
"invalid_utf8",
"invalid_request",
"origin_forbidden",
"manifest_invalid",
"manual_required",
"pairing_unavailable",
]);
function hasExactKeys(value, expected) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length === expected.length &&
expected.every((key) => Object.hasOwn(value, key))
);
}
function nativeResponse(value, nonce) {
if (
hasExactKeys(value, ["v", "ok", "nonce", "pairingString"]) &&
value.v === 1 &&
value.ok === true &&
value.nonce === nonce &&
typeof value.pairingString === "string"
) {
const pairing = parsePairingString(value.pairingString);
return pairing ? { kind: "success", pairing } : { kind: "malformed" };
}
if (
hasExactKeys(value, ["v", "ok", "code"]) &&
value.v === 1 &&
value.ok === false &&
FAILURE_CODES.has(value.code)
) {
return { kind: "failure", code: value.code };
}
return { kind: "malformed" };
}
function randomNonce() {
const hex = crypto.randomUUID().replaceAll("-", "");
let binary = "";
for (let offset = 0; offset < hex.length; offset += 2) {
binary += String.fromCharCode(Number.parseInt(hex.slice(offset, offset + 2), 16));
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
}
function isHostMissing(error) {
const message = String(error?.message ?? error).toLowerCase();
return RETRYABLE_HOST_ERRORS.some((candidate) => message.includes(candidate));
}
function sendNativeBootstrap(chromeApi, request) {
return new Promise((resolve, reject) => {
const port = chromeApi.runtime.connectNative(NATIVE_HOST_NAME);
let settled = false;
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
port.disconnect();
callback(value);
};
const timeoutId = setTimeout(
() => finish(reject, NATIVE_MESSAGE_TIMEOUT),
NATIVE_MESSAGE_TIMEOUT_MS,
);
port.onMessage.addListener((response) => finish(resolve, response));
port.onDisconnect.addListener(() => {
const message = chromeApi.runtime.lastError?.message;
finish(reject, new Error(message || "Native messaging host disconnected."));
});
try {
port.postMessage(request);
} catch (error) {
finish(reject, error);
}
});
}
/** Own coalescing, retry policy, opt-out, and late-response revocation. */
export function createNativeBootstrapController({ chromeApi = chrome, getPairing, applyPairing }) {
let inFlight = null;
let disabledNow = false;
let generation = 0;
async function readState() {
const stored = await chromeApi.storage.local.get([DISABLED_KEY, STATE_KEY, FAILURE_KEY]);
disabledNow ||= stored[DISABLED_KEY] === true;
return {
disabled: disabledNow,
state:
stored[STATE_KEY] === "ready" ||
stored[STATE_KEY] === "retrying" ||
stored[STATE_KEY] === "manual_required" ||
stored[STATE_KEY] === "disabled"
? stored[STATE_KEY]
: "waiting",
failureCode: typeof stored[FAILURE_KEY] === "string" ? stored[FAILURE_KEY] : "",
};
}
async function writeState(state, failureCode = "") {
await chromeApi.storage.local.set({
[STATE_KEY]: state,
...(failureCode ? { [FAILURE_KEY]: failureCode } : {}),
});
if (!failureCode) {
await chromeApi.storage.local.remove([FAILURE_KEY]);
}
}
async function attempt() {
if (inFlight) {
return await inFlight;
}
const ownedGeneration = generation;
inFlight = (async () => {
const pairing = await getPairing();
if (pairing?.relayUrl) {
await writeState("ready");
return { status: "existing" };
}
const state = await readState();
if (disabledNow) {
return { status: "disabled" };
}
if (state.state === "manual_required") {
return { status: "manual_required", code: state.failureCode };
}
const nonce = randomNonce();
let response;
try {
response = await sendNativeBootstrap(chromeApi, {
v: 1,
op: "bootstrap",
nonce,
});
} catch (error) {
if (error === NATIVE_MESSAGE_TIMEOUT || isHostMissing(error)) {
const code = error === NATIVE_MESSAGE_TIMEOUT ? "native_host_timeout" : "host_not_found";
await writeState("retrying", code);
return { status: "retrying", code };
}
await writeState("manual_required", "native_host_error");
return { status: "manual_required", code: "native_host_error" };
}
if (ownedGeneration !== generation || disabledNow) {
return { status: "superseded" };
}
const parsed = nativeResponse(response, nonce);
if (parsed.kind === "malformed") {
await writeState("manual_required", "malformed_response");
return { status: "manual_required", code: "malformed_response" };
}
if (parsed.kind === "failure") {
const retrying = parsed.code === "pairing_unavailable";
await writeState(retrying ? "retrying" : "manual_required", parsed.code);
return { status: retrying ? "retrying" : "manual_required", code: parsed.code };
}
const current = await getPairing();
if (current?.relayUrl || ownedGeneration !== generation || disabledNow) {
return { status: "superseded" };
}
const applied = await applyPairing({
pairing: parsed.pairing,
accessMode: ACCESS_MODE_ALL,
source: "native",
generation: ownedGeneration,
});
if (!applied?.ok) {
if (applied?.existing) {
return { status: "existing" };
}
await writeState("manual_required", "pairing_rejected");
return { status: "manual_required", code: "pairing_rejected" };
}
await writeState("ready");
return { status: "paired" };
})().finally(() => {
inFlight = null;
});
return await inFlight;
}
function disableSynchronously() {
disabledNow = true;
generation += 1;
return chromeApi.storage.local.set({
[DISABLED_KEY]: true,
[STATE_KEY]: "disabled",
});
}
async function enable({ attemptNow = true } = {}) {
disabledNow = false;
generation += 1;
await chromeApi.storage.local.remove([DISABLED_KEY, STATE_KEY, FAILURE_KEY]);
return attemptNow ? await attempt() : { status: "enabled" };
}
async function status() {
const pairing = await getPairing();
const state = await readState();
return {
disabled: state.disabled,
state: pairing?.relayUrl ? "ready" : state.state,
...(state.failureCode ? { failureCode: state.failureCode } : {}),
};
}
return { attempt, disableSynchronously, enable, status };
}
const COPILOT_LOCAL_KEYS = [
"copilotSessionRegistryV1",
"copilotDeviceIdentitiesV1",
"copilotDeviceTokensV1",
];
const COPILOT_SESSION_KEYS = ["copilotBrowserInstanceV1", "copilotPanelBindingsV1"];
const RETIRED_COPILOT_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1";
function canClearRetiredCopilotRegistry(value) {
return (
hasExactKeys(value, ["sessions", "pendingArchives"]) &&
value.sessions !== null &&
typeof value.sessions === "object" &&
!Array.isArray(value.sessions) &&
Object.keys(value.sessions).length === 0 &&
Array.isArray(value.pendingArchives) &&
value.pendingArchives.length === 0
);
}
/** Explicitly discard every retired copilot key. */
export async function discardRetiredCopilotState(chromeApi = chrome) {
await chromeApi.storage.local.set({ [RETIRED_COPILOT_CUSTODY_BLOCKED_KEY]: true });
await chromeApi.storage.session.remove(COPILOT_SESSION_KEYS);
await chromeApi.storage.local.remove(COPILOT_LOCAL_KEYS);
await chromeApi.storage.local.remove([RETIRED_COPILOT_CUSTODY_BLOCKED_KEY]);
}
/** Clear harmless retired state, or preserve custody and fail closed. */
export async function prepareRetiredCopilotState(chromeApi = chrome) {
let stored;
try {
stored = await chromeApi.storage.local.get([
RETIRED_COPILOT_CUSTODY_BLOCKED_KEY,
COPILOT_LOCAL_KEYS[0],
]);
} catch {
return { blocked: true };
}
if (stored === null || typeof stored !== "object" || Array.isArray(stored)) {
return { blocked: true };
}
if (Object.hasOwn(stored, RETIRED_COPILOT_CUSTODY_BLOCKED_KEY)) {
return { blocked: true };
}
if (
Object.hasOwn(stored, COPILOT_LOCAL_KEYS[0]) &&
!canClearRetiredCopilotRegistry(stored[COPILOT_LOCAL_KEYS[0]])
) {
return { blocked: true };
}
try {
await discardRetiredCopilotState(chromeApi);
} catch {
return { blocked: true };
}
return { blocked: false };
}