Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent 0371a8b802
commit af5a4db694
125 changed files with 25018 additions and 1675 deletions
+17
View File
@@ -44,4 +44,21 @@ describe('Bridge v3 protocol', () => {
expect(() => parseCapabilityParams('browser.eval', { code: 'document.title' })).toThrow('mode');
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
});
it('accepts exact Worker boundary handles for remote deep capture', () => {
expect(parseCapabilityParams('browser.deep_capture.start', {
matcher: {
kind: 'boundary', eventKind: 'worker', operation: 'worker.postMessage', wrapperHandleId: 'boundary-wrapper-1',
},
})).toMatchObject({ matcher: { kind: 'boundary', eventKind: 'worker' } });
});
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' });
expect(() => parseCapabilityParams('browser.callable.create', {
source: 'deep-capture', callFrameId: 'frame-1', name: 'Envelope', functionExpression: 'buildEnvelope',
})).toThrow();
});
});
+67 -7
View File
@@ -1,6 +1,7 @@
import * as v from 'valibot';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
import { browserTransformExecuteSchema, browserTransformProfileInputSchema } from './transform';
export const BRIDGE_PROTOCOL_VERSION = 3;
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
@@ -32,6 +33,36 @@ const optionalTabId = v.optional(tabId);
const optionalFrameId = v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0)));
const optionalDocumentId = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)));
const targetFields = { tabId: optionalTabId, frameId: optionalFrameId, documentId: optionalDocumentId };
const cryptoAdapterId = v.pipe(v.string(), v.trim(), v.regex(/^[a-z0-9][a-z0-9.-]{0,63}$/));
const businessFrameHints = v.optional(v.pipe(v.array(v.strictObject({
functionName: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
url: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
support: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
averageDepth: v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(16)),
})), v.maxLength(8)));
const deepCaptureMatcher = v.variant('kind', [
v.strictObject({
kind: v.literal('crypto'),
adapterId: cryptoAdapterId,
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
wrapperHandleId: id,
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
frameHints: businessFrameHints,
}),
v.strictObject({
kind: v.literal('boundary'),
eventKind: v.picklist(['beacon', 'worker', 'message']),
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
wrapperHandleId: id,
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
frameHints: businessFrameHints,
}),
v.strictObject({
kind: v.literal('request'),
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
frameHints: businessFrameHints,
}),
]);
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));
@@ -78,19 +109,48 @@ const capabilityParams = {
'browser.network.export': v.strictObject({ ...targetFields, id }),
'browser.network.poc': v.strictObject({ ...targetFields, id }),
'browser.network.analysis': v.strictObject({ ...targetFields, id }),
'browser.observe.start': v.optional(v.strictObject({
'browser.recording.start': v.optional(v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
})),
'browser.observe.status': v.optional(v.strictObject(targetFields)),
'browser.observe.list': v.optional(v.strictObject({
'browser.recording.status': v.optional(v.strictObject(targetFields)),
'browser.recording.get': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))),
})),
'browser.observe.clear': v.optional(v.strictObject(targetFields)),
'browser.observe.stop': v.optional(v.strictObject(targetFields)),
'browser.recording.clear': v.optional(v.strictObject(targetFields)),
'browser.recording.stop': v.optional(v.strictObject(targetFields)),
'browser.callable.create': v.union([
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
}),
v.strictObject({
...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))),
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('expression'),
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
functionExpression: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
}),
]),
'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.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)),
'browser.deep_capture.resume': v.optional(v.strictObject(targetFields)),
'browser.deep_capture.detach': v.optional(v.strictObject(targetFields)),
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
'browser.transform.profile.save': browserTransformProfileInputSchema,
'browser.transform.profile.delete': v.strictObject({ id }),
'browser.transform.execute': browserTransformExecuteSchema,
'browser.invoke': v.strictObject({
...targetFields,
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
+40 -11
View File
@@ -19,11 +19,26 @@ export const BRIDGE_CAPABILITIES = [
'browser.network.export',
'browser.network.poc',
'browser.network.analysis',
'browser.observe.start',
'browser.observe.status',
'browser.observe.list',
'browser.observe.clear',
'browser.observe.stop',
'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',
@@ -35,7 +50,8 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.storage.read',
'browser.cookies.read',
'browser.network.read',
'browser.observation.read',
'browser.recording.read',
...(!import.meta.env.FIREFOX ? ['browser.transform.read' as const] : []),
];
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
@@ -48,8 +64,15 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.human.takeover',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'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.proxy.read',
'browser.proxy.write',
];
@@ -68,9 +91,15 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.network.read': '读取网络摘要',
'browser.network.capture': '控制网络捕获',
'browser.network.sensitive.read': '读取请求头与请求体',
'browser.observation.read': '读取页面行为观测',
'browser.observation.control': '控制页面行为观测',
'browser.observation.sensitive.read': '读取观测值预览',
'browser.recording.read': '读取浏览器录制',
'browser.recording.control': '控制浏览器录制',
'browser.recording.sensitive.read': '读取录制值预览',
'browser.callable.execute': '创建并执行页面函数',
'browser.debugger.read': '读取暂停现场与作用域',
'browser.debugger.control': '控制页面深度捕获',
'browser.transform.read': '读取浏览器转换配置',
'browser.transform.manage': '管理浏览器转换配置',
'browser.transform.execute': '执行浏览器请求与响应转换',
'browser.proxy.read': '读取代理',
'browser.proxy.write': '切换代理',
};
+224
View File
@@ -15,4 +15,228 @@ describe('extension request schemas', () => {
action: 'context.eval', payload: { mode: 'program', code: '1 + 1', timeoutMs: 500 },
}).action).toBe('context.eval');
});
it('accepts all browser transform grant scopes', () => {
expect(parseExtensionRequest({
action: 'grant.create',
payload: {
targets: [{ tabId: 12, frameId: 0 }],
scopes: ['browser.transform.read', 'browser.transform.manage', 'browser.transform.execute'],
durationMinutes: 5,
},
}).action).toBe('grant.create');
expect(() => parseExtensionRequest({
action: 'grant.create',
payload: { targets: [{ tabId: 12, frameId: 0 }], scopes: ['browser.transform.unknown'], durationMinutes: 5 },
})).toThrow('scopes');
});
it('validates the atomic current-site route reset action', () => {
expect(parseExtensionRequest({
action: 'proxy.site.route.clear', payload: { url: 'https://api.example.test/path' },
}).action).toBe('proxy.site.route.clear');
expect(() => parseExtensionRequest({
action: 'proxy.site.route.clear', payload: { url: 'chrome://extensions' },
})).toThrow('HTTP(S)');
});
it('validates recording bounds and recorded page callables', () => {
expect(parseExtensionRequest({
action: 'recording.start',
payload: { tabId: 12, frameId: 0, captureValues: false, maxEntries: 500, maxValueBytes: 8_192 },
}).action).toBe('recording.start');
expect(() => parseExtensionRequest({
action: 'recording.start', payload: { tabId: 12, maxEntries: 501 },
})).toThrow();
expect(parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
frameId: 0,
source: 'recording',
callHandleId: 'handle-1',
name: 'Login encrypt',
},
}).action).toBe('callable.create');
expect(() => parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'deep-capture',
strategy: 'request-transaction',
callFrameId: 'frame-1',
transaction: {
request: { method: 'POST', url: '/login', expectedDestinations: [] },
inputMode: 'auto',
boundaries: ['fetch'],
},
},
})).toThrow('expectedDestinations');
expect(parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'deep-capture',
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'],
},
},
}).action).toBe('callable.create');
expect(() => parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'recording',
callHandleId: 'handle-1',
name: '',
},
})).toThrow();
expect(parseExtensionRequest({
action: 'callable.execute', payload: { tabId: 12, callableId: 'callable-1', args: ['new plaintext'] },
}).action).toBe('callable.execute');
});
it('validates deep capture matchers and bounded business callables', () => {
expect(parseExtensionRequest({
action: 'deep.capture.start',
payload: {
tabId: 12,
frameId: 0,
matcher: {
kind: 'crypto',
adapterId: 'webcrypto',
operation: 'encrypt',
wrapperHandleId: 'wrapper-1',
frameHints: [{
functionName: 'buildEnvelope', url: 'https://example.test/app.js', support: 3, averageDepth: 1,
}],
},
},
}).action).toBe('deep.capture.start');
expect(() => parseExtensionRequest({
action: 'deep.capture.start',
payload: { tabId: 12, matcher: { kind: 'crypto', operation: 'crypto.subtle.encrypt' } },
})).toThrow();
expect(parseExtensionRequest({
action: 'deep.capture.start',
payload: { tabId: 12, matcher: { kind: 'request', urlPattern: '/api/login' } },
}).action).toBe('deep.capture.start');
expect(parseExtensionRequest({
action: 'deep.capture.start',
payload: {
tabId: 12,
matcher: {
kind: 'boundary', eventKind: 'worker', operation: 'worker.postMessage', wrapperHandleId: 'boundary-wrapper-1',
},
},
}).action).toBe('deep.capture.start');
expect(() => parseExtensionRequest({
action: 'deep.capture.start',
payload: {
tabId: 12,
matcher: { kind: 'boundary', eventKind: 'fetch', operation: 'request', wrapperHandleId: 'boundary-wrapper-1' },
},
})).toThrow();
expect(() => parseExtensionRequest({
action: 'deep.capture.start', payload: { tabId: 12, matcher: { kind: 'request', urlPattern: '' } },
})).toThrow();
expect(parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'deep-capture',
strategy: 'selected-frame',
callFrameId: 'frame-1',
name: 'Login envelope',
},
}).action).toBe('callable.create');
expect(parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'deep-capture',
strategy: 'expression',
callFrameId: 'frame-1',
name: 'Anonymous envelope',
functionExpression: 'scopeFunction',
},
}).action).toBe('callable.create');
expect(() => parseExtensionRequest({
action: 'callable.create',
payload: {
tabId: 12,
source: 'deep-capture',
callFrameId: 'frame-1',
name: 'Legacy expression',
functionExpression: 'buildLoginEnvelope',
sourceUrl: 'https://example.test/login.js',
lineNumber: 42,
},
})).toThrow();
expect(() => parseExtensionRequest({
action: 'callable.execute', payload: { tabId: 12, callableId: 'callable-1', args: Array.from({ length: 65 }) },
})).toThrow();
});
it('validates browser transform profiles and packet execution', () => {
const profile = {
name: 'Login gateway', enabled: true,
target: { tabId: 12, frameId: 0, documentId: 'document-1' },
origin: 'https://example.test',
match: { methods: ['POST'], urlPattern: '*/api/login' },
request: {
enabled: true,
nodes: [
{ id: 'input-1', name: 'Password', kind: 'context.read', path: 'body.password' },
{ id: 'call-1', name: 'Encrypt', kind: 'page.call', callableId: 'callable-1', arguments: [{ nodeId: 'input-1' }] },
{ id: 'output-1', name: 'Write', kind: 'output.write', source: { nodeId: 'call-1' }, destination: 'body.password', encoding: 'auto' },
],
},
response: { enabled: false, nodes: [] },
failMode: 'closed' as const,
maxConcurrency: 1,
};
expect(parseExtensionRequest({ action: 'transform.profile.save', payload: profile }).action).toBe('transform.profile.save');
expect(parseExtensionRequest({
action: 'transform.execute',
payload: {
profileId: 'profile-1', direction: 'request',
packet: { method: 'POST', url: 'https://example.test/api/login', headers: [], bodyBase64: 'e30=' },
},
}).action).toBe('transform.execute');
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: { ...profile, origin: 'https://example.test/login' },
})).toThrow('来源');
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: { ...profile, request: { ...profile.request, nodes: profile.request.nodes.filter((node) => node.kind !== 'output.write') } },
})).toThrow('输出节点');
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: {
...profile,
request: { ...profile.request, nodes: profile.request.nodes.map((node) => node.kind === 'output.write' ? { ...node, destination: 'cookie.password' } : node) },
},
})).toThrow('输出目标');
expect(() => parseExtensionRequest({
action: 'transform.profile.save',
payload: {
...profile,
request: {
...profile.request,
nodes: [...profile.request.nodes, { ...profile.request.nodes[0] }],
},
},
})).toThrow('重复');
});
});
+171 -23
View File
@@ -1,6 +1,7 @@
import * as v from 'valibot';
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
import type { CapabilityScope } from '@/types/models';
import { browserTransformExecuteSchema, 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));
@@ -11,6 +12,44 @@ const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
const targetFields = { tabId: v.optional(tabId), frameId: v.optional(frameId), documentId: v.optional(documentId) };
const businessFrameHints = v.optional(v.pipe(v.array(v.strictObject({
functionName: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(240)),
url: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
support: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
averageDepth: v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(16)),
})), v.maxLength(8)));
const deepCaptureMatcher = v.variant('kind', [
v.strictObject({
kind: v.literal('crypto'),
adapterId: v.pipe(v.string(), v.trim(), v.regex(/^[a-z0-9][a-z0-9.-]{0,63}$/)),
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
wrapperHandleId: id,
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
frameHints: businessFrameHints,
}),
v.strictObject({
kind: v.literal('boundary'),
eventKind: v.picklist(['beacon', 'worker', 'message']),
operation: v.pipe(v.string(), v.trim(), v.regex(/^[A-Za-z0-9_$][A-Za-z0-9_$.-]{0,159}$/)),
wrapperHandleId: id,
scriptUrl: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(4_096))),
frameHints: businessFrameHints,
}),
v.strictObject({
kind: v.literal('request'),
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
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(),
@@ -48,13 +87,61 @@ const proxyProfile = v.pipe(v.strictObject({
return true;
}, '代理配置缺少当前类型所需的主机、端口或 PAC 内容'));
const proxyCondition = v.strictObject({
type: v.picklist(['host_exact', 'host_suffix', 'host_wildcard', 'host_regex', 'url_prefix', 'url_wildcard', 'url_regex', 'keyword']),
value: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(8_192)),
});
const timestamp = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const nonNegativeInteger = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
const sourceFormat = v.picklist(['auto', 'autoproxy', 'switchyomega', 'hosts']);
const sourceStatus = v.picklist(['idle', 'updating', 'ready', 'error']);
const proxyRule = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
patterns: stringList(500, 2_048),
condition: proxyCondition,
proxyProfileId: id,
priority: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000)),
order: nonNegativeInteger,
createdAt: timestamp,
updatedAt: timestamp,
});
const proxyRuleSourceInput = v.strictObject({
id: v.optional(id),
name: v.pipe(shortText, v.minLength(1)),
url: httpUrl,
format: sourceFormat,
enabled: v.boolean(),
matchProfileId: id,
bypassProfileId: id,
order: v.optional(nonNegativeInteger),
updateIntervalMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(15), v.maxValue(43_200)),
});
const proxyRuleSource = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
url: httpUrl,
format: sourceFormat,
enabled: v.boolean(),
matchProfileId: id,
bypassProfileId: id,
order: nonNegativeInteger,
updateIntervalMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(15), v.maxValue(43_200)),
revision: v.optional(id),
contentHash: v.optional(id),
etag: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
lastModified: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
lastCheckedAt: v.optional(timestamp),
lastUpdatedAt: v.optional(timestamp),
status: sourceStatus,
totalRuleCount: nonNegativeInteger,
supportedRuleCount: nonNegativeInteger,
ignoredRuleCount: nonNegativeInteger,
invalidRuleCount: nonNegativeInteger,
error: v.optional(v.pipe(v.string(), v.maxLength(16_384))),
});
const proxyRouting = v.strictObject({
@@ -63,18 +150,25 @@ const proxyRouting = v.strictObject({
});
const proxyConfiguration = v.strictObject({
version: v.literal(1),
version: v.literal(2),
profiles: v.pipe(v.array(proxyProfile), v.minLength(1), v.maxLength(500)),
rules: v.pipe(v.array(proxyRule), v.maxLength(5_000)),
sources: v.pipe(v.array(v.strictObject({
source: proxyRuleSource,
content: v.optional(v.pipe(v.string(), v.maxLength(10 * 1024 * 1024))),
})), v.maxLength(200)),
routing: proxyRouting,
});
const userAgentRule = v.strictObject({
id,
const userAgentValue = v.pipe(
v.string(), v.trim(), v.minLength(1), v.maxLength(1_024),
v.check((value) => !/[\r\n]/.test(value), 'User-Agent 不能包含换行符'),
);
const userAgentProfileInput = v.strictObject({
id: v.optional(id),
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
userAgent: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
domains: stringList(500, 253),
userAgent: userAgentValue,
});
const bridgeConfig = v.strictObject({
@@ -147,9 +241,15 @@ const capabilityScopes: readonly CapabilityScope[] = [
'browser.network.read',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'browser.recording.read',
'browser.recording.control',
'browser.recording.sensitive.read',
'browser.callable.execute',
'browser.debugger.read',
'browser.debugger.control',
'browser.transform.read',
'browser.transform.manage',
'browser.transform.execute',
'browser.proxy.read',
'browser.proxy.write',
];
@@ -165,13 +265,23 @@ const payloadSchemas = {
'proxy.switch': v.strictObject({ id }),
'proxy.rule.save': proxyRule,
'proxy.rule.delete': v.strictObject({ id }),
'proxy.rules.apply': noPayload,
'proxy.auto.apply': noPayload,
'proxy.rules.preview': v.strictObject({ url: httpUrl }),
'proxy.rules.compile': noPayload,
'proxy.rules.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(5_000)) }),
'proxy.rules.settings': proxyRouting,
'proxy.rules.stats': noPayload,
'proxy.rules.stats.clear': noPayload,
'proxy.source.save': proxyRuleSourceInput,
'proxy.source.refresh': v.strictObject({ id }),
'proxy.source.delete': v.strictObject({ id }),
'proxy.sources.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(200)) }),
'proxy.source.rules': v.strictObject({
id,
offset: nonNegativeInteger,
limit: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500)),
query: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(2_048))),
}),
'proxy.site.route': v.strictObject({ url: httpUrl, profileId: id }),
'proxy.site.route.clear': v.strictObject({ url: httpUrl }),
'proxy.auth.set': v.strictObject({ profileId: id, password: v.pipe(v.string(), v.maxLength(4_096)) }),
'proxy.auth.status': v.strictObject({ profileId: id }),
'proxy.config.export': noPayload,
@@ -182,9 +292,12 @@ const payloadSchemas = {
'cookie.removeMany': v.strictObject({ cookies: v.pipe(v.array(cookieRemoveInput), v.minLength(1), v.maxLength(1_000)) }),
'cookie.import': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
'cookie.export': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
'ua.save': userAgentRule,
'ua.delete': v.strictObject({ id }),
'ua.apply': noPayload,
'ua.catalog': noPayload,
'ua.resolve': v.strictObject({ url: httpUrl }),
'ua.profile.save': userAgentProfileInput,
'ua.profile.delete': v.strictObject({ id }),
'ua.site.apply': v.strictObject({ url: httpUrl, profileId: id }),
'ua.site.reset': v.strictObject({ url: httpUrl }),
'context.capture': v.strictObject(contextOptions),
'context.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
'context.node.action': v.pipe(v.strictObject({
@@ -243,16 +356,51 @@ const payloadSchemas = {
'network.capture.send': v.strictObject({ ...targetFields, id }),
'network.capture.poc': v.strictObject({ ...targetFields, id }),
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
'observation.start': v.strictObject({
'recording.start': v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(20), v.maxValue(500))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
}),
'observation.status': v.strictObject(targetFields),
'observation.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
'observation.clear': v.strictObject(targetFields),
'observation.stop': v.strictObject(targetFields),
'recording.status': v.strictObject(targetFields),
'recording.get': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'recording.clear': v.strictObject(targetFields),
'recording.stop': v.strictObject(targetFields),
'callable.create': v.union([
v.strictObject({
...targetFields, source: v.literal('recording'), callHandleId: id,
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
}),
v.strictObject({
...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))),
}),
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,
}),
v.strictObject({
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
strategy: v.literal('expression'),
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
functionExpression: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
}),
]),
'callable.list': v.strictObject(targetFields),
'callable.execute': v.strictObject({ ...targetFields, callableId: id, args: v.pipe(v.array(v.unknown()), v.maxLength(64)) }),
'callable.delete': v.strictObject({ ...targetFields, callableId: id }),
'deep.capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
'deep.capture.status': v.strictObject(targetFields),
'deep.capture.keepalive': v.strictObject(targetFields),
'deep.capture.resume': v.strictObject(targetFields),
'deep.capture.detach': v.strictObject(targetFields),
'transform.profile.list': v.strictObject(targetFields),
'transform.profile.save': browserTransformProfileInputSchema,
'transform.profile.delete': 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,
'agent.runtime.get': noPayload,
+134
View File
@@ -0,0 +1,134 @@
import * as v from 'valibot';
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const frameId = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const blockedPathSegments = new Set(['__proto__', 'prototype', 'constructor']);
function isSafeValuePath(value: string): boolean {
if (value === '$') return true;
const normalized = value.startsWith('$.') ? value.slice(2) : value;
if (!normalized || normalized.startsWith('.') || normalized.endsWith('.') || normalized.includes('..')) return false;
const segments = normalized.split('.');
return segments.length <= 64 && segments.every((segment) => segment.length > 0 && !blockedPathSegments.has(segment));
}
function isOutputDestination(value: string): boolean {
if (value === 'body') return true;
if (value.startsWith('body.')) return isSafeValuePath(value.slice(5));
if (value.toLowerCase().startsWith('header.')) {
const name = value.slice(7);
return Boolean(name) && !/[\r\n:]/.test(name);
}
if (value.startsWith('query.')) {
const name = value.slice(6);
return Boolean(name) && !/[\r\n&#=]/.test(name);
}
return false;
}
const valuePath = v.pipe(
v.string(), v.trim(), v.minLength(1), v.maxLength(512),
v.check(isSafeValuePath, '转换值路径无效或包含保留字段'),
);
const httpOrigin = v.pipe(
v.string(),
v.trim(),
v.url(),
v.maxLength(2_048),
v.check((value) => {
const parsed = new URL(value);
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin === value;
}, '必须是 HTTP(S) 页面来源,不得包含路径'),
);
const target = v.strictObject({ tabId, frameId, documentId: v.optional(documentId) });
const nodeName = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120));
const reference = v.strictObject({ nodeId: id, path: v.optional(valuePath) });
const contextReadNode = v.strictObject({ id, name: nodeName, kind: v.literal('context.read'), path: valuePath });
const builtinNode = v.strictObject({
id,
name: nodeName,
kind: v.literal('builtin'),
operation: v.picklist([
'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',
]),
inputs: v.pipe(v.array(reference), v.maxLength(64)),
options: v.optional(v.record(v.string(), v.unknown())),
});
const pageCallNode = v.strictObject({
id,
name: nodeName,
kind: v.literal('page.call'),
callableId: id,
arguments: v.pipe(v.array(reference), v.maxLength(64)),
});
const outputWriteNode = v.strictObject({
id,
name: nodeName,
kind: v.literal('output.write'),
destination: v.pipe(
v.string(), v.trim(), v.minLength(1), v.maxLength(512),
v.check(isOutputDestination, '输出目标必须是 body、body.<path>、header.<name> 或 query.<name>'),
),
source: reference,
encoding: v.picklist(['auto', 'text', 'json', 'base64']),
});
const pipelineNode = v.union([contextReadNode, builtinNode, pageCallNode, outputWriteNode]);
const direction = v.pipe(
v.strictObject({
enabled: v.boolean(),
nodes: v.pipe(v.array(pipelineNode), v.maxLength(64)),
}),
v.check((value) => !value.enabled || value.nodes.length > 0, '启用的转换方向必须包含 Pipeline 节点'),
v.check((value) => new Set(value.nodes.map((item) => item.id)).size === value.nodes.length, '同一转换方向不能包含重复节点 ID'),
v.check((value) => !value.enabled || value.nodes.some((item) => item.kind === 'output.write'), '启用的转换方向必须包含输出节点'),
);
const profileFields = {
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
enabled: v.boolean(),
target,
origin: httpOrigin,
match: v.strictObject({
methods: v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(32))), v.maxLength(16)),
urlPattern: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
}),
request: direction,
response: direction,
failMode: v.literal('closed'),
maxConcurrency: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(8)),
};
export const browserTransformProfileInputSchema = v.strictObject({
id: v.optional(id),
...profileFields,
});
export const browserTransformProfileSchema = v.strictObject({
id,
...profileFields,
createdAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
updatedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(0)),
});
const header = v.strictObject({
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(512), v.regex(/^[^\r\n:]+$/, 'Header 名称无效')),
value: v.pipe(v.string(), v.maxLength(1_000_000)),
});
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)),
}),
});