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
@@ -29,8 +29,6 @@ interface RecordingWorkspaceProps {
busy: boolean;
run: RunTask;
gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>;
}
@@ -183,8 +181,6 @@ export function RecordingWorkspace({
busy,
run,
gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway,
}: RecordingWorkspaceProps) {
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
@@ -634,8 +630,6 @@ export function RecordingWorkspace({
busy={busy}
run={run}
gatewayShared={gatewayShared}
gatewayShareExpiresAt={gatewayShareExpiresAt}
gatewayBridgeConnected={gatewayBridgeConnected}
onShareGateway={onShareGateway}
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
@@ -45,8 +45,6 @@ interface BrowserTransformWorkspaceProps {
busy: boolean;
run: RunTask;
gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>;
onOpenCapture: () => void;
onOpenRecovery: (profileId: string) => void;
@@ -247,8 +245,6 @@ export function BrowserTransformWorkspace({
busy,
run,
gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway,
onOpenCapture,
onOpenRecovery,
@@ -926,11 +922,9 @@ export function BrowserTransformWorkspace({
replayPersistenceLabel={replayPersistenceLabel(replayPersistence)}
replayPersistenceTitle={replayPersistenceTitle}
gatewayShared={gatewayShared}
gatewayShareExpiresAt={gatewayShareExpiresAt}
gatewayBridgeConnected={gatewayBridgeConnected}
onShareGateway={() => run(
onShareGateway,
gatewayShared ? '共享会话已刷新' : '当前页面已共享给 Yakit',
gatewayShared ? '浏览器实例已连接' : '正在连接 Yakit',
)}
onClear={clearReplay}
canExecute={Boolean(draft?.id && !dirty && !busy && !replayLoading && bindingReady)}
@@ -1,4 +1,4 @@
import { AlertTriangle, CheckCircle2, FlaskConical, Play, Share2, ShieldCheck, Trash2 } from 'lucide-react';
import { AlertTriangle, CheckCircle2, FlaskConical, Play, ShieldCheck, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
ActiveTabInfo,
@@ -28,8 +28,6 @@ export function TransformReplayPanel({
replayPersistenceLabel,
replayPersistenceTitle,
gatewayShared,
gatewayShareExpiresAt,
gatewayBridgeConnected,
onShareGateway,
onClear,
canExecute,
@@ -56,8 +54,6 @@ export function TransformReplayPanel({
replayPersistenceLabel: string;
replayPersistenceTitle: string;
gatewayShared: boolean;
gatewayShareExpiresAt?: number;
gatewayBridgeConnected: boolean;
onShareGateway: () => Promise<void>;
onClear: () => Promise<void>;
canExecute: boolean;
@@ -87,21 +83,19 @@ export function TransformReplayPanel({
</div>
</header>
{draft?.id && <section className={`transform-gateway-share ${gatewayShared ? 'is-active' : ''}`}>
<span className="transform-gateway-share__mark">{gatewayShared ? <ShieldCheck size={15} /> : <Share2 size={15} />}</span>
<span className="transform-gateway-share__mark"><ShieldCheck size={15} /></span>
<div>
<strong>{gatewayShared ? '当前页面已共享给 Yakit' : '在 Yakit 中使用这个网关'}</strong>
<small>{gatewayShared && gatewayShareExpiresAt
? `控制会话 · ${new Date(gatewayShareExpiresAt).toLocaleTimeString()} 到期`
: gatewayBridgeConnected
? '创建 30 分钟控制会话,并保留已共享页面'
: '可先创建会话;引擎重连后即可使用'}</small>
<strong>{gatewayShared ? '当前浏览器实例已接入 Yakit' : '连接 Yakit 后使用这个网关'}</strong>
<small>{gatewayShared
? '页面刷新、跳转后仍可使用,无需续接授权'
: '连接后由 Agent 操作审核策略统一控制'}</small>
</div>
<Button
{!gatewayShared && <Button
size="sm"
variant={gatewayShared ? 'ghost' : 'primary'}
variant="primary"
disabled={busy || !tab}
onClick={() => void onShareGateway()}
>{gatewayShared ? '刷新' : '一键共享'}</Button>
>连接</Button>}
</section>}
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={method} onChange={(event) => onMethodChange(event.target.value)} /><input disabled={replayLoading} aria-label="回放请求 URL" value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="https://example.test/api" /></div></label>
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={headers} onChange={(event) => onHeadersChange(event.target.value)} /></label>
@@ -34,6 +34,29 @@ describe('Bridge v3 identity transcript', () => {
})).resolves.toBe('113961');
});
it('binds a managed browser identity into the signed transcript', () => {
const envelope: BridgeEnvelope = {
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
capabilities: [],
managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
};
expect(clientAuthPayload({
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'engine-1',
challenge: 'nonce-1', envelope,
})).toMatch(/\nytray\ninstance-1\nB$/);
});
it('binds a managed browser identity into the pairing code', async () => {
const input = {
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
publicKey: { kty: 'EC' as const, crv: 'P-256' as const, x: 'x-coordinate', y: 'y-coordinate' },
};
await expect(pairingVerificationCode({
...input, managedInstance: { manager: 'ytray', instanceId: 'instance-1', badge: 'B' },
})).resolves.toBe('005427');
});
it('signs and verifies ECDSA P-256 payloads', async () => {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
+17 -4
View File
@@ -134,7 +134,7 @@ export function clientAuthPayload(input: {
challenge: string;
envelope: BridgeEnvelope;
}): string {
return [
const fields = [
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
[...(input.envelope.capabilities || [])].sort().join(','),
@@ -142,7 +142,15 @@ export function clientAuthPayload(input: {
input.envelope.capabilityCatalog?.hash || '',
input.envelope.taskId || '', input.envelope.grantId || '',
input.envelope.resumeSessionId || '',
].join('\n');
];
if (input.envelope.managedInstance) {
fields.push(
input.envelope.managedInstance.manager,
input.envelope.managedInstance.instanceId,
input.envelope.managedInstance.badge,
);
}
return fields.join('\n');
}
export async function pairingVerificationCode(input: {
@@ -153,11 +161,16 @@ export async function pairingVerificationCode(input: {
clientNonce: string;
serverNonce: string;
publicKey: BridgePublicKey;
managedInstance?: BridgeEnvelope['managedInstance'];
}): Promise<string> {
const payload = [
const fields = [
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
].join('\n');
];
if (input.managedInstance) {
fields.push(input.managedInstance.manager, input.managedInstance.instanceId, input.managedInstance.badge);
}
const payload = fields.join('\n');
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
let value = 0n;
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
+11 -13
View File
@@ -9,7 +9,7 @@ import {
} from '@/protocol/bridge';
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
import { routeCapability } from '@/features/grants/service';
import { currentActiveGrant } from '@/features/grants/lifecycle';
import { browserInstanceAccess } from '@/features/grants/capability-context';
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
import { appendAuditEvent } from '@/features/diagnostics/audit';
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
@@ -301,6 +301,7 @@ export class EngineBridge {
capabilities: [...BRIDGE_CAPABILITIES],
capabilityCatalog,
installationId: config.installationId,
managedInstance: state.bridge.managedInstance,
taskId: state.activeGrant?.taskId,
grantId: state.activeGrant?.id,
resumeSessionId: previousSession?.sessionId,
@@ -522,18 +523,13 @@ export class EngineBridge {
: undefined;
try {
const grant = await currentActiveGrant();
taskId = grant?.taskId;
targetTabId ??= grant?.targets[0]?.tabId;
if (grant) {
const grantTarget = grant.targets.find((target) => target.tabId === targetTabId);
actionId = (await beginAgentAction(grant, {
requestId: message.id,
method: message.method,
targetTabId,
isolationContextId: grantTarget?.isolationContextId,
})).id;
}
const grant = await browserInstanceAccess('browser.tabs.read');
taskId = grant.taskId;
actionId = (await beginAgentAction(grant, {
requestId: message.id,
method: message.method,
targetTabId,
})).id;
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
const result = await Promise.race([operation, cancelled]);
const durationMs = performance.now() - startedAt;
@@ -802,6 +798,7 @@ export class EngineBridge {
socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
managedInstance: config.managedInstance,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
@@ -872,6 +869,7 @@ export class EngineBridge {
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
managedInstance: context.config.managedInstance,
});
if (code !== message.code) {
this.failPairing(new Error('Yak 配对验证码校验失败'));
@@ -5,10 +5,8 @@ import {
} from 'lucide-react';
import { browser } from 'wxt/browser';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
import { READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
@@ -31,9 +29,6 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const bodyRef = useRef<HTMLDivElement>(null);
const grantActive = Boolean(
state.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id),
);
const pendingHandoff = waitingHandoff(state.handoff);
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined;
@@ -177,9 +172,9 @@ export function FloatingPanel({ initialState, initialTab, initialBridge, hostCha
<Button variant="ghost" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={14} />取消</Button>
</div>
</div> : <>
{grantActive && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '共享会话活动'}</strong><small title={state.activeGrant?.taskId}>{state.activeGrant?.taskId} · {state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? '控制权限' : '只读权限'}</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
<label className="floating-share-row"><span><strong>共享当前主 frame</strong><small>30 分钟只读授权,可随时撤销</small></span><Switch checked={grantActive} disabled={!tab || busy} onCheckedChange={(checked) => void run(async () => setState(checked ? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 }) : await request('grant.revoke')))} /></label>
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理控制授权<Settings size={14} /></Button>
{bridge.state === 'connected' && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '浏览器实例已接入'}</strong><small>当前浏览器的 HTTP(S) 页面均可引用</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
<div className="floating-share-row"><span><strong>实例级页面访问</strong><small>刷新、跳转和新标签页自动跟随,无需逐页授权</small></span><ShieldCheck size={16} /></div>
<Button variant="secondary" onClick={() => openWorkspace('engine')}>管理 Agent 连接<Settings size={14} /></Button>
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
</>}
</TabsContent>
+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 });
}