feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+333 -1
View File
@@ -26,6 +26,18 @@ describe('Bridge v3 protocol', () => {
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' });
expect(() => parseBridgePairingEnvelope({
type: 'pair_pending', protocolVersion: BRIDGE_PROTOCOL_VERSION, requestId: 'request-1', serverNonce: 'server-nonce',
engineIdentityId: 'identity-1', code: '123456', expiresAt: Date.now() + 6 * 60_000, publicKey,
})).toThrow('有效期异常');
});
it('rejects undeclared bridge and pairing fields', () => {
expect(() => parseBridgeEnvelope({ type: 'response', id: 'r1', result: {}, legacy: true })).toThrow('$.legacy');
expect(() => parseBridgeEnvelope({
type: 'response', id: 'r1', error: { code: 'failed', message: 'failed', legacy: true },
})).toThrow('$.error.legacy');
expect(() => parseBridgePairingEnvelope({ type: 'pair_error', message: 'failed', legacy: true })).toThrow('$.legacy');
});
it('validates heartbeat and chunk boundaries', () => {
@@ -53,12 +65,332 @@ describe('Bridge v3 protocol', () => {
})).toMatchObject({ matcher: { kind: 'boundary', eventKind: 'worker' } });
});
it('validates browser identity isolation capabilities without accepting an unbounded tab query', () => {
expect(parseCapabilityParams('browser.isolation.inspect', {
tabIds: [12, 13],
})).toEqual({ tabIds: [12, 13] });
expect(parseCapabilityParams('browser.isolation.proof', {
leftTabId: 12,
rightTabId: 13,
})).toEqual({ leftTabId: 12, rightTabId: 13 });
expect(parseCapabilityParams('browser.isolation.incognito.open', {
url: 'https://example.test/login',
})).toEqual({ url: 'https://example.test/login' });
expect(() => parseCapabilityParams('browser.isolation.inspect', { tabIds: [] })).toThrow();
expect(() => parseCapabilityParams('browser.isolation.proof', {
leftTabId: 12,
rightTabId: 12,
})).toThrow();
expect(() => parseCapabilityParams('browser.isolation.incognito.open', {
url: 'chrome://extensions',
})).toThrow();
});
it('binds authorization context capture to a proof, slot and exact document target', () => {
expect(parseCapabilityParams('browser.authorization.context.capture', {
tabId: 12,
frameId: 0,
documentId: 'document-1',
isolationProofId: 'proof-1',
slotId: 'left',
accountLabel: '低权限账号',
})).toEqual({
tabId: 12,
frameId: 0,
documentId: 'document-1',
isolationProofId: 'proof-1',
slotId: 'left',
accountLabel: '低权限账号',
});
expect(parseCapabilityParams('browser.authorization.context.get', {
id: 'auth-context-1',
})).toEqual({ id: 'auth-context-1' });
expect(parseCapabilityParams('browser.authorization.context.attest', {
tabId: 12,
frameId: 0,
documentId: 'document-1',
})).toEqual({
tabId: 12,
frameId: 0,
documentId: 'document-1',
});
expect(parseCapabilityParams('browser.authorization.context.attestation.get', {
id: 'attestation-1',
})).toEqual({ id: 'attestation-1' });
expect(parseCapabilityParams('browser.isolation.container.open', {
url: 'https://example.test/login',
name: '身份 B',
})).toEqual({
url: 'https://example.test/login',
name: '身份 B',
});
expect(parseCapabilityParams('browser.isolation.container.list', {})).toEqual({});
expect(parseCapabilityParams('browser.isolation.container.remove', {
cookieStoreId: 'firefox-container-7',
})).toEqual({ cookieStoreId: 'firefox-container-7' });
expect(parseCapabilityParams('browser.authorization.baseline.capture', {
tabId: 12,
frameId: 0,
documentId: 'document-1',
authContextKind: 'handle',
authContextId: 'auth-context-1',
networkRequestId: 'network-request-1',
comparisonKey: 'A'.repeat(43),
})).toMatchObject({
authContextKind: 'handle',
authContextId: 'auth-context-1',
networkRequestId: 'network-request-1',
});
expect(parseCapabilityParams('browser.authorization.baseline.get', {
id: 'baseline-1',
})).toEqual({ id: 'baseline-1' });
expect(parseCapabilityParams('browser.authorization.baseline.logical.bind', {
id: 'baseline-1',
profileId: 'profile-left',
comparisonKey: 'A'.repeat(43),
})).toEqual({
id: 'baseline-1',
profileId: 'profile-left',
comparisonKey: 'A'.repeat(43),
});
expect(parseCapabilityParams('browser.authorization.baseline.candidates', {
tabId: 12,
frameId: 0,
authContextKind: 'attestation',
authContextId: 'attestation-1',
limit: 50,
})).toMatchObject({
authContextKind: 'attestation',
authContextId: 'attestation-1',
limit: 50,
});
expect(parseCapabilityParams('browser.authorization.baseline.resource.get', {
id: 'baseline-1',
selector: { source: 'wire', location: 'query', path: 'query.orderId' },
})).toEqual({
id: 'baseline-1',
selector: { source: 'wire', location: 'query', path: 'query.orderId' },
});
expect(parseCapabilityParams('browser.authorization.baseline.compile', {
id: 'baseline-1',
selector: { source: 'wire', location: 'query', path: 'query.orderId' },
replacement: {
version: 1,
baselineId: 'baseline-2',
source: 'wire',
location: 'query',
path: 'query.orderId',
valueType: 'string',
byteLength: 2,
valueBase64: 'NDI=',
valueFingerprint: `workspace-hmac-sha256:${'a'.repeat(64)}`,
},
comparisonKey: 'A'.repeat(43),
})).toMatchObject({
id: 'baseline-1',
replacement: { baselineId: 'baseline-2', valueBase64: 'NDI=' },
});
expect(parseCapabilityParams('browser.authorization.baseline.compile', {
id: 'baseline-1',
selector: {
source: 'wire',
location: 'body',
path: 'body.variables.orderId',
},
replacement: {
version: 1,
baselineId: 'baseline-2',
source: 'wire',
location: 'body',
path: 'body.variables.orderId',
valueType: 'number',
byteLength: 2,
valueBase64: 'ODQ=',
valueFingerprint: `workspace-hmac-sha256:${'b'.repeat(64)}`,
},
comparisonKey: 'A'.repeat(43),
})).toMatchObject({
replacement: { valueType: 'number', valueBase64: 'ODQ=' },
});
expect(parseCapabilityParams('browser.authorization.baseline.transform.inspect', {
id: 'baseline-1',
profileId: 'profile-left',
})).toEqual({
id: 'baseline-1',
profileId: 'profile-left',
});
expect(parseCapabilityParams('browser.authorization.baseline.packet.compile', {
id: 'baseline-1',
})).toEqual({ id: 'baseline-1' });
expect(parseCapabilityParams('browser.authorization.baseline.transform.compile', {
id: 'baseline-1',
selector: { source: 'wire', location: 'query', path: 'query.orderId' },
replacement: {
version: 1,
baselineId: 'baseline-2',
source: 'wire',
location: 'query',
path: 'query.orderId',
valueType: 'string',
byteLength: 2,
valueBase64: 'NDI=',
valueFingerprint: `workspace-hmac-sha256:${'a'.repeat(64)}`,
},
comparisonKey: 'A'.repeat(43),
profileId: 'profile-left',
bindingFingerprint: `sha256:${'b'.repeat(64)}`,
})).toMatchObject({
id: 'baseline-1',
profileId: 'profile-left',
bindingFingerprint: `sha256:${'b'.repeat(64)}`,
});
expect(() => parseCapabilityParams('browser.authorization.context.capture', {
tabId: 12,
isolationProofId: 'proof-1',
slotId: 'middle',
})).toThrow();
expect(() => parseCapabilityParams('browser.isolation.container.remove', {
cookieStoreId: 'firefox-default',
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.context.get', {
id: '',
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.context.attestation.get', {
id: '',
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.baseline.capture', {
tabId: 12,
authContextKind: 'handle',
authContextId: 'auth-context-1',
networkRequestId: 'network-request-1',
comparisonKey: 'short',
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.baseline.candidates', {
tabId: 12,
authContextKind: 'handle',
authContextId: 'auth-context-1',
limit: 201,
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.baseline.resource.get', {
id: 'baseline-1',
selector: { location: 'body', path: 'body.orderId' },
})).toThrow();
expect(() => parseCapabilityParams('browser.authorization.baseline.compile', {
id: 'baseline-1',
selector: { source: 'wire', location: 'query', path: 'query.orderId' },
replacement: {
version: 1,
baselineId: 'baseline-2',
source: 'wire',
location: 'query',
path: 'query.orderId',
valueType: 'string',
byteLength: 2,
valueBase64: 'not base64',
valueFingerprint: `workspace-hmac-sha256:${'a'.repeat(64)}`,
},
comparisonKey: 'A'.repeat(43),
})).toThrow();
});
it('accepts automatic selected-frame capture and rejects the legacy expression contract', () => {
expect(parseCapabilityParams('browser.callable.create', {
source: 'deep-capture', strategy: 'selected-frame', callFrameId: 'frame-1', name: 'Envelope',
})).toMatchObject({ strategy: 'selected-frame', callFrameId: 'frame-1' });
candidateId: 'candidate-envelope',
})).toMatchObject({ strategy: 'selected-frame', callFrameId: 'frame-1', candidateId: 'candidate-envelope' });
expect(() => parseCapabilityParams('browser.callable.create', {
source: 'deep-capture', callFrameId: 'frame-1', name: 'Envelope', functionExpression: 'buildEnvelope',
})).toThrow();
});
it('accepts only an evidence candidate reference for request-transaction capture', () => {
expect(parseCapabilityParams('browser.callable.create', {
tabId: 12,
frameId: 0,
source: 'deep-capture',
strategy: 'request-transaction',
callFrameId: 'frame-1',
name: 'Login envelope',
candidateId: 'candidate-login',
})).toMatchObject({
strategy: 'request-transaction',
candidateId: 'candidate-login',
});
expect(() => parseCapabilityParams('browser.callable.create', {
source: 'deep-capture', strategy: 'request-transaction', callFrameId: 'frame-1',
candidateId: 'candidate-login', transaction: {},
})).toThrow();
});
it('validates the bounded browser crypto Agent tool contracts', () => {
const packet = {
method: 'POST',
url: 'https://example.test/login',
headers: [{ name: 'Content-Type', value: 'application/json' }],
bodyBase64: 'e30=',
};
expect(parseCapabilityParams('browser.recording.trace.list', {
tabId: 12, frameId: 0, limit: 20,
})).toMatchObject({ limit: 20 });
expect(parseCapabilityParams('browser.recording.evidence.inspect', {
tabId: 12, traceId: 'trace-1', includeValues: false,
})).toMatchObject({ traceId: 'trace-1', includeValues: false });
expect(parseCapabilityParams('browser.callable.inspect', {
tabId: 12, callableId: 'callable-1',
})).toMatchObject({ callableId: 'callable-1' });
expect(parseCapabilityParams('browser.callable.replay', {
tabId: 12, callableId: 'callable-1', args: [{ username: 'admin' }],
})).toMatchObject({ callableId: 'callable-1' });
expect(parseCapabilityParams('browser.packet.compare', {
tabId: 12, actual: packet, expected: packet, mode: 'structure',
})).toMatchObject({ mode: 'structure' });
expect(parseCapabilityParams('browser.profile.propose', {
tabId: 12,
candidateId: 'candidate-1',
callableId: 'callable-1',
inputPaths: ['body'],
})).toMatchObject({ candidateId: 'candidate-1', callableId: 'callable-1' });
expect(parseCapabilityParams('browser.profile.validation.latest', {
tabId: 12, frameId: 0, documentId: 'document-1',
})).toMatchObject({ tabId: 12, documentId: 'document-1' });
expect(parseCapabilityParams('browser.profile.validate', {
tabId: 12,
candidateId: 'candidate-1',
callableId: 'callable-1',
inputPaths: ['body'],
packet,
})).toMatchObject({
tabId: 12,
candidateId: 'candidate-1',
callableId: 'callable-1',
});
expect(parseCapabilityParams('browser.transform.recovery.start', {
id: 'profile-1',
})).toMatchObject({ id: 'profile-1' });
expect(parseCapabilityParams('browser.transform.recovery.capture', {
id: 'profile-1',
tabId: 12,
frameId: 0,
documentId: 'document-2',
callFrameId: 'frame-1',
strategy: 'request-transaction',
})).toMatchObject({
id: 'profile-1',
documentId: 'document-2',
strategy: 'request-transaction',
});
expect(parseCapabilityParams('browser.transform.recovery.validate', {
id: 'profile-1',
packet,
})).toMatchObject({ id: 'profile-1' });
expect(parseCapabilityParams('browser.transform.recovery.confirm', {
id: 'profile-1',
validationId: 'validation-1',
})).toMatchObject({ validationId: 'validation-1' });
expect(() => parseCapabilityParams('browser.profile.validate', {
tabId: 12,
profile: {},
packet,
})).toThrow();
});
});
+179 -3
View File
@@ -1,7 +1,11 @@
import * as v from 'valibot';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
import { browserTransformExecuteSchema, browserTransformProfileInputSchema } from './transform';
import {
browserTransformExecuteSchema,
browserTransformPacketSchema,
browserTransformProfileInputSchema,
} from './transform';
export const BRIDGE_PROTOCOL_VERSION = 3;
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
@@ -28,7 +32,16 @@ export interface BridgePairingEnvelope {
}
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const comparisonKey = v.pipe(v.string(), v.regex(/^[A-Za-z0-9_-]{43}$/));
const sha256Fingerprint = v.pipe(v.string(), v.regex(/^sha256:[a-f0-9]{64}$/));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const httpUrl = v.pipe(
v.string(),
v.trim(),
v.url(),
v.maxLength(8_192),
v.check((value) => ['http:', 'https:'].includes(new URL(value).protocol), '只允许 HTTP(S) URL'),
);
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)));
@@ -65,11 +78,95 @@ const deepCaptureMatcher = v.variant('kind', [
]);
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 = {
const valuePath = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(512));
const authorizationSelector = v.strictObject({
source: v.picklist(['wire', 'logical']),
location: v.picklist(['header', 'path', 'query', 'body']),
path: valuePath,
});
const authorizationResourceValue = v.strictObject({
version: v.literal(1),
baselineId: id,
source: v.picklist(['wire', 'logical']),
location: v.picklist(['header', 'path', 'query', 'body']),
path: valuePath,
valueType: v.picklist(['string', 'number', 'boolean']),
byteLength: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(8 * 1_024)),
valueBase64: v.pipe(v.string(), v.maxLength(11_000), v.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)),
valueFingerprint: v.pipe(v.string(), v.regex(/^workspace-hmac-sha256:[a-f0-9]{64}$/)),
logicalBindingFingerprint: v.optional(sha256Fingerprint),
});
export const capabilityParams = {
'system.ping': v.optional(v.strictObject({})),
'browser.tabs': v.optional(v.strictObject({})),
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
'browser.isolation.inspect': v.optional(v.strictObject({
tabIds: v.optional(v.pipe(v.array(tabId), v.minLength(1), v.maxLength(256))),
})),
'browser.isolation.proof': v.pipe(v.strictObject({
leftTabId: tabId,
rightTabId: tabId,
}), v.check((input) => input.leftTabId !== input.rightTabId, '双身份槽位不能选择同一个标签页')),
'browser.isolation.incognito.open': v.strictObject({ url: httpUrl }),
'browser.isolation.container.open': v.strictObject({
url: httpUrl,
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(50))),
}),
'browser.isolation.container.list': v.optional(v.strictObject({})),
'browser.isolation.container.remove': v.strictObject({
cookieStoreId: v.pipe(v.string(), v.regex(/^firefox-container-[0-9]+$/)),
}),
'browser.authorization.context.capture': v.strictObject({
...targetFields,
isolationProofId: id,
slotId: v.picklist(['left', 'right']),
accountLabel: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(80))),
}),
'browser.authorization.context.get': v.strictObject({ id }),
'browser.authorization.context.attest': v.strictObject(targetFields),
'browser.authorization.context.attestation.get': v.strictObject({ id }),
'browser.authorization.baseline.capture': v.strictObject({
...targetFields,
authContextKind: v.picklist(['handle', 'attestation']),
authContextId: id,
networkRequestId: id,
comparisonKey,
}),
'browser.authorization.baseline.candidates': v.strictObject({
...targetFields,
authContextKind: v.picklist(['handle', 'attestation']),
authContextId: id,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
}),
'browser.authorization.baseline.get': v.strictObject({ id }),
'browser.authorization.baseline.logical.bind': v.strictObject({
id,
profileId: id,
comparisonKey,
}),
'browser.authorization.baseline.resource.get': v.strictObject({
id,
selector: authorizationSelector,
}),
'browser.authorization.baseline.compile': v.strictObject({
id,
selector: authorizationSelector,
replacement: authorizationResourceValue,
comparisonKey,
}),
'browser.authorization.baseline.packet.compile': v.strictObject({ id }),
'browser.authorization.baseline.transform.inspect': v.strictObject({
id,
profileId: id,
}),
'browser.authorization.baseline.transform.compile': v.strictObject({
id,
selector: authorizationSelector,
replacement: authorizationResourceValue,
comparisonKey,
profileId: id,
bindingFingerprint: sha256Fingerprint,
}),
'browser.context': v.optional(v.strictObject({
...targetFields,
includeDom: v.optional(v.boolean()),
@@ -122,6 +219,16 @@ const capabilityParams = {
})),
'browser.recording.clear': v.optional(v.strictObject(targetFields)),
'browser.recording.stop': v.optional(v.strictObject(targetFields)),
'browser.recording.trace.list': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(100))),
})),
'browser.recording.evidence.inspect': v.strictObject({
...targetFields,
traceId: id,
eventId: v.optional(id),
includeValues: v.optional(v.boolean()),
}),
'browser.callable.create': v.union([
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
@@ -131,6 +238,13 @@ const capabilityParams = {
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('selected-frame'),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
candidateId: v.optional(id),
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('request-transaction'),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
candidateId: id,
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
@@ -142,6 +256,36 @@ const capabilityParams = {
'browser.callable.list': v.optional(v.strictObject(targetFields)),
'browser.callable.execute': v.strictObject({ ...targetFields, callableId: id, args: v.pipe(v.array(v.unknown()), v.maxLength(64)) }),
'browser.callable.delete': v.strictObject({ ...targetFields, callableId: id }),
'browser.callable.inspect': v.strictObject({ ...targetFields, callableId: v.optional(id) }),
'browser.callable.replay': v.strictObject({
...targetFields,
callableId: id,
args: v.pipe(v.array(v.unknown()), v.maxLength(64)),
}),
'browser.packet.compare': v.strictObject({
...targetFields,
actual: browserTransformPacketSchema,
expected: browserTransformPacketSchema,
mode: v.optional(v.picklist(['structure', 'exact'])),
}),
'browser.profile.propose': v.strictObject({
...targetFields,
candidateId: id,
callableId: id,
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
}),
'browser.profile.validation.latest': v.optional(v.strictObject(targetFields)),
'browser.profile.validate': v.strictObject({
...targetFields,
candidateId: id,
callableId: id,
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
packet: browserTransformPacketSchema,
observed: v.optional(browserTransformPacketSchema),
comparisonMode: v.optional(v.picklist(['structure', 'exact'])),
}),
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
@@ -150,6 +294,20 @@ const capabilityParams = {
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
'browser.transform.profile.save': browserTransformProfileInputSchema,
'browser.transform.profile.delete': v.strictObject({ id }),
'browser.transform.recovery.get': v.strictObject({ id }),
'browser.transform.recovery.start': v.strictObject({ id }),
'browser.transform.recovery.capture': v.strictObject({
id,
...targetFields,
callFrameId: id,
strategy: v.picklist(['selected-frame', 'request-transaction']),
}),
'browser.transform.recovery.validate': v.strictObject({
id,
packet: browserTransformPacketSchema,
}),
'browser.transform.recovery.confirm': v.strictObject({ id, validationId: id }),
'browser.transform.recovery.reset': v.strictObject({ id }),
'browser.transform.execute': browserTransformExecuteSchema,
'browser.invoke': v.strictObject({
...targetFields,
@@ -193,6 +351,15 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 消息必须是对象');
const message = input as Record<string, unknown>;
const allowedKeys = new Set([
'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion',
'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId',
'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId',
'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index',
'total', 'data', 'originalBytes',
]);
const unexpected = Object.keys(message).find((key) => !allowedKeys.has(key));
if (unexpected) throw new Error(`Bridge 消息包含未声明字段 $.${unexpected}`);
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
if (message.type === 'challenge') {
@@ -245,6 +412,8 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
if (message.error !== undefined) {
if (!message.error || typeof message.error !== 'object') throw new Error('Bridge 响应错误对象无效');
const responseError = message.error as Record<string, unknown>;
const unexpectedError = Object.keys(responseError).find((key) => !['code', 'message', 'data'].includes(key));
if (unexpectedError) throw new Error(`Bridge 响应错误包含未声明字段 $.error.${unexpectedError}`);
if (typeof responseError.code !== 'string' || typeof responseError.message !== 'string') throw new Error('Bridge 响应错误格式无效');
}
return message as unknown as BridgeEnvelope;
@@ -270,6 +439,12 @@ export function parseBridgePairingEnvelope(raw: unknown): BridgePairingEnvelope
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 配对消息必须是对象');
const message = input as Record<string, unknown>;
const pairingKeys = new Set([
'type', 'protocolVersion', 'requestId', 'installationId', 'client', 'version', 'nonce',
'serverNonce', 'publicKey', 'engineIdentityId', 'code', 'expiresAt', 'deviceId', 'message',
]);
const unexpected = Object.keys(message).find((key) => !pairingKeys.has(key));
if (unexpected) throw new Error(`Bridge 配对消息包含未声明字段 $.${unexpected}`);
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 配对消息文本无效');
@@ -280,6 +455,7 @@ export function parseBridgePairingEnvelope(raw: unknown): BridgePairingEnvelope
}
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 配对申请已经过期');
if (Number(message.expiresAt) - Date.now() > 5 * 60_000) throw new Error('Bridge 配对申请有效期异常');
parseBridgePublicKey(message.publicKey);
}
if (message.type === 'pair_approved') {
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import {
BRIDGE_CAPABILITIES,
canonicalCapabilityCatalogPayload,
capabilityBaseScope,
getBridgeCapabilityCatalog,
} from './capabilities';
describe('versioned Bridge capability catalog', () => {
it('derives every advertised method from one schema-backed catalog', async () => {
const catalog = await getBridgeCapabilityCatalog();
expect(catalog.version).toBe(1);
expect(catalog.schemaDialect).toBe('http://json-schema.org/draft-07/schema#');
expect(catalog.hash).toMatch(/^[a-f0-9]{64}$/);
expect(catalog.capabilities.map((capability) => capability.method)).toEqual(BRIDGE_CAPABILITIES);
expect(new Set(catalog.capabilities.map((capability) => capability.method)).size).toBe(BRIDGE_CAPABILITIES.length);
expect(new TextEncoder().encode(JSON.stringify(catalog)).byteLength).toBeLessThan(1024 * 1024);
});
it('publishes the real validation constraints and target semantics', async () => {
const catalog = await getBridgeCapabilityCatalog();
const evalCapability = catalog.capabilities.find((capability) => capability.method === 'browser.eval');
expect(evalCapability).toMatchObject({
domain: 'page',
access: 'dangerous',
scopes: ['browser.page.eval.expression'],
targetMode: 'document',
defaultTimeoutMs: 60_000,
});
expect(evalCapability?.conditionalScopes).toContainEqual({
scope: 'browser.page.eval.program',
when: 'mode=program',
});
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.recovery.capture')).toMatchObject({
access: 'dangerous',
scopes: ['browser.transform.manage', 'browser.debugger.control', 'browser.callable.execute'],
targetMode: 'profile',
});
expect(capabilityBaseScope('browser.transform.recovery.validate')).toBe('browser.transform.execute');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.proof')).toMatchObject({
domain: 'isolation',
access: 'sensitive-read',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.incognito.open')).toMatchObject({
domain: 'isolation',
access: 'dangerous',
scopes: ['browser.isolation.manage'],
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.context.capture')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.context.get')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.context.attest')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.context.attestation.get')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.capture')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.get')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.logical.bind')).toMatchObject({
domain: 'authorization',
access: 'dangerous',
scopes: expect.arrayContaining(['browser.network.sensitive.read', 'browser.transform.execute']),
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.candidates')).toMatchObject({
domain: 'authorization',
access: 'read',
scopes: ['browser.network.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.resource.get')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.compile')).toMatchObject({
domain: 'authorization',
access: 'dangerous',
scopes: ['browser.network.replay', 'browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.packet.compile')).toMatchObject({
domain: 'authorization',
access: 'dangerous',
scopes: ['browser.network.replay', 'browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.transform.inspect')).toMatchObject({
domain: 'authorization',
access: 'sensitive-read',
scopes: expect.arrayContaining(['browser.transform.read']),
targetMode: 'none',
});
expect(catalog.capabilities.find((capability) => capability.method === 'browser.authorization.baseline.transform.compile')).toMatchObject({
domain: 'authorization',
access: 'dangerous',
scopes: expect.arrayContaining(['browser.network.replay', 'browser.transform.execute']),
targetMode: 'none',
});
expect(capabilityBaseScope('browser.authorization.baseline.compile')).toBe('browser.network.replay');
expect(capabilityBaseScope('browser.authorization.baseline.packet.compile')).toBe('browser.network.replay');
expect(capabilityBaseScope('browser.authorization.baseline.transform.compile')).toBe('browser.network.replay');
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.container.open')).toBeUndefined();
expect(capabilityBaseScope('missing.capability')).toBeUndefined();
});
it('keeps canonical payloads stable and excludes the transmitted hash', async () => {
const catalog = await getBridgeCapabilityCatalog();
const payload = canonicalCapabilityCatalogPayload({
version: catalog.version,
schemaDialect: catalog.schemaDialect,
capabilities: catalog.capabilities,
});
expect(payload).not.toContain(catalog.hash);
expect(payload).toBe(canonicalCapabilityCatalogPayload({
capabilities: catalog.capabilities,
schemaDialect: catalog.schemaDialect,
version: catalog.version,
}));
});
it('matches the Go canonical JSON and SHA-256 test vector', async () => {
const payload = canonicalCapabilityCatalogPayload({
version: 1,
schemaDialect: 'http://json-schema.org/draft-07/schema#',
capabilities: [{
method: 'browser.tabs',
domain: 'page',
access: 'read',
summary: 'List tabs',
scopes: ['browser.tabs.read'],
targetMode: 'none',
defaultTimeoutMs: 20_000,
paramsSchema: { type: 'object', properties: {}, additionalProperties: false },
}],
});
expect(payload).toBe(
'{"capabilities":[{"access":"read","defaultTimeoutMs":20000,"domain":"page","method":"browser.tabs","paramsSchema":{"additionalProperties":false,"properties":{},"type":"object"},"scopes":["browser.tabs.read"],"summary":"List tabs","targetMode":"none"}],"schemaDialect":"http://json-schema.org/draft-07/schema#","version":1}',
);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
expect(hash).toBe('82bc1210338e773137b95359ca0b39c0443bae3d9fdae33f97942cf82119006f');
});
});
+440 -46
View File
@@ -1,62 +1,392 @@
import { toJsonSchema } from '@valibot/to-json-schema';
import type {
BridgeCapabilityAccess,
BridgeCapabilityCatalog,
BridgeCapabilityDescriptor,
BridgeCapabilityDomain,
BridgeCapabilityScopeCondition,
BridgeCapabilityTargetMode,
} from '@/types/messages';
import type { CapabilityScope } from '@/types/models';
import { capabilityParams } from './bridge';
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.recording.start',
'browser.recording.status',
'browser.recording.get',
'browser.recording.clear',
'browser.recording.stop',
'browser.callable.create',
'browser.callable.list',
'browser.callable.execute',
'browser.callable.delete',
...(!import.meta.env.FIREFOX ? [
'browser.deep_capture.start',
'browser.deep_capture.status',
'browser.deep_capture.keepalive',
'browser.deep_capture.resume',
'browser.deep_capture.detach',
'browser.transform.profile.list',
'browser.transform.profile.save',
'browser.transform.profile.delete',
'browser.transform.execute',
] : []),
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ? ['browser.invoke', 'browser.eval'] : []),
'proxy.list',
'proxy.switch',
] as const;
const CAPABILITY_SCHEMA_VERSION = 1;
const CAPABILITY_SCHEMA_DIALECT = 'http://json-schema.org/draft-07/schema#' as const;
const READ_TIMEOUT_MS = 20_000;
const REPLAY_TIMEOUT_MS = 60_000;
export type BridgeCapabilityMethod = keyof typeof capabilityParams;
interface CapabilityMetadata {
domain: BridgeCapabilityDomain;
access: BridgeCapabilityAccess;
summary: string;
scopes: CapabilityScope[];
conditionalScopes?: BridgeCapabilityScopeCondition[];
targetMode: BridgeCapabilityTargetMode;
defaultTimeoutMs: number;
}
const CAPABILITY_METADATA = {
'system.ping': {
domain: 'system', access: 'read', summary: '检查插件可用性与版本',
scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.tabs': {
domain: 'page', access: 'read', summary: '列出当前 grant 明确共享的标签页',
scopes: ['browser.tabs.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.inspect': {
domain: 'isolation', access: 'read', summary: '读取共享标签页的 Cookie Store 与身份隔离上下文',
scopes: ['browser.isolation.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.proof': {
domain: 'isolation', access: 'sensitive-read', summary: '生成有时限的身份隔离证明,并对同 Cookie Store 的 Tab 执行认证存储预检',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.incognito.open': {
domain: 'isolation', access: 'dangerous', summary: '在同一插件安装下创建独立无痕身份页面',
scopes: ['browser.isolation.manage'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.container.open': {
domain: 'isolation', access: 'dangerous', summary: '创建由 Yakit 管理的临时 Firefox Container 身份页面',
scopes: ['browser.isolation.manage'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.container.list': {
domain: 'isolation', access: 'read', summary: '列出由 Yakit 创建且仍然存在的临时 Firefox Container',
scopes: ['browser.isolation.manage'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.container.remove': {
domain: 'isolation', access: 'dangerous', summary: '关闭并删除由 Yakit 创建的临时 Firefox Container',
scopes: ['browser.isolation.manage'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.context.capture': {
domain: 'authorization', access: 'sensitive-read', summary: '为隔离身份生成不含原始凭据的短时认证上下文句柄',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.context.get': {
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前共享会话中的短时认证上下文句柄',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.context.attest': {
domain: 'authorization', access: 'sensitive-read', summary: '为单个隔离页面生成不含原始凭据的跨设备认证证明',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.context.attestation.get': {
domain: 'authorization', access: 'sensitive-read', summary: '实时复核跨设备认证证明及其目标文档',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.capture': {
domain: 'authorization', access: 'sensitive-read', summary: '将已捕获请求封存为不暴露凭据值的短时授权基线',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.candidates': {
domain: 'authorization', access: 'read', summary: '列出不包含 Header 或 Body 值的授权基线请求候选',
scopes: ['browser.network.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.get': {
domain: 'authorization', access: 'sensitive-read', summary: '实时复核授权基线及其认证上下文',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.logical.bind': {
domain: 'authorization', access: 'dangerous', summary: '在本机验证明文网关生成结构,并将短时逻辑字段 HMAC 绑定到授权基线',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read', 'browser.transform.execute'],
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.authorization.baseline.resource.get': {
domain: 'authorization', access: 'sensitive-read', summary: '读取已确认资源选择器的单个短时值,用于跨身份矩阵',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.compile': {
domain: 'authorization', access: 'dangerous', summary: '在实时复核身份后编译一次供 Yak 受限执行器使用的短时请求',
scopes: ['browser.network.replay', 'browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.authorization.baseline.packet.compile': {
domain: 'authorization', access: 'dangerous', summary: '在实时复核身份后编译不可变的完整操作模板或认证骨架',
scopes: ['browser.network.replay', 'browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.authorization.baseline.transform.inspect': {
domain: 'authorization', access: 'sensitive-read', summary: '验证身份页面的明文网关是否完整覆盖授权请求动态字段',
scopes: ['browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read', 'browser.transform.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.baseline.transform.compile': {
domain: 'authorization', access: 'dangerous', summary: '在发起身份自己的页面环境重算签名、Nonce 与时间字段后编译请求',
scopes: ['browser.network.replay', 'browser.network.sensitive.read', 'browser.isolation.read', 'browser.cookies.read', 'browser.storage.read', 'browser.transform.execute'],
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.frames': {
domain: 'page', access: 'read', summary: '列出共享标签页中的 Frame',
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.context': {
domain: 'page', access: 'sensitive-read', summary: '采集 DOM、表单、认证信号及可选 Storage/Cookie 上下文',
scopes: ['browser.dom.read'],
conditionalScopes: [
{ scope: 'browser.storage.read', when: 'includeStorage=true' },
{ scope: 'browser.cookies.read', when: 'includeCookies=true' },
],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.node.inspect': {
domain: 'page', access: 'read', summary: '读取页面上下文快照中的稳定节点',
scopes: ['browser.dom.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.node.action': {
domain: 'page', access: 'write', summary: '点击、聚焦、滚动或填写稳定页面节点',
scopes: ['browser.dom.write'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.cookies': {
domain: 'page', access: 'sensitive-read', summary: '读取目标页面 Cookie,包括已授权的 HttpOnly 值',
scopes: ['browser.cookies.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.takeover': {
domain: 'page', access: 'write', summary: '将目标标签页切换到前台',
scopes: ['browser.tab.activate'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.request': {
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.status': {
domain: 'handoff', access: 'read', summary: '读取当前人工接管状态',
scopes: ['browser.human.takeover'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.start': {
domain: 'network', access: 'control', summary: '启动有界网络捕获,可选采集请求头和 Body',
scopes: ['browser.network.capture'],
conditionalScopes: [{ scope: 'browser.network.sensitive.read', when: 'captureHeaders=true or captureBody=true' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.status': {
domain: 'network', access: 'read', summary: '读取网络捕获状态',
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.list': {
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段继续受 grant 约束',
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.clear': {
domain: 'network', access: 'control', summary: '清空目标页面的网络捕获',
scopes: ['browser.network.capture'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.stop': {
domain: 'network', access: 'control', summary: '停止目标页面的网络捕获',
scopes: ['browser.network.capture'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.export': {
domain: 'network', access: 'sensitive-read', summary: '将已捕获请求导出为完整 HTTP 报文',
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.poc': {
domain: 'network', access: 'execute', summary: '让 Yak 基于已捕获请求生成 PoC',
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.network.analysis': {
domain: 'network', access: 'sensitive-read', summary: '将已捕获请求准备为 Yak AI 分析上下文',
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.recording.start': {
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制,可选采集有界值预览',
scopes: ['browser.recording.control'],
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'captureValues=true' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.status': {
domain: 'recording', access: 'read', summary: '读取录制状态',
scopes: ['browser.recording.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.get': {
domain: 'recording', access: 'read', summary: '读取完整的有界录制快照',
scopes: ['browser.recording.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.clear': {
domain: 'recording', access: 'control', summary: '清空当前录制 Session',
scopes: ['browser.recording.control'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.stop': {
domain: 'recording', access: 'control', summary: '停止并封存当前录制',
scopes: ['browser.recording.control'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.trace.list': {
domain: 'recording', access: 'read', summary: '列出业务 Trace、请求和推断候选元数据',
scopes: ['browser.recording.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.recording.evidence.inspect': {
domain: 'recording', access: 'sensitive-read', summary: '读取 Trace 或事件证据及可选值预览',
scopes: ['browser.recording.read'],
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'includeValues=true' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.create': {
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数',
scopes: ['browser.callable.execute'],
conditionalScopes: [{ scope: 'browser.debugger.control', when: 'source=deep-capture' }],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.list': {
domain: 'callable', access: 'read', summary: '列出当前文档的页面函数',
scopes: ['browser.recording.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.execute': {
domain: 'callable', access: 'execute', summary: '执行文档绑定的页面函数',
scopes: ['browser.callable.execute'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.callable.delete': {
domain: 'callable', access: 'write', summary: '删除页面函数',
scopes: ['browser.callable.execute'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.inspect': {
domain: 'callable', access: 'read', summary: '读取页面函数输入槽、输出契约和来源',
scopes: ['browser.recording.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.callable.replay': {
domain: 'callable', access: 'execute', summary: '使用新参数回放页面函数',
scopes: ['browser.callable.execute'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.deep_capture.start': {
domain: 'debugger', access: 'dangerous', summary: '为密码调用、消息边界或请求武装 Chromium 深度捕获',
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.deep_capture.status': {
domain: 'debugger', access: 'sensitive-read', summary: '读取暂停状态、调用栈和已授权作用域预览',
scopes: ['browser.debugger.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.deep_capture.keepalive': {
domain: 'debugger', access: 'control', summary: '延长暂停页面的交互窗口',
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.deep_capture.resume': {
domain: 'debugger', access: 'control', summary: '恢复页面执行并保留捕获结果',
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.deep_capture.detach': {
domain: 'debugger', access: 'control', summary: '释放扩展拥有的调试会话',
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.profile.list': {
domain: 'transform', access: 'read', summary: '列出目标页面可见的明文网关 Profile',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.profile.save': {
domain: 'transform', access: 'dangerous', summary: '保存或更新完整 Transform Profile',
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.profile.delete': {
domain: 'transform', access: 'write', summary: '删除 Transform Profile',
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.recovery.get': {
domain: 'transform', access: 'read', summary: '读取 Profile 的非敏感文档恢复计划和确定性状态',
scopes: ['browser.transform.read'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.recovery.start': {
domain: 'transform', access: 'dangerous', summary: '按 Profile 恢复计划武装一次性请求边界捕获',
scopes: ['browser.transform.manage', 'browser.debugger.control'],
targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.recovery.capture': {
domain: 'transform', access: 'dangerous', summary: '从暂停现场重新捕获 Profile 的页面函数并保持停用',
scopes: ['browser.transform.manage', 'browser.debugger.control', 'browser.callable.execute'],
targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.recovery.validate': {
domain: 'transform', access: 'execute', summary: '使用当前文档和本地报文验证待恢复 Profile',
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.transform.recovery.confirm': {
domain: 'transform', access: 'dangerous', summary: '确认已验证的恢复结果并替换旧页面绑定',
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.recovery.reset': {
domain: 'transform', access: 'write', summary: '取消当前恢复结果并保持旧 Profile 停用',
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.transform.execute': {
domain: 'transform', access: 'execute', summary: '对 HTTP 报文应用已保存的请求或响应转换',
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.packet.compare': {
domain: 'transform', access: 'read', summary: '按结构或精确模式比较两份 HTTP 报文',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.propose': {
domain: 'transform', access: 'read', summary: '从候选证据和页面函数确定性编译 Profile 提案',
scopes: ['browser.transform.read', 'browser.recording.read'],
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.validation.latest': {
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿',
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.profile.validate': {
domain: 'transform', access: 'execute', summary: '重新编译并执行 Profile,再与候选或报文证据比较',
scopes: ['browser.transform.execute', 'browser.recording.read'],
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.invoke': {
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.eval': {
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 执行表达式或异步程序',
scopes: ['browser.page.eval.expression'],
conditionalScopes: [{ scope: 'browser.page.eval.program', when: 'mode=program' }],
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'proxy.list': {
domain: 'proxy', access: 'read', summary: '列出扩展代理 Profile',
scopes: ['browser.proxy.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'proxy.switch': {
domain: 'proxy', access: 'write', summary: '切换当前代理 Profile',
scopes: ['browser.proxy.write'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
} satisfies Record<BridgeCapabilityMethod, CapabilityMetadata>;
function capabilityAvailable(method: BridgeCapabilityMethod): boolean {
if (method === 'browser.isolation.incognito.open') return !import.meta.env.FIREFOX;
if (method.startsWith('browser.isolation.container.')) return import.meta.env.FIREFOX;
if (method.startsWith('browser.deep_capture.')) return !import.meta.env.FIREFOX;
if (method === 'browser.transform.recovery.start' || method === 'browser.transform.recovery.capture') {
return !import.meta.env.FIREFOX;
}
if (method === 'browser.invoke' || method === 'browser.eval') {
return !(import.meta.env.FIREFOX && import.meta.env.MODE === 'store');
}
return true;
}
export const BRIDGE_CAPABILITIES = (
Object.keys(CAPABILITY_METADATA) as BridgeCapabilityMethod[]
).filter(capabilityAvailable);
export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.tabs.read',
'browser.isolation.read',
'browser.dom.read',
'browser.storage.read',
'browser.cookies.read',
'browser.network.read',
'browser.recording.read',
...(!import.meta.env.FIREFOX ? ['browser.transform.read' as const] : []),
'browser.transform.read',
];
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
...READ_CAPABILITY_SCOPES,
'browser.dom.write',
'browser.isolation.manage',
'browser.tab.activate',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
@@ -64,21 +394,24 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.human.takeover',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.network.replay',
'browser.recording.control',
'browser.recording.sensitive.read',
'browser.callable.execute',
...(!import.meta.env.FIREFOX ? [
'browser.debugger.read' as const,
'browser.debugger.control' as const,
'browser.transform.manage' as const,
'browser.transform.execute' as const,
] : []),
'browser.transform.manage',
'browser.transform.execute',
'browser.proxy.read',
'browser.proxy.write',
];
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.tabs.read': '标签页列表',
'browser.isolation.read': '读取身份隔离状态',
'browser.isolation.manage': '创建隔离身份页面',
'browser.dom.read': '页面 DOM',
'browser.dom.write': '操作页面元素',
'browser.storage.read': '页面 Storage',
@@ -91,6 +424,7 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.network.read': '读取网络摘要',
'browser.network.capture': '控制网络捕获',
'browser.network.sensitive.read': '读取请求头与请求体',
'browser.network.replay': '在页面认证上下文中重放请求',
'browser.recording.read': '读取浏览器录制',
'browser.recording.control': '控制浏览器录制',
'browser.recording.sensitive.read': '读取录制值预览',
@@ -104,6 +438,66 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.proxy.write': '切换代理',
};
export function capabilityBaseScope(method: string): CapabilityScope | undefined {
return CAPABILITY_METADATA[method as BridgeCapabilityMethod]?.scopes[0];
}
export function isControlScopeSet(scopes: readonly CapabilityScope[]): boolean {
return scopes.some((scope) => !READ_CAPABILITY_SCOPES.includes(scope));
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (!value || typeof value !== 'object') return value;
const input = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(input)
.filter((key) => input[key] !== undefined)
.sort()
.map((key) => [key, canonicalize(input[key])]),
);
}
export function canonicalCapabilityCatalogPayload(
catalog: Omit<BridgeCapabilityCatalog, 'hash'>,
): string {
return JSON.stringify(canonicalize(catalog));
}
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function capabilityDescriptor(method: BridgeCapabilityMethod): BridgeCapabilityDescriptor {
const metadata: CapabilityMetadata = CAPABILITY_METADATA[method];
const paramsSchema = toJsonSchema(capabilityParams[method], {
target: 'draft-07',
typeMode: 'input',
errorMode: 'ignore',
}) as Record<string, unknown>;
return {
method,
...metadata,
scopes: [...metadata.scopes],
conditionalScopes: metadata.conditionalScopes?.map((condition) => ({ ...condition })),
paramsSchema,
};
}
let catalogPromise: Promise<BridgeCapabilityCatalog> | undefined;
export function getBridgeCapabilityCatalog(): Promise<BridgeCapabilityCatalog> {
catalogPromise ||= (async () => {
const payload = {
version: CAPABILITY_SCHEMA_VERSION,
schemaDialect: CAPABILITY_SCHEMA_DIALECT,
capabilities: BRIDGE_CAPABILITIES.map(capabilityDescriptor),
};
return {
...payload,
hash: await sha256Hex(canonicalCapabilityCatalogPayload(payload)),
};
})();
return catalogPromise;
}
+152 -15
View File
@@ -29,6 +29,86 @@ describe('extension request schemas', () => {
action: 'grant.create',
payload: { targets: [{ tabId: 12, frameId: 0 }], scopes: ['browser.transform.unknown'], durationMinutes: 5 },
})).toThrow('scopes');
expect(parseExtensionRequest({ action: 'grant.refresh' }).action).toBe('grant.refresh');
expect(() => parseExtensionRequest({
action: 'grant.refresh',
payload: { tabId: 12 },
})).toThrow();
});
it('validates internal isolation actions and target-bound Cookie operations', () => {
expect(parseExtensionRequest({
action: 'isolation.inspect',
payload: { tabIds: [12, 13] },
}).action).toBe('isolation.inspect');
expect(parseExtensionRequest({
action: 'isolation.proof.create',
payload: { leftTabId: 12, rightTabId: 13 },
}).action).toBe('isolation.proof.create');
expect(parseExtensionRequest({
action: 'isolation.incognito.open',
payload: { url: 'https://example.test/login' },
}).action).toBe('isolation.incognito.open');
expect(parseExtensionRequest({
action: 'isolation.container.open',
payload: { url: 'https://example.test/login', name: 'Yakit 身份 B' },
}).action).toBe('isolation.container.open');
expect(parseExtensionRequest({
action: 'isolation.container.list',
}).action).toBe('isolation.container.list');
expect(parseExtensionRequest({
action: 'isolation.container.remove',
payload: { cookieStoreId: 'firefox-container-7' },
}).action).toBe('isolation.container.remove');
expect(() => parseExtensionRequest({
action: 'isolation.container.remove',
payload: { cookieStoreId: 'firefox-default' },
})).toThrow();
expect(parseExtensionRequest({
action: 'cookie.list',
payload: { url: 'https://example.test', tabId: 12 },
}).action).toBe('cookie.list');
expect(() => parseExtensionRequest({
action: 'cookie.list',
payload: { url: 'https://example.test' },
})).toThrow('tabId');
expect(() => parseExtensionRequest({
action: 'isolation.inspect',
payload: { tabIds: [] },
})).toThrow();
});
it('validates browser authorization engine tasks and Yakit handoff', () => {
expect(parseExtensionRequest({
action: 'authorization.engine.task',
payload: {
schema: 'authorization.workspace.create',
payload: {
mode: 'horizontal',
left: { tabId: 12, frameId: 0, accountLabel: 'A' },
right: { tabId: 13, frameId: 0, accountLabel: 'B' },
},
timeoutMs: 60_000,
},
}).action).toBe('authorization.engine.task');
expect(() => parseExtensionRequest({
action: 'authorization.engine.task',
payload: { schema: 'authorization.unknown', payload: {} },
})).toThrow('schema');
expect(parseExtensionRequest({
action: 'authorization.yakit.open',
payload: { workspaceId: 'authorization-workspace-1' },
}).action).toBe('authorization.yakit.open');
expect(parseExtensionRequest({
action: 'network.capture.start',
payload: {
tabId: 12,
frameId: 0,
documentId: 'document-1',
captureHeaders: true,
captureBody: true,
},
}).action).toBe('network.capture.start');
});
it('validates the atomic current-site route reset action', () => {
@@ -65,13 +145,9 @@ describe('extension request schemas', () => {
source: 'deep-capture',
strategy: 'request-transaction',
callFrameId: 'frame-1',
transaction: {
request: { method: 'POST', url: '/login', expectedDestinations: [] },
inputMode: 'auto',
boundaries: ['fetch'],
},
candidateId: '',
},
})).toThrow('expectedDestinations');
})).toThrow();
expect(parseExtensionRequest({
action: 'callable.create',
payload: {
@@ -80,15 +156,7 @@ describe('extension request schemas', () => {
strategy: 'request-transaction',
callFrameId: 'frame-1',
name: 'Login request transaction',
transaction: {
request: {
method: 'POST',
url: 'encrypt/aesrsa.php',
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
},
inputMode: 'auto',
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
},
candidateId: 'candidate-aesrsa',
},
}).action).toBe('callable.create');
expect(() => parseExtensionRequest({
@@ -157,6 +225,7 @@ describe('extension request schemas', () => {
strategy: 'selected-frame',
callFrameId: 'frame-1',
name: 'Login envelope',
candidateId: 'candidate-login',
},
}).action).toBe('callable.create');
expect(parseExtensionRequest({
@@ -206,6 +275,38 @@ describe('extension request schemas', () => {
maxConcurrency: 1,
};
expect(parseExtensionRequest({ action: 'transform.profile.save', payload: profile }).action).toBe('transform.profile.save');
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: { ...profile, requestTransaction: {} },
})).toThrow('requestTransaction');
expect(parseExtensionRequest({
action: 'analysis.profile.propose',
payload: {
tabId: 12,
candidateId: 'candidate-1',
callableId: 'callable-1',
inputPaths: ['body'],
},
}).action).toBe('analysis.profile.propose');
expect(parseExtensionRequest({
action: 'analysis.profile.validate',
payload: {
tabId: 12,
candidateId: 'candidate-1',
callableId: 'callable-1',
inputPaths: ['body'],
packet: {
method: 'POST',
url: 'https://example.test/login',
headers: [],
bodyBase64: 'e30=',
},
},
}).action).toBe('analysis.profile.validate');
expect(parseExtensionRequest({
action: 'analysis.profile.validation.latest',
payload: { tabId: 12, frameId: 0 },
}).action).toBe('analysis.profile.validation.latest');
expect(parseExtensionRequest({
action: 'transform.execute',
payload: {
@@ -213,6 +314,42 @@ describe('extension request schemas', () => {
packet: { method: 'POST', url: 'https://example.test/api/login', headers: [], bodyBase64: 'e30=' },
},
}).action).toBe('transform.execute');
expect(parseExtensionRequest({
action: 'transform.recovery.start',
payload: { id: 'profile-1' },
}).action).toBe('transform.recovery.start');
expect(parseExtensionRequest({
action: 'transform.recovery.capture',
payload: {
id: 'profile-1',
tabId: 12,
frameId: 0,
documentId: 'document-2',
callFrameId: 'frame-1',
strategy: 'request-transaction',
},
}).action).toBe('transform.recovery.capture');
expect(parseExtensionRequest({
action: 'transform.recovery.validate',
payload: {
id: 'profile-1',
packet: { method: 'POST', url: 'https://example.test/api/login', headers: [], bodyBase64: 'e30=' },
},
}).action).toBe('transform.recovery.validate');
expect(parseExtensionRequest({
action: 'transform.recovery.confirm',
payload: { id: 'profile-1', validationId: 'validation-1' },
}).action).toBe('transform.recovery.confirm');
expect(() => parseExtensionRequest({
action: 'transform.recovery.capture',
payload: {
id: 'profile-1',
tabId: 12,
frameId: 0,
callFrameId: 'frame-1',
strategy: 'expression',
},
})).toThrow();
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: { ...profile, origin: 'https://example.test/login' },
+64 -14
View File
@@ -1,7 +1,12 @@
import * as v from 'valibot';
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
import type { CapabilityScope } from '@/types/models';
import { browserTransformExecuteSchema, browserTransformProfileInputSchema } from './transform';
import { capabilityParams } from './bridge';
import {
browserTransformExecuteSchema,
browserTransformPacketSchema,
browserTransformProfileInputSchema,
} from './transform';
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));
@@ -41,15 +46,6 @@ const deepCaptureMatcher = v.variant('kind', [
frameHints: businessFrameHints,
}),
]);
const pageRequestTransaction = v.strictObject({
request: v.strictObject({
method: v.pipe(v.string(), v.trim(), v.toUpperCase(), v.regex(/^[A-Z]{1,16}$/)),
url: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
expectedDestinations: v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(512))), v.minLength(1), v.maxLength(64)),
}),
inputMode: v.literal('auto'),
boundaries: v.pipe(v.array(v.picklist(['fetch', 'xhr', 'beacon', 'form'])), v.minLength(1), v.maxLength(4)),
});
const port = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(65_535));
const proxyHost = v.pipe(
v.string(),
@@ -197,6 +193,7 @@ const partitionKey = v.strictObject({
const cookieInput = v.strictObject({
url,
tabId,
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))),
@@ -229,6 +226,8 @@ const contextOptions = {
const capabilityScopes: readonly CapabilityScope[] = [
'browser.tabs.read',
'browser.isolation.read',
'browser.isolation.manage',
'browser.dom.read',
'browser.dom.write',
'browser.storage.read',
@@ -241,6 +240,7 @@ const capabilityScopes: readonly CapabilityScope[] = [
'browser.network.read',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.network.replay',
'browser.recording.read',
'browser.recording.control',
'browser.recording.sensitive.read',
@@ -260,6 +260,40 @@ const payloadSchemas = {
'tab.get': v.strictObject({ tabId }),
'tab.list': noPayload,
'frame.list': v.strictObject({ tabId }),
'isolation.inspect': v.strictObject({
tabIds: v.optional(v.pipe(v.array(tabId), v.minLength(1), v.maxLength(256))),
}),
'isolation.proof.create': v.pipe(v.strictObject({
leftTabId: tabId,
rightTabId: tabId,
}), v.check((input) => input.leftTabId !== input.rightTabId, '双身份槽位不能选择同一个标签页')),
'isolation.incognito.open': v.strictObject({ url: httpUrl }),
'isolation.container.open': v.strictObject({
url: httpUrl,
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(50))),
}),
'isolation.container.list': noPayload,
'isolation.container.remove': v.strictObject({
cookieStoreId: v.pipe(v.string(), v.regex(/^firefox-container-[0-9]+$/)),
}),
'authorization.engine.task': v.strictObject({
schema: v.picklist([
'authorization.workspace.create',
'authorization.workspace.inspect',
'authorization.baseline.candidates',
'authorization.baseline.bind',
'authorization.logical.bind',
'authorization.plan.create',
'authorization.plan.execute',
'authorization.evidence.inspect',
'authorization.evidence.packet',
'authorization.evidence.diff',
'authorization.evidence.validate',
]),
payload: v.record(v.string(), v.unknown()),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(5_000), v.maxValue(120_000))),
}),
'authorization.yakit.open': v.strictObject({ workspaceId: id }),
'proxy.save': proxyProfile,
'proxy.delete': v.strictObject({ id }),
'proxy.switch': v.strictObject({ id }),
@@ -286,12 +320,12 @@ const payloadSchemas = {
'proxy.auth.status': v.strictObject({ profileId: id }),
'proxy.config.export': noPayload,
'proxy.config.import': v.strictObject({ configuration: proxyConfiguration }),
'cookie.list': v.strictObject({ url }),
'cookie.list': v.strictObject({ url, tabId }),
'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() }),
'cookie.import': v.strictObject({ url, tabId, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
'cookie.export': v.strictObject({ url, tabId, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
'ua.catalog': noPayload,
'ua.resolve': v.strictObject({ url: httpUrl }),
'ua.profile.save': userAgentProfileInput,
@@ -339,6 +373,7 @@ const payloadSchemas = {
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.refresh': noPayload,
'grant.revoke': noPayload,
'handoff.resolve': v.strictObject({ id, outcome: v.picklist(['completed', 'cancelled']) }),
'network.capture.start': v.strictObject({
@@ -375,12 +410,13 @@ const payloadSchemas = {
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('selected-frame'),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
candidateId: v.optional(id),
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('request-transaction'),
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
transaction: pageRequestTransaction,
candidateId: id,
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
@@ -397,9 +433,23 @@ const payloadSchemas = {
'deep.capture.keepalive': v.strictObject(targetFields),
'deep.capture.resume': v.strictObject(targetFields),
'deep.capture.detach': v.strictObject(targetFields),
'analysis.profile.propose': capabilityParams['browser.profile.propose'],
'analysis.profile.validate': capabilityParams['browser.profile.validate'],
'analysis.profile.validation.latest': capabilityParams['browser.profile.validation.latest'],
'transform.profile.list': v.strictObject(targetFields),
'transform.profile.save': browserTransformProfileInputSchema,
'transform.profile.delete': v.strictObject({ id }),
'transform.recovery.get': v.strictObject({ id }),
'transform.recovery.start': v.strictObject({ id }),
'transform.recovery.capture': v.strictObject({
id,
...targetFields,
callFrameId: id,
strategy: v.picklist(['selected-frame', 'request-transaction']),
}),
'transform.recovery.validate': v.strictObject({ id, packet: browserTransformPacketSchema }),
'transform.recovery.confirm': v.strictObject({ id, validationId: id }),
'transform.recovery.reset': v.strictObject({ id }),
'transform.execute': browserTransformExecuteSchema,
'audit.list': v.strictObject({ limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'audit.clear': noPayload,
+188 -8
View File
@@ -55,7 +55,7 @@ const builtinNode = v.strictObject({
'value.literal',
'json.stringify', 'json.parse', 'text.toString', 'url.encode', 'url.decode',
'base64.encode', 'base64.decode', 'hex.encode', 'hex.decode',
'object.pick', 'object.compose', 'form.compose',
'object.pick', 'object.compose', 'form.compose', 'form.serialize',
]),
inputs: v.pipe(v.array(reference), v.maxLength(64)),
options: v.optional(v.record(v.string(), v.unknown())),
@@ -89,6 +89,176 @@ const direction = v.pipe(
v.check((value) => !value.enabled || value.nodes.some((item) => item.kind === 'output.write'), '启用的转换方向必须包含输出节点'),
);
const recoveryFrameHint = v.strictObject({
functionName: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
url: v.optional(v.pipe(v.string(), v.maxLength(4_096))),
support: v.pipe(v.number(), v.minValue(0), v.maxValue(1_000_000)),
averageDepth: v.pipe(v.number(), v.minValue(0), v.maxValue(1_000_000)),
});
const recoveryTransaction = v.strictObject({
version: v.literal(2),
prerequisites: v.pipe(v.array(v.strictObject({
boundary: v.literal('fetch'),
method: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32)),
url: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
requestBodyFormat: v.picklist(['none', 'json', 'form', 'raw']),
maxRequestBodyBytes: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(8 * 1_024 * 1_024)),
response: v.strictObject({
statusCode: v.pipe(v.number(), v.safeInteger(), v.minValue(100), v.maxValue(599)),
url: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
bodyFormat: v.picklist(['json', 'form', 'raw']),
maxBodyBytes: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8 * 1_024 * 1_024)),
requiredPaths: v.pipe(v.array(valuePath), v.minLength(1), v.maxLength(64)),
}),
})), v.maxLength(4)),
request: v.strictObject({
boundary: v.picklist(['fetch', 'xhr', 'beacon', 'form']),
method: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32)),
url: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
expectedDestinations: v.pipe(v.array(valuePath), v.minLength(1), v.maxLength(64)),
bodyFormat: v.picklist(['json', 'form', 'raw']),
}),
inputMode: v.literal('auto'),
});
const recoveryGuide = v.strictObject({
direction: v.picklist(['request', 'response']),
callableId: id,
inputPaths: v.pipe(v.array(valuePath), v.maxLength(64)),
resultPath: v.optional(valuePath),
outputKind: v.picklist(['body', 'json-field', 'form-field', 'header', 'query']),
outputField: v.pipe(v.string(), v.maxLength(512)),
setFormContentType: v.boolean(),
});
const recoveryBinding = v.strictObject({
callableId: id,
name: nodeName,
kind: v.picklist(['recorded-call', 'business-closure', 'request-transaction', 'global-function']),
operation: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
nodeIds: v.pipe(v.array(v.strictObject({
direction: v.picklist(['request', 'response']),
nodeId: id,
})), v.minLength(1), v.maxLength(128)),
inputSemantics: v.pipe(v.array(v.strictObject({
id: v.pipe(v.string(), v.maxLength(120)),
name: v.pipe(v.string(), v.maxLength(120)),
index: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(63)),
role: v.picklist(['data', 'key', 'iv', 'algorithm', 'options', 'signature', 'salt', 'nonce', 'aad', 'unknown']),
dataType: v.pipe(v.string(), v.maxLength(120)),
required: v.boolean(),
retained: v.boolean(),
})), v.maxLength(64)),
output: v.strictObject({
dataType: v.pipe(v.string(), v.maxLength(120)),
encoding: v.picklist(['auto', 'utf8', 'hex', 'base64', 'json']),
shape: v.picklist(['value', 'envelope']),
paths: v.pipe(v.array(valuePath), v.maxLength(64)),
}),
guides: v.pipe(v.array(recoveryGuide), v.maxLength(2)),
frameHints: v.pipe(v.array(recoveryFrameHint), v.maxLength(16)),
transaction: v.optional(recoveryTransaction),
});
const recoveryCapture = v.strictObject({
kind: v.literal('request'),
method: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32)),
url: v.pipe(v.string(), v.maxLength(4_096)),
urlPattern: v.pipe(v.string(), v.maxLength(2_048)),
expectedDestinations: v.pipe(v.array(valuePath), v.maxLength(64)),
bodyFormat: v.picklist(['json', 'form', 'raw']),
frameHints: v.pipe(v.array(recoveryFrameHint), v.maxLength(16)),
automatic: v.boolean(),
reason: v.optional(v.pipe(v.string(), v.maxLength(500))),
});
const recoveryPending = v.strictObject({
target,
callableId: id,
callableName: nodeName,
request: direction,
response: direction,
capturedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
});
const recoveryValidation = v.strictObject({
id,
proofLevel: v.literal('execution-only'),
summary: v.pipe(v.string(), v.maxLength(500)),
validatedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
expiresAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
});
const recoveryPlan = v.strictObject({
contractVersion: v.literal(1),
state: v.picklist(['ready', 'stale', 'capturing', 'validation-required', 'confirmation-required', 'failed']),
desiredEnabled: v.boolean(),
boundDocumentId: v.optional(documentId),
binding: recoveryBinding,
capture: recoveryCapture,
pending: v.optional(recoveryPending),
validation: v.optional(recoveryValidation),
reason: v.optional(v.pipe(v.string(), v.maxLength(500))),
createdAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
updatedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
});
const explanationText = v.pipe(v.string(), v.maxLength(4_096));
const explanationPath = v.pipe(v.string(), v.maxLength(512));
const explanationCrypto = v.strictObject({
adapterId: v.pipe(v.string(), v.maxLength(120)),
providerKind: v.picklist(['native', 'library', 'business', 'wasm', 'unknown']),
family: v.picklist(['symmetric', 'asymmetric', 'digest', 'mac', 'signature', 'kdf', 'key-management', 'unknown']),
operation: v.pipe(v.string(), v.maxLength(240)),
algorithm: v.optional(v.pipe(v.string(), v.maxLength(240))),
mode: v.optional(v.pipe(v.string(), v.maxLength(120))),
padding: v.optional(v.pipe(v.string(), v.maxLength(120))),
inputEncoding: v.optional(v.picklist(['auto', 'utf8', 'hex', 'base64', 'json'])),
outputEncoding: v.optional(v.picklist(['auto', 'utf8', 'hex', 'base64', 'json'])),
state: v.optional(v.strictObject({
model: v.picklist(['stateless', 'receiver', 'session', 'stream', 'async-ready']),
phase: v.optional(v.picklist(['create', 'init', 'update', 'final', 'one-shot'])),
})),
key: v.optional(v.strictObject({
kind: v.picklist(['public', 'private', 'secret', 'unknown']),
bits: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000))),
})),
});
const explanationOperation = v.strictObject({
operation: v.pipe(v.string(), v.maxLength(240)),
destination: v.optional(explanationPath),
crypto: v.optional(explanationCrypto),
});
const explanationStage = v.strictObject({
id,
kind: v.picklist(['input', 'prerequisite', 'page-call', 'builtin', 'output', 'session', 'transport']),
owner: v.picklist(['webfuzzer', 'extension', 'page', 'yak']),
proof: v.picklist(['configured', 'observed', 'supported']),
title: v.pipe(v.string(), v.maxLength(240)),
summary: explanationText,
nodeIds: v.pipe(v.array(id), v.maxLength(64)),
inputPaths: v.pipe(v.array(explanationPath), v.maxLength(64)),
outputPaths: v.pipe(v.array(explanationPath), v.maxLength(64)),
operations: v.pipe(v.array(explanationOperation), v.maxLength(16)),
evidence: v.pipe(v.array(v.strictObject({
strength: v.picklist(['proven', 'supported']),
label: v.pipe(v.string(), v.maxLength(500)),
})), v.maxLength(24)),
network: v.optional(v.strictObject({
method: v.pipe(v.string(), v.maxLength(32)),
route: v.pipe(v.string(), v.maxLength(2_048)),
statusCode: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(100), v.maxValue(999))),
requiredPaths: v.pipe(v.array(explanationPath), v.maxLength(64)),
})),
source: v.optional(v.strictObject({
functionName: v.optional(v.pipe(v.string(), v.maxLength(240))),
url: v.optional(v.pipe(v.string(), v.maxLength(4_096))),
lineNumber: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(100_000_000))),
})),
});
const transformExplanation = v.strictObject({
version: v.literal(1),
directions: v.pipe(v.array(v.strictObject({
direction: v.picklist(['request', 'response']),
summary: explanationText,
stages: v.pipe(v.array(explanationStage), v.maxLength(128)),
})), v.maxLength(2)),
});
const profileFields = {
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
enabled: v.boolean(),
@@ -112,6 +282,14 @@ export const browserTransformProfileInputSchema = v.strictObject({
export const browserTransformProfileSchema = v.strictObject({
id,
...profileFields,
isolationContextId: id,
cookieStoreId: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(320))),
explanation: v.optional(transformExplanation),
requestTransaction: v.optional(v.strictObject({
callableId: id,
transaction: recoveryTransaction,
})),
recovery: v.optional(recoveryPlan),
createdAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
updatedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
});
@@ -121,14 +299,16 @@ const header = v.strictObject({
value: v.pipe(v.string(), v.maxLength(1_000_000)),
});
export const browserTransformPacketSchema = v.strictObject({
method: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32))),
url: v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192)),
statusCode: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(100), v.maxValue(999))),
headers: v.pipe(v.array(header), v.maxLength(512)),
bodyBase64: v.pipe(v.string(), v.maxLength(11_184_820)),
});
export const browserTransformExecuteSchema = v.strictObject({
profileId: id,
direction: v.picklist(['request', 'response']),
packet: v.strictObject({
method: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32))),
url: v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192)),
statusCode: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(100), v.maxValue(999))),
headers: v.pipe(v.array(header), v.maxLength(512)),
bodyBase64: v.pipe(v.string(), v.maxLength(11_184_820)),
}),
packet: browserTransformPacketSchema,
});