feat: add computer control and managed browser
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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('不可用')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user