feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+88
View File
@@ -0,0 +1,88 @@
import { browser } from 'wxt/browser';
import type { BridgeGrant, BrowserTarget, CapabilityScope } from '@/types/models';
import { getFrameInventory } from '@/features/page-context/frames';
import { getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import { ExtensionError } from '@/shared/errors';
import { requireActiveGrant } from './lifecycle';
export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>;
export interface CapabilityRouteContext {
method: string;
input: Record<string, unknown>;
grant: BridgeGrant;
requestEngine?: CapabilityEngineRequest;
}
export interface CapabilityDomainHandler {
id: string;
owns(method: string): boolean;
handle(context: CapabilityRouteContext): Promise<unknown>;
}
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
const grant = await requireActiveGrant();
requireScope(grant, required);
return grant;
}
function originOf(url: string): string {
try {
const origin = new URL(url).origin;
return origin === 'null' ? '' : origin;
} catch {
return '';
}
}
export async function allowedTarget(
grant: BridgeGrant,
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
resolveInPage = true,
): Promise<BrowserTarget> {
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
const target = grant.targets.find((item) => (
item.tabId === requested && item.frameId === requestedFrameId
));
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
const currentTab = await getTab(target.tabId);
if (!currentTab.isolationContextId
|| currentTab.isolationContextId !== target.isolationContextId
|| currentTab.cookieStoreId !== target.cookieStoreId) {
throw new ExtensionError('isolation_stale', '目标标签页的身份隔离上下文已经变化,请重新共享页面');
}
const currentFrame = await browser.webNavigation.getFrame({
tabId: target.tabId,
frameId: target.frameId,
});
if (!currentFrame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在');
let currentOrigin = originOf(currentFrame.url);
if (!currentOrigin) {
currentOrigin = (await getFrameInventory(target.tabId))
.find((frame) => frame.frameId === target.frameId)?.origin || '';
}
if (currentOrigin !== target.origin) {
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
}
if (target.documentId && currentFrame.documentId
&& target.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
}
if (typeof input.documentId === 'string' && target.documentId
&& input.documentId !== target.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
}
if (!resolveInPage) return target;
const resolved = await resolveDocumentTarget(target);
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return resolved;
}
export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) {
throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
}
}
+89
View File
@@ -0,0 +1,89 @@
export type CapabilityDomainId =
| 'navigation-isolation'
| 'authorization'
| 'handoff'
| 'network'
| 'recording-callable-debugger'
| 'transform'
| 'page'
| 'proxy';
export interface CapabilityDomainDefinition {
id: CapabilityDomainId;
owns(method: string): boolean;
}
function exactMethods(id: CapabilityDomainId, methods: readonly string[]): CapabilityDomainDefinition {
const owned = new Set(methods);
return { id, owns: (method) => owned.has(method) };
}
export const NAVIGATION_CAPABILITY_DOMAIN = exactMethods('navigation-isolation', [
'browser.tabs',
'browser.frames',
'browser.isolation.inspect',
'browser.isolation.proof',
'browser.isolation.incognito.open',
'browser.isolation.container.open',
'browser.isolation.container.list',
'browser.isolation.container.remove',
]);
export const AUTHORIZATION_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'authorization',
owns: (method) => method.startsWith('browser.authorization.'),
};
export const HANDOFF_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'handoff',
owns: (method) => method.startsWith('browser.handoff.'),
};
export const NETWORK_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'network',
owns: (method) => method.startsWith('browser.network.'),
};
export const RECORDING_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'recording-callable-debugger',
owns: (method) => method.startsWith('browser.recording.')
|| method.startsWith('browser.callable.')
|| method.startsWith('browser.deep_capture.'),
};
export const TRANSFORM_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
id: 'transform',
owns: (method) => method === 'browser.packet.compare'
|| method.startsWith('browser.profile.')
|| method.startsWith('browser.transform.'),
};
export const PAGE_CAPABILITY_DOMAIN = exactMethods('page', [
'browser.context',
'browser.node.inspect',
'browser.node.action',
'browser.cookies',
'browser.takeover',
'browser.invoke',
'browser.eval',
]);
export const PROXY_CAPABILITY_DOMAIN = exactMethods('proxy', [
'proxy.list',
'proxy.switch',
]);
export const CAPABILITY_DOMAINS: readonly CapabilityDomainDefinition[] = [
NAVIGATION_CAPABILITY_DOMAIN,
AUTHORIZATION_CAPABILITY_DOMAIN,
HANDOFF_CAPABILITY_DOMAIN,
NETWORK_CAPABILITY_DOMAIN,
RECORDING_CAPABILITY_DOMAIN,
TRANSFORM_CAPABILITY_DOMAIN,
PAGE_CAPABILITY_DOMAIN,
PROXY_CAPABILITY_DOMAIN,
];
export function capabilityDomainOwners(method: string): CapabilityDomainDefinition[] {
return CAPABILITY_DOMAINS.filter((domain) => domain.owns(method));
}
@@ -0,0 +1,153 @@
import type { BrowserAuthorizationResourceSelector } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import {
captureAuthContextHandle,
getAuthContextHandle,
} from '@/features/authorization-testing/auth-context';
import {
captureAuthContextAttestation,
getAuthContextAttestation,
} from '@/features/authorization-testing/auth-attestation';
import {
bindAuthorizationBaselineLogicalRequest,
captureAuthorizationBaseline,
compileAuthorizationBaseline,
compileAuthorizationBaselinePacket,
compileAuthorizationBaselineWithTransform,
getAuthorizationBaseline,
inspectAuthorizationBaselineTransform,
listAuthorizationBaselineCandidates,
readAuthorizationBaselineResource,
} from '@/features/authorization-testing/baseline';
import { AUTHORIZATION_CAPABILITY_DOMAIN } from '../capability-domains';
function requireAuthorizationContextScopes(
grant: Parameters<typeof requireScope>[0],
): void {
requireScope(grant, 'browser.cookies.read');
requireScope(grant, 'browser.storage.read');
}
function requireAuthorizationBaselineScopes(
grant: Parameters<typeof requireScope>[0],
): void {
requireScope(grant, 'browser.isolation.read');
requireAuthorizationContextScopes(grant);
}
export const authorizationCapabilityHandler: CapabilityDomainHandler = {
...AUTHORIZATION_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.authorization.context.capture') {
requireAuthorizationContextScopes(grant);
return captureAuthContextHandle({
slotId: input.slotId === 'right' ? 'right' : 'left',
accountLabel: typeof input.accountLabel === 'string' ? input.accountLabel : undefined,
isolationProofId: String(input.isolationProofId || ''),
target: await allowedTarget(grant, input),
grantId: grant.id,
grantExpiresAt: grant.expiresAt,
});
}
if (method === 'browser.authorization.context.get') {
requireAuthorizationContextScopes(grant);
return getAuthContextHandle(String(input.id || ''), grant.id);
}
if (method === 'browser.authorization.context.attest') {
requireAuthorizationContextScopes(grant);
return captureAuthContextAttestation({
target: await allowedTarget(grant, input),
grantId: grant.id,
grantExpiresAt: grant.expiresAt,
});
}
if (method === 'browser.authorization.context.attestation.get') {
requireAuthorizationContextScopes(grant);
return getAuthContextAttestation(String(input.id || ''), grant.id);
}
requireAuthorizationBaselineScopes(grant);
if (method === 'browser.authorization.baseline.capture') {
return captureAuthorizationBaseline({
target: await allowedTarget(grant, input),
grantId: grant.id,
authContextKind: input.authContextKind === 'attestation' ? 'attestation' : 'handle',
authContextId: String(input.authContextId || ''),
networkRequestId: String(input.networkRequestId || ''),
comparisonKey: String(input.comparisonKey || ''),
});
}
if (method === 'browser.authorization.baseline.candidates') {
return listAuthorizationBaselineCandidates({
target: await allowedTarget(grant, input),
grantId: grant.id,
authContextKind: input.authContextKind === 'attestation' ? 'attestation' : 'handle',
authContextId: String(input.authContextId || ''),
limit: typeof input.limit === 'number' ? input.limit : 100,
});
}
if (method === 'browser.authorization.baseline.get') {
return getAuthorizationBaseline(String(input.id || ''), grant.id);
}
if (method === 'browser.authorization.baseline.logical.bind') {
requireScope(grant, 'browser.network.sensitive.read');
requireScope(grant, 'browser.transform.execute');
return bindAuthorizationBaselineLogicalRequest({
id: String(input.id || ''),
grantId: grant.id,
profileId: String(input.profileId || ''),
comparisonKey: String(input.comparisonKey || ''),
});
}
if (method === 'browser.authorization.baseline.resource.get') {
return readAuthorizationBaselineResource({
id: String(input.id || ''),
grantId: grant.id,
selector: input.selector as BrowserAuthorizationResourceSelector,
});
}
if (method === 'browser.authorization.baseline.compile') {
requireScope(grant, 'browser.network.sensitive.read');
return compileAuthorizationBaseline({
id: String(input.id || ''),
grantId: grant.id,
selector: input.selector as BrowserAuthorizationResourceSelector,
replacement: input.replacement as Parameters<typeof compileAuthorizationBaseline>[0]['replacement'],
comparisonKey: String(input.comparisonKey || ''),
});
}
if (method === 'browser.authorization.baseline.packet.compile') {
requireScope(grant, 'browser.network.replay');
requireScope(grant, 'browser.network.sensitive.read');
return compileAuthorizationBaselinePacket({
id: String(input.id || ''),
grantId: grant.id,
});
}
if (method === 'browser.authorization.baseline.transform.inspect') {
requireScope(grant, 'browser.network.sensitive.read');
requireScope(grant, 'browser.transform.read');
return inspectAuthorizationBaselineTransform({
id: String(input.id || ''),
grantId: grant.id,
profileId: String(input.profileId || ''),
});
}
if (method === 'browser.authorization.baseline.transform.compile') {
requireScope(grant, 'browser.network.replay');
requireScope(grant, 'browser.network.sensitive.read');
requireScope(grant, 'browser.transform.execute');
return compileAuthorizationBaselineWithTransform({
id: String(input.id || ''),
grantId: grant.id,
selector: input.selector as BrowserAuthorizationResourceSelector,
replacement: input.replacement as Parameters<typeof compileAuthorizationBaselineWithTransform>[0]['replacement'],
comparisonKey: String(input.comparisonKey || ''),
profileId: String(input.profileId || ''),
bindingFingerprint: String(input.bindingFingerprint || ''),
});
}
throw new Error(`授权能力没有实现: ${method}`);
},
};
@@ -0,0 +1,50 @@
import { browser } from 'wxt/browser';
import type { HandoffReason } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget } from '../capability-context';
import { activateTab } from '@/platform/browser/targets';
import { getState, updateState } from '@/platform/storage/state';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
import { ExtensionError } from '@/shared/errors';
import { HANDOFF_CAPABILITY_DOMAIN } from '../capability-domains';
export const handoffCapabilityHandler: CapabilityDomainHandler = {
...HANDOFF_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.handoff.status') {
const handoff = (await getState()).handoff;
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
}
const resolvedTarget = await allowedTarget(grant, input);
const grantTarget = grant.targets.find((target) => (
target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId
));
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
const now = Date.now();
const state = await updateState((current) => {
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
}
if (current.handoff?.state === 'waiting_for_user') {
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
}
return {
...current,
handoff: {
id: crypto.randomUUID(),
taskId: grant.taskId,
target: grantTarget,
reason: input.reason as HandoffReason,
message: typeof input.message === 'string' ? input.message : '',
state: 'waiting_for_user' as const,
requestedAt: now,
},
};
});
await activateTab(resolvedTarget.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
await setAgentRuntimeState('waiting_for_human', grant);
return state.handoff;
},
};
@@ -0,0 +1,88 @@
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import { getFrameInventory } from '@/features/page-context/frames';
import { getTab } from '@/platform/browser/targets';
import {
createBrowserIsolationProof,
deleteFirefoxContainerIdentity,
inspectBrowserIsolation,
listFirefoxContainerIdentities,
openFirefoxContainerIdentity,
openIncognitoIdentity,
} from '@/features/authorization-testing/isolation';
import { ExtensionError } from '@/shared/errors';
import { NAVIGATION_CAPABILITY_DOMAIN } from '../capability-domains';
export const navigationCapabilityHandler: CapabilityDomainHandler = {
...NAVIGATION_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.tabs') {
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const tabs = await Promise.all(tabIds.map(async (tabId) => {
const targets = grant.targets.filter((target) => target.tabId === tabId);
for (const target of targets) {
try {
await allowedTarget(grant, {
tabId,
frameId: target.frameId,
documentId: target.documentId,
});
return getTab(tabId);
} catch {
// A tab remains visible while at least one explicitly granted frame is current.
}
}
return undefined;
}));
return tabs.filter(Boolean);
}
if (method === 'browser.frames') {
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
}
return getFrameInventory(tabId);
}
if (method === 'browser.isolation.inspect') {
const grantedTabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const requestedTabIds = Array.isArray(input.tabIds)
? input.tabIds.map(Number)
: grantedTabIds;
if (requestedTabIds.some((tabId) => !grantedTabIds.includes(tabId))) {
throw new ExtensionError(
'target_denied',
'身份隔离检查只能读取本次共享会话中的标签页',
);
}
return inspectBrowserIsolation(requestedTabIds);
}
if (method === 'browser.isolation.proof') {
requireScope(grant, 'browser.cookies.read');
requireScope(grant, 'browser.storage.read');
const leftTabId = Number(input.leftTabId);
const rightTabId = Number(input.rightTabId);
if (![leftTabId, rightTabId].every((tabId) => (
grant.targets.some((target) => target.tabId === tabId)
))) {
throw new ExtensionError(
'target_denied',
'隔离证明的两个身份都必须在本次共享会话中',
);
}
return createBrowserIsolationProof(leftTabId, rightTabId);
}
if (method === 'browser.isolation.incognito.open') {
return openIncognitoIdentity(String(input.url || ''));
}
if (method === 'browser.isolation.container.open') {
return openFirefoxContainerIdentity({
url: String(input.url || ''),
name: typeof input.name === 'string' ? input.name : undefined,
});
}
if (method === 'browser.isolation.container.list') {
return listFirefoxContainerIdentities();
}
return deleteFirefoxContainerIdentity(String(input.cookieStoreId || ''));
},
};
@@ -0,0 +1,68 @@
import type {
BrowserRequestAnalysisBundle,
YakPocGenerateResult,
} from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import {
clearNetworkRequests,
exportNetworkRequest,
listNetworkRequests,
networkCaptureStatus,
redactNetworkRequests,
startNetworkCapture,
stopNetworkCapture,
} from '@/features/network-capture/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import { ExtensionError } from '@/shared/errors';
import { NETWORK_CAPABILITY_DOMAIN } from '../capability-domains';
export const networkCapabilityHandler: CapabilityDomainHandler = {
...NETWORK_CAPABILITY_DOMAIN,
async handle({ method, input, grant, requestEngine }) {
const target = await allowedTarget(grant, input);
if (method === 'browser.network.start') {
if (input.captureHeaders === true || input.captureBody === true) {
requireScope(grant, 'browser.network.sensitive.read');
}
return startNetworkCapture(target, {
captureHeaders: input.captureHeaders === true,
captureBody: input.captureBody === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
if (method === 'browser.network.status') return networkCaptureStatus(target);
if (method === 'browser.network.list') {
const records = await listNetworkRequests(
target,
typeof input.limit === 'number' ? input.limit : 100,
);
return grant.scopes.includes('browser.network.sensitive.read')
? records
: redactNetworkRequests(records);
}
if (method === 'browser.network.clear') return clearNetworkRequests(target);
if (method === 'browser.network.stop') return stopNetworkCapture(target);
if (method === 'browser.network.export') {
return exportNetworkRequest(target, String(input.id));
}
if (!requestEngine) {
throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
}
if (method === 'browser.network.poc') {
return requestEngine<YakPocGenerateResult>(
'yakit.poc.generate',
await capturedRequestEnginePayload(target, String(input.id), false),
);
}
return requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(
target,
String(input.id),
grant.scopes.includes('browser.recording.read'),
),
);
},
};
@@ -0,0 +1,97 @@
import { browser } from 'wxt/browser';
import type { PageContextOptions } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import { activateTab } from '@/platform/browser/targets';
import {
actOnPageNode,
capturePageContext,
evalInPage,
inspectPageNode,
invokePageFunction,
} from '@/features/page-context/service';
import { listCookies } from '@/features/cookies/service';
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
import { ExtensionError } from '@/shared/errors';
import { PAGE_CAPABILITY_DOMAIN } from '../capability-domains';
export const pageCapabilityHandler: CapabilityDomainHandler = {
...PAGE_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.context') {
const options: PageContextOptions = {
includeDom: input.includeDom !== false,
includeStorage: input.includeStorage === true,
includeCookies: input.includeCookies === true,
};
if (options.includeStorage) requireScope(grant, 'browser.storage.read');
if (options.includeCookies) requireScope(grant, 'browser.cookies.read');
return capturePageContext(options, await allowedTarget(grant, input));
}
if (method === 'browser.node.inspect') {
const target = await allowedTarget(grant, input);
return inspectPageNode(String(input.captureId), String(input.nodeId), target);
}
if (method === 'browser.node.action') {
const target = await allowedTarget(grant, input);
return actOnPageNode(
String(input.captureId),
String(input.nodeId),
input.action as 'click' | 'focus' | 'scroll' | 'setValue',
target,
typeof input.value === 'string' ? input.value : undefined,
);
}
if (method === 'browser.cookies') {
const target = await allowedTarget(grant, input);
const frame = await browser.webNavigation.getFrame({
tabId: target.tabId,
frameId: target.frameId,
});
const grantTarget = grant.targets.find((item) => (
item.tabId === target.tabId && item.frameId === target.frameId
));
const url = frame?.url && /^https?:/i.test(frame.url)
? frame.url
: `${grantTarget?.origin || ''}/`;
if (!/^https?:/i.test(url)) {
throw new ExtensionError(
'target_unavailable',
'目标 frame 没有可读取 Cookie 的 HTTP 来源',
);
}
return listCookies(url, await resolveTabCookieStoreId(target.tabId));
}
if (method === 'browser.takeover') {
const target = await allowedTarget(grant, input);
await activateTab(target.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
globalThis.setTimeout(
() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }),
10_000,
);
return { activated: true, target };
}
if (method === 'browser.invoke') {
if (typeof input.path !== 'string') throw new Error('缺少页面函数路径');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return invokePageFunction(
input.path,
Array.isArray(input.args) ? input.args : [],
await allowedTarget(grant, input),
timeoutMs,
);
}
if (typeof input.code !== 'string' || !input.code.trim()) {
throw new Error('缺少页面执行代码');
}
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return evalInPage(
input.code,
input.mode as 'expression' | 'program',
await allowedTarget(grant, input),
timeoutMs,
);
},
};
@@ -0,0 +1,14 @@
import type { CapabilityDomainHandler } from '../capability-context';
import { getState } from '@/platform/storage/state';
import { switchProxy } from '@/features/proxy/service';
import { PROXY_CAPABILITY_DOMAIN } from '../capability-domains';
export const proxyCapabilityHandler: CapabilityDomainHandler = {
...PROXY_CAPABILITY_DOMAIN,
async handle({ method, input }) {
if (method === 'proxy.list') return (await getState()).proxyProfiles;
if (typeof input.id !== 'string') throw new Error('缺少代理配置 ID');
await switchProxy(input.id);
return { activeProxyId: input.id };
},
};
@@ -0,0 +1,174 @@
import type { BrowserDeepCaptureMatcher } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import {
browserRecordingStatus,
clearBrowserRecording,
createRecordedPageCallable,
getBrowserRecording,
startBrowserRecording,
stopBrowserRecording,
} from '@/features/browser-recording/service';
import {
createCapturedPageCallable,
deepCaptureStatus,
detachDeepCapture,
keepDeepCaptureAlive,
resumeDeepCapture,
startDeepCapture,
} from '@/features/deep-capture/service';
import {
deletePageCallable,
executePageCallable,
listPageCallables,
} from '@/features/page-callable/service';
import { invalidateBrowserTransformProfilesForCallable } from '@/features/browser-transform/service';
import {
callableInspect,
callableReplay,
recordingEvidenceInspect,
recordingTraceList,
resolveBrowserProfileCallableAnalysis,
resolveBrowserProfileCaptureContext,
stageBrowserProfileEvidence,
} from '@/features/browser-analysis/service';
import { RECORDING_CAPABILITY_DOMAIN } from '../capability-domains';
export const recordingCapabilityHandler: CapabilityDomainHandler = {
...RECORDING_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method.startsWith('browser.recording.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.recording.trace.list') {
return recordingTraceList(target, typeof input.limit === 'number' ? input.limit : 40);
}
if (method === 'browser.recording.evidence.inspect') {
const includeValues = input.includeValues === true;
if (includeValues) requireScope(grant, 'browser.recording.sensitive.read');
return recordingEvidenceInspect(
target,
String(input.traceId || ''),
typeof input.eventId === 'string' ? input.eventId : undefined,
includeValues,
);
}
if (method === 'browser.recording.start') {
if (input.captureValues === true) {
requireScope(grant, 'browser.recording.sensitive.read');
}
return startBrowserRecording(target, {
captureValues: input.captureValues === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxValueBytes: typeof input.maxValueBytes === 'number' ? input.maxValueBytes : undefined,
expiresAt: grant.expiresAt,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
if (method === 'browser.recording.status') return browserRecordingStatus(target);
if (method === 'browser.recording.get') {
const snapshot = await getBrowserRecording(
target,
typeof input.limit === 'number' ? input.limit : 500,
grant.scopes.includes('browser.recording.sensitive.read'),
);
await stageBrowserProfileEvidence(snapshot);
return snapshot;
}
if (method === 'browser.recording.clear') {
return clearBrowserRecording(
target,
grant.scopes.includes('browser.recording.sensitive.read'),
);
}
const snapshot = await stopBrowserRecording(
target,
grant.scopes.includes('browser.recording.sensitive.read'),
);
await stageBrowserProfileEvidence(snapshot);
return snapshot;
}
if (method.startsWith('browser.callable.')) {
const source = String(input.source || '');
const target = await allowedTarget(grant, input, source !== 'deep-capture');
if (method === 'browser.callable.inspect') {
return callableInspect(
target,
typeof input.callableId === 'string' ? input.callableId : undefined,
);
}
if (method === 'browser.callable.replay') {
return callableReplay(
target,
String(input.callableId || ''),
Array.isArray(input.args) ? input.args : [],
);
}
if (method === 'browser.callable.list') return listPageCallables(target);
if (method === 'browser.callable.create') {
if (source === 'deep-capture') {
requireScope(grant, 'browser.debugger.control');
if (input.strategy === 'request-transaction') {
const capture = await resolveBrowserProfileCaptureContext(
target,
String(input.candidateId || ''),
);
return createCapturedPageCallable(target, String(input.callFrameId || ''), {
strategy: 'request-transaction',
name: typeof input.name === 'string' ? input.name : undefined,
transaction: capture.transaction,
analysis: capture.analysis,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
const selectedFrameAnalysis = input.strategy === 'selected-frame' && typeof input.candidateId === 'string'
? await resolveBrowserProfileCallableAnalysis(target, input.candidateId)
: undefined;
return createCapturedPageCallable(
target,
String(input.callFrameId || ''),
input.strategy === 'expression' ? {
strategy: 'expression',
name: String(input.name || ''),
functionExpression: String(input.functionExpression || ''),
} : {
strategy: 'selected-frame',
name: typeof input.name === 'string' ? input.name : undefined,
analysis: selectedFrameAnalysis,
},
{ kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt },
);
}
return createRecordedPageCallable(target, {
callHandleId: String(input.callHandleId || ''),
name: String(input.name || ''),
});
}
if (method === 'browser.callable.execute') {
return executePageCallable(
target,
String(input.callableId || ''),
Array.isArray(input.args) ? input.args : [],
);
}
const callableId = String(input.callableId || '');
const callables = await deletePageCallable(target, callableId);
await invalidateBrowserTransformProfilesForCallable(target, callableId);
return callables;
}
const target = await allowedTarget(
grant,
input,
method === 'browser.deep_capture.start',
);
const owner = { kind: 'grant' as const, grantId: grant.id, expiresAt: grant.expiresAt };
if (method === 'browser.deep_capture.start') {
return startDeepCapture(target, input.matcher as BrowserDeepCaptureMatcher, owner);
}
if (method === 'browser.deep_capture.status') return deepCaptureStatus(target, owner);
if (method === 'browser.deep_capture.keepalive') return keepDeepCaptureAlive(target, owner);
if (method === 'browser.deep_capture.resume') {
return resumeDeepCapture(target, 'engine-request', owner);
}
return detachDeepCapture(target, owner);
},
};
@@ -0,0 +1,145 @@
import type {
BrowserTransformExecuteInput,
BrowserTransformPacket,
BrowserTransformProfileInput,
} from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import {
captureBrowserTransformRecovery,
confirmBrowserTransformRecovery,
deleteBrowserTransformProfile,
executeBrowserTransform,
getBrowserTransformProfile,
getBrowserTransformRecovery,
listBrowserTransformProfiles,
resetBrowserTransformRecovery,
saveBrowserTransformProfile,
startBrowserTransformRecovery,
validateBrowserTransformRecovery,
} from '@/features/browser-transform/service';
import {
compareBrowserPackets,
latestBrowserTransformValidation,
proposeBrowserTransformProfile,
validateInferredBrowserTransformProfile,
} from '@/features/browser-analysis/service';
import { ExtensionError } from '@/shared/errors';
import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains';
export const transformCapabilityHandler: CapabilityDomainHandler = {
...TRANSFORM_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.packet.compare') {
await allowedTarget(grant, input);
return compareBrowserPackets(
input.actual as BrowserTransformPacket,
input.expected as BrowserTransformPacket,
input.mode === 'exact' ? 'exact' : 'structure',
);
}
if (method === 'browser.profile.propose') {
requireScope(grant, 'browser.recording.read');
const target = await allowedTarget(grant, input);
return proposeBrowserTransformProfile(
target,
String(input.candidateId || ''),
String(input.callableId || ''),
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
typeof input.name === 'string' ? input.name : undefined,
);
}
if (method === 'browser.profile.validation.latest') {
return latestBrowserTransformValidation(await allowedTarget(grant, input));
}
if (method === 'browser.profile.validate') {
requireScope(grant, 'browser.recording.read');
const target = await allowedTarget(grant, input);
return validateInferredBrowserTransformProfile(
target,
String(input.candidateId || ''),
String(input.callableId || ''),
input.packet as BrowserTransformPacket,
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
typeof input.name === 'string' ? input.name : undefined,
input.observed as BrowserTransformPacket | undefined,
input.comparisonMode === 'exact' ? 'exact' : 'structure',
);
}
if (method.startsWith('browser.transform.recovery.')) {
const profile = await getBrowserTransformProfile(String(input.id || ''));
const resolveInPage = method !== 'browser.transform.recovery.capture'
&& method !== 'browser.transform.recovery.reset';
const target = await allowedTarget(grant, {
tabId: profile.target.tabId,
frameId: profile.target.frameId,
...(typeof input.documentId === 'string' ? { documentId: input.documentId } : {}),
}, resolveInPage);
const owner = { kind: 'grant' as const, grantId: grant.id, expiresAt: grant.expiresAt };
if (method === 'browser.transform.recovery.get') {
return getBrowserTransformRecovery(profile.id);
}
if (method === 'browser.transform.recovery.start') {
requireScope(grant, 'browser.debugger.control');
return startBrowserTransformRecovery(profile.id, owner);
}
if (method === 'browser.transform.recovery.capture') {
requireScope(grant, 'browser.debugger.control');
requireScope(grant, 'browser.callable.execute');
return captureBrowserTransformRecovery(
profile.id,
target,
String(input.callFrameId || ''),
input.strategy === 'request-transaction'
? 'request-transaction'
: 'selected-frame',
owner,
);
}
if (method === 'browser.transform.recovery.validate') {
return validateBrowserTransformRecovery(
profile.id,
input.packet as BrowserTransformPacket,
);
}
if (method === 'browser.transform.recovery.confirm') {
return confirmBrowserTransformRecovery(profile.id, String(input.validationId || ''));
}
return resetBrowserTransformRecovery(profile.id, owner);
}
if (method === 'browser.transform.profile.list') {
const profiles = await listBrowserTransformProfiles();
const visible = await Promise.all(profiles.map(async (profile) => {
try {
await allowedTarget(grant, profile.target);
return profile;
} catch {
return undefined;
}
}));
return visible.filter(Boolean);
}
if (method === 'browser.transform.profile.save') {
const profileInput = input as unknown as BrowserTransformProfileInput;
const target = await allowedTarget(grant, profileInput.target);
const grantedTarget = grant.targets.find((item) => (
item.tabId === target.tabId && item.frameId === target.frameId
));
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
}
return saveBrowserTransformProfile({ ...profileInput, target });
}
if (method === 'browser.transform.profile.delete') {
const profile = await getBrowserTransformProfile(String(input.id || ''));
await allowedTarget(grant, profile.target);
return deleteBrowserTransformProfile(profile.id);
}
const executeInput = input as unknown as BrowserTransformExecuteInput;
const profile = await getBrowserTransformProfile(executeInput.profileId);
await allowedTarget(grant, profile.target);
return executeBrowserTransform(executeInput);
},
};
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { capabilityParams } from '@/protocol/bridge';
import { capabilityDomainOwners } from './capability-domains';
describe('Grant capability handler registry', () => {
it('assigns every negotiated capability to exactly one domain', () => {
const methods = Object.keys(capabilityParams).filter((method) => method !== 'system.ping');
const invalid = methods.map((method) => ({
method,
owners: capabilityDomainOwners(method).map((owner) => owner.id),
})).filter((entry) => entry.owners.length !== 1);
expect(invalid).toEqual([]);
});
it('does not claim undeclared methods by accident', () => {
expect(capabilityDomainOwners('browser.unknown.future')).toEqual([]);
});
});
+38
View File
@@ -0,0 +1,38 @@
import type {
CapabilityDomainHandler,
CapabilityRouteContext,
} from './capability-context';
import { navigationCapabilityHandler } from './capability-handlers/navigation';
import { authorizationCapabilityHandler } from './capability-handlers/authorization';
import { handoffCapabilityHandler } from './capability-handlers/handoff';
import { networkCapabilityHandler } from './capability-handlers/network';
import { recordingCapabilityHandler } from './capability-handlers/recording';
import { transformCapabilityHandler } from './capability-handlers/transform';
import { pageCapabilityHandler } from './capability-handlers/page';
import { proxyCapabilityHandler } from './capability-handlers/proxy';
export const CAPABILITY_HANDLERS: readonly CapabilityDomainHandler[] = [
navigationCapabilityHandler,
authorizationCapabilityHandler,
handoffCapabilityHandler,
networkCapabilityHandler,
recordingCapabilityHandler,
transformCapabilityHandler,
pageCapabilityHandler,
proxyCapabilityHandler,
];
export function capabilityOwners(method: string): CapabilityDomainHandler[] {
return CAPABILITY_HANDLERS.filter((handler) => handler.owns(method));
}
export async function dispatchCapability(context: CapabilityRouteContext): Promise<unknown> {
const owners = capabilityOwners(context.method);
if (owners.length === 0) throw new Error(`不支持的 Bridge 方法: ${context.method}`);
if (owners.length > 1) {
throw new Error(
`Bridge 方法 ${context.method} 被多个领域重复注册: ${owners.map((item) => item.id).join(', ')}`,
);
}
return owners[0].handle(context);
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { CONTROL_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import type { ActiveTabInfo, ExtensionState } from '@/types/models';
import { authorizationShareGrantInput, gatewayShareActive, gatewayShareGrantInput } from './gateway-share';
const NOW = 1_000_000;
const tab: ActiveTabInfo = {
id: 7,
windowId: 1,
title: 'Login',
url: 'https://app.example.test/login',
incognito: false,
};
function state(): ExtensionState {
return {
version: 7,
} as ExtensionState;
}
describe('gateway quick share', () => {
it('creates a 30 minute control grant for the current main frame', () => {
const input = gatewayShareGrantInput(state(), tab, NOW);
expect(input.targets).toEqual([{ tabId: 7, frameId: 0 }]);
expect(input.durationMinutes).toBe(30);
expect(input.scopes).toEqual(expect.arrayContaining(CONTROL_CAPABILITY_SCOPES));
});
it('preserves an active session while adding the current tab and gateway capabilities', () => {
const current = state();
current.activeGrant = {
id: 'grant',
taskId: 'task',
createdAt: NOW - 10_000,
expiresAt: NOW + 60 * 60_000,
scopes: ['browser.tabs.read'],
targets: [{
tabId: 3,
frameId: 0,
documentId: 'document-a',
isolationContextId: 'profile:default',
origin: 'https://other.example.test',
grantedUrl: 'https://other.example.test/',
title: 'Other',
}],
};
const input = gatewayShareGrantInput(current, tab, NOW);
expect(input.targets).toEqual([
{ tabId: 3, frameId: 0 },
{ tabId: 7, frameId: 0 },
]);
expect(input.durationMinutes).toBe(60);
expect(input.taskId).toBe('task');
expect(current.activeGrant.scopes).toEqual(['browser.tabs.read']);
});
it('only reports ready when the tab, origin, lifetime and control scopes match', () => {
const current = state();
const input = gatewayShareGrantInput(current, tab, NOW);
const grant = {
id: 'grant',
taskId: 'task',
createdAt: NOW,
expiresAt: NOW + 30 * 60_000,
scopes: input.scopes,
targets: [{
tabId: tab.id,
frameId: 0,
documentId: 'document',
isolationContextId: 'profile:default',
origin: 'https://app.example.test',
grantedUrl: tab.url,
title: tab.title,
}],
};
expect(gatewayShareActive(grant, tab, NOW)).toBe(true);
expect(gatewayShareActive({ ...grant, expiresAt: NOW }, tab, NOW)).toBe(false);
expect(gatewayShareActive({ ...grant, scopes: ['browser.tabs.read'] }, tab, NOW)).toBe(false);
expect(gatewayShareActive(grant, { ...tab, url: 'https://elsewhere.example.test/' }, NOW)).toBe(false);
});
it('creates a focused two-tab authorization grant without retaining unrelated targets', () => {
const current = state();
current.activeGrant = {
id: 'grant',
taskId: 'existing-task',
createdAt: NOW - 1_000,
expiresAt: NOW + 45 * 60_000,
scopes: ['browser.tabs.read'],
targets: [{
tabId: 99,
frameId: 0,
documentId: 'unrelated',
isolationContextId: 'unrelated',
origin: 'https://other.example.test',
grantedUrl: 'https://other.example.test',
title: 'Unrelated',
}],
};
const right = { ...tab, id: 8, incognito: true };
const input = authorizationShareGrantInput(current, [tab, right], NOW);
expect(input.targets).toEqual([
{ tabId: 7, frameId: 0 },
{ tabId: 8, frameId: 0 },
]);
expect(input.scopes).toEqual(expect.arrayContaining(CONTROL_CAPABILITY_SCOPES));
expect(input.durationMinutes).toBe(45);
expect(input.taskId).toBe('existing-task');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { CONTROL_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import type {
ActiveTabInfo,
BridgeGrant,
CapabilityScope,
ExtensionState,
GrantCreateInput,
} from '@/types/models';
const DEFAULT_GATEWAY_GRANT_MINUTES = 30;
function targetKey(target: { tabId: number; frameId: number }): string {
return `${target.tabId}:${target.frameId}`;
}
function tabOrigin(tab: ActiveTabInfo): string {
try {
return new URL(tab.url).origin;
} catch {
return '';
}
}
export function gatewayShareActive(
grant: BridgeGrant | undefined,
tab: ActiveTabInfo | undefined,
now = Date.now(),
): boolean {
if (!grant || !tab || grant.expiresAt <= now) return false;
if (!CONTROL_CAPABILITY_SCOPES.every((scope) => grant.scopes.includes(scope))) return false;
const origin = tabOrigin(tab);
return grant.targets.some((target) => (
target.tabId === tab.id
&& target.frameId === 0
&& (!origin || target.origin === origin)
));
}
export function gatewayShareGrantInput(
state: ExtensionState,
tab: ActiveTabInfo,
now = Date.now(),
): GrantCreateInput {
const active = state.activeGrant && state.activeGrant.expiresAt > now
? state.activeGrant
: undefined;
const targets = new Map<string, { tabId: number; frameId: number }>();
active?.targets.forEach((target) => {
targets.set(targetKey(target), { tabId: target.tabId, frameId: target.frameId });
});
targets.set(targetKey({ tabId: tab.id, frameId: 0 }), { tabId: tab.id, frameId: 0 });
const scopes = new Set<CapabilityScope>(active?.scopes || []);
CONTROL_CAPABILITY_SCOPES.forEach((scope) => scopes.add(scope));
const remainingMinutes = active
? Math.ceil((active.expiresAt - now) / 60_000)
: 0;
return {
targets: [...targets.values()],
scopes: [...scopes],
durationMinutes: Math.max(DEFAULT_GATEWAY_GRANT_MINUTES, remainingMinutes),
taskId: active?.taskId,
};
}
export function authorizationShareGrantInput(
state: ExtensionState,
tabs: [ActiveTabInfo, ActiveTabInfo],
now = Date.now(),
): GrantCreateInput {
const active = state.activeGrant && state.activeGrant.expiresAt > now
? state.activeGrant
: undefined;
const scopes = new Set<CapabilityScope>(active?.scopes || []);
CONTROL_CAPABILITY_SCOPES.forEach((scope) => scopes.add(scope));
const remainingMinutes = active
? Math.ceil((active.expiresAt - now) / 60_000)
: 0;
return {
targets: tabs.map((item) => ({ tabId: item.id, frameId: 0 })),
scopes: [...scopes],
durationMinutes: Math.max(DEFAULT_GATEWAY_GRANT_MINUTES, remainingMinutes),
taskId: active?.taskId,
};
}
+284
View File
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BridgeGrant, HumanHandoff } from '@/types/models';
const fixture = vi.hoisted(() => ({
local: {} as Record<string, unknown>,
session: {} as Record<string, unknown>,
alarms: new Map<string, { when?: number }>(),
alarmListeners: [] as Array<(alarm: { name: string }) => void>,
alarmClear: vi.fn(async (_name: string) => false),
alarmCreate: vi.fn(async (_name: string, _options: { when?: number }) => undefined),
stopNetwork: vi.fn(async (_grantId: string) => undefined),
stopRecording: vi.fn(async (_grantId: string) => undefined),
stopDeepCapture: vi.fn(async (_grantId: string) => undefined),
startRuntime: vi.fn(async (_grant: BridgeGrant) => ({ state: 'running' })),
endRuntime: vi.fn(async (_state: 'revoked' | 'expired', _grant: BridgeGrant) => ({ state: 'revoked' })),
appendAudit: vi.fn(async () => undefined),
clearBadge: vi.fn(async () => undefined),
}));
function area(data: Record<string, unknown>) {
return {
async get(keys: string | string[]) {
const list = Array.isArray(keys) ? keys : [keys];
return Object.fromEntries(list.filter((key) => key in data).map((key) => [key, data[key]]));
},
async set(items: Record<string, unknown>) {
Object.assign(data, structuredClone(items));
},
};
}
vi.mock('wxt/browser', () => ({
browser: {
storage: { local: area(fixture.local), session: area(fixture.session) },
alarms: {
clear: fixture.alarmClear,
create: fixture.alarmCreate,
onAlarm: { addListener: vi.fn((listener: (alarm: { name: string }) => void) => fixture.alarmListeners.push(listener)) },
},
action: { setBadgeText: fixture.clearBadge },
},
}));
vi.mock('@/features/network-capture/service', () => ({
stopNetworkCapturesForGrant: fixture.stopNetwork,
}));
vi.mock('@/features/browser-recording/service', () => ({
stopBrowserRecordingsForGrant: fixture.stopRecording,
}));
vi.mock('@/features/deep-capture/service', () => ({
stopDeepCapturesForGrant: fixture.stopDeepCapture,
}));
vi.mock('@/features/agent-runtime/service', () => ({
startAgentRuntime: fixture.startRuntime,
endAgentRuntimeForGrant: fixture.endRuntime,
}));
vi.mock('@/features/diagnostics/audit', () => ({
appendAuditEvent: fixture.appendAudit,
}));
import { DEFAULT_STATE, getState, setState } from '@/platform/storage/state';
import {
ACTIVE_GRANT_EXPIRY_ALARM,
configureGrantLifecycleHooks,
currentActiveGrant,
registerGrantLifecycleListeners,
replaceActiveGrant,
restoreGrantLifecycle,
revokeActiveGrant,
updateActiveGrant,
} from './lifecycle';
const NOW = 4_102_444_800_000;
function grant(id: string, expiresAt = NOW + 60_000): BridgeGrant {
return {
id,
taskId: `task-${id}`,
createdAt: NOW - 1_000,
expiresAt,
scopes: ['browser.tabs.read'],
targets: [{
tabId: 1,
frameId: 0,
documentId: `document-${id}`,
isolationContextId: 'browser-profile:store-1',
cookieStoreId: 'store-1',
origin: 'https://example.test',
grantedUrl: 'https://example.test/',
title: 'Example',
}],
};
}
function handoff(id: string): HumanHandoff {
return {
id,
taskId: 'task-old',
target: grant('handoff').targets[0],
reason: 'mfa',
message: 'Confirm',
state: 'waiting_for_user',
requestedAt: NOW - 2_000,
};
}
describe('grant lifecycle manager', () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
for (const key of Object.keys(fixture.local)) delete fixture.local[key];
for (const key of Object.keys(fixture.session)) delete fixture.session[key];
fixture.alarms.clear();
vi.clearAllMocks();
fixture.alarmClear.mockImplementation(async (name) => fixture.alarms.delete(name));
fixture.alarmCreate.mockImplementation(async (name, options) => {
fixture.alarms.set(name, options);
});
configureGrantLifecycleHooks({});
await setState(structuredClone(DEFAULT_STATE));
});
afterEach(() => vi.useRealTimers());
it('consumes an expired stored grant and releases all grant-owned resources', async () => {
const expired = grant('expired-restore', NOW - 1);
const cancelActiveRequests = vi.fn();
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
configureGrantLifecycleHooks({ cancelActiveRequests });
const state = await restoreGrantLifecycle();
expect(state.activeGrant).toBeUndefined();
expect((await getState()).activeGrant).toBeUndefined();
expect(cancelActiveRequests).toHaveBeenCalledOnce();
expect(fixture.stopNetwork).toHaveBeenCalledWith(expired.id);
expect(fixture.stopRecording).toHaveBeenCalledWith(expired.id);
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(expired.id);
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', expired);
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
});
it('serializes concurrent replacements and cleans the actual previous grant from each commit', async () => {
const old = grant('replace-old');
const first = grant('replace-first', NOW + 120_000);
const second = grant('replace-second', NOW + 180_000);
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: old });
await Promise.all([replaceActiveGrant(first), replaceActiveGrant(second)]);
expect((await getState()).activeGrant?.id).toBe(second.id);
expect(fixture.stopNetwork.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
expect(fixture.stopRecording.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
expect(fixture.stopDeepCapture.mock.calls.map(([id]) => id)).toEqual([old.id, first.id]);
expect(fixture.startRuntime.mock.calls.map(([item]) => item.id)).toEqual([first.id, second.id]);
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: second.expiresAt });
});
it('makes repeated revocation idempotent', async () => {
const active = grant('revoke-once');
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: active });
const first = await revokeActiveGrant();
const second = await revokeActiveGrant();
expect(first.previousGrant?.id).toBe(active.id);
expect(second.previousGrant).toBeUndefined();
expect(fixture.stopNetwork).toHaveBeenCalledTimes(1);
expect(fixture.stopRecording).toHaveBeenCalledTimes(1);
expect(fixture.stopDeepCapture).toHaveBeenCalledTimes(1);
expect(fixture.endRuntime).toHaveBeenCalledTimes(1);
});
it('cancels a waiting handoff and publishes the resolved state on replacement', async () => {
const waiting = handoff('handoff-waiting');
const emitHandoffChanged = vi.fn();
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: grant('handoff-old'),
handoff: waiting,
});
configureGrantLifecycleHooks({ emitHandoffChanged });
const { state } = await replaceActiveGrant(grant('handoff-new'));
expect(state.handoff).toMatchObject({ id: waiting.id, state: 'cancelled', resolvedAt: NOW });
expect(fixture.clearBadge).toHaveBeenCalledWith({ text: '', tabId: waiting.target.tabId });
expect(emitHandoffChanged).toHaveBeenCalledWith(state.handoff);
});
it('rejects an update that reaches the queue after expiry and performs cleanup first', async () => {
const expired = grant('expired-update', NOW - 1);
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: expired });
await expect(updateActiveGrant(expired.id, (item) => item)).rejects.toMatchObject({ code: 'grant_expired' });
expect((await getState()).activeGrant).toBeUndefined();
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(expired.id);
});
it('returns a live grant without rewriting its expiry alarm on every capability lookup', async () => {
const active = grant('lookup-live');
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: active });
await restoreGrantLifecycle();
fixture.alarms.clear();
expect((await currentActiveGrant())?.id).toBe(active.id);
expect(fixture.alarms.size).toBe(0);
});
it('expires the active grant when the exact lifecycle alarm fires', async () => {
const active = grant('alarm-expiry', NOW + 30_000);
registerGrantLifecycleListeners();
await replaceActiveGrant(active);
vi.setSystemTime(active.expiresAt + 1);
fixture.alarmListeners.at(-1)?.({ name: ACTIVE_GRANT_EXPIRY_ALARM });
await currentActiveGrant();
expect((await getState()).activeGrant).toBeUndefined();
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
expect(fixture.endRuntime).toHaveBeenCalledWith('expired', active);
});
it('reschedules an early alarm without revoking a still-live grant', async () => {
const active = grant('alarm-early', NOW + 30_000);
registerGrantLifecycleListeners();
await replaceActiveGrant(active);
fixture.alarms.clear();
fixture.alarmListeners.at(-1)?.({ name: ACTIVE_GRANT_EXPIRY_ALARM });
await currentActiveGrant();
expect((await getState()).activeGrant?.id).toBe(active.id);
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: active.expiresAt });
expect(fixture.stopNetwork).not.toHaveBeenCalledWith(active.id);
});
it('does not commit a replacement when its expiry alarm cannot be scheduled', async () => {
const old = grant('alarm-old');
const next = grant('alarm-next', NOW + 120_000);
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: old });
fixture.alarms.set(ACTIVE_GRANT_EXPIRY_ALARM, { when: old.expiresAt });
fixture.alarmCreate.mockRejectedValueOnce(new Error('alarms unavailable'));
await expect(replaceActiveGrant(next)).rejects.toThrow('alarms unavailable');
expect((await getState()).activeGrant?.id).toBe(old.id);
expect(fixture.alarms.get(ACTIVE_GRANT_EXPIRY_ALARM)).toEqual({ when: old.expiresAt });
expect(fixture.stopNetwork).not.toHaveBeenCalled();
expect(fixture.startRuntime).not.toHaveBeenCalled();
});
it('fails closed when Agent Runtime activation fails after the grant commit', async () => {
const active = grant('runtime-failure');
fixture.startRuntime.mockRejectedValueOnce(new Error('session storage unavailable'));
await expect(replaceActiveGrant(active)).rejects.toMatchObject({ code: 'grant_activation_failed' });
expect((await getState()).activeGrant).toBeUndefined();
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
expect(fixture.stopRecording).toHaveBeenCalledWith(active.id);
expect(fixture.stopDeepCapture).toHaveBeenCalledWith(active.id);
expect(fixture.alarms.has(ACTIVE_GRANT_EXPIRY_ALARM)).toBe(false);
});
it('clears authorization state even when one resource cleanup reports a failure', async () => {
const active = grant('partial-cleanup');
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await setState({ ...structuredClone(DEFAULT_STATE), activeGrant: active });
fixture.stopDeepCapture.mockRejectedValueOnce(new Error('debugger detach failed'));
await revokeActiveGrant();
expect((await getState()).activeGrant).toBeUndefined();
expect(fixture.stopNetwork).toHaveBeenCalledWith(active.id);
expect(fixture.stopRecording).toHaveBeenCalledWith(active.id);
expect(fixture.appendAudit).toHaveBeenCalledWith(expect.objectContaining({
action: 'grant.cleanup',
outcome: 'error',
errorCode: 'grant_cleanup_failed',
}));
consoleError.mockRestore();
});
});
+338
View File
@@ -0,0 +1,338 @@
import { browser } from 'wxt/browser';
import { stopNetworkCapturesForGrant } from '@/features/network-capture/service';
import { stopBrowserRecordingsForGrant } from '@/features/browser-recording/service';
import { stopDeepCapturesForGrant } from '@/features/deep-capture/service';
import {
endAgentRuntimeForGrant, startAgentRuntime,
} from '@/features/agent-runtime/service';
import { appendAuditEvent } from '@/features/diagnostics/audit';
import { getState, updateState } from '@/platform/storage/state';
import type {
BridgeGrant, BridgeGrantTarget, ExtensionState, HumanHandoff,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry';
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure' | 'activation_failure';
interface GrantLifecycleHooks {
cancelActiveRequests?: () => void;
emitHandoffChanged?: (handoff: HumanHandoff) => void;
}
export interface GrantTransition {
state: ExtensionState;
previousGrant?: BridgeGrant;
previousHandoff?: HumanHandoff;
}
let lifecycleQueue: Promise<void> = Promise.resolve();
let listenersRegistered = false;
let hooks: GrantLifecycleHooks = {};
const cleanupTasks = new Map<string, Promise<void>>();
const cleanupOrder: string[] = [];
function enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
const next = lifecycleQueue.then(operation);
lifecycleQueue = next.then(() => undefined, () => undefined);
return next;
}
function rememberCleanup(grantId: string, task: Promise<void>): Promise<void> {
cleanupTasks.set(grantId, task);
cleanupOrder.push(grantId);
while (cleanupOrder.length > 256) {
const oldest = cleanupOrder.shift();
if (oldest) cleanupTasks.delete(oldest);
}
return task;
}
async function synchronizeExpiryAlarm(grant?: BridgeGrant): Promise<void> {
if (grant && grant.expiresAt > Date.now()) {
await browser.alarms.create(ACTIVE_GRANT_EXPIRY_ALARM, { when: grant.expiresAt });
return;
}
await browser.alarms.clear(ACTIVE_GRANT_EXPIRY_ALARM);
}
async function clearExpiryAlarmBestEffort(grant: BridgeGrant): Promise<void> {
try {
await synchronizeExpiryAlarm(undefined);
} catch (error) {
console.error('Grant expiry alarm cleanup failed', error);
void appendAuditEvent({
category: 'grant',
action: 'grant.expiry_alarm.clear',
outcome: 'error',
taskId: grant.taskId,
targetTabId: grant.targets[0]?.tabId,
errorCode: 'grant_alarm_failed',
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
});
}
}
function cancelledHandoff(current: HumanHandoff | undefined, now: number): HumanHandoff | undefined {
return current?.state === 'waiting_for_user'
? { ...current, state: 'cancelled', resolvedAt: now }
: current;
}
function cancelActiveRequestsBestEffort(grant: BridgeGrant): void {
try {
hooks.cancelActiveRequests?.();
} catch (error) {
console.error('Grant request cancellation failed', error);
void appendAuditEvent({
category: 'grant',
action: 'grant.requests.cancel',
outcome: 'error',
taskId: grant.taskId,
targetTabId: grant.targets[0]?.tabId,
errorCode: 'grant_request_cancel_failed',
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
});
}
}
async function publishCancelledHandoff(
previous: HumanHandoff | undefined,
current: HumanHandoff | undefined,
reason: GrantEndReason,
): Promise<void> {
if (previous?.state !== 'waiting_for_user' || !current) return;
await browser.action.setBadgeText({ text: '', tabId: previous.target.tabId }).catch(() => undefined);
try {
hooks.emitHandoffChanged?.(current);
} catch (error) {
console.error('Handoff cancellation event failed', error);
}
void appendAuditEvent({
category: 'handoff',
action: 'handoff.cancelled',
outcome: 'cancelled',
taskId: previous.taskId,
targetTabId: previous.target.tabId,
summary: reason === 'expired'
? '共享会话到期时取消'
: reason === 'replaced' ? '创建新共享会话时取消' : '撤销共享会话时取消',
});
}
function cleanupGrantResources(grant: BridgeGrant, reason: GrantEndReason): Promise<void> {
const existing = cleanupTasks.get(grant.id);
if (existing) return existing;
const runtimeState = reason === 'expired' ? 'expired' as const : 'revoked' as const;
const task = Promise.allSettled([
stopNetworkCapturesForGrant(grant.id),
stopBrowserRecordingsForGrant(grant.id),
stopDeepCapturesForGrant(grant.id),
endAgentRuntimeForGrant(runtimeState, grant),
]).then((results) => {
const failures = results.filter((result) => result.status === 'rejected');
if (failures.length === 0) return;
void appendAuditEvent({
category: 'grant',
action: 'grant.cleanup',
outcome: 'error',
taskId: grant.taskId,
targetTabId: grant.targets[0]?.tabId,
errorCode: 'grant_cleanup_failed',
summary: `${failures.length} 个会话资源清理失败`,
});
console.error(`Grant ${grant.id} cleanup failed`, failures);
});
return rememberCleanup(grant.id, task);
}
async function endActiveGrantInQueue(
reason: GrantEndReason,
expectedGrantId?: string,
now = Date.now(),
): Promise<GrantTransition> {
let previousGrant: BridgeGrant | undefined;
let previousHandoff: HumanHandoff | undefined;
const state = await updateState((current) => {
const grant = current.activeGrant;
if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current;
if (reason === 'expired' && grant.expiresAt > now) return current;
previousGrant = grant;
previousHandoff = current.handoff;
return {
...current,
activeGrant: undefined,
handoff: cancelledHandoff(current.handoff, now),
};
});
if (!previousGrant) {
if (state.activeGrant) await synchronizeExpiryAlarm(state.activeGrant);
else await browser.alarms.clear(ACTIVE_GRANT_EXPIRY_ALARM).catch(() => false);
return { state };
}
cancelActiveRequestsBestEffort(previousGrant);
await clearExpiryAlarmBestEffort(previousGrant);
await cleanupGrantResources(previousGrant, reason);
await publishCancelledHandoff(previousHandoff, state.handoff, reason);
void appendAuditEvent({
category: 'grant',
action: reason === 'expired' ? 'grant.expire' : 'grant.revoke',
outcome: 'success',
taskId: previousGrant.taskId,
targetTabId: previousGrant.targets[0]?.tabId,
summary: reason === 'replaced'
? '已由新共享会话替换'
: reason === 'scheduler_failure'
? '无法建立可靠的到期调度,已安全撤销'
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined,
});
return { state, previousGrant, previousHandoff };
}
export function configureGrantLifecycleHooks(next: GrantLifecycleHooks): void {
hooks = { ...next };
}
export function registerGrantLifecycleListeners(): void {
if (listenersRegistered) return;
listenersRegistered = true;
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== ACTIVE_GRANT_EXPIRY_ALARM) return;
void reconcileGrantLifecycle(true).catch((error) => {
console.error('Grant expiry reconciliation failed', error);
});
});
}
export function restoreGrantLifecycle(): Promise<ExtensionState> {
return reconcileGrantLifecycle(true).then((transition) => transition.state);
}
async function reconcileGrantLifecycle(synchronizeAlarm: boolean): Promise<GrantTransition> {
return enqueueLifecycle(async () => {
const current = await getState();
if (current.activeGrant?.expiresAt && current.activeGrant.expiresAt <= Date.now()) {
return endActiveGrantInQueue('expired', current.activeGrant.id);
}
if (synchronizeAlarm) {
try {
await synchronizeExpiryAlarm(current.activeGrant);
} catch (error) {
if (!current.activeGrant) {
console.error('Stale Grant expiry alarm cleanup failed', error);
return { state: current };
}
void appendAuditEvent({
category: 'grant',
action: 'grant.expiry_alarm.schedule',
outcome: 'error',
taskId: current.activeGrant.taskId,
targetTabId: current.activeGrant.targets[0]?.tabId,
errorCode: 'grant_alarm_failed',
summary: (error instanceof Error ? error.message : String(error)).slice(0, 512),
});
return endActiveGrantInQueue('scheduler_failure', current.activeGrant.id);
}
}
return { state: current };
});
}
export function currentActiveGrant(): Promise<BridgeGrant | undefined> {
return reconcileGrantLifecycle(false).then(({ state }) => state.activeGrant);
}
export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition> {
return enqueueLifecycle(async () => {
if (grant.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '不能创建已经过期的浏览器共享会话');
}
let previousGrant: BridgeGrant | undefined;
let previousHandoff: HumanHandoff | undefined;
const now = Date.now();
await synchronizeExpiryAlarm(grant);
let state: ExtensionState;
try {
state = await updateState((current) => {
previousGrant = current.activeGrant;
previousHandoff = current.handoff;
return {
...current,
activeGrant: grant,
handoff: cancelledHandoff(current.handoff, now),
};
});
} catch (error) {
await synchronizeExpiryAlarm((await getState()).activeGrant).catch(() => undefined);
throw error;
}
if (previousGrant && previousGrant.id !== grant.id) {
cancelActiveRequestsBestEffort(previousGrant);
await cleanupGrantResources(previousGrant, 'replaced');
}
try {
await startAgentRuntime(grant);
} catch (error) {
await endActiveGrantInQueue('activation_failure', grant.id);
throw new ExtensionError(
'grant_activation_failed',
`无法初始化浏览器共享会话: ${error instanceof Error ? error.message : String(error)}`,
);
}
await publishCancelledHandoff(previousHandoff, state.handoff, 'replaced');
return { state, previousGrant, previousHandoff };
});
}
export function revokeActiveGrant(expectedGrantId?: string): Promise<GrantTransition> {
return enqueueLifecycle(() => endActiveGrantInQueue('revoked', expectedGrantId));
}
export function updateActiveGrant(
expectedGrantId: string,
updater: (grant: BridgeGrant) => BridgeGrant,
): Promise<ExtensionState> {
return enqueueLifecycle(async () => {
const before = await getState();
if (!before.activeGrant || before.activeGrant.id !== expectedGrantId) {
throw new ExtensionError('grant_expired', '共享会话已经变化,请重试');
}
if (before.activeGrant.expiresAt <= Date.now()) {
await endActiveGrantInQueue('expired', expectedGrantId);
throw new ExtensionError('grant_expired', '共享会话不存在或已经过期');
}
const nextGrant = updater(before.activeGrant);
await synchronizeExpiryAlarm(nextGrant);
let state: ExtensionState;
try {
state = await updateState((current) => {
if (!current.activeGrant || current.activeGrant.id !== expectedGrantId) {
throw new ExtensionError('grant_expired', '共享会话已经变化,请重试');
}
return { ...current, activeGrant: nextGrant };
});
} catch (error) {
await synchronizeExpiryAlarm((await getState()).activeGrant).catch(() => undefined);
throw error;
}
return state;
});
}
export function requireActiveGrant(): Promise<BridgeGrant> {
return currentActiveGrant().then((grant) => {
if (!grant) throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
return grant;
});
}
export function rebindGrantTargets(
grantId: string,
targets: BridgeGrantTarget[],
): Promise<ExtensionState> {
return updateActiveGrant(grantId, (grant) => ({ ...grant, targets }));
}
+22 -399
View File
@@ -1,417 +1,40 @@
import { browser } from 'wxt/browser';
import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
redactNetworkRequests, startNetworkCapture, stopNetworkCapture,
stopNetworkCapturesForGrant,
} from '@/features/network-capture/service';
import {
browserRecordingStatus, clearBrowserRecording, createRecordedPageCallable, getBrowserRecording, startBrowserRecording,
stopBrowserRecording, stopBrowserRecordingsForGrant,
} from '@/features/browser-recording/service';
import {
createCapturedPageCallable, deepCaptureStatus, detachDeepCapture,
keepDeepCaptureAlive, resumeDeepCapture,
startDeepCapture, stopDeepCapturesForGrant,
} from '@/features/deep-capture/service';
import { deletePageCallable, executePageCallable, listPageCallables } from '@/features/page-callable/service';
import {
deleteBrowserTransformProfile, executeBrowserTransform, getBrowserTransformProfile,
listBrowserTransformProfiles, saveBrowserTransformProfile,
} from '@/features/browser-transform/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import type {
BridgeGrant, BrowserDeepCaptureMatcher, BrowserRequestAnalysisBundle, BrowserTarget,
BrowserTransformExecuteInput, BrowserTransformProfileInput, CapabilityScope, HandoffReason,
PageContextOptions, YakPocGenerateResult,
} from '@/types/models';
import { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
capabilityBaseScope,
CONTROL_CAPABILITY_SCOPES,
READ_CAPABILITY_SCOPES,
} from '@/protocol/capabilities';
import { parseCapabilityParams } from '@/protocol/bridge';
import { getFrameInventory } from '@/features/page-context/frames';
import { activateTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import {
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
} from '@/features/page-context/service';
import { listCookies } from '@/features/cookies/service';
import { getState, updateState } from '@/platform/storage/state';
import { switchProxy } from '@/features/proxy/service';
import { ExtensionError } from '@/shared/errors';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
import { activeGrant, type CapabilityEngineRequest } from './capability-context';
import { dispatchCapability } from './capability-router';
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
const CAPABILITY_SCOPES: Record<string, CapabilityScope> = {
'browser.tabs': 'browser.tabs.read',
'browser.frames': 'browser.tabs.read',
'browser.context': 'browser.dom.read',
'browser.node.inspect': 'browser.dom.read',
'browser.node.action': 'browser.dom.write',
'browser.cookies': 'browser.cookies.read',
'browser.takeover': 'browser.tab.activate',
'browser.handoff.request': 'browser.human.takeover',
'browser.handoff.status': 'browser.human.takeover',
'browser.network.start': 'browser.network.capture',
'browser.network.status': 'browser.network.read',
'browser.network.list': 'browser.network.read',
'browser.network.clear': 'browser.network.capture',
'browser.network.stop': 'browser.network.capture',
'browser.network.export': 'browser.network.sensitive.read',
'browser.network.poc': 'browser.network.sensitive.read',
'browser.network.analysis': 'browser.network.sensitive.read',
'browser.recording.start': 'browser.recording.control',
'browser.recording.status': 'browser.recording.read',
'browser.recording.get': 'browser.recording.read',
'browser.recording.clear': 'browser.recording.control',
'browser.recording.stop': 'browser.recording.control',
'browser.callable.create': 'browser.callable.execute',
'browser.callable.list': 'browser.recording.read',
'browser.callable.execute': 'browser.callable.execute',
'browser.callable.delete': 'browser.callable.execute',
'browser.deep_capture.start': 'browser.debugger.control',
'browser.deep_capture.status': 'browser.debugger.read',
'browser.deep_capture.keepalive': 'browser.debugger.control',
'browser.deep_capture.resume': 'browser.debugger.control',
'browser.deep_capture.detach': 'browser.debugger.control',
'browser.transform.profile.list': 'browser.transform.read',
'browser.transform.profile.save': 'browser.transform.manage',
'browser.transform.profile.delete': 'browser.transform.manage',
'browser.transform.execute': 'browser.transform.execute',
'browser.invoke': 'browser.page.invoke',
'browser.eval': 'browser.page.eval.expression',
'proxy.list': 'browser.proxy.read',
'proxy.switch': 'browser.proxy.write',
};
async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
const state = await getState();
const grant = state.activeGrant;
if (!grant || grant.expiresAt <= Date.now()) {
if (grant) {
const state = await updateState((current) => ({
...current,
activeGrant: undefined,
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
: current.handoff,
}));
await Promise.all([
stopNetworkCapturesForGrant(grant.id),
stopBrowserRecordingsForGrant(grant.id),
stopDeepCapturesForGrant(grant.id),
]);
await setAgentRuntimeState('expired', grant);
if (state.handoff) await browser.action.setBadgeText({ text: '', tabId: state.handoff.target.tabId });
}
throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
}
if (!grant.scopes.includes(required)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${required}`);
return grant;
}
function originOf(url: string): string {
try {
const origin = new URL(url).origin;
return origin === 'null' ? '' : origin;
} catch {
return '';
}
}
async function allowedTarget(
grant: BridgeGrant,
input: { tabId?: unknown; frameId?: unknown; documentId?: unknown },
resolveInPage = true,
): Promise<BrowserTarget> {
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
const target = grant.targets.find((item) => item.tabId === requested && item.frameId === requestedFrameId);
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
const currentFrame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (!currentFrame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在');
let currentOrigin = originOf(currentFrame.url);
if (!currentOrigin) {
currentOrigin = (await getFrameInventory(target.tabId)).find((frame) => frame.frameId === target.frameId)?.origin || '';
}
if (currentOrigin !== target.origin) throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
if (target.documentId && currentFrame.documentId && target.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
}
if (typeof input.documentId === 'string' && target.documentId && input.documentId !== target.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
}
if (!resolveInPage) return target;
const resolved = await resolveDocumentTarget(target);
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return resolved;
}
function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
}
export async function routeCapability(
method: string,
params: unknown,
requestEngine?: <T>(method: string, params: unknown) => Promise<T>,
requestEngine?: CapabilityEngineRequest,
): Promise<unknown> {
if (method === 'system.ping') return { now: Date.now(), extensionVersion: browser.runtime.getManifest().version };
if (import.meta.env.FIREFOX && import.meta.env.MODE === 'store' && ['browser.invoke', 'browser.eval'].includes(method)) {
throw new ExtensionError('channel_unavailable', 'Firefox AMO 渠道不提供页面函数调用或通用 Eval');
if (method === 'system.ping') {
return {
now: Date.now(),
extensionVersion: browser.runtime.getManifest().version,
};
}
if (import.meta.env.FIREFOX
&& import.meta.env.MODE === 'store'
&& ['browser.invoke', 'browser.eval'].includes(method)) {
throw new ExtensionError(
'channel_unavailable',
'Firefox AMO 渠道不提供页面函数调用或通用 Eval',
);
}
const input = parseCapabilityParams(method, params);
const required = method === 'browser.eval' && input.mode === 'program'
? 'browser.page.eval.program'
: CAPABILITY_SCOPES[method];
: capabilityBaseScope(method);
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
const grant = await activeGrant(required);
if (method === 'browser.tabs') {
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const tabs = await Promise.all(tabIds.map(async (tabId) => {
const targets = grant.targets.filter((target) => target.tabId === tabId);
for (const target of targets) {
try {
await allowedTarget(grant, { tabId, frameId: target.frameId, documentId: target.documentId });
return getTab(tabId);
} catch {
// A tab remains visible while at least one explicitly granted frame is current.
}
}
return undefined;
}));
return tabs.filter(Boolean);
}
if (method === 'browser.frames') {
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
}
return getFrameInventory(tabId);
}
if (method === 'browser.handoff.status') {
const handoff = (await getState()).handoff;
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
}
if (method === 'browser.handoff.request') {
const resolvedTarget = await allowedTarget(grant, input);
const grantTarget = grant.targets.find((target) => target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId);
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
const now = Date.now();
const state = await updateState((current) => {
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
}
if (current.handoff?.state === 'waiting_for_user') {
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
}
return {
...current,
handoff: {
id: crypto.randomUUID(),
taskId: grant.taskId,
target: grantTarget,
reason: input.reason as HandoffReason,
message: typeof input.message === 'string' ? input.message : '',
state: 'waiting_for_user',
requestedAt: now,
},
};
});
await activateTab(resolvedTarget.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
await setAgentRuntimeState('waiting_for_human', grant);
return state.handoff;
}
if (method.startsWith('browser.network.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.network.start') {
if (input.captureHeaders === true || input.captureBody === true) requireScope(grant, 'browser.network.sensitive.read');
return startNetworkCapture(target, {
captureHeaders: input.captureHeaders === true,
captureBody: input.captureBody === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
if (method === 'browser.network.status') return networkCaptureStatus(target);
if (method === 'browser.network.list') {
const records = await listNetworkRequests(target, typeof input.limit === 'number' ? input.limit : 100);
return grant.scopes.includes('browser.network.sensitive.read') ? records : redactNetworkRequests(records);
}
if (method === 'browser.network.clear') return clearNetworkRequests(target);
if (method === 'browser.network.stop') return stopNetworkCapture(target);
if (method === 'browser.network.export') return exportNetworkRequest(target, String(input.id));
if (method === 'browser.network.poc') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<YakPocGenerateResult>('yakit.poc.generate', await capturedRequestEnginePayload(target, String(input.id), false));
}
if (method === 'browser.network.analysis') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(target, String(input.id), grant.scopes.includes('browser.recording.read')),
);
}
}
if (method.startsWith('browser.recording.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.recording.start') {
if (input.captureValues === true) requireScope(grant, 'browser.recording.sensitive.read');
return startBrowserRecording(target, {
captureValues: input.captureValues === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxValueBytes: typeof input.maxValueBytes === 'number' ? input.maxValueBytes : undefined,
expiresAt: grant.expiresAt,
}, { kind: 'grant', grantId: grant.id });
}
if (method === 'browser.recording.status') return browserRecordingStatus(target);
if (method === 'browser.recording.get') return getBrowserRecording(
target,
typeof input.limit === 'number' ? input.limit : 500,
grant.scopes.includes('browser.recording.sensitive.read'),
);
if (method === 'browser.recording.clear') return clearBrowserRecording(target, grant.scopes.includes('browser.recording.sensitive.read'));
if (method === 'browser.recording.stop') return stopBrowserRecording(target, grant.scopes.includes('browser.recording.sensitive.read'));
}
if (method.startsWith('browser.callable.')) {
const source = String(input.source || '');
const target = await allowedTarget(grant, input, source !== 'deep-capture');
if (method === 'browser.callable.list') return listPageCallables(target);
if (method === 'browser.callable.create') {
if (source === 'deep-capture') {
requireScope(grant, 'browser.debugger.control');
const strategy = input.strategy === 'expression' ? 'expression' : 'selected-frame';
return createCapturedPageCallable(target, String(input.callFrameId || ''), strategy === 'expression' ? {
strategy,
name: String(input.name || ''),
functionExpression: String(input.functionExpression || ''),
} : {
strategy,
name: typeof input.name === 'string' ? input.name : undefined,
}, { kind: 'grant', grantId: grant.id });
}
return createRecordedPageCallable(target, {
callHandleId: String(input.callHandleId || ''),
name: String(input.name || ''),
});
}
if (method === 'browser.callable.execute') {
return executePageCallable(target, String(input.callableId || ''), Array.isArray(input.args) ? input.args : []);
}
if (method === 'browser.callable.delete') return deletePageCallable(target, String(input.callableId || ''));
}
if (method.startsWith('browser.deep_capture.')) {
const target = await allowedTarget(grant, input, method === 'browser.deep_capture.start');
const owner = { kind: 'grant' as const, grantId: grant.id };
if (method === 'browser.deep_capture.start') {
return startDeepCapture(target, input.matcher as BrowserDeepCaptureMatcher, owner);
}
if (method === 'browser.deep_capture.status') return deepCaptureStatus(target, owner);
if (method === 'browser.deep_capture.keepalive') return keepDeepCaptureAlive(target, owner);
if (method === 'browser.deep_capture.resume') return resumeDeepCapture(target, 'engine-request', owner);
if (method === 'browser.deep_capture.detach') return detachDeepCapture(target, owner);
}
if (method.startsWith('browser.transform.')) {
if (method === 'browser.transform.profile.list') {
const profiles = await listBrowserTransformProfiles();
const visible = await Promise.all(profiles.map(async (profile) => {
try {
await allowedTarget(grant, profile.target);
return profile;
} catch {
return undefined;
}
}));
return visible.filter(Boolean);
}
if (method === 'browser.transform.profile.save') {
const profileInput = input as unknown as BrowserTransformProfileInput;
const target = await allowedTarget(grant, profileInput.target);
const grantedTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
if (!grantedTarget || profileInput.origin !== grantedTarget.origin) {
throw new ExtensionError('target_denied', '转换配置来源不在本次共享会话中');
}
return saveBrowserTransformProfile({ ...profileInput, target });
}
if (method === 'browser.transform.profile.delete') {
const profile = await getBrowserTransformProfile(String(input.id || ''));
await allowedTarget(grant, profile.target);
return deleteBrowserTransformProfile(profile.id);
}
if (method === 'browser.transform.execute') {
const executeInput = input as unknown as BrowserTransformExecuteInput;
const profile = await getBrowserTransformProfile(executeInput.profileId);
await allowedTarget(grant, profile.target);
return executeBrowserTransform(executeInput);
}
}
if (method === 'browser.context') {
const options: PageContextOptions = {
includeDom: input.includeDom !== false,
includeStorage: input.includeStorage === true,
includeCookies: input.includeCookies === true,
};
if (options.includeStorage) requireScope(grant, 'browser.storage.read');
if (options.includeCookies) requireScope(grant, 'browser.cookies.read');
return capturePageContext(options, await allowedTarget(grant, input));
}
if (method === 'browser.node.inspect') {
const target = await allowedTarget(grant, input);
return inspectPageNode(String(input.captureId), String(input.nodeId), target);
}
if (method === 'browser.node.action') {
const target = await allowedTarget(grant, input);
return actOnPageNode(
String(input.captureId),
String(input.nodeId),
input.action as 'click' | 'focus' | 'scroll' | 'setValue',
target,
typeof input.value === 'string' ? input.value : undefined,
);
}
if (method === 'browser.cookies') {
const target = await allowedTarget(grant, input);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
const grantTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
const url = frame?.url && /^https?:/i.test(frame.url) ? frame.url : `${grantTarget?.origin || ''}/`;
if (!/^https?:/i.test(url)) throw new ExtensionError('target_unavailable', '目标 frame 没有可读取 Cookie 的 HTTP 来源');
return listCookies(url);
}
if (method === 'browser.takeover') {
const target = await allowedTarget(grant, input);
await activateTab(target.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
globalThis.setTimeout(() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }), 10_000);
return { activated: true, target };
}
if (method === 'browser.invoke') {
if (typeof input.path !== 'string') throw new Error('缺少页面函数路径');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return invokePageFunction(input.path, Array.isArray(input.args) ? input.args : [], await allowedTarget(grant, input), timeoutMs);
}
if (method === 'browser.eval') {
if (typeof input.code !== 'string' || !input.code.trim()) throw new Error('缺少页面执行代码');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return evalInPage(input.code, input.mode as 'expression' | 'program', await allowedTarget(grant, input), timeoutMs);
}
const state = await getState();
if (method === 'proxy.list') return state.proxyProfiles;
if (method === 'proxy.switch') {
if (typeof input.id !== 'string') throw new Error('缺少代理配置 ID');
await switchProxy(input.id);
return { activeProxyId: input.id };
}
throw new Error(`不支持的 Bridge 方法: ${method}`);
return dispatchCapability({ method, input, grant, requestEngine });
}