mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 04:31:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
+333
-1
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user