UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

773 lines (735 loc) 26.5 kB
import { ACCESS_MODE_ALL, ACCESS_MODE_SELECTED } from "./relay-core.js"; import { addTabToOpenClawGroup } from "./relay-tab-groups.js"; import { TAB_SCOPED_COMMANDS } from "./tab-access-command-scope.js"; import { createTabDocumentProvenance } from "./tab-document-provenance.js"; import { effectiveTabUrl, isValidTabId, tabEligibility } from "./tab-eligibility.js"; import { createTabGroupRevocations } from "./tab-group-revocations.js"; const DENIED_TAB_IDS_KEY = "deniedTabIdsV1"; function initialBlankDocument(tab) { return tab.url === "about:blank" || (!tab.url && tab.pendingUrl === "about:blank"); } /** * Owns access mode, durable browser-session pauses, and revocation epochs. * Every authority-bearing caller captures an epoch and checks through here. */ export function createTabAccessPolicy({ chromeApi = chrome, isSelectedTab, getGroupColor }) { const deniedTabIds = new Set(); // Only createTab below mints these records. Group membership and Tab snapshots // cannot recreate initial-document ownership after navigation or worker restart. const createdTabs = new Map(); const pendingCreations = new Set(); const tabRevisions = new Map(); const provenEpochs = new WeakMap(); let fileAccessGranted = false; let mode = ACCESS_MODE_SELECTED; let enabled = false; let transitioning = false; // Single-tab mutations fail closed without retiring unrelated attachment epochs. const revocationBarriers = new Map(); let revision = 0; let discoveryRevision = 0; let initialized = null; let storageChain = Promise.resolve(); const addTabToGroup = (tabId, created) => addTabToOpenClawGroup(tabId, { chromeApi, getGroupColor, created }); const documents = createTabDocumentProvenance({ access: { get fileAccessAllowed() { return fileAccessGranted; }, invalidateTab, requireTab, provenTabIsCurrent: (tabId, epoch) => groupRevocations.isProvenCurrent(tabId, epoch, epochIsCurrent), recordRootCommit: (tabId, url) => { discoveryRevision += 1; const created = createdTabs.get(tabId); if (created?.initialBlank && url !== "about:blank") { created.initialBlank = false; if (created.handedOff) { createdTabs.delete(tabId); } else { invalidateTab(tabId); } } }, }, }); const groupRevocations = createTabGroupRevocations({ createdTabs, provenEpochs, epochIsCurrent, invalidateDocuments: documents.invalidateGroup, invalidateAll, reviseDiscovery: () => (discoveryRevision += 1), }); async function readTabDocument(tabId) { // A native root commit can overtake Chrome's snapshot callback. Discard it // before consuming provenance, without recapturing the admitted epoch. let root; let tab; do { root = documents.rootRevision(tabId); tab = await chromeApi.tabs.get(tabId); } while (root !== documents.rootRevision(tabId)); return tab; } const mutateStorage = (task) => { const pending = storageChain.then(task, task); storageChain = pending.catch(() => undefined); return pending; }; const persistedIds = () => [...deniedTabIds].toSorted((left, right) => left - right); async function fileAccessAllowed() { try { return (await chromeApi.extension?.isAllowedFileSchemeAccess?.()) === true; } catch { return false; } } function eligibilityForTab(tab, controlledBlank = false) { documents.observeTab(tab); const created = createdTabs.get(tab?.id); if (created && tab.url && tab.url !== "about:blank") { if (created.initialBlank && !created.handedOff) { invalidateTab(tab.id); } created.initialBlank = false; if (created.handedOff) { createdTabs.delete(tab.id); if (!created.isCurrent()) { invalidateTab(tab.id); } } } const options = { fileAccessAllowed: fileAccessGranted, controlledBlank: controlledBlank || documents.get(tab?.id)?.controlledBlank === true, }; const eligibility = tabEligibility(tab, options); if ( eligibility.reason !== "restricted" || !created?.initialBlank || !created.isCurrent() || !initialBlankDocument(tab) ) { return eligibility; } // A pending ordinary destination does not replace the initial document yet. // Check it independently; restricted pending URLs never inherit admission. return tab.pendingUrl && tab.pendingUrl !== "about:blank" ? tabEligibility({ ...tab, url: tab.pendingUrl }, options) : { eligible: true, reason: null }; } async function persistDeniedIds() { const ids = persistedIds(); if (ids.length === 0) { await chromeApi.storage.session.remove([DENIED_TAB_IDS_KEY]); return; } await chromeApi.storage.session.set({ [DENIED_TAB_IDS_KEY]: ids }); } function invalidateTab(tabId) { documents.revokeDocument(tabId); const next = ++discoveryRevision; tabRevisions.set(tabId, { access: next, document: next }); } function retireTab(tabId) { createdTabs.delete(tabId); invalidateTab(tabId); } function capture(tabId, method) { const current = tabRevisions.get(tabId); return { revision, groupRevision: groupRevocations.capture(), tabRevision: current?.access ?? 0, ...(!TAB_SCOPED_COMMANDS.has(method) ? { documentRevision: current?.document ?? 0 } : {}), }; } function tabIsRevoking(tabId) { for (const barrier of revocationBarriers.values()) { if (barrier.tabId === tabId) { return true; } } return false; } function epochMatches(tabId, epoch, groupId = provenEpochs.get(epoch)?.groupId) { return ( enabled && !transitioning && !tabIsRevoking(tabId) && epoch.revision === revision && groupRevocations.isCurrent(epoch, groupId) && epoch.tabRevision === (tabRevisions.get(tabId)?.access ?? 0) && (epoch.documentRevision === undefined || epoch.documentRevision === (tabRevisions.get(tabId)?.document ?? 0)) ); } function epochIsCurrent(tabId, epoch, groupId) { // Handoff retires the creator's epoch gate, not its initial-blank record. // Commands and attachments retain their own epochs after re-selection. const created = createdTabs.get(tabId); return ( epochMatches(tabId, epoch, groupId) && (!created || (created.isCurrent() && (created.handedOff || (groupRevocations.isCreationCurrent(created) && epochMatches(tabId, created.epoch))))) ); } function invalidateAll() { documents.invalidateAll(); revision += 1; discoveryRevision += 1; } function observeTabUpdate(tabId, change, tab) { documents.observeTab(tab); for (const pending of pendingCreations) { if (typeof change.url === "string" && change.url !== "about:blank") { pending.changedDocuments.add(tabId); } } const accessChanged = typeof change.url === "string" || change.status === "loading" || (mode === ACCESS_MODE_SELECTED && typeof change.groupId === "number") || (typeof tab?.pendingUrl === "string" && !eligibilityForTab(tab).eligible); const created = createdTabs.get(tabId); if (!created) { if (mode === ACCESS_MODE_SELECTED && typeof change.groupId === "number") { invalidateTab(tabId); } return accessChanged; } if (typeof change.url === "string") { if (created.initialBlank && change.url === "about:blank" && eligibilityForTab(tab).eligible) { // The pending initial blank can commit after handoff. This is still the // creator's document; settling it must not cancel client initialization. created.tab = { ...created.tab, url: tab.url, pendingUrl: tab.pendingUrl }; return false; } eligibilityForTab(tab); } if (!created.handedOff && typeof change.groupId === "number") { if ( created.grouping && change.groupId >= 0 && (created.expectedGroupId === undefined || created.expectedGroupId === change.groupId) && epochIsCurrent(tabId, created.epoch) && tab?.id === tabId ) { created.groupId = change.groupId; if (created.initialGroup) { created.namingGroup = change.groupId; } created.expectedGroupId = change.groupId; created.grouping = false; return false; } invalidateTab(tabId); } return accessChanged; } async function createTab(message, { isCurrent, attachDebugger, handoff }) { const operationRevision = revision; const started = discoveryRevision; if (!enabled || transitioning || !isCurrent()) { throw new Error("tab creation access was revoked"); } const pending = { started, blankRevisions: new Map(), changedDocuments: new Set() }; pendingCreations.add(pending); let tab; try { tab = await chromeApi.tabs.create({ url: message.url, active: message.background !== true }); } finally { pendingCreations.delete(pending); } let assertAttachment; const created = { tab, // Creation owns a tab, not its first HTTP document (which may redirect). epoch: groupRevocations.captureEpoch( operationRevision, tabRevisions.get(tab.id)?.access ?? 0, ), isCurrent, initialBlank: message.url === "about:blank" && initialBlankDocument(tab), handedOff: false, groupId: tab.groupId, grouping: false, expectedGroupId: undefined, namingGroup: undefined, initialGroup: false, assertCurrent: () => { assertAttachment?.(); if (createdTabs.get(tab.id) !== created || !epochIsCurrent(tab.id, created.epoch)) { throw new Error(`tab ${tab.id} creation access was revoked`); } }, }; // A removal/replacement observed before the create callback invalidates it. if ( !isValidTabId(tab.id) || (message.url === "about:blank" && pending.changedDocuments.has(tab.id)) || ((tabRevisions.get(tab.id)?.access ?? 0) > started && pending.blankRevisions.get(tab.id) !== tabRevisions.get(tab.id)?.access) ) { throw new Error("created tab is no longer available"); } createdTabs.set(tab.id, created); try { created.assertCurrent(); await addTabToGroup(tab.id, created); created.assertCurrent(); await requireTab(tab.id, created.epoch); created.assertCurrent(); const attached = await attachDebugger(tab.id, created.assertCurrent, created.epoch); assertAttachment = attached.assertCurrent; created.assertCurrent(); if (message.focus === true && typeof tab.windowId === "number") { await chromeApi.windows.update(tab.windowId, { focused: true }); created.assertCurrent(); } await requireTab(tab.id, created.epoch); created.assertCurrent(); handoff({ tabId: tab.id, targetId: attached.targetId }); created.handedOff = true; // Earlier inventory reads must not retract the target just handed to a // client. Group events advance the same revision without renewing access. discoveryRevision += 1; } catch (error) { // Rollback belongs to the creator, before any id is handed to the relay. // Never use ordinary close as a privileged bypass or close a user-revoked tab. // Socket/native closure ends handoff authority, but not ownership of this // unhanded tab. Rollback uses the creator epoch and unchanged tab identity. const ownsRollback = () => createdTabs.get(tab.id) === created && !deniedTabIds.has(tab.id) && groupRevocations.isCreationCurrent(created) && epochMatches(tab.id, created.epoch); try { if (ownsRollback()) { const current = await chromeApi.tabs.get(tab.id); if ( ownsRollback() && current.id === tab.id && current.windowId === tab.windowId && ((created.initialBlank && initialBlankDocument(current) && (!current.pendingUrl || current.pendingUrl === "about:blank")) || (effectiveTabUrl(current) === effectiveTabUrl(created.tab) && (!current.url || current.url === effectiveTabUrl(created.tab)))) && current.groupId === created.groupId && current.incognito === tab.incognito ) { await chromeApi.tabs.remove(tab.id); } } } catch { console.warn(`Cleanup failed for created tab ${tab.id}; close it manually.`); throw new Error( `${error instanceof Error ? error.message : String(error)}; cleanup failed for created tab ${tab.id}; close it manually.`, { cause: error }, ); } throw error; } finally { if (!created.handedOff || !created.initialBlank) { if (createdTabs.get(tab.id) === created) { createdTabs.delete(tab.id); if (!created.handedOff) { invalidateTab(tab.id); } } } } } async function initialize(initialMode = ACCESS_MODE_SELECTED, initialEnabled = false) { if (initialized) { return await initialized; } mode = initialMode === ACCESS_MODE_ALL ? ACCESS_MODE_ALL : ACCESS_MODE_SELECTED; enabled = initialEnabled; initialized = (async () => { const [stored, tabs, allowFiles] = await Promise.all([ chromeApi.storage.session.get([DENIED_TAB_IDS_KEY]), chromeApi.tabs.query({}), fileAccessAllowed(), ]); // Chrome reloads the extension and closes its debugger sessions when this // permission changes (Chromium extension_util.cc: SetAllowFileAccess). fileAccessGranted = allowFiles; const existingIds = new Set(); for (const tab of tabs) { if (isValidTabId(tab.id)) { existingIds.add(tab.id); } } const raw = stored[DENIED_TAB_IDS_KEY]; if (Array.isArray(raw)) { for (const tabId of raw) { if (isValidTabId(tabId) && existingIds.has(tabId)) { deniedTabIds.add(tabId); } } } const normalized = persistedIds(); if ( !Array.isArray(raw) || raw.length !== normalized.length || raw.some((tabId, index) => tabId !== normalized[index]) ) { await persistDeniedIds(); } })(); return await initialized; } function setMode(nextMode) { const normalized = nextMode === ACCESS_MODE_ALL ? ACCESS_MODE_ALL : ACCESS_MODE_SELECTED; if (normalized !== mode) { mode = normalized; documents.invalidateAll(); revision += 1; discoveryRevision += 1; } return mode; } function setEnabled(nextEnabled) { const normalized = nextEnabled === true; if (normalized !== enabled) { enabled = normalized; documents.invalidateAll(); revision += 1; discoveryRevision += 1; } } function beginTransition() { if (!transitioning) { transitioning = true; documents.invalidateAll(); revision += 1; discoveryRevision += 1; } } function endTransition() { if (transitioning) { transitioning = false; revision += 1; discoveryRevision += 1; } } function beginRevocation(tabId) { const token = Symbol("tab-access-revocation"); revocationBarriers.set(token, { tabId, controlledBlank: documents.get(tabId)?.controlledBlank === true, }); invalidateTab(tabId); return token; } function endRevocation(token) { const tabId = revocationBarriers.get(token)?.tabId; if (tabId === undefined) { return; } revocationBarriers.delete(token); // An epoch captured behind the barrier must not become valid when it opens. invalidateTab(tabId); } function renewTabAccess(tabId, attachedEpoch, tab) { const blankObservers = !attachedEpoch && tab?.id === tabId && initialBlankDocument(tab) && !tab.incognito && (!tab.pendingUrl || tab.pendingUrl === "about:blank") ? [...pendingCreations].filter( (pending) => !pending.changedDocuments.has(tabId) && ((tabRevisions.get(tabId)?.access ?? 0) <= pending.started || pending.blankRevisions.get(tabId) === tabRevisions.get(tabId)?.access), ) : []; const proof = attachedEpoch && provenEpochs.get(attachedEpoch); const canRenew = proof?.tabId === tabId && epochIsCurrent(tabId, attachedEpoch) && tab?.id === tabId && eligibilityForTab(tab).eligible && (mode === ACCESS_MODE_ALL || tab.groupId === proof.groupId); // An allowed document change retires page reads/actions, not tab authority. // Only an already-proven attachment gets synchronous event renewal. Without // an attachment, an eligible initial HTTP commit can precede create's callback. if (!eligibilityForTab(tab).eligible || (attachedEpoch && !canRenew)) { invalidateTab(tabId); } else { tabRevisions.set(tabId, { access: tabRevisions.get(tabId)?.access ?? 0, document: ++discoveryRevision, }); } // Only the exact physical create callback may consume this initial-blank // observation. A pause/removal/group revocation breaks its revision chain. for (const pending of blankObservers) { pending.blankRevisions.set(tabId, tabRevisions.get(tabId)?.access); } if (!canRenew) { return undefined; } const epoch = capture(tabId); const groupId = mode === ACCESS_MODE_SELECTED ? tab.groupId : undefined; provenEpochs.set(epoch, { tabId, groupId }); return epoch; } async function inspectTab(tabId, epoch = capture(tabId)) { if (!isValidTabId(tabId)) { return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; } if (!epochIsCurrent(tabId, epoch)) { return { accessible: false, eligible: false, denied: false, reason: "revoked", tab: null }; } let tab; try { tab = await readTabDocument(tabId); } catch { return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; } if (!epochIsCurrent(tabId, epoch) || !groupRevocations.isCurrentInMode(mode, epoch, tab)) { return { accessible: false, eligible: false, denied: false, reason: "revoked", tab }; } const document = documents.get(tabId); const eligibility = eligibilityForTab(tab); if (!eligibility.eligible) { return { accessible: false, eligible: false, denied: false, reason: eligibility.reason, tab }; } const denied = mode === ACCESS_MODE_ALL && deniedTabIds.has(tabId); const selected = mode === ACCESS_MODE_SELECTED ? await isSelectedTab(tab) : true; // A lookup can observe removal before its event. Retire the same private // authority now, including the creator's right to roll back this tab. if (!selected && (document || createdTabs.get(tabId)?.epoch === epoch)) { invalidateTab(tabId); } if (!epochIsCurrent(tabId, epoch) || !groupRevocations.isCurrentInMode(mode, epoch, tab)) { return { accessible: false, eligible: true, denied, reason: "revoked", tab }; } if (mode === ACCESS_MODE_SELECTED && selected) { let current; try { current = await readTabDocument(tabId); } catch { return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null }; } if (!epochIsCurrent(tabId, epoch) || !groupRevocations.isCurrentInMode(mode, epoch, tab)) { return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; } const currentEligible = eligibilityForTab(current).eligible; const currentSelected = await isSelectedTab(current); if (!currentSelected && (document || createdTabs.get(tabId)?.epoch === epoch)) { invalidateTab(tabId); } if ( !epochIsCurrent(tabId, epoch) || !groupRevocations.isCurrentInMode(mode, epoch, current) ) { return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; } if ( current.groupId !== tab.groupId || (epoch.documentRevision !== undefined && effectiveTabUrl(current) !== effectiveTabUrl(tab) && !( tab.url === "about:blank" && current.url === "about:blank" && document && documents.get(tabId) === document )) || current.incognito !== tab.incognito || !currentEligible || !currentSelected ) { return { accessible: false, eligible: false, denied, reason: "revoked", tab: current }; } } if (!epochIsCurrent(tabId, epoch) || !groupRevocations.isCurrentInMode(mode, epoch, tab)) { return { accessible: false, eligible: true, denied, reason: "revoked", tab }; } if (!denied && selected) { const groupId = mode === ACCESS_MODE_SELECTED ? tab.groupId : undefined; provenEpochs.set(epoch, { tabId, groupId }); } return { accessible: !denied && selected, eligible: true, denied, reason: denied ? "paused" : selected ? null : "not-selected", tab, }; } async function requireTab(tabId, epoch = capture(tabId), afterNavigation = false) { const state = await inspectTab(tabId, epoch); if (state.accessible) { const document = documents.get(tabId); if (!afterNavigation && document && !document.navigation.confirmed) { throw new Error("Root document navigation is awaiting native confirmation"); } return state.tab; } if (state.reason === "revoked") { throw new Error(`tab ${tabId} access was revoked`); } if (state.reason === "paused") { throw new Error(`tab ${tabId} is paused for OpenClaw`); } if (state.reason === "not-selected") { throw new Error(`tab ${tabId} is not in the OpenClaw tab group`); } if (state.reason === "incognito") { throw new Error(`tab ${tabId} is incognito and unavailable to OpenClaw`); } throw new Error(`tab ${tabId} is restricted or unavailable to OpenClaw`); } async function listAccessibleTabs({ allowDuringTransition = false } = {}) { await initialize(mode); for (;;) { const listRevision = discoveryRevision; if (!enabled || (transitioning && !allowDuringTransition)) { return []; } const tabs = await chromeApi.tabs.query({}); if (listRevision !== discoveryRevision) { continue; } const accessible = []; for (const tab of tabs) { if (listRevision !== discoveryRevision) { break; } if (tabIsRevoking(tab.id) || !eligibilityForTab(tab).eligible) { continue; } if (mode === ACCESS_MODE_ALL) { if (!deniedTabIds.has(tab.id)) { accessible.push(tab); } } else if (await isSelectedTab(tab)) { accessible.push(tab); } } if (listRevision === discoveryRevision) { return accessible; } } } async function pause(tabId) { const controlledBlank = documents.get(tabId)?.controlledBlank === true || [...revocationBarriers.values()].some( (barrier) => barrier.tabId === tabId && barrier.controlledBlank, ); // Revoke synchronously: Chrome lookup and session persistence may yield, // but newly arriving authority must already fail closed. invalidateTab(tabId); deniedTabIds.add(tabId); let tab; try { tab = await chromeApi.tabs.get(tabId); } catch (error) { deniedTabIds.delete(tabId); invalidateTab(tabId); throw error; } if (!eligibilityForTab(tab, controlledBlank).eligible) { deniedTabIds.delete(tabId); invalidateTab(tabId); throw new Error(`tab ${tabId} is restricted or unavailable to OpenClaw`); } await mutateStorage(persistDeniedIds); } async function allow(tabId) { if (!deniedTabIds.has(tabId)) { return; } invalidateTab(tabId); await mutateStorage(async () => { deniedTabIds.delete(tabId); try { await persistDeniedIds(); } catch (error) { deniedTabIds.add(tabId); throw error; } }); invalidateTab(tabId); } async function forgetTab(tabId) { retireTab(tabId); if (!deniedTabIds.delete(tabId)) { return; } await mutateStorage(persistDeniedIds); } async function replaceTab(addedTabId, removedTabId) { retireTab(removedTabId); retireTab(addedTabId); if (!deniedTabIds.delete(removedTabId)) { return false; } deniedTabIds.add(addedTabId); try { await mutateStorage(persistDeniedIds); } catch (error) { // Keep both identities denied in memory when persistence fails; widening // access is worse than retaining a harmless stale ID until restart. deniedTabIds.add(removedTabId); throw error; } return true; } async function clearDenied() { revision += 1; discoveryRevision += 1; deniedTabIds.clear(); await mutateStorage(persistDeniedIds); } return { initialize, get mode() { return mode; }, get discoveryRevision() { return discoveryRevision; }, setMode, setEnabled, beginTransition, endTransition, beginRevocation, endRevocation, capture, epochIsCurrent, invalidateTab, retireTab, retireTabDocument: documents.retireAttachment, forwardDocumentEvent: documents.forwardDocumentEvent, navigateTab: documents.navigateTab, renewTabAccess, invalidateGroup: groupRevocations.invalidate, invalidateAll, observeTabUpdate, createTab, addTabToGroup, inspectTab, requireTab, requireTabAfterNavigation: (tabId, epoch) => requireTab(tabId, epoch, true), listAccessibleTabs, canPublishTab: (tabId) => !createdTabs.has(tabId) || createdTabs.get(tabId).handedOff, pause, allow, forgetTab, replaceTab, clearDenied, isDenied: (tabId) => deniedTabIds.has(tabId), }; }