Files
openclaw/extensions/browser/chrome-extension/modules/tab-access-events.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

196 lines
6.1 KiB
JavaScript

import { ACCESS_MODE_ALL, ACCESS_MODE_SELECTED } from "./relay-core.js";
/** Register Chrome lifecycle events that can grant, revoke, or project tab access. */
export function registerTabAccessEvents({
chromeApi = chrome,
accessReady,
policy,
attachedTabs,
attachedAccessEpochs,
attachingTabs,
send,
scheduleTabsSync,
detachDebugger,
pauseTab,
removeTabFromOpenClawGroup,
runAccessMutation,
}) {
let groupEventRevision = 0;
chromeApi.debugger.onEvent.addListener((source, method, params) => {
if (typeof source.tabId !== "number") {
return;
}
const accessEpoch = attachedAccessEpochs.get(source.tabId);
if (!accessEpoch || !policy.epochIsCurrent(source.tabId, accessEpoch)) {
return;
}
send({
type: "cdpEvent",
tabId: source.tabId,
...(source.sessionId ? { sessionId: source.sessionId } : {}),
method,
params,
});
});
chromeApi.debugger.onDetach.addListener((source, reason) => {
if (typeof source.tabId !== "number") {
return;
}
attachedTabs.delete(source.tabId);
attachedAccessEpochs.delete(source.tabId);
send({ type: "detached", tabId: source.tabId, reason });
if (reason !== "canceled_by_user") {
return;
}
const revocation = policy.beginRevocation(source.tabId);
void runAccessMutation(async () => {
try {
await accessReady;
if (policy.mode === ACCESS_MODE_ALL) {
await pauseTab(source.tabId);
} else {
policy.invalidateTab(source.tabId);
await removeTabFromOpenClawGroup(source.tabId);
scheduleTabsSync();
}
} finally {
policy.endRevocation(revocation);
}
}).catch(() => undefined);
});
chromeApi.tabs.onRemoved.addListener((tabId) => {
void (async () => {
await accessReady;
policy.invalidateTab(tabId);
attachedTabs.delete(tabId);
attachedAccessEpochs.delete(tabId);
scheduleTabsSync();
await policy.forgetTab(tabId).catch(() => undefined);
})();
});
chromeApi.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
const revocation = policy.beginRevocation(addedTabId);
policy.invalidateTab(removedTabId);
attachedTabs.delete(removedTabId);
attachedAccessEpochs.delete(removedTabId);
scheduleTabsSync();
void (async () => {
try {
await accessReady;
await policy.replaceTab(addedTabId, removedTabId);
await Promise.allSettled([attachingTabs.get(removedTabId), attachingTabs.get(addedTabId)]);
await Promise.allSettled([detachDebugger(removedTabId), detachDebugger(addedTabId)]);
} finally {
policy.endRevocation(revocation);
scheduleTabsSync();
}
})().catch(() => undefined);
});
chromeApi.tabs.onUpdated.addListener((tabId, changeInfo) => {
scheduleTabsSync();
if (
typeof changeInfo.url === "string" ||
(policy.mode === ACCESS_MODE_SELECTED && typeof changeInfo.groupId === "number")
) {
// Security contract: every URL change retires synchronous CDP authority.
// Pre-proof events intentionally drop; replay could cross a restricted destination.
policy.invalidateTab(tabId);
}
const eventEpoch = policy.capture(tabId);
void (async () => {
await accessReady;
const eventIsCurrent = () => policy.epochIsCurrent(tabId, eventEpoch);
if (!eventIsCurrent()) {
return;
}
const state = await policy.inspectTab(tabId, eventEpoch);
if (!eventIsCurrent()) {
return;
}
if (!state.accessible) {
await Promise.allSettled([attachingTabs.get(tabId)]);
if (!eventIsCurrent()) {
return;
}
await detachDebugger(tabId);
}
if (attachedTabs.has(tabId) && attachedAccessEpochs.has(tabId)) {
attachedAccessEpochs.set(tabId, eventEpoch);
}
})();
});
const onGroupChanged = () => {
const eventRevision = ++groupEventRevision;
scheduleTabsSync();
if (policy.mode !== ACCESS_MODE_SELECTED) {
return;
}
// Group title/removal changes mutate the selected-mode ACL. Retire every
// attachment epoch synchronously before any readiness or Chrome lookup.
policy.invalidateAll();
void accessReady.then(async () => {
if (eventRevision !== groupEventRevision || policy.mode !== ACCESS_MODE_SELECTED) {
return;
}
const epochs = new Map(
[...attachedAccessEpochs.keys()]
.filter((tabId) => attachedTabs.has(tabId))
.map((tabId) => [tabId, policy.capture(tabId)]),
);
await Promise.allSettled(attachingTabs.values());
if (eventRevision !== groupEventRevision) {
return;
}
const selected = new Set((await policy.listAccessibleTabs()).map((tab) => tab.id));
if (eventRevision !== groupEventRevision) {
return;
}
await Promise.allSettled(
[...attachedTabs]
.filter((tabId) => !selected.has(tabId))
.map((tabId) => detachDebugger(tabId)),
);
if (eventRevision !== groupEventRevision) {
return;
}
let newerTabEventOwnsAccess = false;
for (const [tabId, epoch] of epochs) {
if (!selected.has(tabId) || !attachedTabs.has(tabId)) {
continue;
}
const state = await policy.inspectTab(tabId, epoch);
if (eventRevision !== groupEventRevision) {
return;
}
if (!policy.epochIsCurrent(tabId, epoch)) {
// A newer tab event owns this attachment's revision.
newerTabEventOwnsAccess = true;
continue;
}
if (state.accessible) {
attachedAccessEpochs.set(tabId, epoch);
} else {
await detachDebugger(tabId);
if (eventRevision !== groupEventRevision) {
return;
}
}
}
if (eventRevision !== groupEventRevision) {
return;
}
if (newerTabEventOwnsAccess) {
onGroupChanged();
}
});
};
chromeApi.tabGroups.onUpdated.addListener(onGroupChanged);
chromeApi.tabGroups.onRemoved.addListener(onGroupChanged);
}