feat(browser): add managed instance agent capabilities

This commit is contained in:
go0p
2026-09-03 13:31:59 +08:00
parent 8af9bb777e
commit e00746d834
37 changed files with 608 additions and 281 deletions
+35 -32
View File
@@ -2,10 +2,12 @@ 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 { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
import { CONTROL_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import { ExtensionError } from '@/shared/errors';
import { requireActiveGrant } from './lifecycle';
export type CapabilityEngineRequest = <T>(method: string, params: unknown) => Promise<T>;
export const PAIRED_BROWSER_INSTANCE_ACCESS_ID = 'paired-browser-instance';
export interface CapabilityRouteContext {
method: string;
@@ -20,8 +22,23 @@ export interface CapabilityDomainHandler {
handle(context: CapabilityRouteContext): Promise<unknown>;
}
export async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
const grant = await requireActiveGrant();
export async function browserInstanceAccess(required: CapabilityScope): Promise<BridgeGrant> {
const policy = (await getEnterprisePolicy()).policy;
assertBrowserAccessPolicy(policy, {
programEval: required === 'browser.page.eval.program',
});
const scopes: CapabilityScope[] = [
...CONTROL_CAPABILITY_SCOPES,
...(policy.allowProgramEval === false ? [] : ['browser.page.eval.program' as const]),
];
const grant: BridgeGrant = {
id: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
taskId: PAIRED_BROWSER_INSTANCE_ACCESS_ID,
targets: [],
scopes: [...scopes],
createdAt: 0,
expiresAt: Number.MAX_SAFE_INTEGER,
};
requireScope(grant, required);
return grant;
}
@@ -36,22 +53,15 @@ function originOf(url: string): string {
}
export async function allowedTarget(
grant: BridgeGrant,
_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 currentTab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
const target: BrowserTarget = {
tabId: currentTab.id,
frameId: typeof input.frameId === 'number' ? input.frameId : 0,
};
const currentFrame = await browser.webNavigation.getFrame({
tabId: target.tabId,
frameId: target.frameId,
@@ -62,27 +72,20 @@ export async function allowedTarget(
currentOrigin = (await getFrameInventory(target.tabId))
.find((frame) => frame.frameId === target.frameId)?.origin || '';
}
if (currentOrigin !== target.origin) {
throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
if (!currentOrigin) {
throw new ExtensionError('target_unavailable', '目标 frame 不是可访问的 HTTP(S) 页面');
}
if (target.documentId && currentFrame.documentId
&& target.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: currentOrigin });
if (typeof input.documentId === 'string' && currentFrame.documentId
&& input.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经刷新或导航,请重新获取页面上下文');
}
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;
const currentTarget = { ...target, documentId: currentFrame.documentId };
return resolveInPage ? resolveDocumentTarget(currentTarget) : currentTarget;
}
export function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) {
throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
throw new ExtensionError('permission_denied', `浏览器实例不允许能力: ${scope}`);
}
}
@@ -20,7 +20,10 @@ function exactMethods(id: CapabilityDomainId, methods: readonly string[]): Capab
export const NAVIGATION_CAPABILITY_DOMAIN = exactMethods('navigation-isolation', [
'browser.tabs',
'browser.tab.open',
'browser.thumbnail',
'browser.frames',
'browser.instance.close',
'browser.isolation.inspect',
'browser.isolation.proof',
'browser.isolation.incognito.open',
@@ -3,6 +3,7 @@ import type { HandoffReason } from '@/types/models';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget } from '../capability-context';
import { activateTab } from '@/platform/browser/targets';
import { getTab } from '@/platform/browser/targets';
import { getState, updateState } from '@/platform/storage/state';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
import { ExtensionError } from '@/shared/errors';
@@ -16,15 +17,23 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
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 [tab, frame] = await Promise.all([
getTab(resolvedTarget.tabId),
browser.webNavigation.getFrame(resolvedTarget),
]);
if (!frame?.url || !/^https?:/i.test(frame.url)) {
throw new ExtensionError('target_unavailable', '目标 frame 不是可接管的 HTTP(S) 页面');
}
const grantTarget = {
...resolvedTarget,
isolationContextId: tab.isolationContextId || `browser-profile:tab-${tab.id}`,
cookieStoreId: tab.cookieStoreId,
origin: new URL(frame.url).origin,
grantedUrl: frame.url,
title: tab.title,
};
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', '已有人工接管请求正在等待处理');
}
@@ -1,7 +1,8 @@
import { browser } from 'wxt/browser';
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 { activateTab, getTab, scheduleBrowserInstanceClose } from '@/platform/browser/targets';
import {
createBrowserIsolationProof,
deleteFirefoxContainerIdentity,
@@ -11,70 +12,88 @@ import {
openIncognitoIdentity,
} from '@/features/authorization-testing/isolation';
import { ExtensionError } from '@/shared/errors';
import { assertBrowserAccessPolicy, getEnterprisePolicy } from '@/platform/policy/managed';
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);
const { tabs } = await inspectBrowserIsolation();
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
return tabs.filter((tab) => !allowedOrigins?.length || allowedOrigins.includes(new URL(tab.url).origin))
.sort((left, right) => Number(Boolean(right.active)) - Number(Boolean(left.active))
|| (right.lastAccessed || 0) - (left.lastAccessed || 0));
}
if (method === 'browser.tab.open') {
const url = String(input.url || '');
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, { origin: new URL(url).origin });
const tab = await browser.tabs.create({ url, active: true });
if (!tab.id) throw new ExtensionError('target_unavailable', '浏览器没有返回新标签页 ID');
await activateTab(tab.id);
return { opened: true, id: tab.id, windowId: tab.windowId, active: true, url };
}
if (method === 'browser.thumbnail') {
const tab = await getTab(typeof input.tabId === 'number' ? input.tabId : undefined);
await allowedTarget(grant, { tabId: tab.id }, false);
if (!tab.active) {
throw new ExtensionError('target_not_active', '只能预览浏览器窗口当前可见的标签页');
}
return {
tabId: tab.id,
title: tab.title,
url: tab.url,
capturedAt: Date.now(),
dataUrl: await browser.tabs.captureVisibleTab(tab.windowId, { format: 'jpeg', quality: 55 }),
};
}
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);
const tabId = (await getTab(typeof input.tabId === 'number' ? input.tabId : undefined)).id;
await allowedTarget(grant, { tabId }, false);
const frames = await getFrameInventory(tabId);
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
return frames.filter((frame) => !allowedOrigins?.length
|| Boolean(frame.origin && allowedOrigins.includes(frame.origin)));
}
if (method === 'browser.instance.close') return scheduleBrowserInstanceClose();
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);
: undefined;
const inspection = await inspectBrowserIsolation(requestedTabIds);
const allowedOrigins = (await getEnterprisePolicy()).policy.grantAllowedOrigins;
if (!allowedOrigins?.length) return inspection;
const tabs = inspection.tabs.filter((tab) => allowedOrigins.includes(new URL(tab.url).origin));
const tabIds = new Set(tabs.map((tab) => tab.id));
return {
...inspection,
tabs,
contexts: inspection.contexts
.map((context) => ({ ...context, tabIds: context.tabIds.filter((tabId) => tabIds.has(tabId)) }))
.filter((context) => context.tabIds.length > 0),
};
}
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',
'隔离证明的两个身份都必须在本次共享会话中',
);
}
await Promise.all([
allowedTarget(grant, { tabId: leftTabId }, false),
allowedTarget(grant, { tabId: rightTabId }, false),
]);
return createBrowserIsolationProof(leftTabId, rightTabId);
}
if (method === 'browser.isolation.incognito.open') {
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
origin: new URL(String(input.url || '')).origin,
});
return openIncognitoIdentity(String(input.url || ''));
}
if (method === 'browser.isolation.container.open') {
assertBrowserAccessPolicy((await getEnterprisePolicy()).policy, {
origin: new URL(String(input.url || '')).origin,
});
return openFirefoxContainerIdentity({
url: String(input.url || ''),
name: typeof input.name === 'string' ? input.name : undefined,
@@ -48,12 +48,7 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
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 || ''}/`;
const url = frame?.url || '';
if (!/^https?:/i.test(url)) {
throw new ExtensionError(
'target_unavailable',
@@ -3,6 +3,7 @@ import type {
BrowserTransformPacket,
BrowserTransformProfileInput,
} from '@/types/models';
import { browser } from 'wxt/browser';
import type { CapabilityDomainHandler } from '../capability-context';
import { allowedTarget, requireScope } from '../capability-context';
import {
@@ -124,11 +125,9 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
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', '转换配置来源不在本次共享会话中');
const frame = await browser.webNavigation.getFrame(target);
if (!frame?.url || profileInput.origin !== new URL(frame.url).origin) {
throw new ExtensionError('target_denied', '转换配置来源与当前页面不一致');
}
return saveBrowserTransformProfile({ ...profileInput, target });
}
+23 -25
View File
@@ -11,8 +11,6 @@ const fixture = vi.hoisted(() => ({
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),
}));
@@ -50,10 +48,6 @@ vi.mock('@/features/browser-recording/service', () => ({
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,
}));
@@ -124,19 +118,15 @@ describe('grant lifecycle manager', () => {
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);
});
@@ -152,7 +142,6 @@ describe('grant lifecycle manager', () => {
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 });
});
@@ -168,15 +157,16 @@ describe('grant lifecycle manager', () => {
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 previous = grant('handoff-old');
previous.taskId = waiting.taskId;
const emitHandoffChanged = vi.fn();
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: grant('handoff-old'),
activeGrant: previous,
handoff: waiting,
});
configureGrantLifecycleHooks({ emitHandoffChanged });
@@ -188,6 +178,21 @@ describe('grant lifecycle manager', () => {
expect(emitHandoffChanged).toHaveBeenCalledWith(state.handoff);
});
it('does not cancel a paired-instance handoff when an authorization-test grant ends', async () => {
const waiting = handoff('paired-handoff');
waiting.taskId = 'paired-browser-instance';
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: grant('authorization-test'),
handoff: waiting,
});
const { state } = await revokeActiveGrant();
expect(state.handoff).toEqual(waiting);
expect(fixture.clearBadge).not.toHaveBeenCalled();
});
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 });
@@ -218,7 +223,6 @@ describe('grant lifecycle manager', () => {
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 () => {
@@ -247,20 +251,14 @@ describe('grant lifecycle manager', () => {
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'));
it('does not couple an authorization-test grant to Agent runtime state', async () => {
const active = grant('authorization-only');
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);
await expect(replaceActiveGrant(active)).resolves.toMatchObject({
state: { activeGrant: { id: active.id } },
});
});
it('clears authorization state even when one resource cleanup reports a failure', async () => {
+9 -40
View File
@@ -2,9 +2,6 @@ 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 {
@@ -14,10 +11,9 @@ import { ExtensionError } from '@/shared/errors';
export const ACTIVE_GRANT_EXPIRY_ALARM = 'yakit.active-grant.expiry';
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure' | 'activation_failure';
type GrantEndReason = 'revoked' | 'expired' | 'replaced' | 'scheduler_failure';
interface GrantLifecycleHooks {
cancelActiveRequests?: () => void;
emitHandoffChanged?: (handoff: HumanHandoff) => void;
}
@@ -80,23 +76,6 @@ function cancelledHandoff(current: HumanHandoff | undefined, now: number): Human
: 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,
@@ -124,12 +103,10 @@ async function publishCancelledHandoff(
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;
@@ -159,11 +136,11 @@ async function endActiveGrantInQueue(
if (!grant || (expectedGrantId && grant.id !== expectedGrantId)) return current;
if (reason === 'expired' && grant.expiresAt > now) return current;
previousGrant = grant;
previousHandoff = current.handoff;
previousHandoff = current.handoff?.taskId === grant.taskId ? current.handoff : undefined;
return {
...current,
activeGrant: undefined,
handoff: cancelledHandoff(current.handoff, now),
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
};
});
@@ -173,7 +150,6 @@ async function endActiveGrantInQueue(
return { state };
}
cancelActiveRequestsBestEffort(previousGrant);
await clearExpiryAlarmBestEffort(previousGrant);
await cleanupGrantResources(previousGrant, reason);
await publishCancelledHandoff(previousHandoff, state.handoff, reason);
@@ -187,7 +163,7 @@ async function endActiveGrantInQueue(
? '已由新共享会话替换'
: reason === 'scheduler_failure'
? '无法建立可靠的到期调度,已安全撤销'
: reason === 'activation_failure' ? 'Agent Runtime 初始化失败,已安全撤销' : undefined,
: undefined,
});
return { state, previousGrant, previousHandoff };
}
@@ -258,11 +234,14 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
try {
state = await updateState((current) => {
previousGrant = current.activeGrant;
previousHandoff = current.handoff;
previousHandoff = current.activeGrant
&& current.handoff?.taskId === current.activeGrant.taskId
? current.handoff
: undefined;
return {
...current,
activeGrant: grant,
handoff: cancelledHandoff(current.handoff, now),
handoff: cancelledHandoff(previousHandoff, now) || current.handoff,
};
});
} catch (error) {
@@ -271,18 +250,8 @@ export function replaceActiveGrant(grant: BridgeGrant): Promise<GrantTransition>
}
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 };
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import type { BridgeGrant } from '@/types/models';
const fixture = vi.hoisted(() => ({
access: vi.fn(async (): Promise<BridgeGrant> => ({
id: 'paired-browser-instance',
taskId: 'paired-browser-instance',
targets: [],
scopes: ['browser.dom.read'],
createdAt: 0,
expiresAt: Number.MAX_SAFE_INTEGER,
})),
dispatch: vi.fn(async () => ({ ok: true })),
}));
vi.mock('wxt/browser', () => ({
browser: { runtime: { getManifest: () => ({ version: '1.0.0' }) } },
}));
vi.mock('./capability-context', () => ({
browserInstanceAccess: fixture.access,
}));
vi.mock('./capability-router', () => ({
dispatchCapability: fixture.dispatch,
}));
import { routeCapability } from './service';
describe('paired browser capability routing', () => {
it('routes page access through the paired instance without an active page grant', async () => {
await expect(routeCapability('browser.context', { includeDom: true })).resolves.toEqual({ ok: true });
expect(fixture.access).toHaveBeenCalledWith('browser.dom.read');
expect(fixture.dispatch).toHaveBeenCalledWith(expect.objectContaining({
method: 'browser.context',
input: { includeDom: true },
}));
});
});
+11 -2
View File
@@ -6,7 +6,7 @@ import {
} from '@/protocol/capabilities';
import { parseCapabilityParams } from '@/protocol/bridge';
import { ExtensionError } from '@/shared/errors';
import { activeGrant, type CapabilityEngineRequest } from './capability-context';
import { browserInstanceAccess, type CapabilityEngineRequest } from './capability-context';
import { dispatchCapability } from './capability-router';
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
@@ -17,9 +17,18 @@ export async function routeCapability(
requestEngine?: CapabilityEngineRequest,
): Promise<unknown> {
if (method === 'system.ping') {
const userAgent = globalThis.navigator?.userAgent || '';
const browserName = /Firefox\//i.test(userAgent)
? 'Firefox'
: /Edg\//i.test(userAgent)
? 'Edge'
: /Chrom(?:e|ium)\//i.test(userAgent)
? 'Chrome'
: undefined;
return {
now: Date.now(),
extensionVersion: browser.runtime.getManifest().version,
browserName,
};
}
if (import.meta.env.FIREFOX
@@ -35,6 +44,6 @@ export async function routeCapability(
? 'browser.page.eval.program'
: capabilityBaseScope(method);
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
const grant = await activeGrant(required);
const grant = await browserInstanceAccess(required);
return dispatchCapability({ method, input, grant, requestEngine });
}