mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
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:
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope, parseBridgePairingEnvelope, parseCapabilityParams,
|
||||
} from './bridge';
|
||||
|
||||
describe('Bridge v3 protocol', () => {
|
||||
it('accepts an identified hello_ack', () => {
|
||||
expect(parseBridgeEnvelope({
|
||||
type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, version: 'test', capabilities: [],
|
||||
sessionId: 'session-1', engineIdentityId: 'engine-identity-1', engineInstanceId: 'engine-1', connectionId: 'connection-1', resumed: true,
|
||||
})).toMatchObject({ type: 'hello_ack', resumed: true });
|
||||
});
|
||||
|
||||
it('rejects mismatched versions and missing identities', () => {
|
||||
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: 1, capabilities: [] })).toThrow('不兼容');
|
||||
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, capabilities: [] })).toThrow('engineIdentityId');
|
||||
});
|
||||
|
||||
it('validates engine challenges and pairing responses', () => {
|
||||
const publicKey = { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' } as const;
|
||||
expect(parseBridgeEnvelope({
|
||||
type: 'challenge', protocolVersion: BRIDGE_PROTOCOL_VERSION, engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
|
||||
challenge: 'challenge-1', signature: 'signature-1', timestamp: Date.now(), publicKey,
|
||||
})).toMatchObject({ type: 'challenge', engineIdentityId: 'identity-1' });
|
||||
expect(parseBridgePairingEnvelope({
|
||||
type: 'pair_pending', protocolVersion: BRIDGE_PROTOCOL_VERSION, requestId: 'request-1', serverNonce: 'server-nonce',
|
||||
engineIdentityId: 'identity-1', code: '123456', expiresAt: Date.now() + 60_000, publicKey,
|
||||
})).toMatchObject({ type: 'pair_pending', code: '123456' });
|
||||
});
|
||||
|
||||
it('validates heartbeat and chunk boundaries', () => {
|
||||
expect(parseBridgeEnvelope({ type: 'pong', id: 'p1', sequence: 3, timestamp: 100 })).toMatchObject({ sequence: 3 });
|
||||
expect(() => parseBridgeEnvelope({ type: 'ping' })).toThrow('心跳');
|
||||
expect(parseBridgeEnvelope({
|
||||
type: 'chunk', transferId: 't1', index: 0, total: 2, data: 'eA==', originalBytes: 2,
|
||||
})).toMatchObject({ transferId: 't1' });
|
||||
expect(() => parseBridgeEnvelope({
|
||||
type: 'chunk', transferId: 't1', index: 2, total: 2, data: 'eA==', originalBytes: 2,
|
||||
})).toThrow('序号');
|
||||
});
|
||||
|
||||
it('requires explicit Eval mode and caps raw payloads', () => {
|
||||
expect(parseCapabilityParams('browser.eval', { mode: 'expression', code: 'document.title' })).toMatchObject({ mode: 'expression' });
|
||||
expect(() => parseCapabilityParams('browser.eval', { code: 'document.title' })).toThrow('mode');
|
||||
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import * as v from 'valibot';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgePublicKey } from '@/types/models';
|
||||
|
||||
export const BRIDGE_PROTOCOL_VERSION = 3;
|
||||
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
export const BRIDGE_CHUNK_THRESHOLD_BYTES = 512 * 1024;
|
||||
export const BRIDGE_CHUNK_BYTES = 256 * 1024;
|
||||
export const BRIDGE_MAX_CHUNK_TRANSFERS = 8;
|
||||
export const BRIDGE_CHUNK_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface BridgePairingEnvelope {
|
||||
type: 'pair_request' | 'pair_pending' | 'pair_approved' | 'pair_rejected' | 'pair_expired' | 'pair_error';
|
||||
protocolVersion?: number;
|
||||
requestId?: string;
|
||||
installationId?: string;
|
||||
client?: string;
|
||||
version?: string;
|
||||
nonce?: string;
|
||||
serverNonce?: string;
|
||||
publicKey?: BridgePublicKey;
|
||||
engineIdentityId?: string;
|
||||
code?: string;
|
||||
expiresAt?: number;
|
||||
deviceId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
|
||||
const optionalTabId = v.optional(tabId);
|
||||
const optionalFrameId = v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0)));
|
||||
const optionalDocumentId = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)));
|
||||
const targetFields = { tabId: optionalTabId, frameId: optionalFrameId, documentId: optionalDocumentId };
|
||||
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
|
||||
|
||||
const capabilityParams = {
|
||||
'system.ping': v.optional(v.strictObject({})),
|
||||
'browser.tabs': v.optional(v.strictObject({})),
|
||||
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
|
||||
'browser.context': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
includeDom: v.optional(v.boolean()),
|
||||
includeStorage: v.optional(v.boolean()),
|
||||
includeCookies: v.optional(v.boolean()),
|
||||
})),
|
||||
'browser.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
|
||||
'browser.node.action': v.pipe(v.strictObject({
|
||||
...targetFields,
|
||||
captureId,
|
||||
nodeId,
|
||||
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
|
||||
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
|
||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
||||
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
||||
'browser.handoff.request': v.strictObject({
|
||||
...targetFields,
|
||||
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
|
||||
message: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(500)), ''),
|
||||
}),
|
||||
'browser.handoff.status': v.optional(v.strictObject({})),
|
||||
'browser.network.start': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
captureHeaders: v.optional(v.boolean()),
|
||||
captureBody: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
|
||||
})),
|
||||
'browser.network.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.network.list': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
|
||||
})),
|
||||
'browser.network.clear': v.optional(v.strictObject(targetFields)),
|
||||
'browser.network.stop': v.optional(v.strictObject(targetFields)),
|
||||
'browser.network.export': v.strictObject({ ...targetFields, id }),
|
||||
'browser.network.poc': v.strictObject({ ...targetFields, id }),
|
||||
'browser.network.analysis': v.strictObject({ ...targetFields, id }),
|
||||
'browser.observe.start': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
captureValues: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
|
||||
})),
|
||||
'browser.observe.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.observe.list': v.optional(v.strictObject({
|
||||
...targetFields,
|
||||
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
|
||||
})),
|
||||
'browser.observe.clear': v.optional(v.strictObject(targetFields)),
|
||||
'browser.observe.stop': v.optional(v.strictObject(targetFields)),
|
||||
'browser.invoke': v.strictObject({
|
||||
...targetFields,
|
||||
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
args: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(1_000)), []),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
|
||||
}),
|
||||
'browser.eval': v.strictObject({
|
||||
...targetFields,
|
||||
mode: v.picklist(['expression', 'program']),
|
||||
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
|
||||
}),
|
||||
'proxy.list': v.optional(v.strictObject({})),
|
||||
'proxy.switch': v.strictObject({ id }),
|
||||
} satisfies Record<string, v.GenericSchema>;
|
||||
|
||||
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
|
||||
return issues.map((issue) => {
|
||||
const path = v.getDotPath(issue);
|
||||
return `${path ? `${path}: ` : ''}${issue.message}`;
|
||||
}).join('; ');
|
||||
}
|
||||
|
||||
export function parseCapabilityParams(method: string, input: unknown): Record<string, unknown> {
|
||||
const schema = capabilityParams[method as keyof typeof capabilityParams];
|
||||
if (!schema) throw new Error(`不支持的 Bridge 方法: ${method}`);
|
||||
const result = v.safeParse(schema, input);
|
||||
if (!result.success) throw new Error(`Bridge 方法 ${method} 的参数无效: ${issueMessage(result.issues)}`);
|
||||
return (result.output || {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
|
||||
let input = raw;
|
||||
if (typeof raw === 'string') {
|
||||
if (new TextEncoder().encode(raw).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
|
||||
input = JSON.parse(raw) as unknown;
|
||||
} else {
|
||||
const encoded = JSON.stringify(raw);
|
||||
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 消息必须是对象');
|
||||
const message = input as Record<string, unknown>;
|
||||
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
|
||||
|
||||
if (message.type === 'challenge') {
|
||||
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
|
||||
for (const key of ['engineIdentityId', 'engineInstanceId', 'challenge', 'signature'] as const) {
|
||||
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge ${key} 无效`);
|
||||
}
|
||||
if (!Number.isSafeInteger(message.timestamp) || Number(message.timestamp) <= 0) throw new Error('Bridge challenge 时间无效');
|
||||
parseBridgePublicKey(message.publicKey);
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
|
||||
if (message.type === 'hello_ack') {
|
||||
if (!Number.isSafeInteger(message.protocolVersion) || message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) {
|
||||
throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
|
||||
}
|
||||
if (message.version !== undefined && typeof message.version !== 'string') throw new Error('Bridge 引擎版本无效');
|
||||
if (!Array.isArray(message.capabilities) || message.capabilities.some((item) => typeof item !== 'string')) {
|
||||
throw new Error('Bridge 能力列表无效');
|
||||
}
|
||||
for (const key of ['engineIdentityId', 'engineInstanceId', 'connectionId', 'sessionId'] as const) {
|
||||
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 160) throw new Error(`Bridge ${key} 无效`);
|
||||
}
|
||||
if (message.resumed !== undefined && typeof message.resumed !== 'boolean') throw new Error('Bridge resumed 状态无效');
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
if (message.type === 'request') {
|
||||
if (typeof message.id !== 'string' || !message.id || message.id.length > 160) throw new Error('Bridge 请求 ID 无效');
|
||||
if (typeof message.method !== 'string' || !message.method || message.method.length > 160) throw new Error('Bridge 请求方法无效');
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
if (message.type === 'ping' || message.type === 'pong' || message.type === 'cancel') {
|
||||
if (message.id !== undefined && typeof message.id !== 'string') throw new Error('Bridge 心跳 ID 无效');
|
||||
if (message.type === 'cancel' && !message.id) throw new Error('Bridge cancel 缺少请求 ID');
|
||||
if ((message.type === 'ping' || message.type === 'pong') && (!Number.isSafeInteger(message.sequence) || typeof message.timestamp !== 'number')) throw new Error('Bridge 心跳序号或时间无效');
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
if (message.type === 'chunk') {
|
||||
if (typeof message.transferId !== 'string' || !message.transferId || message.transferId.length > 160) throw new Error('Bridge chunk transferId 无效');
|
||||
if (!Number.isSafeInteger(message.index) || !Number.isSafeInteger(message.total) || Number(message.index) < 0 || Number(message.total) < 1 || Number(message.total) > 128 || Number(message.index) >= Number(message.total)) throw new Error('Bridge chunk 序号无效');
|
||||
if (typeof message.data !== 'string' || message.data.length > 384 * 1024) throw new Error('Bridge chunk 数据无效');
|
||||
if (!Number.isSafeInteger(message.originalBytes) || Number(message.originalBytes) < 1 || Number(message.originalBytes) > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge chunk 原始大小无效');
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
if (message.type === 'response') {
|
||||
if (message.id !== undefined && (typeof message.id !== 'string' || !message.id || message.id.length > 160)) {
|
||||
throw new Error('Bridge 响应 ID 无效');
|
||||
}
|
||||
if (!message.id && !message.error) throw new Error('Bridge 响应缺少 ID');
|
||||
if (message.error !== undefined) {
|
||||
if (!message.error || typeof message.error !== 'object') throw new Error('Bridge 响应错误对象无效');
|
||||
const responseError = message.error as Record<string, unknown>;
|
||||
if (typeof responseError.code !== 'string' || typeof responseError.message !== 'string') throw new Error('Bridge 响应错误格式无效');
|
||||
}
|
||||
return message as unknown as BridgeEnvelope;
|
||||
}
|
||||
throw new Error(`不支持的 Bridge 消息类型: ${message.type}`);
|
||||
}
|
||||
|
||||
function parseBridgePublicKey(input: unknown): BridgePublicKey {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 公钥无效');
|
||||
const key = input as Record<string, unknown>;
|
||||
if (key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string') {
|
||||
throw new Error('Bridge 公钥必须使用 ECDSA P-256');
|
||||
}
|
||||
if (!key.x || !key.y || key.x.length > 128 || key.y.length > 128) throw new Error('Bridge 公钥坐标无效');
|
||||
return key as unknown as BridgePublicKey;
|
||||
}
|
||||
|
||||
export function parseBridgePairingEnvelope(raw: unknown): BridgePairingEnvelope {
|
||||
let input = raw;
|
||||
if (typeof raw === 'string') {
|
||||
if (new TextEncoder().encode(raw).byteLength > 32 * 1024) throw new Error('Bridge 配对消息超过 32 KiB 限制');
|
||||
input = JSON.parse(raw) as unknown;
|
||||
}
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 配对消息必须是对象');
|
||||
const message = input as Record<string, unknown>;
|
||||
const allowed = ['pair_pending', 'pair_approved', 'pair_rejected', 'pair_expired', 'pair_error'];
|
||||
if (typeof message.type !== 'string' || !allowed.includes(message.type)) throw new Error('Bridge 配对消息类型无效');
|
||||
if (message.message !== undefined && (typeof message.message !== 'string' || message.message.length > 1_024)) throw new Error('Bridge 配对消息文本无效');
|
||||
if (message.type === 'pair_pending') {
|
||||
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error('Bridge 配对协议版本不兼容');
|
||||
for (const key of ['requestId', 'serverNonce', 'engineIdentityId', 'code'] as const) {
|
||||
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
|
||||
}
|
||||
if (!/^\d{6}$/.test(String(message.code))) throw new Error('Bridge 配对验证码无效');
|
||||
if (!Number.isSafeInteger(message.expiresAt) || Number(message.expiresAt) <= Date.now()) throw new Error('Bridge 配对申请已经过期');
|
||||
parseBridgePublicKey(message.publicKey);
|
||||
}
|
||||
if (message.type === 'pair_approved') {
|
||||
for (const key of ['requestId', 'deviceId', 'engineIdentityId'] as const) {
|
||||
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
|
||||
}
|
||||
parseBridgePublicKey(message.publicKey);
|
||||
}
|
||||
return message as unknown as BridgePairingEnvelope;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CapabilityScope } from '@/types/models';
|
||||
|
||||
export const BRIDGE_CAPABILITIES = [
|
||||
'system.ping',
|
||||
'browser.tabs',
|
||||
'browser.frames',
|
||||
'browser.context',
|
||||
'browser.node.inspect',
|
||||
'browser.node.action',
|
||||
'browser.cookies',
|
||||
'browser.takeover',
|
||||
'browser.handoff.request',
|
||||
'browser.handoff.status',
|
||||
'browser.network.start',
|
||||
'browser.network.status',
|
||||
'browser.network.list',
|
||||
'browser.network.clear',
|
||||
'browser.network.stop',
|
||||
'browser.network.export',
|
||||
'browser.network.poc',
|
||||
'browser.network.analysis',
|
||||
'browser.observe.start',
|
||||
'browser.observe.status',
|
||||
'browser.observe.list',
|
||||
'browser.observe.clear',
|
||||
'browser.observe.stop',
|
||||
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ? ['browser.invoke', 'browser.eval'] : []),
|
||||
'proxy.list',
|
||||
'proxy.switch',
|
||||
] as const;
|
||||
|
||||
export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
'browser.tabs.read',
|
||||
'browser.dom.read',
|
||||
'browser.storage.read',
|
||||
'browser.cookies.read',
|
||||
'browser.network.read',
|
||||
'browser.observation.read',
|
||||
];
|
||||
|
||||
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
|
||||
...READ_CAPABILITY_SCOPES,
|
||||
'browser.dom.write',
|
||||
'browser.tab.activate',
|
||||
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
|
||||
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
|
||||
: []),
|
||||
'browser.human.takeover',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
'browser.observation.control',
|
||||
'browser.observation.sensitive.read',
|
||||
'browser.proxy.read',
|
||||
'browser.proxy.write',
|
||||
];
|
||||
|
||||
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
|
||||
'browser.tabs.read': '标签页列表',
|
||||
'browser.dom.read': '页面 DOM',
|
||||
'browser.dom.write': '操作页面元素',
|
||||
'browser.storage.read': '页面 Storage',
|
||||
'browser.cookies.read': 'Cookie',
|
||||
'browser.tab.activate': '切到前台',
|
||||
'browser.page.invoke': '调用页面函数',
|
||||
'browser.page.eval.expression': '执行页面表达式',
|
||||
'browser.page.eval.program': '执行页面程序',
|
||||
'browser.human.takeover': '人工接管',
|
||||
'browser.network.read': '读取网络摘要',
|
||||
'browser.network.capture': '控制网络捕获',
|
||||
'browser.network.sensitive.read': '读取请求头与请求体',
|
||||
'browser.observation.read': '读取页面行为观测',
|
||||
'browser.observation.control': '控制页面行为观测',
|
||||
'browser.observation.sensitive.read': '读取观测值预览',
|
||||
'browser.proxy.read': '读取代理',
|
||||
'browser.proxy.write': '切换代理',
|
||||
};
|
||||
|
||||
export function isControlScopeSet(scopes: readonly CapabilityScope[]): boolean {
|
||||
return scopes.some((scope) => !READ_CAPABILITY_SCOPES.includes(scope));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseExtensionRequest } from './extension';
|
||||
|
||||
describe('extension request schemas', () => {
|
||||
it('rejects unknown fields', () => {
|
||||
expect(() => parseExtensionRequest({ action: 'panel.update', payload: { enabled: true, unexpected: true } })).toThrow('unexpected');
|
||||
});
|
||||
|
||||
it('accepts split panel policy and explicit Eval mode', () => {
|
||||
expect(parseExtensionRequest({
|
||||
action: 'panel.update',
|
||||
payload: { displayMode: 'active-task', siteMode: 'denylist', siteOrigins: ['https://example.test'] },
|
||||
}).action).toBe('panel.update');
|
||||
expect(parseExtensionRequest({
|
||||
action: 'context.eval', payload: { mode: 'program', code: '1 + 1', timeoutMs: 500 },
|
||||
}).action).toBe('context.eval');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import * as v from 'valibot';
|
||||
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
|
||||
import type { CapabilityScope } from '@/types/models';
|
||||
|
||||
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const shortText = v.pipe(v.string(), v.trim(), v.maxLength(240));
|
||||
const url = v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192));
|
||||
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
|
||||
const frameId = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
|
||||
const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
|
||||
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
|
||||
const targetFields = { tabId: v.optional(tabId), frameId: v.optional(frameId), documentId: v.optional(documentId) };
|
||||
const port = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(65_535));
|
||||
const proxyHost = v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minLength(1),
|
||||
v.maxLength(253),
|
||||
v.regex(/^[a-zA-Z0-9._:[\]-]+$/, '代理主机只能包含主机名或 IP 地址字符'),
|
||||
);
|
||||
const httpUrl = v.pipe(
|
||||
url,
|
||||
v.check((value) => ['http:', 'https:'].includes(new URL(value).protocol), '只允许 HTTP(S) URL'),
|
||||
);
|
||||
const noPayload = v.optional(v.undefined_());
|
||||
const stringList = (maxItems = 200, maxLength = 2_048) => v.pipe(
|
||||
v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(maxLength))),
|
||||
v.maxLength(maxItems),
|
||||
);
|
||||
|
||||
const proxyProfile = v.pipe(v.strictObject({
|
||||
id,
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
kind: v.picklist(['direct', 'system', 'fixed_servers', 'pac_script']),
|
||||
host: v.optional(proxyHost),
|
||||
port: v.optional(port),
|
||||
scheme: v.optional(v.picklist(['http', 'https', 'socks4', 'socks5'])),
|
||||
pacUrl: v.optional(httpUrl),
|
||||
pacScript: v.optional(v.pipe(v.string(), v.maxLength(1_000_000))),
|
||||
bypass: stringList(500, 2_048),
|
||||
builtin: v.optional(v.boolean()),
|
||||
authEnabled: v.optional(v.boolean()),
|
||||
authUsername: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
|
||||
}), v.check((profile) => {
|
||||
if (profile.kind === 'fixed_servers') return Boolean(profile.host && profile.port && profile.scheme);
|
||||
if (profile.kind === 'pac_script') return Boolean(profile.pacUrl || profile.pacScript?.trim());
|
||||
return true;
|
||||
}, '代理配置缺少当前类型所需的主机、端口或 PAC 内容'));
|
||||
|
||||
const proxyRule = v.strictObject({
|
||||
id,
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
enabled: v.boolean(),
|
||||
patterns: stringList(500, 2_048),
|
||||
proxyProfileId: id,
|
||||
priority: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000)),
|
||||
});
|
||||
|
||||
const proxyRouting = v.strictObject({
|
||||
defaultProfileId: id,
|
||||
failMode: v.picklist(['open', 'closed']),
|
||||
});
|
||||
|
||||
const proxyConfiguration = v.strictObject({
|
||||
version: v.literal(1),
|
||||
profiles: v.pipe(v.array(proxyProfile), v.minLength(1), v.maxLength(500)),
|
||||
rules: v.pipe(v.array(proxyRule), v.maxLength(5_000)),
|
||||
routing: proxyRouting,
|
||||
});
|
||||
|
||||
const userAgentRule = v.strictObject({
|
||||
id,
|
||||
name: v.pipe(shortText, v.minLength(1)),
|
||||
enabled: v.boolean(),
|
||||
userAgent: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
|
||||
domains: stringList(500, 253),
|
||||
});
|
||||
|
||||
const bridgeConfig = v.strictObject({
|
||||
transport: v.picklist(['native', 'websocket']),
|
||||
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
|
||||
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
|
||||
autoConnect: v.boolean(),
|
||||
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
|
||||
pairedEngine: v.optional(v.strictObject({
|
||||
engineIdentityId: id,
|
||||
deviceId: id,
|
||||
publicKey: v.strictObject({
|
||||
kty: v.literal('EC'),
|
||||
crv: v.literal('P-256'),
|
||||
x: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
|
||||
y: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
|
||||
}),
|
||||
pairedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(1)),
|
||||
})),
|
||||
});
|
||||
|
||||
const partitionKey = v.strictObject({
|
||||
topLevelSite: v.optional(httpUrl),
|
||||
hasCrossSiteAncestor: v.optional(v.boolean()),
|
||||
});
|
||||
|
||||
const cookieInput = v.strictObject({
|
||||
url,
|
||||
name: v.pipe(v.string(), v.maxLength(4_096)),
|
||||
value: v.pipe(v.string(), v.maxLength(64 * 1_024)),
|
||||
domain: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(253))),
|
||||
path: v.optional(v.pipe(v.string(), v.maxLength(4_096))),
|
||||
secure: v.optional(v.boolean()),
|
||||
httpOnly: v.optional(v.boolean()),
|
||||
sameSite: v.optional(v.picklist(['no_restriction', 'lax', 'strict', 'unspecified'])),
|
||||
expirationDate: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0))),
|
||||
storeId: v.optional(shortText),
|
||||
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
|
||||
partitionKey: v.optional(partitionKey),
|
||||
});
|
||||
|
||||
const cookieRemoveInput = v.strictObject({
|
||||
url,
|
||||
name: v.pipe(v.string(), v.maxLength(4_096)),
|
||||
storeId: v.optional(shortText),
|
||||
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
|
||||
partitionKey: v.optional(partitionKey),
|
||||
});
|
||||
|
||||
const contextOptions = {
|
||||
includeStorage: v.optional(v.boolean()),
|
||||
includeCookies: v.optional(v.boolean()),
|
||||
includeDom: v.optional(v.boolean()),
|
||||
tabId: v.optional(tabId),
|
||||
frameId: v.optional(frameId),
|
||||
documentId: v.optional(documentId),
|
||||
};
|
||||
|
||||
const capabilityScopes: readonly CapabilityScope[] = [
|
||||
'browser.tabs.read',
|
||||
'browser.dom.read',
|
||||
'browser.dom.write',
|
||||
'browser.storage.read',
|
||||
'browser.cookies.read',
|
||||
'browser.tab.activate',
|
||||
'browser.page.invoke',
|
||||
'browser.page.eval.expression',
|
||||
'browser.page.eval.program',
|
||||
'browser.human.takeover',
|
||||
'browser.network.read',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
'browser.observation.read',
|
||||
'browser.observation.control',
|
||||
'browser.observation.sensitive.read',
|
||||
'browser.proxy.read',
|
||||
'browser.proxy.write',
|
||||
];
|
||||
|
||||
const payloadSchemas = {
|
||||
'state.get': noPayload,
|
||||
'tab.active': noPayload,
|
||||
'tab.get': v.strictObject({ tabId }),
|
||||
'tab.list': noPayload,
|
||||
'frame.list': v.strictObject({ tabId }),
|
||||
'proxy.save': proxyProfile,
|
||||
'proxy.delete': v.strictObject({ id }),
|
||||
'proxy.switch': v.strictObject({ id }),
|
||||
'proxy.rule.save': proxyRule,
|
||||
'proxy.rule.delete': v.strictObject({ id }),
|
||||
'proxy.rules.apply': noPayload,
|
||||
'proxy.rules.preview': v.strictObject({ url: httpUrl }),
|
||||
'proxy.rules.compile': noPayload,
|
||||
'proxy.rules.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(5_000)) }),
|
||||
'proxy.rules.settings': proxyRouting,
|
||||
'proxy.rules.stats': noPayload,
|
||||
'proxy.rules.stats.clear': noPayload,
|
||||
'proxy.auth.set': v.strictObject({ profileId: id, password: v.pipe(v.string(), v.maxLength(4_096)) }),
|
||||
'proxy.auth.status': v.strictObject({ profileId: id }),
|
||||
'proxy.config.export': noPayload,
|
||||
'proxy.config.import': v.strictObject({ configuration: proxyConfiguration }),
|
||||
'cookie.list': v.strictObject({ url }),
|
||||
'cookie.set': cookieInput,
|
||||
'cookie.remove': cookieRemoveInput,
|
||||
'cookie.removeMany': v.strictObject({ cookies: v.pipe(v.array(cookieRemoveInput), v.minLength(1), v.maxLength(1_000)) }),
|
||||
'cookie.import': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
|
||||
'cookie.export': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
|
||||
'ua.save': userAgentRule,
|
||||
'ua.delete': v.strictObject({ id }),
|
||||
'ua.apply': noPayload,
|
||||
'context.capture': v.strictObject(contextOptions),
|
||||
'context.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
|
||||
'context.node.action': v.pipe(v.strictObject({
|
||||
...targetFields,
|
||||
captureId,
|
||||
nodeId,
|
||||
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
|
||||
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
|
||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||
'context.invoke': v.strictObject({
|
||||
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
args: v.pipe(v.array(v.unknown()), v.maxLength(1_000)),
|
||||
tabId: v.optional(tabId),
|
||||
frameId: v.optional(frameId),
|
||||
documentId: v.optional(documentId),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
|
||||
}),
|
||||
'context.eval': v.strictObject({
|
||||
mode: v.picklist(['expression', 'program']),
|
||||
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
|
||||
tabId: v.optional(tabId),
|
||||
frameId: v.optional(frameId),
|
||||
documentId: v.optional(documentId),
|
||||
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
|
||||
}),
|
||||
'panel.update': v.strictObject({
|
||||
enabled: v.optional(v.boolean()),
|
||||
side: v.optional(v.picklist(['left', 'right'])),
|
||||
y: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(1))),
|
||||
displayMode: v.optional(v.picklist(['always', 'active-task'])),
|
||||
siteMode: v.optional(v.picklist(['all', 'allowlist', 'denylist'])),
|
||||
siteOrigins: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.url(), v.maxLength(2_048))), v.maxLength(500))),
|
||||
shortcutEnabled: v.optional(v.boolean()),
|
||||
autoCollapseFullscreen: v.optional(v.boolean()),
|
||||
}),
|
||||
'grant.create': v.strictObject({
|
||||
targets: v.pipe(v.array(v.strictObject({ tabId, frameId })), v.minLength(1), v.maxLength(256)),
|
||||
scopes: v.pipe(v.array(v.picklist(capabilityScopes)), v.minLength(1), v.maxLength(capabilityScopes.length)),
|
||||
durationMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(24 * 60)),
|
||||
taskId: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))),
|
||||
}),
|
||||
'grant.revoke': noPayload,
|
||||
'handoff.resolve': v.strictObject({ id, outcome: v.picklist(['completed', 'cancelled']) }),
|
||||
'network.capture.start': v.strictObject({
|
||||
...targetFields,
|
||||
captureHeaders: v.optional(v.boolean()),
|
||||
captureBody: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
|
||||
}),
|
||||
'network.capture.status': v.strictObject(targetFields),
|
||||
'network.capture.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
|
||||
'network.capture.clear': v.strictObject(targetFields),
|
||||
'network.capture.stop': v.strictObject(targetFields),
|
||||
'network.capture.export': v.strictObject({ ...targetFields, id }),
|
||||
'network.capture.send': v.strictObject({ ...targetFields, id }),
|
||||
'network.capture.poc': v.strictObject({ ...targetFields, id }),
|
||||
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
|
||||
'observation.start': v.strictObject({
|
||||
...targetFields,
|
||||
captureValues: v.optional(v.boolean()),
|
||||
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
|
||||
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
|
||||
}),
|
||||
'observation.status': v.strictObject(targetFields),
|
||||
'observation.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
|
||||
'observation.clear': v.strictObject(targetFields),
|
||||
'observation.stop': v.strictObject(targetFields),
|
||||
'audit.list': v.strictObject({ limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
|
||||
'audit.clear': noPayload,
|
||||
'agent.runtime.get': noPayload,
|
||||
'agent.pause': noPayload,
|
||||
'agent.resume': noPayload,
|
||||
'agent.actions.clear': noPayload,
|
||||
'policy.status': noPayload,
|
||||
'diagnostics.export': noPayload,
|
||||
'metrics.get': noPayload,
|
||||
'metrics.reset': noPayload,
|
||||
'bridge.config.save': bridgeConfig,
|
||||
'bridge.pair': noPayload,
|
||||
'bridge.pair.cancel': noPayload,
|
||||
'bridge.pair.status': noPayload,
|
||||
'bridge.unpair': noPayload,
|
||||
'bridge.connect': noPayload,
|
||||
'bridge.disconnect': noPayload,
|
||||
'bridge.status': noPayload,
|
||||
} satisfies Record<ExtensionAction, v.GenericSchema>;
|
||||
|
||||
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
|
||||
return issues.map((issue) => {
|
||||
const path = v.getDotPath(issue);
|
||||
return `${path ? `${path}: ` : ''}${issue.message}`;
|
||||
}).join('; ');
|
||||
}
|
||||
|
||||
export function parseExtensionRequest(input: unknown): ExtensionRequest {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('扩展消息必须是对象');
|
||||
const record = input as Record<string, unknown>;
|
||||
if (Object.keys(record).some((key) => key !== 'action' && key !== 'payload')) throw new Error('扩展消息包含未知字段');
|
||||
if (typeof record.action !== 'string' || !(record.action in payloadSchemas)) throw new Error('未知扩展操作');
|
||||
const action = record.action as ExtensionAction;
|
||||
const result = v.safeParse(payloadSchemas[action], record.payload);
|
||||
if (!result.success) throw new Error(`操作 ${action} 的参数无效: ${issueMessage(result.issues)}`);
|
||||
return { action, payload: result.output } as ExtensionRequest;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const PROXY_SETTINGS_STORAGE_KEY = 'settings.proxy.v1';
|
||||
export const USER_AGENT_SETTINGS_STORAGE_KEY = 'settings.user-agent.v1';
|
||||
export const BRIDGE_SETTINGS_STORAGE_KEY = 'settings.bridge.v2';
|
||||
export const FLOATING_UI_STORAGE_KEY = 'ui.floating-panel.v1';
|
||||
export const ACTIVE_SESSION_STORAGE_KEY = 'session.browser-agent.v1';
|
||||
export const BRIDGE_SESSION_STORAGE_KEY = 'session.bridge.v1';
|
||||
export const AGENT_RUNTIME_STORAGE_KEY = 'session.agent-runtime.v1';
|
||||
export const STATE_STORAGE_KEYS = [
|
||||
PROXY_SETTINGS_STORAGE_KEY,
|
||||
USER_AGENT_SETTINGS_STORAGE_KEY,
|
||||
BRIDGE_SETTINGS_STORAGE_KEY,
|
||||
FLOATING_UI_STORAGE_KEY,
|
||||
ACTIVE_SESSION_STORAGE_KEY,
|
||||
BRIDGE_SESSION_STORAGE_KEY,
|
||||
AGENT_RUNTIME_STORAGE_KEY,
|
||||
] as const;
|
||||
|
||||
export function isStateStorageChange(changes: Record<string, unknown>): boolean {
|
||||
return STATE_STORAGE_KEYS.some((key) => key in changes);
|
||||
}
|
||||
export const AUDIT_STORAGE_KEY = 'yakit-audit-log-v1';
|
||||
export const NETWORK_CAPTURE_STORAGE_KEY = 'yakit-network-capture-v1';
|
||||
export const CONTEXT_DIGEST_STORAGE_KEY = 'yakit-context-digests-v1';
|
||||
export const PAGE_LIFECYCLE_STORAGE_KEY = 'yakit-page-lifecycle-v1';
|
||||
export const PROXY_AUTH_STORAGE_KEY = 'yakit-proxy-auth-v1';
|
||||
export const PROXY_STATS_STORAGE_KEY = 'yakit-proxy-stats-v1';
|
||||
export const RUNTIME_METRICS_STORAGE_KEY = 'runtime.metrics.v1';
|
||||
Reference in New Issue
Block a user