mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
feat(browser): add local handoff presentation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user