feat(browser): add local handoff presentation

This commit is contained in:
go0p
2026-09-03 17:04:00 +08:00
parent 0a349a7928
commit 8e84735ecf
11 changed files with 536 additions and 26 deletions
+2 -15
View File
@@ -64,6 +64,7 @@ import { handleCookieRequest } from './handlers/cookies';
import { handleUserAgentRequest } from './handlers/user-agent';
import { handleRecordingRequest } from './handlers/recording';
import { handleTransformRequest } from './handlers/transform';
import { resolveHandoff } from '@/features/handoff/service';
function originOf(url: string): string {
const parsed = new URL(url);
@@ -299,21 +300,7 @@ async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.
}
case 'handoff.resolve': {
const input = request.payload;
const state = await updateState((current) => {
if (!current.handoff || current.handoff.id !== input.id || current.handoff.state !== 'waiting_for_user') {
throw new ExtensionError('handoff_not_waiting', '人工接管请求不存在或已经结束');
}
return {
...current,
handoff: { ...current.handoff, state: input.outcome, resolvedAt: Date.now() },
};
});
const handoff = state.handoff!;
await setAgentRuntimeState(
input.outcome === 'completed' ? 'running' : 'paused',
await browserInstanceAccess('browser.tabs.read'),
);
await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
const { state, handoff } = await resolveHandoff(input.id, input.outcome);
engineBridge.emitEvent('browser.handoff.changed', handoff);
void appendAuditEvent({
category: 'handoff', action: `handoff.${input.outcome}`, outcome: input.outcome === 'completed' ? 'success' : 'cancelled',
@@ -87,9 +87,26 @@ vi.mock('@/platform/storage/state', () => ({
vi.mock('@/protocol/capabilities', () => ({
BRIDGE_CAPABILITIES: [],
capabilityVisibleToAgent: vi.fn((method: string) => ![
'browser.thumbnail',
'browser.handoff.presentation.get',
'browser.handoff.focus',
'browser.handoff.resolve',
].includes(method)),
getBridgeCapabilityCatalog: vi.fn(async () => ({ version: 1, capabilities: [] })),
}));
vi.mock('@/features/grants/capability-context', () => ({
browserInstanceAccess: vi.fn(async () => ({
id: 'paired-browser-instance',
taskId: 'paired-browser-instance',
targets: [],
scopes: ['browser.tabs.read'],
createdAt: 0,
expiresAt: Number.MAX_SAFE_INTEGER,
})),
}));
vi.mock('@/features/grants/service', () => ({
routeCapability: vi.fn(async () => ({ ok: true })),
}));
@@ -129,6 +146,7 @@ import {
BRIDGE_HEARTBEAT_TIMEOUT_MS,
EngineBridge,
} from './service';
import { beginAgentAction } from '@/features/agent-runtime/service';
import {
BRIDGE_CHUNK_TIMEOUT_MS,
BRIDGE_PROTOCOL_VERSION,
@@ -214,6 +232,26 @@ describe('Engine Bridge transport lifecycle', () => {
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
});
it('routes local UI capabilities without entering the paused Agent action gate', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
socket.receive({
type: 'request',
id: 'local-ui-1',
method: 'browser.handoff.presentation.get',
params: { handoffId: 'handoff-1' },
});
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent.map((item) => JSON.parse(item)).find((item) => item.id === 'local-ui-1')).toMatchObject({
type: 'response',
id: 'local-ui-1',
result: { ok: true },
});
expect(beginAgentAction).not.toHaveBeenCalled();
});
it('closes a half-open connection and rejects pending calls after missed heartbeats', async () => {
const bridge = new EngineBridge();
const socket = await connect(bridge);
+7 -1
View File
@@ -1,7 +1,11 @@
import { browser } from 'wxt/browser';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
import { BRIDGE_CAPABILITIES, getBridgeCapabilityCatalog } from '@/protocol/capabilities';
import {
BRIDGE_CAPABILITIES,
capabilityVisibleToAgent,
getBridgeCapabilityCatalog,
} from '@/protocol/capabilities';
import {
BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS,
BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope,
@@ -525,11 +529,13 @@ export class EngineBridge {
try {
const grant = await browserInstanceAccess('browser.tabs.read');
taskId = grant.taskId;
if (capabilityVisibleToAgent(message.method)) {
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;
@@ -2,16 +2,29 @@ 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 { getTab } 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';
import { focusHandoff, getHandoffPresentation, resolveHandoff } from '@/features/handoff/service';
export const handoffCapabilityHandler: CapabilityDomainHandler = {
...HANDOFF_CAPABILITY_DOMAIN,
async handle({ method, input, grant }) {
if (method === 'browser.handoff.presentation.get') {
return getHandoffPresentation(String(input.handoffId || ''), grant);
}
if (method === 'browser.handoff.focus') {
return focusHandoff(String(input.handoffId || ''), grant);
}
if (method === 'browser.handoff.resolve') {
return resolveHandoff(
String(input.handoffId || ''),
input.outcome === 'cancelled' ? 'cancelled' : 'completed',
grant,
);
}
if (method === 'browser.handoff.status') {
const handoff = (await getState()).handoff;
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
@@ -50,7 +63,6 @@ export const handoffCapabilityHandler: CapabilityDomainHandler = {
},
};
});
await activateTab(resolvedTarget.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
await setAgentRuntimeState('waiting_for_human', grant);
@@ -14,6 +14,7 @@ import { listCookies } from '@/features/cookies/service';
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
import { ExtensionError } from '@/shared/errors';
import { PAGE_CAPABILITY_DOMAIN } from '../capability-domains';
import { getState } from '@/platform/storage/state';
export const pageCapabilityHandler: CapabilityDomainHandler = {
...PAGE_CAPABILITY_DOMAIN,
@@ -63,7 +64,12 @@ export const pageCapabilityHandler: CapabilityDomainHandler = {
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
globalThis.setTimeout(
() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }),
() => void getState()
.then((state) => browser.action.setBadgeText({
text: state.bridge.managedInstance?.badge || '',
tabId: target.tabId,
}))
.catch(() => undefined),
10_000,
);
return { activated: true, target };
+110
View File
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fixture = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
activateTab: vi.fn(async () => undefined),
getFrame: vi.fn(),
getTab: vi.fn(),
resolveDocumentTarget: vi.fn(),
executeScript: vi.fn(),
scriptingTarget: vi.fn((target) => target),
}));
vi.mock('wxt/browser', () => ({
browser: {
storage: {},
webNavigation: { getFrame: fixture.getFrame },
scripting: { executeScript: fixture.executeScript },
},
}));
vi.mock('@/platform/storage/state', () => ({
getState: vi.fn(async () => structuredClone(fixture.state)),
updateState: vi.fn(),
}));
vi.mock('@/platform/browser/targets', () => ({
activateTab: fixture.activateTab,
getTab: fixture.getTab,
resolveDocumentTarget: fixture.resolveDocumentTarget,
scriptingTarget: fixture.scriptingTarget,
}));
import { ExtensionError } from '@/shared/errors';
import { focusHandoff, getHandoffPresentation, isSafeHandoffPresentationDataUrl } from './service';
const grant = {
id: 'paired-browser-instance',
taskId: 'paired-browser-instance',
targets: [],
scopes: [],
createdAt: 0,
expiresAt: Number.MAX_SAFE_INTEGER,
};
function waitingHandoff(origin = 'https://passport.example.test') {
return {
handoff: {
id: 'handoff-1',
taskId: 'paired-browser-instance',
state: 'waiting_for_user',
reason: 'qr_code',
target: {
tabId: 7,
frameId: 0,
documentId: 'document-old',
origin,
grantedUrl: `${origin}/login`,
title: 'Sign in',
},
},
};
}
describe('handoff presentation data URL validation', () => {
beforeEach(() => {
vi.clearAllMocks();
fixture.state = {};
});
it('accepts bounded raster data and rejects executable or oversized content', () => {
expect(isSafeHandoffPresentationDataUrl('data:image/png;base64,AAAA')).toBe(true);
expect(isSafeHandoffPresentationDataUrl('data:image/svg+xml,<svg onload="alert(1)"/>')).toBe(false);
expect(isSafeHandoffPresentationDataUrl(`data:image/png;base64,${'AAAA'.repeat(350_000)}`)).toBe(false);
});
it('focuses only the waiting handoff owned by the local paired task', async () => {
fixture.state = {
handoff: {
id: 'handoff-1',
taskId: 'paired-browser-instance',
state: 'waiting_for_user',
target: { tabId: 7 },
},
};
await expect(focusHandoff('handoff-1', grant)).resolves.toEqual({ focused: true, tabId: 7 });
expect(fixture.activateTab).toHaveBeenCalledWith(7);
await expect(focusHandoff('other-handoff', grant)).rejects.toMatchObject({ code: 'handoff_not_waiting' });
});
it('rebinds presentation reads after a same-origin document refresh', async () => {
fixture.state = waitingHandoff();
fixture.resolveDocumentTarget
.mockRejectedValueOnce(new ExtensionError('stale_document', 'stale'))
.mockResolvedValueOnce({ tabId: 7, frameId: 0, documentId: 'document-new' });
fixture.getFrame.mockResolvedValue({ url: 'https://passport.example.test/login?refreshed=1' });
fixture.getTab.mockResolvedValue({ id: 7 });
fixture.executeScript.mockResolvedValue([]);
await expect(getHandoffPresentation('handoff-1', grant)).resolves.toMatchObject({ state: 'not_found' });
expect(fixture.resolveDocumentTarget).toHaveBeenLastCalledWith({ tabId: 7, frameId: 0 });
});
it('reports a changed page instead of leaking a stale-document error', async () => {
fixture.state = waitingHandoff();
fixture.resolveDocumentTarget.mockRejectedValueOnce(new ExtensionError('stale_document', 'stale'));
fixture.getFrame.mockResolvedValue({ url: 'https://www.example.test/' });
await expect(getHandoffPresentation('handoff-1', grant)).resolves.toMatchObject({ state: 'page_changed' });
expect(fixture.resolveDocumentTarget).toHaveBeenCalledTimes(1);
});
});
+299
View File
@@ -0,0 +1,299 @@
import { browser, type Browser } from 'wxt/browser';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
import { browserInstanceAccess } from '@/features/grants/capability-context';
import { activateTab, getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
import { getState, updateState } from '@/platform/storage/state';
import { ExtensionError } from '@/shared/errors';
import type { BridgeGrant, HandoffState, HumanHandoff } from '@/types/models';
const MAX_PRESENTATION_BYTES = 1024 * 1024;
const SAFE_RASTER_DATA_URL = /^data:image\/(?:png|jpeg|webp);base64,/i;
interface PageQrCandidate {
dataUrl?: string;
source: 'image' | 'canvas' | 'svg' | 'background' | 'screenshot';
rect: { x: number; y: number; width: number; height: number };
viewport: { width: number; height: number; devicePixelRatio: number };
title: string;
url: string;
}
export interface HandoffPresentation {
handoffId: string;
state: HandoffState | 'not_found' | 'page_changed';
title: string;
url: string;
capturedAt: number;
source?: PageQrCandidate['source'];
dataUrl?: string;
}
async function resolvePresentationTarget(target: HumanHandoff['target']) {
try {
return await resolveDocumentTarget(target);
} catch (error) {
if (!(error instanceof ExtensionError) || !['stale_document', 'target_unavailable'].includes(error.code)) throw error;
}
const frame = await browser.webNavigation.getFrame({
tabId: target.tabId,
frameId: target.frameId,
}).catch(() => null);
if (!frame?.url || !/^https?:/i.test(frame.url) || new URL(frame.url).origin !== target.origin) return undefined;
return resolveDocumentTarget({ tabId: target.tabId, frameId: target.frameId }).catch(() => undefined);
}
function dataUrlBytes(value: string): number {
const comma = value.indexOf(',');
return comma < 0 ? Number.MAX_SAFE_INTEGER : Math.ceil((value.length - comma - 1) * 0.75);
}
export function isSafeHandoffPresentationDataUrl(value: unknown): value is string {
return typeof value === 'string'
&& SAFE_RASTER_DATA_URL.test(value)
&& dataUrlBytes(value) <= MAX_PRESENTATION_BYTES;
}
async function findQrCandidateInPage(): Promise<PageQrCandidate | undefined> {
const resolvePresentationDataUrl = async (source: string, width: number, height: number): Promise<string | undefined> => {
const rasterize = async (url: string): Promise<string | undefined> => {
const image = new Image();
image.decoding = 'async';
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error('image load failed'));
image.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = Math.min(1024, Math.max(1, image.naturalWidth || Math.round(width)));
canvas.height = Math.min(1024, Math.max(1, image.naturalHeight || Math.round(height)));
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
};
try {
if (/^data:image\/(?:png|jpeg|webp);base64,/i.test(source)) return source;
if (/^data:image\/svg\+xml/i.test(source)) return rasterize(source);
if (!/^(?:blob:|https?:)/i.test(source)) return undefined;
const response = await fetch(source);
if (!response.ok) return undefined;
const blob = await response.blob();
if (blob.size > 1024 * 1024) return undefined;
const localUrl = URL.createObjectURL(blob);
try {
if (/^image\/(?:png|jpeg|webp)$/i.test(blob.type)) {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
if (blob.type === 'image/svg+xml') return await rasterize(localUrl);
} finally {
URL.revokeObjectURL(localUrl);
}
} catch {
return undefined;
}
return undefined;
};
const keywords = /(?:^|[^a-z])(qr|qrcode|scan)(?:[^a-z]|$)|二维码|扫码|扫码登录/i;
const selector = 'img,canvas,svg,[role="img"],[class*="qr" i],[id*="qr" i]';
const seen = new Set<Element>();
const candidates: Array<{ element: Element; score: number; rect: DOMRect }> = [];
for (const element of document.querySelectorAll(selector)) {
if (seen.has(element)) continue;
seen.add(element);
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
if (
rect.width < 96 || rect.height < 96
|| rect.bottom <= 0 || rect.right <= 0
|| rect.top >= innerHeight || rect.left >= innerWidth
|| style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0
) continue;
const ratio = rect.width / rect.height;
if (ratio < 0.72 || ratio > 1.38) continue;
const ownText = [
element.id,
element.getAttribute('class'),
element.getAttribute('alt'),
element.getAttribute('aria-label'),
element.getAttribute('title'),
].filter(Boolean).join(' ');
let contextText = '';
let parent: Element | null = element;
for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) {
contextText += ` ${parent.textContent || ''}`;
if (contextText.length >= 500) break;
}
const inDialog = Boolean(element.closest('dialog,[role="dialog"],[aria-modal="true"]'));
const score = (keywords.test(ownText) ? 8 : 0)
+ (keywords.test(contextText.slice(0, 500)) ? 5 : 0)
+ (Math.abs(1 - ratio) < 0.12 ? 4 : 2)
+ (inDialog ? 2 : 0)
+ (element instanceof HTMLCanvasElement || element instanceof SVGElement ? 1 : 0);
if (score >= 6) candidates.push({ element, score, rect });
}
candidates.sort((left, right) => right.score - left.score || right.rect.width - left.rect.width);
for (const { element, rect } of candidates.slice(0, 8)) {
let source: PageQrCandidate['source'] = 'screenshot';
let dataUrl: string | undefined;
try {
if (element instanceof HTMLCanvasElement) {
source = 'canvas';
dataUrl = element.toDataURL('image/png');
} else if (element instanceof SVGElement) {
source = 'svg';
const svg = new XMLSerializer().serializeToString(element);
dataUrl = await resolvePresentationDataUrl(
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
rect.width,
rect.height,
);
} else {
const imageSource = element instanceof HTMLImageElement
? element.currentSrc || element.src
: getComputedStyle(element).backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1] || '';
source = element instanceof HTMLImageElement ? 'image' : 'background';
dataUrl = await resolvePresentationDataUrl(imageSource, rect.width, rect.height);
}
} catch {
dataUrl = undefined;
}
return {
dataUrl,
source: dataUrl ? source : 'screenshot',
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
viewport: { width: innerWidth, height: innerHeight, devicePixelRatio: devicePixelRatio || 1 },
title: document.title,
url: location.href,
};
}
return undefined;
}
async function cropVisibleTab(
tab: Awaited<ReturnType<typeof getTab>>,
candidate: PageQrCandidate,
): Promise<string | undefined> {
if (!tab.active || typeof OffscreenCanvas === 'undefined') return undefined;
const screenshot = await browser.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
const bitmap = await createImageBitmap(await (await fetch(screenshot)).blob());
const scaleX = bitmap.width / candidate.viewport.width;
const scaleY = bitmap.height / candidate.viewport.height;
const padding = 12;
const x = Math.max(0, Math.floor((candidate.rect.x - padding) * scaleX));
const y = Math.max(0, Math.floor((candidate.rect.y - padding) * scaleY));
const width = Math.min(bitmap.width - x, Math.ceil((candidate.rect.width + padding * 2) * scaleX));
const height = Math.min(bitmap.height - y, Math.ceil((candidate.rect.height + padding * 2) * scaleY));
if (width < 1 || height < 1) return undefined;
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (!context) return undefined;
context.fillStyle = '#fff';
context.fillRect(0, 0, width, height);
context.drawImage(bitmap, x, y, width, height, 0, 0, width, height);
bitmap.close();
const bytes = new Uint8Array(await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer());
if (bytes.byteLength > MAX_PRESENTATION_BYTES) return undefined;
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return `data:image/png;base64,${btoa(binary)}`;
}
export async function getHandoffPresentation(handoffId: string, grant: BridgeGrant): Promise<HandoffPresentation> {
const handoff = (await getState()).handoff;
if (!handoff || handoff.id !== handoffId || handoff.taskId !== grant.taskId) {
throw new ExtensionError('handoff_not_found', '人工接管请求不存在');
}
const base = {
handoffId,
state: handoff.state,
title: handoff.target.title || '',
url: handoff.target.grantedUrl || '',
capturedAt: Date.now(),
};
if (handoff.state !== 'waiting_for_user' || handoff.reason !== 'qr_code') return base;
const target = await resolvePresentationTarget(handoff.target);
if (!target) return { ...base, state: 'page_changed' };
const tab = await getTab(target.tabId);
const results = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: findQrCandidateInPage,
}) as Array<Browser.scripting.InjectionResult<PageQrCandidate | undefined>>;
if (results.length !== 1 || !results[0]?.result) return { ...base, state: 'not_found' };
const candidate = results[0].result;
const directDataUrl = isSafeHandoffPresentationDataUrl(candidate.dataUrl) ? candidate.dataUrl : undefined;
const dataUrl = directDataUrl
? directDataUrl
: target.frameId === 0
? await cropVisibleTab(tab, candidate).catch(() => undefined)
: undefined;
if (!isSafeHandoffPresentationDataUrl(dataUrl)) {
return { ...base, state: 'not_found', title: candidate.title || base.title, url: candidate.url || base.url };
}
return {
...base,
state: 'waiting_for_user',
title: candidate.title || base.title,
url: candidate.url || base.url,
source: directDataUrl ? candidate.source : 'screenshot',
dataUrl,
};
}
export async function focusHandoff(handoffId: string, grant: BridgeGrant): Promise<{ focused: true; tabId: number }> {
const handoff = (await getState()).handoff;
if (
!handoff
|| handoff.id !== handoffId
|| handoff.taskId !== grant.taskId
|| handoff.state !== 'waiting_for_user'
) {
throw new ExtensionError('handoff_not_waiting', '人工接管请求不存在或已经结束');
}
await activateTab(handoff.target.tabId);
return { focused: true, tabId: handoff.target.tabId };
}
export async function resolveHandoff(
handoffId: string,
outcome: Extract<HandoffState, 'completed' | 'cancelled'>,
grant?: BridgeGrant,
): Promise<{ state: Awaited<ReturnType<typeof getState>>; handoff: HumanHandoff }> {
const state = await updateState((current) => {
if (
!current.handoff
|| current.handoff.id !== handoffId
|| current.handoff.state !== 'waiting_for_user'
|| (grant && current.handoff.taskId !== grant.taskId)
) {
throw new ExtensionError('handoff_not_waiting', '人工接管请求不存在或已经结束');
}
return {
...current,
handoff: { ...current.handoff, state: outcome, resolvedAt: Date.now() },
};
});
const handoff = state.handoff!;
await setAgentRuntimeState(
outcome === 'completed' ? 'running' : 'paused',
grant || await browserInstanceAccess('browser.tabs.read'),
);
await browser.action.setBadgeText({
text: state.bridge.managedInstance?.badge || '',
tabId: handoff.target.tabId,
}).catch(() => undefined);
return { state, handoff };
}
+10
View File
@@ -193,6 +193,16 @@ export const capabilityParams = {
message: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(500)), ''),
}),
'browser.handoff.status': v.optional(v.strictObject({})),
'browser.handoff.presentation.get': v.strictObject({
handoffId: id,
}),
'browser.handoff.focus': v.strictObject({
handoffId: id,
}),
'browser.handoff.resolve': v.strictObject({
handoffId: id,
outcome: v.picklist(['completed', 'cancelled']),
}),
'browser.network.start': v.optional(v.strictObject({
...targetFields,
captureHeaders: v.optional(v.boolean()),
+18
View File
@@ -3,6 +3,7 @@ import {
BRIDGE_CAPABILITIES,
canonicalCapabilityCatalogPayload,
capabilityBaseScope,
capabilityVisibleToAgent,
getBridgeCapabilityCatalog,
} from './capabilities';
@@ -34,6 +35,23 @@ describe('versioned Bridge capability catalog', () => {
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.thumbnail')).toMatchObject({
agentVisible: false,
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.handoff.presentation.get')).toMatchObject({
agentVisible: false,
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.handoff.focus')).toMatchObject({
agentVisible: false,
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.handoff.resolve')).toMatchObject({
agentVisible: false,
});
expect(capabilityVisibleToAgent('browser.handoff.presentation.get')).toBe(false);
expect(capabilityVisibleToAgent('browser.handoff.focus')).toBe(false);
expect(capabilityVisibleToAgent('browser.handoff.resolve')).toBe(false);
expect(capabilityVisibleToAgent('browser.thumbnail')).toBe(false);
expect(capabilityVisibleToAgent('browser.context')).toBe(true);
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.recovery.capture')).toMatchObject({
access: 'dangerous',
scopes: ['browser.transform.manage', 'browser.debugger.control', 'browser.callable.execute'],
+24 -2
View File
@@ -20,6 +20,7 @@ export type BridgeCapabilityMethod = keyof typeof capabilityParams;
interface CapabilityMetadata {
domain: BridgeCapabilityDomain;
access: BridgeCapabilityAccess;
agentVisible?: boolean;
summary: string;
scopes: CapabilityScope[];
conditionalScopes?: BridgeCapabilityScopeCondition[];
@@ -42,7 +43,7 @@ const CAPABILITY_METADATA = {
},
'browser.thumbnail': {
domain: 'page', access: 'read', summary: '读取当前可见标签页的低清预览图,供 Yakit 实例列表展示',
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS, agentVisible: false,
},
'browser.isolation.inspect': {
domain: 'isolation', access: 'read', summary: '读取浏览器实例内标签页的 Cookie Store 与身份隔离上下文',
@@ -168,13 +169,29 @@ const CAPABILITY_METADATA = {
scopes: ['browser.instance.close'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.request': {
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
domain: 'handoff', access: 'write',
summary: '页面需要用户扫码、MFA、验证码或设备确认时调用;Yakit 会在本地呈现交互内容,Agent 只等待结果',
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.status': {
domain: 'handoff', access: 'read', summary: '读取当前人工接管状态',
scopes: ['browser.human.takeover'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.presentation.get': {
domain: 'handoff', access: 'sensitive-read', agentVisible: false,
summary: '仅在本机提取当前扫码接管的二维码展示数据',
scopes: ['browser.human.takeover'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.focus': {
domain: 'handoff', access: 'write', agentVisible: false,
summary: '二维码无法在本地呈现时,由 Yakit 将对应浏览器实例切换到前台',
scopes: ['browser.human.takeover'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.resolve': {
domain: 'handoff', access: 'write', agentVisible: false,
summary: '由 Yakit 本地界面完成或取消人工接管',
scopes: ['browser.human.takeover'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.start': {
domain: 'network', access: 'control', summary: '启动有界网络捕获,可选采集请求头和 Body',
scopes: ['browser.network.capture'],
@@ -458,6 +475,11 @@ export function capabilityBaseScope(method: string): CapabilityScope | undefined
return CAPABILITY_METADATA[method as BridgeCapabilityMethod]?.scopes[0];
}
export function capabilityVisibleToAgent(method: string): boolean {
const metadata = CAPABILITY_METADATA[method as BridgeCapabilityMethod] as CapabilityMetadata | undefined;
return metadata?.agentVisible !== false;
}
export function isControlScopeSet(scopes: readonly CapabilityScope[]): boolean {
return scopes.some((scope) => !READ_CAPABILITY_SCOPES.includes(scope));
}
+2
View File
@@ -306,6 +306,8 @@ export interface BridgeCapabilityDescriptor {
method: string;
domain: BridgeCapabilityDomain;
access: BridgeCapabilityAccess;
/** False keeps local presentation/control methods out of the Agent tool catalog. */
agentVisible?: boolean;
summary: string;
scopes: CapabilityScope[];
conditionalScopes?: BridgeCapabilityScopeCondition[];