mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user