Update project structure and dependencies; add architecture documentation and improve build scripts. Introduce new versioning and permissions for enhanced functionality.

This commit is contained in:
go0p
2026-07-17 23:43:21 +08:00
parent f9ed85c068
commit 726a97849b
113 changed files with 15944 additions and 6556 deletions
+120
View File
@@ -0,0 +1,120 @@
import { browser } from 'wxt/browser';
import { AGENT_RUNTIME_STORAGE_KEY } from '@/protocol/storage';
import type {
AgentActionRecord, AgentActionState, AgentRuntime, AgentRuntimeState, BridgeGrant,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
interface StorageArea {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const MAX_ACTIONS = 200;
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
let queue: Promise<void> = Promise.resolve();
let fallbackRuntime: AgentRuntime | undefined;
function emptyRuntime(): AgentRuntime {
return { state: 'idle', updatedAt: Date.now(), actions: [] };
}
function normalizeRuntime(input: unknown): AgentRuntime {
if (!input || typeof input !== 'object') return emptyRuntime();
const value = input as Partial<AgentRuntime>;
return {
state: value.state || 'idle',
taskId: value.taskId,
grantId: value.grantId,
startedAt: value.startedAt,
pausedAt: value.pausedAt,
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : Date.now(),
actions: Array.isArray(value.actions) ? value.actions.slice(-MAX_ACTIONS) : [],
};
}
export async function getAgentRuntime(): Promise<AgentRuntime> {
if (!sessionStorage) return fallbackRuntime || emptyRuntime();
return normalizeRuntime((await sessionStorage.get(AGENT_RUNTIME_STORAGE_KEY))[AGENT_RUNTIME_STORAGE_KEY]);
}
async function mutate(updater: (current: AgentRuntime) => AgentRuntime | Promise<AgentRuntime>): Promise<AgentRuntime> {
let resolveResult!: (runtime: AgentRuntime) => void;
let rejectResult!: (error: unknown) => void;
const result = new Promise<AgentRuntime>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
queue = queue.then(async () => {
try {
const next = normalizeRuntime(await updater(await getAgentRuntime()));
fallbackRuntime = next;
await sessionStorage?.set({ [AGENT_RUNTIME_STORAGE_KEY]: next });
resolveResult(next);
} catch (error) {
rejectResult(error);
}
});
return result;
}
export function startAgentRuntime(grant: BridgeGrant): Promise<AgentRuntime> {
const now = Date.now();
return mutate((current) => ({
state: 'running', taskId: grant.taskId, grantId: grant.id, startedAt: now,
updatedAt: now, actions: current.grantId === grant.id ? current.actions : [],
}));
}
export function setAgentRuntimeState(state: AgentRuntimeState, grant?: BridgeGrant): Promise<AgentRuntime> {
return mutate((current) => ({
...current,
state,
taskId: grant?.taskId || current.taskId,
grantId: grant?.id || current.grantId,
pausedAt: state === 'paused' ? Date.now() : undefined,
updatedAt: Date.now(),
actions: ['revoked', 'expired'].includes(state)
? current.actions.map((action) => action.state === 'running'
? { ...action, state: 'cancelled', completedAt: Date.now(), durationMs: Date.now() - action.startedAt, errorCode: state }
: action)
: current.actions,
}));
}
export function clearAgentActions(): Promise<AgentRuntime> {
return mutate((current) => ({ ...current, actions: [], updatedAt: Date.now() }));
}
export async function beginAgentAction(
grant: BridgeGrant,
input: { requestId: string; method: string; targetTabId?: number },
): Promise<AgentActionRecord> {
let created!: AgentActionRecord;
await mutate((current) => {
const runtime = current.grantId === grant.id
? current
: { state: 'running' as const, taskId: grant.taskId, grantId: grant.id, startedAt: Date.now(), updatedAt: Date.now(), actions: [] };
if (runtime.state === 'paused' || runtime.state === 'waiting_for_human') {
throw new ExtensionError('agent_paused', runtime.state === 'waiting_for_human' ? 'Agent 正在等待用户完成接管步骤' : 'Agent 操作已被用户暂停');
}
if (runtime.state !== 'running') throw new ExtensionError('grant_expired', 'Agent 会话已经结束');
created = {
id: crypto.randomUUID(), requestId: input.requestId, taskId: grant.taskId, grantId: grant.id,
method: input.method, targetTabId: input.targetTabId, state: 'running', startedAt: Date.now(),
};
return { ...runtime, updatedAt: Date.now(), actions: [...runtime.actions, created].slice(-MAX_ACTIONS) };
});
return created;
}
export function finishAgentAction(id: string, state: Exclude<AgentActionState, 'running'>, errorCode?: string): Promise<AgentRuntime> {
const now = Date.now();
return mutate((current) => ({
...current,
updatedAt: now,
actions: current.actions.map((action) => action.id === id && action.state === 'running'
? { ...action, state, completedAt: now, durationMs: now - action.startedAt, errorCode }
: action),
}));
}
+57
View File
@@ -0,0 +1,57 @@
import { browser } from 'wxt/browser';
import type { BrowserCookie, CookieInput, CookieRemoveInput } from '@/types/models';
function toCookie(cookie: Browser.cookies.Cookie): BrowserCookie {
const extended = cookie as Browser.cookies.Cookie & {
firstPartyDomain?: string;
priority?: 'low' | 'medium' | 'high';
sameParty?: boolean;
};
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
session: cookie.session,
expirationDate: cookie.expirationDate,
hostOnly: cookie.hostOnly,
storeId: cookie.storeId,
firstPartyDomain: extended.firstPartyDomain || undefined,
partitionKey: cookie.partitionKey,
priority: extended.priority,
sameParty: extended.sameParty,
};
}
export async function listCookies(url: string): Promise<BrowserCookie[]> {
const cookies = await browser.cookies.getAll({ url, partitionKey: {} }).catch(() => browser.cookies.getAll({ url }));
return cookies.map(toCookie).sort((left, right) => left.name.localeCompare(right.name));
}
export async function setCookie(input: CookieInput): Promise<BrowserCookie> {
const details = {
url: input.url,
name: input.name,
value: input.value,
domain: input.domain || undefined,
path: input.path || '/',
secure: input.secure,
httpOnly: input.httpOnly,
sameSite: input.sameSite || 'unspecified',
expirationDate: input.expirationDate,
storeId: input.storeId,
...(input.firstPartyDomain ? { firstPartyDomain: input.firstPartyDomain } : {}),
partitionKey: input.partitionKey,
} as Parameters<typeof browser.cookies.set>[0];
const cookie = await browser.cookies.set(details);
if (!cookie) throw new Error('Cookie 写入失败');
return toCookie(cookie);
}
export async function removeCookie(input: CookieRemoveInput): Promise<void> {
const result = await browser.cookies.remove(input);
if (!result) throw new Error('Cookie 不存在或删除失败');
}
+24
View File
@@ -0,0 +1,24 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: {} }));
import type { BrowserCookie } from '@/types/models';
import { buildCookieUrl, exportCookies } from './transfer';
const cookie = {
name: 'session', value: 'secret-value', domain: '.example.test', path: '/', secure: true,
httpOnly: true, hostOnly: false, session: false, sameSite: 'lax', storeId: '0',
} as BrowserCookie;
describe('Cookie transfer', () => {
it('constructs a domain/path aware URL', () => {
expect(buildCookieUrl('http://app.example.test/start', { domain: '.example.test', path: 'api', secure: true }))
.toBe('https://example.test/api');
});
it('redacts exports unless values are explicitly requested', () => {
expect(exportCookies([cookie], 'json', false)).toContain('[REDACTED]');
expect(exportCookies([cookie], 'netscape', false)).not.toContain('secret-value');
expect(exportCookies([cookie], 'set-cookie', true)).toContain('session=secret-value');
});
});
+165
View File
@@ -0,0 +1,165 @@
import type {
BrowserCookie, CookieImportResult, CookieInput, CookieTransferFormat,
} from '@/types/models';
import { setCookie } from '@/features/cookies/service';
const MAX_COOKIES = 1_000;
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024;
function assertTransferSize(text: string): void {
if (new TextEncoder().encode(text).byteLength > MAX_TRANSFER_BYTES) throw new Error('Cookie 导入内容超过 2 MiB');
}
export function buildCookieUrl(baseUrl: string, input: Pick<CookieInput, 'domain' | 'path' | 'secure'>): string {
const base = new URL(baseUrl);
const host = input.domain?.replace(/^\./, '') || base.hostname;
const protocol = input.secure ? 'https:' : base.protocol === 'https:' ? 'https:' : 'http:';
const path = input.path?.startsWith('/') ? input.path : `/${input.path || ''}`;
return `${protocol}//${host}${path}`;
}
function sameSite(value: unknown): CookieInput['sameSite'] {
const normalized = String(value || '').toLowerCase().replace('none', 'no_restriction');
return ['lax', 'strict', 'no_restriction', 'unspecified'].includes(normalized)
? normalized as CookieInput['sameSite']
: 'unspecified';
}
function fromRecord(value: unknown, baseUrl: string, warnings: string[]): CookieInput | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Record<string, unknown>;
if (typeof input.name !== 'string' || typeof input.value !== 'string' || input.name.length > 4_096 || input.value.length > 64 * 1_024) return undefined;
const output: CookieInput = {
url: baseUrl,
name: input.name,
value: input.value,
domain: typeof input.domain === 'string' ? input.domain.slice(0, 253) : undefined,
path: typeof input.path === 'string' ? input.path.slice(0, 4_096) : '/',
secure: input.secure === true,
httpOnly: input.httpOnly === true,
sameSite: sameSite(input.sameSite),
expirationDate: typeof input.expirationDate === 'number' && Number.isFinite(input.expirationDate) ? input.expirationDate : undefined,
storeId: typeof input.storeId === 'string' ? input.storeId.slice(0, 240) : undefined,
};
if (input.partitionKey && typeof input.partitionKey === 'object') {
const partition = input.partitionKey as Record<string, unknown>;
output.partitionKey = {
topLevelSite: typeof partition.topLevelSite === 'string' ? partition.topLevelSite.slice(0, 8_192) : undefined,
hasCrossSiteAncestor: partition.hasCrossSiteAncestor === true,
};
}
if (input.priority || input.sameParty) warnings.push(`${input.name}: Priority/SameParty 无法通过浏览器 Cookies API 写回`);
output.url = buildCookieUrl(baseUrl, output);
return output;
}
function parseJSON(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed)) throw new Error('JSON Cookie 必须是数组');
if (parsed.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
return parsed.map((item) => fromRecord(item, baseUrl, warnings)).filter((item): item is CookieInput => Boolean(item));
}
function parseNetscape(text: string, baseUrl: string): CookieInput[] {
const output: CookieInput[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const httpOnly = rawLine.startsWith('#HttpOnly_');
if ((!httpOnly && rawLine.trim().startsWith('#')) || !rawLine.trim()) continue;
const line = httpOnly ? rawLine.slice('#HttpOnly_'.length) : rawLine;
const fields = line.split('\t');
if (fields.length < 7) continue;
const [domain, , path, secure, expiration, name, ...value] = fields;
const item: CookieInput = {
url: baseUrl, name, value: value.join('\t'), domain, path: path || '/', secure: secure.toUpperCase() === 'TRUE', httpOnly,
expirationDate: Number(expiration) > 0 ? Number(expiration) : undefined,
sameSite: 'unspecified',
};
item.url = buildCookieUrl(baseUrl, item);
output.push(item);
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
}
return output;
}
function parseSetCookie(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
const output: CookieInput[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.replace(/^set-cookie:\s*/i, '').trim();
if (!line) continue;
const [pair, ...attributes] = line.split(';').map((part) => part.trim());
const separator = pair.indexOf('=');
if (separator < 0) continue;
const item: CookieInput = { url: baseUrl, name: pair.slice(0, separator), value: pair.slice(separator + 1), path: '/', sameSite: 'unspecified' };
for (const attribute of attributes) {
const [rawName, ...rawValue] = attribute.split('=');
const name = rawName.toLowerCase();
const value = rawValue.join('=');
if (name === 'domain') item.domain = value;
else if (name === 'path') item.path = value || '/';
else if (name === 'secure') item.secure = true;
else if (name === 'httponly') item.httpOnly = true;
else if (name === 'samesite') item.sameSite = sameSite(value);
else if (name === 'expires') {
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) item.expirationDate = timestamp / 1_000;
} else if (name === 'max-age' && Number.isFinite(Number(value))) item.expirationDate = Date.now() / 1_000 + Number(value);
else if (name === 'partitioned') item.partitionKey = { topLevelSite: new URL(baseUrl).origin };
else if (name === 'priority' || name === 'sameparty') warnings.push(`${item.name}: ${rawName} 无法通过浏览器 Cookies API 写回`);
}
item.url = buildCookieUrl(baseUrl, item);
output.push(item);
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
}
return output;
}
export async function importCookies(baseUrl: string, format: CookieTransferFormat, text: string): Promise<CookieImportResult> {
assertTransferSize(text);
const warnings: string[] = [];
const cookies = format === 'json' ? parseJSON(text, baseUrl, warnings)
: format === 'netscape' ? parseNetscape(text, baseUrl)
: parseSetCookie(text, baseUrl, warnings);
let imported = 0;
let failed = 0;
for (const cookie of cookies) {
try {
await setCookie(cookie);
imported += 1;
} catch (error) {
failed += 1;
if (warnings.length < 50) warnings.push(`${cookie.name}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (cookies.length === 0) warnings.push('没有解析到可导入的 Cookie');
return { imported, failed, warnings: warnings.slice(0, 50) };
}
function displayValue(cookie: BrowserCookie, includeValues: boolean): string {
return includeValues ? cookie.value : '[REDACTED]';
}
export function exportCookies(cookies: BrowserCookie[], format: CookieTransferFormat, includeValues: boolean): string {
if (format === 'json') {
return JSON.stringify(cookies.map((cookie) => ({ ...cookie, value: displayValue(cookie, includeValues) })), null, 2);
}
if (format === 'netscape') {
const lines = ['# Netscape HTTP Cookie File', '# Exported by Yakit Browser Agent'];
for (const cookie of cookies) {
const domain = `${cookie.httpOnly ? '#HttpOnly_' : ''}${cookie.domain}`;
lines.push([domain, cookie.hostOnly ? 'FALSE' : 'TRUE', cookie.path, cookie.secure ? 'TRUE' : 'FALSE', Math.floor(cookie.expirationDate || 0), cookie.name, displayValue(cookie, includeValues)].join('\t'));
}
return `${lines.join('\n')}\n`;
}
return cookies.map((cookie) => {
const attributes = [`Path=${cookie.path}`];
if (!cookie.hostOnly) attributes.push(`Domain=${cookie.domain}`);
if (cookie.expirationDate) attributes.push(`Expires=${new Date(cookie.expirationDate * 1_000).toUTCString()}`);
if (cookie.secure) attributes.push('Secure');
if (cookie.httpOnly) attributes.push('HttpOnly');
if (cookie.sameSite && cookie.sameSite !== 'unspecified') attributes.push(`SameSite=${cookie.sameSite === 'no_restriction' ? 'None' : cookie.sameSite}`);
if (cookie.partitionKey) attributes.push('Partitioned');
if (cookie.priority) attributes.push(`Priority=${cookie.priority}`);
if (cookie.sameParty) attributes.push('SameParty');
return `Set-Cookie: ${cookie.name}=${displayValue(cookie, includeValues)}; ${attributes.join('; ')}`;
}).join('\n');
}
+31
View File
@@ -0,0 +1,31 @@
import { browser } from 'wxt/browser';
import type { AuditEvent } from '@/types/models';
import { AUDIT_STORAGE_KEY } from '@/protocol/storage';
const MAX_AUDIT_EVENTS = 500;
let auditQueue: Promise<void> = Promise.resolve();
export type NewAuditEvent = Omit<AuditEvent, 'id' | 'timestamp'>;
export function appendAuditEvent(input: NewAuditEvent): Promise<void> {
const operation = auditQueue.then(async () => {
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
const event: AuditEvent = { id: crypto.randomUUID(), timestamp: Date.now(), ...input };
await browser.storage.local.set({ [AUDIT_STORAGE_KEY]: [...current, event].slice(-MAX_AUDIT_EVENTS) });
});
auditQueue = operation.catch(() => undefined);
return operation;
}
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
return current.slice(-Math.min(Math.max(limit, 1), MAX_AUDIT_EVENTS)).reverse();
}
export async function clearAuditEvents(): Promise<void> {
const operation = auditQueue.then(() => browser.storage.local.remove(AUDIT_STORAGE_KEY));
auditQueue = operation.catch(() => undefined);
return operation;
}
+46
View File
@@ -0,0 +1,46 @@
import { browser } from 'wxt/browser';
import { STATE_STORAGE_KEYS } from '@/protocol/storage';
import type { BridgeStatus, DiagnosticsBundle } from '@/types/models';
import { listAuditEvents } from '@/features/diagnostics/audit';
import { getState } from '@/platform/storage/state';
import { getEnterprisePolicy } from '@/platform/policy/managed';
import { getRuntimeMetrics } from './metrics';
export async function createDiagnosticsBundle(bridge: BridgeStatus): Promise<DiagnosticsBundle> {
const manifest = browser.runtime.getManifest();
const sessionArea = (browser.storage as unknown as { session?: { get(keys: string[]): Promise<Record<string, unknown>> } }).session;
const [state, platform, policy, metrics, audit, local, session] = await Promise.all([
getState(), browser.runtime.getPlatformInfo(), getEnterprisePolicy(), getRuntimeMetrics(), listAuditEvents(100),
browser.storage.local.get([...STATE_STORAGE_KEYS]),
sessionArea?.get([...STATE_STORAGE_KEYS]) || Promise.resolve({}),
]);
const { taskId: _taskId, grantId: _grantId, ...safeBridge } = bridge;
return {
schemaVersion: 1,
generatedAt: Date.now(),
extension: {
version: manifest.version,
manifestVersion: manifest.manifest_version,
buildChannel: import.meta.env.MODE,
permissions: [...(manifest.permissions || [])].sort(),
},
platform: { os: platform.os, arch: platform.arch },
bridge: safeBridge,
policy,
state: {
proxyProfiles: state.proxyProfiles.length,
proxyRules: state.proxyRules.length,
userAgentRules: state.userAgentRules.length,
floatingPanelEnabled: state.floatingPanel.enabled,
activeGrant: Boolean(state.activeGrant),
activeGrantTargets: state.activeGrant?.targets.length || 0,
activeGrantScopes: state.activeGrant?.scopes || [],
handoffState: state.handoff?.state,
},
storageDomains: Object.fromEntries(STATE_STORAGE_KEYS.map((key) => [key, key in local || key in session])),
metrics,
recentAudit: audit.map(({ timestamp, category, action, outcome, durationMs, errorCode }) => ({
timestamp, category, action, outcome, durationMs, errorCode,
})),
};
}
+77
View File
@@ -0,0 +1,77 @@
import { browser } from 'wxt/browser';
import { RUNTIME_METRICS_STORAGE_KEY } from '@/protocol/storage';
import type { RuntimeMetrics } from '@/types/models';
let queue: Promise<void> = Promise.resolve();
function defaults(): RuntimeMetrics {
const now = Date.now();
return {
version: 1, firstSeenAt: now, updatedAt: now, serviceWorkerStarts: 0,
bridgeConnectAttempts: 0, bridgeConnections: 0, bridgeDisconnects: 0, bridgeErrors: 0,
heartbeatSamples: 0, heartbeatLatencyTotalMs: 0, heartbeatLatencyMaxMs: 0, capabilities: {},
};
}
export async function getRuntimeMetrics(): Promise<RuntimeMetrics> {
const stored = (await browser.storage.local.get(RUNTIME_METRICS_STORAGE_KEY))[RUNTIME_METRICS_STORAGE_KEY];
if (!stored || typeof stored !== 'object') return defaults();
return { ...defaults(), ...(stored as Partial<RuntimeMetrics>), capabilities: (stored as RuntimeMetrics).capabilities || {} };
}
function mutate(updater: (current: RuntimeMetrics) => RuntimeMetrics): void {
queue = queue.then(async () => {
const next = updater(await getRuntimeMetrics());
next.updatedAt = Date.now();
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
}).catch(() => undefined);
}
export function recordServiceWorkerStart(): void {
mutate((current) => ({ ...current, serviceWorkerStarts: current.serviceWorkerStarts + 1 }));
}
export function recordBridgeState(state: 'connecting' | 'connected' | 'disconnected' | 'error'): void {
mutate((current) => ({
...current,
bridgeConnectAttempts: current.bridgeConnectAttempts + (state === 'connecting' ? 1 : 0),
bridgeConnections: current.bridgeConnections + (state === 'connected' ? 1 : 0),
bridgeDisconnects: current.bridgeDisconnects + (state === 'disconnected' ? 1 : 0),
bridgeErrors: current.bridgeErrors + (state === 'error' ? 1 : 0),
}));
}
export function recordHeartbeat(latencyMs: number): void {
const bounded = Math.min(Math.max(Math.round(latencyMs), 0), 60_000);
mutate((current) => ({
...current,
heartbeatSamples: current.heartbeatSamples + 1,
heartbeatLatencyTotalMs: current.heartbeatLatencyTotalMs + bounded,
heartbeatLatencyMaxMs: Math.max(current.heartbeatLatencyMaxMs, bounded),
}));
}
export function recordCapabilityMetric(method: string, durationMs: number, error: boolean): void {
mutate((current) => {
const previous = current.capabilities[method] || { count: 0, errorCount: 0, totalDurationMs: 0, maxDurationMs: 0 };
const duration = Math.min(Math.max(Math.round(durationMs), 0), 60_000);
return {
...current,
capabilities: {
...current.capabilities,
[method]: {
count: previous.count + 1,
errorCount: previous.errorCount + (error ? 1 : 0),
totalDurationMs: previous.totalDurationMs + duration,
maxDurationMs: Math.max(previous.maxDurationMs, duration),
},
},
};
});
}
export async function resetRuntimeMetrics(): Promise<RuntimeMetrics> {
const next = defaults();
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
return next;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import type { BridgeEnvelope } from '@/types/messages';
import {
clientAuthPayload, engineChallengePayload, pairingVerificationCode, signBridgePayload, verifyBridgePayload,
} from './identity';
describe('Bridge v3 identity transcript', () => {
it('keeps the Go-compatible canonical field order', () => {
expect(engineChallengePayload({
engineIdentityId: 'identity-1', engineInstanceId: 'instance-1', challenge: 'nonce-1', timestamp: 123,
})).toBe('yak-browser-bridge-v3\nengine-challenge\nidentity-1\ninstance-1\nnonce-1\n123');
const envelope: BridgeEnvelope = {
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
capabilities: ['z.capability', 'a.capability'], taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
};
expect(clientAuthPayload({
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
challenge: 'nonce-1', envelope,
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\ntask-1\ngrant-1\nsession-1');
});
it('matches the shared pairing verification vector', async () => {
await expect(pairingVerificationCode({
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
publicKey: { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' },
})).resolves.toBe('113961');
});
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);
const publicKey = { kty: 'EC' as const, crv: 'P-256' as const, x: publicJWK.x!, y: publicJWK.y! };
const signature = await signBridgePayload(pair.privateKey, 'payload');
await expect(verifyBridgePayload(publicKey, 'payload', signature)).resolves.toBe(true);
await expect(verifyBridgePayload(publicKey, 'tampered', signature)).resolves.toBe(false);
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
const DATABASE_NAME = 'yakit-browser-bridge-identity-v1';
const STORE_NAME = 'identities';
interface StoredBrowserIdentity {
installationId: string;
privateKey: CryptoKey;
publicKey: BridgePublicKey;
createdAt: number;
}
function openIdentityDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME, { keyPath: 'installationId' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('无法打开浏览器配对身份数据库'));
});
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('浏览器配对身份数据库操作失败'));
});
}
async function readIdentity(installationId: string): Promise<StoredBrowserIdentity | undefined> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readonly');
return await requestResult(transaction.objectStore(STORE_NAME).get(installationId)) as StoredBrowserIdentity | undefined;
} finally {
database.close();
}
}
async function writeIdentity(identity: StoredBrowserIdentity): Promise<void> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readwrite');
await requestResult(transaction.objectStore(STORE_NAME).put(identity));
} finally {
database.close();
}
}
export async function clearBrowserBridgeIdentity(installationId: string): Promise<void> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readwrite');
await requestResult(transaction.objectStore(STORE_NAME).delete(installationId));
} finally {
database.close();
}
}
function normalizePublicJWK(value: JsonWebKey): BridgePublicKey {
if (value.kty !== 'EC' || value.crv !== 'P-256' || !value.x || !value.y) throw new Error('浏览器配对公钥不是 ECDSA P-256');
return { kty: 'EC', crv: 'P-256', x: value.x, y: value.y };
}
export async function getOrCreateBrowserBridgeIdentity(installationId: string): Promise<StoredBrowserIdentity> {
const existing = await readIdentity(installationId);
if (existing?.privateKey && existing.publicKey) return existing;
const generated = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const [publicJWK, privatePKCS8] = await Promise.all([
crypto.subtle.exportKey('jwk', generated.publicKey),
crypto.subtle.exportKey('pkcs8', generated.privateKey),
]);
const privateKey = await crypto.subtle.importKey('pkcs8', privatePKCS8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
const identity: StoredBrowserIdentity = {
installationId,
privateKey,
publicKey: normalizePublicJWK(publicJWK),
createdAt: Date.now(),
};
await writeIdentity(identity);
return identity;
}
function bytesToBase64URL(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
}
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
function base64URLToBytes(value: string): Uint8Array {
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (value.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
export function randomBridgeNonce(): string {
return bytesToBase64URL(crypto.getRandomValues(new Uint8Array(32)));
}
export async function signBridgePayload(privateKey: CryptoKey, payload: string): Promise<string> {
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(payload));
return bytesToBase64URL(new Uint8Array(signature));
}
export async function verifyBridgePayload(publicKey: BridgePublicKey, payload: string, signature: string): Promise<boolean> {
const key = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, key,
base64URLToBytes(signature).buffer as ArrayBuffer,
new TextEncoder().encode(payload),
);
}
export function engineChallengePayload(input: {
engineIdentityId: string;
engineInstanceId: string;
challenge: string;
timestamp: number;
}): string {
return [
'yak-browser-bridge-v3', 'engine-challenge', input.engineIdentityId, input.engineInstanceId,
input.challenge, String(input.timestamp),
].join('\n');
}
export function clientAuthPayload(input: {
origin: string;
engineIdentityId: string;
engineInstanceId: string;
challenge: string;
envelope: BridgeEnvelope;
}): string {
return [
'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(','), input.envelope.taskId || '', input.envelope.grantId || '',
input.envelope.resumeSessionId || '',
].join('\n');
}
export async function pairingVerificationCode(input: {
engineIdentityId: string;
requestId: string;
origin: string;
installationId: string;
clientNonce: string;
serverNonce: string;
publicKey: BridgePublicKey;
}): Promise<string> {
const payload = [
'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');
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);
return String(value % 1_000_000n).padStart(6, '0');
}
export function publicKeysEqual(left: BridgePublicKey, right: BridgePublicKey): boolean {
return left.kty === right.kty && left.crv === right.crv && left.x === right.x && left.y === right.y;
}
+755
View File
@@ -0,0 +1,755 @@
import { browser } from 'wxt/browser';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
import { BRIDGE_CAPABILITIES } 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,
parseBridgePairingEnvelope, type BridgePairingEnvelope,
} from '@/protocol/bridge';
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
import { routeCapability } from '@/features/grants/service';
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
import { appendAuditEvent } from '@/features/diagnostics/audit';
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
import { recordBridgeState, recordCapabilityMetric, recordHeartbeat } from '@/features/diagnostics/metrics';
import {
clearBrowserBridgeIdentity, clientAuthPayload, engineChallengePayload, getOrCreateBrowserBridgeIdentity,
pairingVerificationCode, publicKeysEqual, randomBridgeNonce, signBridgePayload, verifyBridgePayload,
} from './identity';
const STATUS_EVENT = 'bridge.status.changed';
const PAIRING_STATUS_EVENT = 'bridge.pairing.status.changed';
const RECONNECT_DELAY = 3_000;
const HEARTBEAT_INTERVAL = 20_000;
const HANDSHAKE_TIMEOUT = 5_000;
const MAX_CONCURRENT_REQUESTS = 8;
const ENGINE_REQUEST_TIMEOUT = 10_000;
const MAX_OUTGOING_REQUESTS = 4;
interface OutgoingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface ChunkAssembly {
createdAt: number;
total: number;
originalBytes: number;
parts: Array<Uint8Array | undefined>;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function isLoopbackEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return (url.protocol === 'ws:' || url.protocol === 'wss:')
&& ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
} catch {
return false;
}
}
export class EngineBridge {
private socket?: WebSocket;
private nativePort?: Browser.runtime.Port;
private reconnectTimer?: ReturnType<typeof globalThis.setTimeout>;
private heartbeatTimer?: ReturnType<typeof globalThis.setInterval>;
private handshakeTimer?: ReturnType<typeof globalThis.setTimeout>;
private handshakeResolve?: () => void;
private handshakeReject?: (error: Error) => void;
private connectPromise?: Promise<void>;
private pairingSocket?: WebSocket;
private pairingTimer?: ReturnType<typeof globalThis.setTimeout>;
private pairingResolve?: (status: BridgePairingStatus) => void;
private pairingReject?: (error: Error) => void;
private pairingContext?: {
config: BridgeConfig;
clientNonce: string;
publicKey: BridgePublicKey;
privateKey: CryptoKey;
requestId?: string;
engineIdentityId?: string;
enginePublicKey?: BridgePublicKey;
};
private readonly inFlight = new Map<string, AbortController>();
private readonly outgoing = new Map<string, OutgoingRequest>();
private readonly chunks = new Map<string, ChunkAssembly>();
private heartbeatSequence = 0;
private manuallyClosed = false;
private status: BridgeStatus = { state: 'disconnected', message: '未连接引擎' };
private pairingStatus: BridgePairingStatus = { state: 'idle', message: '尚未配对' };
getStatus(): BridgeStatus {
return this.status;
}
getPairingStatus(): BridgePairingStatus {
return this.pairingStatus;
}
emitEvent(method: string, params: unknown): void {
if (this.status.state === 'connected') this.send({ type: 'event', method, params });
}
requestEngine<T>(method: string, params: unknown, timeoutMs = ENGINE_REQUEST_TIMEOUT): Promise<T> {
if (this.status.state !== 'connected') return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎未连接'));
if (!this.nativePort && this.socket?.readyState !== WebSocket.OPEN) {
return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎连接不可用'));
}
if (this.outgoing.size >= MAX_OUTGOING_REQUESTS) {
return Promise.reject(new ExtensionError('server_busy', `插件到 Yak 的并行请求已达到 ${MAX_OUTGOING_REQUESTS} 个上限`));
}
if (this.status.capabilities && !this.status.capabilities.includes(method)) {
return Promise.reject(new ExtensionError('engine_capability_unavailable', `Yak 引擎不支持能力: ${method}`));
}
const id = `extension-${crypto.randomUUID()}`;
return new Promise<T>((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
this.outgoing.delete(id);
this.send({ type: 'cancel', id });
reject(new ExtensionError('engine_timeout', `Yak 引擎请求超过 ${timeoutMs}ms`));
}, timeoutMs);
this.outgoing.set(id, { resolve: (value) => resolve(value as T), reject, timer });
try {
this.send({ type: 'request', id, method, params });
} catch (error) {
globalThis.clearTimeout(timer);
this.outgoing.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
async connect(config?: BridgeConfig): Promise<void> {
if (this.status.state === 'connected') return;
if (this.connectPromise) return this.connectPromise;
const effectiveConfig = config || (await getState()).bridge;
if (!effectiveConfig.pairedEngine) throw new Error('浏览器插件尚未与 Yak 引擎配对');
const attempt = (effectiveConfig.transport === 'native'
? this.connectNative(effectiveConfig)
: this.connectWebSocket(effectiveConfig)).catch((error) => {
const failure = error instanceof Error ? error : new Error(String(error));
if (!this.manuallyClosed && this.status.state !== 'error') this.setStatus({ state: 'error', message: failure.message });
throw failure;
});
const tracked = attempt.finally(() => {
if (this.connectPromise === tracked) this.connectPromise = undefined;
});
this.connectPromise = tracked;
return tracked;
}
disconnect(): void {
this.manuallyClosed = true;
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
this.failHandshake(new Error('Bridge 连接已取消'));
this.stopHeartbeat();
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Bridge 已断开'));
this.socket?.close(1000, 'user disconnected');
this.socket = undefined;
this.nativePort?.disconnect();
this.nativePort = undefined;
this.setStatus({ state: 'disconnected', message: '已手动断开' });
}
cancelActiveRequests(): void {
this.abortInFlight();
}
private async connectWebSocket(config: BridgeConfig): Promise<void> {
if (!isLoopbackEndpoint(config.endpoint)) {
throw new Error('Bridge 仅允许连接本机 ws://127.0.0.1、localhost 或 ::1');
}
this.manuallyClosed = false;
this.setStatus({ state: 'connecting', message: '正在连接本地 Yak 引擎' });
const socket = new WebSocket(config.endpoint);
this.socket = socket;
const negotiated = this.createHandshakePromise();
socket.addEventListener('open', () => this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' }));
socket.addEventListener('message', (event) => void this.onMessage(String(event.data)));
socket.addEventListener('error', () => {
const error = new Error('Bridge 连接失败');
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
});
socket.addEventListener('close', () => {
this.stopHeartbeat();
if (this.socket === socket) {
this.socket = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
}
this.failHandshake(new Error('Bridge 在协议协商完成前断开'));
if (!this.manuallyClosed) this.setStatus({ state: 'disconnected', message: '与 Yak 引擎的连接已断开' });
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
return negotiated;
}
private async connectNative(config: BridgeConfig): Promise<void> {
if (!config.nativeHost.trim()) throw new Error('Native Messaging Host 名称不能为空');
this.manuallyClosed = false;
this.setStatus({ state: 'connecting', message: '正在连接 Yakit Native Host' });
const port = browser.runtime.connectNative(config.nativeHost.trim());
this.nativePort = port;
const negotiated = this.createHandshakePromise();
port.onMessage.addListener((message) => void this.onMessage(message));
port.onDisconnect.addListener(() => {
const lastError = browser.runtime.lastError?.message;
if (this.nativePort === port) {
this.nativePort = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
}
this.failHandshake(new Error(lastError || 'Native Host 在协议协商完成前断开'));
this.stopHeartbeat();
this.setStatus({ state: lastError ? 'error' : 'disconnected', message: lastError || 'Native Host 已断开' });
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' });
return negotiated;
}
private async answerChallenge(config: BridgeConfig, challenge: BridgeEnvelope): Promise<void> {
const paired = config.pairedEngine;
if (!paired || !challenge.publicKey || !challenge.engineIdentityId || !challenge.engineInstanceId || !challenge.challenge || !challenge.signature || !challenge.timestamp) {
throw new Error('Yak 引擎返回了不完整的身份挑战');
}
if (Math.abs(Date.now() - challenge.timestamp) > 60_000) throw new Error('Yak 引擎身份挑战已经过期');
if (paired.engineIdentityId !== challenge.engineIdentityId || !publicKeysEqual(paired.publicKey, challenge.publicKey)) {
throw new Error('Yak 引擎身份与首次配对记录不一致');
}
const verified = await verifyBridgePayload(challenge.publicKey, engineChallengePayload({
engineIdentityId: challenge.engineIdentityId,
engineInstanceId: challenge.engineInstanceId,
challenge: challenge.challenge,
timestamp: challenge.timestamp,
}), challenge.signature);
if (!verified) throw new Error('Yak 引擎身份签名验证失败');
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const auth: BridgeEnvelope = {
type: 'auth',
client: 'yakit-browser-extension',
version: browser.runtime.getManifest().version,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: [...BRIDGE_CAPABILITIES],
installationId: config.installationId,
taskId: state.activeGrant?.taskId,
grantId: state.activeGrant?.id,
resumeSessionId: previousSession?.sessionId,
challenge: challenge.challenge,
};
auth.signature = await signBridgePayload(identity.privateKey, clientAuthPayload({
origin: browser.runtime.getURL('').replace(/\/$/, ''),
engineIdentityId: challenge.engineIdentityId,
engineInstanceId: challenge.engineInstanceId,
challenge: challenge.challenge,
envelope: auth,
}));
this.send(auth);
}
private createHandshakePromise(): Promise<void> {
this.failHandshake(new Error('Bridge 协议协商已被新连接替代'));
return new Promise<void>((resolve, reject) => {
this.handshakeResolve = resolve;
this.handshakeReject = reject;
this.handshakeTimer = globalThis.setTimeout(() => {
const error = new Error(`Bridge 协议协商超过 ${HANDSHAKE_TIMEOUT / 1_000} 秒`);
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
this.socket?.close(1002, 'handshake timeout');
this.nativePort?.disconnect();
}, HANDSHAKE_TIMEOUT);
});
}
private async completeHandshake(message: BridgeEnvelope): Promise<void> {
if (!this.handshakeResolve) return;
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
const resolve = this.handshakeResolve;
this.handshakeTimer = undefined;
this.handshakeResolve = undefined;
this.handshakeReject = undefined;
this.setStatus({
state: 'connected',
message: '已连接 Yak 引擎',
connectedAt: Date.now(),
engineVersion: message.version,
protocolVersion: message.protocolVersion,
capabilities: message.capabilities,
sessionId: message.sessionId,
engineInstanceId: message.engineInstanceId,
engineIdentityId: message.engineIdentityId,
connectionId: message.connectionId,
taskId: message.taskId,
grantId: message.grantId,
resumed: message.resumed,
});
await setBridgeRuntimeSession({
sessionId: message.sessionId!,
engineInstanceId: message.engineInstanceId!,
engineIdentityId: message.engineIdentityId,
taskId: message.taskId,
grantId: message.grantId,
updatedAt: Date.now(),
});
this.startHeartbeat();
resolve();
}
private failHandshake(error: Error): void {
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
const reject = this.handshakeReject;
this.handshakeTimer = undefined;
this.handshakeResolve = undefined;
this.handshakeReject = undefined;
reject?.(error);
}
private async onMessage(raw: unknown): Promise<void> {
let message: BridgeEnvelope;
try {
message = parseBridgeEnvelope(raw);
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
if (this.status.state === 'negotiating') {
this.failHandshake(failure);
this.setStatus({ state: 'error', message: failure.message });
this.socket?.close(1002, 'invalid handshake');
this.nativePort?.disconnect();
}
return;
}
if (message.type === 'chunk') {
try {
const assembled = this.acceptChunk(message);
if (assembled !== undefined) await this.onMessage(assembled);
} catch (error) {
this.setStatus({ ...this.status, message: error instanceof Error ? error.message : String(error) });
}
return;
}
if (message.type === 'challenge') {
try {
await this.answerChallenge((await getState()).bridge, message);
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
this.failHandshake(failure);
this.setStatus({ state: 'error', message: failure.message });
this.socket?.close(1008, 'identity verification failed');
this.nativePort?.disconnect();
}
return;
}
if (message.type === 'hello_ack') {
await this.completeHandshake(message);
return;
}
if (message.type === 'response' && message.error && this.status.state === 'negotiating') {
const error = new Error(message.error.message || 'Bridge 拒绝连接');
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
return;
}
if (message.type === 'response' && message.id) {
const pending = this.outgoing.get(message.id);
if (!pending) return;
globalThis.clearTimeout(pending.timer);
this.outgoing.delete(message.id);
if (message.error) pending.reject(new ExtensionError(message.error.code, message.error.message));
else pending.resolve(message.result);
return;
}
if (message.type === 'ping') {
this.send({
type: 'pong', id: message.id, sequence: message.sequence,
timestamp: message.timestamp, replyTimestamp: Date.now(),
});
return;
}
if (message.type === 'pong') {
const now = Date.now();
const latencyMs = Math.max(0, now - Number(message.timestamp));
recordHeartbeat(latencyMs);
this.setStatus({
...this.status,
heartbeatSequence: message.sequence,
latencyMs,
lastHeartbeatAt: now,
});
return;
}
if (message.type === 'cancel' && message.id) {
this.inFlight.get(message.id)?.abort();
return;
}
if (this.status.state !== 'connected' || message.type !== 'request' || !message.id || !message.method) return;
if (this.inFlight.size >= MAX_CONCURRENT_REQUESTS) {
this.send({
type: 'response',
id: message.id,
error: { code: 'server_busy', message: `Bridge 并行请求已达到 ${MAX_CONCURRENT_REQUESTS} 个上限` },
});
void appendAuditEvent({
category: 'capability', action: message.method, outcome: 'denied', errorCode: 'server_busy',
targetTabId: typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
? (message.params as { tabId: number }).tabId
: undefined,
});
return;
}
if (this.inFlight.has(message.id)) {
this.send({
type: 'response', id: message.id,
error: { code: 'duplicate_request_id', message: 'Bridge 请求 ID 正在使用中' },
});
return;
}
const controller = new AbortController();
this.inFlight.set(message.id, controller);
const cancelled = new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(new ExtensionError('cancelled', 'Bridge 请求已取消')), { once: true });
});
const startedAt = performance.now();
let taskId: string | undefined;
let actionId: string | undefined;
let targetTabId = typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
? (message.params as { tabId: number }).tabId
: undefined;
try {
const grant = (await getState()).activeGrant;
taskId = grant?.taskId;
targetTabId ??= grant?.targets[0]?.tabId;
if (grant) {
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;
this.send({ type: 'response', id: message.id, result });
if (actionId) void finishAgentAction(actionId, 'success');
recordCapabilityMetric(message.method, durationMs, false);
void appendAuditEvent({
category: 'capability', action: message.method, outcome: 'success', taskId,
targetTabId, durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
});
} catch (error) {
const code = errorCode(error);
recordCapabilityMetric(message.method, performance.now() - startedAt, true);
this.send({
type: 'response',
id: message.id,
error: { code, message: error instanceof Error ? error.message : String(error) },
});
if (actionId) {
void finishAgentAction(
actionId,
code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
code,
);
}
void appendAuditEvent({
category: 'capability', action: message.method,
outcome: code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
taskId, targetTabId, errorCode: code,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
});
} finally {
this.inFlight.delete(message.id);
}
}
private send(message: BridgeEnvelope): void {
let encoded = JSON.stringify(message);
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) {
if (message.type !== 'response' || !message.id) throw new Error('Bridge 出站消息超过 16 MiB 限制');
encoded = JSON.stringify({
type: 'response',
id: message.id,
error: { code: 'payload_too_large', message: 'Bridge 响应超过 16 MiB 限制' },
} satisfies BridgeEnvelope);
}
const bytes = new TextEncoder().encode(encoded);
if (bytes.byteLength > BRIDGE_CHUNK_THRESHOLD_BYTES) {
const transferId = `chunk-${crypto.randomUUID()}`;
const total = Math.ceil(bytes.byteLength / BRIDGE_CHUNK_BYTES);
for (let index = 0; index < total; index += 1) {
const start = index * BRIDGE_CHUNK_BYTES;
this.sendRaw(JSON.stringify({
type: 'chunk', transferId, index, total, originalBytes: bytes.byteLength,
data: bytesToBase64(bytes.subarray(start, Math.min(start + BRIDGE_CHUNK_BYTES, bytes.byteLength))),
} satisfies BridgeEnvelope));
}
return;
}
this.sendRaw(encoded);
}
private sendRaw(encoded: string): void {
if (this.nativePort) {
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
return;
}
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoded);
}
private acceptChunk(message: BridgeEnvelope): string | undefined {
const now = Date.now();
for (const [id, assembly] of this.chunks) {
if (now - assembly.createdAt > BRIDGE_CHUNK_TIMEOUT_MS) this.chunks.delete(id);
}
const transferId = message.transferId!;
let assembly = this.chunks.get(transferId);
if (!assembly) {
if (this.chunks.size >= BRIDGE_MAX_CHUNK_TRANSFERS) throw new Error('Bridge 并行分片传输超过上限');
assembly = {
createdAt: now, total: message.total!, originalBytes: message.originalBytes!,
parts: new Array<Uint8Array | undefined>(message.total!),
};
this.chunks.set(transferId, assembly);
}
if (assembly.total !== message.total || assembly.originalBytes !== message.originalBytes) {
this.chunks.delete(transferId);
throw new Error('Bridge 分片元数据不一致');
}
const part = base64ToBytes(message.data!);
if (part.byteLength > BRIDGE_CHUNK_BYTES || (message.index! < assembly.total - 1 && part.byteLength !== BRIDGE_CHUNK_BYTES)) {
this.chunks.delete(transferId);
throw new Error('Bridge 分片大小无效');
}
assembly.parts[message.index!] = part;
if (assembly.parts.some((item) => item === undefined)) return undefined;
const bytes = new Uint8Array(assembly.originalBytes);
let offset = 0;
for (const item of assembly.parts) {
bytes.set(item!, offset);
offset += item!.byteLength;
}
this.chunks.delete(transferId);
if (offset !== assembly.originalBytes) throw new Error('Bridge 分片重组大小不匹配');
return new TextDecoder().decode(bytes);
}
private scheduleReconnect(config: BridgeConfig): void {
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
this.reconnectTimer = globalThis.setTimeout(() => void this.connect(config).catch(() => undefined), RECONNECT_DELAY);
}
private abortInFlight(): void {
for (const controller of this.inFlight.values()) controller.abort();
this.inFlight.clear();
this.chunks.clear();
}
private rejectOutgoing(error: Error): void {
for (const pending of this.outgoing.values()) {
globalThis.clearTimeout(pending.timer);
pending.reject(error);
}
this.outgoing.clear();
}
private startHeartbeat(): void {
this.stopHeartbeat();
const ping = () => {
const sequence = ++this.heartbeatSequence;
this.send({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: Date.now() });
};
ping();
this.heartbeatTimer = globalThis.setInterval(ping, HEARTBEAT_INTERVAL);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) globalThis.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
private setStatus(status: BridgeStatus): void {
if (status.state !== this.status.state && ['connecting', 'connected', 'disconnected', 'error'].includes(status.state)) {
recordBridgeState(status.state as 'connecting' | 'connected' | 'disconnected' | 'error');
}
this.status = status;
void browser.runtime.sendMessage({ action: STATUS_EVENT, payload: status }).catch(() => undefined);
}
async startPairing(): Promise<BridgePairingStatus> {
const config = (await getState()).bridge;
if (config.pairedEngine) return { state: 'approved', message: '当前浏览器已经完成配对', engineIdentityId: config.pairedEngine.engineIdentityId };
if (!isLoopbackEndpoint(config.endpoint)) throw new Error('配对仅允许访问本机 Yak Bridge');
if (this.pairingSocket && ['requesting', 'pending'].includes(this.pairingStatus.state)) return this.pairingStatus;
this.cancelPairing(false);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const clientNonce = randomBridgeNonce();
const pairingURL = new URL(config.endpoint);
pairingURL.pathname = '/pairing';
pairingURL.search = '';
pairingURL.hash = '';
const socket = new WebSocket(pairingURL.toString());
this.pairingSocket = socket;
this.pairingContext = { config, clientNonce, publicKey: identity.publicKey, privateKey: identity.privateKey };
this.setPairingStatus({ state: 'requesting', message: '正在向本机 Yak 引擎申请配对' });
const pending = new Promise<BridgePairingStatus>((resolve, reject) => {
this.pairingResolve = resolve;
this.pairingReject = reject;
this.pairingTimer = globalThis.setTimeout(() => {
const error = new Error('Yak 引擎配对请求超过 5 秒未响应');
this.failPairing(error);
socket.close(1000, 'pairing timeout');
}, HANDSHAKE_TIMEOUT);
});
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
});
socket.addEventListener('message', (event) => void this.onPairingMessage(String(event.data)));
socket.addEventListener('error', () => this.failPairing(new Error('无法连接本机 Yak 配对服务')));
socket.addEventListener('close', () => {
if (this.pairingSocket === socket) this.pairingSocket = undefined;
if (['requesting', 'pending'].includes(this.pairingStatus.state)) this.failPairing(new Error('Yak 配对连接已断开'));
});
return pending;
}
cancelPairing(notify = true): BridgePairingStatus {
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.pairingResolve = undefined;
this.pairingReject = undefined;
this.pairingContext = undefined;
this.pairingSocket?.close(1000, 'pairing cancelled');
this.pairingSocket = undefined;
const status: BridgePairingStatus = { state: 'idle', message: '配对已取消' };
if (notify) this.setPairingStatus(status);
return status;
}
async unpair(): Promise<void> {
const state = await getState();
this.disconnect();
this.cancelPairing(false);
await clearBrowserBridgeIdentity(state.bridge.installationId);
await updateState((current) => ({
...current,
bridge: {
...current.bridge,
pairedEngine: undefined,
autoConnect: false,
},
}));
this.setPairingStatus({ state: 'idle', message: '本地配对凭据已清除,浏览器安装身份保持不变' });
}
private async onPairingMessage(raw: unknown): Promise<void> {
let message: BridgePairingEnvelope;
try {
message = parseBridgePairingEnvelope(raw);
} catch (error) {
this.failPairing(error instanceof Error ? error : new Error(String(error)));
return;
}
const context = this.pairingContext;
if (!context) return;
if (message.type === 'pair_pending') {
const code = await pairingVerificationCode({
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
});
if (code !== message.code) {
this.failPairing(new Error('Yak 配对验证码校验失败'));
this.pairingSocket?.close(1008, 'pairing transcript mismatch');
return;
}
context.requestId = message.requestId;
context.engineIdentityId = message.engineIdentityId;
context.enginePublicKey = message.publicKey;
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
const status: BridgePairingStatus = {
state: 'pending', message: '请在 Yakit 中确认相同的验证码',
requestId: message.requestId, code, engineIdentityId: message.engineIdentityId, expiresAt: message.expiresAt,
};
this.setPairingStatus(status);
this.pairingResolve?.(status);
this.pairingResolve = undefined;
this.pairingReject = undefined;
return;
}
if (message.type === 'pair_approved') {
if (!context.requestId || message.requestId !== context.requestId || !context.engineIdentityId || !context.enginePublicKey
|| message.engineIdentityId !== context.engineIdentityId || !message.publicKey || !publicKeysEqual(message.publicKey, context.enginePublicKey)) {
this.failPairing(new Error('Yak 配对批准信息与当前申请不一致'));
return;
}
const next = await updateState((current) => ({
...current,
bridge: {
...current.bridge,
autoConnect: true,
pairedEngine: {
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
publicKey: message.publicKey!, pairedAt: Date.now(),
},
},
}));
this.setPairingStatus({ state: 'approved', message: '已与 Yak 引擎安全配对', engineIdentityId: message.engineIdentityId });
this.pairingContext = undefined;
this.pairingSocket?.close(1000, 'pairing approved');
this.pairingSocket = undefined;
await this.connect(next.bridge);
return;
}
const state = message.type === 'pair_rejected' ? 'rejected' : message.type === 'pair_expired' ? 'expired' : 'error';
const status: BridgePairingStatus = { state, message: message.message || 'Yak 引擎拒绝了配对申请', requestId: message.requestId };
this.setPairingStatus(status);
this.pairingContext = undefined;
this.pairingSocket?.close(1000, state);
this.pairingSocket = undefined;
}
private failPairing(error: Error): void {
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.pairingReject?.(error);
this.pairingResolve = undefined;
this.pairingReject = undefined;
this.pairingContext = undefined;
this.setPairingStatus({ state: 'error', message: error.message });
}
private setPairingStatus(status: BridgePairingStatus): void {
this.pairingStatus = status;
void browser.runtime.sendMessage({ action: PAIRING_STATUS_EVENT, payload: status }).catch(() => undefined);
}
}
export const engineBridge = new EngineBridge();
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle, Braces, Check, ChevronLeft, ChevronRight, Copy, ExternalLink, GripVertical,
EyeOff, Network, Pause, Play, Radio, RefreshCw, Settings, ShieldCheck, X,
} 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';
interface FloatingPanelProps {
initialState: ExtensionState;
initialTab?: ActiveTabInfo;
initialBridge: BridgeStatus;
yakIconUrl: string;
embedded?: boolean;
}
export function FloatingPanel({ initialState, initialTab, initialBridge, yakIconUrl, embedded = false }: FloatingPanelProps) {
const [state, setState] = useState(initialState);
const [bridge, setBridge] = useState(initialBridge);
const [tab] = useState(initialTab);
const [expanded, setExpanded] = useState(embedded);
const [side, setSide] = useState(initialState.floatingPanel.side);
const [y, setY] = useState(initialState.floatingPanel.y);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const [context, setContext] = useState<PageContext>();
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const drag = useRef<{ pointerId: number; startX: number; startY: number; moved: boolean } | undefined>(undefined);
const activeProfile = useMemo(
() => state.proxyProfiles.find((profile) => profile.id === state.activeProxyId),
[state],
);
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;
useEffect(() => {
void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
const listener = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) {
void request('state.get').then((next) => {
setState(next);
setSide(next.floatingPanel.side);
setY(next.floatingPanel.y);
}).catch(() => undefined);
}
if (AGENT_RUNTIME_STORAGE_KEY in changes) void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
};
browser.storage.onChanged.addListener(listener);
return () => browser.storage.onChanged.removeListener(listener);
}, []);
useEffect(() => {
const listener = (message: unknown) => {
const input = message as { action?: string; payload?: BridgeStatus };
if (input?.action === 'bridge.status.changed' && input.payload) setBridge(input.payload);
};
browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(listener);
}, []);
// Embedded mode: report natural content height so the host shell can size the iframe (no dead space, internal scroll when clamped).
useEffect(() => {
if (!embedded) return undefined;
const post = () => {
const header = document.querySelector('.floating-panel__header');
const body = document.querySelector('.floating-panel__body');
const height = (header?.getBoundingClientRect().height || 46) + (body?.scrollHeight || 0);
window.parent.postMessage({ channel: 'yakit-floating-host', type: 'resize', height: Math.ceil(height) }, '*');
};
post();
const observer = new ResizeObserver(post);
observer.observe(document.body);
return () => observer.disconnect();
}, [embedded]);
const run = async (task: () => Promise<void>) => {
setBusy(true);
setNotice('');
try {
await task();
} catch (error) {
setNotice(errorMessage(error));
} finally {
setBusy(false);
}
};
const openWorkspace = (section: string) => {
const target = tab ? `?tabId=${tab.id}` : '';
window.open(browser.runtime.getURL(`/options.html${target}#${section}`), '_blank', 'noopener');
};
const hideCurrentSite = () => run(async () => {
if (!tab?.url) return;
const origin = new URL(tab.url).origin;
const current = state.floatingPanel;
const siteOrigins = current.siteMode === 'allowlist'
? current.siteOrigins.filter((item) => item !== origin)
: [...new Set([...current.siteOrigins, origin])];
setState(await request('panel.update', {
siteMode: current.siteMode === 'allowlist' ? 'allowlist' : 'denylist', siteOrigins,
}));
});
const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLElement>) => {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - current.startX, event.clientY - current.startY) > 4) current.moved = true;
if (!current.moved) return;
setY(Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92));
setSide(event.clientX < window.innerWidth / 2 ? 'left' : 'right');
};
const onPointerUp = (event: React.PointerEvent<HTMLElement>) => {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
drag.current = undefined;
if (current.moved) {
const nextSide = event.clientX < window.innerWidth / 2 ? 'left' : 'right';
const nextY = Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92);
setSide(nextSide);
setY(nextY);
void request('panel.update', { side: nextSide, y: nextY }).then(setState).catch(() => undefined);
} else {
setExpanded((value) => !value);
}
};
if (!state.floatingPanel.enabled) return null;
const collapseEmbedded = () => window.parent.postMessage({ channel: 'yakit-floating-host', type: 'collapse' }, '*');
return (
<div className={`floating-panel floating-panel--${side} ${embedded ? 'floating-panel--embedded' : ''} ${expanded ? 'is-expanded' : ''}`} style={embedded ? undefined : { top: `${y * 100}%` }}>
<div className="floating-panel__header" onClick={embedded ? collapseEmbedded : undefined} onPointerDown={embedded ? undefined : onPointerDown} onPointerMove={embedded ? undefined : onPointerMove} onPointerUp={embedded ? undefined : onPointerUp}>
<button className="floating-panel__brand" aria-label={expanded ? '收起 Yakit Browser Agent' : '展开 Yakit Browser Agent'}>
<img src={yakIconUrl} alt="Yak" draggable={false} />
<span className={`floating-panel__signal ${bridge.state}`} />
</button>
{expanded && <>
<div className="floating-panel__title">
<strong>Yakit Browser Agent</strong>
<span>{activeProfile?.name || (state.activeProxyId === 'rules' ? '按规则分流' : '浏览器工具')}</span>
</div>
<GripVertical className="floating-panel__grip" size={15} aria-hidden="true" />
{side === 'right' ? <ChevronRight size={15} /> : <ChevronLeft size={15} />}
</>}
</div>
{expanded && (
<div className="floating-panel__body">
<Tabs key={handoff?.id || 'default'} defaultValue={handoff ? 'agent' : 'proxy'}>
<TabsList className="floating-tabs">
<TabsTrigger value="proxy"><Network size={13} />代理</TabsTrigger>
<TabsTrigger value="context"><Braces size={13} />上下文</TabsTrigger>
<TabsTrigger value="agent">{handoff ? <AlertTriangle size={13} /> : <ShieldCheck size={13} />}Agent</TabsTrigger>
</TabsList>
<TabsContent value="proxy" className="floating-tab-content">
<div className="floating-section-heading"><span>快速切换</span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
<div className="floating-option-list">
{state.proxyProfiles.map((profile) => (
<button key={profile.id} className={state.activeProxyId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="floating-radio" />
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
</button>
))}
{state.proxyRules.length > 0 && <button className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}><i className="floating-radio" /><span><strong>按规则分流</strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} 条启用规则</small></span></button>}
</div>
</TabsContent>
<TabsContent value="context" className="floating-tab-content">
<div className="floating-page-meta"><strong title={tab?.title}>{tab?.title || '当前页面不可访问'}</strong><span title={tab?.url}>{tab?.url || '仅支持 HTTP(S) 页面'}</span></div>
<Button variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void run(async () => setContext(await request('context.capture', { includeDom: true, includeStorage: true, includeCookies: true, tabId: tab?.id })))}>
{busy ? <RefreshCw className="spin" size={14} /> : <Radio size={14} />}采集页面环境
</Button>
{context && <div className="floating-result"><span>{context.document?.forms.length || 0} 个表单 · {context.document?.interactive.length || 0} 个交互元素</span><Button size="icon" variant="ghost" title="复制上下文 JSON" onClick={() => void navigator.clipboard.writeText(JSON.stringify(context, null, 2))}><Copy size={14} /></Button></div>}
<Button variant="ghost" onClick={() => openWorkspace('context')}>打开上下文工作台<ExternalLink size={14} /></Button>
</TabsContent>
<TabsContent value="agent" className="floating-tab-content">
<div className="floating-status-row"><span className={`floating-dot ${bridge.state}`} /><span><strong>{bridge.state === 'connected' ? 'Yak 引擎在线' : 'Yak 引擎离线'}</strong><small>{bridge.message}</small></span><Button size="sm" variant="ghost" disabled={busy} onClick={() => void run(async () => { if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect'); setBridge(await request('bridge.status')); })}>{bridge.state === 'connected' ? '断开' : '连接'}</Button></div>
{handoff ? <div className="floating-handoff" aria-live="assertive">
<div className="floating-handoff__copy"><AlertTriangle size={16} /><span><strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong><small>{handoff.message}</small></span></div>
<div className="floating-handoff__actions">
<Button variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} />已完成</Button>
<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>
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} />在此站点隐藏</Button>
</>}
</TabsContent>
</Tabs>
{notice && <div className="floating-notice">{notice}</div>}
</div>
)}
</div>
);
}
+314
View File
@@ -0,0 +1,314 @@
import { browser } from 'wxt/browser';
import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
redactNetworkRequests, startNetworkCapture, stopNetworkCapture,
stopNetworkCapturesForGrant,
} from '@/features/network-capture/service';
import {
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
stopPageObservation, stopPageObservationsForGrant,
} from '@/features/page-observation/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import type {
BridgeGrant, BrowserRequestAnalysisBundle, BrowserTarget, CapabilityScope, HandoffReason,
PageContextOptions, YakPocGenerateResult,
} from '@/types/models';
import { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import { parseCapabilityParams } from '@/protocol/bridge';
import { getFrameInventory } from '@/features/page-context/frames';
import { activateTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import {
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
} from '@/features/page-context/service';
import { listCookies } from '@/features/cookies/service';
import { getState, updateState } from '@/platform/storage/state';
import { switchProxy } from '@/features/proxy/service';
import { ExtensionError } from '@/shared/errors';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
const CAPABILITY_SCOPES: Record<string, CapabilityScope> = {
'browser.tabs': 'browser.tabs.read',
'browser.frames': 'browser.tabs.read',
'browser.context': 'browser.dom.read',
'browser.node.inspect': 'browser.dom.read',
'browser.node.action': 'browser.dom.write',
'browser.cookies': 'browser.cookies.read',
'browser.takeover': 'browser.tab.activate',
'browser.handoff.request': 'browser.human.takeover',
'browser.handoff.status': 'browser.human.takeover',
'browser.network.start': 'browser.network.capture',
'browser.network.status': 'browser.network.read',
'browser.network.list': 'browser.network.read',
'browser.network.clear': 'browser.network.capture',
'browser.network.stop': 'browser.network.capture',
'browser.network.export': 'browser.network.sensitive.read',
'browser.network.poc': 'browser.network.sensitive.read',
'browser.network.analysis': 'browser.network.sensitive.read',
'browser.observe.start': 'browser.observation.control',
'browser.observe.status': 'browser.observation.read',
'browser.observe.list': 'browser.observation.read',
'browser.observe.clear': 'browser.observation.control',
'browser.observe.stop': 'browser.observation.control',
'browser.invoke': 'browser.page.invoke',
'browser.eval': 'browser.page.eval.expression',
'proxy.list': 'browser.proxy.read',
'proxy.switch': 'browser.proxy.write',
};
async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
const state = await getState();
const grant = state.activeGrant;
if (!grant || grant.expiresAt <= Date.now()) {
if (grant) {
const state = await updateState((current) => ({
...current,
activeGrant: undefined,
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
: current.handoff,
}));
await Promise.all([
stopNetworkCapturesForGrant(grant.id),
stopPageObservationsForGrant(grant.id),
]);
await setAgentRuntimeState('expired', grant);
if (state.handoff) await browser.action.setBadgeText({ text: '', tabId: state.handoff.target.tabId });
}
throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
}
if (!grant.scopes.includes(required)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${required}`);
return grant;
}
function originOf(url: string): string {
try {
const origin = new URL(url).origin;
return origin === 'null' ? '' : origin;
} catch {
return '';
}
}
async function allowedTarget(grant: BridgeGrant, input: Record<string, unknown>): Promise<BrowserTarget> {
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
const target = grant.targets.find((item) => item.tabId === requested && item.frameId === requestedFrameId);
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
const currentFrame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (!currentFrame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在');
let currentOrigin = originOf(currentFrame.url);
if (!currentOrigin) {
currentOrigin = (await getFrameInventory(target.tabId)).find((frame) => frame.frameId === target.frameId)?.origin || '';
}
if (currentOrigin !== target.origin) throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
if (target.documentId && currentFrame.documentId && target.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
}
if (typeof input.documentId === 'string' && target.documentId && input.documentId !== target.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
}
const resolved = await resolveDocumentTarget(target);
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return resolved;
}
function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
}
export async function routeCapability(
method: string,
params: unknown,
requestEngine?: <T>(method: string, params: unknown) => Promise<T>,
): Promise<unknown> {
if (method === 'system.ping') return { now: Date.now(), extensionVersion: browser.runtime.getManifest().version };
if (import.meta.env.FIREFOX && import.meta.env.MODE === 'store' && ['browser.invoke', 'browser.eval'].includes(method)) {
throw new ExtensionError('channel_unavailable', 'Firefox AMO 渠道不提供页面函数调用或通用 Eval');
}
const input = parseCapabilityParams(method, params);
const required = method === 'browser.eval' && input.mode === 'program'
? 'browser.page.eval.program'
: CAPABILITY_SCOPES[method];
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
const grant = await activeGrant(required);
if (method === 'browser.tabs') {
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const tabs = await Promise.all(tabIds.map(async (tabId) => {
const targets = grant.targets.filter((target) => target.tabId === tabId);
for (const target of targets) {
try {
await allowedTarget(grant, { tabId, frameId: target.frameId, documentId: target.documentId });
return getTab(tabId);
} catch {
// A tab remains visible while at least one explicitly granted frame is current.
}
}
return undefined;
}));
return tabs.filter(Boolean);
}
if (method === 'browser.frames') {
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
}
return getFrameInventory(tabId);
}
if (method === 'browser.handoff.status') {
const handoff = (await getState()).handoff;
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
}
if (method === 'browser.handoff.request') {
const resolvedTarget = await allowedTarget(grant, input);
const grantTarget = grant.targets.find((target) => target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId);
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
const now = Date.now();
const state = await updateState((current) => {
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
}
if (current.handoff?.state === 'waiting_for_user') {
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
}
return {
...current,
handoff: {
id: crypto.randomUUID(),
taskId: grant.taskId,
target: grantTarget,
reason: input.reason as HandoffReason,
message: typeof input.message === 'string' ? input.message : '',
state: 'waiting_for_user',
requestedAt: now,
},
};
});
await activateTab(resolvedTarget.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
await setAgentRuntimeState('waiting_for_human', grant);
return state.handoff;
}
if (method.startsWith('browser.network.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.network.start') {
if (input.captureHeaders === true || input.captureBody === true) requireScope(grant, 'browser.network.sensitive.read');
return startNetworkCapture(target, {
captureHeaders: input.captureHeaders === true,
captureBody: input.captureBody === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
if (method === 'browser.network.status') return networkCaptureStatus(target);
if (method === 'browser.network.list') {
const records = await listNetworkRequests(target, typeof input.limit === 'number' ? input.limit : 100);
return grant.scopes.includes('browser.network.sensitive.read') ? records : redactNetworkRequests(records);
}
if (method === 'browser.network.clear') return clearNetworkRequests(target);
if (method === 'browser.network.stop') return stopNetworkCapture(target);
if (method === 'browser.network.export') return exportNetworkRequest(target, String(input.id));
if (method === 'browser.network.poc') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<YakPocGenerateResult>('yakit.poc.generate', await capturedRequestEnginePayload(target, String(input.id), false));
}
if (method === 'browser.network.analysis') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(target, String(input.id), grant.scopes.includes('browser.observation.read')),
);
}
}
if (method.startsWith('browser.observe.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.observe.start') {
if (input.captureValues === true) requireScope(grant, 'browser.observation.sensitive.read');
return startPageObservation(target, {
captureValues: input.captureValues === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxValueBytes: typeof input.maxValueBytes === 'number' ? input.maxValueBytes : undefined,
expiresAt: grant.expiresAt,
}, { kind: 'grant', grantId: grant.id });
}
if (method === 'browser.observe.status') return pageObservationStatus(target);
if (method === 'browser.observe.list') {
return listPageObservations(
target,
typeof input.limit === 'number' ? input.limit : 100,
grant.scopes.includes('browser.observation.sensitive.read'),
);
}
if (method === 'browser.observe.clear') return clearPageObservations(target);
if (method === 'browser.observe.stop') return stopPageObservation(target);
}
if (method === 'browser.context') {
const options: PageContextOptions = {
includeDom: input.includeDom !== false,
includeStorage: input.includeStorage === true,
includeCookies: input.includeCookies === true,
};
if (options.includeStorage) requireScope(grant, 'browser.storage.read');
if (options.includeCookies) requireScope(grant, 'browser.cookies.read');
return capturePageContext(options, await allowedTarget(grant, input));
}
if (method === 'browser.node.inspect') {
const target = await allowedTarget(grant, input);
return inspectPageNode(String(input.captureId), String(input.nodeId), target);
}
if (method === 'browser.node.action') {
const target = await allowedTarget(grant, input);
return actOnPageNode(
String(input.captureId),
String(input.nodeId),
input.action as 'click' | 'focus' | 'scroll' | 'setValue',
target,
typeof input.value === 'string' ? input.value : undefined,
);
}
if (method === 'browser.cookies') {
const target = await allowedTarget(grant, input);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
const grantTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
const url = frame?.url && /^https?:/i.test(frame.url) ? frame.url : `${grantTarget?.origin || ''}/`;
if (!/^https?:/i.test(url)) throw new ExtensionError('target_unavailable', '目标 frame 没有可读取 Cookie 的 HTTP 来源');
return listCookies(url);
}
if (method === 'browser.takeover') {
const target = await allowedTarget(grant, input);
await activateTab(target.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
globalThis.setTimeout(() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }), 10_000);
return { activated: true, target };
}
if (method === 'browser.invoke') {
if (typeof input.path !== 'string') throw new Error('缺少页面函数路径');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return invokePageFunction(input.path, Array.isArray(input.args) ? input.args : [], await allowedTarget(grant, input), timeoutMs);
}
if (method === 'browser.eval') {
if (typeof input.code !== 'string' || !input.code.trim()) throw new Error('缺少页面执行代码');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return evalInPage(input.code, input.mode as 'expression' | 'program', await allowedTarget(grant, input), timeoutMs);
}
const state = await getState();
if (method === 'proxy.list') return state.proxyProfiles;
if (method === 'proxy.switch') {
if (typeof input.id !== 'string') throw new Error('缺少代理配置 ID');
await switchProxy(input.id);
return { activeProxyId: input.id };
}
throw new Error(`不支持的 Bridge 方法: ${method}`);
}
+28
View File
@@ -0,0 +1,28 @@
import type { AuditEvent, HandoffReason, HumanHandoff } from '@/types/models';
export const HANDOFF_REASON_LABELS: Record<HandoffReason, string> = {
qr_code: '需要扫码',
mfa: '需要二次验证',
captcha: '需要完成验证码',
device_confirmation: '需要设备确认',
other: '需要人工操作',
};
export const AUDIT_CATEGORY_LABELS: Record<AuditEvent['category'], string> = {
grant: '授权',
bridge: 'Bridge',
capability: '能力调用',
handoff: '人工接管',
settings: '设置',
};
export const AUDIT_OUTCOME_LABELS: Record<AuditEvent['outcome'], string> = {
success: '成功',
denied: '已拒绝',
error: '错误',
cancelled: '已取消',
};
export function waitingHandoff(handoff?: HumanHandoff): HumanHandoff | undefined {
return handoff?.state === 'waiting_for_user' ? handoff : undefined;
}
+20
View File
@@ -0,0 +1,20 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { declarativeNetRequest: {} } }));
import { buildUserAgentDnrRules } from './user-agent';
describe('User-Agent DNR rules', () => {
it('normalizes domains and covers browser request resource types', () => {
const [rule] = buildUserAgentDnrRules([{
id: 'ua-1', name: 'Test', enabled: true, userAgent: 'Yakit-E2E/1.0', domains: ['https://*.example.test/path'],
}]);
expect(rule.condition.urlFilter).toBe('||example.test^');
expect(rule.condition.resourceTypes).toContain('websocket');
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set', value: 'Yakit-E2E/1.0' }] });
});
it('ignores disabled rules', () => {
expect(buildUserAgentDnrRules([{ id: 'x', name: 'X', enabled: false, userAgent: 'x', domains: [] }])).toHaveLength(0);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { browser } from 'wxt/browser';
import type { UserAgentRule } from '@/types/models';
const RULE_ID_BASE = 20_000;
const MAX_UA_RULES = 5_000;
function domainFilter(domain: string): string {
const normalized = domain.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
return normalized ? `||${normalized}^` : '*';
}
export function buildUserAgentDnrRules(rules: UserAgentRule[]): Browser.declarativeNetRequest.Rule[] {
const addRules: Browser.declarativeNetRequest.Rule[] = [];
let nextRuleId = RULE_ID_BASE;
for (const rule of rules.filter((item) => item.enabled)) {
const domains = rule.domains.length > 0 ? [...new Set(rule.domains)] : [''];
for (const domain of domains) {
if (nextRuleId >= RULE_ID_BASE + MAX_UA_RULES) {
throw new Error(`User-Agent 动态规则超过 ${MAX_UA_RULES} 条限制`);
}
addRules.push({
id: nextRuleId,
priority: nextRuleId - RULE_ID_BASE + 1,
action: {
type: 'modifyHeaders',
requestHeaders: [{ header: 'user-agent', operation: 'set', value: rule.userAgent }],
},
condition: {
urlFilter: domainFilter(domain),
resourceTypes: [
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
'font', 'media', 'websocket', 'other',
],
},
});
nextRuleId += 1;
}
}
return addRules;
}
export async function applyUserAgentRules(rules: UserAgentRule[]): Promise<void> {
const oldRuleIds = (await browser.declarativeNetRequest.getDynamicRules())
.map((rule) => rule.id)
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_BASE + 10_000);
await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, addRules: buildUserAgentDnrRules(rules) });
}
+409
View File
@@ -0,0 +1,409 @@
import { browser, type Browser } from 'wxt/browser';
import { NETWORK_CAPTURE_STORAGE_KEY } from '@/protocol/storage';
import type {
BrowserTarget, NetworkBody, NetworkCaptureOptions, NetworkCaptureStatus, NetworkHeader,
NetworkRequestExport, NetworkRequestRecord,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
const DEFAULT_OPTIONS: NetworkCaptureOptions = {
captureHeaders: false,
captureBody: false,
maxEntries: 100,
maxBodyBytes: 32 * 1024,
};
const MAX_ENTRIES = 200;
const MAX_BODY_BYTES = 64 * 1024;
const MAX_HEADER_COUNT = 256;
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_SESSION_BYTES = 5 * 1024 * 1024;
const CAPTURED_RESOURCE_TYPES = ['xmlhttprequest', 'ping', 'other', 'main_frame', 'sub_frame'] as const;
interface CaptureSession {
target: BrowserTarget;
startedAt: number;
droppedCount: number;
options: NetworkCaptureOptions;
records: NetworkRequestRecord[];
owner: { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number };
}
interface SessionStorageArea {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const captureSessions = new Map<number, CaptureSession>();
const sessionStorage = (browser.storage as unknown as { session?: SessionStorageArea }).session;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let notifyTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const pendingNotificationTabs = new Set<number>();
function normalizedOptions(input?: Partial<NetworkCaptureOptions>): NetworkCaptureOptions {
return {
captureHeaders: input?.captureHeaders === true,
captureBody: input?.captureBody === true,
maxEntries: Math.min(Math.max(input?.maxEntries || DEFAULT_OPTIONS.maxEntries, 10), MAX_ENTRIES),
maxBodyBytes: Math.min(Math.max(input?.maxBodyBytes || DEFAULT_OPTIONS.maxBodyBytes, 1024), MAX_BODY_BYTES),
};
}
function isCaptureSession(value: unknown): value is CaptureSession {
if (!value || typeof value !== 'object') return false;
const session = value as Partial<CaptureSession>;
return Boolean(
session.target && Number.isSafeInteger(session.target.tabId) && Number.isSafeInteger(session.target.frameId)
&& typeof session.startedAt === 'number' && Array.isArray(session.records),
);
}
async function restoreSessions(): Promise<void> {
if (!sessionStorage) return;
try {
const stored = await sessionStorage.get(NETWORK_CAPTURE_STORAGE_KEY);
const sessions = stored[NETWORK_CAPTURE_STORAGE_KEY];
if (!Array.isArray(sessions)) return;
for (const value of sessions) {
if (!isCaptureSession(value)) continue;
const session: CaptureSession = {
...value,
droppedCount: Number.isSafeInteger(value.droppedCount) ? value.droppedCount : 0,
options: normalizedOptions(value.options),
records: value.records.slice(-MAX_ENTRIES),
owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'
? value.owner
: { kind: 'local' },
};
captureSessions.set(session.target.tabId, session);
}
} catch {
// Session persistence is an optimization; capture still works in memory.
}
}
const restorePromise = restoreSessions();
function schedulePersist(): void {
if (!sessionStorage || persistTimer) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
void sessionStorage.set({ [NETWORK_CAPTURE_STORAGE_KEY]: [...captureSessions.values()] }).catch(() => undefined);
}, 250);
}
function notifyChanged(tabId: number): void {
pendingNotificationTabs.add(tabId);
if (notifyTimer) return;
notifyTimer = globalThis.setTimeout(() => {
notifyTimer = undefined;
const tabIds = [...pendingNotificationTabs];
pendingNotificationTabs.clear();
for (const changedTabId of tabIds) {
void browser.runtime.sendMessage({ action: 'network.capture.changed', payload: { tabId: changedTabId } }).catch(() => undefined);
}
}, 100);
}
function matchingSession(details: Pick<Browser.webRequest.WebRequestDetails, 'tabId' | 'frameId' | 'type'> & { documentId?: string }): CaptureSession | undefined {
const session = captureSessions.get(details.tabId);
if (!session || details.frameId !== session.target.frameId) return undefined;
if (session.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(details.tabId);
schedulePersist();
notifyChanged(details.tabId);
return undefined;
}
const isFrameNavigation = details.type === 'main_frame' || details.type === 'sub_frame';
if (!isFrameNavigation && session.target.documentId && details.documentId && session.target.documentId !== details.documentId) return undefined;
return session;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function encodeBody(bytes: Uint8Array, byteLength: number, truncated: boolean): NetworkBody {
try {
return { encoding: 'utf8', data: new TextDecoder('utf-8', { fatal: true }).decode(bytes), byteLength, truncated };
} catch {
return { encoding: 'base64', data: bytesToBase64(bytes), byteLength, truncated };
}
}
function requestBody(details: Browser.webRequest.OnBeforeRequestDetails, maxBytes: number): NetworkBody | undefined {
const raw = details.requestBody?.raw || [];
if (raw.length > 0) {
const parts = raw.flatMap((part) => part.bytes ? [new Uint8Array(part.bytes)] : []);
const byteLength = parts.reduce((total, part) => total + part.byteLength, 0);
const output = new Uint8Array(Math.min(byteLength, maxBytes));
let offset = 0;
for (const part of parts) {
if (offset >= output.length) break;
const slice = part.subarray(0, output.length - offset);
output.set(slice, offset);
offset += slice.length;
}
const body = encodeBody(output, byteLength, byteLength > output.length);
if (parts.length !== raw.length) body.reconstructed = true;
return body;
}
const formData = details.requestBody?.formData;
if (!formData) return undefined;
const params = new URLSearchParams();
for (const [key, values] of Object.entries(formData)) {
for (const value of values) params.append(key, typeof value === 'string' ? value : '[binary]');
}
const bytes = new TextEncoder().encode(params.toString());
return { ...encodeBody(bytes.subarray(0, maxBytes), bytes.byteLength, bytes.byteLength > maxBytes), reconstructed: true };
}
function normalizeHeaders(headers?: Browser.webRequest.HttpHeader[]): NetworkHeader[] | undefined {
if (!headers) return undefined;
const output: NetworkHeader[] = [];
let remaining = MAX_HEADER_BYTES;
for (const header of headers.slice(0, MAX_HEADER_COUNT)) {
const name = header.name.slice(0, 256);
const sourceValue = header.value || (header.binaryValue ? `[binary:${header.binaryValue.byteLength}]` : '');
const value = sourceValue.slice(0, Math.min(MAX_HEADER_VALUE_LENGTH, Math.max(remaining - name.length, 0)));
if (remaining <= name.length) break;
output.push({ name, value });
remaining -= name.length + value.length;
}
return output;
}
function findRecord(session: CaptureSession, requestId: string): NetworkRequestRecord | undefined {
for (let index = session.records.length - 1; index >= 0; index -= 1) {
if (session.records[index].requestId === requestId) return session.records[index];
}
return undefined;
}
function commit(session: CaptureSession, tabId: number): void {
while (session.records.length > session.options.maxEntries) {
session.records.shift();
session.droppedCount += 1;
}
let estimatedBytes = session.records.reduce((total, record) => total + JSON.stringify(record).length, 0);
while (estimatedBytes > MAX_SESSION_BYTES && session.records.length > 1) {
const removed = session.records.shift();
estimatedBytes -= removed ? JSON.stringify(removed).length : 0;
session.droppedCount += 1;
}
schedulePersist();
notifyChanged(tabId);
}
function onBeforeRequest(details: Browser.webRequest.OnBeforeRequestDetails): Browser.webRequest.BlockingResponse | undefined {
const session = matchingSession(details);
if (!session) return undefined;
let record = findRecord(session, details.requestId);
if (!record) {
record = {
id: crypto.randomUUID(), requestId: details.requestId, tabId: details.tabId, frameId: details.frameId,
documentId: details.documentId, url: details.url, method: details.method, resourceType: details.type,
initiator: details.initiator, startedAt: details.timeStamp, requestHeadersCaptured: session.options.captureHeaders,
requestBodyCaptured: session.options.captureBody, redirects: [],
};
session.records.push(record);
} else {
record.url = details.url;
record.method = details.method;
record.startedAt = details.timeStamp;
}
if (session.options.captureBody) record.requestBody = requestBody(details, session.options.maxBodyBytes);
commit(session, details.tabId);
return undefined;
}
function onBeforeSendHeaders(details: Browser.webRequest.OnBeforeSendHeadersDetails): Browser.webRequest.BlockingResponse | undefined {
const session = matchingSession(details);
if (!session?.options.captureHeaders) return undefined;
const record = findRecord(session, details.requestId);
if (!record) return undefined;
record.requestHeaders = normalizeHeaders(details.requestHeaders);
commit(session, details.tabId);
return undefined;
}
function onBeforeRedirect(details: Browser.webRequest.OnBeforeRedirectDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.redirects.push({ url: details.url, statusCode: details.statusCode, redirectUrl: details.redirectUrl, timestamp: details.timeStamp });
record.statusCode = details.statusCode;
record.statusLine = details.statusLine;
commit(session, details.tabId);
}
function completeRecord(details: Browser.webRequest.OnCompletedDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.completedAt = details.timeStamp;
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
record.statusCode = details.statusCode;
record.statusLine = details.statusLine;
record.fromCache = details.fromCache;
record.ip = details.ip;
if (session.options.captureHeaders) record.responseHeaders = normalizeHeaders(details.responseHeaders);
const contentType = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-type')?.value;
const contentLength = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-length')?.value;
record.responseContentType = contentType?.slice(0, 512);
if (contentLength && Number.isSafeInteger(Number(contentLength))) record.responseSize = Number(contentLength);
commit(session, details.tabId);
}
function errorRecord(details: Browser.webRequest.OnErrorOccurredDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.completedAt = details.timeStamp;
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
record.error = details.error.slice(0, 512);
commit(session, details.tabId);
}
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestBody']);
browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestHeaders', 'extraHeaders']);
browser.webRequest.onBeforeRedirect.addListener(onBeforeRedirect, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onCompleted.addListener(completeRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onErrorOccurred.addListener(errorRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] });
browser.tabs.onRemoved.addListener((tabId) => {
if (captureSessions.delete(tabId)) schedulePersist();
});
function sameTarget(left: BrowserTarget, right: BrowserTarget): boolean {
return left.tabId === right.tabId && left.frameId === right.frameId
&& (!left.documentId || !right.documentId || left.documentId === right.documentId);
}
function sessionFor(target: BrowserTarget): CaptureSession | undefined {
const session = captureSessions.get(target.tabId);
if (session?.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(target.tabId);
schedulePersist();
return undefined;
}
return session && sameTarget(session.target, target) ? session : undefined;
}
export async function startNetworkCapture(
target: BrowserTarget,
options?: Partial<NetworkCaptureOptions>,
owner: CaptureSession['owner'] = { kind: 'local' },
): Promise<NetworkCaptureStatus> {
await restorePromise;
const session: CaptureSession = { target, startedAt: Date.now(), droppedCount: 0, options: normalizedOptions(options), records: [], owner };
captureSessions.set(target.tabId, session);
schedulePersist();
notifyChanged(target.tabId);
return networkCaptureStatus(target);
}
export async function networkCaptureStatus(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
return session
? { active: true, target: session.target, startedAt: session.startedAt, count: session.records.length, droppedCount: session.droppedCount, options: session.options }
: { active: false, target, count: 0, droppedCount: 0 };
}
export async function listNetworkRequests(target: BrowserTarget, limit = 100): Promise<NetworkRequestRecord[]> {
await restorePromise;
const session = sessionFor(target);
if (!session) return [];
return structuredClone(session.records.slice(-Math.min(Math.max(limit, 1), MAX_ENTRIES)).reverse());
}
export async function clearNetworkRequests(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
if (session) {
session.records = [];
session.droppedCount = 0;
schedulePersist();
notifyChanged(target.tabId);
}
return networkCaptureStatus(target);
}
export async function stopNetworkCapture(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
captureSessions.delete(target.tabId);
schedulePersist();
notifyChanged(target.tabId);
return { active: false, target, count: 0, droppedCount: 0 };
}
export async function stopNetworkCapturesForGrant(grantId: string): Promise<void> {
await restorePromise;
let changed = false;
for (const [tabId, session] of captureSessions) {
if (session.owner.kind !== 'grant' || session.owner.grantId !== grantId) continue;
captureSessions.delete(tabId);
notifyChanged(tabId);
changed = true;
}
if (changed) schedulePersist();
}
function bodyBytes(body?: NetworkBody): Uint8Array {
if (!body) return new Uint8Array();
return body.encoding === 'base64' ? base64ToBytes(body.data) : new TextEncoder().encode(body.data);
}
export async function exportNetworkRequest(target: BrowserTarget, id: string): Promise<NetworkRequestExport> {
await restorePromise;
const session = sessionFor(target);
const record = session?.records.find((item) => item.id === id);
if (!record) throw new ExtensionError('network_request_not_found', '网络请求不存在或已经被有界缓冲区淘汰');
if (!record.requestHeadersCaptured || !record.requestHeaders) {
throw new ExtensionError('network_headers_not_captured', '该请求未捕获实际请求头,无法生成可重放数据包');
}
const url = new URL(record.url);
const headers = record.requestHeaders.filter((header) => !header.name.startsWith(':'));
if (!headers.some((header) => header.name.toLowerCase() === 'host')) {
headers.unshift({ name: 'Host', value: url.host });
}
const path = `${url.pathname || '/'}${url.search}`;
const head = `${record.method} ${path} HTTP/1.1\r\n${headers.map((header) => `${header.name}: ${header.value}`).join('\r\n')}\r\n\r\n`;
const headBytes = new TextEncoder().encode(head);
const body = bodyBytes(record.requestBody);
const packet = new Uint8Array(headBytes.length + body.length);
packet.set(headBytes);
packet.set(body, headBytes.length);
const limitations: string[] = [];
if (record.requestBody?.truncated) limitations.push(`请求体只保留前 ${body.length} 字节`);
if (record.requestBody?.reconstructed) limitations.push('浏览器未提供完整原始请求体,当前内容由可用字段重建');
if (!record.requestBody && !['GET', 'HEAD', 'OPTIONS'].includes(record.method.toUpperCase())) {
limitations.push(record.requestBodyCaptured ? '浏览器未提供该请求体,重放数据包可能不完整' : '捕获时未启用请求体,重放数据包可能不完整');
}
const rawRequest = record.requestBody?.encoding === 'base64'
? `${head}[binary body: ${record.requestBody.byteLength} bytes]`
: `${head}${record.requestBody?.data || ''}`;
return { id: record.id, url: record.url, isHttps: url.protocol === 'https:', rawRequest, rawRequestBase64: bytesToBase64(packet), limitations };
}
export function redactNetworkRequests(records: NetworkRequestRecord[]): NetworkRequestRecord[] {
return records.map(({ requestHeaders: _requestHeaders, responseHeaders: _responseHeaders, requestBody: _requestBody, ...record }) => ({
...record,
requestHeadersCaptured: false,
requestBodyCaptured: false,
}));
}
+18
View File
@@ -0,0 +1,18 @@
import { observationAnalysisWindow } from '@/features/page-observation/service';
import type { BrowserTarget } from '@/types/models';
import { exportNetworkRequest, listNetworkRequests } from './service';
export async function capturedRequestEnginePayload(target: BrowserTarget, id: string, includeObservations: boolean) {
const [exported, records] = await Promise.all([
exportNetworkRequest(target, id),
listNetworkRequests(target, 200),
]);
const record = records.find((item) => item.id === id);
return {
rawRequestBase64: exported.rawRequestBase64,
isHttps: exported.isHttps,
observations: includeObservations && record
? await observationAnalysisWindow(target, record.startedAt)
: [],
};
}
@@ -0,0 +1,74 @@
import { browser, type Browser } from 'wxt/browser';
import type { ContentScriptContext } from 'wxt/utils/content-script-context';
import { createOpaqueId } from '@/shared/id';
import {
PAGE_BRIDGE_CHANNEL,
PAGE_REQUEST_EVENT,
PAGE_RESPONSE_EVENT,
type PageBridgeRequest,
type PageBridgeResponse,
type PageOperation,
} from './protocol';
type InternalMessage = PageOperation & { channel: typeof PAGE_BRIDGE_CHANNEL; timeoutMs?: number };
export async function installPageWorldBridge(ctx: ContentScriptContext): Promise<void> {
const pending = new Map<string, {
resolve: (response: PageBridgeResponse) => void;
timer: ReturnType<typeof globalThis.setTimeout>;
}>();
const { script } = await injectScript('/page-main-world.js', {
keepInDom: true,
modifyScript(element) {
element.id = createOpaqueId('yakit-page-bridge');
},
});
const onResponse = (event: Event) => {
if (!(event instanceof CustomEvent) || typeof event.detail !== 'string') return;
let response: PageBridgeResponse;
try {
response = JSON.parse(event.detail) as PageBridgeResponse;
} catch {
return;
}
const task = pending.get(response.id);
if (!task) return;
globalThis.clearTimeout(task.timer);
pending.delete(response.id);
task.resolve(response);
};
script.addEventListener(PAGE_RESPONSE_EVENT, onResponse);
ctx.onInvalidated(() => {
script.removeEventListener(PAGE_RESPONSE_EVENT, onResponse);
script.remove();
for (const task of pending.values()) globalThis.clearTimeout(task.timer);
pending.clear();
});
const execute = (message: InternalMessage): Promise<PageBridgeResponse> => {
const id = createOpaqueId('page-request');
const timeoutMs = Math.min(Math.max(message.timeoutMs || 10_000, 250), 60_000);
const request: PageBridgeRequest = message.operation === 'eval'
? { id, timeoutMs, operation: 'eval', mode: message.mode, code: message.code }
: { id, timeoutMs, operation: 'invoke', path: message.path, args: message.args };
return new Promise((resolve) => {
const timer = globalThis.setTimeout(() => {
pending.delete(id);
resolve({ id, ok: false, error: { name: 'TimeoutError', message: `页面执行超过 ${timeoutMs}ms` } });
}, timeoutMs);
pending.set(id, { resolve, timer });
script.dispatchEvent(new CustomEvent(PAGE_REQUEST_EVENT, { detail: JSON.stringify(request) }));
});
};
const onMessage = (message: unknown, _sender: Browser.runtime.MessageSender, sendResponse: (response: PageBridgeResponse) => void) => {
const input = message as InternalMessage;
if (input?.channel !== PAGE_BRIDGE_CHANNEL || !['eval', 'invoke'].includes(input.operation)) return undefined;
void execute(input).then(sendResponse);
return true;
};
browser.runtime.onMessage.addListener(onMessage);
ctx.onInvalidated(() => browser.runtime.onMessage.removeListener(onMessage));
}
@@ -0,0 +1,27 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: {} }));
Object.assign(globalThis, { Node: class Node {}, Element: class Element {} });
import { executeInUserScriptWorld } from './execution-adapter';
describe('page execution serializer', () => {
it('serializes BigInt and circular values without throwing', async () => {
const response = await executeInUserScriptWorld({
operation: 'eval', mode: 'expression',
code: '(() => { const value = { big: 42n }; value.self = value; return value; })()', timeoutMs: 500,
}, () => { const value: Record<string, unknown> = { big: 42n }; value.self = value; return value; });
expect(response.ok).toBe(true);
if (response.ok) {
expect(response.result.value).toMatchObject({ big: { $type: 'bigint', value: '42' }, self: { $type: 'circular' } });
expect(response.result.truncated).toBe(false);
}
});
it('distinguishes expression and program syntax', async () => {
const expression = await executeInUserScriptWorld({ operation: 'eval', mode: 'expression', code: '1 + 1', timeoutMs: 500 }, () => 1 + 1);
const program = await executeInUserScriptWorld({ operation: 'eval', mode: 'program', code: 'const answer = 40; answer + 2', timeoutMs: 500 }, () => { const answer = 40; return answer + 2; });
expect(expression.ok && expression.result.value).toBe(2);
expect(program.ok && program.result.value).toBe(42);
});
});
@@ -0,0 +1,230 @@
import { browser } from 'wxt/browser';
import type { BrowserTarget, PageEvalResult } from '@/types/models';
import { PAGE_BRIDGE_CHANNEL, type PageBridgeResponse, type PageOperation } from './protocol';
export type PageExecutionMode = 'user-scripts' | 'injected-bridge' | 'invoke-only';
interface PageExecutionAdapter {
readonly mode: PageExecutionMode;
execute(target: BrowserTarget, operation: PageOperation, timeoutMs: number): Promise<PageEvalResult>;
}
interface UserScriptInjectionResult {
frameId: number;
documentId?: string;
result?: unknown;
error?: string;
}
interface UserScriptsApi {
getScripts(): Promise<unknown[]>;
execute(injection: {
target: { tabId: number; frameIds?: number[]; documentIds?: string[] };
js: Array<{ code: string }>;
world: 'MAIN' | 'USER_SCRIPT';
}): Promise<UserScriptInjectionResult[]>;
}
type UserScriptExecutionResponse = {
ok: true;
result: PageEvalResult;
} | {
ok: false;
error: { name: string; message: string; stack?: string };
};
type PageEvaluation = () => unknown | Promise<unknown>;
function evaluationSource(operation: PageOperation): string {
if (operation.operation !== 'eval') return 'undefined';
if (operation.mode === 'expression') return `async () => (\n${operation.code}\n)`;
return `async () => {\n${operation.code}\n}`;
}
export async function executeInUserScriptWorld(
input: PageOperation & { timeoutMs: number },
evaluate?: PageEvaluation,
): Promise<UserScriptExecutionResponse> {
const MAX_DEPTH = 6;
const MAX_ITEMS = 100;
const MAX_STRING = 100_000;
const startedAt = performance.now();
const serialize = (value: unknown): Omit<PageEvalResult, 'durationMs'> => {
const seen = new WeakSet<object>();
let truncated = false;
const visit = (current: unknown, depth: number): unknown => {
if (current === null) return null;
if (typeof current === 'string') {
if (current.length > MAX_STRING) truncated = true;
return current.slice(0, MAX_STRING);
}
if (typeof current === 'number' || typeof current === 'boolean') return current;
if (typeof current === 'undefined') return { $type: 'undefined' };
if (typeof current === 'bigint') return { $type: 'bigint', value: current.toString() };
if (typeof current === 'symbol') return { $type: 'symbol', value: String(current) };
if (typeof current === 'function') {
const source = Function.prototype.toString.call(current);
if (source.length > 2_000) truncated = true;
return { $type: 'function', name: current.name || '', source: source.slice(0, 2_000) };
}
if (depth >= MAX_DEPTH) {
truncated = true;
return { $type: 'max-depth', constructor: (current as object).constructor?.name || 'Object' };
}
if (seen.has(current as object)) return { $type: 'circular' };
seen.add(current as object);
if (current instanceof Error) return { $type: 'error', name: current.name, message: current.message, stack: current.stack?.slice(0, 10_000) };
if (current instanceof Date) return { $type: 'date', value: current.toISOString() };
if (current instanceof RegExp) return { $type: 'regexp', value: String(current) };
if (current instanceof Node) {
const element = current instanceof Element ? current : current.parentElement;
const html = element?.outerHTML || current.textContent || '';
if (html.length > 10_000) truncated = true;
return { $type: 'node', name: current.nodeName, html: html.slice(0, 10_000) };
}
if (Array.isArray(current)) {
if (current.length > MAX_ITEMS) truncated = true;
return current.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1));
}
const output: Record<string, unknown> = {};
const allKeys = Reflect.ownKeys(current as object);
if (allKeys.length > MAX_ITEMS) truncated = true;
for (const key of allKeys.slice(0, MAX_ITEMS)) {
const name = typeof key === 'symbol' ? `[${String(key)}]` : key;
try {
output[name] = visit(Reflect.get(current as object, key), depth + 1);
} catch (error) {
output[name] = { $type: 'unreadable', message: error instanceof Error ? error.message : String(error) };
}
}
return output;
};
const normalized = visit(value, 0);
let preview: string;
try {
preview = typeof value === 'string' ? value : JSON.stringify(normalized);
} catch {
preview = String(value);
}
return {
value: normalized,
type: value === null ? 'null' : typeof value,
preview: preview.slice(0, 2_000),
truncated: truncated || preview.length > 2_000,
};
};
try {
const operation = (async () => {
if (input.operation === 'eval') {
if (!evaluate) throw new Error('页面 Eval 缺少直接 User Script 执行体');
return await evaluate();
}
const segments = input.path.split('.').filter(Boolean);
let owner: unknown = window;
let target: unknown = window;
for (const segment of segments) {
owner = target;
target = Reflect.get(target as object, segment);
}
if (typeof target !== 'function') throw new TypeError(`${input.path} is not a function`);
return await Reflect.apply(target, owner, input.args);
})();
let timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = globalThis.setTimeout(() => reject(new Error(`页面执行超过 ${input.timeoutMs}ms`)), input.timeoutMs);
});
const serialized = serialize(await Promise.race([operation, timeout]).finally(() => {
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
}));
return { ok: true, result: { ...serialized, durationMs: Math.round((performance.now() - startedAt) * 100) / 100 } };
} catch (error) {
return {
ok: false,
error: {
name: error instanceof Error ? error.name : 'Error',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack?.slice(0, 10_000) : undefined,
},
};
}
}
const injectedBridgeAdapter: PageExecutionAdapter = {
mode: 'injected-bridge',
async execute(target, operation, timeoutMs) {
const response = await browser.tabs.sendMessage(target.tabId, {
channel: PAGE_BRIDGE_CHANNEL,
...operation,
timeoutMs,
}, target.documentId ? { documentId: target.documentId } : { frameId: target.frameId }) as PageBridgeResponse;
if (!response?.ok) throw new Error(response?.error?.message || '页面主世界执行失败');
return response.result;
},
};
const userScriptsAdapter: PageExecutionAdapter = {
mode: 'user-scripts',
async execute(target, operation, timeoutMs) {
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
if (!userScripts?.execute) {
throw new Error('User Scripts API 不可用;Chrome 138+ 还需要在扩展详情中启用“允许用户脚本”');
}
const input = JSON.stringify({ ...operation, timeoutMs }).replaceAll('<', '\\u003c');
const code = `(${executeInUserScriptWorld.toString()})(${input},${evaluationSource(operation)})`;
const [injection] = await userScripts.execute({
target: target.documentId
? { tabId: target.tabId, documentIds: [target.documentId] }
: { tabId: target.tabId, frameIds: [target.frameId] },
world: 'MAIN',
js: [{ code }],
});
if (!injection) throw new Error('User Scripts API 没有返回主框架执行结果');
if (injection.error) throw new Error(injection.error);
const response = injection.result as UserScriptExecutionResponse | undefined;
if (!response) throw new Error('User Scripts API 返回了空执行结果');
if (!response.ok) throw new Error(response.error.message);
return response.result;
},
};
const enterpriseAdapter: PageExecutionAdapter = {
mode: 'user-scripts',
async execute(target, operation, timeoutMs) {
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
if (!userScripts?.execute || !userScripts.getScripts) {
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
}
try {
await userScripts.getScripts();
} catch {
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
}
return userScriptsAdapter.execute(target, operation, timeoutMs);
},
};
const invokeOnlyAdapter: PageExecutionAdapter = {
mode: 'invoke-only',
async execute() {
throw new Error('Firefox AMO 渠道仅提供结构化浏览器命令,不包含页面函数调用或 Eval');
},
};
const executionAdapter = import.meta.env.FIREFOX && import.meta.env.MODE === 'store'
? invokeOnlyAdapter
: !import.meta.env.FIREFOX
&& (import.meta.env.MODE === 'production' || import.meta.env.MODE === 'store')
? userScriptsAdapter
: !import.meta.env.FIREFOX && import.meta.env.MODE === 'enterprise'
? enterpriseAdapter
: injectedBridgeAdapter;
export function getPageExecutionMode(): PageExecutionMode {
return executionAdapter.mode;
}
export function executePageOperation(target: BrowserTarget, operation: PageOperation, timeoutMs = 10_000): Promise<PageEvalResult> {
return executionAdapter.execute(target, operation, Math.min(Math.max(timeoutMs, 250), 60_000));
}
+81
View File
@@ -0,0 +1,81 @@
import { browser, type Browser } from 'wxt/browser';
import type { PageFrameSummary } from '@/types/models';
interface FrameProbe {
title: string;
name: string;
origin: string;
url: string;
readyState: string;
sandbox: string[];
}
type FrameProbeResult = Browser.scripting.InjectionResult<FrameProbe> & { documentId?: string };
function probeFrame(): FrameProbe {
let sandbox: string[] = [];
try {
sandbox = Array.from(window.frameElement?.getAttribute('sandbox')?.split(/\s+/).filter(Boolean) || []).slice(0, 32);
} catch {
// Cross-origin parent access is not required for frame inventory.
}
return {
title: document.title.slice(0, 1_000),
name: window.name.slice(0, 240),
origin: location.origin,
url: location.href.slice(0, 8_192),
readyState: document.readyState,
sandbox,
};
}
function urlOrigin(url: string): string {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : '';
} catch {
return '';
}
}
export async function getFrameInventory(tabId: number): Promise<PageFrameSummary[]> {
const [navigationFrames, probeResults] = await Promise.all([
browser.webNavigation.getAllFrames({ tabId }).catch(() => null),
browser.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: probeFrame,
}).catch(() => [] as FrameProbeResult[]),
]);
const probes = new Map((probeResults as FrameProbeResult[]).map((probe) => [probe.frameId, probe]));
const navigation = navigationFrames || [];
const frameIds = new Set<number>([...navigation.map((frame) => frame.frameId), ...probes.keys()]);
const topNavigation = navigation.find((frame) => frame.frameId === 0);
const topProbe = probes.get(0)?.result;
const topOrigin = topProbe?.origin && topProbe.origin !== 'null' ? topProbe.origin : urlOrigin(topNavigation?.url || topProbe?.url || '');
return [...frameIds].sort((left, right) => left - right).slice(0, 256).map((frameId) => {
const navigationFrame = navigation.find((frame) => frame.frameId === frameId);
const injection = probes.get(frameId);
const probe = injection?.result;
const url = probe?.url || navigationFrame?.url || '';
const detectedOrigin = probe?.origin && probe.origin !== 'null' ? probe.origin : urlOrigin(url);
const origin = detectedOrigin || (navigationFrame?.parentFrameId === 0 && /^about:(blank|srcdoc)/.test(url) ? topOrigin : '');
return {
tabId,
frameId,
documentId: injection?.documentId || navigationFrame?.documentId,
parentFrameId: navigationFrame?.parentFrameId ?? (frameId === 0 ? -1 : 0),
parentDocumentId: navigationFrame?.parentDocumentId,
url,
origin,
title: probe?.title || (frameId === 0 ? 'Main frame' : `Frame ${frameId}`),
name: probe?.name || '',
frameType: String(navigationFrame?.frameType || (frameId === 0 ? 'outermost_frame' : 'sub_frame')),
documentLifecycle: String(navigationFrame?.documentLifecycle || 'active'),
isTop: frameId === 0,
sameOrigin: Boolean(origin && topOrigin && origin === topOrigin),
accessible: Boolean(injection?.result),
sandbox: probe?.sandbox || [],
};
});
}
+77
View File
@@ -0,0 +1,77 @@
import { browser } from 'wxt/browser';
import { PAGE_LIFECYCLE_STORAGE_KEY } from '@/protocol/storage';
import type { PageLifecycleEvent } from '@/types/models';
const MAX_EVENTS_PER_TAB = 100;
const MAX_PERSISTED_TABS = 16;
const eventsByTab = new Map<number, PageLifecycleEvent[]>();
const sessionStorage = (browser.storage as unknown as {
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
}).session;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
function isLifecycleEvent(value: unknown): value is PageLifecycleEvent {
if (!value || typeof value !== 'object') return false;
const event = value as Partial<PageLifecycleEvent>;
return typeof event.id === 'string' && ['document', 'history', 'fragment'].includes(String(event.kind))
&& Number.isSafeInteger(event.tabId) && Number.isSafeInteger(event.frameId)
&& typeof event.url === 'string' && typeof event.timestamp === 'number';
}
async function restore(): Promise<void> {
if (!sessionStorage) return;
try {
const stored = await sessionStorage.get(PAGE_LIFECYCLE_STORAGE_KEY);
const values = stored[PAGE_LIFECYCLE_STORAGE_KEY];
if (!Array.isArray(values)) return;
for (const entry of values.slice(-MAX_PERSISTED_TABS)) {
if (!Array.isArray(entry) || typeof entry[0] !== 'number' || !Array.isArray(entry[1])) continue;
eventsByTab.set(entry[0], entry[1].filter(isLifecycleEvent).slice(-MAX_EVENTS_PER_TAB));
}
} catch {
// Lifecycle tracking remains available in memory.
}
}
const restored = restore();
function schedulePersist(): void {
if (!sessionStorage || persistTimer) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
void sessionStorage.set({ [PAGE_LIFECYCLE_STORAGE_KEY]: [...eventsByTab].slice(-MAX_PERSISTED_TABS) }).catch(() => undefined);
}, 250);
}
async function record(
kind: PageLifecycleEvent['kind'],
details: { tabId: number; frameId: number; documentId?: string; url: string; timeStamp: number; transitionType?: string },
): Promise<void> {
if (details.tabId < 0 || !/^(https?|about):/i.test(details.url)) return;
await restored;
const event: PageLifecycleEvent = {
id: crypto.randomUUID(),
kind,
tabId: details.tabId,
frameId: details.frameId,
documentId: details.documentId,
url: details.url.slice(0, 8_192),
timestamp: details.timeStamp,
transitionType: details.transitionType,
};
eventsByTab.set(details.tabId, [...(eventsByTab.get(details.tabId) || []), event].slice(-MAX_EVENTS_PER_TAB));
schedulePersist();
}
browser.webNavigation.onCommitted.addListener((details) => void record('document', details));
browser.webNavigation.onHistoryStateUpdated.addListener((details) => void record('history', details));
browser.webNavigation.onReferenceFragmentUpdated.addListener((details) => void record('fragment', details));
browser.tabs.onRemoved.addListener((tabId) => {
if (eventsByTab.delete(tabId)) schedulePersist();
});
export async function getPageLifecycle(tabId: number, frameId: number, documentId?: string): Promise<PageLifecycleEvent[]> {
await restored;
return (eventsByTab.get(tabId) || []).filter((event) => event.frameId === frameId
&& (!documentId || !event.documentId || event.documentId === documentId)).slice(-50);
}
+24
View File
@@ -0,0 +1,24 @@
import type { PageEvalResult } from '@/types/models';
export const PAGE_BRIDGE_CHANNEL = 'yakit-page-bridge-v1';
export const PAGE_REQUEST_EVENT = 'yakit:page-request:v1';
export const PAGE_RESPONSE_EVENT = 'yakit:page-response:v1';
export type PageOperation =
| { operation: 'eval'; mode: 'expression' | 'program'; code: string }
| { operation: 'invoke'; path: string; args: unknown[] };
export type PageBridgeRequest = PageOperation & {
id: string;
timeoutMs: number;
};
export type PageBridgeResponse = {
id: string;
ok: true;
result: PageEvalResult;
} | {
id: string;
ok: false;
error: { name: string; message: string; stack?: string };
};
+763
View File
@@ -0,0 +1,763 @@
import { browser } from 'wxt/browser';
import type {
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
} from '@/types/models';
import { executePageOperation } from '@/features/page-context/execution-adapter';
import { getFrameInventory } from '@/features/page-context/frames';
import { getPageLifecycle } from '@/features/page-context/lifecycle';
import { CONTEXT_DIGEST_STORAGE_KEY } from '@/protocol/storage';
import { listCookies } from '@/features/cookies/service';
import { ExtensionError } from '@/shared/errors';
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
const MAX_SCANNED_ELEMENTS = 10_000;
const MAX_NODES = 400;
const MAX_FORMS = 50;
const MAX_HEADINGS = 80;
const MAX_BODY_TEXT = 20 * 1024;
const MAX_STORAGE_ENTRIES = 100;
const MAX_STORAGE_VALUE = 4 * 1024;
const MAX_STORAGE_BYTES = 128 * 1024;
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
const trim = (value: string | null | undefined, max = 240) => (value || '').replace(/\s+/g, ' ').trim().slice(0, max);
const truncateUtf8 = (value: string, maxBytes: number) => {
const bytes = encoder.encode(value);
if (bytes.byteLength <= maxBytes) return { value, byteLength: bytes.byteLength, truncated: false };
let end = maxBytes;
while (end > 0) {
try { return { value: decoder.decode(bytes.subarray(0, end)), byteLength: bytes.byteLength, truncated: true }; }
catch { end -= 1; }
}
return { value: '', byteLength: bytes.byteLength, truncated: true };
};
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
const nodes = new Map<string, Element>();
const summaries = new Map<string, PageNodeSummary>();
const nodeIds = new WeakMap<Element, string>();
const semanticOccurrences = new Map<string, number>();
const interactive: PageNodeSummary[] = [];
const forms: PageFormSummary[] = [];
const headings: Array<{ level: number; text: string }> = [];
const meta: Record<string, string> = {};
const limitsReached = new Set<string>();
let scannedElementCount = 0;
let passwordFieldCount = 0;
let hasLoginControl = false;
let hasLogoutControl = false;
let hasAccountControl = false;
let metaCount = 0;
const selectorHint = (element: Element) => {
if (element.id) return `#${CSS.escape(element.id)}`.slice(0, 240);
const testId = element.getAttribute('data-testid');
if (testId) return `[data-testid="${CSS.escape(testId)}"]`.slice(0, 240);
const name = element.getAttribute('name');
if (name) return `${element.tagName.toLowerCase()}[name="${CSS.escape(name)}"]`.slice(0, 240);
const role = element.getAttribute('role');
return `${element.tagName.toLowerCase()}${role ? `[role="${CSS.escape(role)}"]` : ''}`.slice(0, 240);
};
const accessibleName = (element: Element) => {
const labelledBy = element.getAttribute('aria-labelledby');
const labelledText = labelledBy?.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
const labels = 'labels' in element
? Array.from((element as HTMLInputElement).labels || []).map((label) => label.textContent || '').join(' ')
: '';
return trim(element.getAttribute('aria-label') || labelledText || labels || element.getAttribute('alt')
|| element.getAttribute('title') || element.getAttribute('placeholder') || element.textContent);
};
const semanticBase = (element: Element, name: string) => {
const tag = element.tagName.toLowerCase();
if (element.id) return `${tag}#${trim(element.id, 120)}`;
const testId = element.getAttribute('data-testid');
if (testId) return `${tag}[testid=${trim(testId, 120)}]`;
const fieldName = element.getAttribute('name');
if (fieldName) return `${tag}[name=${trim(fieldName, 120)}]`;
let href = '';
const rawHref = element.getAttribute('href');
if (rawHref) {
try {
const parsed = new URL(rawHref, location.href);
href = `${parsed.origin}${parsed.pathname}`;
} catch {
href = rawHref.split('?')[0];
}
}
return `${tag}|${element.getAttribute('role') || ''}|${element.getAttribute('type') || ''}|${trim(href, 180)}|${name}`;
};
const register = (element: Element, shadowDepth: number) => {
const existing = nodeIds.get(element);
if (existing) return summaries.get(existing);
if (nodes.size >= MAX_NODES) {
limitsReached.add('interactive_nodes');
return undefined;
}
const name = accessibleName(element);
const base = semanticBase(element, name);
const occurrence = semanticOccurrences.get(base) || 0;
semanticOccurrences.set(base, occurrence + 1);
const nodeId = `n${(nodes.size + 1).toString(36)}`;
const style = getComputedStyle(element);
const visible = element.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
const rawHref = element.getAttribute('href');
let href: string | undefined;
if (rawHref) {
try { href = new URL(rawHref, location.href).href.slice(0, 2_048); } catch { href = rawHref.slice(0, 2_048); }
}
const control = element as HTMLInputElement;
const summary: PageNodeSummary = {
nodeId,
semanticKey: `${base}|${occurrence}`.slice(0, 500),
tag: element.tagName.toLowerCase(),
role: trim(element.getAttribute('role'), 120),
type: trim(element.getAttribute('type'), 120),
name: trim(element.getAttribute('name'), 240),
text: trim(element.textContent),
accessibleName: name,
selectorHint: selectorHint(element),
visible,
disabled: Boolean(control.disabled || element.getAttribute('aria-disabled') === 'true'),
required: Boolean(control.required || element.getAttribute('aria-required') === 'true'),
...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type) ? { checked: element.checked } : {}),
...(href ? { href } : {}),
...(element.getAttribute('placeholder') ? { placeholder: trim(element.getAttribute('placeholder')) } : {}),
...(element.getAttribute('autocomplete') ? { autocomplete: trim(element.getAttribute('autocomplete')) } : {}),
shadowDepth,
};
nodes.set(nodeId, element);
nodeIds.set(element, nodeId);
summaries.set(nodeId, summary);
return summary;
};
const interactiveSelector = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[contenteditable="true"]';
const visitRoot = (root: Document | ShadowRoot, shadowDepth: number) => {
if (root instanceof ShadowRoot && root.host.tagName.toLowerCase() === 'yakit-browser-agent') return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = walker.nextNode();
while (current) {
const element = current as Element;
if (scannedElementCount >= MAX_SCANNED_ELEMENTS) {
limitsReached.add('scanned_elements');
return;
}
scannedElementCount += 1;
if (input.options.includeDom !== false && element.matches(interactiveSelector)) {
const summary = register(element, shadowDepth);
if (summary) {
interactive.push(summary);
const label = String(summary.accessibleName || summary.text || '');
if (element instanceof HTMLInputElement && element.type === 'password') passwordFieldCount += 1;
if (/\b(log\s?in|sign\s?in)\b|登录|登入/i.test(label)) hasLoginControl = true;
if (/\b(log\s?out|sign\s?out)\b|退出|注销/i.test(label)) hasLogoutControl = true;
if (/\b(account|profile|dashboard)\b|账户|账号|个人中心/i.test(label)) hasAccountControl = true;
}
}
if (input.options.includeDom !== false && /^H[1-6]$/.test(element.tagName) && headings.length < MAX_HEADINGS) {
headings.push({ level: Number(element.tagName.slice(1)), text: trim(element.textContent, 500) });
}
if (input.options.includeDom !== false && element instanceof HTMLFormElement && forms.length < MAX_FORMS) {
const formSummary = register(element, shadowDepth);
if (formSummary) {
const fieldNodeIds: string[] = [];
const fieldWalker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
let field = fieldWalker.nextNode();
while (field && fieldNodeIds.length < 100) {
const fieldElement = field as Element;
if (fieldElement.matches('input,select,textarea,button')) {
const fieldNodeId = register(fieldElement, shadowDepth)?.nodeId;
if (fieldNodeId) fieldNodeIds.push(fieldNodeId);
}
field = fieldWalker.nextNode();
}
forms.push({
nodeId: formSummary.nodeId,
semanticKey: formSummary.semanticKey,
action: element.action.slice(0, 2_048),
method: element.method || 'get',
name: element.name.slice(0, 240),
fieldNodeIds,
});
}
}
if (element instanceof HTMLMetaElement && metaCount < 80) {
const key = element.getAttribute('name') || element.getAttribute('property') || '';
if (key) {
meta[key.slice(0, 240)] = (element.content || '').slice(0, 2_048);
metaCount += 1;
}
}
if (element.shadowRoot) visitRoot(element.shadowRoot, shadowDepth + 1);
current = walker.nextNode();
}
};
if (input.options.includeDom !== false) visitRoot(document, 0);
if (headings.length >= MAX_HEADINGS) limitsReached.add('headings');
if (forms.length >= MAX_FORMS) limitsReached.add('forms');
const storageError = (error: unknown) => {
try { return (error instanceof Error ? error.message : String(error)).slice(0, 500); }
catch { return 'Storage access failed'; }
};
const readStorage = (name: 'localStorage' | 'sessionStorage'): PageStorageSummary => {
const entries: Array<{ key: string; value: string; byteLength: number; authRelated: boolean; truncated: boolean }> = [];
let approximateBytes = 0;
let storage: Storage | undefined;
try {
storage = globalThis[name];
} catch (error) {
return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false, error: storageError(error) };
}
if (!storage) return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false };
let totalEntries = 0;
try {
totalEntries = storage.length;
for (let index = 0; index < totalEntries && entries.length < MAX_STORAGE_ENTRIES; index += 1) {
const key = storage.key(index);
if (!key) continue;
const raw = storage.getItem(key) || '';
if (approximateBytes >= MAX_STORAGE_BYTES) break;
const bounded = truncateUtf8(raw, Math.min(MAX_STORAGE_VALUE, MAX_STORAGE_BYTES - approximateBytes));
approximateBytes += encoder.encode(bounded.value).byteLength;
entries.push({ key: key.slice(0, 500), value: bounded.value, byteLength: bounded.byteLength, authRelated: /(auth|token|jwt|session|login|user|csrf|sid)/i.test(key), truncated: bounded.truncated });
}
return { supported: true, entries, totalEntries, approximateBytes, truncated: entries.length < totalEntries };
} catch (error) {
return { supported: true, entries, totalEntries, approximateBytes, truncated: true, error: storageError(error) };
}
};
const collectStorageInventory = async (): Promise<BrowserStorageInventory> => {
const normalizeKey = (key: IDBValidKey): string | number => {
if (typeof key === 'string') return key.slice(0, 500);
if (typeof key === 'number') return key;
if (key instanceof Date) return key.toISOString();
if (Array.isArray(key)) return JSON.stringify(key).slice(0, 500);
return `[binary key: ${key.byteLength} bytes]`;
};
const requestValue = <T,>(request: IDBRequest<T>, timeoutMs = 700): Promise<T> => new Promise((resolve, reject) => {
const timer = globalThis.setTimeout(() => reject(new Error('IndexedDB request timed out')), timeoutMs);
request.onsuccess = () => { globalThis.clearTimeout(timer); resolve(request.result); };
request.onerror = () => { globalThis.clearTimeout(timer); reject(request.error || new Error('IndexedDB request failed')); };
});
let indexedDBApi: IDBFactory | undefined;
let indexedDBAccessError: string | undefined;
try { indexedDBApi = globalThis.indexedDB; }
catch (error) { indexedDBAccessError = storageError(error); }
const openDatabase = (api: IDBFactory, name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
const request = api.open(name);
let settled = false;
const timer = globalThis.setTimeout(() => { settled = true; reject(new Error('IndexedDB open timed out')); }, 700);
request.onsuccess = () => {
globalThis.clearTimeout(timer);
if (settled) request.result.close(); else { settled = true; resolve(request.result); }
};
request.onerror = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(request.error || new Error('IndexedDB open failed')); } };
request.onblocked = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(new Error('IndexedDB open was blocked')); } };
});
const indexedResult: BrowserStorageInventory['indexedDB'] = {
supported: Boolean(indexedDBApi && typeof indexedDBApi.databases === 'function'),
databases: [],
truncated: false,
...(indexedDBAccessError ? { error: indexedDBAccessError } : {}),
};
if (indexedResult.supported && indexedDBApi) {
try {
const allDatabases = await Promise.race([
indexedDBApi.databases(),
new Promise<never>((_, reject) => globalThis.setTimeout(() => reject(new Error('IndexedDB inventory timed out')), 1_000)),
]);
const databases = allDatabases.filter((database) => database.name).slice(0, 10);
indexedResult.truncated = allDatabases.length > databases.length;
let remainingStores = 50;
for (const databaseInfo of databases) {
const name = databaseInfo.name!;
try {
const database = await openDatabase(indexedDBApi, name);
const storeNames = Array.from(database.objectStoreNames).slice(0, Math.min(20, remainingStores));
const databaseSummary: BrowserStorageInventory['indexedDB']['databases'][number] = {
name: name.slice(0, 500), version: database.version, stores: [],
truncated: database.objectStoreNames.length > storeNames.length,
};
if (storeNames.length > 0) {
for (const storeName of storeNames) {
try {
const store = database.transaction(storeName, 'readonly').objectStore(storeName);
const [count, keys] = await Promise.all([
requestValue(store.count()),
requestValue(store.getAllKeys(undefined, 10)),
]);
databaseSummary.stores.push({
name: storeName.slice(0, 500),
keyPath: typeof store.keyPath === 'string'
? store.keyPath.slice(0, 500)
: Array.isArray(store.keyPath) ? store.keyPath.map((item) => item.slice(0, 500)).slice(0, 20) : null,
autoIncrement: store.autoIncrement,
count,
sampleKeys: keys.map(normalizeKey),
truncated: count > keys.length,
});
} catch (error) {
databaseSummary.stores.push({
name: storeName.slice(0, 500), keyPath: null, autoIncrement: false, sampleKeys: [], truncated: true,
error: storageError(error),
});
}
remainingStores -= 1;
}
}
database.close();
indexedResult.databases.push(databaseSummary);
if (remainingStores <= 0) { indexedResult.truncated = true; break; }
} catch (error) {
indexedResult.databases.push({
name: name.slice(0, 500), version: databaseInfo.version || 0, stores: [], truncated: true,
error: storageError(error),
});
}
}
} catch (error) {
indexedResult.error = storageError(error);
}
}
let cacheStorageApi: CacheStorage | undefined;
let cacheStorageAccessError: string | undefined;
try { cacheStorageApi = globalThis.caches; }
catch (error) { cacheStorageAccessError = storageError(error); }
const cacheResult: BrowserStorageInventory['cacheStorage'] = {
supported: Boolean(cacheStorageApi && typeof cacheStorageApi.keys === 'function'),
names: [],
truncated: false,
...(cacheStorageAccessError ? { error: cacheStorageAccessError } : {}),
};
if (cacheResult.supported && cacheStorageApi) {
try {
const names = await cacheStorageApi.keys();
cacheResult.names = names.slice(0, 50).map((name) => name.slice(0, 500));
cacheResult.truncated = names.length > cacheResult.names.length;
} catch (error) {
cacheResult.error = storageError(error);
}
}
return { indexedDB: indexedResult, cacheStorage: cacheResult };
};
const cryptoPattern = /(encrypt|decrypt|crypto|cipher|sign|hash|md5|sha|aes|rsa|sm2|sm3|sm4|encode|decode)/i;
const cryptoCandidates: Array<{ path: string; kind: string }> = [];
for (const key of Object.getOwnPropertyNames(window).slice(0, 5_000)) {
if (!cryptoPattern.test(key)) continue;
try { cryptoCandidates.push({ path: key, kind: typeof Reflect.get(window, key) }); }
catch { cryptoCandidates.push({ path: key, kind: 'unreadable' }); }
if (cryptoCandidates.length >= 100) break;
}
const collectBodyText = () => {
if (input.options.includeDom === false || !document.body) return { value: '', truncated: false };
const parts: string[] = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let remaining = MAX_BODY_TEXT;
let visited = 0;
let truncated = false;
let current = walker.nextNode();
while (current && remaining > 0 && visited < 5_000) {
visited += 1;
const parent = current.parentElement;
if (parent && !parent.closest('script,style,noscript,template,[hidden],[aria-hidden="true"],yakit-browser-agent')) {
const raw = current.nodeValue || '';
const normalized = raw.slice(0, MAX_BODY_TEXT).replace(/\s+/g, ' ').trim();
if (normalized) {
const separatorBytes = parts.length ? 1 : 0;
if (remaining <= separatorBytes) { truncated = true; break; }
const bounded = truncateUtf8(normalized, remaining - separatorBytes);
parts.push(bounded.value);
remaining -= encoder.encode(bounded.value).byteLength + separatorBytes;
truncated ||= bounded.truncated || raw.length > MAX_BODY_TEXT;
}
}
current = walker.nextNode();
}
if (current || visited >= 5_000) truncated = true;
return { value: parts.join('\n'), truncated };
};
const bodyText = collectBodyText();
let storageInventory: BrowserStorageInventory | undefined;
if (input.options.includeStorage) {
try {
storageInventory = await collectStorageInventory();
} catch (error) {
const message = storageError(error);
storageInventory = {
indexedDB: { supported: false, databases: [], truncated: false, error: message },
cacheStorage: { supported: false, names: [], truncated: false, error: message },
};
}
}
const localStorageSummary = input.options.includeStorage ? readStorage('localStorage') : undefined;
const sessionStorageSummary = input.options.includeStorage ? readStorage('sessionStorage') : undefined;
const registry = { captureId: input.captureId, nodes, summaries };
Reflect.set(globalThis, registryKey, registry);
return {
document: {
title: document.title.slice(0, 1_000),
url: location.href.slice(0, 8_192),
referrer: document.referrer.slice(0, 8_192),
language: (document.documentElement.lang || navigator.language).slice(0, 100),
charset: document.characterSet,
readyState: document.readyState,
bodyText: bodyText.value,
bodyTextTruncated: bodyText.truncated,
headings,
forms,
interactive,
meta,
localStorage: localStorageSummary,
sessionStorage: sessionStorageSummary,
storageInventory,
cryptoCandidates,
scannedElementCount,
limitsReached: [...limitsReached],
},
authenticationSeed: { passwordFieldCount, hasLoginControl, hasLogoutControl, hasAccountControl },
};
}
interface ContextDigest {
captureId: string;
documentId?: string;
title: string;
url: string;
authentication: PageAuthenticationSignals['status'];
included: string;
nodes: Map<string, PageContextChange & { signature: string }>;
formSignature: string;
storageKeys: Set<string>;
cookieNames: Set<string>;
}
const contextDigests = new Map<string, ContextDigest>();
const MAX_MEMORY_CONTEXT_DIGESTS = 32;
const MAX_PERSISTED_CONTEXT_DIGESTS = 8;
const contextSessionStorage = (browser.storage as unknown as {
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
}).session;
let contextPersistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
interface StoredContextDigest extends Omit<ContextDigest, 'nodes' | 'storageKeys' | 'cookieNames'> {
nodes: Array<[string, PageContextChange & { signature: string }]>;
storageKeys: string[];
cookieNames: string[];
}
function storedDigest(input: ContextDigest): StoredContextDigest {
return { ...input, nodes: [...input.nodes], storageKeys: [...input.storageKeys], cookieNames: [...input.cookieNames] };
}
async function restoreContextDigests(): Promise<void> {
if (!contextSessionStorage) return;
try {
const stored = await contextSessionStorage.get(CONTEXT_DIGEST_STORAGE_KEY);
const values = stored[CONTEXT_DIGEST_STORAGE_KEY];
if (!Array.isArray(values)) return;
for (const item of values.slice(-MAX_PERSISTED_CONTEXT_DIGESTS)) {
const entry = item as Partial<StoredContextDigest> & { key?: unknown };
if (typeof entry.key !== 'string' || typeof entry.captureId !== 'string' || typeof entry.title !== 'string'
|| typeof entry.url !== 'string' || !Array.isArray(entry.nodes) || !Array.isArray(entry.storageKeys)
|| !Array.isArray(entry.cookieNames) || !['authenticated', 'unauthenticated', 'unknown'].includes(String(entry.authentication))) continue;
contextDigests.set(entry.key, {
captureId: entry.captureId,
documentId: typeof entry.documentId === 'string' ? entry.documentId : undefined,
title: entry.title,
url: entry.url,
authentication: entry.authentication as PageAuthenticationSignals['status'],
included: typeof entry.included === 'string' ? entry.included : 'dom',
nodes: new Map(entry.nodes),
formSignature: typeof entry.formSignature === 'string' ? entry.formSignature : '',
storageKeys: new Set(entry.storageKeys.filter((value): value is string => typeof value === 'string')),
cookieNames: new Set(entry.cookieNames.filter((value): value is string => typeof value === 'string')),
});
}
} catch {
// Context diff remains available in memory when session storage is unavailable.
}
}
const contextDigestRestore = restoreContextDigests();
function scheduleContextDigestPersist(): void {
if (!contextSessionStorage || contextPersistTimer) return;
contextPersistTimer = globalThis.setTimeout(() => {
contextPersistTimer = undefined;
const values = [...contextDigests].slice(-MAX_PERSISTED_CONTEXT_DIGESTS).map(([key, digest]) => ({ key, ...storedDigest(digest) }));
void contextSessionStorage.set({ [CONTEXT_DIGEST_STORAGE_KEY]: values }).catch(() => undefined);
}, 250);
}
browser.tabs.onRemoved.addListener((tabId) => {
let changed = false;
for (const key of contextDigests.keys()) {
if (!key.startsWith(`${tabId}:`)) continue;
contextDigests.delete(key);
changed = true;
}
if (changed) scheduleContextDigestPersist();
});
function difference(left: Set<string>, right: Set<string>, limit = 100): string[] {
return [...left].filter((item) => !right.has(item)).slice(0, limit);
}
async function contextDiff(context: Omit<PageContext, 'diff'>): Promise<PageContextDiff> {
await contextDigestRestore;
const key = `${context.target.tabId}:${context.target.frameId}`;
const nodes = new Map(context.document.interactive.map((node) => [node.semanticKey, {
semanticKey: node.semanticKey, tag: node.tag, text: node.accessibleName || node.text, nodeId: node.nodeId,
signature: `${node.visible}|${node.disabled}|${node.required}|${node.checked ?? ''}|${node.href || ''}`,
}]));
const storageKeys = new Set([
...(context.document.localStorage?.entries.map((entry) => `local:${entry.key}`) || []),
...(context.document.sessionStorage?.entries.map((entry) => `session:${entry.key}`) || []),
]);
const cookieNames = new Set(context.authentication.cookieNames);
const current: ContextDigest = {
captureId: context.captureId,
documentId: context.target.documentId,
title: context.document.title,
url: context.document.url,
authentication: context.authentication.status,
included: `${context.included.dom}:${context.included.storage}:${context.included.cookies}`,
nodes,
formSignature: context.document.forms.map((form) => `${form.semanticKey}|${form.method}|${form.action}|${form.fieldNodeIds.length}`).join('\n'),
storageKeys,
cookieNames,
};
const previous = contextDigests.get(key);
contextDigests.delete(key);
contextDigests.set(key, current);
while (contextDigests.size > MAX_MEMORY_CONTEXT_DIGESTS) contextDigests.delete(contextDigests.keys().next().value!);
scheduleContextDigestPersist();
if (!previous) {
return {
kind: 'initial', toCaptureId: context.captureId, changedSections: [],
addedNodes: [], removedNodes: [], addedStorageKeys: [], removedStorageKeys: [], addedCookieNames: [], removedCookieNames: [],
};
}
const changedSections = new Set<PageContextDiff['changedSections'][number]>();
const sameOptions = previous.included === current.included;
const [previousDom, previousStorage, previousCookies] = previous.included.split(':').map((value) => value === 'true');
if (!sameOptions) changedSections.add('capture_options');
if (previous.title !== current.title || previous.url !== current.url || previous.documentId !== current.documentId) changedSections.add('document');
if (sameOptions && previous.authentication !== current.authentication) changedSections.add('authentication');
if (previousDom && context.included.dom && previous.formSignature !== current.formSignature) changedSections.add('forms');
const addedNodes = previousDom && context.included.dom ? [...current.nodes.entries()].filter(([semanticKey, node]) => {
const old = previous.nodes.get(semanticKey);
return !old || old.signature !== node.signature;
}).map(([, node]) => node).slice(0, 50) : [];
const removedNodes = previousDom && context.included.dom ? [...previous.nodes.entries()].filter(([semanticKey, node]) => {
const next = current.nodes.get(semanticKey);
return !next || next.signature !== node.signature;
}).map(([, node]) => ({ semanticKey: node.semanticKey, tag: node.tag, text: node.text })).slice(0, 50) : [];
if (addedNodes.length || removedNodes.length) changedSections.add('interactive');
const addedStorageKeys = previousStorage && context.included.storage ? difference(current.storageKeys, previous.storageKeys) : [];
const removedStorageKeys = previousStorage && context.included.storage ? difference(previous.storageKeys, current.storageKeys) : [];
if (addedStorageKeys.length || removedStorageKeys.length) changedSections.add('storage');
const addedCookieNames = previousCookies && context.included.cookies ? difference(current.cookieNames, previous.cookieNames) : [];
const removedCookieNames = previousCookies && context.included.cookies ? difference(previous.cookieNames, current.cookieNames) : [];
if (addedCookieNames.length || removedCookieNames.length) changedSections.add('cookies');
const documentChanged = Boolean(previous.documentId && current.documentId && previous.documentId !== current.documentId);
return {
kind: documentChanged ? 'document_changed' : changedSections.size ? 'changed' : 'unchanged',
fromCaptureId: previous.captureId,
toCaptureId: context.captureId,
changedSections: [...changedSections], addedNodes, removedNodes,
addedStorageKeys, removedStorageKeys, addedCookieNames, removedCookieNames,
};
}
function authenticationSignals(
seed: { passwordFieldCount: number; hasLoginControl: boolean; hasLogoutControl: boolean; hasAccountControl: boolean },
documentContext: PageContext['document'],
cookieNames: string[],
): PageAuthenticationSignals {
const evidence: string[] = [];
let score = 0;
if (seed.hasLogoutControl) { score += 3; evidence.push('页面存在退出登录控件'); }
if (seed.hasAccountControl) { score += 2; evidence.push('页面存在账户或个人中心控件'); }
if (seed.passwordFieldCount > 0) { score -= 2; evidence.push(`页面存在 ${seed.passwordFieldCount} 个密码输入框`); }
if (seed.hasLoginControl) { score -= 1; evidence.push('页面存在登录控件'); }
const authCookieNames = cookieNames.filter((name) => /(auth|token|jwt|session|login|sid)/i.test(name));
if (authCookieNames.length > 0) { score += 2; evidence.push(`发现 ${authCookieNames.length} 个疑似认证 Cookie 名称`); }
const storageKeys = [
...(documentContext.localStorage?.entries || []),
...(documentContext.sessionStorage?.entries || []),
].filter((entry) => entry.authRelated).map((entry) => entry.key);
if (storageKeys.length > 0) { score += 2; evidence.push(`发现 ${storageKeys.length} 个疑似认证 Storage 键`); }
return {
status: score >= 2 ? 'authenticated' : score <= -2 ? 'unauthenticated' : 'unknown',
confidence: Math.min(0.95, Math.round((0.3 + Math.abs(score) * 0.1) * 100) / 100),
evidence: evidence.slice(0, 8),
passwordFieldCount: seed.passwordFieldCount,
cookieNames: cookieNames.slice(0, 200),
storageKeys: storageKeys.slice(0, 200),
};
}
export async function capturePageContext(options: PageContextOptions = {}, input?: BrowserTarget | number): Promise<PageContext> {
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
if (!/^https?:/i.test(tab.url)) throw new Error('当前页面不允许采集上下文');
const target = await resolveDocumentTarget(input || tab.id);
const captureId = crypto.randomUUID();
let injections: Array<Browser.scripting.InjectionResult & { error?: string }>;
try {
injections = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: collectDocumentContext,
args: [{ options, captureId }],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${message}`);
}
if (injections.length !== 1) throw new ExtensionError('context_capture_failed', '页面上下文采集无法唯一定位目标文档');
const [{ result, error }] = injections;
if (error) throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${error}`);
if (result === undefined) throw new ExtensionError('context_capture_failed', '页面上下文采集脚本没有返回结果');
const collected = result as Awaited<ReturnType<typeof collectDocumentContext>>;
const [cookies, frames, lifecycle] = await Promise.all([
options.includeCookies ? listCookies(collected.document.url) : undefined,
getFrameInventory(target.tabId),
getPageLifecycle(target.tabId, target.frameId, target.documentId),
]);
const authentication = authenticationSignals(collected.authenticationSeed, collected.document, cookies?.map((cookie) => cookie.name) || []);
const contextWithoutDiff: Omit<PageContext, 'diff'> = {
captureId,
capturedAt: Date.now(),
included: { dom: options.includeDom !== false, storage: options.includeStorage === true, cookies: options.includeCookies === true },
tab,
target,
frames,
lifecycle,
authentication,
document: collected.document,
cookies,
};
return { ...contextWithoutDiff, diff: await contextDiff(contextWithoutDiff) };
}
function operateRegisteredNode(input: { captureId: string; nodeId: string; operation: 'inspect' | PageNodeAction; value?: string }) {
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
const registry = Reflect.get(globalThis, registryKey) as {
captureId?: string;
nodes?: Map<string, Element>;
summaries?: Map<string, PageNodeSummary>;
} | undefined;
if (!registry || registry.captureId !== input.captureId) {
return { ok: false as const, code: 'stale_node', message: '上下文快照已经失效,请重新采集页面上下文' };
}
const element = registry.nodes?.get(input.nodeId);
const summary = registry.summaries?.get(input.nodeId);
if (!element || !summary || !element.isConnected) {
return { ok: false as const, code: 'stale_node', message: '页面元素已被替换或移除,请重新采集页面上下文' };
}
const safeAttributes = new Set(['id', 'name', 'type', 'role', 'href', 'action', 'method', 'placeholder', 'autocomplete', 'disabled', 'required', 'checked', 'aria-label', 'aria-labelledby', 'aria-disabled', 'aria-required']);
const attributes: Record<string, string> = {};
for (const attribute of Array.from(element.attributes).slice(0, 80)) {
if (safeAttributes.has(attribute.name)) attributes[attribute.name] = attribute.value.slice(0, 2_048);
}
const rect = element.getBoundingClientRect();
const node = {
...summary,
connected: true,
attributes,
...(rect.width || rect.height ? { bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } } : {}),
};
if (input.operation === 'inspect') return { ok: true as const, node };
const control = element as HTMLInputElement;
if (input.operation === 'click') {
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
}
const click = (element as HTMLElement).click;
if (typeof click !== 'function') return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持原生 click 操作' };
globalThis.setTimeout(() => click.call(element), 0);
} else if (input.operation === 'focus') {
if (!(element instanceof HTMLElement)) return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持聚焦' };
element.focus({ preventScroll: true });
} else if (input.operation === 'scroll') {
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
} else if (input.operation === 'setValue') {
if (typeof input.value !== 'string') return { ok: false as const, code: 'invalid_node_action', message: 'setValue 缺少 value' };
if (element instanceof HTMLInputElement) {
if (element.type === 'file') return { ok: false as const, code: 'node_not_actionable', message: '不能通过 setValue 写入文件输入框' };
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLTextAreaElement) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLSelectElement) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLElement && element.isContentEditable) {
element.textContent = input.value;
} else {
return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持 setValue' };
}
element.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText', data: input.value }));
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}
return { ok: true as const, node };
}
async function operateNode(
captureId: string,
nodeId: string,
operation: 'inspect' | PageNodeAction,
input: BrowserTarget | number,
value?: string,
): Promise<PageNodeDetails> {
const target = await resolveDocumentTarget(input);
const [{ result }] = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: operateRegisteredNode,
args: [{ captureId, nodeId, operation, value }],
});
if (!result?.ok) throw new ExtensionError(result?.code || 'node_operation_failed', result?.message || '页面元素操作失败');
return {
...(result.node as unknown as PageNodeSummary),
connected: true,
attributes: (result.node.attributes || {}) as Record<string, string>,
bounds: result.node.bounds,
reference: { captureId, nodeId, ...target },
};
}
export function inspectPageNode(captureId: string, nodeId: string, input: BrowserTarget | number): Promise<PageNodeDetails> {
return operateNode(captureId, nodeId, 'inspect', input);
}
export async function actOnPageNode(
captureId: string,
nodeId: string,
action: PageNodeAction,
input: BrowserTarget | number,
value?: string,
): Promise<PageNodeActionResult> {
const node = await operateNode(captureId, nodeId, action, input, value);
return { action, completedAt: Date.now(), node };
}
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
const target = await resolveDocumentTarget(input || tab.id);
return executePageOperation(target, { operation: 'invoke', path, args }, timeoutMs);
}
export async function evalInPage(code: string, mode: 'expression' | 'program', input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
if (!code.trim()) throw new Error('执行代码不能为空');
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
const target = await resolveDocumentTarget(input || tab.id);
return executePageOperation(target, { operation: 'eval', mode, code }, timeoutMs);
}
+217
View File
@@ -0,0 +1,217 @@
import { browser, type Browser } from 'wxt/browser';
import { scriptingTarget } from '@/platform/browser/targets';
import type {
BrowserTarget, PageObservationOptions, PageObservationRecord, PageObservationStatus,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
const OBSERVER_SCRIPT = '/page-observer-main-world.js' as const;
const DEFAULT_OPTIONS: PageObservationOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
const MAX_ENTRIES = 200;
interface PageObserverSnapshot {
version: 2;
active: boolean;
startedAt?: number;
count: number;
droppedCount: number;
options?: PageObservationOptions;
records: PageObservationRecord[];
}
interface OwnedObservation {
target: BrowserTarget;
owner: { kind: 'local' } | { kind: 'grant'; grantId: string };
}
type ObserverCommand = 'start' | 'status' | 'list' | 'clear' | 'stop';
const ownedObservations = new Map<string, OwnedObservation>();
function targetKey(target: BrowserTarget): string {
return `${target.tabId}:${target.frameId}`;
}
function pageObserverCommand(command: ObserverCommand, input: Record<string, unknown>): unknown {
const controller = (window as unknown as Record<string, unknown>).__YAKIT_PAGE_OBSERVER_V2__ as {
version?: unknown;
command?: (name: ObserverCommand, params: Record<string, unknown>) => unknown;
} | undefined;
if (controller?.version !== 2 || typeof controller.command !== 'function') {
if (command === 'status') return { version: 2, active: false, count: 0, droppedCount: 0, records: [] };
throw new Error('页面观测器未安装');
}
return controller.command(command, input);
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === 'string' ? value.slice(0, maxLength) : undefined;
}
function normalizeOptions(input?: Partial<PageObservationOptions>): PageObservationOptions {
return {
captureValues: input?.captureValues === true,
maxEntries: Math.max(10, Math.min(Math.floor(input?.maxEntries || DEFAULT_OPTIONS.maxEntries), MAX_ENTRIES)),
maxValueBytes: Math.max(256, Math.min(Math.floor(input?.maxValueBytes || DEFAULT_OPTIONS.maxValueBytes), 8_192)),
expiresAt: typeof input?.expiresAt === 'number' && Number.isFinite(input.expiresAt) ? input.expiresAt : undefined,
};
}
function normalizeRecord(value: unknown, allowSensitive: boolean): PageObservationRecord | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Record<string, unknown>;
const kinds = ['fetch', 'xhr', 'form', 'websocket', 'webcrypto', 'cryptojs'] as const;
if (typeof input.id !== 'string' || !kinds.includes(input.kind as typeof kinds[number]) || typeof input.operation !== 'string') return undefined;
const output = {
id: input.id.slice(0, 160),
sequence: Math.max(0, Math.floor(finiteNumber(input.sequence))),
timestamp: finiteNumber(input.timestamp),
kind: input.kind as PageObservationRecord['kind'],
operation: input.operation.slice(0, 160),
sensitiveCaptured: allowSensitive && input.sensitiveCaptured === true,
} as PageObservationRecord & Record<string, unknown>;
const stringLimits: Record<string, number> = {
url: 8_192, method: 32, algorithm: 240, socketId: 160, dataType: 120,
stack: 4_096, scriptUrl: 2_048, error: 512,
};
for (const [key, limit] of Object.entries(stringLimits)) {
const normalized = optionalString(input[key], limit);
if (normalized !== undefined) output[key] = normalized;
}
if (input.direction === 'send' || input.direction === 'receive') output.direction = input.direction;
for (const key of ['byteLength', 'resultByteLength'] as const) {
if (input[key] !== undefined) output[key] = Math.max(0, finiteNumber(input[key]));
}
if (allowSensitive) {
output.inputPreview = optionalString(input.inputPreview, 8_192);
output.outputPreview = optionalString(input.outputPreview, 8_192);
}
return output;
}
function normalizeSnapshot(value: unknown, allowSensitive: boolean): PageObserverSnapshot {
if (!value || typeof value !== 'object') throw new ExtensionError('observer_unavailable', '页面观测器返回了无效状态');
const input = value as Record<string, unknown>;
if (input.version !== 2 || typeof input.active !== 'boolean' || !Array.isArray(input.records)) {
throw new ExtensionError('observer_unavailable', '页面观测器协议不兼容');
}
const pageOptions = input.options && typeof input.options === 'object'
? normalizeOptions(input.options as Partial<PageObservationOptions>)
: undefined;
return {
version: 2,
active: input.active,
startedAt: input.startedAt === undefined ? undefined : finiteNumber(input.startedAt),
count: Math.max(0, Math.floor(finiteNumber(input.count))),
droppedCount: Math.max(0, Math.floor(finiteNumber(input.droppedCount))),
options: pageOptions,
records: input.records.slice(-MAX_ENTRIES).map((item) => normalizeRecord(item, allowSensitive)).filter((item): item is PageObservationRecord => Boolean(item)),
};
}
async function executeCommand(
target: BrowserTarget,
command: ObserverCommand,
input: Record<string, unknown> = {},
allowSensitive = false,
): Promise<PageObserverSnapshot> {
let results: Browser.scripting.InjectionResult[];
try {
results = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: pageObserverCommand,
args: [command, input],
});
} catch (error) {
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
}
if (results.length !== 1) throw new ExtensionError('observer_unavailable', '页面观测器无法唯一定位目标文档');
return normalizeSnapshot(results[0].result, allowSensitive);
}
async function install(target: BrowserTarget): Promise<void> {
try {
await browser.scripting.executeScript({ target: scriptingTarget(target), world: 'MAIN', files: [OBSERVER_SCRIPT] });
} catch (error) {
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
}
}
function statusFrom(target: BrowserTarget, snapshot: PageObserverSnapshot): PageObservationStatus {
return {
active: snapshot.active,
target,
startedAt: snapshot.startedAt,
count: snapshot.count,
droppedCount: snapshot.droppedCount,
options: snapshot.options,
};
}
export async function startPageObservation(
target: BrowserTarget,
input?: Partial<PageObservationOptions>,
owner: OwnedObservation['owner'] = { kind: 'local' },
): Promise<PageObservationStatus> {
const options = normalizeOptions(input);
await install(target);
const snapshot = await executeCommand(target, 'start', { ...options }, options.captureValues);
ownedObservations.set(targetKey(target), { target, owner });
return statusFrom(target, snapshot);
}
export async function pageObservationStatus(target: BrowserTarget): Promise<PageObservationStatus> {
try {
return statusFrom(target, await executeCommand(target, 'status'));
} catch (error) {
if (error instanceof ExtensionError && error.code === 'observer_unavailable') {
return { active: false, target, count: 0, droppedCount: 0 };
}
throw error;
}
}
export async function listPageObservations(target: BrowserTarget, limit = 100, allowSensitive = false): Promise<PageObservationRecord[]> {
const snapshot = await executeCommand(target, 'list', { limit: Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)) }, allowSensitive);
return snapshot.records;
}
export async function clearPageObservations(target: BrowserTarget): Promise<PageObservationStatus> {
return statusFrom(target, await executeCommand(target, 'clear'));
}
export async function stopPageObservation(target: BrowserTarget): Promise<PageObservationStatus> {
const snapshot = await executeCommand(target, 'stop').catch(() => undefined);
ownedObservations.delete(targetKey(target));
return snapshot ? statusFrom(target, snapshot) : { active: false, target, count: 0, droppedCount: 0 };
}
export async function stopPageObservationsForGrant(grantId: string): Promise<void> {
const matches = [...ownedObservations.values()].filter((item) => item.owner.kind === 'grant' && item.owner.grantId === grantId);
await Promise.allSettled(matches.map((item) => stopPageObservation(item.target)));
}
export async function observationAnalysisWindow(target: BrowserTarget, centerTimestamp: number): Promise<Array<Pick<
PageObservationRecord,
'kind' | 'operation' | 'algorithm' | 'direction' | 'scriptUrl' | 'byteLength' | 'resultByteLength' | 'timestamp'
>>> {
const records = await listPageObservations(target, MAX_ENTRIES, false).catch(() => []);
return records.filter((item) => Math.abs(item.timestamp - centerTimestamp) <= 60_000).map((item) => ({
kind: item.kind,
operation: item.operation,
algorithm: item.algorithm,
direction: item.direction,
scriptUrl: item.scriptUrl,
byteLength: item.byteLength,
resultByteLength: item.resultByteLength,
timestamp: item.timestamp,
}));
}
browser.tabs.onRemoved.addListener((tabId) => {
for (const [key, observation] of ownedObservations) if (observation.target.tabId === tabId) ownedObservations.delete(key);
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { ProxyProfile, ProxyRule } from '@/types/models';
import { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
const profiles: ProxyProfile[] = [
{ id: 'direct', name: 'Direct', kind: 'direct', bypass: [] },
{ id: 'mitm', name: 'MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083, bypass: [] },
];
const rules: ProxyRule[] = [
{ id: 'low', name: 'Low', enabled: true, patterns: ['*.example.test'], proxyProfileId: 'direct', priority: 10 },
{ id: 'high', name: 'High', enabled: true, patterns: ['api.example.test'], proxyProfileId: 'mitm', priority: 20 },
];
describe('proxy compiler', () => {
it('matches exact, subdomain, wildcard and URL patterns', () => {
expect(proxyPatternMatches('example.test', 'https://api.example.test/path')).toBe(true);
expect(proxyPatternMatches('*.example.test', 'https://example.test/path')).toBe(true);
expect(proxyPatternMatches('api?.example.test', 'https://api1.example.test/')).toBe(true);
expect(proxyPatternMatches('https://*/api/*', 'https://api.example.test/api/1')).toBe(true);
expect(proxyPatternMatches('example.test', 'not-a-url')).toBe(false);
});
it('orders PAC branches by priority and applies fail-open', () => {
const pac = compileProxyRules(rules, profiles, { defaultProfileId: 'direct', failMode: 'open' });
expect(pac.indexOf('High [priority=20]')).toBeLessThan(pac.indexOf('Low [priority=10]'));
expect(pac).toContain('PROXY 127.0.0.1:8083; DIRECT');
expect(pac.trim().endsWith('}')).toBe(true);
});
it('reports deterministic conflicts and winner', () => {
const preview = previewProxyRules('https://api.example.test/', rules, profiles, { defaultProfileId: 'direct', failMode: 'closed' });
expect(preview.conflict).toBe(true);
expect(preview.matchedRuleIds).toEqual(['high', 'low']);
expect(preview.effectiveProfileId).toBe('mitm');
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview } from '@/types/models';
function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
if (profile.kind === 'direct') return 'DIRECT';
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能嵌套到规则 PAC 中`);
const host = profile.host || '127.0.0.1';
const port = profile.port || 8083;
const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}`
: profile.scheme === 'socks5' ? `SOCKS5 ${host}:${port}`
: profile.scheme === 'https' ? `HTTPS ${host}:${port}` : `PROXY ${host}:${port}`;
return failMode === 'open' ? `${proxy}; DIRECT` : proxy;
}
function pacLiteral(value: string): string {
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
}
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
return [...rules].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
}
function pacCondition(rawPattern: string): string {
const pattern = rawPattern.trim();
if (!pattern) return '';
if (pattern.includes('://') || pattern.includes('/')) return `shExpMatch(url, ${pacLiteral(pattern)})`;
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2);
return `(host === ${pacLiteral(domain)} || dnsDomainIs(host, ${pacLiteral(`.${domain}`)}))`;
}
if (pattern.includes('*') || pattern.includes('?')) return `shExpMatch(host, ${pacLiteral(pattern)})`;
return `(host === ${pacLiteral(pattern)} || dnsDomainIs(host, ${pacLiteral(`.${pattern}`)}))`;
}
export function compileProxyRules(
rules: ProxyRule[],
profiles: ProxyProfile[],
routing: ProxyRoutingSettings = { defaultProfileId: 'direct', failMode: 'closed' },
): string {
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
const branches = sortedProxyRules(rules)
.filter((rule) => rule.enabled && rule.patterns.length > 0)
.flatMap((rule) => {
const profile = profileMap.get(rule.proxyProfileId);
if (!profile) return [];
const conditions = rule.patterns.map(pacCondition).filter(Boolean);
return conditions.length > 0 ? [` // ${rule.name} [priority=${rule.priority}]\n if (${conditions.join(' || ')}) return ${pacLiteral(profileToPac(profile, routing.failMode))};`] : [];
});
const fallback = profileMap.get(routing.defaultProfileId) || profileMap.get('direct');
return `function FindProxyForURL(url, host) {\n${branches.join('\n')}\n return ${pacLiteral(fallback ? profileToPac(fallback, routing.failMode) : 'DIRECT')};\n}`;
}
function wildcardRegexp(pattern: string): RegExp {
return new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`, 'i');
}
export function proxyPatternMatches(rawPattern: string, rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
const pattern = rawPattern.trim();
if (!pattern) return false;
if (pattern.includes('://') || pattern.includes('/')) return wildcardRegexp(pattern).test(rawUrl);
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2).toLowerCase();
return url.hostname.toLowerCase() === domain || url.hostname.toLowerCase().endsWith(`.${domain}`);
}
if (pattern.includes('*') || pattern.includes('?')) return wildcardRegexp(pattern).test(url.hostname);
return url.hostname.toLowerCase() === pattern.toLowerCase() || url.hostname.toLowerCase().endsWith(`.${pattern.toLowerCase()}`);
} catch {
return false;
}
}
export function previewProxyRules(
url: string,
rules: ProxyRule[],
profiles: ProxyProfile[],
routing: ProxyRoutingSettings,
): ProxyRulePreview {
const matches = sortedProxyRules(rules).filter((rule) => rule.enabled && rule.patterns.some((pattern) => proxyPatternMatches(pattern, url)));
const profileIds = [...new Set(matches.map((rule) => rule.proxyProfileId))];
const effectiveProfileId = matches[0]?.proxyProfileId || routing.defaultProfileId;
const profile = profiles.find((item) => item.id === effectiveProfileId) || profiles.find((item) => item.id === 'direct')!;
return {
url,
matchedRuleIds: matches.map((rule) => rule.id),
effectiveRuleId: matches[0]?.id,
effectiveProfileId: profile.id,
effectiveProxy: profileToPac(profile, routing.failMode),
conflict: profileIds.length > 1,
conflictProfileIds: profileIds,
};
}
+170
View File
@@ -0,0 +1,170 @@
import { browser } from 'wxt/browser';
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY, PROXY_STATS_STORAGE_KEY } from '@/protocol/storage';
import type {
ExtensionState, ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview, ProxyRuleStats,
} from '@/types/models';
import { getState, updateState } from '@/platform/storage/state';
import {
compileProxyRules, previewProxyRules, proxyPatternMatches, sortedProxyRules,
} from './compiler';
export { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
function isFirefox(): boolean {
return Boolean(import.meta.env.FIREFOX);
}
function chromeProxyValue(profile: ProxyProfile): object {
if (profile.kind === 'direct') return { mode: 'direct' };
if (profile.kind === 'system') return { mode: 'system' };
if (profile.kind === 'pac_script') {
return {
mode: 'pac_script',
pacScript: profile.pacScript
? { data: profile.pacScript, mandatory: true }
: { url: profile.pacUrl, mandatory: true },
};
}
return {
mode: 'fixed_servers',
rules: {
singleProxy: {
scheme: profile.scheme || 'http',
host: profile.host || '127.0.0.1',
port: profile.port || 8083,
},
bypassList: profile.bypass,
},
};
}
function firefoxProxyValue(profile: ProxyProfile): object {
if (profile.kind === 'direct') return { proxyType: 'none' };
if (profile.kind === 'system') return { proxyType: 'system' };
if (profile.kind === 'pac_script') {
return profile.pacUrl
? { proxyType: 'autoConfig', autoConfigUrl: profile.pacUrl }
: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(profile.pacScript || '')}` };
}
if (profile.scheme === 'socks4' || profile.scheme === 'socks5') {
return {
proxyType: 'manual',
socks: `${profile.host}:${profile.port}`,
socksVersion: profile.scheme === 'socks4' ? 4 : 5,
proxyDNS: true,
passthrough: profile.bypass.join(', '),
};
}
const address = `${profile.scheme || 'http'}://${profile.host}:${profile.port}`;
return { proxyType: 'manual', http: address, ssl: address, httpProxyAll: true, passthrough: profile.bypass.join(', ') };
}
export async function switchProxy(profileId: string): Promise<void> {
const state = await getState();
const profile = state.proxyProfiles.find((item) => item.id === profileId);
if (!profile) throw new Error('代理配置不存在');
if (!browser.proxy?.settings) throw new Error('当前浏览器不支持代理 API');
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
await updateState((current) => ({ ...current, activeProxyId: profileId }));
}
export async function applyProxyRules(): Promise<void> {
const state = await getState();
const pacScript = compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting);
if (isFirefox()) {
await browser.proxy.settings.set({
value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig,
scope: 'regular',
});
} else {
await browser.proxy.settings.set({
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
scope: 'regular',
});
}
await updateState((current) => ({ ...current, activeProxyId: 'rules' }));
}
interface StorageArea {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
const authPasswords = new Map<string, string>();
const ruleStats = new Map<string, ProxyRuleStats>();
let routingState: ExtensionState | undefined;
let statsTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
void getState().then((state) => { routingState = state; }).catch(() => undefined);
if (sessionStorage) {
void sessionStorage.get(PROXY_AUTH_STORAGE_KEY).then((stored) => {
const values = stored[PROXY_AUTH_STORAGE_KEY];
if (values && typeof values === 'object') for (const [id, password] of Object.entries(values)) if (typeof password === 'string') authPasswords.set(id, password);
}).catch(() => undefined);
void sessionStorage.get(PROXY_STATS_STORAGE_KEY).then((stored) => {
const values = stored[PROXY_STATS_STORAGE_KEY];
if (Array.isArray(values)) for (const item of values) {
const stat = item as ProxyRuleStats;
if (typeof stat.ruleId === 'string' && Number.isFinite(stat.hits)) ruleStats.set(stat.ruleId, stat);
}
}).catch(() => undefined);
}
browser.storage.onChanged.addListener((changes) => {
if (isStateStorageChange(changes)) void getState().then((state) => { routingState = state; }).catch(() => undefined);
});
function persistStats(): void {
if (!sessionStorage || statsTimer) return;
statsTimer = globalThis.setTimeout(() => {
statsTimer = undefined;
void sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [...ruleStats.values()] }).catch(() => undefined);
}, 1_000);
}
browser.webRequest.onBeforeRequest.addListener((details) => {
const state = routingState;
if (!state || state.activeProxyId !== 'rules') return;
const rule = sortedProxyRules(state.proxyRules).find((item) => item.enabled && item.patterns.some((pattern) => proxyPatternMatches(pattern, details.url)));
if (!rule) return;
const current = ruleStats.get(rule.id) || { ruleId: rule.id, hits: 0 };
ruleStats.set(rule.id, { ...current, hits: current.hits + 1, lastHitAt: Date.now(), lastUrl: details.url.slice(0, 2_048) });
persistStats();
}, { urls: ['<all_urls>'] });
browser.webRequest.onAuthRequired.addListener((details, asyncCallback) => {
const state = routingState;
const profile = state?.proxyProfiles.find((item) => item.id === state.activeProxyId);
const password = profile && authPasswords.get(profile.id);
const response = details.isProxy && profile?.authEnabled && profile.authUsername && password
? { authCredentials: { username: profile.authUsername, password } }
: {};
if (asyncCallback) {
asyncCallback(response);
return undefined;
}
return response;
}, { urls: ['<all_urls>'] }, [isFirefox() ? 'blocking' : 'asyncBlocking']);
export async function setProxyAuthPassword(profileId: string, password: string): Promise<void> {
if (password) authPasswords.set(profileId, password);
else authPasswords.delete(profileId);
if (sessionStorage) await sessionStorage.set({ [PROXY_AUTH_STORAGE_KEY]: Object.fromEntries(authPasswords) });
}
export function hasProxyAuthPassword(profileId: string): boolean {
return authPasswords.has(profileId);
}
export function getProxyRuleStats(): ProxyRuleStats[] {
return [...ruleStats.values()].sort((left, right) => right.hits - left.hits);
}
export async function clearProxyRuleStats(): Promise<void> {
ruleStats.clear();
if (sessionStorage) await sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [] });
}