mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 07:33:46 -06:00
8362f74c99
* feat(browser): add all-tabs extension access * fix(browser): preserve cancel revocation during startup * test(browser): align all-tabs Chromium fixtures
405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
import { ACCESS_MODE_ALL, ACCESS_MODE_SELECTED } from "./relay-core.js";
|
|
import { effectiveTabUrl, tabEligibility } from "./tab-eligibility.js";
|
|
|
|
const DENIED_TAB_IDS_KEY = "deniedTabIdsV1";
|
|
|
|
function isValidTabId(value) {
|
|
return Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
|
|
/**
|
|
* 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 }) {
|
|
const deniedTabIds = new Set();
|
|
const tabRevisions = new Map();
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
async function tabIsEligible(tab) {
|
|
return tabEligibility(tab, {
|
|
fileAccessAllowed:
|
|
tab?.url?.startsWith("file:") || tab?.pendingUrl?.startsWith("file:")
|
|
? await fileAccessAllowed()
|
|
: true,
|
|
}).eligible;
|
|
}
|
|
|
|
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) {
|
|
tabRevisions.set(tabId, (tabRevisions.get(tabId) ?? 0) + 1);
|
|
discoveryRevision += 1;
|
|
}
|
|
|
|
function capture(tabId) {
|
|
return { revision, tabRevision: tabRevisions.get(tabId) ?? 0 };
|
|
}
|
|
|
|
function tabIsRevoking(tabId) {
|
|
for (const revokedTabId of revocationBarriers.values()) {
|
|
if (revokedTabId === tabId) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function epochIsCurrent(tabId, epoch) {
|
|
return (
|
|
enabled &&
|
|
!transitioning &&
|
|
!tabIsRevoking(tabId) &&
|
|
epoch.revision === revision &&
|
|
epoch.tabRevision === (tabRevisions.get(tabId) ?? 0)
|
|
);
|
|
}
|
|
|
|
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] = await Promise.all([
|
|
chromeApi.storage.session.get([DENIED_TAB_IDS_KEY]),
|
|
chromeApi.tabs.query({}),
|
|
]);
|
|
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;
|
|
revision += 1;
|
|
discoveryRevision += 1;
|
|
}
|
|
return mode;
|
|
}
|
|
|
|
function setEnabled(nextEnabled) {
|
|
const normalized = nextEnabled === true;
|
|
if (normalized !== enabled) {
|
|
enabled = normalized;
|
|
revision += 1;
|
|
discoveryRevision += 1;
|
|
}
|
|
}
|
|
|
|
function beginTransition() {
|
|
if (!transitioning) {
|
|
transitioning = true;
|
|
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);
|
|
invalidateTab(tabId);
|
|
return token;
|
|
}
|
|
|
|
function endRevocation(token) {
|
|
const tabId = revocationBarriers.get(token);
|
|
if (tabId === undefined) {
|
|
return;
|
|
}
|
|
revocationBarriers.delete(token);
|
|
// An epoch captured behind the barrier must not become valid when it opens.
|
|
invalidateTab(tabId);
|
|
}
|
|
|
|
async function inspectTab(tabId, epoch = capture(tabId)) {
|
|
if (!isValidTabId(tabId)) {
|
|
return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null };
|
|
}
|
|
if (!enabled || transitioning || tabIsRevoking(tabId)) {
|
|
return { accessible: false, eligible: false, denied: false, reason: "revoked", tab: null };
|
|
}
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: false, denied: false, reason: "revoked", tab: null };
|
|
}
|
|
let tab;
|
|
try {
|
|
tab = await chromeApi.tabs.get(tabId);
|
|
} catch {
|
|
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 };
|
|
}
|
|
let allowedFileAccess = true;
|
|
if (tab?.url?.startsWith("file:") || tab?.pendingUrl?.startsWith("file:")) {
|
|
allowedFileAccess = await fileAccessAllowed();
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: false, denied: false, reason: "revoked", tab };
|
|
}
|
|
}
|
|
const eligibility = tabEligibility(tab, {
|
|
fileAccessAllowed: allowedFileAccess,
|
|
});
|
|
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;
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: true, denied, reason: "revoked", tab };
|
|
}
|
|
if (mode === ACCESS_MODE_SELECTED && selected) {
|
|
let current;
|
|
try {
|
|
current = await chromeApi.tabs.get(tabId);
|
|
} catch {
|
|
return { accessible: false, eligible: false, denied: false, reason: "missing", tab: null };
|
|
}
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: false, denied, reason: "revoked", tab: current };
|
|
}
|
|
const currentEligible = await tabIsEligible(current);
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: false, denied, reason: "revoked", tab: current };
|
|
}
|
|
const currentSelected = await isSelectedTab(current);
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: false, denied, reason: "revoked", tab: current };
|
|
}
|
|
if (
|
|
current.groupId !== tab.groupId ||
|
|
effectiveTabUrl(current) !== effectiveTabUrl(tab) ||
|
|
current.incognito !== tab.incognito ||
|
|
!currentEligible ||
|
|
!currentSelected
|
|
) {
|
|
return { accessible: false, eligible: false, denied, reason: "revoked", tab: current };
|
|
}
|
|
}
|
|
if (!epochIsCurrent(tabId, epoch)) {
|
|
return { accessible: false, eligible: true, denied, reason: "revoked", tab };
|
|
}
|
|
return {
|
|
accessible: !denied && selected,
|
|
eligible: true,
|
|
denied,
|
|
reason: denied ? "paused" : selected ? null : "not-selected",
|
|
tab,
|
|
};
|
|
}
|
|
|
|
async function requireTab(tabId, epoch = capture(tabId)) {
|
|
const state = await inspectTab(tabId, epoch);
|
|
if (state.accessible) {
|
|
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({});
|
|
const accessible = [];
|
|
for (const tab of tabs) {
|
|
if (tabIsRevoking(tab.id)) {
|
|
continue;
|
|
}
|
|
if (!(await tabIsEligible(tab))) {
|
|
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) {
|
|
// 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 (!(await tabIsEligible(tab))) {
|
|
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) {
|
|
invalidateTab(tabId);
|
|
if (!deniedTabIds.delete(tabId)) {
|
|
return;
|
|
}
|
|
await mutateStorage(persistDeniedIds);
|
|
}
|
|
|
|
async function replaceTab(addedTabId, removedTabId) {
|
|
invalidateTab(removedTabId);
|
|
invalidateTab(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;
|
|
},
|
|
setMode,
|
|
setEnabled,
|
|
beginTransition,
|
|
endTransition,
|
|
beginRevocation,
|
|
endRevocation,
|
|
capture,
|
|
epochIsCurrent,
|
|
invalidateTab,
|
|
invalidateAll: () => {
|
|
revision += 1;
|
|
discoveryRevision += 1;
|
|
},
|
|
inspectTab,
|
|
requireTab,
|
|
listAccessibleTabs,
|
|
pause,
|
|
allow,
|
|
forgetTab,
|
|
replaceTab,
|
|
clearDenied,
|
|
isDenied: (tabId) => deniedTabIds.has(tabId),
|
|
};
|
|
}
|