feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
@@ -0,0 +1,210 @@
import { describe, expect, it, vi } from 'vitest'
import {
BrowserModelTools,
browserBackInputSchema,
browserClickInputSchema,
browserNavigateInputSchema,
browserScreenshotInputSchema,
browserSelectInputSchema,
browserSnapshotInputSchema,
browserTypeInputSchema,
type BrowserToolService
} from './browser-model-tools'
function createService(): BrowserToolService {
return {
getOrigin: vi.fn(() => 'https://example.com'),
navigate: vi.fn(async (_conversationId, url) => ({
url,
origin: 'https://example.com'
})),
snapshot: vi.fn(async () => ({
url: 'https://example.com/',
title: 'Example',
nodes: [],
truncated: false
})),
click: vi.fn(async () => undefined),
type: vi.fn(async () => undefined),
select: vi.fn(async () => undefined),
back: vi.fn(async () => ({
url: 'https://previous.example/',
origin: 'https://previous.example'
})),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
})),
releaseConversation: vi.fn(async () => undefined)
}
}
const signal = new AbortController().signal
const ref = 'b_abcdefghijklmnop'
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
const definitions = tools.listTools()
expect(definitions.map((definition) => definition.name)).toEqual([
'browser_navigate',
'browser_snapshot',
'browser_click',
'browser_type',
'browser_select',
'browser_back',
'browser_screenshot'
])
expect(
definitions.every(
(definition) =>
definition.source === 'builtin' &&
definition.inputSchema.additionalProperties === false
)
).toBe(true)
expect(tools.ownsTool('browser_upload')).toBe(false)
expect(tools.ownsTool('browser_download')).toBe(false)
})
it('uses strict Zod parsing for every operation', () => {
const cases: Array<[typeof browserSnapshotInputSchema, unknown]> = [
[browserNavigateInputSchema, { url: 'https://example.com', extra: true }],
[browserSnapshotInputSchema, { extra: true }],
[browserClickInputSchema, { ref: 'not-a-ref' }],
[browserTypeInputSchema, { ref, text: '', extra: true }],
[browserSelectInputSchema, { ref, value: '', extra: true }],
[browserBackInputSchema, { extra: true }],
[browserScreenshotInputSchema, { extra: true }]
]
for (const [schema, value] of cases) {
expect(() => schema.parse(value)).toThrow()
}
expect(() =>
browserNavigateInputSchema.parse({ url: 'file:///etc/passwd' })
).not.toThrow()
// Zod limits shape and size; the URL policy is deliberately applied by
// approval/call handling so non-HTTP schemes still fail before execution.
})
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
const approval = tools.getApproval('browser_navigate', {
url: 'https://example.com/path?token=top-secret'
})
expect(approval).toMatchObject({
scopeKey: 'model:browser:navigate:https://example.com',
allowPermanent: false
})
expect(JSON.stringify(approval)).not.toContain('top-secret')
expect(approval.argumentSummary).toContain('[查询参数已隐藏]')
expect(() =>
tools.getApproval('browser_navigate', {
url: 'file:///etc/passwd'
})
).toThrow('HTTP(S)')
})
it('redacts typed and selected values and prevents session-grant reuse', async () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
})
const first = tools.getApproval('browser_type', {
ref,
text: 'top-secret'
})
const second = tools.getApproval('browser_type', {
ref,
text: 'top-secret'
})
expect(first.scopeKey).not.toBe(second.scopeKey)
expect(first.allowPermanent).toBe(false)
expect(JSON.stringify(first)).not.toContain('top-secret')
const result = await tools.callTool(
'browser_type',
{ ref, text: 'top-secret' },
signal
)
expect(service.type).toHaveBeenCalledWith(
'conversation',
ref,
'top-secret',
signal
)
expect(JSON.stringify(result)).not.toContain('top-secret')
expect(result.parts[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('[已隐藏]')
})
const selectApproval = tools.getApproval('browser_select', {
ref,
value: 'private-value'
})
expect(JSON.stringify(selectApproval)).not.toContain('private-value')
})
it('dispatches safe operations and returns correctly counted results', async () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
})
const navigate = await tools.callTool(
'browser_navigate',
{ url: 'https://example.com/' },
signal
)
const snapshot = await tools.callTool('browser_snapshot', {}, signal)
const click = await tools.callTool('browser_click', { ref }, signal)
const back = await tools.callTool('browser_back', {}, signal)
for (const result of [navigate, snapshot, click, back]) {
const part = result.parts[0]
if (!part || part.type !== 'text') {
throw new Error('expected text result')
}
expect(result.contextBytes).toBe(Buffer.byteLength(part.text))
}
const screenshot = await tools.callTool('browser_screenshot', {}, signal)
expect(screenshot).toEqual({
parts: [
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
}
],
contextBytes: Buffer.byteLength('iVBORw0KGgo=')
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
expect(() =>
tools.getApproval('browser_click', { ref, extra: true })
).toThrow()
await expect(
tools.callTool('browser_upload', { path: 'secret.txt' }, signal)
).rejects.toThrow('未知浏览器工具')
const controller = new AbortController()
controller.abort()
await expect(
tools.callTool('browser_snapshot', {}, controller.signal)
).rejects.toHaveProperty('name', 'AbortError')
})
})
+379
View File
@@ -0,0 +1,379 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type {
ModelToolDefinition,
ModelToolResult
} from '../agent/model-tool-provider'
import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service'
const MAX_REF_LENGTH = 64
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const refSchema = z
.string()
.min(1)
.max(MAX_REF_LENGTH)
.regex(/^b_[A-Za-z0-9_-]{1,61}$/u, '')
export const browserNavigateInputSchema = z
.object({
url: z.string().min(1).max(8_192)
})
.strict()
export const browserSnapshotInputSchema = z.object({}).strict()
export const browserClickInputSchema = z
.object({
ref: refSchema
})
.strict()
export const browserTypeInputSchema = z
.object({
ref: refSchema,
text: z.string().min(1).max(MAX_INPUT_LENGTH)
})
.strict()
export const browserSelectInputSchema = z
.object({
ref: refSchema,
value: z.string().min(1).max(MAX_SELECT_LENGTH)
})
.strict()
export const browserBackInputSchema = z.object({}).strict()
export const browserScreenshotInputSchema = z.object({}).strict()
type BrowserToolName =
| 'browser_navigate'
| 'browser_snapshot'
| 'browser_click'
| 'browser_type'
| 'browser_select'
| 'browser_back'
| 'browser_screenshot'
function getBrowserToolMetadata(name: BrowserToolName) {
const summary = builtinModelTools.find((tool) => tool.name === name)
if (!summary) {
throw new Error(`缺少内置浏览器工具定义:${name}`)
}
return {
name,
displayName: summary.displayName,
description: summary.description,
source: 'builtin' as const
}
}
const definitions = [
{
...getBrowserToolMetadata('browser_navigate'),
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
minLength: 1,
maxLength: 8_192,
description: '完整的公开 HTTP(S) URL'
}
},
required: ['url'],
additionalProperties: false
},
},
{
...getBrowserToolMetadata('browser_snapshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_click'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
}
},
required: ['ref'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_type'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
text: {
type: 'string',
minLength: 1,
maxLength: MAX_INPUT_LENGTH,
description: '要输入的文本(审批界面不会显示内容)'
}
},
required: ['ref', 'text'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_select'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
value: {
type: 'string',
minLength: 1,
maxLength: MAX_SELECT_LENGTH
}
},
required: ['ref', 'value'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_back'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_screenshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
}
] as const satisfies readonly ModelToolDefinition[]
export type BrowserToolService = Pick<
BrowserService,
| 'getOrigin'
| 'navigate'
| 'snapshot'
| 'click'
| 'type'
| 'select'
| 'back'
| 'screenshot'
| 'releaseConversation'
>
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
}
function createTextResult(value: unknown): ModelToolResult {
const text = JSON.stringify(value)
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
function safeOrigin(value: string | undefined): string {
return value ?? '尚未导航'
}
function navigationLabel(url: URL): string {
const pathname =
url.pathname.length > 500 ? `${url.pathname.slice(0, 500)}` : url.pathname
return `${url.origin}${pathname}${url.search ? '?[查询参数已隐藏]' : ''}`
}
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
}
listTools(): ModelToolDefinition[] {
return definitions.map((definition) => ({
...definition,
inputSchema: { ...definition.inputSchema }
}))
}
ownsTool(name: string): name is BrowserToolName {
return definitions.some((definition) => definition.name === name)
}
getApproval(
tool: ModelToolDefinition | BrowserToolName,
argumentsValue: Record<string, unknown>,
argumentSummaryFromRuntime?: string
): RuntimeApprovalRequest {
void argumentSummaryFromRuntime
const name = typeof tool === 'string' ? tool : tool.name
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
)
let description: string
let argumentSummary: string
let scopeKey: string
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
const target = canonicalizeBrowserUrl(input.url)
const label = navigationLabel(target)
description = `将在隔离浏览器中访问 ${label}。仅允许公开 HTTP(S) 地址。`
argumentSummary = label
scopeKey = `model:browser:navigate:${target.origin}`
} else if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
description = `读取 ${currentOrigin} 的页面结构;可编辑字段值会被隐藏。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:snapshot:${currentOrigin}`
} else if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
description = `点击 ${currentOrigin} 页面中的元素 ${input.ref}`
argumentSummary = `元素:${input.ref};来源:${currentOrigin}`
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
} else if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
// A session approval must never authorize a later value, even for the
// same element. The nonce intentionally makes this invocation-only.
scopeKey = `model:browser:type:${randomUUID()}`
} else if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的选择控件 ${input.ref} 选择已隐藏的值。`
argumentSummary = `元素:${input.ref};选项值:[已隐藏,${input.value.length} 个字符]`
scopeKey = `model:browser:select:${randomUUID()}`
} else if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
description = `${currentOrigin} 返回浏览器历史记录中的上一页。目标仍需通过 URL 安全策略。`
argumentSummary = `当前来源:${currentOrigin}`
scopeKey = `model:browser:back:${randomUUID()}`
} else {
browserScreenshotInputSchema.parse(argumentsValue)
description = `截取 ${currentOrigin} 当前可见页面区域。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:screenshot:${currentOrigin}`
}
const definition = definitions.find((item) => item.name === name)
if (!definition) {
throw new Error(`未知浏览器工具:${name}`)
}
return {
scopeKey,
title: `允许${definition.displayName}`,
description,
toolName: definition.displayName,
argumentSummary,
allowPermanent: false
}
}
async callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
await this.service.type(
this.conversationId,
input.ref,
input.text,
signal
)
return createTextResult({
typed: input.ref,
text: '[已隐藏]',
characters: input.text.length
})
}
if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
await this.service.select(
this.conversationId,
input.ref,
input.value,
signal
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
)
return {
parts: [
{
type: 'image',
mimeType: screenshot.mimeType,
data: screenshot.data
}
],
contextBytes: Buffer.byteLength(screenshot.data)
}
}
async release(): Promise<void> {
await this.service.releaseConversation(this.conversationId)
}
}
+473
View File
@@ -0,0 +1,473 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
BrowserService,
type BrowserDriverLike,
type BrowserSessionLike
} from './browser-service'
import type { BrowserWebContents } from './electron-browser-session'
type HarnessSlot = {
currentOrigin?: string
approvedOrigin?: string
session: BrowserSessionLike
driver: BrowserDriverLike
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function createHarness(options: {
maximumSessions?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
sessionGate?: Promise<void>
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
await options.sessionGate
const webContents = {} as BrowserWebContents
const slot = {} as HarnessSlot
const session: BrowserSessionLike = {
webContents,
approveNavigation: vi.fn((target) => {
slot.approvedOrigin = target.origin
}),
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
dispose: vi.fn(options.dispose ?? (async () => undefined))
}
const driver: BrowserDriverLike = {
navigate: vi.fn(async (url) => {
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
return { url }
}),
snapshot: vi.fn(async () => ({
url: `${slot.currentOrigin}/page`,
title: 'Page',
nodes: [],
truncated: false
})),
click: vi.fn(async () => undefined),
type: vi.fn(async () => undefined),
select: vi.fn(async () => undefined),
getBackTarget: vi.fn(async () => ({
entryId: 4,
url: 'https://previous.example/back'
})),
backTo: vi.fn(async (target) => {
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
return { url: target.url }
}),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
})),
dispose: vi.fn()
}
Object.assign(slot, { session, driver })
slots.push(slot)
byContents.set(webContents, slot)
return session
})
const service = new BrowserService({
policy: new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
]),
maximumSessions: options.maximumSessions,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
createSession,
createDriver: (contents) => {
const slot = byContents.get(contents)
if (!slot) {
throw new Error('unknown contents')
}
return slot.driver
}
})
return { createSession, service, slots }
}
afterEach(() => {
vi.useRealTimers()
})
describe('BrowserService', () => {
it('publishes browser status and live frames through session cleanup', async () => {
const harness = createHarness()
const states: Array<{
status: string
frameDataUrl?: string
}> = []
const removeListener = harness.service.onState((state) => {
states.push(state)
})
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.click('conversation', 'button_ref', signal)
await harness.service.releaseConversation('conversation')
expect(states.map((state) => state.status)).toEqual([
'creating',
'loading',
'ready',
'acting',
'ready',
'stopped'
])
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
'data:image/png;base64,iVBORw0KGgo='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
const replayed: string[] = []
const removeReplayListener = harness.service.onState((state) => {
replayed.push(state.status)
})
expect(replayed).toEqual([])
removeReplayListener()
removeListener()
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during frame capture', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: string[] = []
harness.service.onState((state) => states.push(state.status))
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.screenshot).mockImplementationOnce(
async (operationSignal) =>
new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const click = harness.service.click(
'conversation',
'button_ref',
signal
)
await vi.waitFor(() =>
expect(slot.driver.screenshot).toHaveBeenCalledTimes(2)
)
await harness.service.releaseConversation('conversation')
await expect(click).rejects.toThrow('浏览器会话已释放')
expect(states.at(-1)).toBe('stopped')
})
it('isolates browser state and drivers by conversation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation-a', 'https://a.example/', signal)
await harness.service.navigate('conversation-b', 'https://b.example/', signal)
await harness.service.snapshot('conversation-a', signal)
expect(harness.service.getSessionCount()).toBe(2)
expect(harness.service.getOrigin('conversation-a')).toBe(
'https://a.example'
)
expect(harness.service.getOrigin('conversation-b')).toBe(
'https://b.example'
)
expect(harness.slots[0]?.driver.snapshot).toHaveBeenCalledOnce()
expect(harness.slots[1]?.driver.snapshot).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
for (const id of ['one', 'two', 'three']) {
await harness.service.navigate(id, `https://${id}.example/`, signal)
}
await expect(
harness.service.navigate('four', 'https://four.example/', signal)
).rejects.toThrow('3 个上限')
expect(harness.createSession).toHaveBeenCalledTimes(3)
await harness.service.dispose()
})
it('serializes operations in one conversation and lets queued callers cancel', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.click).mockImplementationOnce(async () =>
clickGate.promise
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(slot.driver.click).toHaveBeenCalled())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
expect(slot.driver.snapshot).not.toHaveBeenCalled()
clickGate.resolve()
await click
await harness.service.dispose()
})
it('does not let a canceled queued waiter clear the active operation owner', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
let activeSignal: AbortSignal | undefined
vi.mocked(slot.driver.click).mockImplementationOnce(
async (_ref, operationSignal) => {
activeSignal = operationSignal
await clickGate.promise
}
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(activeSignal).toBeDefined())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
const clickResult = expect(click).rejects.toThrow(
'浏览器会话已释放'
)
await harness.service.releaseConversation('conversation')
expect(activeSignal?.aborted).toBe(true)
clickGate.resolve()
await clickResult
await harness.service.dispose()
})
it('abandons a sole canceled creation without retaining or consuming a slot', async () => {
const creationGate = deferred<void>()
const harness = createHarness({
maximumSessions: 1,
sessionGate: creationGate.promise
})
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'canceled',
'https://canceled.example/',
canceledController.signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel creation'))
await expect(canceled).rejects.toThrow('cancel creation')
expect(harness.service.getSessionCount()).toBe(0)
const replacement = harness.service.navigate(
'replacement',
'https://replacement.example/',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.createSession).toHaveBeenCalledTimes(2)
)
creationGate.resolve()
await expect(replacement).resolves.toMatchObject({
origin: 'https://replacement.example'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('preserves a shared creation while another waiter cancels', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'conversation',
'https://example.com/first',
canceledController.signal
)
const shared = harness.service.navigate(
'conversation',
'https://example.com/second',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel one waiter'))
await expect(canceled).rejects.toThrow('cancel one waiter')
creationGate.resolve()
await expect(shared).resolves.toMatchObject({
origin: 'https://example.com'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('expires idle sessions and clears their isolated resources', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
await harness.service.navigate(
'conversation',
'https://a.example/',
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(101)
await vi.waitFor(() => expect(harness.service.getSessionCount()).toBe(0))
expect(harness.slots[0]?.driver.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://current.example/',
signal
)
await expect(harness.service.back('conversation', signal)).resolves.toEqual({
url: 'https://previous.example/back',
origin: 'https://previous.example'
})
expect(harness.service.getOrigin('conversation')).toBe(
'https://previous.example'
)
expect(harness.slots[0]?.approvedOrigin).toBe(
'https://previous.example'
)
await harness.service.dispose()
})
it('fails closed and releases a slot when navigation origin does not match', async () => {
const harness = createHarness()
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
slot.currentOrigin = 'https://attacker.example'
await expect(
harness.service.snapshot(
'conversation',
new AbortController().signal
)
).rejects.toThrow('来源已改变')
expect(harness.service.getSessionCount()).toBe(0)
expect(slot.session.dispose).toHaveBeenCalled()
await harness.service.dispose()
})
it('bounds cleanup and makes release and dispose idempotent', async () => {
const harness = createHarness({
cleanupTimeoutMs: 5,
dispose: async () => new Promise(() => undefined)
})
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
})
it('settles a creation and release race without blocking later reuse', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const firstNavigation = harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
const release = harness.service.releaseConversation('conversation')
creationGate.resolve()
await release
await expect(firstNavigation).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/new',
new AbortController().signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
it('clears current sessions and remains reusable', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.clearSessions()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/again',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
})
+863
View File
@@ -0,0 +1,863 @@
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
CdpBrowserDriver,
type BrowserHistoryTarget,
type BrowserScreenshot,
type BrowserSnapshot
} from './cdp-browser-driver'
import {
ElectronBrowserSession,
type BrowserWebContents
} from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000
const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000
export type BrowserSessionLike = {
readonly webContents: BrowserWebContents
approveNavigation(
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
): void
getCurrentOrigin(): string | undefined
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
export type BrowserDriverLike = {
navigate(url: string, signal: AbortSignal): Promise<{ url: string }>
snapshot(signal: AbortSignal): Promise<BrowserSnapshot>
click(ref: string, signal: AbortSignal): Promise<void>
type(ref: string, text: string, signal: AbortSignal): Promise<void>
select(ref: string, value: string, signal: AbortSignal): Promise<void>
getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget>
backTo(
target: BrowserHistoryTarget,
signal: AbortSignal
): Promise<{ url: string }>
screenshot(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): void
}
export type BrowserServiceOptions = {
policy?: BrowserUrlPolicy
maximumSessions?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
liveFrameDelayMs?: number
createSession?: (
policy: BrowserUrlPolicy,
signal: AbortSignal
) => Promise<BrowserSessionLike>
createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike
}
type BrowserSlot = {
conversationId: string
session: BrowserSessionLike
driver: BrowserDriverLike
origin?: string
tail: Promise<void>
active?: AbortController
idleTimer?: ReturnType<typeof setTimeout>
lastUsedAt: number
released: boolean
}
type SlotCreation = {
controller: AbortController
promise: Promise<BrowserSlot>
waiters: Set<symbol>
}
function waitFor<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((resolve, reject) => {
const abort = (): void => reject(signal.reason)
signal.addEventListener('abort', abort, { once: true })
void promise.then(
(value) => {
signal.removeEventListener('abort', abort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async function boundedCleanup(
cleanup: Promise<void>,
timeoutMs: number
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
cleanup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error('浏览器会话清理超时')),
timeoutMs
)
})
])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
async function defaultCreateSession(
policy: BrowserUrlPolicy,
signal: AbortSignal
): Promise<BrowserSessionLike> {
return ElectronBrowserSession.create({ policy }, signal)
}
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
return new CdpBrowserDriver(webContents)
}
export class BrowserService {
private readonly policy: BrowserUrlPolicy
private readonly maximumSessions: number
private readonly idleTimeoutMs: number
private readonly cleanupTimeoutMs: number
private readonly liveFrameDelayMs: number
private readonly createSession: NonNullable<
BrowserServiceOptions['createSession']
>
private readonly createDriver: NonNullable<
BrowserServiceOptions['createDriver']
>
private readonly slots = new Map<string, BrowserSlot>()
private readonly creations = new Map<string, SlotCreation>()
private readonly releaseRequests = new Set<string>()
private readonly stateListeners = new Set<
(state: BrowserLiveState) => void
>()
private readonly liveStates = new Map<string, BrowserLiveState>()
private lifecycle = new AbortController()
private clearOperation?: Promise<void>
private clearing = false
private disposed = false
constructor(options: BrowserServiceOptions = {}) {
this.policy = options.policy ?? new BrowserUrlPolicy()
this.maximumSessions = options.maximumSessions ?? 3
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
this.cleanupTimeoutMs =
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
this.createSession = options.createSession ?? defaultCreateSession
this.createDriver = options.createDriver ?? defaultCreateDriver
if (
!Number.isSafeInteger(this.maximumSessions) ||
this.maximumSessions < 1 ||
!Number.isSafeInteger(this.idleTimeoutMs) ||
this.idleTimeoutMs < 1 ||
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
this.cleanupTimeoutMs < 1 ||
!Number.isSafeInteger(this.liveFrameDelayMs) ||
this.liveFrameDelayMs < 0
) {
throw new Error('浏览器服务限制配置无效')
}
}
getOrigin(conversationId: string): string | undefined {
return this.slots.get(conversationId)?.origin
}
getSessionCount(): number {
return this.slots.size
}
onState(listener: (state: BrowserLiveState) => void): () => void {
this.stateListeners.add(listener)
for (const state of this.liveStates.values()) {
listener(state)
}
return () => {
this.stateListeners.delete(listener)
}
}
private emitState(
conversationId: string,
status: BrowserLiveState['status'],
update: Partial<
Pick<BrowserLiveState, 'url' | 'frameDataUrl' | 'error'>
> = {}
): void {
const previous = this.liveStates.get(conversationId)
const state: BrowserLiveState = {
conversationId,
status,
...(previous?.url ? { url: previous.url } : {}),
...update,
updatedAt: Date.now()
}
if (status !== 'failed') {
delete state.error
}
if (status === 'stopped') {
this.liveStates.delete(conversationId)
} else {
this.liveStates.set(conversationId, state)
}
for (const listener of this.stateListeners) {
try {
listener(state)
} catch {
// A UI observer must not interrupt browser control.
}
}
}
private async captureFrame(
conversationId: string,
slot: BrowserSlot,
signal: AbortSignal,
url?: string,
screenshot?: BrowserScreenshot
): Promise<void> {
let frame = screenshot
if (!frame) {
if (this.liveFrameDelayMs > 0) {
await waitFor(
new Promise<void>((resolve) =>
setTimeout(resolve, this.liveFrameDelayMs)
),
signal
)
}
const previewController = new AbortController()
const timeout = setTimeout(
() =>
previewController.abort(
new Error('浏览器实时画面捕获超时')
),
2_000
)
try {
const previewSignal = AbortSignal.any([
signal,
previewController.signal
])
frame = slot.session.captureScreenshot
? await slot.session.captureScreenshot(previewSignal)
: await slot.driver.screenshot(previewSignal)
} catch {
signal.throwIfAborted()
// Browser control succeeds even when the optional live frame fails.
} finally {
clearTimeout(timeout)
}
}
signal.throwIfAborted()
if (slot.released || this.slots.get(conversationId) !== slot) {
return
}
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
...(frame
? {
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
}
: {})
})
}
private emitFailure(
conversationId: string,
stage: string,
error: unknown
): void {
const detail =
error instanceof Error && error.message
? error.message.slice(0, 180)
: '未知错误'
this.emitState(conversationId, 'failed', {
error: `${stage}失败:${detail}`.slice(0, 240)
})
}
private shouldEmitFailure(
conversationId: string,
signal: AbortSignal,
error: unknown
): boolean {
if (signal.aborted || this.releaseRequests.has(conversationId)) {
return false
}
const message = error instanceof Error ? error.message : ''
return ![
'浏览器会话已释放',
'浏览器会话已清除',
'浏览器会话已关闭',
'浏览器服务已关闭'
].some((reason) => message.includes(reason))
}
private async getOrCreateSlot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserSlot> {
if (this.disposed || this.clearing || this.lifecycle.signal.aborted) {
throw new Error('浏览器服务已关闭')
}
const existing = this.slots.get(conversationId)
if (existing && !existing.released) {
return existing
}
let creation = this.creations.get(conversationId)
const pendingCreations = [...this.creations.keys()].filter(
(id) => !this.slots.has(id)
).length
if (
!creation &&
this.slots.size + pendingCreations >= this.maximumSessions
) {
throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`)
}
if (!creation) {
const controller = new AbortController()
const waiters = new Set<symbol>()
const promise = this.createSlot(
conversationId,
AbortSignal.any([this.lifecycle.signal, controller.signal]),
() => waiters.size > 0
)
const currentCreation = { controller, promise, waiters }
creation = currentCreation
this.creations.set(conversationId, creation)
const removeCreation = (): void => {
if (this.creations.get(conversationId) === creation) {
this.creations.delete(conversationId)
}
}
void promise.then(removeCreation, removeCreation)
}
const waiter = Symbol(conversationId)
creation.waiters.add(waiter)
try {
return await waitFor(creation.promise, signal)
} finally {
creation.waiters.delete(waiter)
if (
creation.waiters.size === 0 &&
this.creations.get(conversationId) === creation
) {
this.creations.delete(conversationId)
creation.controller.abort(new Error('浏览器会话创建已取消'))
}
}
}
private async createSlot(
conversationId: string,
signal: AbortSignal,
hasWaiters: () => boolean
): Promise<BrowserSlot> {
const session = await this.createSession(
this.policy,
signal
)
if (
this.disposed ||
signal.aborted ||
!hasWaiters() ||
this.releaseRequests.has(conversationId)
) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw new Error('浏览器服务已关闭')
}
let driver: BrowserDriverLike
try {
driver = this.createDriver(session.webContents)
} catch (error) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(
() => undefined
)
throw error
}
const slot: BrowserSlot = {
conversationId,
session,
driver,
tail: Promise.resolve(),
lastUsedAt: Date.now(),
released: false
}
this.slots.set(conversationId, slot)
this.scheduleIdleExpiry(slot)
return slot
}
private requireSlot(conversationId: string): BrowserSlot {
if (this.disposed) {
throw new Error('浏览器服务已关闭')
}
const slot = this.slots.get(conversationId)
if (!slot || slot.released || !slot.origin) {
throw new Error('当前对话尚未建立浏览器会话,请先导航')
}
return slot
}
private scheduleIdleExpiry(slot: BrowserSlot): void {
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
}
if (slot.released || this.disposed) {
return
}
slot.idleTimer = setTimeout(() => {
if (Date.now() - slot.lastUsedAt < this.idleTimeoutMs) {
this.scheduleIdleExpiry(slot)
return
}
void this.releaseSlot(slot).catch(() => undefined)
}, this.idleTimeoutMs)
}
private async serialize<T>(
slot: BrowserSlot,
signal: AbortSignal,
operation: (effectiveSignal: AbortSignal) => Promise<T>,
status?: 'loading' | 'acting'
): Promise<T> {
signal.throwIfAborted()
if (slot.released || this.disposed) {
throw new Error('浏览器会话已关闭')
}
let releaseGate!: () => void
const gate = new Promise<void>((resolve) => {
releaseGate = resolve
})
const predecessor = slot.tail.catch(() => undefined)
slot.tail = predecessor.then(() => gate)
let operationController: AbortController | undefined
try {
await waitFor(predecessor, signal)
if (slot.released || this.disposed) {
throw new Error('浏览器会话已关闭')
}
if (status) {
this.emitState(slot.conversationId, status)
}
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
slot.idleTimer = undefined
}
operationController = new AbortController()
slot.active = operationController
const effectiveSignal = AbortSignal.any([
signal,
this.lifecycle.signal,
operationController.signal
])
return await operation(effectiveSignal)
} finally {
if (operationController && slot.active === operationController) {
slot.active = undefined
}
releaseGate()
if (operationController) {
slot.lastUsedAt = Date.now()
this.scheduleIdleExpiry(slot)
}
}
}
private verifyCurrentOrigin(slot: BrowserSlot): string {
const current = slot.session.getCurrentOrigin()
if (!slot.origin || current !== slot.origin) {
throw new Error('浏览器页面来源已改变,会话已被拒绝')
}
return current
}
private async verifyCurrentOriginOrRelease(
slot: BrowserSlot
): Promise<string> {
try {
return this.verifyCurrentOrigin(slot)
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}
private async runInSession<T>(
conversationId: string,
signal: AbortSignal,
status: 'loading' | 'acting',
failureStage: string,
operation: (
slot: BrowserSlot,
effectiveSignal: AbortSignal
) => Promise<T>
): Promise<T> {
try {
const slot = this.requireSlot(conversationId)
return await this.serialize(
slot,
signal,
(effectiveSignal) => operation(slot, effectiveSignal),
status
)
} catch (error) {
if (this.shouldEmitFailure(conversationId, signal, error)) {
this.emitFailure(conversationId, failureStage, error)
}
throw error
}
}
async navigate(
conversationId: string,
url: string,
signal: AbortSignal
): Promise<{ url: string; origin: string }> {
if (
!this.slots.has(conversationId) &&
!this.creations.has(conversationId)
) {
this.emitState(conversationId, 'creating', { url })
}
try {
const slot = await this.getOrCreateSlot(conversationId, signal)
return await this.serialize(slot, signal, async (effectiveSignal) => {
const target = await this.policy.validate(url, effectiveSignal)
slot.session.approveNavigation(target)
try {
const result = await slot.driver.navigate(
target.url.href,
effectiveSignal
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
throw new Error('浏览器导航结果来源不一致')
}
slot.origin = finalTarget.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
finalTarget.url.href
)
return {
url: finalTarget.url.href,
origin: finalTarget.origin
}
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}, 'loading')
} catch (error) {
if (this.shouldEmitFailure(conversationId, signal, error)) {
this.emitFailure(conversationId, '浏览器导航', error)
}
throw error
}
}
async snapshot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserSnapshot> {
return this.runInSession(
conversationId,
signal,
'acting',
'读取浏览器页面',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const snapshot = await slot.driver.snapshot(effectiveSignal)
const target = canonicalizeBrowserUrl(snapshot.url)
if (target.origin !== slot.origin) {
await this.releaseSlot(slot).catch(() => undefined)
throw new Error('浏览器快照来源与当前会话不一致')
}
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
target.href
)
return { ...snapshot, url: target.href }
}
)
}
async click(
conversationId: string,
ref: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器点击',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.click(ref, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async type(
conversationId: string,
ref: string,
text: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器输入',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.type(ref, text, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async select(
conversationId: string,
ref: string,
value: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器选择',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.select(ref, value, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async back(
conversationId: string,
signal: AbortSignal
): Promise<{ url: string; origin: string }> {
return this.runInSession(
conversationId,
signal,
'loading',
'浏览器返回',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const historyTarget =
await slot.driver.getBackTarget(effectiveSignal)
const target = await this.policy.validate(
historyTarget.url,
effectiveSignal
)
slot.session.approveNavigation(target)
try {
const result = await slot.driver.backTo(
historyTarget,
effectiveSignal
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
throw new Error('浏览器返回结果来源不一致')
}
slot.origin = finalTarget.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
finalTarget.url.href
)
return {
url: finalTarget.url.href,
origin: finalTarget.origin
}
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}
)
}
async screenshot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserScreenshot> {
return this.runInSession(
conversationId,
signal,
'acting',
'浏览器截图',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const screenshot =
await slot.driver.screenshot(effectiveSignal)
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
undefined,
screenshot
)
return screenshot
}
)
}
async releaseConversation(conversationId: string): Promise<void> {
this.releaseRequests.add(conversationId)
let releasedSlot = false
try {
const creation = this.creations.get(conversationId)
if (creation) {
creation.controller.abort(new Error('浏览器会话已释放'))
const slot = await creation.promise.catch(() => undefined)
if (slot) {
await this.releaseSlot(slot)
releasedSlot = true
}
}
const slot = this.slots.get(conversationId)
if (slot) {
await this.releaseSlot(slot)
releasedSlot = true
}
if (!releasedSlot) {
this.emitState(conversationId, 'stopped')
}
} finally {
if (!this.disposed) {
this.releaseRequests.delete(conversationId)
}
}
}
private async releaseSlot(slot: BrowserSlot): Promise<void> {
if (slot.released) {
return
}
slot.released = true
this.slots.delete(slot.conversationId)
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
slot.idleTimer = undefined
}
slot.active?.abort(new Error('浏览器会话已释放'))
try {
slot.driver.dispose()
} finally {
try {
await boundedCleanup(slot.session.dispose(), this.cleanupTimeoutMs)
} finally {
this.emitState(slot.conversationId, 'stopped')
}
}
}
clearSessions(): Promise<void> {
if (this.disposed) {
return Promise.resolve()
}
if (this.clearOperation) {
return this.clearOperation
}
const operation = this.performClearSessions()
this.clearOperation = operation
void operation.then(
() => {
if (this.clearOperation === operation) {
this.clearOperation = undefined
}
},
() => {
if (this.clearOperation === operation) {
this.clearOperation = undefined
}
}
)
return operation
}
private async performClearSessions(): Promise<void> {
this.clearing = true
const lifecycle = this.lifecycle
lifecycle.abort(new Error('浏览器会话已清除'))
const requestedReleases = new Set(this.creations.keys())
for (const conversationId of requestedReleases) {
this.releaseRequests.add(conversationId)
}
const slots = new Set(this.slots.values())
try {
await Promise.allSettled(
[...this.creations.values()].map((creation) => creation.promise)
)
for (const slot of this.slots.values()) {
slots.add(slot)
}
await Promise.allSettled(
[...slots].map((slot) => this.releaseSlot(slot))
)
this.slots.clear()
} finally {
if (!this.disposed) {
for (const conversationId of requestedReleases) {
this.releaseRequests.delete(conversationId)
}
this.lifecycle = new AbortController()
this.clearing = false
}
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.clearing = true
this.lifecycle.abort(new Error('浏览器服务已关闭'))
const slots = new Set(this.slots.values())
for (const [conversationId, creation] of this.creations) {
this.releaseRequests.add(conversationId)
creation.controller.abort(new Error('浏览器服务已关闭'))
const created = await creation.promise.catch(() => undefined)
if (created) {
slots.add(created)
}
}
for (const slot of this.slots.values()) {
slots.add(slot)
}
await Promise.allSettled([...slots].map((slot) => this.releaseSlot(slot)))
this.slots.clear()
this.stateListeners.clear()
this.liveStates.clear()
}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
isPublicBrowserAddress
} from './browser-url-policy'
const signal = new AbortController().signal
describe('BrowserUrlPolicy', () => {
it.each([
'file:///etc/passwd',
'data:text/html,hello',
'javascript:alert(1)',
'ssh://example.com',
'https://user:secret@example.com/',
'http://localhost/',
'http://printer/',
'http://service.local/',
'http://metadata.google.internal/',
'http://169.254.169.254/latest/meta-data/',
'http://[::1]/'
])('rejects unsafe URL %s', (url) => {
expect(() => canonicalizeBrowserUrl(url)).toThrow()
})
it.each([
'0.0.0.0',
'10.0.0.1',
'100.64.0.1',
'127.0.0.1',
'169.254.169.254',
'172.20.1.1',
'192.168.1.1',
'192.0.2.1',
'224.0.0.1',
'::',
'::1',
'::ffff:127.0.0.1',
'fc00::1',
'fe80::1',
'ff02::1',
'2001:db8::1'
])('classifies %s as non-public', (address) => {
expect(isPublicBrowserAddress(address)).toBe(false)
})
it('accepts canonical public HTTP(S) URLs and strips fragments', async () => {
const resolver = vi.fn(async () => [
{ address: '93.184.216.34', family: 4 as const },
{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 as const }
])
const policy = new BrowserUrlPolicy(resolver)
await expect(
policy.validate('https://example.com:8443/docs?q=1#section', signal)
).resolves.toMatchObject({
origin: 'https://example.com:8443',
url: expect.objectContaining({
href: 'https://example.com:8443/docs?q=1'
})
})
expect(resolver).toHaveBeenCalledWith(
'example.com',
expect.any(AbortSignal)
)
})
it('rejects empty, private, malformed, and mixed DNS answers', async () => {
for (const answers of [
[],
[{ address: '10.0.0.2', family: 4 as const }],
[
{ address: '93.184.216.34', family: 4 as const },
{ address: '127.0.0.1', family: 4 as const }
],
[{ address: 'not-an-address', family: 4 as const }]
]) {
const policy = new BrowserUrlPolicy(async () => answers)
await expect(policy.validate('https://example.com', signal)).rejects.toThrow(
'混合地址'
)
}
})
it('validates redirects and keeps them on the approved origin', async () => {
const policy = new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
])
await expect(
policy.validateRedirect(
'https://example.com/next',
'https://example.com',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
await expect(
policy.validateRedirect(
'https://other.example/next',
'https://example.com',
signal
)
).rejects.toThrow('超出已批准来源')
})
it('honors cancellation before and after DNS resolution', async () => {
const before = new AbortController()
before.abort()
await expect(
new BrowserUrlPolicy(vi.fn()).validate('https://example.com', before.signal)
).rejects.toHaveProperty('name', 'AbortError')
const after = new AbortController()
const policy = new BrowserUrlPolicy(async () => {
after.abort()
return [{ address: '93.184.216.34', family: 4 }]
})
await expect(
policy.validate('https://example.com', after.signal)
).rejects.toHaveProperty('name', 'AbortError')
})
})
+350
View File
@@ -0,0 +1,350 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
export type BrowserResolvedAddress = {
address: string
family: 4 | 6
}
export type BrowserDnsResolver = (
hostname: string,
signal: AbortSignal
) => Promise<readonly BrowserResolvedAddress[]>
export type ValidatedBrowserUrl = {
url: URL
origin: string
addresses: readonly BrowserResolvedAddress[]
}
const LOCAL_HOST_SUFFIXES = [
'.home',
'.internal',
'.invalid',
'.lan',
'.local',
'.localdomain',
'.localhost',
'.test'
]
const BLOCKED_HOSTS = new Set([
'instance-data',
'instance-data.ec2.internal',
'metadata',
'metadata.aws.internal',
'metadata.google.internal'
])
function ipv4Number(address: string): number | undefined {
if (isIP(address) !== 4) {
return undefined
}
const octets = address.split('.').map(Number)
if (octets.length !== 4) {
return undefined
}
return (
(((octets[0] ?? 0) << 24) |
((octets[1] ?? 0) << 16) |
((octets[2] ?? 0) << 8) |
(octets[3] ?? 0)) >>>
0
)
}
function inIpv4Range(value: number, base: number, prefix: number): boolean {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return (value & mask) === (base & mask)
}
function isPublicIpv4(address: string): boolean {
const value = ipv4Number(address)
if (value === undefined) {
return false
}
const blocked: Array<[number, number]> = [
[0x00000000, 8],
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xa9fe0000, 16],
[0xac100000, 12],
[0xc0000000, 24],
[0xc0000200, 24],
[0xc0586300, 24],
[0xc0a80000, 16],
[0xc6120000, 15],
[0xc6336400, 24],
[0xcb007100, 24],
[0xe0000000, 4],
[0xf0000000, 4]
]
return !blocked.some(([base, prefix]) =>
inIpv4Range(value, base, prefix)
)
}
function expandIpv6(address: string): readonly number[] | undefined {
const withoutZone = address.toLowerCase().split('%', 1)[0] ?? ''
if (isIP(withoutZone) !== 6) {
return undefined
}
let normalized = withoutZone
const ipv4Match = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/u)
if (ipv4Match) {
const ipv4 = ipv4Number(ipv4Match[1] ?? '')
if (ipv4 === undefined) {
return undefined
}
normalized = normalized.replace(
ipv4Match[1] ?? '',
`${((ipv4 >>> 16) & 0xffff).toString(16)}:${(ipv4 & 0xffff).toString(16)}`
)
}
const halves = normalized.split('::')
if (halves.length > 2) {
return undefined
}
const left = (halves[0] ?? '').split(':').filter(Boolean)
const right = (halves[1] ?? '').split(':').filter(Boolean)
const missing = 8 - left.length - right.length
if (
(halves.length === 1 && missing !== 0) ||
(halves.length === 2 && missing < 1)
) {
return undefined
}
const groups = [
...left,
...Array.from({ length: Math.max(0, missing) }, () => '0'),
...right
].map((group) => Number.parseInt(group, 16))
return groups.length === 8 &&
groups.every((group) => Number.isInteger(group) && group <= 0xffff)
? groups
: undefined
}
function ipv6Prefix(
groups: readonly number[],
expected: readonly number[],
prefixBits: number
): boolean {
let remaining = prefixBits
for (let index = 0; remaining > 0; index += 1) {
const bits = Math.min(16, remaining)
const mask = (0xffff << (16 - bits)) & 0xffff
if (((groups[index] ?? 0) & mask) !== ((expected[index] ?? 0) & mask)) {
return false
}
remaining -= bits
}
return true
}
function isPublicIpv6(address: string): boolean {
const groups = expandIpv6(address)
if (!groups) {
return false
}
if (groups.slice(0, 5).every((group) => group === 0)) {
const sixth = groups[5] ?? 0
if (sixth === 0xffff) {
const mapped = `${(groups[6] ?? 0) >>> 8}.${(groups[6] ?? 0) & 0xff}.${(groups[7] ?? 0) >>> 8}.${(groups[7] ?? 0) & 0xff}`
return isPublicIpv4(mapped)
}
if (sixth === 0) {
return false
}
}
const blocked: Array<[readonly number[], number]> = [
[[0, 0, 0, 0, 0, 0, 0, 0], 128],
[[0, 0, 0, 0, 0, 0, 0, 1], 128],
[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96],
[[0x64, 0xff9b, 1, 0, 0, 0, 0, 0], 48],
[[0x100, 0, 0, 0, 0, 0, 0, 0], 64],
[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32],
[[0x2001, 2, 0, 0, 0, 0, 0, 0], 48],
[[0x2001, 0x10, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32],
[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16],
[[0x3fff, 0, 0, 0, 0, 0, 0, 0], 20],
[[0x5f00, 0, 0, 0, 0, 0, 0, 0], 16],
[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7],
[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xfec0, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8]
]
return !blocked.some(([prefix, bits]) =>
ipv6Prefix(groups, prefix, bits)
)
}
export function isPublicBrowserAddress(address: string): boolean {
const family = isIP(address.split('%', 1)[0] ?? '')
return family === 4
? isPublicIpv4(address)
: family === 6
? isPublicIpv6(address)
: false
}
export function canonicalizeBrowserUrl(input: string): URL {
if (input !== input.trim() || input.length === 0 || input.length > 8_192) {
throw new Error('浏览器 URL 无效')
}
let url: URL
try {
url = new URL(input)
} catch {
throw new Error('浏览器 URL 无效')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('浏览器仅支持 HTTP(S) URL')
}
if (url.username || url.password || !url.hostname || url.origin === 'null') {
throw new Error('浏览器 URL 不允许包含凭据或无效来源')
}
const rawHostname = url.hostname.toLowerCase()
const hostname = (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
).replace(/\.$/u, '')
if (
hostname !== (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
) ||
(!hostname.includes('.') && isIP(hostname) === 0) ||
BLOCKED_HOSTS.has(hostname) ||
LOCAL_HOST_SUFFIXES.some(
(suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix)
)
) {
throw new Error('浏览器 URL 不允许访问本机或内部名称')
}
if (isIP(hostname) !== 0 && !isPublicBrowserAddress(hostname)) {
throw new Error('浏览器 URL 不允许访问私有或保留地址')
}
url.hash = ''
return url
}
async function defaultResolver(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
signal.throwIfAborted()
const result = await dnsLookup(hostname, {
all: true,
verbatim: true
})
signal.throwIfAborted()
return result
.filter(
(entry): entry is { address: string; family: 4 | 6 } =>
entry.family === 4 || entry.family === 6
)
.map((entry) => ({ address: entry.address, family: entry.family }))
}
export class BrowserUrlPolicy {
constructor(
private readonly resolveDns: BrowserDnsResolver = defaultResolver,
private readonly resolutionTimeoutMs = 10_000
) {
if (
!Number.isSafeInteger(resolutionTimeoutMs) ||
resolutionTimeoutMs < 1
) {
throw new Error('浏览器 DNS 解析期限无效')
}
}
private async resolve(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
const timeout = AbortSignal.timeout(this.resolutionTimeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
const resolution = this.resolveDns(hostname, effectiveSignal)
return new Promise((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器 DNS 解析超时(${this.resolutionTimeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void resolution.then(
(addresses) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(addresses)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async validate(
input: string | URL,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
signal.throwIfAborted()
const url = canonicalizeBrowserUrl(
typeof input === 'string' ? input : input.toString()
)
const literalHostname =
url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname
const literalFamily = isIP(literalHostname)
const addresses =
literalFamily === 4 || literalFamily === 6
? [{
address: literalHostname,
family: literalFamily
} as const]
: await this.resolve(url.hostname, signal)
signal.throwIfAborted()
if (
addresses.length === 0 ||
addresses.some(
(entry) =>
entry.family !== isIP(entry.address) ||
!isPublicBrowserAddress(entry.address)
)
) {
throw new Error('浏览器目标解析到私有、保留或混合地址')
}
return {
url,
origin: url.origin,
addresses: [...addresses]
}
}
async validateRedirect(
input: string,
approvedOrigin: string,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
const target = await this.validate(input, signal)
if (target.origin !== approvedOrigin) {
throw new Error('浏览器重定向超出已批准来源')
}
return target
}
}
+470
View File
@@ -0,0 +1,470 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { CdpBrowserDriver } from './cdp-browser-driver'
import type {
BrowserDebugger,
BrowserWebContents
} from './electron-browser-session'
function createHarness(
command: (
method: string,
parameters?: Record<string, unknown>
) => Promise<unknown>
) {
const contentEvents = new EventEmitter()
const debuggerEvents = new EventEmitter()
let currentUrl = 'https://example.com/page'
const sendCommand = vi.fn(command)
const browserDebugger: BrowserDebugger = {
attach: vi.fn(),
detach: vi.fn(),
isAttached: vi.fn(() => true),
sendCommand,
on: (event, listener) =>
debuggerEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
debuggerEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
)
}
const webContents: BrowserWebContents = {
debugger: browserDebugger,
on: (event, listener) =>
contentEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
contentEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
setWindowOpenHandler: vi.fn(),
getURL: vi.fn(() => currentUrl),
stop: vi.fn(),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
return {
browserDebugger,
contentEvents,
debuggerEvents,
sendCommand,
webContents,
setUrl(url: string) {
currentUrl = url
}
}
}
function standardCommand(
method: string,
parameters?: Record<string, unknown>
): Promise<unknown> {
if (method === 'Accessibility.getFullAXTree') {
return Promise.resolve({
nodes: [
{
nodeId: 'root',
backendDOMNodeId: 10,
role: { value: 'RootWebArea' },
name: { value: 'Example' }
},
{
nodeId: 'button',
parentId: 'root',
backendDOMNodeId: 11,
role: { value: 'button' },
name: { value: 'Submit' }
},
{
nodeId: 'input',
parentId: 'root',
backendDOMNodeId: 12,
role: { value: 'textbox' },
name: { value: 'Email' },
value: { value: 'typed-secret@example.com' },
properties: [{ name: 'editable', value: { value: true } }]
},
{
nodeId: 'password',
parentId: 'root',
backendDOMNodeId: 13,
role: { value: 'password' },
name: { value: 'Password' },
value: { value: 'secret' }
}
]
})
}
if (method === 'Runtime.evaluate') {
return Promise.resolve(
parameters?.expression === 'document.readyState'
? { result: { value: 'complete' } }
: {
result: {
value: {
title: 'Example',
url: 'https://example.com/page'
}
}
}
)
}
if (method === 'DOM.describeNode') {
const backendNodeId = parameters?.backendNodeId
return Promise.resolve({
node: {
backendNodeId,
nodeName:
backendNodeId === 12
? 'INPUT'
: backendNodeId === 13
? 'INPUT'
: 'BUTTON',
attributes:
backendNodeId === 12
? ['type', 'text']
: backendNodeId === 13
? ['type', 'password']
: []
}
})
}
if (method === 'DOM.getBoxModel') {
return Promise.resolve({
model: { content: [10, 20, 110, 20, 110, 60, 10, 60] }
})
}
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({
cssVisualViewport: { clientWidth: 800, clientHeight: 600 }
})
}
if (method === 'Page.getNavigationHistory') {
return Promise.resolve({
currentIndex: 1,
entries: [
{ id: 4, url: 'https://previous.example/' },
{ id: 5, url: 'https://example.com/page' }
]
})
}
if (method === 'Page.captureScreenshot') {
return Promise.resolve({
data: Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
})
}
return Promise.resolve({})
}
function selectCommand(
selection: { selected: boolean; value: string }
): (
method: string,
parameters?: Record<string, unknown>
) => Promise<unknown> {
return async (method, parameters) => {
if (method === 'Accessibility.getFullAXTree') {
return {
nodes: [
{
nodeId: 'root',
backendDOMNodeId: 20,
role: { value: 'RootWebArea' },
name: { value: 'Example' }
},
{
nodeId: 'select',
parentId: 'root',
backendDOMNodeId: 21,
role: { value: 'combobox' },
name: { value: 'Region' }
}
]
}
}
if (method === 'DOM.describeNode') {
return {
node: {
backendNodeId: parameters?.backendNodeId,
nodeName: 'SELECT',
attributes: []
}
}
}
if (method === 'DOM.resolveNode') {
return { object: { objectId: 'select-object' } }
}
if (method === 'Runtime.callFunctionOn') {
return { result: { value: selection } }
}
return standardCommand(method, parameters)
}
}
describe('CdpBrowserDriver', () => {
it('creates opaque refs and redacts editable and protected values', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
expect(snapshot).toMatchObject({
url: 'https://example.com/page',
title: 'Example',
truncated: false
})
expect(snapshot.nodes).toHaveLength(4)
expect(snapshot.nodes.every((node) => /^b_[A-Za-z0-9_-]+$/u.test(node.ref)))
.toBe(true)
expect(snapshot.nodes.find((node) => node.name === 'Email')?.value)
.toBeUndefined()
expect(snapshot.nodes.find((node) => node.name === 'Password')?.value)
.toBeUndefined()
expect(JSON.stringify(snapshot)).not.toContain('typed-secret')
driver.dispose()
})
it('rejects accessibility trees above the configured byte limit', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents, {
maximumAxBytes: 100
})
await expect(
driver.snapshot(new AbortController().signal)
).rejects.toThrow('可访问性树超过安全限制')
driver.dispose()
})
it('keeps refs for subframe navigation and invalidates them for main-frame navigation', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const button = snapshot.nodes.find((node) => node.name === 'Submit')
if (!button) {
throw new Error('button missing')
}
await driver.click(button.ref, new AbortController().signal)
expect(harness.sendCommand).toHaveBeenCalledWith(
'Input.dispatchMouseEvent',
expect.objectContaining({ type: 'mousePressed', x: 60, y: 40 })
)
harness.contentEvents.emit(
'did-start-navigation',
{},
'https://ads.example/frame',
false,
false,
1,
2
)
await expect(
driver.click(button.ref, new AbortController().signal)
).resolves.toBeUndefined()
harness.contentEvents.emit(
'did-start-navigation',
{},
'https://example.com/next',
false,
true,
1,
1
)
await expect(
driver.click(button.ref, new AbortController().signal)
).rejects.toThrow('引用已失效')
driver.dispose()
})
it('keeps refs for subframe redirects and invalidates them for main-frame redirects', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const button = snapshot.nodes.find((node) => node.name === 'Submit')
if (!button) {
throw new Error('button missing')
}
harness.contentEvents.emit(
'will-redirect',
{},
'https://ads.example/redirect',
false,
false,
1,
2
)
await expect(
driver.click(button.ref, new AbortController().signal)
).resolves.toBeUndefined()
harness.contentEvents.emit(
'will-redirect',
{},
'https://example.com/redirect',
false,
true,
1,
1
)
await expect(
driver.click(button.ref, new AbortController().signal)
).rejects.toThrow('引用已失效')
driver.dispose()
})
it('rejects password, file, hidden, and stale typing targets', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const password = snapshot.nodes.find((node) => node.name === 'Password')
if (!password) {
throw new Error('password missing')
}
await expect(
driver.type(password.ref, 'never-send', new AbortController().signal)
).rejects.toThrow('受保护')
expect(
harness.sendCommand.mock.calls.some(
([method, parameters]) =>
method === 'Input.insertText' &&
parameters?.text === 'never-send'
)
).toBe(false)
driver.dispose()
})
it('selects an exact native option value with fixed internal DOM code', async () => {
const selectedValue = `us-west'); globalThis.compromised = true; ('`
const harness = createHarness(
selectCommand({ selected: true, value: selectedValue })
)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const select = snapshot.nodes.find((node) => node.name === 'Region')
if (!select) {
throw new Error('select missing')
}
await driver.select(
select.ref,
selectedValue,
new AbortController().signal
)
const call = harness.sendCommand.mock.calls.find(
([method]) => method === 'Runtime.callFunctionOn'
)
expect(call?.[1]).toMatchObject({
objectId: 'select-object',
arguments: [{ value: selectedValue }],
returnByValue: true
})
expect(call?.[1]?.functionDeclaration).toEqual(expect.any(String))
expect(String(call?.[1]?.functionDeclaration)).not.toContain(selectedValue)
expect(String(call?.[1]?.functionDeclaration)).toContain(
"new Event('input'"
)
expect(String(call?.[1]?.functionDeclaration)).toContain(
"new Event('change'"
)
expect(
harness.sendCommand.mock.calls.some(
([method]) =>
method === 'Input.insertText' ||
method === 'Input.dispatchKeyEvent'
)
).toBe(false)
expect(harness.sendCommand).toHaveBeenCalledWith(
'Runtime.releaseObject',
{ objectId: 'select-object' }
)
driver.dispose()
})
it('rejects a native select result that is not an exact value match', async () => {
const harness = createHarness(
selectCommand({ selected: false, value: 'partial-match' })
)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const select = snapshot.nodes.find((node) => node.name === 'Region')
if (!select) {
throw new Error('select missing')
}
await expect(
driver.select(
select.ref,
'partial-match-longer',
new AbortController().signal
)
).rejects.toThrow('完全匹配')
expect(harness.sendCommand).toHaveBeenCalledWith(
'Runtime.releaseObject',
{ objectId: 'select-object' }
)
driver.dispose()
})
it('bounds screenshots and returns only validated PNG data', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.screenshot(new AbortController().signal)
).resolves.toMatchObject({
type: 'image',
mimeType: 'image/png'
})
harness.sendCommand.mockImplementation(async (method) =>
method === 'Page.captureScreenshot' ? { data: 'bm90LXBuZw==' } : {}
)
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('截图无效')
driver.dispose()
})
it('validates a history target again before returning', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const target = await driver.getBackTarget(new AbortController().signal)
expect(target).toEqual({
entryId: 4,
url: 'https://previous.example/'
})
harness.setUrl('https://previous.example/')
await expect(
driver.backTo(target, new AbortController().signal)
).resolves.toEqual({ url: 'https://previous.example/' })
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigateToHistoryEntry',
{ entryId: 4 }
)
driver.dispose()
})
it('bounds hung commands and removes listeners on disposal', async () => {
const harness = createHarness(async () => new Promise(() => undefined))
const driver = new CdpBrowserDriver(harness.webContents, { timeoutMs: 5 })
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('超时')
driver.dispose()
expect(harness.contentEvents.listenerCount('did-start-navigation')).toBe(0)
expect(harness.debuggerEvents.listenerCount('detach')).toBe(0)
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('不可用')
})
})
+797
View File
@@ -0,0 +1,797 @@
import { createHash, randomBytes } from 'node:crypto'
import type {
BrowserDebugger,
BrowserEventListener,
BrowserWebContents
} from './electron-browser-session'
const DEFAULT_TIMEOUT_MS = 15_000
const MAX_AX_NODES = 500
const MAX_AX_DEPTH = 20
const MAX_AX_BYTES = 1024 * 1024
const MAX_SNAPSHOT_BYTES = 128 * 1024
const MAX_SCREENSHOT_BYTES = 512 * 1024
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const SELECT_OPTION_FUNCTION = `function (expectedValue) {
const options = Array.from(this.options);
const option = options.find((candidate) => candidate.value === expectedValue);
if (!option) {
return { selected: false, value: this.value };
}
this.value = expectedValue;
const selected = this.value === expectedValue;
if (selected) {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}
return { selected, value: this.value };
}`
type CdpAxValue = {
type?: string
value?: unknown
}
type CdpAxProperty = {
name?: string
value?: CdpAxValue
}
type CdpAxNode = {
nodeId?: string
backendDOMNodeId?: number
parentId?: string
ignored?: boolean
role?: CdpAxValue
name?: CdpAxValue
value?: CdpAxValue
properties?: CdpAxProperty[]
}
export type BrowserSnapshotNode = {
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}
export type BrowserSnapshot = {
url: string
title: string
nodes: BrowserSnapshotNode[]
truncated: boolean
}
export type BrowserScreenshot = {
type: 'image'
mimeType: 'image/png'
data: string
}
export class BrowserStaleReferenceError extends Error {
constructor(message = '浏览器元素引用已失效,请重新获取快照') {
super(message)
this.name = 'BrowserStaleReferenceError'
}
}
export type BrowserHistoryTarget = {
entryId: number
url: string
}
type RefBinding = {
backendNodeId: number
generation: number
role: string
protected: boolean
}
export type CdpBrowserDriverOptions = {
timeoutMs?: number
maximumAxNodes?: number
maximumAxDepth?: number
maximumAxBytes?: number
maximumSnapshotBytes?: number
maximumScreenshotBytes?: number
}
type ResolvedTarget = {
backendNodeId: number
bounds: { x: number; y: number; width: number; height: number }
}
function stringValue(value: CdpAxValue | undefined): string {
return typeof value?.value === 'string'
? value.value.slice(0, 2_000)
: value?.value === undefined
? ''
: String(value.value).slice(0, 2_000)
}
function propertyBoolean(
node: CdpAxNode,
name: string
): boolean | undefined {
const value = node.properties?.find((property) => property.name === name)?.value
?.value
return typeof value === 'boolean' ? value : undefined
}
function isProtectedAxNode(node: CdpAxNode): boolean {
const role = stringValue(node.role).toLowerCase()
const properties = new Map(
node.properties?.map((property) => [
property.name,
property.value?.value
])
)
return (
role === 'password' ||
properties.get('hidden') === true ||
properties.get('protected') === true ||
properties.get('valuetext') === '••••••••'
)
}
function delayAbortable(
milliseconds: number,
signal: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(finish, milliseconds)
function finish(): void {
signal.removeEventListener('abort', abort)
resolve()
}
function abort(): void {
clearTimeout(timer)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
})
}
function jsonStringBytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d ||
code === 0x22 ||
code === 0x5c
) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
value.charCodeAt(index + 1) >= 0xdc00 &&
value.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4
index += 1
} else if (code >= 0xd800 && code <= 0xdfff) {
bytes += 6
} else {
bytes += 3
}
}
return bytes
}
function exceedsJsonByteLimit(value: unknown, maximumBytes: number): boolean {
let bytes = 0
const stack = [value]
const seen = new WeakSet<object>()
const add = (amount: number): boolean => {
bytes += amount
return bytes > maximumBytes
}
while (stack.length > 0) {
const current = stack.pop()
if (current === null) {
if (add(4)) return true
} else if (typeof current === 'string') {
if (add(jsonStringBytes(current))) return true
} else if (typeof current === 'number') {
if (add(Number.isFinite(current) ? String(current).length : 4)) {
return true
}
} else if (typeof current === 'boolean') {
if (add(current ? 4 : 5)) return true
} else if (Array.isArray(current)) {
if (seen.has(current) || add(current.length > 0 ? current.length + 1 : 2)) {
return true
}
seen.add(current)
for (let index = current.length - 1; index >= 0; index -= 1) {
stack.push(current[index])
}
} else if (typeof current === 'object') {
if (seen.has(current)) return true
seen.add(current)
const entries = Object.entries(current).filter(
([, entryValue]) => entryValue !== undefined
)
if (add(entries.length > 0 ? entries.length + 1 : 2)) return true
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, entryValue] = entries[index]!
if (add(jsonStringBytes(key) + 1)) return true
stack.push(entryValue)
}
} else {
return true
}
}
return false
}
export class CdpBrowserDriver {
private readonly debugger: BrowserDebugger
private readonly timeoutMs: number
private readonly maximumAxNodes: number
private readonly maximumAxDepth: number
private readonly maximumAxBytes: number
private readonly maximumSnapshotBytes: number
private readonly maximumScreenshotBytes: number
private readonly refSecret = randomBytes(16)
private readonly refs = new Map<string, RefBinding>()
private readonly listeners: Array<{
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}> = []
private generation = 0
private disposed = false
constructor(
private readonly webContents: BrowserWebContents,
options: CdpBrowserDriverOptions = {}
) {
this.debugger = webContents.debugger
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
this.maximumAxNodes = options.maximumAxNodes ?? MAX_AX_NODES
this.maximumAxDepth = options.maximumAxDepth ?? MAX_AX_DEPTH
this.maximumAxBytes = options.maximumAxBytes ?? MAX_AX_BYTES
this.maximumSnapshotBytes =
options.maximumSnapshotBytes ?? MAX_SNAPSHOT_BYTES
this.maximumScreenshotBytes =
options.maximumScreenshotBytes ?? MAX_SCREENSHOT_BYTES
this.listen(
webContents,
'did-start-navigation',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(
webContents,
'will-redirect',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(webContents, 'render-process-gone', () => this.invalidate())
this.listen(this.debugger, 'detach', () => this.invalidate())
}
private listen(
target: {
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private invalidate(): void {
this.generation += 1
this.refs.clear()
}
private command<T>(
method: string,
parameters: Record<string, unknown> | undefined,
signal: AbortSignal
): Promise<T> {
if (this.disposed || !this.debugger.isAttached()) {
return Promise.reject(new Error('浏览器调试连接不可用'))
}
signal.throwIfAborted()
const timeout = AbortSignal.timeout(this.timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器操作超时(${this.timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void this.debugger
.sendCommand(method, parameters)
.then((result) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(result as T)
}
})
.catch((error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(new Error(`浏览器命令失败:${method}`, { cause: error }))
})
})
}
async navigate(url: string, signal: AbortSignal): Promise<{ url: string }> {
this.invalidate()
const result = await this.command<{
errorText?: string
}>('Page.navigate', { url }, signal)
if (result.errorText) {
throw new Error(`浏览器导航失败:${result.errorText.slice(0, 200)}`)
}
await this.waitForDocument(signal)
return { url: this.webContents.getURL() || url }
}
private async waitForDocument(signal: AbortSignal): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await this.command<{
result?: { value?: string }
}>(
'Runtime.evaluate',
{
expression: 'document.readyState',
returnByValue: true,
awaitPromise: false
},
signal
)
if (
result.result?.value === 'interactive' ||
result.result?.value === 'complete'
) {
return
}
await delayAbortable(50, signal)
}
throw new Error('浏览器页面未在安全期限内就绪')
}
private refFor(backendNodeId: number): string {
return `b_${createHash('sha256')
.update(this.refSecret)
.update(String(this.generation))
.update(':')
.update(String(backendNodeId))
.digest('base64url')
.slice(0, 18)}`
}
async snapshot(signal: AbortSignal): Promise<BrowserSnapshot> {
this.invalidate()
const response = await this.command<{ nodes?: CdpAxNode[] }>(
'Accessibility.getFullAXTree',
{ depth: this.maximumAxDepth },
signal
)
if (exceedsJsonByteLimit(response, this.maximumAxBytes)) {
throw new Error('浏览器可访问性树超过安全限制')
}
const allNodes = response.nodes ?? []
const limited = allNodes.slice(0, this.maximumAxNodes)
const knownDepth = new Map<string, number>()
const output: BrowserSnapshotNode[] = []
for (const node of limited) {
const parentDepth = node.parentId
? knownDepth.get(node.parentId)
: -1
const depth = (parentDepth ?? this.maximumAxDepth) + 1
if (node.nodeId) {
knownDepth.set(node.nodeId, depth)
}
if (
depth > this.maximumAxDepth ||
node.ignored ||
!node.backendDOMNodeId
) {
continue
}
const role = stringValue(node.role) || 'unknown'
const ref = this.refFor(node.backendDOMNodeId)
const protectedNode = isProtectedAxNode(node)
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
})
const item: BrowserSnapshotNode = {
ref,
role,
name: stringValue(node.name),
disabled: propertyBoolean(node, 'disabled'),
focused: propertyBoolean(node, 'focused'),
editable: propertyBoolean(node, 'editable')
}
const value = stringValue(node.value)
const redactedValue =
protectedNode ||
item.editable === true ||
['combobox', 'searchbox', 'spinbutton', 'textbox'].includes(
role.toLowerCase()
)
if (value && !redactedValue) {
item.value = value
}
output.push(item)
}
const document = await this.command<{
result?: { value?: { title?: unknown; url?: unknown } }
}>(
'Runtime.evaluate',
{
expression: '({title: document.title, url: location.href})',
returnByValue: true,
awaitPromise: false
},
signal
)
const title =
typeof document.result?.value?.title === 'string'
? document.result.value.title.slice(0, 500)
: ''
const url =
typeof document.result?.value?.url === 'string'
? document.result.value.url.slice(0, 8_192)
: this.webContents.getURL()
const snapshot = {
url,
title,
nodes: output,
truncated: allNodes.length > limited.length
}
if (Buffer.byteLength(JSON.stringify(snapshot)) > this.maximumSnapshotBytes) {
this.refs.clear()
throw new Error('浏览器快照超过安全限制')
}
return snapshot
}
private async resolveTarget(
ref: string,
action: 'click' | 'type' | 'select',
signal: AbortSignal
): Promise<ResolvedTarget> {
const binding = this.refs.get(ref)
if (!binding || binding.generation !== this.generation) {
throw new BrowserStaleReferenceError()
}
const described = await this.command<{
node?: Record<string, unknown> & {
attributes?: unknown
nodeName?: unknown
backendNodeId?: unknown
}
}>(
'DOM.describeNode',
{
backendNodeId: binding.backendNodeId,
depth: 0,
pierce: false
},
signal
)
const node = described.node
if (!node || node.backendNodeId !== binding.backendNodeId) {
throw new Error('浏览器元素状态已改变,请重新获取快照')
}
const attributes = Array.isArray(node.attributes)
? node.attributes.filter(
(value): value is string => typeof value === 'string'
)
: []
const attributeMap = new Map<string, string>()
for (let index = 0; index + 1 < attributes.length; index += 2) {
attributeMap.set(
(attributes[index] ?? '').toLowerCase(),
attributes[index + 1] ?? ''
)
}
const nodeName =
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
const blocked =
binding.protected ||
attributeMap.has('hidden') ||
attributeMap.has('disabled') ||
attributeMap.has('inert') ||
attributeMap.has('readonly') ||
attributeMap.get('aria-hidden') === 'true' ||
attributeMap.get('aria-disabled') === 'true' ||
inputType === 'hidden' ||
inputType === 'password' ||
inputType === 'file'
if (blocked) {
throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段')
}
if (
action === 'type' &&
!(
nodeName === 'textarea' ||
nodeName === 'input' ||
attributeMap.get('contenteditable') === 'true'
)
) {
throw new Error('浏览器目标不是可编辑字段')
}
if (action === 'select' && nodeName !== 'select') {
throw new Error('浏览器目标不是选择控件')
}
const model = await this.command<{
model?: {
content?: number[]
border?: number[]
}
}>(
'DOM.getBoxModel',
{ backendNodeId: binding.backendNodeId },
signal
)
const quad = model.model?.content ?? model.model?.border
if (!quad || quad.length !== 8 || quad.some((value) => !Number.isFinite(value))) {
throw new Error('浏览器元素不可见或没有有效边界')
}
const xs = [quad[0] ?? 0, quad[2] ?? 0, quad[4] ?? 0, quad[6] ?? 0]
const ys = [quad[1] ?? 0, quad[3] ?? 0, quad[5] ?? 0, quad[7] ?? 0]
const x = Math.min(...xs)
const y = Math.min(...ys)
const width = Math.max(...xs) - x
const height = Math.max(...ys) - y
const metrics = await this.command<{
cssVisualViewport?: { clientWidth?: number; clientHeight?: number }
layoutViewport?: { clientWidth?: number; clientHeight?: number }
}>('Page.getLayoutMetrics', undefined, signal)
const viewport = metrics.cssVisualViewport ?? metrics.layoutViewport
const viewportWidth = viewport?.clientWidth ?? 0
const viewportHeight = viewport?.clientHeight ?? 0
if (
width < 1 ||
height < 1 ||
x < 0 ||
y < 0 ||
x + width > viewportWidth ||
y + height > viewportHeight
) {
throw new Error('浏览器元素超出当前可见页面边界')
}
return {
backendNodeId: binding.backendNodeId,
bounds: { x, y, width, height }
}
}
async click(ref: string, signal: AbortSignal): Promise<void> {
const target = await this.resolveTarget(ref, 'click', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseMoved', x, y },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
}
async type(
ref: string,
text: string,
signal: AbortSignal
): Promise<void> {
if (!text || text.length > MAX_INPUT_LENGTH) {
throw new Error('浏览器输入内容为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'type', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command('Input.insertText', { text }, signal)
}
async select(
ref: string,
value: string,
signal: AbortSignal
): Promise<void> {
if (!value || value.length > MAX_SELECT_LENGTH) {
throw new Error('浏览器选择值为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'select', signal)
const resolved = await this.command<{
object?: { objectId?: string }
}>(
'DOM.resolveNode',
{ backendNodeId: target.backendNodeId },
signal
)
const objectId = resolved.object?.objectId
if (!objectId) {
throw new Error('浏览器选择控件已失效')
}
try {
const result = await this.command<{
exceptionDetails?: unknown
result?: {
value?: { selected?: unknown; value?: unknown }
}
}>(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: SELECT_OPTION_FUNCTION,
arguments: [{ value }],
returnByValue: true,
awaitPromise: false,
silent: true
},
signal
)
if (
result.exceptionDetails ||
result.result?.value?.selected !== true ||
result.result.value.value !== value
) {
throw new Error('浏览器未找到完全匹配的选择项')
}
} finally {
await this.command(
'Runtime.releaseObject',
{ objectId },
new AbortController().signal
).catch(() => undefined)
}
}
async getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget> {
const history = await this.command<{
currentIndex?: number
entries?: Array<{ id?: number; url?: string }>
}>('Page.getNavigationHistory', undefined, signal)
const index = history.currentIndex ?? -1
const entry = history.entries?.[index - 1]
if (
index < 1 ||
typeof entry?.id !== 'number' ||
typeof entry.url !== 'string' ||
entry.url.length === 0 ||
entry.url.length > 8_192
) {
throw new Error('浏览器没有可返回的页面')
}
return { entryId: entry.id, url: entry.url }
}
async backTo(
target: BrowserHistoryTarget,
signal: AbortSignal
): Promise<{ url: string }> {
const current = await this.getBackTarget(signal)
if (current.entryId !== target.entryId || current.url !== target.url) {
throw new Error('浏览器历史记录已改变,请重试')
}
this.invalidate()
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
await this.waitForDocument(signal)
return { url: this.webContents.getURL() }
}
async back(signal: AbortSignal): Promise<{ url: string }> {
return this.backTo(await this.getBackTarget(signal), signal)
}
async screenshot(signal: AbortSignal): Promise<BrowserScreenshot> {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'png',
fromSurface: true,
captureBeyondViewport: false
},
signal
)
if (
typeof result.data !== 'string' ||
result.data.length === 0 ||
result.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
result.data
)
) {
throw new Error('浏览器返回了无效截图')
}
const data = Buffer.from(result.data, 'base64')
if (
data.byteLength > this.maximumScreenshotBytes ||
data.byteLength < 8 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
) ||
data.toString('base64') !== result.data
) {
throw new Error('浏览器截图无效或超过安全限制')
}
return { type: 'image', mimeType: 'image/png', data: result.data }
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.invalidate()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
}
}
@@ -0,0 +1,361 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy } from './browser-url-policy'
import {
ElectronBrowserSession,
type BrowserPartitionSession,
type BrowserWebContents,
type BrowserWindowHandle,
type FilteringProxyLike
} from './electron-browser-session'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function createHarness() {
const debuggerEvents = new EventEmitter()
const contentEvents = new EventEmitter()
const partitionEvents = new EventEmitter()
let currentUrl = ''
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
const sendCommand = vi.fn(async () => ({}))
const webContents: BrowserWebContents = {
debugger: {
attach: vi.fn(),
detach: vi.fn(),
isAttached: vi.fn(() => true),
sendCommand,
on: (event, listener) =>
debuggerEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
debuggerEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
)
},
on: (event, listener) =>
contentEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
contentEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
setWindowOpenHandler: vi.fn((handler) => {
openHandler = handler
}),
capturePage: vi.fn(async () => ({
toPNG: () =>
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
})),
getURL: vi.fn(() => currentUrl),
stop: vi.fn(),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
const window: BrowserWindowHandle = {
webContents,
loadURL: vi.fn(async (url: string) => {
currentUrl = url
}),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
let permissionCheck: ((...values: unknown[]) => boolean) | undefined
let permissionRequest:
| ((
contents: unknown,
permission: string,
callback: (granted: boolean) => void,
details: unknown
) => void)
| undefined
let displayMedia:
| ((
request: unknown,
callback: (streams: Record<string, never>) => void
) => void)
| undefined
const partition: BrowserPartitionSession = {
setPermissionCheckHandler: vi.fn((handler) => {
permissionCheck = handler
}),
setPermissionRequestHandler: vi.fn((handler) => {
permissionRequest = handler
}),
setDisplayMediaRequestHandler: vi.fn((handler) => {
displayMedia = handler
}),
setProxy: vi.fn(async () => undefined),
on: (event, listener) =>
partitionEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
partitionEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
clearData: vi.fn(async () => undefined),
closeAllConnections: vi.fn(async () => undefined)
}
const proxy: FilteringProxyLike = {
start: vi.fn(async () => 'http://127.0.0.1:12345'),
dispose: vi.fn(async () => undefined)
}
const policy = new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
])
return {
contentEvents,
debuggerEvents,
partitionEvents,
partition,
proxy,
policy,
sendCommand,
webContents,
window,
setCurrentUrl(value: string) {
currentUrl = value
},
getOpenHandler: () => openHandler,
getPermissionCheck: () => permissionCheck,
getPermissionRequest: () => permissionRequest,
getDisplayMedia: () => displayMedia
}
}
describe('ElectronBrowserSession', () => {
it('creates an isolated sandboxed partition and denies privileged capabilities', async () => {
const harness = createHarness()
const createWindow = vi.fn(async (options: Record<string, unknown>) => {
const preferences = options.webPreferences as Record<string, unknown>
expect(preferences).toMatchObject({
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false,
devTools: false
})
return harness.window
})
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: vi.fn(async () => harness.partition),
createWindow,
createProxy: () => harness.proxy
})
expect(session.partition).toMatch(/^browser-/u)
expect(harness.partition.setProxy).toHaveBeenCalledWith({
mode: 'fixed_servers',
proxyRules: 'http://127.0.0.1:12345',
proxyBypassRules: '<-loopback>'
})
expect(harness.getPermissionCheck()?.()).toBe(false)
const permissionCallback = vi.fn()
harness.getPermissionRequest()?.({}, 'geolocation', permissionCallback, {})
expect(permissionCallback).toHaveBeenCalledWith(false)
const mediaCallback = vi.fn()
harness.getDisplayMedia()?.({}, mediaCallback)
expect(mediaCallback).toHaveBeenCalledWith({})
expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({
action: 'deny'
})
expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank')
expect(
vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0]
).toBeLessThan(
vi.mocked(harness.webContents.debugger.attach).mock
.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
expect(harness.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
expect(harness.sendCommand).toHaveBeenCalledWith('Page.enable')
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.setInterceptFileChooserDialog',
{ enabled: true }
)
await expect(
session.captureScreenshot(new AbortController().signal)
).resolves.toEqual({
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
})
const downloadEvent = { preventDefault: vi.fn() }
const item = { cancel: vi.fn() }
harness.partitionEvents.emit('will-download', downloadEvent, item)
expect(downloadEvent.preventDefault).toHaveBeenCalled()
expect(item.cancel).toHaveBeenCalled()
harness.debuggerEvents.emit(
'message',
{},
'Page.fileChooserOpened',
{}
)
await vi.waitFor(() =>
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.handleFileChooser',
{ action: 'cancel' }
)
)
await session.dispose()
})
it('allows only the explicitly approved top-level origin', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
const target = await harness.policy.validate(
'https://example.com/start',
new AbortController().signal
)
session.approveNavigation(target)
harness.setCurrentUrl('https://example.com/page')
expect(session.getCurrentOrigin()).toBe('https://example.com')
const sameOriginEvent = { preventDefault: vi.fn() }
harness.contentEvents.emit(
'will-navigate',
sameOriginEvent,
'https://example.com/next'
)
expect(sameOriginEvent.preventDefault).not.toHaveBeenCalled()
const foreignEvent = { preventDefault: vi.fn() }
harness.contentEvents.emit(
'will-redirect',
foreignEvent,
'https://attacker.example/'
)
expect(foreignEvent.preventDefault).toHaveBeenCalled()
harness.setCurrentUrl('https://attacker.example/')
harness.contentEvents.emit(
'did-navigate',
{},
'https://attacker.example/'
)
expect(harness.webContents.stop).toHaveBeenCalled()
expect(session.getCurrentOrigin()).toBeUndefined()
await expect(
session.validateRedirect(
'https://attacker.example/',
new AbortController().signal
)
).rejects.toThrow('超出已批准来源')
await session.dispose()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
await session.dispose()
await session.dispose()
expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce()
expect(harness.window.destroy).toHaveBeenCalledOnce()
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(harness.partition.clearData).toHaveBeenCalledOnce()
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
expect(harness.contentEvents.listenerCount('will-navigate')).toBe(0)
expect(harness.debuggerEvents.listenerCount('message')).toBe(0)
})
it('cleans up partially created resources when debugger setup fails', async () => {
const harness = createHarness()
harness.sendCommand.mockRejectedValueOnce(new Error('debugger failed'))
await expect(
ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
).rejects.toThrow('无法创建安全浏览器会话')
expect(harness.window.destroy).toHaveBeenCalled()
expect(harness.partition.clearData).toHaveBeenCalled()
expect(harness.proxy.dispose).toHaveBeenCalled()
})
it('reclaims a partition that resolves after setup times out', async () => {
const harness = createHarness()
const partitionGate = deferred<BrowserPartitionSession>()
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: () => partitionGate.promise,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
partitionGate.resolve(harness.partition)
await vi.waitFor(() =>
expect(harness.partition.clearData).toHaveBeenCalledOnce()
)
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
})
it('destroys a hidden window that resolves after setup times out', async () => {
const harness = createHarness()
const windowGate = deferred<BrowserWindowHandle>()
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: async () => harness.partition,
createWindow: () => windowGate.promise,
createProxy: () => harness.proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
windowGate.resolve(harness.window)
await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce())
expect(harness.partition.clearData).toHaveBeenCalledOnce()
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
})
it('disposes a proxy whose start resolves after setup times out', async () => {
const harness = createHarness()
const proxyGate = deferred<string>()
const proxy: FilteringProxyLike = {
start: vi.fn(() => proxyGate.promise),
dispose: vi.fn(async () => undefined)
}
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
proxyGate.resolve('http://127.0.0.1:12345')
await vi.waitFor(() => expect(proxy.dispose).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,531 @@
import { randomUUID } from 'node:crypto'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
type ValidatedBrowserUrl
} from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
export type BrowserEventListener = (...argumentsValue: never[]) => void
export type BrowserDebugger = {
attach(protocolVersion?: string): void
detach(): void
isAttached(): boolean
sendCommand(
method: string,
commandParams?: Record<string, unknown>
): Promise<unknown>
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
}
export type BrowserWebContents = {
debugger: BrowserDebugger
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
setWindowOpenHandler(
handler: (details: { url: string }) => { action: 'deny' }
): void
capturePage?(): Promise<{
toPNG(): Buffer
}>
getURL(): string
stop(): void
close?(options?: { waitForBeforeUnload?: boolean }): void
destroy(): void
isDestroyed(): boolean
}
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
destroy(): void
isDestroyed(): boolean
}
export type BrowserPartitionSession = {
setPermissionCheckHandler(
handler: (...argumentsValue: never[]) => boolean
): void
setPermissionRequestHandler(
handler: (
webContents: unknown,
permission: string,
callback: (granted: boolean) => void,
details: unknown
) => void
): void
setDisplayMediaRequestHandler(
handler: (
request: unknown,
callback: (streams: Record<string, never>) => void
) => void
): void
setProxy(configuration: {
mode: 'fixed_servers'
proxyRules: string
proxyBypassRules: string
}): Promise<void>
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
clearData(): Promise<void>
closeAllConnections(): Promise<void>
}
export type FilteringProxyLike = {
start(): Promise<string>
dispose(): Promise<void>
}
export type ElectronBrowserSessionOptions = {
policy: BrowserUrlPolicy
cleanupTimeoutMs?: number
setupTimeoutMs?: number
createPartition?: (partition: string) => Promise<BrowserPartitionSession>
createWindow?: (
options: Record<string, unknown>
) => Promise<BrowserWindowHandle>
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
}
type Listener = {
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}
async function cleanupIsolatedState(
partitionSession: BrowserPartitionSession | undefined,
proxy: FilteringProxyLike,
timeoutMs: number
): Promise<void> {
const cleanup = Promise.allSettled([
partitionSession?.closeAllConnections() ?? Promise.resolve(),
partitionSession?.clearData() ?? Promise.resolve(),
proxy.dispose()
])
let timer: ReturnType<typeof setTimeout> | undefined
const results = await Promise.race([
cleanup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error('浏览器隔离数据清理超时')),
timeoutMs
)
})
]).finally(() => {
if (timer) {
clearTimeout(timer)
}
})
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw new Error('浏览器隔离数据清理失败', { cause: failure.reason })
}
}
async function boundedSetup<T>(
operation: Promise<T>,
signal: AbortSignal,
timeoutMs: number,
cleanupLateValue?: (value: T) => void | Promise<void>
): Promise<T> {
signal.throwIfAborted()
const timeout = AbortSignal.timeout(timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器会话创建超时(${timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void operation.then(
(value) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
try {
void Promise.resolve(cleanupLateValue?.(value)).catch(
() => undefined
)
} catch {
// Cleanup is best-effort after the caller has already timed out.
}
abort()
} else {
resolve(value)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async function defaultCreatePartition(
partition: string
): Promise<BrowserPartitionSession> {
const electron = await import('electron')
return electron.session.fromPartition(
partition
) as unknown as BrowserPartitionSession
}
async function defaultCreateWindow(
options: Record<string, unknown>
): Promise<BrowserWindowHandle> {
const electron = await import('electron')
return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle
}
export class ElectronBrowserSession {
readonly partition: string
readonly webContents: BrowserWebContents
private approvedOrigin?: string
private readonly listeners: Listener[] = []
private disposed = false
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly window: BrowserWindowHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number
) {
this.partition = partition
this.webContents = window.webContents
}
static async create(
options: ElectronBrowserSessionOptions,
signal: AbortSignal = new AbortController().signal
): Promise<ElectronBrowserSession> {
const partition = `browser-${randomUUID()}`
const createPartition = options.createPartition ?? defaultCreatePartition
const createWindow = options.createWindow ?? defaultCreateWindow
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000
const setupTimeoutMs = options.setupTimeoutMs ?? 15_000
if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) {
throw new Error('浏览器会话清理期限无效')
}
if (!Number.isSafeInteger(setupTimeoutMs) || setupTimeoutMs < 1) {
throw new Error('浏览器会话创建期限无效')
}
const proxy =
options.createProxy?.(options.policy) ??
new FilteringProxy({ policy: options.policy })
let proxyDisposal: Promise<void> | undefined
const managedProxy: FilteringProxyLike = {
start: () => proxy.start(),
dispose: () => {
proxyDisposal ??= proxy.dispose()
return proxyDisposal
}
}
let partitionSession: BrowserPartitionSession | undefined
let window: BrowserWindowHandle | undefined
let result: ElectronBrowserSession | undefined
let setupStage = '启动代理'
try {
const proxyUrl = await boundedSetup(
managedProxy.start(),
signal,
setupTimeoutMs,
async () => managedProxy.dispose()
)
setupStage = '创建隔离会话'
partitionSession = await boundedSetup(
createPartition(partition),
signal,
setupTimeoutMs,
async (latePartition) =>
cleanupIsolatedState(
latePartition,
managedProxy,
cleanupTimeoutMs
)
)
partitionSession.setPermissionCheckHandler(() => false)
partitionSession.setPermissionRequestHandler(
(_contents, _permission, callback) => callback(false)
)
partitionSession.setDisplayMediaRequestHandler(
(_request, callback) => callback({})
)
setupStage = '配置网络代理'
await boundedSetup(
partitionSession.setProxy({
mode: 'fixed_servers',
proxyRules: proxyUrl,
proxyBypassRules: '<-loopback>'
}),
signal,
setupTimeoutMs
)
setupStage = '创建浏览器窗口'
window = await boundedSetup(
createWindow({
show: false,
width: 1280,
height: 900,
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
setupTimeoutMs,
(lateWindow) => {
if (!lateWindow.isDestroyed()) {
lateWindow.destroy()
} else if (!lateWindow.webContents.isDestroyed()) {
lateWindow.webContents.destroy()
}
}
)
setupStage = '加载初始页面'
await boundedSetup(
window.loadURL('about:blank'),
signal,
setupTimeoutMs
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
window,
managedProxy,
partition,
cleanupTimeoutMs
)
setupStage = '初始化浏览器协议'
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
return result
} catch (error) {
if (result) {
await result.dispose().catch(() => undefined)
} else if (window && !window.isDestroyed()) {
window.destroy()
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
} else {
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
}
const detail =
error instanceof Error && error.message
? error.message.slice(0, 160)
: '未知错误'
throw new Error(
`无法创建安全浏览器会话:${setupStage}失败(${detail}`,
{ cause: error }
)
}
}
private listen(
target: Listener['target'] & {
on(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private async initialize(): Promise<void> {
const contents = this.webContents
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'will-redirect', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'login', (
event: { preventDefault(): void },
_details: unknown,
_authInfo: unknown,
callback: () => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'select-client-certificate', (
event: { preventDefault(): void },
_url: string,
_certificates: unknown[],
callback: (certificate?: unknown) => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
if (url && !this.isApprovedUrl(url)) {
contents.stop()
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
await contents.debugger.sendCommand('Accessibility.enable')
this.assertOpen()
await contents.debugger.sendCommand('Page.setInterceptFileChooserDialog', {
enabled: true
})
this.assertOpen()
this.listen(
contents.debugger,
'message',
(_event: unknown, method: string) => {
if (method === 'Page.fileChooserOpened') {
void contents.debugger
.sendCommand('Page.handleFileChooser', { action: 'cancel' })
.catch(() => undefined)
}
}
)
}
private assertOpen(): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
}
private isApprovedUrl(input: string): boolean {
try {
return (
this.approvedOrigin !== undefined &&
canonicalizeBrowserUrl(input).origin === this.approvedOrigin
)
} catch {
return false
}
}
approveNavigation(target: ValidatedBrowserUrl): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
this.approvedOrigin = target.origin
}
getApprovedOrigin(): string | undefined {
return this.approvedOrigin
}
getCurrentOrigin(): string | undefined {
const current = this.webContents.getURL()
if (!current) {
return undefined
}
try {
const origin = canonicalizeBrowserUrl(current).origin
return origin === this.approvedOrigin ? origin : undefined
} catch {
return undefined
}
}
async captureScreenshot(
signal: AbortSignal
): Promise<{
type: 'image'
mimeType: 'image/png'
data: string
}> {
this.assertOpen()
if (!this.webContents.capturePage) {
throw new Error('浏览器原生画面捕获不可用')
}
const image = await boundedSetup(
this.webContents.capturePage(),
signal,
2_000
)
this.assertOpen()
const data = image.toPNG()
if (
data.byteLength < 8 ||
data.byteLength > 5 * 1_024 * 1_024 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
throw new Error('浏览器原生画面无效或过大')
}
return {
type: 'image',
mimeType: 'image/png',
data: data.toString('base64')
}
}
async validateRedirect(url: string, signal: AbortSignal): Promise<void> {
if (!this.approvedOrigin) {
throw new Error('浏览器没有已批准来源')
}
await this.policy.validateRedirect(url, this.approvedOrigin, signal)
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.approvedOrigin = undefined
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
if (this.webContents.debugger.isAttached()) {
this.webContents.debugger.detach()
}
this.webContents.stop()
if (!this.window.isDestroyed()) {
this.window.destroy()
} else if (!this.webContents.isDestroyed()) {
this.webContents.destroy()
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
}
}
+388
View File
@@ -0,0 +1,388 @@
import {
Server,
createServer as createHttpServer,
request as httpRequest
} from 'node:http'
import { connect as netConnect, createServer as createNetServer } from 'node:net'
import type { AddressInfo, NetConnectOpts, Socket } from 'node:net'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { BrowserUrlPolicy } from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
const disposals: Array<() => Promise<void>> = []
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
afterEach(async () => {
await Promise.allSettled(disposals.splice(0).map((dispose) => dispose()))
})
function closeServer(server: {
close(callback: (error?: Error) => void): void
}): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}
async function listen(server: {
listen(port: number, host: string, callback: () => void): void
address(): string | AddressInfo | null
}): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('test server did not bind')
}
return address.port
}
describe('FilteringProxy', () => {
it('contains tunnel socket closure errors but still surfaces listen failures', async () => {
const upstreams: PassThrough[] = []
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({
policy,
connect: () => {
const upstream = new PassThrough()
upstreams.push(upstream)
return upstream as unknown as Socket
}
})
disposals.push(() => proxy.dispose())
const handleConnect = (
proxy as unknown as {
handleConnect(
request: { url: string },
client: PassThrough,
head: Buffer
): Promise<void>
}
).handleConnect.bind(proxy)
for (const code of ['ECONNABORTED', 'ECONNRESET', 'EPIPE']) {
for (const failingSide of ['client', 'upstream'] as const) {
const client = new PassThrough()
await handleConnect(
{ url: 'example.com:443' },
client,
Buffer.alloc(0)
)
const upstream = upstreams.at(-1)
expect(upstream).toBeDefined()
upstream?.emit('connect')
const socketError = Object.assign(new Error(`write ${code}`), {
code
})
expect(() =>
(failingSide === 'client' ? client : upstream)?.emit(
'error',
socketError
)
).not.toThrow()
expect(client.destroyed).toBe(true)
expect(upstream?.destroyed).toBe(true)
}
}
const listenFailure = Object.assign(new Error('listen denied'), {
code: 'EACCES'
})
const listen = vi
.spyOn(Server.prototype, 'listen')
.mockImplementation(function (this: Server) {
queueMicrotask(() => this.emit('error', listenFailure))
return this
} as typeof Server.prototype.listen)
const failedProxy = new FilteringProxy({ policy })
await expect(failedProxy.start()).rejects.toMatchObject({
message: '浏览器过滤代理启动失败',
cause: listenFailure
})
expect(listen).toHaveBeenCalled()
listen.mockRestore()
})
it('binds only to loopback, pins HTTP to the validated address, and strips credentials', async () => {
let receivedAuthorization: string | undefined
const upstream = createHttpServer((request, response) => {
receivedAuthorization = request.headers.authorization
response.end('safe')
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
expect(proxyUrl.hostname).toBe('127.0.0.1')
const body = await new Promise<string>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
method: 'GET',
path: `http://example.com:${upstreamPort}/resource`,
headers: {
authorization: 'Bearer secret',
'proxy-authorization': 'Basic secret'
}
},
(response) => {
let value = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => {
value += chunk
})
response.on('end', () => resolve(value))
}
)
request.once('error', reject)
request.end()
})
expect(body).toBe('safe')
expect(receivedAuthorization).toBeUndefined()
expect(policy.validate).toHaveBeenCalled()
})
it('contains aborted upstream HTTP responses', async () => {
const upstream = createHttpServer((_request, response) => {
response.writeHead(200)
response.write('partial')
response.socket?.destroy()
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
await new Promise<void>((resolve) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/aborted`
},
(response) => {
response.resume()
response.once('aborted', resolve)
response.once('error', resolve)
response.once('end', resolve)
}
)
request.once('error', () => resolve())
request.end()
})
})
it('does not create an upstream HTTP request after the client disconnects during validation', async () => {
const upstreamRequest = vi.fn()
const upstream = createHttpServer(upstreamRequest)
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const validation = deferred<{
url: URL
origin: string
addresses: Array<{ address: string; family: 4 }>
}>()
const policy = {
validate: vi.fn(() => validation.promise)
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const request = httpRequest({
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/cancelled`
})
request.once('error', () => undefined)
request.end()
await vi.waitFor(() => expect(policy.validate).toHaveBeenCalledOnce())
request.destroy()
await new Promise((resolve) => setTimeout(resolve, 20))
validation.resolve({
url: new URL(`http://example.com:${upstreamPort}/cancelled`),
origin: `http://example.com:${upstreamPort}`,
addresses: [{ address: '127.0.0.1', family: 4 }]
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(upstreamRequest).not.toHaveBeenCalled()
})
it('pins CONNECT TCP destinations while leaving TLS hostname handling to the client', async () => {
const upstream = createNetServer((socket) => socket.pipe(socket))
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '93.184.216.34', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
let requestedOptions: NetConnectOpts | undefined
const connect = vi.fn((options: NetConnectOpts): Socket => {
requestedOptions = options
return netConnect({
host: '127.0.0.1',
port: upstreamPort,
family: 4
})
})
const proxy = new FilteringProxy({ policy, connect })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const echoed = await new Promise<string>((resolve, reject) => {
const socket = netConnect({
host: proxyUrl.hostname,
port: Number(proxyUrl.port)
})
let response = ''
let tunnelReady = false
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk: string) => {
response += chunk
if (!tunnelReady && response.includes('\r\n\r\n')) {
tunnelReady = true
response = ''
socket.write('tls-bytes')
} else if (tunnelReady && response.includes('tls-bytes')) {
socket.destroy()
resolve(response)
}
})
socket.once('connect', () => {
socket.write(
`CONNECT example.com:${upstreamPort} HTTP/1.1\r\nHost: example.com\r\n\r\n`
)
})
})
expect(echoed).toContain('tls-bytes')
expect(requestedOptions).toEqual({
host: '93.184.216.34',
port: upstreamPort,
family: 4
})
})
it('fails closed when validation rejects and bounds active connections', async () => {
const policy = {
validate: vi.fn(async () => {
throw new Error('blocked')
})
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({
policy,
maximumConnections: 1
})
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const status = await new Promise<number | undefined>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: 'http://example.com/'
},
(response) => {
response.resume()
response.once('end', () => resolve(response.statusCode))
}
)
request.once('error', reject)
request.end()
})
expect(status).toBe(403)
await proxy.dispose()
await expect(proxy.start()).rejects.toThrow('已关闭')
})
it('holds request reservations until slow upstream responses complete', async () => {
const releaseUpstream = deferred<void>()
let upstreamRequests = 0
const upstream = createHttpServer(async (_request, response) => {
upstreamRequests += 1
await releaseUpstream.promise
response.end('done')
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy, maximumConnections: 1 })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const requestStatus = (): Promise<number | undefined> =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/slow`
},
(response) => {
response.resume()
response.once('end', () => resolve(response.statusCode))
}
)
request.once('error', reject)
request.end()
})
const first = requestStatus()
await vi.waitFor(() => expect(upstreamRequests).toBe(1))
const rejected = await Promise.all(
Array.from({ length: 12 }, () => requestStatus())
)
expect(rejected).toEqual(Array.from({ length: 12 }, () => 503))
expect(upstreamRequests).toBe(1)
releaseUpstream.resolve()
await expect(first).resolves.toBe(200)
await expect(requestStatus()).resolves.toBe(200)
expect(upstreamRequests).toBe(2)
})
})
+345
View File
@@ -0,0 +1,345 @@
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { connect as netConnect } from 'node:net'
import type { NetConnectOpts, Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { BrowserUrlPolicy, type ValidatedBrowserUrl } from './browser-url-policy'
export type FilteringProxyOptions = {
policy: BrowserUrlPolicy
maximumConnections?: number
maximumRequestBytes?: number
connect?: (options: NetConnectOpts) => Socket
}
type ActiveStream = {
destroy(error?: Error): void
}
function rejectHttp(response: ServerResponse, status = 403): void {
if (!response.headersSent) {
response.writeHead(status, {
connection: 'close',
'content-type': 'text/plain; charset=utf-8'
})
}
response.end('Request blocked')
}
function stripProxyHeaders(
headers: IncomingMessage['headers']
): Record<string, string | string[] | undefined> {
const result = { ...headers }
delete result.authorization
delete result['proxy-authorization']
delete result['proxy-connection']
delete result.connection
delete result['keep-alive']
delete result.te
delete result.trailer
delete result['transfer-encoding']
delete result.upgrade
return result
}
export class FilteringProxy {
private readonly policy: BrowserUrlPolicy
private readonly maximumConnections: number
private readonly maximumRequestBytes: number
private readonly connectSocket: (options: NetConnectOpts) => Socket
private readonly controller = new AbortController()
private readonly streams = new Set<ActiveStream>()
private readonly reservations = new Set<ActiveStream>()
private server?: Server
private proxyUrl?: string
private disposed = false
constructor(options: FilteringProxyOptions) {
this.policy = options.policy
this.maximumConnections = options.maximumConnections ?? 32
this.maximumRequestBytes = options.maximumRequestBytes ?? 1024 * 1024
this.connectSocket = options.connect ?? netConnect
}
async start(): Promise<string> {
if (this.proxyUrl) {
return this.proxyUrl
}
if (this.disposed) {
throw new Error('浏览器过滤代理已关闭')
}
const server = createHttpServer((request, response) => {
void this.handleHttp(request, response)
})
server.on('connect', (request, client, head) => {
void this.handleConnect(request, client, head)
})
server.on('clientError', (_error, socket) => socket.destroy())
this.server = server
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => reject(error)
server.once('error', onError)
server.listen(0, '127.0.0.1', () => {
server.off('error', onError)
resolve()
})
})
} catch (error) {
await this.dispose()
throw new Error('浏览器过滤代理启动失败', { cause: error })
}
const address = server.address()
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
await this.dispose()
throw new Error('浏览器过滤代理未安全绑定到回环地址')
}
this.proxyUrl = `http://127.0.0.1:${address.port}`
return this.proxyUrl
}
private reserve(stream: ActiveStream): boolean {
if (
this.disposed ||
this.controller.signal.aborted ||
this.reservations.size >= this.maximumConnections
) {
return false
}
this.streams.add(stream)
this.reservations.add(stream)
return true
}
private releaseStream(stream: ActiveStream): void {
this.streams.delete(stream)
}
private releaseReservation(stream: ActiveStream): void {
this.reservations.delete(stream)
}
private async validateAtConnect(url: URL): Promise<ValidatedBrowserUrl> {
return this.policy.validate(url, this.controller.signal)
}
private async handleHttp(
incoming: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (!this.reserve(incoming)) {
rejectHttp(response, 503)
return
}
incoming.once('error', () => response.destroy())
response.once('error', () => incoming.destroy())
incoming.once('close', () => this.releaseStream(incoming))
let responseClosed = false
const releaseReservation = (): void =>
this.releaseReservation(incoming)
response.once('finish', releaseReservation)
response.once('close', () => {
responseClosed = true
releaseReservation()
})
try {
if (!incoming.url) {
rejectHttp(response)
return
}
const target = await this.validateAtConnect(new URL(incoming.url))
const address = target.addresses[0]
if (!address) {
rejectHttp(response)
return
}
if (
incoming.destroyed ||
response.destroyed ||
response.writableEnded ||
responseClosed
) {
return
}
const request = (
target.url.protocol === 'https:' ? httpsRequest : httpRequest
)(
target.url,
{
method: incoming.method,
headers: {
...stripProxyHeaders(incoming.headers),
host: target.url.host
},
lookup: (_hostname, options, callback) => {
if (options.all) {
callback(null, [
{ address: address.address, family: address.family }
])
} else {
callback(null, address.address, address.family)
}
},
signal: this.controller.signal
},
(upstream) => {
const destroyForward = (): void => {
upstream.destroy()
request.destroy()
if (!response.destroyed) {
response.destroy()
}
}
upstream.once('error', destroyForward)
response.once('error', destroyForward)
response.once('close', () => {
if (!upstream.complete) {
upstream.destroy()
}
})
response.writeHead(
upstream.statusCode ?? 502,
stripProxyHeaders(upstream.headers)
)
upstream.pipe(response)
}
)
this.streams.add(request)
request.once('close', () => this.releaseStream(request))
request.once('error', () => {
if (response.headersSent) {
response.destroy()
} else if (!response.destroyed) {
rejectHttp(response, 502)
}
})
incoming.once('aborted', () => request.destroy())
incoming.once('error', () => request.destroy())
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.byteLength
if (bytes > this.maximumRequestBytes) {
request.destroy(new Error('浏览器请求超过安全限制'))
incoming.destroy()
}
})
incoming.pipe(request)
} catch {
rejectHttp(response)
}
}
private async handleConnect(
request: IncomingMessage,
client: Duplex,
head: Buffer
): Promise<void> {
if (!this.reserve(client)) {
client.destroy()
return
}
let upstream: Socket | undefined
const destroyUpstream = (): void => {
if (upstream && !upstream.destroyed) {
upstream.destroy()
}
}
const destroyTunnel = (): void => {
destroyUpstream()
if (!client.destroyed) {
client.destroy()
}
}
// A browser can abandon a CONNECT tunnel while validation or a piped
// write is in flight. Socket errors are connection-local; without an
// error listener Node promotes them to an uncaught main-process error.
client.once('error', destroyTunnel)
client.once('close', () => {
this.releaseStream(client)
this.releaseReservation(client)
destroyUpstream()
})
try {
if (!request.url || request.url.length > 1_000) {
client.destroy()
return
}
const authority = new URL(`https://${request.url}`)
if (
authority.username ||
authority.password ||
authority.pathname !== '/' ||
authority.search ||
authority.hash
) {
client.destroy()
return
}
const target = await this.validateAtConnect(authority)
const address = target.addresses[0]
if (!address) {
client.destroy()
return
}
const port = authority.port ? Number(authority.port) : 443
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
client.destroy()
return
}
if (client.destroyed) {
return
}
const connectedUpstream = this.connectSocket({
// Pin the TCP destination to the policy-approved address. The CONNECT
// tunnel remains opaque, so Chromium still verifies TLS against the
// original authority hostname rather than this address.
host: address.address,
port,
family: address.family
})
upstream = connectedUpstream
this.streams.add(connectedUpstream)
const release = (): void => this.releaseStream(connectedUpstream)
connectedUpstream.once('close', release)
connectedUpstream.once('error', destroyTunnel)
if (client.destroyed) {
connectedUpstream.destroy()
return
}
connectedUpstream.once('connect', () => {
if (client.destroyed) {
connectedUpstream.destroy()
return
}
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) {
connectedUpstream.write(head)
}
connectedUpstream.pipe(client)
client.pipe(connectedUpstream)
})
} catch {
destroyTunnel()
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.controller.abort(new Error('浏览器过滤代理已关闭'))
for (const stream of this.streams) {
stream.destroy()
}
this.streams.clear()
this.reservations.clear()
const server = this.server
this.server = undefined
this.proxyUrl = undefined
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
}