feat(browser): add managed instance agent capabilities

This commit is contained in:
go0p
2026-09-03 13:31:59 +08:00
parent 8af9bb777e
commit e00746d834
37 changed files with 608 additions and 281 deletions
+6
View File
@@ -57,6 +57,12 @@ describe('Bridge v3 protocol', () => {
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
});
it('opens only HTTP(S) pages in the attached browser instance', () => {
expect(parseCapabilityParams('browser.tab.open', { url: 'https://www.baidu.com/' }))
.toEqual({ url: 'https://www.baidu.com/' });
expect(() => parseCapabilityParams('browser.tab.open', { url: 'chrome://settings' })).toThrow('HTTP(S)');
});
it('accepts exact Worker boundary handles for remote deep capture', () => {
expect(parseCapabilityParams('browser.deep_capture.start', {
matcher: {
+15
View File
@@ -19,6 +19,7 @@ export interface BridgePairingEnvelope {
protocolVersion?: number;
requestId?: string;
installationId?: string;
managedInstance?: BridgeEnvelope['managedInstance'];
client?: string;
version?: string;
nonce?: string;
@@ -99,6 +100,8 @@ const authorizationResourceValue = v.strictObject({
export const capabilityParams = {
'system.ping': v.optional(v.strictObject({})),
'browser.tabs': v.optional(v.strictObject({})),
'browser.tab.open': v.strictObject({ url: httpUrl }),
'browser.thumbnail': v.optional(v.strictObject({ tabId: optionalTabId })),
'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))),
@@ -183,6 +186,7 @@ export const capabilityParams = {
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'browser.cookies': v.optional(v.strictObject(targetFields)),
'browser.takeover': v.optional(v.strictObject(targetFields)),
'browser.instance.close': v.optional(v.strictObject({})),
'browser.handoff.request': v.strictObject({
...targetFields,
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
@@ -354,6 +358,7 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
const allowedKeys = new Set([
'id', 'type', 'method', 'params', 'result', 'error', 'client', 'version', 'protocolVersion',
'capabilities', 'capabilityCatalog', 'sessionId', 'taskId', 'grantId', 'installationId',
'managedInstance',
'engineInstanceId', 'engineIdentityId', 'challenge', 'signature', 'publicKey', 'connectionId',
'resumeSessionId', 'resumed', 'sequence', 'timestamp', 'replyTimestamp', 'transferId', 'index',
'total', 'data', 'originalBytes',
@@ -361,6 +366,16 @@ export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
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.managedInstance !== undefined) {
const managed = message.managedInstance as Record<string, unknown>;
if (!managed || typeof managed !== 'object' || Array.isArray(managed)
|| !['ytray', 'yakit'].includes(String(managed.manager || ''))
|| typeof managed.instanceId !== 'string' || !/^[A-Za-z0-9-]{1,160}$/.test(managed.instanceId)
|| typeof managed.badge !== 'string' || !/^[A-Z]{1,2}$/.test(managed.badge)
|| Object.keys(managed).some((key) => !['manager', 'instanceId', 'badge'].includes(key))) {
throw new Error('Bridge 浏览器实例身份无效');
}
}
if (message.type === 'challenge') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
+21 -5
View File
@@ -33,11 +33,19 @@ const CAPABILITY_METADATA = {
scopes: [], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.tabs': {
domain: 'page', access: 'read', summary: '列出当前 grant 明确共享的标签页',
domain: 'page', access: 'read', summary: '列出当前浏览器实例中的全部 HTTP(S) 标签页;配对实例无需逐页授权',
scopes: ['browser.tabs.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.tab.open': {
domain: 'page', access: 'write', summary: '在当前浏览器实例中新建并前台打开 HTTP(S) 页面',
scopes: ['browser.tabs.write'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.thumbnail': {
domain: 'page', access: 'read', summary: '读取当前可见标签页的低清预览图,供 Yakit 实例列表展示',
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.inspect': {
domain: 'isolation', access: 'read', summary: '读取共享标签页的 Cookie Store 与身份隔离上下文',
domain: 'isolation', access: 'read', summary: '读取浏览器实例内标签页的 Cookie Store 与身份隔离上下文',
scopes: ['browser.isolation.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.isolation.proof': {
@@ -67,7 +75,7 @@ const CAPABILITY_METADATA = {
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.authorization.context.get': {
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前共享会话中的短时认证上下文句柄',
domain: 'authorization', access: 'sensitive-read', summary: '实时复核并读取当前浏览器实例中的短时认证上下文句柄',
scopes: ['browser.isolation.read', 'browser.cookies.read', 'browser.storage.read'],
targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
@@ -127,7 +135,7 @@ const CAPABILITY_METADATA = {
targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
},
'browser.frames': {
domain: 'page', access: 'read', summary: '列出共享标签页中的 Frame',
domain: 'page', access: 'read', summary: '列出浏览器实例指定标签页中的 Frame',
scopes: ['browser.tabs.read'], targetMode: 'tab', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.context': {
@@ -155,6 +163,10 @@ const CAPABILITY_METADATA = {
domain: 'page', access: 'write', summary: '将目标标签页切换到前台',
scopes: ['browser.tab.activate'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.instance.close': {
domain: 'page', access: 'dangerous', summary: '关闭当前浏览器实例的全部窗口',
scopes: ['browser.instance.close'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.handoff.request': {
domain: 'handoff', access: 'write', summary: '请求用户完成扫码、MFA、验证码或设备确认',
scopes: ['browser.human.takeover'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
@@ -174,7 +186,7 @@ const CAPABILITY_METADATA = {
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.list': {
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段继续受 grant 约束',
domain: 'network', access: 'read', summary: '列出已捕获请求,敏感字段仍由 Agent 操作审核策略保护',
scopes: ['browser.network.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
},
'browser.network.clear': {
@@ -385,9 +397,11 @@ export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
...READ_CAPABILITY_SCOPES,
'browser.tabs.write',
'browser.dom.write',
'browser.isolation.manage',
'browser.tab.activate',
'browser.instance.close',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
: []),
@@ -410,6 +424,7 @@ export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.tabs.read': '标签页列表',
'browser.tabs.write': '打开网页',
'browser.isolation.read': '读取身份隔离状态',
'browser.isolation.manage': '创建隔离身份页面',
'browser.dom.read': '页面 DOM',
@@ -417,6 +432,7 @@ export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.storage.read': '页面 Storage',
'browser.cookies.read': 'Cookie',
'browser.tab.activate': '切到前台',
'browser.instance.close': '关闭浏览器实例',
'browser.page.invoke': '调用页面函数',
'browser.page.eval.expression': '执行页面表达式',
'browser.page.eval.program': '执行页面程序',
+11
View File
@@ -120,6 +120,17 @@ describe('extension request schemas', () => {
})).toThrow('HTTP(S)');
});
it('validates manager-owned browser instance binding', () => {
expect(parseExtensionRequest({
action: 'bridge.managed-instance.bind',
payload: { manager: 'ytray', instanceId: '13367db6-232a-40d1-ad84-81ee5d97634f', badge: 'B' },
}).action).toBe('bridge.managed-instance.bind');
expect(() => parseExtensionRequest({
action: 'bridge.managed-instance.bind',
payload: { manager: 'web', instanceId: '../shared', badge: '3' },
})).toThrow();
});
it('validates recording bounds and recorded page callables', () => {
expect(parseExtensionRequest({
action: 'recording.start',
+10
View File
@@ -167,12 +167,19 @@ const userAgentProfileInput = v.strictObject({
userAgent: userAgentValue,
});
const managedInstance = v.strictObject({
manager: v.picklist(['ytray', 'yakit']),
instanceId: v.pipe(v.string(), v.regex(/^[A-Za-z0-9-]{1,160}$/)),
badge: v.pipe(v.string(), v.regex(/^[A-Z]{1,2}$/)),
});
const bridgeConfig = v.strictObject({
transport: v.picklist(['native', 'websocket']),
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
autoConnect: v.boolean(),
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
managedInstance: v.optional(managedInstance),
pairedEngine: v.optional(v.strictObject({
engineIdentityId: id,
deviceId: id,
@@ -226,6 +233,7 @@ const contextOptions = {
const capabilityScopes: readonly CapabilityScope[] = [
'browser.tabs.read',
'browser.tabs.write',
'browser.isolation.read',
'browser.isolation.manage',
'browser.dom.read',
@@ -233,6 +241,7 @@ const capabilityScopes: readonly CapabilityScope[] = [
'browser.storage.read',
'browser.cookies.read',
'browser.tab.activate',
'browser.instance.close',
'browser.page.invoke',
'browser.page.eval.expression',
'browser.page.eval.program',
@@ -462,6 +471,7 @@ const payloadSchemas = {
'metrics.get': noPayload,
'metrics.reset': noPayload,
'bridge.config.save': bridgeConfig,
'bridge.managed-instance.bind': managedInstance,
'bridge.pair': noPayload,
'bridge.pair.cancel': noPayload,
'bridge.pair.status': noPayload,