feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+64 -1
View File
@@ -1,6 +1,25 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { createAgentRuntime } from './create-runtime'
import { AgentRuntimeController } from './runtime-controller'
function createBrowserService(): BrowserToolService & {
dispose: ReturnType<typeof vi.fn>
} {
return {
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(),
snapshot: vi.fn(),
click: vi.fn(),
type: vi.fn(),
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
}
}
function settings(
overrides: Partial<ResolvedRuntimeSettings> = {}
@@ -41,6 +60,50 @@ describe('createAgentRuntime model compatibility', () => {
await runtime.dispose()
})
it('shares injected browser service without runtime-owned disposal', async () => {
const browserService = createBrowserService()
const first = createAgentRuntime(process.cwd(), settings(), {
browserService
})
const second = createAgentRuntime(process.cwd(), settings(), {
browserService
})
const controller = new AgentRuntimeController(first)
await controller.releaseConversation('conversation-one')
await controller.replace(second)
await controller.releaseConversation('conversation-two')
await controller.dispose()
expect(browserService.releaseConversation).toHaveBeenNthCalledWith(
1,
'conversation-one'
)
expect(browserService.releaseConversation).toHaveBeenNthCalledWith(
2,
'conversation-two'
)
expect(browserService.dispose).not.toHaveBeenCalled()
})
it('does not expose the browser service to OpenCode runtimes', async () => {
const browserService = createBrowserService()
const runtime = createAgentRuntime(
process.cwd(),
settings({
provider: 'opencode',
opencodeBaseUrl: 'http://127.0.0.1:4096'
}),
{ browserService }
)
await runtime.releaseConversation?.('opencode-conversation')
await runtime.dispose()
expect(browserService.releaseConversation).not.toHaveBeenCalled()
expect(browserService.dispose).not.toHaveBeenCalled()
})
it('keeps OpenCode independent profiles Anthropic API-key only', () => {
expect(() =>
createAgentRuntime(
+4 -1
View File
@@ -9,6 +9,7 @@ import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BundledRuntimePaths } from './bundled-runtimes'
import type { ContinueHostLauncher } from './continue-host-adapter'
import { resolveRuntimeSandbox } from './runtime-sandbox'
import type { BrowserToolService } from '../browser/browser-model-tools'
export type AgentCapabilityContext = {
skillInstructions?: string
@@ -16,6 +17,7 @@ export type AgentCapabilityContext = {
continueHostCacheRoot?: string
bundledRuntimePaths?: BundledRuntimePaths
continueHostLauncher?: ContinueHostLauncher
browserService?: BrowserToolService
}
export function createAgentRuntime(
@@ -127,7 +129,8 @@ export function createAgentRuntime(
authentication: modelAuthentication,
skillInstructions: capabilities.skillInstructions,
defaultWorkspace: workspace,
mcpServers: capabilities.mcpServers
mcpServers: capabilities.mcpServers,
browserService: capabilities.browserService
})
}
+488 -11
View File
@@ -1,10 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ModelToolDefinition,
ModelToolProviderLike
import {
RecoverableModelToolError,
type ModelToolDefinition,
type ModelToolProviderLike,
type ModelToolResult
} from './model-tool-provider'
import { ModelAgentRuntime } from './model-runtime'
const toolPng = Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
function createTextToolResult(text: string): ModelToolResult {
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
function createMultimodalToolResult(): ModelToolResult {
return {
parts: [
{ type: 'text', text: 'tool result' },
{
type: 'image',
mimeType: 'image/png',
data: toolPng
}
],
contextBytes:
Buffer.byteLength('tool result') + Buffer.byteLength(toolPng)
}
}
function createEventStream(text: string): string {
return [
'event: message_start',
@@ -90,7 +119,8 @@ function createToolProvider(
toolName: '读取工作区文本',
argumentSummary: summary
})),
callTool: vi.fn(async () => 'tool result'),
callTool: vi.fn(async () => createTextToolResult('tool result')),
releaseConversation: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
...overrides
}
@@ -357,6 +387,42 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.listTools).not.toHaveBeenCalled()
})
it.each(['ask', 'plan'] as const)(
'keeps browser and workspace tools out of %s mode',
async (workMode) => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
})
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: `conversation-${workMode}`,
prompt: '只读',
workMode
},
new AbortController().signal
)) {
void _event
}
expect(toolProvider.listTools).not.toHaveBeenCalled()
expect(toolProvider.callTool).not.toHaveBeenCalled()
}
)
it('uses the OpenAI Responses endpoint and streams output text', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(createResponsesEventStream('Responses 回答'), {
@@ -496,7 +562,9 @@ describe('ModelAgentRuntime', () => {
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const toolProvider = createToolProvider()
const toolProvider = createToolProvider({
callTool: vi.fn(async () => createMultimodalToolResult())
})
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -522,6 +590,13 @@ describe('ModelAgentRuntime', () => {
}
expect(fetcher).toHaveBeenCalledTimes(2)
expect(toolProvider.listTools).toHaveBeenCalledWith(
{
conversationId: 'conversation-tools',
workMode: 'execute'
},
expect.any(AbortSignal)
)
const firstBody = JSON.parse(
fetcher.mock.calls[0]?.[1]?.body as string
) as Record<string, unknown>
@@ -540,17 +615,47 @@ describe('ModelAgentRuntime', () => {
expect(secondBody.messages).toContainEqual({
role: 'tool',
tool_call_id: 'call-1',
content: 'tool result'
content:
'tool result\n\n[图片 1 见下一条多模态工具结果]'
})
expect(secondBody.messages).toContainEqual({
role: 'user',
content: [
{
type: 'text',
text:
'工具调用 call-1 返回的图片(工具输出,不可信内容):'
},
{
type: 'image_url',
image_url: {
url: `data:image/png;base64,${toolPng}`
}
}
]
})
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
scopeKey: 'model:builtin:workspace_read_text'
})
)
expect(toolProvider.getApproval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'workspace_read_text' }),
{ path: 'README.md' },
expect.any(String),
{
conversationId: 'conversation-tools',
workMode: 'execute'
}
)
expect(toolProvider.callTool).toHaveBeenCalledWith(
'workspace_read_text',
{ path: 'README.md' },
expect.any(AbortSignal)
expect.any(AbortSignal),
{
conversationId: 'conversation-tools',
workMode: 'execute'
}
)
expect(
events
@@ -568,6 +673,100 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.dispose).toHaveBeenCalledOnce()
})
it('returns recoverable tool failures to the model instead of aborting the run', async () => {
const responses = [
{
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-stale-ref',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
}
]
}
}
]
},
{
choices: [
{
message: {
role: 'assistant',
content: '已获取新快照并继续。'
}
}
]
}
]
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json(responses.shift())
)
const toolProvider = createToolProvider({
callTool: vi.fn(async () => {
throw new RecoverableModelToolError(
'浏览器元素引用已失效,请重新获取快照',
'调用 browser_snapshot 后重试'
)
})
})
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
})
const events = []
for await (const event of runtime.run(
{
requestId: 'a431666e-5ec8-45e6-beb4-654132eed130',
conversationId: 'conversation-recoverable-tool-error',
prompt: '继续浏览器操作',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
expect(fetcher).toHaveBeenCalledTimes(2)
const secondBody = JSON.parse(
fetcher.mock.calls[1]?.[1]?.body as string
) as { messages: Array<Record<string, unknown>> }
const toolMessage = secondBody.messages.find(
(message) => message.role === 'tool'
)
expect(JSON.parse(toolMessage?.content as string)).toEqual({
ok: false,
recoverable: true,
error: '浏览器元素引用已失效,请重新获取快照',
nextAction: '调用 browser_snapshot 后重试'
})
expect(
events
.filter((event) => event.type === 'tool')
.map((event) => event.state)
).toEqual(['pending', 'running', 'recoverable'])
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '已获取新快照并继续。'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('continues OpenAI Responses with function_call_output', async () => {
const responses = [
{
@@ -611,7 +810,9 @@ describe('ModelAgentRuntime', () => {
protocol: 'openai-responses',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
toolProvider: createToolProvider({
callTool: vi.fn(async () => createMultimodalToolResult())
})
})
const events = []
@@ -651,7 +852,16 @@ describe('ModelAgentRuntime', () => {
{
type: 'function_call_output',
call_id: 'call-responses-1',
output: 'tool result'
output: [
{
type: 'input_text',
text: 'tool result'
},
{
type: 'input_image',
image_url: `data:image/png;base64,${toolPng}`
}
]
}
]
})
@@ -758,7 +968,9 @@ describe('ModelAgentRuntime', () => {
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher,
toolProvider: createToolProvider()
toolProvider: createToolProvider({
callTool: vi.fn(async () => createMultimodalToolResult())
})
})
for await (const _event of runtime.run(
@@ -795,12 +1007,277 @@ describe('ModelAgentRuntime', () => {
{
type: 'tool_result',
tool_use_id: 'toolu-1',
content: 'tool result'
content: [
{
type: 'text',
text: 'tool result'
},
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: toolPng
}
}
]
}
]
})
})
it('does not issue a follow-up model request after tool cancellation', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-aborted',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const controller = new AbortController()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider({
callTool: vi.fn(async () => {
controller.abort()
return createTextToolResult('late result')
})
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
controller.signal,
async () => 'once'
)) {
void _event
}
}
await expect(consume()).rejects.toThrow()
expect(fetcher).toHaveBeenCalledOnce()
})
it('terminates repeated identical tool rounds without exhausting hard limits', async () => {
let callId = 0
const fetcher = vi.fn<typeof fetch>(async () => {
callId += 1
return Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: `call-repeat-${callId}`,
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
}
}
]
}
}
]
})
})
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: 'conversation-repeat',
prompt: 'repeat',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
}
await expect(consume()).rejects.toThrow('没有取得进展')
expect(fetcher).toHaveBeenCalledTimes(3)
expect(toolProvider.callTool).toHaveBeenCalledTimes(2)
})
it('releases provider state for only the requested conversation', async () => {
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
toolProvider
})
await runtime.releaseConversation('conversation-release')
expect(toolProvider.releaseConversation).toHaveBeenCalledOnce()
expect(toolProvider.releaseConversation).toHaveBeenCalledWith(
'conversation-release'
)
})
it('releases known conversations before provider disposal and permits replacement reuse', async () => {
const lifecycle: string[] = []
const released = new Set<string>()
const createProvider = (): ModelToolProviderLike =>
createToolProvider({
releaseConversation: vi.fn(async (conversationId) => {
lifecycle.push(`release:${conversationId}`)
released.add(conversationId)
}),
dispose: vi.fn(async () => {
lifecycle.push('dispose')
})
})
const createRuntime = (toolProvider: ModelToolProviderLike) =>
new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher: vi.fn<typeof fetch>(async () =>
new Response(
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n',
{
status: 200,
headers: { 'content-type': 'text/event-stream' }
}
)
),
toolProvider
})
const request = {
requestId: crypto.randomUUID(),
conversationId: 'conversation-replacement',
prompt: 'hello',
workMode: 'ask' as const
}
const firstProvider = createProvider()
const firstRuntime = createRuntime(firstProvider)
for await (const _event of firstRuntime.run(
request,
new AbortController().signal
)) {
void _event
}
await firstRuntime.dispose()
expect(lifecycle).toEqual([
'release:conversation-replacement',
'dispose'
])
expect(released).toContain('conversation-replacement')
const replacement = createRuntime(createProvider())
const replacementEvents = []
for await (const event of replacement.run(
{ ...request, requestId: crypto.randomUUID() },
new AbortController().signal
)) {
replacementEvents.push(event)
}
expect(replacementEvents.at(-1)).toMatchObject({ type: 'done' })
await replacement.dispose()
})
it('counts image base64 data against the aggregate tool context limit', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider({
callTool: vi.fn(async () => ({
parts: [
{
type: 'image' as const,
mimeType: 'image/png' as const,
data: imageData
}
],
contextBytes: Buffer.byteLength(imageData)
}))
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
}
await expect(consume()).rejects.toThrow('结果总量超过 1MB')
expect(fetcher).toHaveBeenCalledOnce()
})
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+254 -28
View File
@@ -5,11 +5,16 @@ import type {
ModelProtocol
} from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import {
ModelToolProvider,
RecoverableModelToolError,
type ModelToolCallContext,
type ModelToolDefinition,
type ModelToolProviderLike
type ModelToolProviderLike,
type ModelToolResult,
type ModelToolResultPart
} from './model-tool-provider'
import {
createOpenAIChatCompletionsUrl,
@@ -86,8 +91,10 @@ const maxImageResponseBytes = 5_300_000
const maxChatResponseBytes = 2 * 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const maxToolCallsPerRun = 12
const maxToolRounds = 8
const maxToolCallsPerRun = 40
const maxToolRounds = 24
const maxRepeatedIdenticalCalls = 3
const maxIdenticalRoundsWithoutProgress = 2
export type ModelRuntimeOptions = {
apiKey?: string
@@ -98,6 +105,7 @@ export type ModelRuntimeOptions = {
skillInstructions?: string
defaultWorkspace?: string
mcpServers?: ResolvedMcpServer[]
browserService?: BrowserToolService
toolProvider?: ModelToolProviderLike
fetcher?: typeof fetch
}
@@ -459,6 +467,152 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
return parsed as Record<string, unknown>
}
function canonicalizeToolArguments(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeToolArguments)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeToolArguments(item)])
)
}
return value
}
function getToolCallFingerprint(call: ModelToolCall): string {
return `${call.name}:${JSON.stringify(
canonicalizeToolArguments(call.arguments)
)}`
}
function validateToolResult(result: ModelToolResult): number {
if (
!Array.isArray(result.parts) ||
result.parts.length === 0 ||
!Number.isSafeInteger(result.contextBytes) ||
result.contextBytes < 0
) {
throw new Error('直连模型工具返回了无效结果')
}
let contextBytes = 0
for (const part of result.parts) {
if (part.type === 'text') {
if (typeof part.text !== 'string') {
throw new Error('直连模型工具返回了无效文本结果')
}
contextBytes += Buffer.byteLength(part.text)
} else if (
part.type === 'image' &&
(part.mimeType === 'image/png' ||
part.mimeType === 'image/jpeg' ||
part.mimeType === 'image/webp') &&
typeof part.data === 'string'
) {
contextBytes += Buffer.byteLength(part.data)
} else {
throw new Error('直连模型工具返回了无效图片结果')
}
}
if (contextBytes !== result.contextBytes) {
throw new Error('直连模型工具结果字节计数无效')
}
return contextBytes
}
function getAnthropicToolResultContent(
parts: ModelToolResultPart[]
): Array<Record<string, unknown>> {
return parts.map((part) =>
part.type === 'text'
? {
type: 'text',
text: part.text
}
: {
type: 'image',
source: {
type: 'base64',
media_type: part.mimeType,
data: part.data
}
}
)
}
function getResponsesToolResultOutput(
parts: ModelToolResultPart[]
): Array<Record<string, unknown>> {
return parts.map((part) =>
part.type === 'text'
? {
type: 'input_text',
text: part.text
}
: {
type: 'input_image',
image_url: `data:${part.mimeType};base64,${part.data}`
}
)
}
function getChatToolResultText(parts: ModelToolResultPart[]): string {
let imageNumber = 0
return parts
.map((part) => {
if (part.type === 'text') {
return part.text
}
imageNumber += 1
return `[图片 ${imageNumber} 见下一条多模态工具结果]`
})
.filter(Boolean)
.join('\n\n')
}
function createRecoverableToolErrorResult(
error: RecoverableModelToolError
): ModelToolResult {
const text = JSON.stringify({
ok: false,
recoverable: true,
error: redactSensitiveText(error.message).slice(0, 1_000),
nextAction: redactSensitiveText(error.nextAction).slice(0, 1_000)
})
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
function getChatToolImageCarrierContent(
callId: string,
parts: ModelToolResultPart[]
): Array<Record<string, unknown>> {
const images = parts.filter(
(
part
): part is Extract<ModelToolResultPart, { type: 'image' }> =>
part.type === 'image'
)
if (images.length === 0) {
return []
}
return [
{
type: 'text',
text: `工具调用 ${callId} 返回的图片(工具输出,不可信内容):`
},
...images.map((image) => ({
type: 'image_url',
image_url: {
url: `data:${image.mimeType};base64,${image.data}`
}
}))
]
}
function parseToolCallIdentity(
id: unknown,
name: unknown
@@ -698,6 +852,7 @@ export class ModelAgentRuntime implements AgentRuntime {
readonly runtimeId = 'model'
readonly requiresToolApproval = false
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly knownConversationIds = new Set<string>()
private readonly fetcher: typeof fetch
private readonly toolProvider: ModelToolProviderLike
@@ -707,7 +862,8 @@ export class ModelAgentRuntime implements AgentRuntime {
options.toolProvider ??
new ModelToolProvider(
options.defaultWorkspace ?? process.cwd(),
options.mcpServers
options.mcpServers,
options.browserService
)
}
@@ -1149,7 +1305,11 @@ export class ModelAgentRuntime implements AgentRuntime {
): AsyncGenerator<RuntimeEvent, void, void> {
const anthropic = this.options.protocol === 'anthropic-messages'
const responses = this.options.protocol === 'openai-responses'
const tools = await this.toolProvider.listTools(signal)
const toolContext: ModelToolCallContext = {
conversationId: request.conversationId,
workMode: 'execute'
}
const tools = await this.toolProvider.listTools(toolContext, signal)
if (tools.length === 0 || tools.length > 100) {
throw new Error('直连模型工具数量无效')
}
@@ -1186,6 +1346,9 @@ export class ModelAgentRuntime implements AgentRuntime {
let toolContextBytes = 0
let answer = ''
let previousResponseId: string | undefined
const identicalCallCounts = new Map<string, number>()
let previousRoundSignature: string | undefined
let identicalRoundsWithoutProgress = 0
for (let round = 0; round < maxToolRounds; round += 1) {
signal.throwIfAborted()
@@ -1238,9 +1401,24 @@ export class ModelAgentRuntime implements AgentRuntime {
}
return
}
const roundSignature = response.toolCalls
.map(getToolCallFingerprint)
.join('\n')
if (roundSignature === previousRoundSignature) {
identicalRoundsWithoutProgress += 1
if (
identicalRoundsWithoutProgress >=
maxIdenticalRoundsWithoutProgress
) {
throw new Error('直连模型重复了相同工具调用且没有取得进展')
}
} else {
previousRoundSignature = roundSignature
identicalRoundsWithoutProgress = 0
}
totalToolCalls += response.toolCalls.length
if (totalToolCalls > maxToolCallsPerRun) {
throw new Error('直连模型单次运行的工具调用超过 12 个')
throw new Error('直连模型单次运行的工具调用超过 40 个')
}
if (responses) {
if (!response.responseId) {
@@ -1254,8 +1432,16 @@ export class ModelAgentRuntime implements AgentRuntime {
}
const anthropicResults: Array<Record<string, unknown>> = []
const responsesResults: Array<Record<string, unknown>> = []
const chatImageCarrierContent: Array<Record<string, unknown>> = []
for (const call of response.toolCalls) {
signal.throwIfAborted()
const callFingerprint = getToolCallFingerprint(call)
const identicalCallCount =
(identicalCallCounts.get(callFingerprint) ?? 0) + 1
identicalCallCounts.set(callFingerprint, identicalCallCount)
if (identicalCallCount > maxRepeatedIdenticalCalls) {
throw new Error('直连模型重复请求了完全相同的工具调用')
}
if (seenCallIds.has(call.id)) {
throw new Error('模型重复使用了工具调用 ID')
}
@@ -1291,7 +1477,8 @@ export class ModelAgentRuntime implements AgentRuntime {
this.toolProvider.getApproval(
tool,
call.arguments,
safeToolArgumentSummary(call.arguments)
safeToolArgumentSummary(call.arguments),
toolContext
)
)
} catch (error) {
@@ -1316,6 +1503,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
throw new Error(`用户拒绝了工具「${displayName}`)
}
signal.throwIfAborted()
yield {
requestId: request.requestId,
type: 'tool',
@@ -1325,27 +1513,38 @@ export class ModelAgentRuntime implements AgentRuntime {
summary: `正在执行直连模型工具:${displayName}`
}
let result: string
let result: ModelToolResult
let toolFailed = false
try {
result = await this.toolProvider.callTool(
tool.name,
call.arguments,
signal
signal,
toolContext
)
} catch (error) {
const recoverable = error instanceof RecoverableModelToolError
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具执行失败:${displayName}`
state: recoverable ? 'recoverable' : 'failed',
summary:
recoverable
? `直连模型工具需要刷新后重试:${displayName}`
: `直连模型工具执行失败:${displayName}`
}
if (recoverable) {
result = createRecoverableToolErrorResult(error)
toolFailed = true
} else {
throw new Error(`工具「${displayName}」执行失败`, {
cause: error
})
}
throw new Error(`工具「${displayName}」执行失败`, {
cause: error
})
}
toolContextBytes += Buffer.byteLength(result)
toolContextBytes += validateToolResult(result)
if (toolContextBytes > maxToolContextBytes) {
yield {
requestId: request.requestId,
@@ -1361,28 +1560,34 @@ export class ModelAgentRuntime implements AgentRuntime {
responsesResults.push({
type: 'function_call_output',
call_id: call.id,
output: result
output: getResponsesToolResultOutput(result.parts)
})
} else if (anthropic) {
anthropicResults.push({
type: 'tool_result',
tool_use_id: call.id,
content: result
content: getAnthropicToolResultContent(result.parts),
...(toolFailed ? { is_error: true } : {})
})
} else {
messages.push({
role: 'tool',
tool_call_id: call.id,
content: result
content: getChatToolResultText(result.parts)
})
chatImageCarrierContent.push(
...getChatToolImageCarrierContent(call.id, result.parts)
)
}
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`
if (!toolFailed) {
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`
}
}
}
if (anthropic) {
@@ -1392,9 +1597,15 @@ export class ModelAgentRuntime implements AgentRuntime {
})
} else if (responses) {
messages.splice(0, messages.length, ...responsesResults)
} else if (chatImageCarrierContent.length > 0) {
messages.push({
role: 'user',
content: chatImageCarrierContent
})
}
signal.throwIfAborted()
}
throw new Error('直连模型工具调用轮次超过 8 轮')
throw new Error('直连模型工具调用轮次超过 24 轮')
}
async *run(
@@ -1402,6 +1613,7 @@ export class ModelAgentRuntime implements AgentRuntime {
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
this.knownConversationIds.add(request.conversationId)
if (!this.isConfigured()) {
throw new Error('请先在设置中配置模型接口 API Key')
}
@@ -1575,12 +1787,26 @@ export class ModelAgentRuntime implements AgentRuntime {
}
async dispose(): Promise<void> {
const conversationIds = new Set([
...this.knownConversationIds,
...this.conversations.keys()
])
await Promise.allSettled(
[...conversationIds].map((conversationId) =>
this.toolProvider.releaseConversation(conversationId)
)
)
this.knownConversationIds.clear()
this.conversations.clear()
await this.toolProvider.dispose()
}
releaseConversation(conversationId: string): Promise<void> {
async releaseConversation(conversationId: string): Promise<void> {
this.conversations.delete(conversationId)
return Promise.resolve()
try {
await this.toolProvider.releaseConversation(conversationId)
} finally {
this.knownConversationIds.delete(conversationId)
}
}
}
+513 -35
View File
@@ -9,16 +9,24 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
const mocks = vi.hoisted(() => {
const tasks = {
callToolStream: vi.fn(),
cancelTask: vi.fn()
}
const client = {
connect: vi.fn(),
listTools: vi.fn(),
callTool: vi.fn(),
experimental: { tasks },
close: vi.fn()
}
return {
client,
tasks,
Client: vi.fn(function Client() {
return client
}),
@@ -33,9 +41,63 @@ vi.mock('../capabilities/mcp-client-transport', () => ({
createMcpTransport: mocks.createMcpTransport
}))
import { ModelToolProvider } from './model-tool-provider'
import {
ModelToolProvider,
type ModelToolCallContext
} from './model-tool-provider'
const temporaryDirectories: string[] = []
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
const toolContext = {
conversationId: 'provider-test-conversation',
workMode: 'execute'
} satisfies ModelToolCallContext
function createBrowserService(): 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: png
})),
releaseConversation: vi.fn(async () => undefined)
}
}
function createMcpServer(): ResolvedMcpServer {
return {
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Search MCP',
description: '',
enabled: true,
assignments: ['model'],
secretConfigured: false,
transport: 'stdio',
command: 'node',
args: ['server.js']
}
}
async function createWorkspace(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-tools-'))
@@ -51,6 +113,13 @@ describe('ModelToolProvider', () => {
mocks.client.callTool.mockResolvedValue({
content: [{ type: 'text', text: 'MCP result' }]
})
mocks.tasks.callToolStream.mockImplementation(async function* () {
yield {
type: 'result',
result: { content: [{ type: 'text', text: 'MCP task result' }] }
}
})
mocks.tasks.cancelTask.mockResolvedValue({})
mocks.client.close.mockResolvedValue(undefined)
})
@@ -71,7 +140,7 @@ describe('ModelToolProvider', () => {
const provider = new ModelToolProvider(workspace)
const signal = new AbortController().signal
await expect(provider.listTools(signal)).resolves.toEqual(
await expect(provider.listTools(toolContext, signal)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'workspace_read_text' }),
expect.objectContaining({ name: 'workspace_list_directory' }),
@@ -82,23 +151,37 @@ describe('ModelToolProvider', () => {
provider.callTool(
'workspace_read_text',
{ path: 'docs/note.txt' },
signal
signal,
toolContext
)
).resolves.toBe('hello')
await expect(
provider.callTool(
'workspace_list_directory',
{ path: 'docs' },
signal
)
).resolves.toContain('"note.txt"')
await expect(
provider.callTool(
'workspace_write_text',
{ path: 'docs/output.txt', content: 'saved' },
signal
)
).resolves.toContain('"bytesWritten":5')
).resolves.toEqual({
parts: [{ type: 'text', text: 'hello' }],
contextBytes: 5
})
const listing = await provider.callTool(
'workspace_list_directory',
{ path: 'docs' },
signal,
toolContext
)
expect(listing.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('"note.txt"')
})
])
const written = await provider.callTool(
'workspace_write_text',
{ path: 'docs/output.txt', content: 'saved' },
signal,
toolContext
)
expect(written.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('"bytesWritten":5')
})
])
await expect(
readFile(join(workspace, 'docs', 'output.txt'), 'utf8')
).resolves.toBe('saved')
@@ -112,11 +195,116 @@ describe('ModelToolProvider', () => {
provider.callTool(
'workspace_read_text',
{ path: '../outside.txt' },
new AbortController().signal
new AbortController().signal,
toolContext
)
).rejects.toThrow('不能超出工作区')
})
it('delegates browser tools with per-call conversation context', async () => {
const workspace = await createWorkspace()
const browserService = createBrowserService()
const provider = new ModelToolProvider(workspace, [], browserService)
const firstContext = {
conversationId: 'browser-conversation-one',
workMode: 'execute'
} satisfies ModelToolCallContext
const secondContext = {
conversationId: 'browser-conversation-two',
workMode: 'execute'
} satisfies ModelToolCallContext
const signal = new AbortController().signal
for (const workMode of ['ask', 'plan'] as const) {
const readOnlyContext = {
conversationId: `browser-${workMode}`,
workMode
} satisfies ModelToolCallContext
await expect(
provider.listTools(readOnlyContext, signal)
).resolves.not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'browser_screenshot' })
])
)
await expect(
provider.callTool(
'browser_screenshot',
{},
signal,
readOnlyContext
)
).rejects.toThrow('未知工具')
}
expect(browserService.screenshot).not.toHaveBeenCalled()
const tools = await provider.listTools(firstContext, signal)
expect(
tools.filter((tool) => tool.name.startsWith('browser_'))
).toHaveLength(7)
const navigate = tools.find((tool) => tool.name === 'browser_navigate')
expect(
provider.getApproval(
navigate!,
{ url: 'https://example.com/path?secret=value' },
'runtime summary',
firstContext
)
).toMatchObject({
scopeKey: 'model:browser:navigate:https://example.com',
argumentSummary: 'https://example.com/path?[查询参数已隐藏]',
allowPermanent: false
})
await expect(
provider.callTool('browser_screenshot', {}, signal, firstContext)
).resolves.toEqual({
parts: [{ type: 'image', mimeType: 'image/png', data: png }],
contextBytes: Buffer.byteLength(png)
})
await provider.callTool('browser_screenshot', {}, signal, secondContext)
expect(browserService.screenshot).toHaveBeenNthCalledWith(
1,
firstContext.conversationId,
signal
)
expect(browserService.screenshot).toHaveBeenNthCalledWith(
2,
secondContext.conversationId,
signal
)
await provider.releaseConversation(firstContext.conversationId)
expect(browserService.releaseConversation).toHaveBeenCalledWith(
firstContext.conversationId
)
expect(browserService.releaseConversation).not.toHaveBeenCalledWith(
secondContext.conversationId
)
})
it('marks stale browser references as recoverable model tool errors', async () => {
const workspace = await createWorkspace()
const browserService = createBrowserService()
vi.mocked(browserService.click).mockRejectedValue(
new BrowserStaleReferenceError()
)
const provider = new ModelToolProvider(workspace, [], browserService)
await expect(
provider.callTool(
'browser_click',
{ ref: 'b_currentReference' },
new AbortController().signal,
toolContext
)
).rejects.toMatchObject({
name: 'RecoverableModelToolError',
message: '浏览器元素引用已失效,请重新获取快照',
nextAction: expect.stringContaining('browser_snapshot')
})
})
it('loads and invokes configured MCP tools through provider-safe names', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
@@ -132,21 +320,10 @@ describe('ModelToolProvider', () => {
}
]
})
const server = {
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Search MCP',
description: '',
enabled: true,
assignments: ['model'],
secretConfigured: false,
transport: 'stdio',
command: 'node',
args: ['server.js']
} satisfies ResolvedMcpServer
const provider = new ModelToolProvider(workspace, [server])
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const signal = new AbortController().signal
const tools = await provider.listTools(signal)
const tools = await provider.listTools(toolContext, signal)
const mcpTool = tools.find((tool) => tool.source === 'mcp')
expect(mcpTool).toMatchObject({
displayName: 'Search MCP / search-web',
@@ -157,9 +334,13 @@ describe('ModelToolProvider', () => {
provider.callTool(
mcpTool?.name ?? '',
{ query: 'GoodBuddy' },
signal
signal,
toolContext
)
).resolves.toBe('MCP result')
).resolves.toEqual({
parts: [{ type: 'text', text: 'MCP result' }],
contextBytes: 10
})
expect(mocks.client.callTool).toHaveBeenCalledWith(
{
name: 'search-web',
@@ -168,11 +349,308 @@ describe('ModelToolProvider', () => {
undefined,
expect.objectContaining({
timeout: 30_000,
signal
signal,
resetTimeoutOnProgress: true,
maxTotalTimeout: 300_000,
onprogress: expect.any(Function)
})
)
await provider.dispose()
expect(mocks.client.close).toHaveBeenCalledOnce()
})
it('preserves ordered bounded MCP text, image, and unsupported audio parts', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [
{
name: 'capture',
inputSchema: { type: 'object' }
}
]
})
mocks.client.callTool.mockResolvedValue({
content: [
{ type: 'text', text: 'before' },
{ type: 'image', mimeType: 'image/png', data: png },
{ type: 'audio', mimeType: 'audio/wav', data: 'ignored' },
{ type: 'text', text: 'after' }
]
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, new AbortController().signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).resolves.toEqual({
parts: [
{ type: 'text', text: 'before' },
{
type: 'image',
mimeType: 'image/png',
data: png
},
{ type: 'text', text: '[audio result unsupported]' },
{ type: 'text', text: 'after' }
],
contextBytes:
Buffer.byteLength('before') +
Buffer.byteLength(png) +
Buffer.byteLength('[audio result unsupported]') +
Buffer.byteLength('after')
})
})
it.each([
{
mimeType: 'image/jpeg',
data: Buffer.from([0xff, 0xd8, 0xff]).toString('base64')
},
{
mimeType: 'image/webp',
data: Buffer.from([
0x52, 0x49, 0x46, 0x46,
0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50
]).toString('base64')
}
])('accepts a valid $mimeType signature', async ({ mimeType, data }) => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'capture', inputSchema: { type: 'object' } }]
})
mocks.client.callTool.mockResolvedValue({
content: [{ type: 'image', mimeType, data }]
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, new AbortController().signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'image', mimeType, data }],
contextBytes: Buffer.byteLength(data)
})
})
it.each([
{
name: 'malformed base64',
image: {
type: 'image',
mimeType: 'image/png',
data: `${png.slice(0, -1)}!`
},
message: '无效的 base64'
},
{
name: 'MIME signature mismatch',
image: {
type: 'image',
mimeType: 'image/jpeg',
data: png
},
message: 'MIME 类型与文件签名不匹配'
},
{
name: 'unsupported MIME type',
image: {
type: 'image',
mimeType: 'image/gif',
data: png
},
message: '不支持的图片格式'
}
])('rejects $name in MCP image blocks', async ({ image, message }) => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'capture', inputSchema: { type: 'object' } }]
})
mocks.client.callTool.mockResolvedValue({ content: [image] })
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, new AbortController().signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).rejects.toThrow(message)
})
it('counts encoded and decoded image data against the MCP result budget', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'capture', inputSchema: { type: 'object' } }]
})
const encodedContextOversizedPng = Buffer.concat([
Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
]),
Buffer.alloc(200 * 1024)
]).toString('base64')
mocks.client.callTool.mockResolvedValue({
content: [
{
type: 'image',
mimeType: 'image/png',
data: encodedContextOversizedPng
}
]
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, new AbortController().signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).rejects.toThrow('工具结果超过 256KB')
const decodedOversizedPng = Buffer.concat([
Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
]),
Buffer.alloc(256 * 1024)
]).toString('base64')
mocks.client.callTool.mockResolvedValue({
content: [
{
type: 'image',
mimeType: 'image/png',
data: decodedOversizedPng
}
]
})
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).rejects.toThrow('过大的 base64 图片')
})
it('bounds MCP content block and image counts', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'capture', inputSchema: { type: 'object' } }]
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, new AbortController().signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
mocks.client.callTool.mockResolvedValue({
content: Array.from({ length: 101 }, () => ({
type: 'text',
text: 'x'
}))
})
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).rejects.toThrow('内容块数量超过安全限制')
mocks.client.callTool.mockResolvedValue({
content: Array.from({ length: 9 }, () => ({
type: 'image',
mimeType: 'image/png',
data: png
}))
})
await expect(
provider.callTool(
tool?.name ?? '',
{},
new AbortController().signal,
toolContext
)
).rejects.toThrow('图片数量超过安全限制')
})
it('streams required task tools and best-effort cancels their MCP task', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [
{
name: 'long-job',
inputSchema: { type: 'object' },
execution: { taskSupport: 'required' }
}
]
})
const controller = new AbortController()
mocks.tasks.callToolStream.mockImplementation(async function* (
_params,
_schema,
options
) {
yield {
type: 'taskCreated',
task: { taskId: 'task-1', status: 'working' }
}
controller.abort()
throw options.signal.reason
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const tools = await provider.listTools(toolContext, controller.signal)
const tool = tools.find((candidate) => candidate.source === 'mcp')
expect(tool?.taskSupport).toBe('required')
await expect(
provider.callTool(
tool?.name ?? '',
{},
controller.signal,
toolContext
)
).rejects.toThrow()
expect(mocks.tasks.callToolStream).toHaveBeenCalledWith(
{
name: 'long-job',
arguments: {}
},
undefined,
expect.objectContaining({
timeout: 30_000,
signal: controller.signal,
resetTimeoutOnProgress: true,
maxTotalTimeout: 300_000
})
)
expect(mocks.tasks.cancelTask).toHaveBeenCalledWith(
'task-1',
{
timeout: 5_000,
maxTotalTimeout: 5_000
}
)
})
})
+350 -86
View File
@@ -14,6 +14,7 @@ import {
resolve
} from 'node:path'
import { z } from 'zod'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import { createMcpTransport } from '../capabilities/mcp-client-transport'
import {
@@ -23,6 +24,11 @@ import {
readBoundedUtf8File
} from '../workspace-file-access'
import type { RuntimeApprovalRequest } from './runtime'
import {
BrowserModelTools,
type BrowserToolService
} from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
const MAX_MODEL_TOOLS = 100
const MAX_MCP_SERVERS = 16
@@ -31,6 +37,15 @@ const MAX_TOOL_RESULT_BYTES = 256 * 1024
const MAX_READ_BYTES = 256 * 1024
const MAX_WRITE_BYTES = 512 * 1024
const MCP_TIMEOUT_MS = 30_000
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
const MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
const MAX_MCP_CONTENT_BLOCKS = 100
const MAX_MCP_IMAGES = 8
const [
workspaceReadTextTool,
workspaceListDirectoryTool,
workspaceWriteTextTool
] = builtinModelTools
const workspacePathSchema = z
.string()
@@ -66,20 +81,62 @@ export type ModelToolDefinition = {
inputSchema: Record<string, unknown>
source: 'builtin' | 'mcp'
serverName?: string
taskSupport?: 'forbidden' | 'optional' | 'required'
}
export type ModelToolResultPart =
| {
type: 'text'
text: string
}
| {
type: 'image'
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
data: string
}
export type ModelToolResult = {
parts: ModelToolResultPart[]
contextBytes: number
}
export type ModelToolCallContext = {
conversationId: string
workMode: 'ask' | 'plan' | 'execute'
}
export class RecoverableModelToolError extends Error {
readonly nextAction: string
constructor(
message: string,
nextAction: string,
options?: ErrorOptions
) {
super(message, options)
this.name = 'RecoverableModelToolError'
this.nextAction = nextAction
}
}
export interface ModelToolProviderLike {
listTools(signal: AbortSignal): Promise<ModelToolDefinition[]>
listTools(
context: ModelToolCallContext,
signal: AbortSignal
): Promise<ModelToolDefinition[]>
getApproval(
tool: ModelToolDefinition,
argumentsValue: Record<string, unknown>,
argumentSummary: string
argumentSummary: string,
context: ModelToolCallContext
): RuntimeApprovalRequest
callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<string>
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult>
releaseConversation(conversationId: string): Promise<void>
dispose(): Promise<void>
}
@@ -151,64 +208,177 @@ function createMcpToolName(serverId: string, originalName: string): string {
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
}
function getMcpResultText(result: unknown): string {
function createTextToolResult(text: string): ModelToolResult {
const contextBytes = Buffer.byteLength(text)
if (contextBytes > MAX_TOOL_RESULT_BYTES) {
throw new Error('工具结果超过 256KB 安全限制')
}
return {
parts: [{ type: 'text', text }],
contextBytes
}
}
function parseMcpImage(
content: Record<string, unknown>
): Extract<ModelToolResultPart, { type: 'image' }> {
const mimeType = content.mimeType
if (
mimeType !== 'image/png' &&
mimeType !== 'image/jpeg' &&
mimeType !== 'image/webp'
) {
throw new Error('MCP 工具返回了不支持的图片格式')
}
if (
typeof content.data !== 'string' ||
content.data.length === 0 ||
content.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
content.data
)
) {
throw new Error('MCP 工具返回了无效的 base64 图片')
}
const decoded = Buffer.from(content.data, 'base64')
if (
decoded.length === 0 ||
decoded.length > MAX_TOOL_RESULT_BYTES ||
decoded.toString('base64') !== content.data
) {
throw new Error('MCP 工具返回了无效或过大的 base64 图片')
}
const signatureMatches =
mimeType === 'image/png'
? decoded.length >= 8 &&
decoded.subarray(0, 8).equals(
Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
])
)
: mimeType === 'image/jpeg'
? decoded.length >= 3 &&
decoded[0] === 0xff &&
decoded[1] === 0xd8 &&
decoded[2] === 0xff
: decoded.length >= 12 &&
decoded.subarray(0, 4).toString('ascii') === 'RIFF' &&
decoded.subarray(8, 12).toString('ascii') === 'WEBP'
if (!signatureMatches) {
throw new Error('MCP 工具图片的 MIME 类型与文件签名不匹配')
}
return {
type: 'image',
mimeType,
data: content.data
}
}
function normalizeMcpResult(result: unknown): ModelToolResult {
if (!result || typeof result !== 'object') {
return boundedJson(result, 'MCP 工具结果无法序列化')
return createTextToolResult(
boundedJson(result, 'MCP 工具结果无法序列化')
)
}
const record = result as Record<string, unknown>
if (record.isError === true) {
throw new Error('MCP Server 报告工具执行失败')
}
if ('toolResult' in record) {
return boundedJson(record.toolResult, 'MCP 工具结果无法序列化')
if (
record.toolResult &&
typeof record.toolResult === 'object' &&
(
Array.isArray(
(record.toolResult as Record<string, unknown>).content
) ||
'structuredContent' in
(record.toolResult as Record<string, unknown>) ||
'isError' in (record.toolResult as Record<string, unknown>)
)
) {
return normalizeMcpResult(record.toolResult)
}
return createTextToolResult(
boundedJson(record.toolResult, 'MCP 工具结果无法序列化')
)
}
const sections: string[] = []
const parts: ModelToolResultPart[] = []
if (
record.structuredContent &&
typeof record.structuredContent === 'object'
) {
sections.push(
boundedJson(
parts.push({
type: 'text',
text: boundedJson(
record.structuredContent,
'MCP 结构化工具结果无法序列化'
)
)
})
}
if (Array.isArray(record.content)) {
for (const item of record.content.slice(0, 100)) {
if (record.content.length > MAX_MCP_CONTENT_BLOCKS) {
throw new Error('MCP 工具结果内容块数量超过安全限制')
}
let imageCount = 0
for (const item of record.content) {
if (!item || typeof item !== 'object') {
continue
}
const content = item as Record<string, unknown>
if (content.type === 'text' && typeof content.text === 'string') {
sections.push(content.text)
parts.push({ type: 'text', text: content.text })
} else if (
content.type === 'resource' &&
content.resource &&
typeof content.resource === 'object' &&
typeof (content.resource as Record<string, unknown>).text === 'string'
) {
sections.push(
(content.resource as Record<string, unknown>).text as string
)
parts.push({
type: 'text',
text: (content.resource as Record<string, unknown>).text as string
})
} else if (content.type === 'resource_link') {
sections.push(
boundedJson(content, 'MCP 资源链接无法序列化')
)
} else if (content.type === 'image' || content.type === 'audio') {
sections.push(`[${String(content.type)} result omitted]`)
parts.push({
type: 'text',
text: boundedJson(content, 'MCP 资源链接无法序列化')
})
} else if (content.type === 'image') {
imageCount += 1
if (imageCount > MAX_MCP_IMAGES) {
throw new Error('MCP 工具结果图片数量超过安全限制')
}
parts.push(parseMcpImage(content))
} else if (content.type === 'audio') {
parts.push({
type: 'text',
text: '[audio result unsupported]'
})
}
}
}
const text = sections.join('\n\n').trim()
if (!text) {
return '{}'
if (parts.length === 0) {
return createTextToolResult('{}')
}
if (Buffer.byteLength(text) > MAX_TOOL_RESULT_BYTES) {
let contextBytes = 0
let decodedImageBytes = 0
for (const part of parts) {
contextBytes += Buffer.byteLength(
part.type === 'text' ? part.text : part.data
)
if (part.type === 'image') {
decodedImageBytes += Buffer.from(part.data, 'base64').length
}
}
if (
contextBytes > MAX_TOOL_RESULT_BYTES ||
decodedImageBytes > MAX_TOOL_RESULT_BYTES
) {
throw new Error('工具结果超过 256KB 安全限制')
}
return text
return { parts, contextBytes }
}
export class ModelToolProvider implements ModelToolProviderLike {
@@ -218,9 +388,21 @@ export class ModelToolProvider implements ModelToolProviderLike {
constructor(
private readonly workspace: string,
private readonly mcpServers: ResolvedMcpServer[] = []
private readonly mcpServers: ResolvedMcpServer[] = [],
private readonly browserService?: BrowserToolService
) {}
private getBrowserTools(
context: ModelToolCallContext
): BrowserModelTools | undefined {
return this.browserService && context.workMode === 'execute'
? new BrowserModelTools({
service: this.browserService,
conversationId: context.conversationId
})
: undefined
}
private async getWorkspace(): Promise<string> {
this.canonicalWorkspace ??= getCanonicalWorkspace(
this.workspace,
@@ -289,10 +471,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getBuiltinTools(): ModelToolDefinition[] {
return [
{
name: 'workspace_read_text',
displayName: '读取工作区文本',
description:
'读取当前工作区内一个不超过 256KB 的 UTF-8 文本文件。',
name: workspaceReadTextTool.name,
displayName: workspaceReadTextTool.displayName,
description: workspaceReadTextTool.description,
inputSchema: {
type: 'object',
properties: {
@@ -307,10 +488,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
source: 'builtin'
},
{
name: 'workspace_list_directory',
displayName: '列出工作区目录',
description:
'列出当前工作区内目录的直属内容,最多返回 200 项。',
name: workspaceListDirectoryTool.name,
displayName: workspaceListDirectoryTool.displayName,
description: workspaceListDirectoryTool.description,
inputSchema: {
type: 'object',
properties: {
@@ -324,10 +504,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
source: 'builtin'
},
{
name: 'workspace_write_text',
displayName: '写入工作区文本',
description:
'在当前工作区内新建或覆盖一个不超过 512KB 的 UTF-8 文本文件;父目录必须已存在。',
name: workspaceWriteTextTool.name,
displayName: workspaceWriteTextTool.displayName,
description: workspaceWriteTextTool.description,
inputSchema: {
type: 'object',
properties: {
@@ -366,7 +545,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
timeout: MCP_TIMEOUT_MS,
signal
})
if (result.tools.length > MAX_MODEL_TOOLS - 3) {
const builtinToolCount =
this.getBuiltinTools().length + (this.browserService ? 7 : 0)
if (result.tools.length > MAX_MODEL_TOOLS - builtinToolCount) {
throw new Error(
`MCP Server「${server.name}」提供的工具数量超过安全限制`
)
@@ -386,7 +567,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
.slice(0, 1_000),
inputSchema: normalizeToolSchema(tool.inputSchema),
source: 'mcp',
serverName: server.name
serverName: server.name,
taskSupport: tool.execution?.taskSupport
}
}))
if (
@@ -423,9 +605,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
)
.then((connections) => {
const bindings = new Map<string, McpToolBinding>()
const builtinToolCount =
this.getBuiltinTools().length + (this.browserService ? 7 : 0)
for (const connection of connections) {
for (const binding of connection.tools) {
if (bindings.size + 3 >= MAX_MODEL_TOOLS) {
if (bindings.size + builtinToolCount >= MAX_MODEL_TOOLS) {
throw new Error('直连模型工具总数超过 100 个安全限制')
}
if (bindings.has(binding.definition.name)) {
@@ -448,11 +632,16 @@ export class ModelToolProvider implements ModelToolProviderLike {
return this.mcpBindings
}
async listTools(signal: AbortSignal): Promise<ModelToolDefinition[]> {
async listTools(
context: ModelToolCallContext,
signal: AbortSignal
): Promise<ModelToolDefinition[]> {
signal.throwIfAborted()
const bindings = await this.getMcpBindings(signal)
const browserTools = this.getBrowserTools(context)
return [
...this.getBuiltinTools(),
...(browserTools?.listTools() ?? []),
...[...bindings.values()].map((binding) => binding.definition)
]
}
@@ -460,8 +649,17 @@ export class ModelToolProvider implements ModelToolProviderLike {
getApproval(
tool: ModelToolDefinition,
argumentsValue: Record<string, unknown>,
argumentSummary: string
argumentSummary: string,
context: ModelToolCallContext
): RuntimeApprovalRequest {
const browserTools = this.getBrowserTools(context)
if (browserTools?.ownsTool(tool.name)) {
return browserTools.getApproval(
tool,
argumentsValue,
argumentSummary
)
}
const path =
typeof argumentsValue.path === 'string'
? argumentsValue.path.slice(0, 500)
@@ -490,20 +688,38 @@ export class ModelToolProvider implements ModelToolProviderLike {
async callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<string> {
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult> {
signal.throwIfAborted()
const browserTools = this.getBrowserTools(context)
if (browserTools?.ownsTool(name)) {
try {
return await browserTools.callTool(name, argumentsValue, signal)
} catch (error) {
if (error instanceof BrowserStaleReferenceError) {
throw new RecoverableModelToolError(
error.message,
'调用 browser_snapshot 获取新快照,然后用新引用重试刚才的操作',
{ cause: error }
)
}
throw error
}
}
if (name === 'workspace_read_text') {
const input = readInputSchema.parse(argumentsValue)
const filePath = await this.resolveExistingPath(input.path, 'file')
return (
await readBoundedUtf8File(
filePath,
MAX_READ_BYTES,
'工作区文本文件超过 256KB 安全限制',
'工作区读取目标不是有效 UTF-8 文本'
)
).content
return createTextToolResult(
(
await readBoundedUtf8File(
filePath,
MAX_READ_BYTES,
'工作区文本文件超过 256KB 安全限制',
'工作区读取目标不是有效 UTF-8 文本'
)
).content
)
}
if (name === 'workspace_list_directory') {
const input = listInputSchema.parse(argumentsValue)
@@ -515,21 +731,23 @@ export class ModelToolProvider implements ModelToolProviderLike {
directoryPath,
200
)
return boundedJson(
{
entries: listing.entries
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => ({
name: entry.name,
type: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: 'other'
})),
truncated: listing.truncated
},
'工作区目录结果无法序列化'
return createTextToolResult(
boundedJson(
{
entries: listing.entries
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => ({
name: entry.name,
type: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: 'other'
})),
truncated: listing.truncated
},
'工作区目录结果无法序列化'
)
)
}
if (name === 'workspace_write_text') {
@@ -551,12 +769,14 @@ export class ModelToolProvider implements ModelToolProviderLike {
await rm(temporaryPath, { force: true }).catch(() => undefined)
throw new Error('无法安全写入工作区文件', { cause: error })
}
return boundedJson(
{
path: input.path,
bytesWritten: Buffer.byteLength(input.content)
},
'工作区写入结果无法序列化'
return createTextToolResult(
boundedJson(
{
path: input.path,
bytesWritten: Buffer.byteLength(input.content)
},
'工作区写入结果无法序列化'
)
)
}
@@ -564,18 +784,52 @@ export class ModelToolProvider implements ModelToolProviderLike {
if (!binding) {
throw new Error('模型请求了未知工具')
}
const result = await binding.client.callTool(
{
name: binding.originalName,
arguments: argumentsValue
},
undefined,
{
timeout: MCP_TIMEOUT_MS,
signal
const params = {
name: binding.originalName,
arguments: argumentsValue
}
const options = {
timeout: MCP_TIMEOUT_MS,
signal,
onprogress: () => undefined,
resetTimeoutOnProgress: true,
maxTotalTimeout: MCP_CALL_MAX_TOTAL_TIMEOUT_MS
}
if (binding.definition.taskSupport !== 'required') {
return normalizeMcpResult(
await binding.client.callTool(params, undefined, options)
)
}
let taskId: string | undefined
try {
for await (const message of binding.client.experimental.tasks.callToolStream(
params,
undefined,
options
)) {
if (
(message.type === 'taskCreated' ||
message.type === 'taskStatus') &&
typeof message.task.taskId === 'string'
) {
taskId = message.task.taskId
} else if (message.type === 'result') {
return normalizeMcpResult(message.result)
} else if (message.type === 'error') {
throw message.error
}
}
)
return getMcpResultText(result)
throw new Error('MCP 任务工具未返回最终结果')
} catch (error) {
if (taskId) {
await binding.client.experimental.tasks.cancelTask(taskId, {
timeout: MCP_TASK_CANCEL_TIMEOUT_MS,
maxTotalTimeout: MCP_TASK_CANCEL_TIMEOUT_MS
}).catch(() => undefined)
}
throw error
}
}
async dispose(): Promise<void> {
@@ -584,4 +838,14 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.mcpBindings = undefined
await Promise.allSettled(clients.map((client) => client.close()))
}
async releaseConversation(conversationId: string): Promise<void> {
if (!this.browserService) {
return
}
await new BrowserModelTools({
service: this.browserService,
conversationId
}).release()
}
}
+102 -2
View File
@@ -24,7 +24,7 @@ async function createDatabase(): Promise<AssistantDatabase> {
}
describe('AssistantDatabase', () => {
it('migrates existing databases to schema version 5', async () => {
it('migrates existing databases to schema version 6', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -52,7 +52,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(5)
).toBe(6)
expect(
current
.prepare(
@@ -95,6 +95,74 @@ describe('AssistantDatabase', () => {
current.close()
})
it('idempotently migrates version 5 databases to computer control audit schema', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-control-audit-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const versionFive = new DatabaseSync(databasePath)
versionFive.exec(`
DROP TABLE computer_control_actions;
PRAGMA user_version = 5;
`)
versionFive.close()
for (let attempt = 0; attempt < 2; attempt += 1) {
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
migrated.close()
}
const current = new DatabaseSync(databasePath)
expect(
(
current.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
).toBe(6)
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table'
AND name = 'computer_control_actions'`
)
.get()
).toEqual({ name: 'computer_control_actions' })
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'index'
AND name = 'computer_control_actions_recent_idx'`
)
.get()
).toEqual({ name: 'computer_control_actions_recent_idx' })
expect(
current
.prepare(
'PRAGMA foreign_key_list(computer_control_actions)'
)
.all()
).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: 'tasks',
from: 'task_id',
to: 'id',
on_delete: 'CASCADE'
})
])
)
current.close()
})
it('creates a default project and persists project updates', async () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
@@ -138,6 +206,38 @@ describe('AssistantDatabase', () => {
database.close()
})
it('creates, updates, and soft-deletes expert roles', async () => {
const database = await createDatabase()
const expert = database.createExpert({
name: '代码审查专家',
description: '检查代码正确性',
systemInstructions: 'Review code for actionable bugs.'
})
const updated = database.updateExpert(expert.id, {
name: '高级代码审查专家',
description: '检查正确性和安全性',
systemInstructions: 'Review correctness and security risks.'
})
expect(updated).toMatchObject({
id: expert.id,
name: '高级代码审查专家',
description: '检查正确性和安全性',
systemInstructions: 'Review correctness and security risks.',
enabled: true
})
database.removeExpert(expert.id)
expect(
database.listExperts().some((item) => item.id === expert.id)
).toBe(false)
expect(() => database.getExpert(expert.id)).toThrow(
'专家不存在或已停用'
)
database.close()
})
it('persists task lifecycle and events', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
+368 -11
View File
@@ -12,6 +12,7 @@ import type {
AssistantTask,
ConversationSnapshot,
ExpertCreateInput,
ExpertUpdateInput,
HeartbeatCreateInput,
HeartbeatSummaryOutput,
HeartbeatUpdateInput,
@@ -22,6 +23,13 @@ import type {
TokenUsageRecord,
TokenUsageSummary
} from '../../shared/assistant-contracts'
import {
computerControlErrorCodeSchema,
computerControlRiskSchema,
type ComputerControlErrorCode,
type ComputerControlRisk
} from '../../shared/computer-control-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
type ProjectRow = {
@@ -188,6 +196,23 @@ type TokenUsageRecordRow = {
cache_write_tokens: number
}
type ComputerControlActionRow = {
command_id: string
task_id: string
conversation_id: string
lease_id: string
action: ComputerControlAuditEvent['action']
risk: ComputerControlRisk
outcome: ComputerControlAuditEvent['outcome']
error_code: ComputerControlErrorCode | null
occurred_at: number
text_length: number | null
text_digest: string | null
}
export type PersistedComputerControlAuditEvent =
ComputerControlAuditEvent
export type ClaimedHeartbeatRun = {
config: AssistantHeartbeatConfig
run: AssistantHeartbeatRun
@@ -407,6 +432,76 @@ function validateTokenCount(value: number, label: string): number {
return value
}
const computerControlActions = [
'observe',
'activate',
'replace_text',
'select_option',
'scroll'
] as const
const computerControlOutcomes = [
'completed',
'denied',
'failed',
'outcome_unknown'
] as const
function validateComputerControlId(
value: string,
label: string,
maximumLength: number,
opaque = false
): string {
if (typeof value !== 'string') {
throw new TypeError(`${label} must be a string`)
}
if (
value.length < (opaque ? 16 : 1) ||
value.length > maximumLength ||
(opaque
? !/^[A-Za-z0-9_-]+$/.test(value)
: [...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
}))
) {
throw new RangeError(`Invalid ${label}`)
}
return value
}
function validateComputerControlEnum<T extends string>(
value: string,
label: string,
allowed: readonly T[]
): T {
if (!allowed.includes(value as T)) {
throw new RangeError(`Invalid ${label}`)
}
return value as T
}
function toComputerControlAuditEvent(
row: ComputerControlActionRow
): PersistedComputerControlAuditEvent {
return {
timestamp: row.occurred_at,
taskId: row.task_id,
conversationId: row.conversation_id,
leaseId: row.lease_id,
commandId: row.command_id,
action: row.action,
risk: row.risk,
outcome: row.outcome,
...(row.error_code ? { errorCode: row.error_code } : {}),
...(row.text_length === null
? {}
: { textLength: row.text_length }),
...(row.text_digest === null
? {}
: { textDigest: row.text_digest })
}
}
const interruptedTaskError = '应用退出时任务仍在运行'
const interruptedMessageStatus = '上次运行意外中断,可以重新发送问题'
@@ -575,6 +670,7 @@ export class AssistantDatabase {
'schedules',
'memory_items',
'artifacts',
'computer_control_actions',
'task_events',
'runs',
'model_usage_calls',
@@ -1112,6 +1208,165 @@ export class AssistantDatabase {
)
}
persistComputerControlAudit(
event: ComputerControlAuditEvent
): void {
if (!event || typeof event !== 'object') {
throw new TypeError('Computer control audit event is required')
}
const taskId = validateComputerControlId(
event.taskId,
'taskId',
128
)
const conversationId = validateComputerControlId(
event.conversationId,
'conversationId',
128
)
const leaseId = validateComputerControlId(
event.leaseId,
'leaseId',
160,
true
)
const commandId = validateComputerControlId(
event.commandId,
'commandId',
160,
true
)
const action = validateComputerControlEnum(
event.action,
'action',
computerControlActions
)
const risk = computerControlRiskSchema.parse(event.risk)
const outcome = validateComputerControlEnum(
event.outcome,
'outcome',
computerControlOutcomes
)
if (
!Number.isSafeInteger(event.timestamp) ||
event.timestamp < 0
) {
throw new RangeError('Invalid timestamp')
}
const errorCode =
event.errorCode === undefined
? undefined
: computerControlErrorCodeSchema.parse(event.errorCode)
if (
(outcome === 'completed' && errorCode !== undefined) ||
(outcome !== 'completed' && errorCode === undefined)
) {
throw new RangeError('Invalid outcome and errorCode combination')
}
const hasTextMetadata =
event.textLength !== undefined ||
event.textDigest !== undefined
if (
(action === 'replace_text') !== hasTextMetadata ||
(hasTextMetadata &&
(!Number.isInteger(event.textLength) ||
event.textLength! < 0 ||
event.textLength! > 4_096 ||
typeof event.textDigest !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.textDigest)))
) {
throw new RangeError('Invalid redacted text metadata')
}
const database = this.requireDatabase()
const createdAt = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const inserted = database
.prepare(
`INSERT OR IGNORE INTO computer_control_actions
(command_id, task_id, conversation_id, lease_id, action, risk,
outcome, error_code, occurred_at, text_length, text_digest,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
commandId,
taskId,
conversationId,
leaseId,
action,
risk,
outcome,
errorCode ?? null,
event.timestamp,
event.textLength ?? null,
event.textDigest ?? null,
createdAt
)
if (inserted.changes === 1) {
database
.prepare(
`INSERT INTO task_events
(task_id, run_id, kind, payload_json, created_at)
VALUES (?, NULL, 'computer_control', ?, ?)`
)
.run(
taskId,
JSON.stringify({
commandId,
action,
risk,
outcome,
...(errorCode ? { errorCode } : {}),
...(event.textLength === undefined
? {}
: { textLength: event.textLength }),
...(event.textDigest === undefined
? {}
: { textDigest: event.textDigest })
}),
createdAt
)
database
.prepare(
`DELETE FROM computer_control_actions
WHERE command_id IN (
SELECT command_id FROM computer_control_actions
ORDER BY occurred_at DESC, created_at DESC
LIMIT -1 OFFSET 10000
)`
)
.run()
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listRecentComputerControlAudit(
limit = 100
): PersistedComputerControlAuditEvent[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new RangeError(
'Computer control audit limit must be between 1 and 500'
)
}
const rows = this.requireDatabase()
.prepare(
`SELECT command_id, task_id, conversation_id, lease_id, action,
risk, outcome, error_code, occurred_at, text_length,
text_digest
FROM computer_control_actions
ORDER BY occurred_at DESC, created_at DESC
LIMIT ?`
)
.all(limit) as ComputerControlActionRow[]
return rows.map(toComputerControlAuditEvent)
}
listArtifacts(projectId?: string, limit = 100): AssistantArtifact[] {
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
const columns = `id, project_id, task_id, kind, title, mime_type,
@@ -2304,6 +2559,43 @@ export class AssistantDatabase {
return this.getExpert(id)
}
updateExpert(
expertId: string,
input: ExpertUpdateInput
): AssistantExpert {
const result = this.requireDatabase()
.prepare(
`UPDATE experts
SET name = ?, description = ?, system_instructions = ?,
updated_at = ?
WHERE id = ? AND enabled = 1`
)
.run(
input.name,
input.description,
input.systemInstructions,
new Date().toISOString(),
expertId
)
if (result.changes === 0) {
throw new Error('专家不存在或已停用')
}
return this.getExpert(expertId)
}
removeExpert(expertId: string): void {
const result = this.requireDatabase()
.prepare(
`UPDATE experts
SET enabled = 0, updated_at = ?
WHERE id = ? AND enabled = 1`
)
.run(new Date().toISOString(), expertId)
if (result.changes === 0) {
throw new Error('专家不存在或已停用')
}
}
getExpert(expertId: string): AssistantExpert {
const row = this.requireDatabase()
.prepare('SELECT * FROM experts WHERE id = ? AND enabled = 1')
@@ -2358,7 +2650,7 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version >= 5) {
if (version.user_version >= 6) {
return
}
if (version.user_version < 1) {
@@ -2620,9 +2912,10 @@ export class AssistantDatabase {
COMMIT;
`)
}
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS model_usage_calls (
if (version.user_version < 4) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS model_usage_calls (
request_id TEXT NOT NULL
REFERENCES tasks(id) ON DELETE CASCADE,
call_id TEXT NOT NULL,
@@ -2642,17 +2935,81 @@ export class AssistantDatabase {
CREATE INDEX IF NOT EXISTS model_usage_calls_dimensions_idx
ON model_usage_calls(runtime, provider, model);
PRAGMA user_version = 4;
COMMIT;
`)
database.exec(`
BEGIN IMMEDIATE;
CREATE INDEX IF NOT EXISTS tasks_status_idx
COMMIT;
`)
}
if (version.user_version < 5) {
database.exec(`
BEGIN IMMEDIATE;
CREATE INDEX IF NOT EXISTS tasks_status_idx
ON tasks(status);
CREATE INDEX IF NOT EXISTS messages_state_idx
ON messages(state);
PRAGMA user_version = 5;
COMMIT;
`)
COMMIT;
`)
}
if (version.user_version < 6) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS computer_control_actions (
command_id TEXT PRIMARY KEY
CHECK(length(command_id) BETWEEN 16 AND 160),
task_id TEXT NOT NULL
REFERENCES tasks(id) ON DELETE CASCADE
CHECK(length(task_id) BETWEEN 1 AND 128),
conversation_id TEXT NOT NULL
CHECK(length(conversation_id) BETWEEN 1 AND 128),
lease_id TEXT NOT NULL
CHECK(length(lease_id) BETWEEN 16 AND 160),
action TEXT NOT NULL
CHECK(action IN ('observe', 'activate', 'replace_text',
'select_option', 'scroll')),
risk TEXT NOT NULL
CHECK(risk IN ('observe', 'navigate', 'input', 'commit',
'forbidden')),
outcome TEXT NOT NULL
CHECK(outcome IN ('completed', 'denied', 'failed',
'outcome_unknown')),
error_code TEXT
CHECK(error_code IS NULL OR error_code IN (
'invalid_request', 'driver_unavailable', 'driver_timeout',
'lease_not_found', 'lease_expired', 'lease_mismatch',
'observation_not_found', 'observation_stale',
'observation_consumed', 'element_not_found',
'window_not_foreground', 'element_identity_changed',
'focus_failed', 'forbidden', 'approval_denied',
'approval_timeout', 'cancelled', 'command_id_conflict',
'outcome_unknown', 'internal_error')),
occurred_at INTEGER NOT NULL
CHECK(occurred_at BETWEEN 0 AND 9007199254740991),
text_length INTEGER
CHECK(text_length IS NULL OR
text_length BETWEEN 0 AND 4096),
text_digest TEXT
CHECK(text_digest IS NULL OR (
length(text_digest) = 64 AND
text_digest NOT GLOB '*[^0-9a-f]*')),
created_at TEXT NOT NULL,
CHECK(
(outcome = 'completed' AND error_code IS NULL) OR
(outcome <> 'completed' AND error_code IS NOT NULL)
),
CHECK(
(action = 'replace_text' AND text_length IS NOT NULL AND
text_digest IS NOT NULL) OR
(action <> 'replace_text' AND text_length IS NULL AND
text_digest IS NULL)
)
);
CREATE INDEX IF NOT EXISTS computer_control_actions_recent_idx
ON computer_control_actions(occurred_at DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS computer_control_actions_task_idx
ON computer_control_actions(task_id, occurred_at DESC);
PRAGMA user_version = 6;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {
@@ -91,7 +91,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
user_version: number
}
).user_version
).toBe(5)
).toBe(6)
expect(
(
check
@@ -0,0 +1,210 @@
import { describe, expect, it, vi } from 'vitest'
import {
BrowserModelTools,
browserBackInputSchema,
browserClickInputSchema,
browserNavigateInputSchema,
browserScreenshotInputSchema,
browserSelectInputSchema,
browserSnapshotInputSchema,
browserTypeInputSchema,
type BrowserToolService
} from './browser-model-tools'
function createService(): BrowserToolService {
return {
getOrigin: vi.fn(() => 'https://example.com'),
navigate: vi.fn(async (_conversationId, url) => ({
url,
origin: 'https://example.com'
})),
snapshot: vi.fn(async () => ({
url: 'https://example.com/',
title: 'Example',
nodes: [],
truncated: false
})),
click: vi.fn(async () => undefined),
type: vi.fn(async () => undefined),
select: vi.fn(async () => undefined),
back: vi.fn(async () => ({
url: 'https://previous.example/',
origin: 'https://previous.example'
})),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
})),
releaseConversation: vi.fn(async () => undefined)
}
}
const signal = new AbortController().signal
const ref = 'b_abcdefghijklmnop'
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
const definitions = tools.listTools()
expect(definitions.map((definition) => definition.name)).toEqual([
'browser_navigate',
'browser_snapshot',
'browser_click',
'browser_type',
'browser_select',
'browser_back',
'browser_screenshot'
])
expect(
definitions.every(
(definition) =>
definition.source === 'builtin' &&
definition.inputSchema.additionalProperties === false
)
).toBe(true)
expect(tools.ownsTool('browser_upload')).toBe(false)
expect(tools.ownsTool('browser_download')).toBe(false)
})
it('uses strict Zod parsing for every operation', () => {
const cases: Array<[typeof browserSnapshotInputSchema, unknown]> = [
[browserNavigateInputSchema, { url: 'https://example.com', extra: true }],
[browserSnapshotInputSchema, { extra: true }],
[browserClickInputSchema, { ref: 'not-a-ref' }],
[browserTypeInputSchema, { ref, text: '', extra: true }],
[browserSelectInputSchema, { ref, value: '', extra: true }],
[browserBackInputSchema, { extra: true }],
[browserScreenshotInputSchema, { extra: true }]
]
for (const [schema, value] of cases) {
expect(() => schema.parse(value)).toThrow()
}
expect(() =>
browserNavigateInputSchema.parse({ url: 'file:///etc/passwd' })
).not.toThrow()
// Zod limits shape and size; the URL policy is deliberately applied by
// approval/call handling so non-HTTP schemes still fail before execution.
})
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
const approval = tools.getApproval('browser_navigate', {
url: 'https://example.com/path?token=top-secret'
})
expect(approval).toMatchObject({
scopeKey: 'model:browser:navigate:https://example.com',
allowPermanent: false
})
expect(JSON.stringify(approval)).not.toContain('top-secret')
expect(approval.argumentSummary).toContain('[查询参数已隐藏]')
expect(() =>
tools.getApproval('browser_navigate', {
url: 'file:///etc/passwd'
})
).toThrow('HTTP(S)')
})
it('redacts typed and selected values and prevents session-grant reuse', async () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
})
const first = tools.getApproval('browser_type', {
ref,
text: 'top-secret'
})
const second = tools.getApproval('browser_type', {
ref,
text: 'top-secret'
})
expect(first.scopeKey).not.toBe(second.scopeKey)
expect(first.allowPermanent).toBe(false)
expect(JSON.stringify(first)).not.toContain('top-secret')
const result = await tools.callTool(
'browser_type',
{ ref, text: 'top-secret' },
signal
)
expect(service.type).toHaveBeenCalledWith(
'conversation',
ref,
'top-secret',
signal
)
expect(JSON.stringify(result)).not.toContain('top-secret')
expect(result.parts[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('[已隐藏]')
})
const selectApproval = tools.getApproval('browser_select', {
ref,
value: 'private-value'
})
expect(JSON.stringify(selectApproval)).not.toContain('private-value')
})
it('dispatches safe operations and returns correctly counted results', async () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
})
const navigate = await tools.callTool(
'browser_navigate',
{ url: 'https://example.com/' },
signal
)
const snapshot = await tools.callTool('browser_snapshot', {}, signal)
const click = await tools.callTool('browser_click', { ref }, signal)
const back = await tools.callTool('browser_back', {}, signal)
for (const result of [navigate, snapshot, click, back]) {
const part = result.parts[0]
if (!part || part.type !== 'text') {
throw new Error('expected text result')
}
expect(result.contextBytes).toBe(Buffer.byteLength(part.text))
}
const screenshot = await tools.callTool('browser_screenshot', {}, signal)
expect(screenshot).toEqual({
parts: [
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
}
],
contextBytes: Buffer.byteLength('iVBORw0KGgo=')
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
})
expect(() =>
tools.getApproval('browser_click', { ref, extra: true })
).toThrow()
await expect(
tools.callTool('browser_upload', { path: 'secret.txt' }, signal)
).rejects.toThrow('未知浏览器工具')
const controller = new AbortController()
controller.abort()
await expect(
tools.callTool('browser_snapshot', {}, controller.signal)
).rejects.toHaveProperty('name', 'AbortError')
})
})
+379
View File
@@ -0,0 +1,379 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type {
ModelToolDefinition,
ModelToolResult
} from '../agent/model-tool-provider'
import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service'
const MAX_REF_LENGTH = 64
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const refSchema = z
.string()
.min(1)
.max(MAX_REF_LENGTH)
.regex(/^b_[A-Za-z0-9_-]{1,61}$/u, '')
export const browserNavigateInputSchema = z
.object({
url: z.string().min(1).max(8_192)
})
.strict()
export const browserSnapshotInputSchema = z.object({}).strict()
export const browserClickInputSchema = z
.object({
ref: refSchema
})
.strict()
export const browserTypeInputSchema = z
.object({
ref: refSchema,
text: z.string().min(1).max(MAX_INPUT_LENGTH)
})
.strict()
export const browserSelectInputSchema = z
.object({
ref: refSchema,
value: z.string().min(1).max(MAX_SELECT_LENGTH)
})
.strict()
export const browserBackInputSchema = z.object({}).strict()
export const browserScreenshotInputSchema = z.object({}).strict()
type BrowserToolName =
| 'browser_navigate'
| 'browser_snapshot'
| 'browser_click'
| 'browser_type'
| 'browser_select'
| 'browser_back'
| 'browser_screenshot'
function getBrowserToolMetadata(name: BrowserToolName) {
const summary = builtinModelTools.find((tool) => tool.name === name)
if (!summary) {
throw new Error(`缺少内置浏览器工具定义:${name}`)
}
return {
name,
displayName: summary.displayName,
description: summary.description,
source: 'builtin' as const
}
}
const definitions = [
{
...getBrowserToolMetadata('browser_navigate'),
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
minLength: 1,
maxLength: 8_192,
description: '完整的公开 HTTP(S) URL'
}
},
required: ['url'],
additionalProperties: false
},
},
{
...getBrowserToolMetadata('browser_snapshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_click'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
}
},
required: ['ref'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_type'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
text: {
type: 'string',
minLength: 1,
maxLength: MAX_INPUT_LENGTH,
description: '要输入的文本(审批界面不会显示内容)'
}
},
required: ['ref', 'text'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_select'),
inputSchema: {
type: 'object',
properties: {
ref: {
type: 'string',
pattern: '^b_[A-Za-z0-9_-]{1,61}$',
maxLength: MAX_REF_LENGTH
},
value: {
type: 'string',
minLength: 1,
maxLength: MAX_SELECT_LENGTH
}
},
required: ['ref', 'value'],
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_back'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
},
{
...getBrowserToolMetadata('browser_screenshot'),
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false
}
}
] as const satisfies readonly ModelToolDefinition[]
export type BrowserToolService = Pick<
BrowserService,
| 'getOrigin'
| 'navigate'
| 'snapshot'
| 'click'
| 'type'
| 'select'
| 'back'
| 'screenshot'
| 'releaseConversation'
>
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
}
function createTextResult(value: unknown): ModelToolResult {
const text = JSON.stringify(value)
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
function safeOrigin(value: string | undefined): string {
return value ?? '尚未导航'
}
function navigationLabel(url: URL): string {
const pathname =
url.pathname.length > 500 ? `${url.pathname.slice(0, 500)}` : url.pathname
return `${url.origin}${pathname}${url.search ? '?[查询参数已隐藏]' : ''}`
}
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
}
listTools(): ModelToolDefinition[] {
return definitions.map((definition) => ({
...definition,
inputSchema: { ...definition.inputSchema }
}))
}
ownsTool(name: string): name is BrowserToolName {
return definitions.some((definition) => definition.name === name)
}
getApproval(
tool: ModelToolDefinition | BrowserToolName,
argumentsValue: Record<string, unknown>,
argumentSummaryFromRuntime?: string
): RuntimeApprovalRequest {
void argumentSummaryFromRuntime
const name = typeof tool === 'string' ? tool : tool.name
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
)
let description: string
let argumentSummary: string
let scopeKey: string
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
const target = canonicalizeBrowserUrl(input.url)
const label = navigationLabel(target)
description = `将在隔离浏览器中访问 ${label}。仅允许公开 HTTP(S) 地址。`
argumentSummary = label
scopeKey = `model:browser:navigate:${target.origin}`
} else if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
description = `读取 ${currentOrigin} 的页面结构;可编辑字段值会被隐藏。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:snapshot:${currentOrigin}`
} else if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
description = `点击 ${currentOrigin} 页面中的元素 ${input.ref}`
argumentSummary = `元素:${input.ref};来源:${currentOrigin}`
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
} else if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
// A session approval must never authorize a later value, even for the
// same element. The nonce intentionally makes this invocation-only.
scopeKey = `model:browser:type:${randomUUID()}`
} else if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的选择控件 ${input.ref} 选择已隐藏的值。`
argumentSummary = `元素:${input.ref};选项值:[已隐藏,${input.value.length} 个字符]`
scopeKey = `model:browser:select:${randomUUID()}`
} else if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
description = `${currentOrigin} 返回浏览器历史记录中的上一页。目标仍需通过 URL 安全策略。`
argumentSummary = `当前来源:${currentOrigin}`
scopeKey = `model:browser:back:${randomUUID()}`
} else {
browserScreenshotInputSchema.parse(argumentsValue)
description = `截取 ${currentOrigin} 当前可见页面区域。`
argumentSummary = `来源:${currentOrigin}`
scopeKey = `model:browser:screenshot:${currentOrigin}`
}
const definition = definitions.find((item) => item.name === name)
if (!definition) {
throw new Error(`未知浏览器工具:${name}`)
}
return {
scopeKey,
title: `允许${definition.displayName}`,
description,
toolName: definition.displayName,
argumentSummary,
allowPermanent: false
}
}
async callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (!this.ownsTool(name)) {
throw new Error(`未知浏览器工具:${name}`)
}
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
await this.service.type(
this.conversationId,
input.ref,
input.text,
signal
)
return createTextResult({
typed: input.ref,
text: '[已隐藏]',
characters: input.text.length
})
}
if (name === 'browser_select') {
const input = browserSelectInputSchema.parse(argumentsValue)
await this.service.select(
this.conversationId,
input.ref,
input.value,
signal
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
)
return {
parts: [
{
type: 'image',
mimeType: screenshot.mimeType,
data: screenshot.data
}
],
contextBytes: Buffer.byteLength(screenshot.data)
}
}
async release(): Promise<void> {
await this.service.releaseConversation(this.conversationId)
}
}
+473
View File
@@ -0,0 +1,473 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
BrowserService,
type BrowserDriverLike,
type BrowserSessionLike
} from './browser-service'
import type { BrowserWebContents } from './electron-browser-session'
type HarnessSlot = {
currentOrigin?: string
approvedOrigin?: string
session: BrowserSessionLike
driver: BrowserDriverLike
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function createHarness(options: {
maximumSessions?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
sessionGate?: Promise<void>
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
await options.sessionGate
const webContents = {} as BrowserWebContents
const slot = {} as HarnessSlot
const session: BrowserSessionLike = {
webContents,
approveNavigation: vi.fn((target) => {
slot.approvedOrigin = target.origin
}),
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
dispose: vi.fn(options.dispose ?? (async () => undefined))
}
const driver: BrowserDriverLike = {
navigate: vi.fn(async (url) => {
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
return { url }
}),
snapshot: vi.fn(async () => ({
url: `${slot.currentOrigin}/page`,
title: 'Page',
nodes: [],
truncated: false
})),
click: vi.fn(async () => undefined),
type: vi.fn(async () => undefined),
select: vi.fn(async () => undefined),
getBackTarget: vi.fn(async () => ({
entryId: 4,
url: 'https://previous.example/back'
})),
backTo: vi.fn(async (target) => {
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
return { url: target.url }
}),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
})),
dispose: vi.fn()
}
Object.assign(slot, { session, driver })
slots.push(slot)
byContents.set(webContents, slot)
return session
})
const service = new BrowserService({
policy: new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
]),
maximumSessions: options.maximumSessions,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
createSession,
createDriver: (contents) => {
const slot = byContents.get(contents)
if (!slot) {
throw new Error('unknown contents')
}
return slot.driver
}
})
return { createSession, service, slots }
}
afterEach(() => {
vi.useRealTimers()
})
describe('BrowserService', () => {
it('publishes browser status and live frames through session cleanup', async () => {
const harness = createHarness()
const states: Array<{
status: string
frameDataUrl?: string
}> = []
const removeListener = harness.service.onState((state) => {
states.push(state)
})
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.click('conversation', 'button_ref', signal)
await harness.service.releaseConversation('conversation')
expect(states.map((state) => state.status)).toEqual([
'creating',
'loading',
'ready',
'acting',
'ready',
'stopped'
])
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
'data:image/png;base64,iVBORw0KGgo='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
const replayed: string[] = []
const removeReplayListener = harness.service.onState((state) => {
replayed.push(state.status)
})
expect(replayed).toEqual([])
removeReplayListener()
removeListener()
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during frame capture', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: string[] = []
harness.service.onState((state) => states.push(state.status))
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.screenshot).mockImplementationOnce(
async (operationSignal) =>
new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const click = harness.service.click(
'conversation',
'button_ref',
signal
)
await vi.waitFor(() =>
expect(slot.driver.screenshot).toHaveBeenCalledTimes(2)
)
await harness.service.releaseConversation('conversation')
await expect(click).rejects.toThrow('浏览器会话已释放')
expect(states.at(-1)).toBe('stopped')
})
it('isolates browser state and drivers by conversation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation-a', 'https://a.example/', signal)
await harness.service.navigate('conversation-b', 'https://b.example/', signal)
await harness.service.snapshot('conversation-a', signal)
expect(harness.service.getSessionCount()).toBe(2)
expect(harness.service.getOrigin('conversation-a')).toBe(
'https://a.example'
)
expect(harness.service.getOrigin('conversation-b')).toBe(
'https://b.example'
)
expect(harness.slots[0]?.driver.snapshot).toHaveBeenCalledOnce()
expect(harness.slots[1]?.driver.snapshot).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
for (const id of ['one', 'two', 'three']) {
await harness.service.navigate(id, `https://${id}.example/`, signal)
}
await expect(
harness.service.navigate('four', 'https://four.example/', signal)
).rejects.toThrow('3 个上限')
expect(harness.createSession).toHaveBeenCalledTimes(3)
await harness.service.dispose()
})
it('serializes operations in one conversation and lets queued callers cancel', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.click).mockImplementationOnce(async () =>
clickGate.promise
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(slot.driver.click).toHaveBeenCalled())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
expect(slot.driver.snapshot).not.toHaveBeenCalled()
clickGate.resolve()
await click
await harness.service.dispose()
})
it('does not let a canceled queued waiter clear the active operation owner', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const clickGate = deferred<void>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
let activeSignal: AbortSignal | undefined
vi.mocked(slot.driver.click).mockImplementationOnce(
async (_ref, operationSignal) => {
activeSignal = operationSignal
await clickGate.promise
}
)
const click = harness.service.click('conversation', 'b_ref', signal)
await vi.waitFor(() => expect(activeSignal).toBeDefined())
const queuedController = new AbortController()
const queued = harness.service.snapshot(
'conversation',
queuedController.signal
)
queuedController.abort(new Error('cancel queued'))
await expect(queued).rejects.toThrow('cancel queued')
const clickResult = expect(click).rejects.toThrow(
'浏览器会话已释放'
)
await harness.service.releaseConversation('conversation')
expect(activeSignal?.aborted).toBe(true)
clickGate.resolve()
await clickResult
await harness.service.dispose()
})
it('abandons a sole canceled creation without retaining or consuming a slot', async () => {
const creationGate = deferred<void>()
const harness = createHarness({
maximumSessions: 1,
sessionGate: creationGate.promise
})
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'canceled',
'https://canceled.example/',
canceledController.signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel creation'))
await expect(canceled).rejects.toThrow('cancel creation')
expect(harness.service.getSessionCount()).toBe(0)
const replacement = harness.service.navigate(
'replacement',
'https://replacement.example/',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.createSession).toHaveBeenCalledTimes(2)
)
creationGate.resolve()
await expect(replacement).resolves.toMatchObject({
origin: 'https://replacement.example'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('preserves a shared creation while another waiter cancels', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const canceledController = new AbortController()
const canceled = harness.service.navigate(
'conversation',
'https://example.com/first',
canceledController.signal
)
const shared = harness.service.navigate(
'conversation',
'https://example.com/second',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
canceledController.abort(new Error('cancel one waiter'))
await expect(canceled).rejects.toThrow('cancel one waiter')
creationGate.resolve()
await expect(shared).resolves.toMatchObject({
origin: 'https://example.com'
})
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
await harness.service.dispose()
})
it('expires idle sessions and clears their isolated resources', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
await harness.service.navigate(
'conversation',
'https://a.example/',
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(101)
await vi.waitFor(() => expect(harness.service.getSessionCount()).toBe(0))
expect(harness.slots[0]?.driver.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://current.example/',
signal
)
await expect(harness.service.back('conversation', signal)).resolves.toEqual({
url: 'https://previous.example/back',
origin: 'https://previous.example'
})
expect(harness.service.getOrigin('conversation')).toBe(
'https://previous.example'
)
expect(harness.slots[0]?.approvedOrigin).toBe(
'https://previous.example'
)
await harness.service.dispose()
})
it('fails closed and releases a slot when navigation origin does not match', async () => {
const harness = createHarness()
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
slot.currentOrigin = 'https://attacker.example'
await expect(
harness.service.snapshot(
'conversation',
new AbortController().signal
)
).rejects.toThrow('来源已改变')
expect(harness.service.getSessionCount()).toBe(0)
expect(slot.session.dispose).toHaveBeenCalled()
await harness.service.dispose()
})
it('bounds cleanup and makes release and dispose idempotent', async () => {
const harness = createHarness({
cleanupTimeoutMs: 5,
dispose: async () => new Promise(() => undefined)
})
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
})
it('settles a creation and release race without blocking later reuse', async () => {
const creationGate = deferred<void>()
const harness = createHarness({ sessionGate: creationGate.promise })
const firstNavigation = harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
const release = harness.service.releaseConversation('conversation')
creationGate.resolve()
await release
await expect(firstNavigation).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/new',
new AbortController().signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
it('clears current sessions and remains reusable', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.clearSessions()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[0]?.session.dispose).toHaveBeenCalledOnce()
await expect(
harness.service.navigate(
'conversation',
'https://example.com/again',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
expect(harness.createSession).toHaveBeenCalledTimes(2)
await harness.service.dispose()
})
})
+863
View File
@@ -0,0 +1,863 @@
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
CdpBrowserDriver,
type BrowserHistoryTarget,
type BrowserScreenshot,
type BrowserSnapshot
} from './cdp-browser-driver'
import {
ElectronBrowserSession,
type BrowserWebContents
} from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000
const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000
export type BrowserSessionLike = {
readonly webContents: BrowserWebContents
approveNavigation(
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
): void
getCurrentOrigin(): string | undefined
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
export type BrowserDriverLike = {
navigate(url: string, signal: AbortSignal): Promise<{ url: string }>
snapshot(signal: AbortSignal): Promise<BrowserSnapshot>
click(ref: string, signal: AbortSignal): Promise<void>
type(ref: string, text: string, signal: AbortSignal): Promise<void>
select(ref: string, value: string, signal: AbortSignal): Promise<void>
getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget>
backTo(
target: BrowserHistoryTarget,
signal: AbortSignal
): Promise<{ url: string }>
screenshot(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): void
}
export type BrowserServiceOptions = {
policy?: BrowserUrlPolicy
maximumSessions?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
liveFrameDelayMs?: number
createSession?: (
policy: BrowserUrlPolicy,
signal: AbortSignal
) => Promise<BrowserSessionLike>
createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike
}
type BrowserSlot = {
conversationId: string
session: BrowserSessionLike
driver: BrowserDriverLike
origin?: string
tail: Promise<void>
active?: AbortController
idleTimer?: ReturnType<typeof setTimeout>
lastUsedAt: number
released: boolean
}
type SlotCreation = {
controller: AbortController
promise: Promise<BrowserSlot>
waiters: Set<symbol>
}
function waitFor<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((resolve, reject) => {
const abort = (): void => reject(signal.reason)
signal.addEventListener('abort', abort, { once: true })
void promise.then(
(value) => {
signal.removeEventListener('abort', abort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async function boundedCleanup(
cleanup: Promise<void>,
timeoutMs: number
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
cleanup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error('浏览器会话清理超时')),
timeoutMs
)
})
])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
async function defaultCreateSession(
policy: BrowserUrlPolicy,
signal: AbortSignal
): Promise<BrowserSessionLike> {
return ElectronBrowserSession.create({ policy }, signal)
}
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
return new CdpBrowserDriver(webContents)
}
export class BrowserService {
private readonly policy: BrowserUrlPolicy
private readonly maximumSessions: number
private readonly idleTimeoutMs: number
private readonly cleanupTimeoutMs: number
private readonly liveFrameDelayMs: number
private readonly createSession: NonNullable<
BrowserServiceOptions['createSession']
>
private readonly createDriver: NonNullable<
BrowserServiceOptions['createDriver']
>
private readonly slots = new Map<string, BrowserSlot>()
private readonly creations = new Map<string, SlotCreation>()
private readonly releaseRequests = new Set<string>()
private readonly stateListeners = new Set<
(state: BrowserLiveState) => void
>()
private readonly liveStates = new Map<string, BrowserLiveState>()
private lifecycle = new AbortController()
private clearOperation?: Promise<void>
private clearing = false
private disposed = false
constructor(options: BrowserServiceOptions = {}) {
this.policy = options.policy ?? new BrowserUrlPolicy()
this.maximumSessions = options.maximumSessions ?? 3
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
this.cleanupTimeoutMs =
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
this.createSession = options.createSession ?? defaultCreateSession
this.createDriver = options.createDriver ?? defaultCreateDriver
if (
!Number.isSafeInteger(this.maximumSessions) ||
this.maximumSessions < 1 ||
!Number.isSafeInteger(this.idleTimeoutMs) ||
this.idleTimeoutMs < 1 ||
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
this.cleanupTimeoutMs < 1 ||
!Number.isSafeInteger(this.liveFrameDelayMs) ||
this.liveFrameDelayMs < 0
) {
throw new Error('浏览器服务限制配置无效')
}
}
getOrigin(conversationId: string): string | undefined {
return this.slots.get(conversationId)?.origin
}
getSessionCount(): number {
return this.slots.size
}
onState(listener: (state: BrowserLiveState) => void): () => void {
this.stateListeners.add(listener)
for (const state of this.liveStates.values()) {
listener(state)
}
return () => {
this.stateListeners.delete(listener)
}
}
private emitState(
conversationId: string,
status: BrowserLiveState['status'],
update: Partial<
Pick<BrowserLiveState, 'url' | 'frameDataUrl' | 'error'>
> = {}
): void {
const previous = this.liveStates.get(conversationId)
const state: BrowserLiveState = {
conversationId,
status,
...(previous?.url ? { url: previous.url } : {}),
...update,
updatedAt: Date.now()
}
if (status !== 'failed') {
delete state.error
}
if (status === 'stopped') {
this.liveStates.delete(conversationId)
} else {
this.liveStates.set(conversationId, state)
}
for (const listener of this.stateListeners) {
try {
listener(state)
} catch {
// A UI observer must not interrupt browser control.
}
}
}
private async captureFrame(
conversationId: string,
slot: BrowserSlot,
signal: AbortSignal,
url?: string,
screenshot?: BrowserScreenshot
): Promise<void> {
let frame = screenshot
if (!frame) {
if (this.liveFrameDelayMs > 0) {
await waitFor(
new Promise<void>((resolve) =>
setTimeout(resolve, this.liveFrameDelayMs)
),
signal
)
}
const previewController = new AbortController()
const timeout = setTimeout(
() =>
previewController.abort(
new Error('浏览器实时画面捕获超时')
),
2_000
)
try {
const previewSignal = AbortSignal.any([
signal,
previewController.signal
])
frame = slot.session.captureScreenshot
? await slot.session.captureScreenshot(previewSignal)
: await slot.driver.screenshot(previewSignal)
} catch {
signal.throwIfAborted()
// Browser control succeeds even when the optional live frame fails.
} finally {
clearTimeout(timeout)
}
}
signal.throwIfAborted()
if (slot.released || this.slots.get(conversationId) !== slot) {
return
}
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
...(frame
? {
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
}
: {})
})
}
private emitFailure(
conversationId: string,
stage: string,
error: unknown
): void {
const detail =
error instanceof Error && error.message
? error.message.slice(0, 180)
: '未知错误'
this.emitState(conversationId, 'failed', {
error: `${stage}失败:${detail}`.slice(0, 240)
})
}
private shouldEmitFailure(
conversationId: string,
signal: AbortSignal,
error: unknown
): boolean {
if (signal.aborted || this.releaseRequests.has(conversationId)) {
return false
}
const message = error instanceof Error ? error.message : ''
return ![
'浏览器会话已释放',
'浏览器会话已清除',
'浏览器会话已关闭',
'浏览器服务已关闭'
].some((reason) => message.includes(reason))
}
private async getOrCreateSlot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserSlot> {
if (this.disposed || this.clearing || this.lifecycle.signal.aborted) {
throw new Error('浏览器服务已关闭')
}
const existing = this.slots.get(conversationId)
if (existing && !existing.released) {
return existing
}
let creation = this.creations.get(conversationId)
const pendingCreations = [...this.creations.keys()].filter(
(id) => !this.slots.has(id)
).length
if (
!creation &&
this.slots.size + pendingCreations >= this.maximumSessions
) {
throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`)
}
if (!creation) {
const controller = new AbortController()
const waiters = new Set<symbol>()
const promise = this.createSlot(
conversationId,
AbortSignal.any([this.lifecycle.signal, controller.signal]),
() => waiters.size > 0
)
const currentCreation = { controller, promise, waiters }
creation = currentCreation
this.creations.set(conversationId, creation)
const removeCreation = (): void => {
if (this.creations.get(conversationId) === creation) {
this.creations.delete(conversationId)
}
}
void promise.then(removeCreation, removeCreation)
}
const waiter = Symbol(conversationId)
creation.waiters.add(waiter)
try {
return await waitFor(creation.promise, signal)
} finally {
creation.waiters.delete(waiter)
if (
creation.waiters.size === 0 &&
this.creations.get(conversationId) === creation
) {
this.creations.delete(conversationId)
creation.controller.abort(new Error('浏览器会话创建已取消'))
}
}
}
private async createSlot(
conversationId: string,
signal: AbortSignal,
hasWaiters: () => boolean
): Promise<BrowserSlot> {
const session = await this.createSession(
this.policy,
signal
)
if (
this.disposed ||
signal.aborted ||
!hasWaiters() ||
this.releaseRequests.has(conversationId)
) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw new Error('浏览器服务已关闭')
}
let driver: BrowserDriverLike
try {
driver = this.createDriver(session.webContents)
} catch (error) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(
() => undefined
)
throw error
}
const slot: BrowserSlot = {
conversationId,
session,
driver,
tail: Promise.resolve(),
lastUsedAt: Date.now(),
released: false
}
this.slots.set(conversationId, slot)
this.scheduleIdleExpiry(slot)
return slot
}
private requireSlot(conversationId: string): BrowserSlot {
if (this.disposed) {
throw new Error('浏览器服务已关闭')
}
const slot = this.slots.get(conversationId)
if (!slot || slot.released || !slot.origin) {
throw new Error('当前对话尚未建立浏览器会话,请先导航')
}
return slot
}
private scheduleIdleExpiry(slot: BrowserSlot): void {
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
}
if (slot.released || this.disposed) {
return
}
slot.idleTimer = setTimeout(() => {
if (Date.now() - slot.lastUsedAt < this.idleTimeoutMs) {
this.scheduleIdleExpiry(slot)
return
}
void this.releaseSlot(slot).catch(() => undefined)
}, this.idleTimeoutMs)
}
private async serialize<T>(
slot: BrowserSlot,
signal: AbortSignal,
operation: (effectiveSignal: AbortSignal) => Promise<T>,
status?: 'loading' | 'acting'
): Promise<T> {
signal.throwIfAborted()
if (slot.released || this.disposed) {
throw new Error('浏览器会话已关闭')
}
let releaseGate!: () => void
const gate = new Promise<void>((resolve) => {
releaseGate = resolve
})
const predecessor = slot.tail.catch(() => undefined)
slot.tail = predecessor.then(() => gate)
let operationController: AbortController | undefined
try {
await waitFor(predecessor, signal)
if (slot.released || this.disposed) {
throw new Error('浏览器会话已关闭')
}
if (status) {
this.emitState(slot.conversationId, status)
}
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
slot.idleTimer = undefined
}
operationController = new AbortController()
slot.active = operationController
const effectiveSignal = AbortSignal.any([
signal,
this.lifecycle.signal,
operationController.signal
])
return await operation(effectiveSignal)
} finally {
if (operationController && slot.active === operationController) {
slot.active = undefined
}
releaseGate()
if (operationController) {
slot.lastUsedAt = Date.now()
this.scheduleIdleExpiry(slot)
}
}
}
private verifyCurrentOrigin(slot: BrowserSlot): string {
const current = slot.session.getCurrentOrigin()
if (!slot.origin || current !== slot.origin) {
throw new Error('浏览器页面来源已改变,会话已被拒绝')
}
return current
}
private async verifyCurrentOriginOrRelease(
slot: BrowserSlot
): Promise<string> {
try {
return this.verifyCurrentOrigin(slot)
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}
private async runInSession<T>(
conversationId: string,
signal: AbortSignal,
status: 'loading' | 'acting',
failureStage: string,
operation: (
slot: BrowserSlot,
effectiveSignal: AbortSignal
) => Promise<T>
): Promise<T> {
try {
const slot = this.requireSlot(conversationId)
return await this.serialize(
slot,
signal,
(effectiveSignal) => operation(slot, effectiveSignal),
status
)
} catch (error) {
if (this.shouldEmitFailure(conversationId, signal, error)) {
this.emitFailure(conversationId, failureStage, error)
}
throw error
}
}
async navigate(
conversationId: string,
url: string,
signal: AbortSignal
): Promise<{ url: string; origin: string }> {
if (
!this.slots.has(conversationId) &&
!this.creations.has(conversationId)
) {
this.emitState(conversationId, 'creating', { url })
}
try {
const slot = await this.getOrCreateSlot(conversationId, signal)
return await this.serialize(slot, signal, async (effectiveSignal) => {
const target = await this.policy.validate(url, effectiveSignal)
slot.session.approveNavigation(target)
try {
const result = await slot.driver.navigate(
target.url.href,
effectiveSignal
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
throw new Error('浏览器导航结果来源不一致')
}
slot.origin = finalTarget.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
finalTarget.url.href
)
return {
url: finalTarget.url.href,
origin: finalTarget.origin
}
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}, 'loading')
} catch (error) {
if (this.shouldEmitFailure(conversationId, signal, error)) {
this.emitFailure(conversationId, '浏览器导航', error)
}
throw error
}
}
async snapshot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserSnapshot> {
return this.runInSession(
conversationId,
signal,
'acting',
'读取浏览器页面',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const snapshot = await slot.driver.snapshot(effectiveSignal)
const target = canonicalizeBrowserUrl(snapshot.url)
if (target.origin !== slot.origin) {
await this.releaseSlot(slot).catch(() => undefined)
throw new Error('浏览器快照来源与当前会话不一致')
}
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
target.href
)
return { ...snapshot, url: target.href }
}
)
}
async click(
conversationId: string,
ref: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器点击',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.click(ref, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async type(
conversationId: string,
ref: string,
text: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器输入',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.type(ref, text, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async select(
conversationId: string,
ref: string,
value: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'acting',
'浏览器选择',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
await slot.driver.select(ref, value, effectiveSignal)
await this.captureFrame(conversationId, slot, effectiveSignal)
}
)
}
async back(
conversationId: string,
signal: AbortSignal
): Promise<{ url: string; origin: string }> {
return this.runInSession(
conversationId,
signal,
'loading',
'浏览器返回',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const historyTarget =
await slot.driver.getBackTarget(effectiveSignal)
const target = await this.policy.validate(
historyTarget.url,
effectiveSignal
)
slot.session.approveNavigation(target)
try {
const result = await slot.driver.backTo(
historyTarget,
effectiveSignal
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
throw new Error('浏览器返回结果来源不一致')
}
slot.origin = finalTarget.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
finalTarget.url.href
)
return {
url: finalTarget.url.href,
origin: finalTarget.origin
}
} catch (error) {
await this.releaseSlot(slot).catch(() => undefined)
throw error
}
}
)
}
async screenshot(
conversationId: string,
signal: AbortSignal
): Promise<BrowserScreenshot> {
return this.runInSession(
conversationId,
signal,
'acting',
'浏览器截图',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const screenshot =
await slot.driver.screenshot(effectiveSignal)
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
undefined,
screenshot
)
return screenshot
}
)
}
async releaseConversation(conversationId: string): Promise<void> {
this.releaseRequests.add(conversationId)
let releasedSlot = false
try {
const creation = this.creations.get(conversationId)
if (creation) {
creation.controller.abort(new Error('浏览器会话已释放'))
const slot = await creation.promise.catch(() => undefined)
if (slot) {
await this.releaseSlot(slot)
releasedSlot = true
}
}
const slot = this.slots.get(conversationId)
if (slot) {
await this.releaseSlot(slot)
releasedSlot = true
}
if (!releasedSlot) {
this.emitState(conversationId, 'stopped')
}
} finally {
if (!this.disposed) {
this.releaseRequests.delete(conversationId)
}
}
}
private async releaseSlot(slot: BrowserSlot): Promise<void> {
if (slot.released) {
return
}
slot.released = true
this.slots.delete(slot.conversationId)
if (slot.idleTimer) {
clearTimeout(slot.idleTimer)
slot.idleTimer = undefined
}
slot.active?.abort(new Error('浏览器会话已释放'))
try {
slot.driver.dispose()
} finally {
try {
await boundedCleanup(slot.session.dispose(), this.cleanupTimeoutMs)
} finally {
this.emitState(slot.conversationId, 'stopped')
}
}
}
clearSessions(): Promise<void> {
if (this.disposed) {
return Promise.resolve()
}
if (this.clearOperation) {
return this.clearOperation
}
const operation = this.performClearSessions()
this.clearOperation = operation
void operation.then(
() => {
if (this.clearOperation === operation) {
this.clearOperation = undefined
}
},
() => {
if (this.clearOperation === operation) {
this.clearOperation = undefined
}
}
)
return operation
}
private async performClearSessions(): Promise<void> {
this.clearing = true
const lifecycle = this.lifecycle
lifecycle.abort(new Error('浏览器会话已清除'))
const requestedReleases = new Set(this.creations.keys())
for (const conversationId of requestedReleases) {
this.releaseRequests.add(conversationId)
}
const slots = new Set(this.slots.values())
try {
await Promise.allSettled(
[...this.creations.values()].map((creation) => creation.promise)
)
for (const slot of this.slots.values()) {
slots.add(slot)
}
await Promise.allSettled(
[...slots].map((slot) => this.releaseSlot(slot))
)
this.slots.clear()
} finally {
if (!this.disposed) {
for (const conversationId of requestedReleases) {
this.releaseRequests.delete(conversationId)
}
this.lifecycle = new AbortController()
this.clearing = false
}
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.clearing = true
this.lifecycle.abort(new Error('浏览器服务已关闭'))
const slots = new Set(this.slots.values())
for (const [conversationId, creation] of this.creations) {
this.releaseRequests.add(conversationId)
creation.controller.abort(new Error('浏览器服务已关闭'))
const created = await creation.promise.catch(() => undefined)
if (created) {
slots.add(created)
}
}
for (const slot of this.slots.values()) {
slots.add(slot)
}
await Promise.allSettled([...slots].map((slot) => this.releaseSlot(slot)))
this.slots.clear()
this.stateListeners.clear()
this.liveStates.clear()
}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
isPublicBrowserAddress
} from './browser-url-policy'
const signal = new AbortController().signal
describe('BrowserUrlPolicy', () => {
it.each([
'file:///etc/passwd',
'data:text/html,hello',
'javascript:alert(1)',
'ssh://example.com',
'https://user:secret@example.com/',
'http://localhost/',
'http://printer/',
'http://service.local/',
'http://metadata.google.internal/',
'http://169.254.169.254/latest/meta-data/',
'http://[::1]/'
])('rejects unsafe URL %s', (url) => {
expect(() => canonicalizeBrowserUrl(url)).toThrow()
})
it.each([
'0.0.0.0',
'10.0.0.1',
'100.64.0.1',
'127.0.0.1',
'169.254.169.254',
'172.20.1.1',
'192.168.1.1',
'192.0.2.1',
'224.0.0.1',
'::',
'::1',
'::ffff:127.0.0.1',
'fc00::1',
'fe80::1',
'ff02::1',
'2001:db8::1'
])('classifies %s as non-public', (address) => {
expect(isPublicBrowserAddress(address)).toBe(false)
})
it('accepts canonical public HTTP(S) URLs and strips fragments', async () => {
const resolver = vi.fn(async () => [
{ address: '93.184.216.34', family: 4 as const },
{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 as const }
])
const policy = new BrowserUrlPolicy(resolver)
await expect(
policy.validate('https://example.com:8443/docs?q=1#section', signal)
).resolves.toMatchObject({
origin: 'https://example.com:8443',
url: expect.objectContaining({
href: 'https://example.com:8443/docs?q=1'
})
})
expect(resolver).toHaveBeenCalledWith(
'example.com',
expect.any(AbortSignal)
)
})
it('rejects empty, private, malformed, and mixed DNS answers', async () => {
for (const answers of [
[],
[{ address: '10.0.0.2', family: 4 as const }],
[
{ address: '93.184.216.34', family: 4 as const },
{ address: '127.0.0.1', family: 4 as const }
],
[{ address: 'not-an-address', family: 4 as const }]
]) {
const policy = new BrowserUrlPolicy(async () => answers)
await expect(policy.validate('https://example.com', signal)).rejects.toThrow(
'混合地址'
)
}
})
it('validates redirects and keeps them on the approved origin', async () => {
const policy = new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
])
await expect(
policy.validateRedirect(
'https://example.com/next',
'https://example.com',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
await expect(
policy.validateRedirect(
'https://other.example/next',
'https://example.com',
signal
)
).rejects.toThrow('超出已批准来源')
})
it('honors cancellation before and after DNS resolution', async () => {
const before = new AbortController()
before.abort()
await expect(
new BrowserUrlPolicy(vi.fn()).validate('https://example.com', before.signal)
).rejects.toHaveProperty('name', 'AbortError')
const after = new AbortController()
const policy = new BrowserUrlPolicy(async () => {
after.abort()
return [{ address: '93.184.216.34', family: 4 }]
})
await expect(
policy.validate('https://example.com', after.signal)
).rejects.toHaveProperty('name', 'AbortError')
})
})
+350
View File
@@ -0,0 +1,350 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
export type BrowserResolvedAddress = {
address: string
family: 4 | 6
}
export type BrowserDnsResolver = (
hostname: string,
signal: AbortSignal
) => Promise<readonly BrowserResolvedAddress[]>
export type ValidatedBrowserUrl = {
url: URL
origin: string
addresses: readonly BrowserResolvedAddress[]
}
const LOCAL_HOST_SUFFIXES = [
'.home',
'.internal',
'.invalid',
'.lan',
'.local',
'.localdomain',
'.localhost',
'.test'
]
const BLOCKED_HOSTS = new Set([
'instance-data',
'instance-data.ec2.internal',
'metadata',
'metadata.aws.internal',
'metadata.google.internal'
])
function ipv4Number(address: string): number | undefined {
if (isIP(address) !== 4) {
return undefined
}
const octets = address.split('.').map(Number)
if (octets.length !== 4) {
return undefined
}
return (
(((octets[0] ?? 0) << 24) |
((octets[1] ?? 0) << 16) |
((octets[2] ?? 0) << 8) |
(octets[3] ?? 0)) >>>
0
)
}
function inIpv4Range(value: number, base: number, prefix: number): boolean {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return (value & mask) === (base & mask)
}
function isPublicIpv4(address: string): boolean {
const value = ipv4Number(address)
if (value === undefined) {
return false
}
const blocked: Array<[number, number]> = [
[0x00000000, 8],
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xa9fe0000, 16],
[0xac100000, 12],
[0xc0000000, 24],
[0xc0000200, 24],
[0xc0586300, 24],
[0xc0a80000, 16],
[0xc6120000, 15],
[0xc6336400, 24],
[0xcb007100, 24],
[0xe0000000, 4],
[0xf0000000, 4]
]
return !blocked.some(([base, prefix]) =>
inIpv4Range(value, base, prefix)
)
}
function expandIpv6(address: string): readonly number[] | undefined {
const withoutZone = address.toLowerCase().split('%', 1)[0] ?? ''
if (isIP(withoutZone) !== 6) {
return undefined
}
let normalized = withoutZone
const ipv4Match = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/u)
if (ipv4Match) {
const ipv4 = ipv4Number(ipv4Match[1] ?? '')
if (ipv4 === undefined) {
return undefined
}
normalized = normalized.replace(
ipv4Match[1] ?? '',
`${((ipv4 >>> 16) & 0xffff).toString(16)}:${(ipv4 & 0xffff).toString(16)}`
)
}
const halves = normalized.split('::')
if (halves.length > 2) {
return undefined
}
const left = (halves[0] ?? '').split(':').filter(Boolean)
const right = (halves[1] ?? '').split(':').filter(Boolean)
const missing = 8 - left.length - right.length
if (
(halves.length === 1 && missing !== 0) ||
(halves.length === 2 && missing < 1)
) {
return undefined
}
const groups = [
...left,
...Array.from({ length: Math.max(0, missing) }, () => '0'),
...right
].map((group) => Number.parseInt(group, 16))
return groups.length === 8 &&
groups.every((group) => Number.isInteger(group) && group <= 0xffff)
? groups
: undefined
}
function ipv6Prefix(
groups: readonly number[],
expected: readonly number[],
prefixBits: number
): boolean {
let remaining = prefixBits
for (let index = 0; remaining > 0; index += 1) {
const bits = Math.min(16, remaining)
const mask = (0xffff << (16 - bits)) & 0xffff
if (((groups[index] ?? 0) & mask) !== ((expected[index] ?? 0) & mask)) {
return false
}
remaining -= bits
}
return true
}
function isPublicIpv6(address: string): boolean {
const groups = expandIpv6(address)
if (!groups) {
return false
}
if (groups.slice(0, 5).every((group) => group === 0)) {
const sixth = groups[5] ?? 0
if (sixth === 0xffff) {
const mapped = `${(groups[6] ?? 0) >>> 8}.${(groups[6] ?? 0) & 0xff}.${(groups[7] ?? 0) >>> 8}.${(groups[7] ?? 0) & 0xff}`
return isPublicIpv4(mapped)
}
if (sixth === 0) {
return false
}
}
const blocked: Array<[readonly number[], number]> = [
[[0, 0, 0, 0, 0, 0, 0, 0], 128],
[[0, 0, 0, 0, 0, 0, 0, 1], 128],
[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96],
[[0x64, 0xff9b, 1, 0, 0, 0, 0, 0], 48],
[[0x100, 0, 0, 0, 0, 0, 0, 0], 64],
[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32],
[[0x2001, 2, 0, 0, 0, 0, 0, 0], 48],
[[0x2001, 0x10, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32],
[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16],
[[0x3fff, 0, 0, 0, 0, 0, 0, 0], 20],
[[0x5f00, 0, 0, 0, 0, 0, 0, 0], 16],
[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7],
[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xfec0, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8]
]
return !blocked.some(([prefix, bits]) =>
ipv6Prefix(groups, prefix, bits)
)
}
export function isPublicBrowserAddress(address: string): boolean {
const family = isIP(address.split('%', 1)[0] ?? '')
return family === 4
? isPublicIpv4(address)
: family === 6
? isPublicIpv6(address)
: false
}
export function canonicalizeBrowserUrl(input: string): URL {
if (input !== input.trim() || input.length === 0 || input.length > 8_192) {
throw new Error('浏览器 URL 无效')
}
let url: URL
try {
url = new URL(input)
} catch {
throw new Error('浏览器 URL 无效')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('浏览器仅支持 HTTP(S) URL')
}
if (url.username || url.password || !url.hostname || url.origin === 'null') {
throw new Error('浏览器 URL 不允许包含凭据或无效来源')
}
const rawHostname = url.hostname.toLowerCase()
const hostname = (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
).replace(/\.$/u, '')
if (
hostname !== (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
) ||
(!hostname.includes('.') && isIP(hostname) === 0) ||
BLOCKED_HOSTS.has(hostname) ||
LOCAL_HOST_SUFFIXES.some(
(suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix)
)
) {
throw new Error('浏览器 URL 不允许访问本机或内部名称')
}
if (isIP(hostname) !== 0 && !isPublicBrowserAddress(hostname)) {
throw new Error('浏览器 URL 不允许访问私有或保留地址')
}
url.hash = ''
return url
}
async function defaultResolver(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
signal.throwIfAborted()
const result = await dnsLookup(hostname, {
all: true,
verbatim: true
})
signal.throwIfAborted()
return result
.filter(
(entry): entry is { address: string; family: 4 | 6 } =>
entry.family === 4 || entry.family === 6
)
.map((entry) => ({ address: entry.address, family: entry.family }))
}
export class BrowserUrlPolicy {
constructor(
private readonly resolveDns: BrowserDnsResolver = defaultResolver,
private readonly resolutionTimeoutMs = 10_000
) {
if (
!Number.isSafeInteger(resolutionTimeoutMs) ||
resolutionTimeoutMs < 1
) {
throw new Error('浏览器 DNS 解析期限无效')
}
}
private async resolve(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
const timeout = AbortSignal.timeout(this.resolutionTimeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
const resolution = this.resolveDns(hostname, effectiveSignal)
return new Promise((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器 DNS 解析超时(${this.resolutionTimeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void resolution.then(
(addresses) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(addresses)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async validate(
input: string | URL,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
signal.throwIfAborted()
const url = canonicalizeBrowserUrl(
typeof input === 'string' ? input : input.toString()
)
const literalHostname =
url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname
const literalFamily = isIP(literalHostname)
const addresses =
literalFamily === 4 || literalFamily === 6
? [{
address: literalHostname,
family: literalFamily
} as const]
: await this.resolve(url.hostname, signal)
signal.throwIfAborted()
if (
addresses.length === 0 ||
addresses.some(
(entry) =>
entry.family !== isIP(entry.address) ||
!isPublicBrowserAddress(entry.address)
)
) {
throw new Error('浏览器目标解析到私有、保留或混合地址')
}
return {
url,
origin: url.origin,
addresses: [...addresses]
}
}
async validateRedirect(
input: string,
approvedOrigin: string,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
const target = await this.validate(input, signal)
if (target.origin !== approvedOrigin) {
throw new Error('浏览器重定向超出已批准来源')
}
return target
}
}
+470
View File
@@ -0,0 +1,470 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { CdpBrowserDriver } from './cdp-browser-driver'
import type {
BrowserDebugger,
BrowserWebContents
} from './electron-browser-session'
function createHarness(
command: (
method: string,
parameters?: Record<string, unknown>
) => Promise<unknown>
) {
const contentEvents = new EventEmitter()
const debuggerEvents = new EventEmitter()
let currentUrl = 'https://example.com/page'
const sendCommand = vi.fn(command)
const browserDebugger: BrowserDebugger = {
attach: vi.fn(),
detach: vi.fn(),
isAttached: vi.fn(() => true),
sendCommand,
on: (event, listener) =>
debuggerEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
debuggerEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
)
}
const webContents: BrowserWebContents = {
debugger: browserDebugger,
on: (event, listener) =>
contentEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
contentEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
setWindowOpenHandler: vi.fn(),
getURL: vi.fn(() => currentUrl),
stop: vi.fn(),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
return {
browserDebugger,
contentEvents,
debuggerEvents,
sendCommand,
webContents,
setUrl(url: string) {
currentUrl = url
}
}
}
function standardCommand(
method: string,
parameters?: Record<string, unknown>
): Promise<unknown> {
if (method === 'Accessibility.getFullAXTree') {
return Promise.resolve({
nodes: [
{
nodeId: 'root',
backendDOMNodeId: 10,
role: { value: 'RootWebArea' },
name: { value: 'Example' }
},
{
nodeId: 'button',
parentId: 'root',
backendDOMNodeId: 11,
role: { value: 'button' },
name: { value: 'Submit' }
},
{
nodeId: 'input',
parentId: 'root',
backendDOMNodeId: 12,
role: { value: 'textbox' },
name: { value: 'Email' },
value: { value: 'typed-secret@example.com' },
properties: [{ name: 'editable', value: { value: true } }]
},
{
nodeId: 'password',
parentId: 'root',
backendDOMNodeId: 13,
role: { value: 'password' },
name: { value: 'Password' },
value: { value: 'secret' }
}
]
})
}
if (method === 'Runtime.evaluate') {
return Promise.resolve(
parameters?.expression === 'document.readyState'
? { result: { value: 'complete' } }
: {
result: {
value: {
title: 'Example',
url: 'https://example.com/page'
}
}
}
)
}
if (method === 'DOM.describeNode') {
const backendNodeId = parameters?.backendNodeId
return Promise.resolve({
node: {
backendNodeId,
nodeName:
backendNodeId === 12
? 'INPUT'
: backendNodeId === 13
? 'INPUT'
: 'BUTTON',
attributes:
backendNodeId === 12
? ['type', 'text']
: backendNodeId === 13
? ['type', 'password']
: []
}
})
}
if (method === 'DOM.getBoxModel') {
return Promise.resolve({
model: { content: [10, 20, 110, 20, 110, 60, 10, 60] }
})
}
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({
cssVisualViewport: { clientWidth: 800, clientHeight: 600 }
})
}
if (method === 'Page.getNavigationHistory') {
return Promise.resolve({
currentIndex: 1,
entries: [
{ id: 4, url: 'https://previous.example/' },
{ id: 5, url: 'https://example.com/page' }
]
})
}
if (method === 'Page.captureScreenshot') {
return Promise.resolve({
data: Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
})
}
return Promise.resolve({})
}
function selectCommand(
selection: { selected: boolean; value: string }
): (
method: string,
parameters?: Record<string, unknown>
) => Promise<unknown> {
return async (method, parameters) => {
if (method === 'Accessibility.getFullAXTree') {
return {
nodes: [
{
nodeId: 'root',
backendDOMNodeId: 20,
role: { value: 'RootWebArea' },
name: { value: 'Example' }
},
{
nodeId: 'select',
parentId: 'root',
backendDOMNodeId: 21,
role: { value: 'combobox' },
name: { value: 'Region' }
}
]
}
}
if (method === 'DOM.describeNode') {
return {
node: {
backendNodeId: parameters?.backendNodeId,
nodeName: 'SELECT',
attributes: []
}
}
}
if (method === 'DOM.resolveNode') {
return { object: { objectId: 'select-object' } }
}
if (method === 'Runtime.callFunctionOn') {
return { result: { value: selection } }
}
return standardCommand(method, parameters)
}
}
describe('CdpBrowserDriver', () => {
it('creates opaque refs and redacts editable and protected values', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
expect(snapshot).toMatchObject({
url: 'https://example.com/page',
title: 'Example',
truncated: false
})
expect(snapshot.nodes).toHaveLength(4)
expect(snapshot.nodes.every((node) => /^b_[A-Za-z0-9_-]+$/u.test(node.ref)))
.toBe(true)
expect(snapshot.nodes.find((node) => node.name === 'Email')?.value)
.toBeUndefined()
expect(snapshot.nodes.find((node) => node.name === 'Password')?.value)
.toBeUndefined()
expect(JSON.stringify(snapshot)).not.toContain('typed-secret')
driver.dispose()
})
it('rejects accessibility trees above the configured byte limit', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents, {
maximumAxBytes: 100
})
await expect(
driver.snapshot(new AbortController().signal)
).rejects.toThrow('可访问性树超过安全限制')
driver.dispose()
})
it('keeps refs for subframe navigation and invalidates them for main-frame navigation', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const button = snapshot.nodes.find((node) => node.name === 'Submit')
if (!button) {
throw new Error('button missing')
}
await driver.click(button.ref, new AbortController().signal)
expect(harness.sendCommand).toHaveBeenCalledWith(
'Input.dispatchMouseEvent',
expect.objectContaining({ type: 'mousePressed', x: 60, y: 40 })
)
harness.contentEvents.emit(
'did-start-navigation',
{},
'https://ads.example/frame',
false,
false,
1,
2
)
await expect(
driver.click(button.ref, new AbortController().signal)
).resolves.toBeUndefined()
harness.contentEvents.emit(
'did-start-navigation',
{},
'https://example.com/next',
false,
true,
1,
1
)
await expect(
driver.click(button.ref, new AbortController().signal)
).rejects.toThrow('引用已失效')
driver.dispose()
})
it('keeps refs for subframe redirects and invalidates them for main-frame redirects', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const button = snapshot.nodes.find((node) => node.name === 'Submit')
if (!button) {
throw new Error('button missing')
}
harness.contentEvents.emit(
'will-redirect',
{},
'https://ads.example/redirect',
false,
false,
1,
2
)
await expect(
driver.click(button.ref, new AbortController().signal)
).resolves.toBeUndefined()
harness.contentEvents.emit(
'will-redirect',
{},
'https://example.com/redirect',
false,
true,
1,
1
)
await expect(
driver.click(button.ref, new AbortController().signal)
).rejects.toThrow('引用已失效')
driver.dispose()
})
it('rejects password, file, hidden, and stale typing targets', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const password = snapshot.nodes.find((node) => node.name === 'Password')
if (!password) {
throw new Error('password missing')
}
await expect(
driver.type(password.ref, 'never-send', new AbortController().signal)
).rejects.toThrow('受保护')
expect(
harness.sendCommand.mock.calls.some(
([method, parameters]) =>
method === 'Input.insertText' &&
parameters?.text === 'never-send'
)
).toBe(false)
driver.dispose()
})
it('selects an exact native option value with fixed internal DOM code', async () => {
const selectedValue = `us-west'); globalThis.compromised = true; ('`
const harness = createHarness(
selectCommand({ selected: true, value: selectedValue })
)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const select = snapshot.nodes.find((node) => node.name === 'Region')
if (!select) {
throw new Error('select missing')
}
await driver.select(
select.ref,
selectedValue,
new AbortController().signal
)
const call = harness.sendCommand.mock.calls.find(
([method]) => method === 'Runtime.callFunctionOn'
)
expect(call?.[1]).toMatchObject({
objectId: 'select-object',
arguments: [{ value: selectedValue }],
returnByValue: true
})
expect(call?.[1]?.functionDeclaration).toEqual(expect.any(String))
expect(String(call?.[1]?.functionDeclaration)).not.toContain(selectedValue)
expect(String(call?.[1]?.functionDeclaration)).toContain(
"new Event('input'"
)
expect(String(call?.[1]?.functionDeclaration)).toContain(
"new Event('change'"
)
expect(
harness.sendCommand.mock.calls.some(
([method]) =>
method === 'Input.insertText' ||
method === 'Input.dispatchKeyEvent'
)
).toBe(false)
expect(harness.sendCommand).toHaveBeenCalledWith(
'Runtime.releaseObject',
{ objectId: 'select-object' }
)
driver.dispose()
})
it('rejects a native select result that is not an exact value match', async () => {
const harness = createHarness(
selectCommand({ selected: false, value: 'partial-match' })
)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
const select = snapshot.nodes.find((node) => node.name === 'Region')
if (!select) {
throw new Error('select missing')
}
await expect(
driver.select(
select.ref,
'partial-match-longer',
new AbortController().signal
)
).rejects.toThrow('完全匹配')
expect(harness.sendCommand).toHaveBeenCalledWith(
'Runtime.releaseObject',
{ objectId: 'select-object' }
)
driver.dispose()
})
it('bounds screenshots and returns only validated PNG data', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.screenshot(new AbortController().signal)
).resolves.toMatchObject({
type: 'image',
mimeType: 'image/png'
})
harness.sendCommand.mockImplementation(async (method) =>
method === 'Page.captureScreenshot' ? { data: 'bm90LXBuZw==' } : {}
)
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('截图无效')
driver.dispose()
})
it('validates a history target again before returning', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const target = await driver.getBackTarget(new AbortController().signal)
expect(target).toEqual({
entryId: 4,
url: 'https://previous.example/'
})
harness.setUrl('https://previous.example/')
await expect(
driver.backTo(target, new AbortController().signal)
).resolves.toEqual({ url: 'https://previous.example/' })
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigateToHistoryEntry',
{ entryId: 4 }
)
driver.dispose()
})
it('bounds hung commands and removes listeners on disposal', async () => {
const harness = createHarness(async () => new Promise(() => undefined))
const driver = new CdpBrowserDriver(harness.webContents, { timeoutMs: 5 })
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('超时')
driver.dispose()
expect(harness.contentEvents.listenerCount('did-start-navigation')).toBe(0)
expect(harness.debuggerEvents.listenerCount('detach')).toBe(0)
await expect(
driver.screenshot(new AbortController().signal)
).rejects.toThrow('不可用')
})
})
+797
View File
@@ -0,0 +1,797 @@
import { createHash, randomBytes } from 'node:crypto'
import type {
BrowserDebugger,
BrowserEventListener,
BrowserWebContents
} from './electron-browser-session'
const DEFAULT_TIMEOUT_MS = 15_000
const MAX_AX_NODES = 500
const MAX_AX_DEPTH = 20
const MAX_AX_BYTES = 1024 * 1024
const MAX_SNAPSHOT_BYTES = 128 * 1024
const MAX_SCREENSHOT_BYTES = 512 * 1024
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const SELECT_OPTION_FUNCTION = `function (expectedValue) {
const options = Array.from(this.options);
const option = options.find((candidate) => candidate.value === expectedValue);
if (!option) {
return { selected: false, value: this.value };
}
this.value = expectedValue;
const selected = this.value === expectedValue;
if (selected) {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}
return { selected, value: this.value };
}`
type CdpAxValue = {
type?: string
value?: unknown
}
type CdpAxProperty = {
name?: string
value?: CdpAxValue
}
type CdpAxNode = {
nodeId?: string
backendDOMNodeId?: number
parentId?: string
ignored?: boolean
role?: CdpAxValue
name?: CdpAxValue
value?: CdpAxValue
properties?: CdpAxProperty[]
}
export type BrowserSnapshotNode = {
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}
export type BrowserSnapshot = {
url: string
title: string
nodes: BrowserSnapshotNode[]
truncated: boolean
}
export type BrowserScreenshot = {
type: 'image'
mimeType: 'image/png'
data: string
}
export class BrowserStaleReferenceError extends Error {
constructor(message = '浏览器元素引用已失效,请重新获取快照') {
super(message)
this.name = 'BrowserStaleReferenceError'
}
}
export type BrowserHistoryTarget = {
entryId: number
url: string
}
type RefBinding = {
backendNodeId: number
generation: number
role: string
protected: boolean
}
export type CdpBrowserDriverOptions = {
timeoutMs?: number
maximumAxNodes?: number
maximumAxDepth?: number
maximumAxBytes?: number
maximumSnapshotBytes?: number
maximumScreenshotBytes?: number
}
type ResolvedTarget = {
backendNodeId: number
bounds: { x: number; y: number; width: number; height: number }
}
function stringValue(value: CdpAxValue | undefined): string {
return typeof value?.value === 'string'
? value.value.slice(0, 2_000)
: value?.value === undefined
? ''
: String(value.value).slice(0, 2_000)
}
function propertyBoolean(
node: CdpAxNode,
name: string
): boolean | undefined {
const value = node.properties?.find((property) => property.name === name)?.value
?.value
return typeof value === 'boolean' ? value : undefined
}
function isProtectedAxNode(node: CdpAxNode): boolean {
const role = stringValue(node.role).toLowerCase()
const properties = new Map(
node.properties?.map((property) => [
property.name,
property.value?.value
])
)
return (
role === 'password' ||
properties.get('hidden') === true ||
properties.get('protected') === true ||
properties.get('valuetext') === '••••••••'
)
}
function delayAbortable(
milliseconds: number,
signal: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(finish, milliseconds)
function finish(): void {
signal.removeEventListener('abort', abort)
resolve()
}
function abort(): void {
clearTimeout(timer)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
})
}
function jsonStringBytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d ||
code === 0x22 ||
code === 0x5c
) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
value.charCodeAt(index + 1) >= 0xdc00 &&
value.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4
index += 1
} else if (code >= 0xd800 && code <= 0xdfff) {
bytes += 6
} else {
bytes += 3
}
}
return bytes
}
function exceedsJsonByteLimit(value: unknown, maximumBytes: number): boolean {
let bytes = 0
const stack = [value]
const seen = new WeakSet<object>()
const add = (amount: number): boolean => {
bytes += amount
return bytes > maximumBytes
}
while (stack.length > 0) {
const current = stack.pop()
if (current === null) {
if (add(4)) return true
} else if (typeof current === 'string') {
if (add(jsonStringBytes(current))) return true
} else if (typeof current === 'number') {
if (add(Number.isFinite(current) ? String(current).length : 4)) {
return true
}
} else if (typeof current === 'boolean') {
if (add(current ? 4 : 5)) return true
} else if (Array.isArray(current)) {
if (seen.has(current) || add(current.length > 0 ? current.length + 1 : 2)) {
return true
}
seen.add(current)
for (let index = current.length - 1; index >= 0; index -= 1) {
stack.push(current[index])
}
} else if (typeof current === 'object') {
if (seen.has(current)) return true
seen.add(current)
const entries = Object.entries(current).filter(
([, entryValue]) => entryValue !== undefined
)
if (add(entries.length > 0 ? entries.length + 1 : 2)) return true
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, entryValue] = entries[index]!
if (add(jsonStringBytes(key) + 1)) return true
stack.push(entryValue)
}
} else {
return true
}
}
return false
}
export class CdpBrowserDriver {
private readonly debugger: BrowserDebugger
private readonly timeoutMs: number
private readonly maximumAxNodes: number
private readonly maximumAxDepth: number
private readonly maximumAxBytes: number
private readonly maximumSnapshotBytes: number
private readonly maximumScreenshotBytes: number
private readonly refSecret = randomBytes(16)
private readonly refs = new Map<string, RefBinding>()
private readonly listeners: Array<{
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}> = []
private generation = 0
private disposed = false
constructor(
private readonly webContents: BrowserWebContents,
options: CdpBrowserDriverOptions = {}
) {
this.debugger = webContents.debugger
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
this.maximumAxNodes = options.maximumAxNodes ?? MAX_AX_NODES
this.maximumAxDepth = options.maximumAxDepth ?? MAX_AX_DEPTH
this.maximumAxBytes = options.maximumAxBytes ?? MAX_AX_BYTES
this.maximumSnapshotBytes =
options.maximumSnapshotBytes ?? MAX_SNAPSHOT_BYTES
this.maximumScreenshotBytes =
options.maximumScreenshotBytes ?? MAX_SCREENSHOT_BYTES
this.listen(
webContents,
'did-start-navigation',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(
webContents,
'will-redirect',
(
_event: unknown,
_url: string,
_isInPlace: boolean,
isMainFrame: boolean | undefined
) => {
if (isMainFrame !== false) {
this.invalidate()
}
}
)
this.listen(webContents, 'render-process-gone', () => this.invalidate())
this.listen(this.debugger, 'detach', () => this.invalidate())
}
private listen(
target: {
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private invalidate(): void {
this.generation += 1
this.refs.clear()
}
private command<T>(
method: string,
parameters: Record<string, unknown> | undefined,
signal: AbortSignal
): Promise<T> {
if (this.disposed || !this.debugger.isAttached()) {
return Promise.reject(new Error('浏览器调试连接不可用'))
}
signal.throwIfAborted()
const timeout = AbortSignal.timeout(this.timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器操作超时(${this.timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void this.debugger
.sendCommand(method, parameters)
.then((result) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(result as T)
}
})
.catch((error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(new Error(`浏览器命令失败:${method}`, { cause: error }))
})
})
}
async navigate(url: string, signal: AbortSignal): Promise<{ url: string }> {
this.invalidate()
const result = await this.command<{
errorText?: string
}>('Page.navigate', { url }, signal)
if (result.errorText) {
throw new Error(`浏览器导航失败:${result.errorText.slice(0, 200)}`)
}
await this.waitForDocument(signal)
return { url: this.webContents.getURL() || url }
}
private async waitForDocument(signal: AbortSignal): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await this.command<{
result?: { value?: string }
}>(
'Runtime.evaluate',
{
expression: 'document.readyState',
returnByValue: true,
awaitPromise: false
},
signal
)
if (
result.result?.value === 'interactive' ||
result.result?.value === 'complete'
) {
return
}
await delayAbortable(50, signal)
}
throw new Error('浏览器页面未在安全期限内就绪')
}
private refFor(backendNodeId: number): string {
return `b_${createHash('sha256')
.update(this.refSecret)
.update(String(this.generation))
.update(':')
.update(String(backendNodeId))
.digest('base64url')
.slice(0, 18)}`
}
async snapshot(signal: AbortSignal): Promise<BrowserSnapshot> {
this.invalidate()
const response = await this.command<{ nodes?: CdpAxNode[] }>(
'Accessibility.getFullAXTree',
{ depth: this.maximumAxDepth },
signal
)
if (exceedsJsonByteLimit(response, this.maximumAxBytes)) {
throw new Error('浏览器可访问性树超过安全限制')
}
const allNodes = response.nodes ?? []
const limited = allNodes.slice(0, this.maximumAxNodes)
const knownDepth = new Map<string, number>()
const output: BrowserSnapshotNode[] = []
for (const node of limited) {
const parentDepth = node.parentId
? knownDepth.get(node.parentId)
: -1
const depth = (parentDepth ?? this.maximumAxDepth) + 1
if (node.nodeId) {
knownDepth.set(node.nodeId, depth)
}
if (
depth > this.maximumAxDepth ||
node.ignored ||
!node.backendDOMNodeId
) {
continue
}
const role = stringValue(node.role) || 'unknown'
const ref = this.refFor(node.backendDOMNodeId)
const protectedNode = isProtectedAxNode(node)
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
})
const item: BrowserSnapshotNode = {
ref,
role,
name: stringValue(node.name),
disabled: propertyBoolean(node, 'disabled'),
focused: propertyBoolean(node, 'focused'),
editable: propertyBoolean(node, 'editable')
}
const value = stringValue(node.value)
const redactedValue =
protectedNode ||
item.editable === true ||
['combobox', 'searchbox', 'spinbutton', 'textbox'].includes(
role.toLowerCase()
)
if (value && !redactedValue) {
item.value = value
}
output.push(item)
}
const document = await this.command<{
result?: { value?: { title?: unknown; url?: unknown } }
}>(
'Runtime.evaluate',
{
expression: '({title: document.title, url: location.href})',
returnByValue: true,
awaitPromise: false
},
signal
)
const title =
typeof document.result?.value?.title === 'string'
? document.result.value.title.slice(0, 500)
: ''
const url =
typeof document.result?.value?.url === 'string'
? document.result.value.url.slice(0, 8_192)
: this.webContents.getURL()
const snapshot = {
url,
title,
nodes: output,
truncated: allNodes.length > limited.length
}
if (Buffer.byteLength(JSON.stringify(snapshot)) > this.maximumSnapshotBytes) {
this.refs.clear()
throw new Error('浏览器快照超过安全限制')
}
return snapshot
}
private async resolveTarget(
ref: string,
action: 'click' | 'type' | 'select',
signal: AbortSignal
): Promise<ResolvedTarget> {
const binding = this.refs.get(ref)
if (!binding || binding.generation !== this.generation) {
throw new BrowserStaleReferenceError()
}
const described = await this.command<{
node?: Record<string, unknown> & {
attributes?: unknown
nodeName?: unknown
backendNodeId?: unknown
}
}>(
'DOM.describeNode',
{
backendNodeId: binding.backendNodeId,
depth: 0,
pierce: false
},
signal
)
const node = described.node
if (!node || node.backendNodeId !== binding.backendNodeId) {
throw new Error('浏览器元素状态已改变,请重新获取快照')
}
const attributes = Array.isArray(node.attributes)
? node.attributes.filter(
(value): value is string => typeof value === 'string'
)
: []
const attributeMap = new Map<string, string>()
for (let index = 0; index + 1 < attributes.length; index += 2) {
attributeMap.set(
(attributes[index] ?? '').toLowerCase(),
attributes[index + 1] ?? ''
)
}
const nodeName =
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
const blocked =
binding.protected ||
attributeMap.has('hidden') ||
attributeMap.has('disabled') ||
attributeMap.has('inert') ||
attributeMap.has('readonly') ||
attributeMap.get('aria-hidden') === 'true' ||
attributeMap.get('aria-disabled') === 'true' ||
inputType === 'hidden' ||
inputType === 'password' ||
inputType === 'file'
if (blocked) {
throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段')
}
if (
action === 'type' &&
!(
nodeName === 'textarea' ||
nodeName === 'input' ||
attributeMap.get('contenteditable') === 'true'
)
) {
throw new Error('浏览器目标不是可编辑字段')
}
if (action === 'select' && nodeName !== 'select') {
throw new Error('浏览器目标不是选择控件')
}
const model = await this.command<{
model?: {
content?: number[]
border?: number[]
}
}>(
'DOM.getBoxModel',
{ backendNodeId: binding.backendNodeId },
signal
)
const quad = model.model?.content ?? model.model?.border
if (!quad || quad.length !== 8 || quad.some((value) => !Number.isFinite(value))) {
throw new Error('浏览器元素不可见或没有有效边界')
}
const xs = [quad[0] ?? 0, quad[2] ?? 0, quad[4] ?? 0, quad[6] ?? 0]
const ys = [quad[1] ?? 0, quad[3] ?? 0, quad[5] ?? 0, quad[7] ?? 0]
const x = Math.min(...xs)
const y = Math.min(...ys)
const width = Math.max(...xs) - x
const height = Math.max(...ys) - y
const metrics = await this.command<{
cssVisualViewport?: { clientWidth?: number; clientHeight?: number }
layoutViewport?: { clientWidth?: number; clientHeight?: number }
}>('Page.getLayoutMetrics', undefined, signal)
const viewport = metrics.cssVisualViewport ?? metrics.layoutViewport
const viewportWidth = viewport?.clientWidth ?? 0
const viewportHeight = viewport?.clientHeight ?? 0
if (
width < 1 ||
height < 1 ||
x < 0 ||
y < 0 ||
x + width > viewportWidth ||
y + height > viewportHeight
) {
throw new Error('浏览器元素超出当前可见页面边界')
}
return {
backendNodeId: binding.backendNodeId,
bounds: { x, y, width, height }
}
}
async click(ref: string, signal: AbortSignal): Promise<void> {
const target = await this.resolveTarget(ref, 'click', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseMoved', x, y },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
}
async type(
ref: string,
text: string,
signal: AbortSignal
): Promise<void> {
if (!text || text.length > MAX_INPUT_LENGTH) {
throw new Error('浏览器输入内容为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'type', signal)
const x = target.bounds.x + target.bounds.width / 2
const y = target.bounds.y + target.bounds.height / 2
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mousePressed', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command(
'Input.dispatchMouseEvent',
{ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 },
signal
)
await this.command('Input.insertText', { text }, signal)
}
async select(
ref: string,
value: string,
signal: AbortSignal
): Promise<void> {
if (!value || value.length > MAX_SELECT_LENGTH) {
throw new Error('浏览器选择值为空或超过安全限制')
}
const target = await this.resolveTarget(ref, 'select', signal)
const resolved = await this.command<{
object?: { objectId?: string }
}>(
'DOM.resolveNode',
{ backendNodeId: target.backendNodeId },
signal
)
const objectId = resolved.object?.objectId
if (!objectId) {
throw new Error('浏览器选择控件已失效')
}
try {
const result = await this.command<{
exceptionDetails?: unknown
result?: {
value?: { selected?: unknown; value?: unknown }
}
}>(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: SELECT_OPTION_FUNCTION,
arguments: [{ value }],
returnByValue: true,
awaitPromise: false,
silent: true
},
signal
)
if (
result.exceptionDetails ||
result.result?.value?.selected !== true ||
result.result.value.value !== value
) {
throw new Error('浏览器未找到完全匹配的选择项')
}
} finally {
await this.command(
'Runtime.releaseObject',
{ objectId },
new AbortController().signal
).catch(() => undefined)
}
}
async getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget> {
const history = await this.command<{
currentIndex?: number
entries?: Array<{ id?: number; url?: string }>
}>('Page.getNavigationHistory', undefined, signal)
const index = history.currentIndex ?? -1
const entry = history.entries?.[index - 1]
if (
index < 1 ||
typeof entry?.id !== 'number' ||
typeof entry.url !== 'string' ||
entry.url.length === 0 ||
entry.url.length > 8_192
) {
throw new Error('浏览器没有可返回的页面')
}
return { entryId: entry.id, url: entry.url }
}
async backTo(
target: BrowserHistoryTarget,
signal: AbortSignal
): Promise<{ url: string }> {
const current = await this.getBackTarget(signal)
if (current.entryId !== target.entryId || current.url !== target.url) {
throw new Error('浏览器历史记录已改变,请重试')
}
this.invalidate()
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
await this.waitForDocument(signal)
return { url: this.webContents.getURL() }
}
async back(signal: AbortSignal): Promise<{ url: string }> {
return this.backTo(await this.getBackTarget(signal), signal)
}
async screenshot(signal: AbortSignal): Promise<BrowserScreenshot> {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'png',
fromSurface: true,
captureBeyondViewport: false
},
signal
)
if (
typeof result.data !== 'string' ||
result.data.length === 0 ||
result.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
result.data
)
) {
throw new Error('浏览器返回了无效截图')
}
const data = Buffer.from(result.data, 'base64')
if (
data.byteLength > this.maximumScreenshotBytes ||
data.byteLength < 8 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
) ||
data.toString('base64') !== result.data
) {
throw new Error('浏览器截图无效或超过安全限制')
}
return { type: 'image', mimeType: 'image/png', data: result.data }
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.invalidate()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
}
}
@@ -0,0 +1,361 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { BrowserUrlPolicy } from './browser-url-policy'
import {
ElectronBrowserSession,
type BrowserPartitionSession,
type BrowserWebContents,
type BrowserWindowHandle,
type FilteringProxyLike
} from './electron-browser-session'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function createHarness() {
const debuggerEvents = new EventEmitter()
const contentEvents = new EventEmitter()
const partitionEvents = new EventEmitter()
let currentUrl = ''
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
const sendCommand = vi.fn(async () => ({}))
const webContents: BrowserWebContents = {
debugger: {
attach: vi.fn(),
detach: vi.fn(),
isAttached: vi.fn(() => true),
sendCommand,
on: (event, listener) =>
debuggerEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
debuggerEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
)
},
on: (event, listener) =>
contentEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
contentEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
setWindowOpenHandler: vi.fn((handler) => {
openHandler = handler
}),
capturePage: vi.fn(async () => ({
toPNG: () =>
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
})),
getURL: vi.fn(() => currentUrl),
stop: vi.fn(),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
const window: BrowserWindowHandle = {
webContents,
loadURL: vi.fn(async (url: string) => {
currentUrl = url
}),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
let permissionCheck: ((...values: unknown[]) => boolean) | undefined
let permissionRequest:
| ((
contents: unknown,
permission: string,
callback: (granted: boolean) => void,
details: unknown
) => void)
| undefined
let displayMedia:
| ((
request: unknown,
callback: (streams: Record<string, never>) => void
) => void)
| undefined
const partition: BrowserPartitionSession = {
setPermissionCheckHandler: vi.fn((handler) => {
permissionCheck = handler
}),
setPermissionRequestHandler: vi.fn((handler) => {
permissionRequest = handler
}),
setDisplayMediaRequestHandler: vi.fn((handler) => {
displayMedia = handler
}),
setProxy: vi.fn(async () => undefined),
on: (event, listener) =>
partitionEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
partitionEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
clearData: vi.fn(async () => undefined),
closeAllConnections: vi.fn(async () => undefined)
}
const proxy: FilteringProxyLike = {
start: vi.fn(async () => 'http://127.0.0.1:12345'),
dispose: vi.fn(async () => undefined)
}
const policy = new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
])
return {
contentEvents,
debuggerEvents,
partitionEvents,
partition,
proxy,
policy,
sendCommand,
webContents,
window,
setCurrentUrl(value: string) {
currentUrl = value
},
getOpenHandler: () => openHandler,
getPermissionCheck: () => permissionCheck,
getPermissionRequest: () => permissionRequest,
getDisplayMedia: () => displayMedia
}
}
describe('ElectronBrowserSession', () => {
it('creates an isolated sandboxed partition and denies privileged capabilities', async () => {
const harness = createHarness()
const createWindow = vi.fn(async (options: Record<string, unknown>) => {
const preferences = options.webPreferences as Record<string, unknown>
expect(preferences).toMatchObject({
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false,
devTools: false
})
return harness.window
})
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: vi.fn(async () => harness.partition),
createWindow,
createProxy: () => harness.proxy
})
expect(session.partition).toMatch(/^browser-/u)
expect(harness.partition.setProxy).toHaveBeenCalledWith({
mode: 'fixed_servers',
proxyRules: 'http://127.0.0.1:12345',
proxyBypassRules: '<-loopback>'
})
expect(harness.getPermissionCheck()?.()).toBe(false)
const permissionCallback = vi.fn()
harness.getPermissionRequest()?.({}, 'geolocation', permissionCallback, {})
expect(permissionCallback).toHaveBeenCalledWith(false)
const mediaCallback = vi.fn()
harness.getDisplayMedia()?.({}, mediaCallback)
expect(mediaCallback).toHaveBeenCalledWith({})
expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({
action: 'deny'
})
expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank')
expect(
vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0]
).toBeLessThan(
vi.mocked(harness.webContents.debugger.attach).mock
.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
expect(harness.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
expect(harness.sendCommand).toHaveBeenCalledWith('Page.enable')
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.setInterceptFileChooserDialog',
{ enabled: true }
)
await expect(
session.captureScreenshot(new AbortController().signal)
).resolves.toEqual({
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
})
const downloadEvent = { preventDefault: vi.fn() }
const item = { cancel: vi.fn() }
harness.partitionEvents.emit('will-download', downloadEvent, item)
expect(downloadEvent.preventDefault).toHaveBeenCalled()
expect(item.cancel).toHaveBeenCalled()
harness.debuggerEvents.emit(
'message',
{},
'Page.fileChooserOpened',
{}
)
await vi.waitFor(() =>
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.handleFileChooser',
{ action: 'cancel' }
)
)
await session.dispose()
})
it('allows only the explicitly approved top-level origin', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
const target = await harness.policy.validate(
'https://example.com/start',
new AbortController().signal
)
session.approveNavigation(target)
harness.setCurrentUrl('https://example.com/page')
expect(session.getCurrentOrigin()).toBe('https://example.com')
const sameOriginEvent = { preventDefault: vi.fn() }
harness.contentEvents.emit(
'will-navigate',
sameOriginEvent,
'https://example.com/next'
)
expect(sameOriginEvent.preventDefault).not.toHaveBeenCalled()
const foreignEvent = { preventDefault: vi.fn() }
harness.contentEvents.emit(
'will-redirect',
foreignEvent,
'https://attacker.example/'
)
expect(foreignEvent.preventDefault).toHaveBeenCalled()
harness.setCurrentUrl('https://attacker.example/')
harness.contentEvents.emit(
'did-navigate',
{},
'https://attacker.example/'
)
expect(harness.webContents.stop).toHaveBeenCalled()
expect(session.getCurrentOrigin()).toBeUndefined()
await expect(
session.validateRedirect(
'https://attacker.example/',
new AbortController().signal
)
).rejects.toThrow('超出已批准来源')
await session.dispose()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
await session.dispose()
await session.dispose()
expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce()
expect(harness.window.destroy).toHaveBeenCalledOnce()
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(harness.partition.clearData).toHaveBeenCalledOnce()
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
expect(harness.contentEvents.listenerCount('will-navigate')).toBe(0)
expect(harness.debuggerEvents.listenerCount('message')).toBe(0)
})
it('cleans up partially created resources when debugger setup fails', async () => {
const harness = createHarness()
harness.sendCommand.mockRejectedValueOnce(new Error('debugger failed'))
await expect(
ElectronBrowserSession.create({
policy: harness.policy,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
).rejects.toThrow('无法创建安全浏览器会话')
expect(harness.window.destroy).toHaveBeenCalled()
expect(harness.partition.clearData).toHaveBeenCalled()
expect(harness.proxy.dispose).toHaveBeenCalled()
})
it('reclaims a partition that resolves after setup times out', async () => {
const harness = createHarness()
const partitionGate = deferred<BrowserPartitionSession>()
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: () => partitionGate.promise,
createWindow: async () => harness.window,
createProxy: () => harness.proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
partitionGate.resolve(harness.partition)
await vi.waitFor(() =>
expect(harness.partition.clearData).toHaveBeenCalledOnce()
)
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
})
it('destroys a hidden window that resolves after setup times out', async () => {
const harness = createHarness()
const windowGate = deferred<BrowserWindowHandle>()
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: async () => harness.partition,
createWindow: () => windowGate.promise,
createProxy: () => harness.proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
windowGate.resolve(harness.window)
await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce())
expect(harness.partition.clearData).toHaveBeenCalledOnce()
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
})
it('disposes a proxy whose start resolves after setup times out', async () => {
const harness = createHarness()
const proxyGate = deferred<string>()
const proxy: FilteringProxyLike = {
start: vi.fn(() => proxyGate.promise),
dispose: vi.fn(async () => undefined)
}
const creation = ElectronBrowserSession.create({
policy: harness.policy,
setupTimeoutMs: 5,
createPartition: async () => harness.partition,
createWindow: async () => harness.window,
createProxy: () => proxy
})
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
proxyGate.resolve('http://127.0.0.1:12345')
await vi.waitFor(() => expect(proxy.dispose).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,531 @@
import { randomUUID } from 'node:crypto'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
type ValidatedBrowserUrl
} from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
export type BrowserEventListener = (...argumentsValue: never[]) => void
export type BrowserDebugger = {
attach(protocolVersion?: string): void
detach(): void
isAttached(): boolean
sendCommand(
method: string,
commandParams?: Record<string, unknown>
): Promise<unknown>
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
}
export type BrowserWebContents = {
debugger: BrowserDebugger
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
setWindowOpenHandler(
handler: (details: { url: string }) => { action: 'deny' }
): void
capturePage?(): Promise<{
toPNG(): Buffer
}>
getURL(): string
stop(): void
close?(options?: { waitForBeforeUnload?: boolean }): void
destroy(): void
isDestroyed(): boolean
}
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
destroy(): void
isDestroyed(): boolean
}
export type BrowserPartitionSession = {
setPermissionCheckHandler(
handler: (...argumentsValue: never[]) => boolean
): void
setPermissionRequestHandler(
handler: (
webContents: unknown,
permission: string,
callback: (granted: boolean) => void,
details: unknown
) => void
): void
setDisplayMediaRequestHandler(
handler: (
request: unknown,
callback: (streams: Record<string, never>) => void
) => void
): void
setProxy(configuration: {
mode: 'fixed_servers'
proxyRules: string
proxyBypassRules: string
}): Promise<void>
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
clearData(): Promise<void>
closeAllConnections(): Promise<void>
}
export type FilteringProxyLike = {
start(): Promise<string>
dispose(): Promise<void>
}
export type ElectronBrowserSessionOptions = {
policy: BrowserUrlPolicy
cleanupTimeoutMs?: number
setupTimeoutMs?: number
createPartition?: (partition: string) => Promise<BrowserPartitionSession>
createWindow?: (
options: Record<string, unknown>
) => Promise<BrowserWindowHandle>
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
}
type Listener = {
target: { off(event: string, listener: BrowserEventListener): unknown }
event: string
listener: BrowserEventListener
}
async function cleanupIsolatedState(
partitionSession: BrowserPartitionSession | undefined,
proxy: FilteringProxyLike,
timeoutMs: number
): Promise<void> {
const cleanup = Promise.allSettled([
partitionSession?.closeAllConnections() ?? Promise.resolve(),
partitionSession?.clearData() ?? Promise.resolve(),
proxy.dispose()
])
let timer: ReturnType<typeof setTimeout> | undefined
const results = await Promise.race([
cleanup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error('浏览器隔离数据清理超时')),
timeoutMs
)
})
]).finally(() => {
if (timer) {
clearTimeout(timer)
}
})
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw new Error('浏览器隔离数据清理失败', { cause: failure.reason })
}
}
async function boundedSetup<T>(
operation: Promise<T>,
signal: AbortSignal,
timeoutMs: number,
cleanupLateValue?: (value: T) => void | Promise<void>
): Promise<T> {
signal.throwIfAborted()
const timeout = AbortSignal.timeout(timeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器会话创建超时(${timeoutMs}ms`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void operation.then(
(value) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
try {
void Promise.resolve(cleanupLateValue?.(value)).catch(
() => undefined
)
} catch {
// Cleanup is best-effort after the caller has already timed out.
}
abort()
} else {
resolve(value)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
async function defaultCreatePartition(
partition: string
): Promise<BrowserPartitionSession> {
const electron = await import('electron')
return electron.session.fromPartition(
partition
) as unknown as BrowserPartitionSession
}
async function defaultCreateWindow(
options: Record<string, unknown>
): Promise<BrowserWindowHandle> {
const electron = await import('electron')
return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle
}
export class ElectronBrowserSession {
readonly partition: string
readonly webContents: BrowserWebContents
private approvedOrigin?: string
private readonly listeners: Listener[] = []
private disposed = false
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly window: BrowserWindowHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number
) {
this.partition = partition
this.webContents = window.webContents
}
static async create(
options: ElectronBrowserSessionOptions,
signal: AbortSignal = new AbortController().signal
): Promise<ElectronBrowserSession> {
const partition = `browser-${randomUUID()}`
const createPartition = options.createPartition ?? defaultCreatePartition
const createWindow = options.createWindow ?? defaultCreateWindow
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000
const setupTimeoutMs = options.setupTimeoutMs ?? 15_000
if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) {
throw new Error('浏览器会话清理期限无效')
}
if (!Number.isSafeInteger(setupTimeoutMs) || setupTimeoutMs < 1) {
throw new Error('浏览器会话创建期限无效')
}
const proxy =
options.createProxy?.(options.policy) ??
new FilteringProxy({ policy: options.policy })
let proxyDisposal: Promise<void> | undefined
const managedProxy: FilteringProxyLike = {
start: () => proxy.start(),
dispose: () => {
proxyDisposal ??= proxy.dispose()
return proxyDisposal
}
}
let partitionSession: BrowserPartitionSession | undefined
let window: BrowserWindowHandle | undefined
let result: ElectronBrowserSession | undefined
let setupStage = '启动代理'
try {
const proxyUrl = await boundedSetup(
managedProxy.start(),
signal,
setupTimeoutMs,
async () => managedProxy.dispose()
)
setupStage = '创建隔离会话'
partitionSession = await boundedSetup(
createPartition(partition),
signal,
setupTimeoutMs,
async (latePartition) =>
cleanupIsolatedState(
latePartition,
managedProxy,
cleanupTimeoutMs
)
)
partitionSession.setPermissionCheckHandler(() => false)
partitionSession.setPermissionRequestHandler(
(_contents, _permission, callback) => callback(false)
)
partitionSession.setDisplayMediaRequestHandler(
(_request, callback) => callback({})
)
setupStage = '配置网络代理'
await boundedSetup(
partitionSession.setProxy({
mode: 'fixed_servers',
proxyRules: proxyUrl,
proxyBypassRules: '<-loopback>'
}),
signal,
setupTimeoutMs
)
setupStage = '创建浏览器窗口'
window = await boundedSetup(
createWindow({
show: false,
width: 1280,
height: 900,
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
setupTimeoutMs,
(lateWindow) => {
if (!lateWindow.isDestroyed()) {
lateWindow.destroy()
} else if (!lateWindow.webContents.isDestroyed()) {
lateWindow.webContents.destroy()
}
}
)
setupStage = '加载初始页面'
await boundedSetup(
window.loadURL('about:blank'),
signal,
setupTimeoutMs
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
window,
managedProxy,
partition,
cleanupTimeoutMs
)
setupStage = '初始化浏览器协议'
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
return result
} catch (error) {
if (result) {
await result.dispose().catch(() => undefined)
} else if (window && !window.isDestroyed()) {
window.destroy()
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
} else {
await cleanupIsolatedState(
partitionSession,
managedProxy,
cleanupTimeoutMs
).catch(() => undefined)
}
const detail =
error instanceof Error && error.message
? error.message.slice(0, 160)
: '未知错误'
throw new Error(
`无法创建安全浏览器会话:${setupStage}失败(${detail}`,
{ cause: error }
)
}
}
private listen(
target: Listener['target'] & {
on(event: string, listener: BrowserEventListener): unknown
},
event: string,
listener: BrowserEventListener
): void {
target.on(event, listener)
this.listeners.push({ target, event, listener })
}
private async initialize(): Promise<void> {
const contents = this.webContents
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'will-redirect', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'login', (
event: { preventDefault(): void },
_details: unknown,
_authInfo: unknown,
callback: () => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'select-client-certificate', (
event: { preventDefault(): void },
_url: string,
_certificates: unknown[],
callback: (certificate?: unknown) => void
) => {
event.preventDefault()
callback()
})
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
if (url && !this.isApprovedUrl(url)) {
contents.stop()
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
await contents.debugger.sendCommand('Accessibility.enable')
this.assertOpen()
await contents.debugger.sendCommand('Page.setInterceptFileChooserDialog', {
enabled: true
})
this.assertOpen()
this.listen(
contents.debugger,
'message',
(_event: unknown, method: string) => {
if (method === 'Page.fileChooserOpened') {
void contents.debugger
.sendCommand('Page.handleFileChooser', { action: 'cancel' })
.catch(() => undefined)
}
}
)
}
private assertOpen(): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
}
private isApprovedUrl(input: string): boolean {
try {
return (
this.approvedOrigin !== undefined &&
canonicalizeBrowserUrl(input).origin === this.approvedOrigin
)
} catch {
return false
}
}
approveNavigation(target: ValidatedBrowserUrl): void {
if (this.disposed) {
throw new Error('浏览器会话已关闭')
}
this.approvedOrigin = target.origin
}
getApprovedOrigin(): string | undefined {
return this.approvedOrigin
}
getCurrentOrigin(): string | undefined {
const current = this.webContents.getURL()
if (!current) {
return undefined
}
try {
const origin = canonicalizeBrowserUrl(current).origin
return origin === this.approvedOrigin ? origin : undefined
} catch {
return undefined
}
}
async captureScreenshot(
signal: AbortSignal
): Promise<{
type: 'image'
mimeType: 'image/png'
data: string
}> {
this.assertOpen()
if (!this.webContents.capturePage) {
throw new Error('浏览器原生画面捕获不可用')
}
const image = await boundedSetup(
this.webContents.capturePage(),
signal,
2_000
)
this.assertOpen()
const data = image.toPNG()
if (
data.byteLength < 8 ||
data.byteLength > 5 * 1_024 * 1_024 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
throw new Error('浏览器原生画面无效或过大')
}
return {
type: 'image',
mimeType: 'image/png',
data: data.toString('base64')
}
}
async validateRedirect(url: string, signal: AbortSignal): Promise<void> {
if (!this.approvedOrigin) {
throw new Error('浏览器没有已批准来源')
}
await this.policy.validateRedirect(url, this.approvedOrigin, signal)
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.approvedOrigin = undefined
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
if (this.webContents.debugger.isAttached()) {
this.webContents.debugger.detach()
}
this.webContents.stop()
if (!this.window.isDestroyed()) {
this.window.destroy()
} else if (!this.webContents.isDestroyed()) {
this.webContents.destroy()
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
}
}
+388
View File
@@ -0,0 +1,388 @@
import {
Server,
createServer as createHttpServer,
request as httpRequest
} from 'node:http'
import { connect as netConnect, createServer as createNetServer } from 'node:net'
import type { AddressInfo, NetConnectOpts, Socket } from 'node:net'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { BrowserUrlPolicy } from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
const disposals: Array<() => Promise<void>> = []
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
afterEach(async () => {
await Promise.allSettled(disposals.splice(0).map((dispose) => dispose()))
})
function closeServer(server: {
close(callback: (error?: Error) => void): void
}): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}
async function listen(server: {
listen(port: number, host: string, callback: () => void): void
address(): string | AddressInfo | null
}): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('test server did not bind')
}
return address.port
}
describe('FilteringProxy', () => {
it('contains tunnel socket closure errors but still surfaces listen failures', async () => {
const upstreams: PassThrough[] = []
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({
policy,
connect: () => {
const upstream = new PassThrough()
upstreams.push(upstream)
return upstream as unknown as Socket
}
})
disposals.push(() => proxy.dispose())
const handleConnect = (
proxy as unknown as {
handleConnect(
request: { url: string },
client: PassThrough,
head: Buffer
): Promise<void>
}
).handleConnect.bind(proxy)
for (const code of ['ECONNABORTED', 'ECONNRESET', 'EPIPE']) {
for (const failingSide of ['client', 'upstream'] as const) {
const client = new PassThrough()
await handleConnect(
{ url: 'example.com:443' },
client,
Buffer.alloc(0)
)
const upstream = upstreams.at(-1)
expect(upstream).toBeDefined()
upstream?.emit('connect')
const socketError = Object.assign(new Error(`write ${code}`), {
code
})
expect(() =>
(failingSide === 'client' ? client : upstream)?.emit(
'error',
socketError
)
).not.toThrow()
expect(client.destroyed).toBe(true)
expect(upstream?.destroyed).toBe(true)
}
}
const listenFailure = Object.assign(new Error('listen denied'), {
code: 'EACCES'
})
const listen = vi
.spyOn(Server.prototype, 'listen')
.mockImplementation(function (this: Server) {
queueMicrotask(() => this.emit('error', listenFailure))
return this
} as typeof Server.prototype.listen)
const failedProxy = new FilteringProxy({ policy })
await expect(failedProxy.start()).rejects.toMatchObject({
message: '浏览器过滤代理启动失败',
cause: listenFailure
})
expect(listen).toHaveBeenCalled()
listen.mockRestore()
})
it('binds only to loopback, pins HTTP to the validated address, and strips credentials', async () => {
let receivedAuthorization: string | undefined
const upstream = createHttpServer((request, response) => {
receivedAuthorization = request.headers.authorization
response.end('safe')
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
expect(proxyUrl.hostname).toBe('127.0.0.1')
const body = await new Promise<string>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
method: 'GET',
path: `http://example.com:${upstreamPort}/resource`,
headers: {
authorization: 'Bearer secret',
'proxy-authorization': 'Basic secret'
}
},
(response) => {
let value = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => {
value += chunk
})
response.on('end', () => resolve(value))
}
)
request.once('error', reject)
request.end()
})
expect(body).toBe('safe')
expect(receivedAuthorization).toBeUndefined()
expect(policy.validate).toHaveBeenCalled()
})
it('contains aborted upstream HTTP responses', async () => {
const upstream = createHttpServer((_request, response) => {
response.writeHead(200)
response.write('partial')
response.socket?.destroy()
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
await new Promise<void>((resolve) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/aborted`
},
(response) => {
response.resume()
response.once('aborted', resolve)
response.once('error', resolve)
response.once('end', resolve)
}
)
request.once('error', () => resolve())
request.end()
})
})
it('does not create an upstream HTTP request after the client disconnects during validation', async () => {
const upstreamRequest = vi.fn()
const upstream = createHttpServer(upstreamRequest)
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const validation = deferred<{
url: URL
origin: string
addresses: Array<{ address: string; family: 4 }>
}>()
const policy = {
validate: vi.fn(() => validation.promise)
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const request = httpRequest({
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/cancelled`
})
request.once('error', () => undefined)
request.end()
await vi.waitFor(() => expect(policy.validate).toHaveBeenCalledOnce())
request.destroy()
await new Promise((resolve) => setTimeout(resolve, 20))
validation.resolve({
url: new URL(`http://example.com:${upstreamPort}/cancelled`),
origin: `http://example.com:${upstreamPort}`,
addresses: [{ address: '127.0.0.1', family: 4 }]
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(upstreamRequest).not.toHaveBeenCalled()
})
it('pins CONNECT TCP destinations while leaving TLS hostname handling to the client', async () => {
const upstream = createNetServer((socket) => socket.pipe(socket))
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '93.184.216.34', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
let requestedOptions: NetConnectOpts | undefined
const connect = vi.fn((options: NetConnectOpts): Socket => {
requestedOptions = options
return netConnect({
host: '127.0.0.1',
port: upstreamPort,
family: 4
})
})
const proxy = new FilteringProxy({ policy, connect })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const echoed = await new Promise<string>((resolve, reject) => {
const socket = netConnect({
host: proxyUrl.hostname,
port: Number(proxyUrl.port)
})
let response = ''
let tunnelReady = false
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk: string) => {
response += chunk
if (!tunnelReady && response.includes('\r\n\r\n')) {
tunnelReady = true
response = ''
socket.write('tls-bytes')
} else if (tunnelReady && response.includes('tls-bytes')) {
socket.destroy()
resolve(response)
}
})
socket.once('connect', () => {
socket.write(
`CONNECT example.com:${upstreamPort} HTTP/1.1\r\nHost: example.com\r\n\r\n`
)
})
})
expect(echoed).toContain('tls-bytes')
expect(requestedOptions).toEqual({
host: '93.184.216.34',
port: upstreamPort,
family: 4
})
})
it('fails closed when validation rejects and bounds active connections', async () => {
const policy = {
validate: vi.fn(async () => {
throw new Error('blocked')
})
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({
policy,
maximumConnections: 1
})
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const status = await new Promise<number | undefined>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: 'http://example.com/'
},
(response) => {
response.resume()
response.once('end', () => resolve(response.statusCode))
}
)
request.once('error', reject)
request.end()
})
expect(status).toBe(403)
await proxy.dispose()
await expect(proxy.start()).rejects.toThrow('已关闭')
})
it('holds request reservations until slow upstream responses complete', async () => {
const releaseUpstream = deferred<void>()
let upstreamRequests = 0
const upstream = createHttpServer(async (_request, response) => {
upstreamRequests += 1
await releaseUpstream.promise
response.end('done')
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [{ address: '127.0.0.1', family: 4 as const }]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy, maximumConnections: 1 })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const requestStatus = (): Promise<number | undefined> =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/slow`
},
(response) => {
response.resume()
response.once('end', () => resolve(response.statusCode))
}
)
request.once('error', reject)
request.end()
})
const first = requestStatus()
await vi.waitFor(() => expect(upstreamRequests).toBe(1))
const rejected = await Promise.all(
Array.from({ length: 12 }, () => requestStatus())
)
expect(rejected).toEqual(Array.from({ length: 12 }, () => 503))
expect(upstreamRequests).toBe(1)
releaseUpstream.resolve()
await expect(first).resolves.toBe(200)
await expect(requestStatus()).resolves.toBe(200)
expect(upstreamRequests).toBe(2)
})
})
+345
View File
@@ -0,0 +1,345 @@
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { connect as netConnect } from 'node:net'
import type { NetConnectOpts, Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { BrowserUrlPolicy, type ValidatedBrowserUrl } from './browser-url-policy'
export type FilteringProxyOptions = {
policy: BrowserUrlPolicy
maximumConnections?: number
maximumRequestBytes?: number
connect?: (options: NetConnectOpts) => Socket
}
type ActiveStream = {
destroy(error?: Error): void
}
function rejectHttp(response: ServerResponse, status = 403): void {
if (!response.headersSent) {
response.writeHead(status, {
connection: 'close',
'content-type': 'text/plain; charset=utf-8'
})
}
response.end('Request blocked')
}
function stripProxyHeaders(
headers: IncomingMessage['headers']
): Record<string, string | string[] | undefined> {
const result = { ...headers }
delete result.authorization
delete result['proxy-authorization']
delete result['proxy-connection']
delete result.connection
delete result['keep-alive']
delete result.te
delete result.trailer
delete result['transfer-encoding']
delete result.upgrade
return result
}
export class FilteringProxy {
private readonly policy: BrowserUrlPolicy
private readonly maximumConnections: number
private readonly maximumRequestBytes: number
private readonly connectSocket: (options: NetConnectOpts) => Socket
private readonly controller = new AbortController()
private readonly streams = new Set<ActiveStream>()
private readonly reservations = new Set<ActiveStream>()
private server?: Server
private proxyUrl?: string
private disposed = false
constructor(options: FilteringProxyOptions) {
this.policy = options.policy
this.maximumConnections = options.maximumConnections ?? 32
this.maximumRequestBytes = options.maximumRequestBytes ?? 1024 * 1024
this.connectSocket = options.connect ?? netConnect
}
async start(): Promise<string> {
if (this.proxyUrl) {
return this.proxyUrl
}
if (this.disposed) {
throw new Error('浏览器过滤代理已关闭')
}
const server = createHttpServer((request, response) => {
void this.handleHttp(request, response)
})
server.on('connect', (request, client, head) => {
void this.handleConnect(request, client, head)
})
server.on('clientError', (_error, socket) => socket.destroy())
this.server = server
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => reject(error)
server.once('error', onError)
server.listen(0, '127.0.0.1', () => {
server.off('error', onError)
resolve()
})
})
} catch (error) {
await this.dispose()
throw new Error('浏览器过滤代理启动失败', { cause: error })
}
const address = server.address()
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
await this.dispose()
throw new Error('浏览器过滤代理未安全绑定到回环地址')
}
this.proxyUrl = `http://127.0.0.1:${address.port}`
return this.proxyUrl
}
private reserve(stream: ActiveStream): boolean {
if (
this.disposed ||
this.controller.signal.aborted ||
this.reservations.size >= this.maximumConnections
) {
return false
}
this.streams.add(stream)
this.reservations.add(stream)
return true
}
private releaseStream(stream: ActiveStream): void {
this.streams.delete(stream)
}
private releaseReservation(stream: ActiveStream): void {
this.reservations.delete(stream)
}
private async validateAtConnect(url: URL): Promise<ValidatedBrowserUrl> {
return this.policy.validate(url, this.controller.signal)
}
private async handleHttp(
incoming: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (!this.reserve(incoming)) {
rejectHttp(response, 503)
return
}
incoming.once('error', () => response.destroy())
response.once('error', () => incoming.destroy())
incoming.once('close', () => this.releaseStream(incoming))
let responseClosed = false
const releaseReservation = (): void =>
this.releaseReservation(incoming)
response.once('finish', releaseReservation)
response.once('close', () => {
responseClosed = true
releaseReservation()
})
try {
if (!incoming.url) {
rejectHttp(response)
return
}
const target = await this.validateAtConnect(new URL(incoming.url))
const address = target.addresses[0]
if (!address) {
rejectHttp(response)
return
}
if (
incoming.destroyed ||
response.destroyed ||
response.writableEnded ||
responseClosed
) {
return
}
const request = (
target.url.protocol === 'https:' ? httpsRequest : httpRequest
)(
target.url,
{
method: incoming.method,
headers: {
...stripProxyHeaders(incoming.headers),
host: target.url.host
},
lookup: (_hostname, options, callback) => {
if (options.all) {
callback(null, [
{ address: address.address, family: address.family }
])
} else {
callback(null, address.address, address.family)
}
},
signal: this.controller.signal
},
(upstream) => {
const destroyForward = (): void => {
upstream.destroy()
request.destroy()
if (!response.destroyed) {
response.destroy()
}
}
upstream.once('error', destroyForward)
response.once('error', destroyForward)
response.once('close', () => {
if (!upstream.complete) {
upstream.destroy()
}
})
response.writeHead(
upstream.statusCode ?? 502,
stripProxyHeaders(upstream.headers)
)
upstream.pipe(response)
}
)
this.streams.add(request)
request.once('close', () => this.releaseStream(request))
request.once('error', () => {
if (response.headersSent) {
response.destroy()
} else if (!response.destroyed) {
rejectHttp(response, 502)
}
})
incoming.once('aborted', () => request.destroy())
incoming.once('error', () => request.destroy())
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.byteLength
if (bytes > this.maximumRequestBytes) {
request.destroy(new Error('浏览器请求超过安全限制'))
incoming.destroy()
}
})
incoming.pipe(request)
} catch {
rejectHttp(response)
}
}
private async handleConnect(
request: IncomingMessage,
client: Duplex,
head: Buffer
): Promise<void> {
if (!this.reserve(client)) {
client.destroy()
return
}
let upstream: Socket | undefined
const destroyUpstream = (): void => {
if (upstream && !upstream.destroyed) {
upstream.destroy()
}
}
const destroyTunnel = (): void => {
destroyUpstream()
if (!client.destroyed) {
client.destroy()
}
}
// A browser can abandon a CONNECT tunnel while validation or a piped
// write is in flight. Socket errors are connection-local; without an
// error listener Node promotes them to an uncaught main-process error.
client.once('error', destroyTunnel)
client.once('close', () => {
this.releaseStream(client)
this.releaseReservation(client)
destroyUpstream()
})
try {
if (!request.url || request.url.length > 1_000) {
client.destroy()
return
}
const authority = new URL(`https://${request.url}`)
if (
authority.username ||
authority.password ||
authority.pathname !== '/' ||
authority.search ||
authority.hash
) {
client.destroy()
return
}
const target = await this.validateAtConnect(authority)
const address = target.addresses[0]
if (!address) {
client.destroy()
return
}
const port = authority.port ? Number(authority.port) : 443
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
client.destroy()
return
}
if (client.destroyed) {
return
}
const connectedUpstream = this.connectSocket({
// Pin the TCP destination to the policy-approved address. The CONNECT
// tunnel remains opaque, so Chromium still verifies TLS against the
// original authority hostname rather than this address.
host: address.address,
port,
family: address.family
})
upstream = connectedUpstream
this.streams.add(connectedUpstream)
const release = (): void => this.releaseStream(connectedUpstream)
connectedUpstream.once('close', release)
connectedUpstream.once('error', destroyTunnel)
if (client.destroyed) {
connectedUpstream.destroy()
return
}
connectedUpstream.once('connect', () => {
if (client.destroyed) {
connectedUpstream.destroy()
return
}
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) {
connectedUpstream.write(head)
}
connectedUpstream.pipe(client)
client.pipe(connectedUpstream)
})
} catch {
destroyTunnel()
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.controller.abort(new Error('浏览器过滤代理已关闭'))
for (const stream of this.streams) {
stream.destroy()
}
this.streams.clear()
this.reservations.clear()
const server = this.server
this.server = undefined
this.proxyUrl = undefined
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
}
@@ -0,0 +1,173 @@
import {
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
BrowserProfileService,
FileBrowserProfileStore,
MemoryBrowserProfileStore
} from './browser-profile-service'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('BrowserProfileService', () => {
it('defaults new profiles to isolated managed mode and migrates version 1', async () => {
const legacyId = 'af6774e4-39e0-4479-b81b-42ec0f85c353'
const store = new MemoryBrowserProfileStore({
version: 1,
profiles: [{ id: legacyId, name: '旧配置' }]
})
const migrated = await new BrowserProfileService(store).getSnapshot()
expect(migrated).toEqual({
version: 2,
profiles: [
{
id: legacyId,
name: '旧配置',
mode: 'managed-isolated',
references: []
}
],
defaultProfileId: legacyId
})
const created = await new BrowserProfileService(
new MemoryBrowserProfileStore()
).createProfile('隔离浏览器')
expect(created.profiles[0]).toMatchObject({
name: '隔离浏览器',
mode: 'managed-isolated',
references: []
})
expect(created.defaultProfileId).toBe(created.profiles[0]?.id)
})
it('persists loaded state only when migration changes it', async () => {
const profileId = 'af6774e4-39e0-4479-b81b-42ec0f85c353'
const currentStore = {
load: vi.fn(async () => ({
version: 2,
profiles: [
{
id: profileId,
name: '当前配置',
mode: 'managed-isolated',
references: []
}
],
defaultProfileId: profileId
})),
save: vi.fn(async () => undefined)
}
const current = new BrowserProfileService(currentStore)
await current.getSnapshot()
await current.getSnapshot()
expect(currentStore.load).toHaveBeenCalledOnce()
expect(currentStore.save).not.toHaveBeenCalled()
const legacyStore = {
load: vi.fn(async () => ({
version: 1,
profiles: [{ id: profileId, name: '旧配置' }]
})),
save: vi.fn(async () => undefined)
}
await new BrowserProfileService(legacyStore).getSnapshot()
expect(legacyStore.save).toHaveBeenCalledOnce()
expect(legacyStore.save).toHaveBeenCalledWith(
expect.objectContaining({ version: 2 })
)
})
it('persists only strict browser metadata without arguments or environment', async () => {
const service = new BrowserProfileService(new MemoryBrowserProfileStore())
const state = await service.createProfile('浏览器')
const id = state.profiles[0]?.id
if (!id) {
throw new Error('Expected browser profile')
}
await expect(
service.selectBrowser(id, {
executablePath: resolve(process.execPath),
displayName: 'Selected browser',
source: 'user-selected',
args: ['--remote-debugging-port=1']
} as never)
).rejects.toThrow()
await expect(
service.selectBrowser(id, {
executablePath: resolve(process.execPath),
displayName: 'Selected browser',
source: 'user-selected',
env: { TOKEN: 'secret' }
} as never)
).rejects.toThrow()
})
it('blocks deletion while a profile is referenced', async () => {
const service = new BrowserProfileService(new MemoryBrowserProfileStore())
const created = await service.createProfile('自动化配置')
const id = created.profiles[0]?.id
if (!id) {
throw new Error('Expected browser profile')
}
const reference = { kind: 'automation' as const, id: 'job:daily-check' }
await service.addReference(id, reference)
await expect(service.deleteProfile(id)).rejects.toThrow('Referenced')
await service.removeReference(id, reference)
await expect(service.deleteProfile(id)).resolves.toMatchObject({
profiles: [],
defaultProfileId: null
})
})
it('uses atomic files under the owned root and rejects a symlink root', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-browser-store-'))
temporaryDirectories.push(directory)
const root = join(directory, 'owned')
const outsideRoot = join(directory, 'outside')
const outside = join(outsideRoot, 'browser-profiles.json')
await mkdir(outsideRoot)
await writeFile(outside, '{"sentinel":true}', 'utf8')
await symlink(outsideRoot, root, 'junction')
const service = new BrowserProfileService(
new FileBrowserProfileStore(root)
)
await expect(service.getSnapshot()).rejects.toThrow('real directory')
await expect(readFile(outside, 'utf8')).resolves.toBe('{"sentinel":true}')
})
it('rejects invalid defaults and unknown persisted properties', async () => {
const unknownId = 'b7f29e4c-1c4a-4aa0-ac58-5165451dde07'
const service = new BrowserProfileService(
new MemoryBrowserProfileStore({
version: 2,
profiles: [],
defaultProfileId: unknownId,
args: ['--unsafe']
})
)
await expect(service.getSnapshot()).rejects.toThrow()
})
})
@@ -0,0 +1,424 @@
import { randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { z } from 'zod'
import {
browserProfileIdSchema,
browserProfileNameSchema
} from '../../shared/capability-contracts'
const MAX_PROFILES = 32
const MAX_REFERENCES = 64
const MAX_STORE_BYTES = 256 * 1024
const boundedPathSchema = z
.string()
.trim()
.min(1)
.max(2_048)
.refine((value) => isAbsolute(value), 'Browser executable path must be absolute')
.refine(
(value) => resolve(value) === value,
'Browser executable path must be normalized'
)
export const browserExecutableMetadataSchema = z
.object({
executablePath: boundedPathSchema,
displayName: browserProfileNameSchema,
source: z.literal('user-selected')
})
.strict()
export const browserProfileReferenceSchema = z
.object({
kind: z.enum(['capability', 'automation']),
id: z.string().trim().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/u)
})
.strict()
export const browserProfileSchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
mode: z.literal('managed-isolated'),
browser: browserExecutableMetadataSchema.optional(),
references: z.array(browserProfileReferenceSchema).max(MAX_REFERENCES)
})
.strict()
export const browserProfileStateSchema = z
.object({
version: z.literal(2),
profiles: z.array(browserProfileSchema).max(MAX_PROFILES),
defaultProfileId: browserProfileIdSchema.nullable()
})
.strict()
.superRefine((state, context) => {
const ids = new Set<string>()
for (const [index, profile] of state.profiles.entries()) {
if (ids.has(profile.id)) {
context.addIssue({
code: 'custom',
path: ['profiles', index, 'id'],
message: 'Browser profile IDs must be unique'
})
}
ids.add(profile.id)
}
if (state.defaultProfileId && !ids.has(state.defaultProfileId)) {
context.addIssue({
code: 'custom',
path: ['defaultProfileId'],
message: 'Default browser profile must exist'
})
}
})
const version1ProfileSchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
browser: browserExecutableMetadataSchema.optional()
})
.strict()
const version1StateSchema = z
.object({
version: z.literal(1),
profiles: z.array(version1ProfileSchema).max(MAX_PROFILES),
defaultProfileId: browserProfileIdSchema.nullable().optional()
})
.strict()
export type BrowserExecutableMetadata = z.infer<
typeof browserExecutableMetadataSchema
>
export type BrowserProfileReference = z.infer<
typeof browserProfileReferenceSchema
>
export type BrowserProfile = z.infer<typeof browserProfileSchema>
export type BrowserProfileState = z.infer<typeof browserProfileStateSchema>
const emptyState = (): BrowserProfileState => ({
version: 2,
profiles: [],
defaultProfileId: null
})
function migrateState(
value: unknown
): { state: BrowserProfileState; migrated: boolean } {
const version = z
.object({ version: z.union([z.literal(1), z.literal(2)]) })
.passthrough()
.parse(value).version
if (version === 2) {
return {
state: browserProfileStateSchema.parse(value),
migrated: false
}
}
const legacy = version1StateSchema.parse(value)
return {
state: browserProfileStateSchema.parse({
version: 2,
profiles: legacy.profiles.map((profile) => ({
...profile,
mode: 'managed-isolated',
references: []
})),
defaultProfileId:
legacy.defaultProfileId ?? legacy.profiles[0]?.id ?? null
}),
migrated: true
}
}
export interface BrowserProfileStore {
load(): Promise<unknown | undefined>
save(state: BrowserProfileState): Promise<void>
}
export class MemoryBrowserProfileStore implements BrowserProfileStore {
private value: unknown
constructor(initialValue?: unknown) {
this.value = initialValue
}
async load(): Promise<unknown | undefined> {
return structuredClone(this.value)
}
async save(state: BrowserProfileState): Promise<void> {
this.value = structuredClone(state)
}
}
export class FileBrowserProfileStore implements BrowserProfileStore {
private readonly fileName = 'browser-profiles.json'
constructor(private readonly ownedRoot: string) {
if (!isAbsolute(ownedRoot)) {
throw new Error('Browser profile storage root must be absolute')
}
}
private async prepareRoot(): Promise<{ root: string; filePath: string }> {
await mkdir(this.ownedRoot, { recursive: true, mode: 0o700 })
const rootDetails = await lstat(this.ownedRoot)
if (!rootDetails.isDirectory() || rootDetails.isSymbolicLink()) {
throw new Error('Browser profile storage root must be a real directory')
}
const root = await realpath(this.ownedRoot)
const filePath = join(root, this.fileName)
const fromRoot = relative(root, filePath)
if (
fromRoot.startsWith('..') ||
isAbsolute(fromRoot) ||
fromRoot === ''
) {
throw new Error('Browser profile storage path escapes its owned root')
}
return { root, filePath }
}
async load(): Promise<unknown | undefined> {
const { filePath } = await this.prepareRoot()
try {
const details = await lstat(filePath)
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error('Browser profile storage file must be a regular file')
}
if (details.size > MAX_STORE_BYTES) {
throw new Error('Browser profile storage file is too large')
}
return JSON.parse(await readFile(filePath, 'utf8')) as unknown
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined
}
throw error
}
}
async save(state: BrowserProfileState): Promise<void> {
const { root, filePath } = await this.prepareRoot()
try {
const targetDetails = await lstat(filePath)
if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) {
throw new Error('Browser profile storage file must be a regular file')
}
} catch (error) {
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
) {
throw error
}
}
const temporaryPath = join(root, `.${this.fileName}.${randomUUID()}.tmp`)
try {
await writeFile(
temporaryPath,
`${JSON.stringify(browserProfileStateSchema.parse(state), null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600, flag: 'wx' }
)
await rename(temporaryPath, filePath)
} finally {
await rm(temporaryPath, { force: true })
}
}
}
export class BrowserProfileService {
private state?: BrowserProfileState
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly store: BrowserProfileStore) {}
private async getState(): Promise<BrowserProfileState> {
if (!this.state) {
const loaded = await this.store.load()
const result =
loaded === undefined
? { state: emptyState(), migrated: false }
: migrateState(loaded)
if (result.migrated) {
await this.store.save(result.state)
}
this.state = result.state
}
return this.state
}
private async update(
operation: (state: BrowserProfileState) => BrowserProfileState
): Promise<BrowserProfileState> {
let result: BrowserProfileState | undefined
const queued = this.updateQueue.then(async () => {
const next = browserProfileStateSchema.parse(
operation(structuredClone(await this.getState()))
)
await this.store.save(next)
this.state = next
result = next
})
this.updateQueue = queued.catch(() => undefined)
await queued
if (!result) {
throw new Error('Browser profile update failed')
}
return structuredClone(result)
}
async getSnapshot(): Promise<BrowserProfileState> {
return structuredClone(await this.getState())
}
async createProfile(name: string): Promise<BrowserProfileState> {
const parsedName = browserProfileNameSchema.parse(name)
return this.update((state) => {
if (state.profiles.length >= MAX_PROFILES) {
throw new Error('Browser profile limit reached')
}
const profile: BrowserProfile = {
id: randomUUID(),
name: parsedName,
mode: 'managed-isolated',
references: []
}
state.profiles.push(profile)
state.defaultProfileId ??= profile.id
return state
})
}
async renameProfile(
profileId: string,
name: string
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedName = browserProfileNameSchema.parse(name)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.name = parsedName
return state
})
}
async selectBrowser(
profileId: string,
browser?: BrowserExecutableMetadata
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedBrowser = browserExecutableMetadataSchema
.optional()
.parse(browser)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.browser = parsedBrowser
return state
})
}
async setDefaultProfile(profileId: string): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
return this.update((state) => {
if (!state.profiles.some((profile) => profile.id === id)) {
throw new Error('Browser profile not found')
}
state.defaultProfileId = id
return state
})
}
async addReference(
profileId: string,
reference: BrowserProfileReference
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedReference = browserProfileReferenceSchema.parse(reference)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
if (
!profile.references.some(
(candidate) =>
candidate.kind === parsedReference.kind &&
candidate.id === parsedReference.id
)
) {
profile.references.push(parsedReference)
}
return state
})
}
async removeReference(
profileId: string,
reference: BrowserProfileReference
): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
const parsedReference = browserProfileReferenceSchema.parse(reference)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
profile.references = profile.references.filter(
(candidate) =>
candidate.kind !== parsedReference.kind ||
candidate.id !== parsedReference.id
)
return state
})
}
async deleteProfile(profileId: string): Promise<BrowserProfileState> {
const id = browserProfileIdSchema.parse(profileId)
return this.update((state) => {
const profile = state.profiles.find((candidate) => candidate.id === id)
if (!profile) {
throw new Error('Browser profile not found')
}
if (profile.references.length > 0) {
throw new Error('Referenced browser profiles cannot be deleted')
}
state.profiles = state.profiles.filter(
(candidate) => candidate.id !== id
)
if (state.defaultProfileId === id) {
state.defaultProfileId = state.profiles[0]?.id ?? null
}
return state
})
}
}
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import { capabilityDiagnosticReportSchema } from '../../shared/capability-contracts'
import {
CapabilityDiagnostics,
type CapabilityDiagnosticCheck
} from './capability-diagnostics'
const originalDiagnosticSecret = process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET
afterEach(() => {
if (originalDiagnosticSecret === undefined) {
delete process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET
} else {
process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET = originalDiagnosticSecret
}
})
function check(
id: string,
status: 'available' | 'degraded' | 'unavailable',
summary = `${id} result`
): CapabilityDiagnosticCheck {
return {
id,
run: async () => ({ status, summary })
}
}
describe('CapabilityDiagnostics', () => {
it('aggregates required checks with unavailable taking precedence', async () => {
const diagnostics = new CapabilityDiagnostics(
[
check('browser-executable', 'degraded'),
check('managed-profile-root', 'unavailable')
],
{ now: () => new Date('2026-08-05T12:00:00.000Z') }
)
await expect(
diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'win32',
architecture: 'x64'
})
).resolves.toMatchObject({
status: 'unavailable',
checkedAt: '2026-08-05T12:00:00.000Z',
checks: [
{ id: 'browser-executable', status: 'degraded' },
{ id: 'managed-profile-root', status: 'unavailable' }
]
})
})
it('reports available, degraded, disabled and unsupported states', async () => {
const available = new CapabilityDiagnostics([
check('browser-executable', 'available'),
check('managed-profile-root', 'available')
])
const degraded = new CapabilityDiagnostics([
check('browser-executable', 'available'),
check('managed-profile-root', 'degraded')
])
const request = {
capabilityId: 'host-browser-control' as const,
enabled: true,
platform: 'linux' as const,
architecture: 'arm64'
}
await expect(available.diagnose(request)).resolves.toMatchObject({
status: 'available'
})
await expect(degraded.diagnose(request)).resolves.toMatchObject({
status: 'degraded'
})
await expect(
available.diagnose({ ...request, enabled: false })
).resolves.toMatchObject({ status: 'disabled', checks: [] })
await expect(
available.diagnose({
...request,
platform: 'freebsd'
})
).resolves.toMatchObject({
status: 'unavailable',
checks: [{ id: 'platform-support', status: 'unavailable' }]
})
})
it('redacts environment values, credentials and sensitive paths', async () => {
process.env.GOODBUDDY_DIAGNOSTIC_TEST_SECRET =
'environment-secret-value-12345'
const diagnostics = new CapabilityDiagnostics([
{
id: 'browser-executable',
run: async () => ({
status: 'degraded',
summary:
'token=top-secret-token Bearer abc.def.ghi environment-secret-value-12345 /home/alice/private/browser',
remedy:
'apiKey=sk-abcdefghijklmnop at C:\\Users\\Alice\\AppData\\browser'
})
},
check('managed-profile-root', 'available')
])
const report = await diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'linux',
architecture: 'x64'
})
const serialized = JSON.stringify(report)
expect(capabilityDiagnosticReportSchema.parse(report)).toEqual(report)
expect(serialized).not.toContain('top-secret-token')
expect(serialized).not.toContain('abc.def.ghi')
expect(serialized).not.toContain('environment-secret-value-12345')
expect(serialized).not.toContain('/home/alice')
expect(serialized).not.toContain('C:\\\\Users\\\\Alice')
expect(serialized).toContain('[redacted')
})
it('fails closed when a check times out', async () => {
const diagnostics = new CapabilityDiagnostics(
[
{
id: 'browser-executable',
run: async () => new Promise(() => undefined)
},
check('managed-profile-root', 'available')
],
{ timeoutMs: 10 }
)
const report = await diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'win32',
architecture: 'x64'
})
expect(report.status).toBe('unavailable')
expect(report.checks[0]).toMatchObject({
id: 'browser-executable',
status: 'unavailable',
summary: '诊断检查超时。'
})
})
it('fails closed promptly when cancelled', async () => {
const controller = new AbortController()
const diagnostics = new CapabilityDiagnostics(
[
{
id: 'browser-executable',
run: async () => new Promise(() => undefined)
},
check('managed-profile-root', 'available')
],
{ timeoutMs: 1_000 }
)
const pending = diagnostics.diagnose({
capabilityId: 'host-browser-control',
enabled: true,
platform: 'darwin',
architecture: 'arm64',
signal: controller.signal
})
controller.abort()
await expect(pending).resolves.toMatchObject({
status: 'unavailable',
checks: [
{
id: 'browser-executable',
status: 'unavailable',
summary: '诊断检查已取消。'
},
{
id: 'managed-profile-root',
status: 'unavailable',
summary: '诊断检查已取消。'
}
]
})
})
})
@@ -0,0 +1,262 @@
import { z } from 'zod'
import {
capabilityDiagnosticCheckSchema,
capabilityDiagnosticCheckStatusSchema,
capabilityDiagnosticReportSchema,
type CapabilityDiagnosticReport,
type ComputerCapabilityId
} from '../../shared/capability-contracts'
export type { CapabilityDiagnosticReport } from '../../shared/capability-contracts'
import { redactSensitiveText } from '../agent/approval-summary'
import {
getComputerCapability,
isComputerCapabilitySupported,
type ComputerCapabilityImplementationKind
} from './computer-capability-catalog'
const MAX_SUMMARY_LENGTH = 240
const MAX_REMEDY_LENGTH = 400
const diagnosticCheckIdSchema = capabilityDiagnosticCheckSchema.shape.id
export const diagnosticCheckResultSchema = z
.object({
status: capabilityDiagnosticCheckStatusSchema,
summary: z.string().min(1).max(4_096),
remedy: z.string().max(4_096).optional()
})
.strict()
export type DiagnosticCheckResult = z.infer<
typeof diagnosticCheckResultSchema
>
export type CapabilityDiagnosticCheck = Readonly<{
id: string
run: (signal: AbortSignal) => Promise<DiagnosticCheckResult>
}>
export type CapabilityDiagnosticRequest = Readonly<{
capabilityId: ComputerCapabilityId
enabled: boolean
platform: NodeJS.Platform
architecture: string
availableImplementationKinds?: ReadonlySet<ComputerCapabilityImplementationKind>
signal?: AbortSignal
}>
function redactText(value: string, maximumLength: number): string {
let redacted = redactSensitiveText(value)
const environmentValues = Object.values(process.env)
.filter((candidate): candidate is string => Boolean(candidate?.length && candidate.length >= 8))
.sort((left, right) => right.length - left.length)
for (const environmentValue of environmentValues) {
redacted = redacted.split(environmentValue).join('[redacted-env]')
}
redacted = redacted
.replace(
/(?:[a-zA-Z]:\\(?:Users|Documents and Settings)\\|\/(?:Users|home|root|private|var\/folders|tmp)\/)[^\s"'<>]+/gu,
'[redacted-path]'
)
.replace(/[\r\n\t]+/gu, ' ')
.trim()
return redacted.slice(0, maximumLength)
}
function unavailableCheck(
id: string,
summary: string,
remedy?: string
): CapabilityDiagnosticReport['checks'][number] {
return {
id,
status: 'unavailable',
summary: redactText(summary, MAX_SUMMARY_LENGTH),
...(remedy
? { remedy: redactText(remedy, MAX_REMEDY_LENGTH) }
: {})
}
}
export class CapabilityDiagnostics {
private readonly checks: ReadonlyMap<string, CapabilityDiagnosticCheck>
private readonly timeoutMs: number
private readonly now: () => Date
constructor(
checks: readonly CapabilityDiagnosticCheck[],
options: Readonly<{ timeoutMs?: number; now?: () => Date }> = {}
) {
const mapped = new Map<string, CapabilityDiagnosticCheck>()
for (const check of checks) {
const id = diagnosticCheckIdSchema.parse(check.id)
if (mapped.has(id)) {
throw new Error(`Duplicate capability diagnostic check: ${id}`)
}
mapped.set(id, Object.freeze({ id, run: check.run }))
}
this.checks = mapped
this.timeoutMs = z
.number()
.int()
.min(10)
.max(30_000)
.parse(options.timeoutMs ?? 5_000)
this.now = options.now ?? (() => new Date())
}
private checkedAt(): string {
const value = this.now()
if (Number.isNaN(value.getTime())) {
throw new Error('Diagnostic clock returned an invalid date')
}
return value.toISOString()
}
private async runCheck(
id: string,
parentSignal?: AbortSignal
): Promise<CapabilityDiagnosticReport['checks'][number]> {
const check = this.checks.get(id)
if (!check) {
return unavailableCheck(
id,
'缺少必需的诊断检查。',
'请重新安装或修复此能力的受管组件。'
)
}
if (parentSignal?.aborted) {
return unavailableCheck(id, '诊断检查已取消。', '请重试诊断。')
}
const controller = new AbortController()
let timedOut = false
let cancelled = false
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const timeoutResult = new Promise<'timeout'>((resolveTimeout) => {
timeoutHandle = setTimeout(() => {
timedOut = true
controller.abort(new Error('Diagnostic check timed out'))
resolveTimeout('timeout')
}, this.timeoutMs)
})
let resolveCancellation: (() => void) | undefined
const cancellation = new Promise<'cancelled'>((resolveCancelled) => {
resolveCancellation = () => resolveCancelled('cancelled')
})
const abortFromParent = (): void => {
cancelled = true
controller.abort(parentSignal?.reason)
resolveCancellation?.()
}
parentSignal?.addEventListener('abort', abortFromParent, { once: true })
const operation = Promise.resolve()
.then(() => check.run(controller.signal))
.then(
(result) => ({ kind: 'result' as const, result }),
(error: unknown) => ({ kind: 'error' as const, error })
)
try {
const outcome = await Promise.race([
operation,
timeoutResult.then((kind) => ({ kind })),
cancellation.then((kind) => ({ kind }))
])
if (outcome.kind === 'timeout' || timedOut) {
return unavailableCheck(
id,
'诊断检查超时。',
'请确认受管组件可响应后重试。'
)
}
if (outcome.kind === 'cancelled' || cancelled) {
return unavailableCheck(id, '诊断检查已取消。', '请重试诊断。')
}
if (outcome.kind === 'error') {
const message =
outcome.error instanceof Error
? outcome.error.message
: '未知诊断错误'
return unavailableCheck(
id,
`诊断检查失败:${message}`,
'请修复本地环境后重试。'
)
}
try {
const result = diagnosticCheckResultSchema.parse(outcome.result)
return {
id,
status: result.status,
summary: redactText(result.summary, MAX_SUMMARY_LENGTH),
...(result.remedy
? { remedy: redactText(result.remedy, MAX_REMEDY_LENGTH) }
: {})
}
} catch {
return unavailableCheck(
id,
'诊断检查返回了无效结果。',
'请重新安装或修复此能力的受管组件。'
)
}
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
parentSignal?.removeEventListener('abort', abortFromParent)
}
}
async diagnose(
request: CapabilityDiagnosticRequest
): Promise<CapabilityDiagnosticReport> {
const capability = getComputerCapability(request.capabilityId)
const checkedAt = this.checkedAt()
if (!request.enabled) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status: 'disabled',
checkedAt,
checks: []
})
}
if (
!isComputerCapabilitySupported(
capability,
request.platform,
request.architecture,
request.availableImplementationKinds
)
) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status: 'unavailable',
checkedAt,
checks: [
unavailableCheck(
'platform-support',
'当前操作系统或处理器架构不受支持。',
'请在能力目录列出的受支持平台上使用此能力。'
)
]
})
}
const checks = []
for (const id of capability.requiredDiagnostics) {
checks.push(await this.runCheck(id, request.signal))
}
const status = checks.some((check) => check.status === 'unavailable')
? 'unavailable'
: checks.some((check) => check.status === 'degraded')
? 'degraded'
: 'available'
return capabilityDiagnosticReportSchema.parse({
capabilityId: capability.id,
status,
checkedAt,
checks
})
}
}
@@ -1,11 +1,17 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CapabilityService,
type CapabilityCipher
type CapabilityCipher,
type CapabilityServiceOptions
} from './capability-service'
import {
BrowserProfileService,
MemoryBrowserProfileStore
} from './browser-profile-service'
import { CapabilityDiagnostics } from './capability-diagnostics'
const temporaryDirectories: string[] = []
@@ -15,6 +21,33 @@ const cipher: CapabilityCipher = {
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
}
class FailingBrowserProfileService extends BrowserProfileService {
failNextAddAfterSave = false
failNextRemoveAfterSave = false
override async addReference(
...args: Parameters<BrowserProfileService['addReference']>
): ReturnType<BrowserProfileService['addReference']> {
const result = await super.addReference(...args)
if (this.failNextAddAfterSave) {
this.failNextAddAfterSave = false
throw new Error('Injected add reference failure')
}
return result
}
override async removeReference(
...args: Parameters<BrowserProfileService['removeReference']>
): ReturnType<BrowserProfileService['removeReference']> {
const result = await super.removeReference(...args)
if (this.failNextRemoveAfterSave) {
this.failNextRemoveAfterSave = false
throw new Error('Injected remove reference failure')
}
return result
}
}
async function writeSkill(
root: string,
id: string,
@@ -42,7 +75,16 @@ async function writeSkill(
)
}
async function createService(): Promise<{
async function createService(
options: CapabilityServiceOptions = {
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
}
): Promise<{
directory: string
filePath: string
builtinRoot: string
@@ -64,12 +106,14 @@ async function createService(): Promise<{
filePath,
builtinRoot,
importedRoot,
cipher
cipher,
options
)
}
}
afterEach(async () => {
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
@@ -78,6 +122,77 @@ afterEach(async () => {
})
describe('CapabilityService', () => {
it('memoizes concurrent loads and retries after a failed load', async () => {
const initialStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => undefined)
}
const created = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(initialStore)
})
await Promise.all([
created.service.getComputerCapabilityStatus('host-browser-control'),
created.service.getComputerCapabilityStatus('host-browser-control'),
created.service.getComputerCapabilityStatus('host-browser-control')
])
expect(initialStore.load).toHaveBeenCalledOnce()
let profileLoads = 0
const browserProfiles = new BrowserProfileService({
load: vi.fn(async () => {
profileLoads += 1
if (profileLoads === 1) {
throw new Error('Injected profile load failure')
}
return undefined
}),
save: vi.fn(async () => undefined)
})
const retrying = new CapabilityService(
created.filePath,
created.builtinRoot,
created.importedRoot,
cipher,
{
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles
}
)
await expect(
Promise.all([
retrying.getComputerCapabilityStatus('host-browser-control'),
retrying.getComputerCapabilityStatus('host-browser-control')
])
).rejects.toThrow('Injected profile load failure')
await expect(
retrying.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
expect(profileLoads).toBe(2)
})
it('reports safe enabled and supported capability status', async () => {
const { service } = await createService()
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
await service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: true, supported: true })
})
it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
@@ -267,4 +382,375 @@ describe('CapabilityService', () => {
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
})
it('migrates v1 to v2 without losing skills, MCP configuration, or encrypted secrets', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
const credential = Buffer.from(
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
).toString('base64')
await writeFile(
filePath,
JSON.stringify({
version: 1,
skills: {
'document-writing': {
enabled: false,
assignments: ['model']
}
},
mcpServers: [
{
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Preserved MCP',
description: 'migration',
enabled: true,
assignments: ['model'],
credential: {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: credential
},
transport: 'http',
url: 'https://mcp.example.com/mcp'
}
]
}),
'utf8'
)
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher,
{
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
}
)
await expect(service.getSnapshot()).resolves.toMatchObject({
skills: [
expect.objectContaining({
id: 'document-writing',
enabled: false,
assignments: ['model']
})
],
mcpServers: [
expect.objectContaining({
name: 'Preserved MCP',
secretConfigured: true
})
],
computerCapabilities: [
expect.objectContaining({
id: 'host-browser-control',
enabled: false
}),
expect.objectContaining({
id: 'linux-desktop-control',
enabled: false
})
]
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 2')
expect(persisted).toContain(credential)
expect(persisted).not.toContain('preserved-secret')
})
it('gates enablement on the supported platform and architecture', async () => {
const { service } = await createService({
platform: 'darwin',
architecture: 'arm64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).rejects.toThrow('不支持')
const snapshot = await service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
expect(snapshot.computerCapabilities).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'host-browser-control',
enabled: true
})
])
)
})
it('maintains browser profile references and rejects unknown profiles', async () => {
const { service } = await createService()
await expect(
service.setComputerCapabilityBrowserProfile(
'host-browser-control',
'b7f29e4c-1c4a-4aa0-ac58-5165451dde07'
)
).rejects.toThrow('不存在')
const created = await service.createBrowserProfile('隔离工作配置')
const profileId = created.browserProfiles?.profiles[0]?.id
if (!profileId) {
throw new Error('Expected browser profile')
}
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
profileId
)
await expect(service.removeBrowserProfile(profileId)).rejects.toThrow(
'Referenced'
)
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
null
)
await expect(service.removeBrowserProfile(profileId)).resolves.toMatchObject(
{
browserProfiles: { profiles: [], defaultProfileId: null }
}
)
})
it('compensates browser profile references when add or capability persistence fails', async () => {
const addProfiles = new FailingBrowserProfileService(
new MemoryBrowserProfileStore()
)
const addCase = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: addProfiles
})
const addCreated = await addCase.service.createBrowserProfile(
'添加失败配置'
)
const addProfileId = addCreated.browserProfiles?.profiles[0]?.id
if (!addProfileId) {
throw new Error('Expected add-failure browser profile')
}
addProfiles.failNextAddAfterSave = true
await expect(
addCase.service.setComputerCapabilityBrowserProfile(
'host-browser-control',
addProfileId
)
).rejects.toThrow('Injected add reference failure')
await expect(
addCase.service.removeBrowserProfile(addProfileId)
).resolves.toMatchObject({
browserProfiles: { profiles: [], defaultProfileId: null }
})
const persistProfiles = new BrowserProfileService(
new MemoryBrowserProfileStore()
)
const persistCase = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: persistProfiles
})
const persistCreated =
await persistCase.service.createBrowserProfile('保存失败配置')
const persistProfileId =
persistCreated.browserProfiles?.profiles[0]?.id
if (!persistProfileId) {
throw new Error('Expected persistence-failure browser profile')
}
await mkdir(persistCase.filePath)
await expect(
persistCase.service.setComputerCapabilityBrowserProfile(
'host-browser-control',
persistProfileId
)
).rejects.toThrow()
await expect(
persistCase.service.removeBrowserProfile(persistProfileId)
).resolves.toMatchObject({
browserProfiles: { profiles: [], defaultProfileId: null }
})
})
it('rolls back capability and profile stores when old-reference removal fails', async () => {
const browserProfiles = new FailingBrowserProfileService(
new MemoryBrowserProfileStore()
)
const { service } = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles
})
const first = await service.createBrowserProfile('原配置')
const firstId = first.browserProfiles?.profiles[0]?.id
const second = await service.createBrowserProfile('新配置')
const secondId = second.browserProfiles?.profiles[1]?.id
if (!firstId || !secondId) {
throw new Error('Expected two browser profiles')
}
await service.setComputerCapabilityBrowserProfile(
'host-browser-control',
firstId
)
browserProfiles.failNextRemoveAfterSave = true
await expect(
service.setComputerCapabilityBrowserProfile(
'host-browser-control',
secondId
)
).rejects.toThrow('Injected remove reference failure')
await expect(service.getSnapshot()).resolves.toMatchObject({
computerCapabilities: expect.arrayContaining([
expect.objectContaining({
id: 'host-browser-control',
browserProfileId: firstId
})
])
})
await expect(service.removeBrowserProfile(secondId)).resolves.toBeDefined()
await expect(service.removeBrowserProfile(firstId)).rejects.toThrow(
'Referenced'
)
})
it('redacts injected diagnostics and never claims browser availability outside Electron', async () => {
process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET =
'service-diagnostic-secret-value'
const diagnostics = new CapabilityDiagnostics([
{
id: 'browser-executable',
run: async () => ({
status: 'available',
summary:
'token=visible-token service-diagnostic-secret-value C:\\Users\\Alice\\browser'
})
},
{
id: 'managed-profile-root',
run: async () => ({
status: 'available',
summary: 'managed storage ready'
})
}
])
const { service } = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics
})
await service.setComputerCapabilityEnabled('host-browser-control', true)
const report = await service.diagnoseComputerCapability(
'host-browser-control'
)
expect(report.status).toBe('available')
expect(JSON.stringify(report)).not.toContain('visible-token')
expect(JSON.stringify(report)).not.toContain(
'service-diagnostic-secret-value'
)
const outsideElectron = await createService({
platform: 'win32',
architecture: 'x64',
electronTarget: false,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics
})
await expect(
outsideElectron.service.setComputerCapabilityEnabled(
'host-browser-control',
true
)
).rejects.toThrow('诊断不可用')
await expect(
outsideElectron.service.getComputerCapabilityStatus(
'host-browser-control'
)
).resolves.toEqual({ enabled: false, supported: true })
delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET
})
it('keeps Linux desktop unavailable without a registered native adapter', async () => {
const { service } = await createService({
platform: 'linux',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).rejects.toThrow('不支持')
await expect(
service.getComputerCapabilityStatus('linux-desktop-control')
).resolves.toEqual({ enabled: false, supported: false })
})
it('allows Linux desktop enablement with available injected diagnostics', async () => {
const diagnostics = new CapabilityDiagnostics(
['linux-session', 'desktop-driver', 'desktop-permissions'].map(
(id) => ({
id,
run: async () => ({
status: 'available' as const,
summary: `${id} ready`
})
})
)
)
const { service } = await createService({
platform: 'linux',
architecture: 'arm64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
),
diagnostics,
availableComputerCapabilityImplementations: [
'managed-browser-driver',
'managed-linux-desktop-driver'
]
})
await expect(
service.setComputerCapabilityEnabled(
'linux-desktop-control',
true
)
).resolves.toMatchObject({
computerCapabilities: expect.arrayContaining([
expect.objectContaining({
id: 'linux-desktop-control',
enabled: true
})
])
})
})
})
+472 -17
View File
@@ -14,19 +14,43 @@ import { basename, dirname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import {
browserProfileIdSchema,
browserProfileNameSchema,
browserProfilesSummarySchema,
capabilityDiagnosticReportSchema,
capabilityAssignmentsSchema,
computerCapabilityConfigSummarySchema,
computerCapabilityIdSchema,
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
skillIdSchema,
skillSummarySchema,
type CapabilityAssignments,
type CapabilityDiagnosticReport,
type CapabilitySnapshot,
type BrowserProfilesSummary,
type ComputerCapabilityId,
type McpServerInput,
type McpServerSummary,
type RuntimeTarget,
type SkillSummary
} from '../../shared/capability-contracts'
import {
BrowserProfileService,
FileBrowserProfileStore,
type BrowserProfileState
} from './browser-profile-service'
import {
CapabilityDiagnostics,
type CapabilityDiagnosticCheck
} from './capability-diagnostics'
import {
computerCapabilityCatalog,
getComputerCapability,
isComputerCapabilitySupported,
type ComputerCapabilityImplementationKind
} from './computer-capability-catalog'
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
@@ -92,7 +116,7 @@ const storedMcpServerSchema = z.discriminatedUnion('transport', [
.strict()
])
const storedCapabilitiesSchema = z
const storedCapabilitiesV1Schema = z
.object({
version: z.literal(1),
skills: z.record(skillIdSchema, skillStateSchema),
@@ -100,6 +124,28 @@ const storedCapabilitiesSchema = z
})
.strict()
const computerCapabilityStateSchema = z
.object({
enabled: z.boolean(),
browserProfileId: browserProfileIdSchema.nullable()
})
.strict()
const storedCapabilitiesSchema = z
.object({
version: z.literal(2),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64),
computerCapabilities: z
.object({
'host-browser-control': computerCapabilityStateSchema,
'linux-desktop-control': computerCapabilityStateSchema
})
.strict()
})
.strict()
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
@@ -121,6 +167,37 @@ export type ResolvedMcpServer = McpServerSummary & {
secret?: string
}
export type CapabilityServiceOptions = Readonly<{
platform?: NodeJS.Platform
architecture?: string
electronTarget?: boolean
browserProfiles?: BrowserProfileService
diagnostics?: CapabilityDiagnostics
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
}>
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
return {
'host-browser-control': {
enabled: false,
browserProfileId: null
},
'linux-desktop-control': {
enabled: false,
browserProfileId: null
}
}
}
function emptyStoredCapabilities(): StoredCapabilities {
return {
version: 2,
skills: {},
mcpServers: [],
computerCapabilities: defaultComputerCapabilityStates()
}
}
function defaultSkillState(): z.infer<typeof skillStateSchema> {
return {
enabled: true,
@@ -235,24 +312,109 @@ async function copySkillPackage(
export class CapabilityService {
private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities>
private updateQueue: Promise<void> = Promise.resolve()
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly electronTarget: boolean
private readonly browserProfiles: BrowserProfileService
private readonly diagnostics: CapabilityDiagnostics
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
constructor(
private readonly filePath: string,
private readonly builtinSkillsRoot: string,
private readonly importedSkillsRoot: string,
private readonly cipher: CapabilityCipher
) {}
private async load(): Promise<StoredCapabilities> {
if (this.state) {
return this.state
}
let loaded: StoredCapabilities
try {
loaded = storedCapabilitiesSchema.parse(
JSON.parse(await readFile(this.filePath, 'utf8'))
private readonly cipher: CapabilityCipher,
options: CapabilityServiceOptions = {}
) {
this.platform = options.platform ?? process.platform
this.architecture = options.architecture ?? process.arch
this.electronTarget =
options.electronTarget ?? Boolean(process.versions.electron)
this.availableComputerCapabilityImplementations = new Set(
options.availableComputerCapabilityImplementations ?? [
'managed-browser-driver'
]
)
this.browserProfiles =
options.browserProfiles ??
new BrowserProfileService(
new FileBrowserProfileStore(
join(dirname(this.filePath), 'browser-profiles')
)
)
const checks: CapabilityDiagnosticCheck[] = [
{
id: 'browser-executable',
run: async () =>
this.electronTarget
? {
status: 'available',
summary: 'GoodBuddy 的受管 Electron 浏览器核心可用。'
}
: {
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
},
{
id: 'managed-profile-root',
run: async () => {
await this.browserProfiles.getSnapshot()
return {
status: 'available',
summary: '隔离的托管浏览器配置存储可用。'
}
}
}
]
this.diagnostics =
options.diagnostics ?? new CapabilityDiagnostics(checks)
}
private load(): Promise<StoredCapabilities> {
if (this.state) {
return Promise.resolve(this.state)
}
if (this.loadPromise) {
return this.loadPromise
}
const pending = this.loadUncached()
const tracked = pending.catch((error: unknown) => {
if (this.loadPromise === tracked) {
this.loadPromise = undefined
this.state = undefined
}
throw error
})
this.loadPromise = tracked
return tracked
}
private async loadUncached(): Promise<StoredCapabilities> {
let loaded: StoredCapabilities
let shouldPersist = false
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
const version = z
.object({ version: z.union([z.literal(1), z.literal(2)]) })
.passthrough()
.parse(raw).version
if (version === 1) {
const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 2,
skills: legacy.skills,
mcpServers: legacy.mcpServers,
computerCapabilities: defaultComputerCapabilityStates()
}
shouldPersist = true
} else {
loaded = storedCapabilitiesSchema.parse(raw)
}
} catch (error) {
if (
error &&
@@ -260,13 +422,13 @@ export class CapabilityService {
'code' in error &&
error.code === 'ENOENT'
) {
loaded = { version: 1, skills: {}, mcpServers: [] }
loaded = emptyStoredCapabilities()
} else {
await rename(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
loaded = { version: 1, skills: {}, mcpServers: [] }
loaded = emptyStoredCapabilities()
}
}
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
@@ -284,12 +446,30 @@ export class CapabilityService {
}
: loaded
this.state = storedCapabilitiesSchema.parse(migrated)
if (migrateMcpAssignments) {
await this.validateBrowserProfileReferences(this.state)
if (shouldPersist || migrateMcpAssignments) {
await this.persist(this.state)
}
return this.state
}
private async validateBrowserProfileReferences(
state: StoredCapabilities
): Promise<void> {
const profiles = await this.browserProfiles.getSnapshot()
const profileIds = new Set(profiles.profiles.map((profile) => profile.id))
for (const capability of computerCapabilityCatalog) {
const profileId =
state.computerCapabilities[capability.id].browserProfileId
if (profileId && !profileIds.has(profileId)) {
throw new Error('电脑控制能力引用了不存在的浏览器配置')
}
if (capability.id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
}
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation)
this.updateQueue = result.then(
@@ -339,9 +519,10 @@ export class CapabilityService {
}
async getSnapshot(): Promise<CapabilitySnapshot> {
const [state, catalog] = await Promise.all([
const [state, catalog, browserProfileState] = await Promise.all([
this.load(),
this.getSkillCatalog()
this.getSkillCatalog(),
this.browserProfiles.getSnapshot()
])
return {
skills: catalog
@@ -358,10 +539,284 @@ export class CapabilityService {
),
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
),
computerCapabilities: computerCapabilityCatalog.map((capability) =>
computerCapabilityConfigSummarySchema.parse({
id: capability.id,
name: capability.name,
description: capability.description,
enabled: state.computerCapabilities[capability.id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
),
browserProfileId:
state.computerCapabilities[capability.id].browserProfileId,
riskSummary: capability.riskSummary
})
),
browserProfiles: this.toBrowserProfilesSummary(browserProfileState)
}
}
async getComputerCapabilityStatus(
capabilityId: ComputerCapabilityId
): Promise<{ enabled: boolean; supported: boolean }> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
const state = await this.load()
return {
enabled: state.computerCapabilities[id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
}
}
private toBrowserProfilesSummary(
state: BrowserProfileState
): BrowserProfilesSummary {
return browserProfilesSummarySchema.parse({
profiles: state.profiles.map((profile) => ({
id: profile.id,
name: profile.name,
mode: profile.mode
})),
defaultProfileId: state.defaultProfileId
})
}
setComputerCapabilityEnabled(
capabilityId: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
if (
enabled &&
!isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
) {
throw new Error('当前操作系统或处理器架构不支持此能力')
}
if (enabled) {
const report = await this.diagnoseComputerCapabilityState(id, true)
if (report.status === 'unavailable') {
throw new Error('能力诊断不可用,未启用此能力')
}
}
const state = await this.load()
await this.persist({
...state,
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
enabled
}
}
})
return this.getSnapshot()
})
}
setComputerCapabilityBrowserProfile(
capabilityId: ComputerCapabilityId,
browserProfileId: string | null
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const profileId = browserProfileIdSchema.nullable().parse(
browserProfileId
)
if (id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
const profiles = await this.browserProfiles.getSnapshot()
if (
profileId &&
!profiles.profiles.some((profile) => profile.id === profileId)
) {
throw new Error('浏览器配置不存在')
}
const state = await this.load()
const previousProfileId =
state.computerCapabilities[id].browserProfileId
if (profileId === previousProfileId) {
return this.getSnapshot()
}
const reference = { kind: 'capability' as const, id }
if (profileId) {
try {
await this.browserProfiles.addReference(profileId, reference)
} catch (error) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'浏览器配置引用添加失败,且补偿清理未完成',
{ cause: compensationError }
)
}
throw error
}
}
const nextState: StoredCapabilities = {
...state,
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
browserProfileId: profileId
}
}
}
try {
await this.persist(nextState)
} catch (error) {
if (profileId) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'电脑控制配置保存失败,且新增引用补偿清理未完成',
{ cause: compensationError }
)
}
}
throw error
}
if (previousProfileId) {
try {
await this.browserProfiles.removeReference(
previousProfileId,
reference
)
} catch (error) {
try {
await this.browserProfiles.addReference(
previousProfileId,
reference
)
await this.persist(state)
if (profileId) {
await this.browserProfiles.removeReference(
profileId,
reference
)
}
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'旧浏览器配置引用移除失败,且回滚未完成',
{ cause: compensationError }
)
}
throw error
}
}
return this.getSnapshot()
})
}
async diagnoseComputerCapability(
capabilityId: ComputerCapabilityId
): Promise<CapabilityDiagnosticReport> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const state = await this.load()
return this.diagnoseComputerCapabilityState(
id,
state.computerCapabilities[id].enabled
)
}
private async diagnoseComputerCapabilityState(
id: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilityDiagnosticReport> {
if (
id === 'host-browser-control' &&
enabled &&
!this.electronTarget
) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: id,
status: 'unavailable',
checkedAt: new Date().toISOString(),
checks: [
{
id: 'electron-target',
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
]
})
}
return capabilityDiagnosticReportSchema.parse(
await this.diagnostics.diagnose({
capabilityId: id,
enabled,
platform: this.platform,
architecture: this.architecture,
availableImplementationKinds:
this.availableComputerCapabilityImplementations
})
)
}
createBrowserProfile(name: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.createProfile(
browserProfileNameSchema.parse(name)
)
return this.getSnapshot()
})
}
renameBrowserProfile(
profileId: string,
name: string
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.renameProfile(
browserProfileIdSchema.parse(profileId),
browserProfileNameSchema.parse(name)
)
return this.getSnapshot()
})
}
setDefaultBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.setDefaultProfile(
browserProfileIdSchema.parse(profileId)
)
return this.getSnapshot()
})
}
removeBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.deleteProfile(
browserProfileIdSchema.parse(profileId)
)
return this.getSnapshot()
})
}
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const canonicalSource = await realpath(sourcePath)
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
computerCapabilityCatalog,
getComputerCapability,
isComputerCapabilitySupported
} from './computer-capability-catalog'
describe('computer capability catalog', () => {
it('defines immutable, disabled curated capabilities without launch overrides', () => {
expect(computerCapabilityCatalog.map(({ id }) => id)).toEqual([
'host-browser-control',
'linux-desktop-control'
])
for (const capability of computerCapabilityCatalog) {
expect(capability.enabledByDefault).toBe(false)
expect(capability.requiredDiagnostics.length).toBeGreaterThan(0)
expect(capability).not.toHaveProperty('executable')
expect(capability).not.toHaveProperty('args')
expect(capability).not.toHaveProperty('env')
}
expect(Object.isFrozen(computerCapabilityCatalog)).toBe(true)
})
it('rejects unsupported platform and architecture combinations', () => {
const browser = getComputerCapability('host-browser-control')
const desktop = getComputerCapability('linux-desktop-control')
expect(isComputerCapabilitySupported(browser, 'win32', 'x64')).toBe(true)
expect(isComputerCapabilitySupported(browser, 'linux', 'arm64')).toBe(true)
expect(isComputerCapabilitySupported(browser, 'win32', 'ia32')).toBe(false)
expect(isComputerCapabilitySupported(browser, 'freebsd', 'x64')).toBe(false)
expect(isComputerCapabilitySupported(desktop, 'darwin', 'arm64')).toBe(
false
)
expect(isComputerCapabilitySupported(desktop, 'linux', 'x64')).toBe(false)
expect(
isComputerCapabilitySupported(
desktop,
'linux',
'x64',
new Set(['managed-linux-desktop-driver'])
)
).toBe(true)
expect(desktop.description).toContain('技术预览')
expect(desktop.riskSummary).toContain('尚未')
})
})
@@ -0,0 +1,111 @@
import {
computerCapabilityIdSchema,
type ComputerCapabilityId
} from '../../shared/capability-contracts'
export const computerCapabilityIds = computerCapabilityIdSchema.options
export type { ComputerCapabilityId }
export type ComputerCapabilityPlatform = 'win32' | 'darwin' | 'linux'
export type ComputerCapabilityArchitecture = 'x64' | 'arm64'
export type ComputerCapabilityImplementationKind =
| 'managed-browser-driver'
| 'managed-linux-desktop-driver'
const PRODUCTION_IMPLEMENTATIONS: ReadonlySet<ComputerCapabilityImplementationKind> =
new Set(['managed-browser-driver'])
export type ComputerCapabilityCatalogEntry = Readonly<{
id: ComputerCapabilityId
name: string
description: string
enabledByDefault: false
implementationKind: ComputerCapabilityImplementationKind
supportedTargets: readonly Readonly<{
platform: ComputerCapabilityPlatform
architectures: readonly ComputerCapabilityArchitecture[]
}>[]
riskSummary: string
requiredDiagnostics: readonly string[]
}>
const ARCHITECTURES = Object.freeze(['x64', 'arm64'] as const)
export const computerCapabilityCatalog: readonly ComputerCapabilityCatalogEntry[] =
Object.freeze([
Object.freeze({
id: 'host-browser-control',
name: '浏览器控制',
description:
'使用临时隔离会话执行网页操作;命名配置当前仅保存未来托管隔离所需的元数据。',
enabledByDefault: false,
implementationKind: 'managed-browser-driver',
supportedTargets: Object.freeze([
Object.freeze({
platform: 'win32',
architectures: ARCHITECTURES
}),
Object.freeze({
platform: 'darwin',
architectures: ARCHITECTURES
}),
Object.freeze({
platform: 'linux',
architectures: ARCHITECTURES
})
]),
riskSummary:
'可读取网页内容并代表用户操作网站;当前执行不会复用命名配置,仍必须保持临时隔离和审批策略。',
requiredDiagnostics: Object.freeze([
'browser-executable',
'managed-profile-root'
])
}),
Object.freeze({
id: 'linux-desktop-control',
name: 'Linux 桌面控制',
description:
'技术预览:保留 Linux 桌面控制核心与诊断,注册真实原生适配器后才可启用。',
enabledByDefault: false,
implementationKind: 'managed-linux-desktop-driver',
supportedTargets: Object.freeze([
Object.freeze({
platform: 'linux',
architectures: ARCHITECTURES
})
]),
riskSummary:
'真实 D-Bus、PipeWire、libei 或 XTest 适配器尚未随产品提供;注册适配器后仍须保持审批、超时和审计边界。',
requiredDiagnostics: Object.freeze([
'linux-session',
'desktop-driver',
'desktop-permissions'
])
})
] satisfies ComputerCapabilityCatalogEntry[])
export function getComputerCapability(
id: ComputerCapabilityId
): ComputerCapabilityCatalogEntry {
const capability = computerCapabilityCatalog.find((entry) => entry.id === id)
if (!capability) {
throw new Error(`Unknown computer capability: ${id}`)
}
return capability
}
export function isComputerCapabilitySupported(
capability: ComputerCapabilityCatalogEntry,
platform: NodeJS.Platform,
architecture: string,
availableImplementations: ReadonlySet<ComputerCapabilityImplementationKind> =
PRODUCTION_IMPLEMENTATIONS
): boolean {
return (
availableImplementations.has(capability.implementationKind) &&
capability.supportedTargets.some(
(target) =>
target.platform === platform &&
target.architectures.some((candidate) => candidate === architecture)
)
)
}
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from './capability-service'
import {
createCuratedMcpLaunch,
type CuratedMcpFileSystem,
type CuratedMcpPathMetadata
} from './curated-mcp-launch'
const transportMocks = vi.hoisted(() => ({
stdio: vi.fn(function StdioClientTransport(options: unknown) {
return { kind: 'stdio', options }
}),
http: vi.fn(),
sse: vi.fn()
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: transportMocks.stdio
}))
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: transportMocks.http
}))
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
SSEClientTransport: transportMocks.sse
}))
import { createMcpTransport } from './mcp-client-transport'
const root = process.platform === 'win32' ? 'C:\\GoodBuddy' : '/opt/goodbuddy'
const executable =
process.platform === 'win32'
? `${root}\\helpers\\curated.exe`
: `${root}/helpers/curated`
const cwd =
process.platform === 'win32'
? `${root}\\helpers`
: `${root}/helpers`
const metadata = (
canonicalPath: string,
kind: 'directory' | 'file',
overrides: Partial<CuratedMcpPathMetadata> = {}
): CuratedMcpPathMetadata => ({
canonicalPath,
uid: 1000,
mode: kind === 'directory' ? 0o40755 : 0o100755,
isDirectory: kind === 'directory',
isFile: kind === 'file',
isSymbolicLink: false,
...overrides
})
const fileSystem = (
overrides: Readonly<Record<string, Partial<CuratedMcpPathMetadata>>> = {}
): CuratedMcpFileSystem => ({
inspect: vi.fn(async (path: string) => {
const kind = path === executable ? 'file' : 'directory'
return metadata(path, kind, overrides[path])
})
})
describe('curated MCP launches', () => {
it('keeps raw custom stdio on the SDK default environment', () => {
createMcpTransport({
transport: 'stdio',
command: 'custom-mcp',
args: ['--serve'],
env: { DISPLAY: ':0', TOKEN: 'secret' },
cwd
} as unknown as ResolvedMcpServer)
expect(transportMocks.stdio).toHaveBeenLastCalledWith({
command: 'custom-mcp',
args: ['--serve'],
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
})
it('passes only validated values from an opaque curated descriptor', async () => {
const validateLinuxDesktopEnvironment = vi.fn(async () => ({
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000'
}))
const descriptor = await createCuratedMcpLaunch(
{
executable,
args: ['--stdio'],
cwd,
ownedRoots: [root],
ownerUid: 1000,
allowedEnvironmentNames: ['LANG'],
environment: { LANG: 'zh_CN.UTF-8' },
linuxDesktopEnvironment: {
source: {},
uid: 1000,
fileSystem: { inspect: vi.fn() }
}
},
{
fileSystem: fileSystem(),
validateLinuxDesktopEnvironment
}
)
createMcpTransport(descriptor)
expect(validateLinuxDesktopEnvironment).toHaveBeenCalledOnce()
expect(transportMocks.stdio).toHaveBeenLastCalledWith({
command: executable,
args: ['--stdio'],
cwd,
env: {
LANG: 'zh_CN.UTF-8',
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000'
},
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
})
it('rejects serialized attempts to spoof a curated descriptor', () => {
expect(() =>
createMcpTransport({
transport: 'curated-stdio',
command: executable,
args: [],
cwd,
env: { DISPLAY: ':0' }
} as unknown as ResolvedMcpServer)
).toThrow('无效的精选 MCP 启动描述')
})
it.each([
['symlink executable', { [executable]: { isSymbolicLink: true } }],
[
'non-canonical executable',
{ [executable]: { canonicalPath: `${executable}.real` } }
],
['wrong owner', { [cwd]: { uid: 2000 } }],
['writable root', { [root]: { mode: 0o40777 } }]
])('rejects an unsafe path: %s', async (_name, overrides) => {
await expect(
createCuratedMcpLaunch(
{
executable,
cwd,
ownedRoots: [root],
ownerUid: 1000
},
{ fileSystem: fileSystem(overrides) }
)
).rejects.toThrow(/Unsafe curated MCP/u)
})
it.each([
'LD_PRELOAD',
'NODE_OPTIONS',
'GTK_MODULES',
'QT_PLUGIN_PATH',
'ELECTRON_RUN_AS_NODE',
'CHROME_EXTRA_ARGS',
'HTTPS_PROXY',
'SERVICE_TOKEN'
])('rejects unsafe environment name %s', async (name) => {
await expect(
createCuratedMcpLaunch(
{
executable,
cwd,
ownedRoots: [root],
ownerUid: 1000,
allowedEnvironmentNames: [name],
environment: { [name]: 'unsafe' }
},
{ fileSystem: fileSystem() }
)
).rejects.toThrow(/Unsafe curated MCP environment/u)
})
})
+276
View File
@@ -0,0 +1,276 @@
import { lstat, realpath, stat } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import {
buildDesktopHelperEnvironment,
type SecurePathMetadata,
type SessionEnvironmentOptions
} from '../linux-desktop/session-environment'
import { isPathInside } from '../workspace-file-access'
const MAX_ARGUMENTS = 64
const MAX_ARGUMENT_LENGTH = 4_096
const MAX_ENVIRONMENT_ENTRIES = 64
const MAX_ENVIRONMENT_VALUE_LENGTH = 8_192
const DESKTOP_ENVIRONMENT_NAMES = new Set([
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
])
declare const curatedMcpLaunchBrand: unique symbol
export type CuratedMcpPathMetadata = Readonly<SecurePathMetadata>
export interface CuratedMcpFileSystem {
inspect(path: string): Promise<CuratedMcpPathMetadata>
}
export type CuratedMcpLaunchOptions = Readonly<{
executable: string
args?: readonly string[]
cwd: string
ownedRoots: readonly string[]
ownerUid: number
environment?: Readonly<Record<string, string>>
allowedEnvironmentNames?: readonly string[]
linuxDesktopEnvironment?: SessionEnvironmentOptions
}>
export type CuratedMcpLaunchDescriptor = Readonly<{
readonly [curatedMcpLaunchBrand]: true
readonly transport: 'curated-stdio'
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly env: Readonly<Record<string, string>>
}>
export type CuratedMcpLaunchDependencies = Readonly<{
fileSystem?: CuratedMcpFileSystem
validateLinuxDesktopEnvironment?: (
options: SessionEnvironmentOptions
) => Promise<NodeJS.ProcessEnv>
}>
const descriptors = new WeakSet<object>()
const defaultFileSystem: CuratedMcpFileSystem = {
async inspect(path) {
const [linkMetadata, canonicalPath] = await Promise.all([
lstat(path),
realpath(path)
])
const metadata = await stat(canonicalPath)
return {
canonicalPath,
uid: metadata.uid,
mode: metadata.mode,
isDirectory: metadata.isDirectory(),
isFile: metadata.isFile(),
isSymbolicLink: linkMetadata.isSymbolicLink()
}
}
}
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const isSafePathText = (value: string): boolean =>
value.length >= 1 &&
value.length <= MAX_ENVIRONMENT_VALUE_LENGTH &&
!hasControlCharacters(value)
const assertOwnedCanonicalPath = (
requestedPath: string,
metadata: CuratedMcpPathMetadata,
ownerUid: number,
expectedKind: 'directory' | 'file'
): void => {
if (
!isAbsolute(requestedPath) ||
metadata.canonicalPath !== requestedPath ||
metadata.isSymbolicLink ||
metadata.uid !== ownerUid ||
(metadata.mode & 0o022) !== 0 ||
(expectedKind === 'directory'
? !metadata.isDirectory
: !metadata.isFile)
) {
throw new Error(`Unsafe curated MCP ${expectedKind} path`)
}
}
const isRejectedEnvironmentName = (name: string): boolean => {
const upperName = name.toUpperCase()
return (
upperName.startsWith('LD_') ||
upperName === 'NODE_OPTIONS' ||
upperName === 'GTK_MODULES' ||
upperName === 'QT_PLUGIN_PATH' ||
upperName.startsWith('ELECTRON_') ||
upperName.startsWith('CHROME_') ||
upperName.startsWith('CHROMIUM_') ||
upperName === 'HTTP_PROXY' ||
upperName === 'HTTPS_PROXY' ||
upperName === 'ALL_PROXY' ||
upperName === 'NO_PROXY' ||
/(?:CREDENTIAL|PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY|AUTHORIZATION|COOKIE)/u.test(
upperName
)
)
}
const copyEnvironment = (
source: Readonly<Record<string, string>>,
allowedNames: ReadonlySet<string>,
target: Record<string, string>
): void => {
for (const [name, value] of Object.entries(source)) {
if (
!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) ||
!allowedNames.has(name) ||
isRejectedEnvironmentName(name) ||
value.length > MAX_ENVIRONMENT_VALUE_LENGTH ||
hasControlCharacters(value)
) {
throw new Error(`Unsafe curated MCP environment variable: ${name}`)
}
target[name] = value
}
}
export async function createCuratedMcpLaunch(
options: CuratedMcpLaunchOptions,
dependencies: CuratedMcpLaunchDependencies = {}
): Promise<CuratedMcpLaunchDescriptor> {
if (
options.ownedRoots.length === 0 ||
options.ownedRoots.some(
(root) => !isAbsolute(root) || !isSafePathText(root)
)
) {
throw new Error('Curated MCP requires an absolute allowlisted root')
}
if (
!isAbsolute(options.executable) ||
!isAbsolute(options.cwd) ||
!isSafePathText(options.executable) ||
!isSafePathText(options.cwd) ||
(options.args?.length ?? 0) > MAX_ARGUMENTS ||
options.args?.some(
(argument) =>
argument.length > MAX_ARGUMENT_LENGTH ||
hasControlCharacters(argument)
)
) {
throw new Error('Invalid curated MCP launch parameters')
}
const fileSystem = dependencies.fileSystem ?? defaultFileSystem
const rootMetadata = await Promise.all(
options.ownedRoots.map((root) => fileSystem.inspect(root))
)
rootMetadata.forEach((metadata, index) => {
assertOwnedCanonicalPath(
options.ownedRoots[index]!,
metadata,
options.ownerUid,
'directory'
)
})
const [executableMetadata, cwdMetadata] = await Promise.all([
fileSystem.inspect(options.executable),
fileSystem.inspect(options.cwd)
])
assertOwnedCanonicalPath(
options.executable,
executableMetadata,
options.ownerUid,
'file'
)
assertOwnedCanonicalPath(
options.cwd,
cwdMetadata,
options.ownerUid,
'directory'
)
if (
(process.platform !== 'win32' &&
(executableMetadata.mode & 0o111) === 0) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, executableMetadata.canonicalPath)
) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, cwdMetadata.canonicalPath)
)
) {
throw new Error('Curated MCP paths are outside the owned allowlist')
}
const allowedNames = new Set(options.allowedEnvironmentNames ?? [])
if (
(options.allowedEnvironmentNames?.length ?? 0) >
MAX_ENVIRONMENT_ENTRIES ||
allowedNames.size > MAX_ENVIRONMENT_ENTRIES ||
[...allowedNames].some(
(name) =>
isRejectedEnvironmentName(name) ||
DESKTOP_ENVIRONMENT_NAMES.has(name.toUpperCase())
)
) {
throw new Error('Unsafe curated MCP environment allowlist')
}
const environment: Record<string, string> = {}
copyEnvironment(options.environment ?? {}, allowedNames, environment)
if (options.linuxDesktopEnvironment) {
const validate =
dependencies.validateLinuxDesktopEnvironment ??
buildDesktopHelperEnvironment
const desktopEnvironment = await validate(
options.linuxDesktopEnvironment
)
copyEnvironment(
desktopEnvironment as Record<string, string>,
new Set([
'PATH',
'LANG',
'LC_ALL',
'LC_CTYPE',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
]),
environment
)
}
const descriptor = Object.freeze({
transport: 'curated-stdio' as const,
command: executableMetadata.canonicalPath,
args: Object.freeze([...(options.args ?? [])]),
cwd: cwdMetadata.canonicalPath,
env: Object.freeze({ ...environment })
}) as CuratedMcpLaunchDescriptor
descriptors.add(descriptor)
return descriptor
}
export function isCuratedMcpLaunchDescriptor(
value: unknown
): value is CuratedMcpLaunchDescriptor {
return (
typeof value === 'object' &&
value !== null &&
descriptors.has(value)
)
}
+20 -1
View File
@@ -6,6 +6,10 @@ import type {
Transport
} from '@modelcontextprotocol/sdk/shared/transport.js'
import type { ResolvedMcpServer } from './capability-service'
import {
isCuratedMcpLaunchDescriptor,
type CuratedMcpLaunchDescriptor
} from './curated-mcp-launch'
function validateRemoteUrl(value: string): URL {
const url = new URL(value)
@@ -34,8 +38,23 @@ function createRestrictedFetch(origin: string): FetchLike {
}
export function createMcpTransport(
server: ResolvedMcpServer
server: ResolvedMcpServer | CuratedMcpLaunchDescriptor
): Transport {
if (isCuratedMcpLaunchDescriptor(server)) {
return new StdioClientTransport({
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: { ...server.env },
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
}
if ((server as { transport?: string }).transport === 'curated-stdio') {
throw new Error('无效的精选 MCP 启动描述')
}
if (server.transport === 'stdio') {
return new StdioClientTransport({
command: server.command,
+32
View File
@@ -141,4 +141,36 @@ describe('testMcpServer', () => {
)
expect(mocks.client.close).toHaveBeenCalledOnce()
})
it('accepts cancellation, closes the client, and hides abort details', async () => {
mocks.client.connect.mockImplementation(
async (
_transport: unknown,
options: { signal: AbortSignal }
) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(new Error('sensitive abort reason')),
{ once: true }
)
})
)
const controller = new AbortController()
const pending = testMcpServer(
{
...common,
transport: 'stdio',
command: 'node',
args: ['server.js']
} satisfies ResolvedMcpServer,
controller.signal
)
controller.abort(new Error('caller private context'))
await expect(pending).rejects.toThrow('MCP 连接测试已取消')
expect(mocks.client.listTools).not.toHaveBeenCalled()
expect(mocks.client.close).toHaveBeenCalledOnce()
})
})
+44 -12
View File
@@ -3,10 +3,12 @@ import type { McpServerTestResult } from '../../shared/capability-contracts'
import type { ResolvedMcpServer } from './capability-service'
import { createMcpTransport } from './mcp-client-transport'
const MCP_TEST_TIMEOUT_MS = 12_000
const MCP_TEST_TOTAL_TIMEOUT_MS = 12_000
const MCP_TEST_INACTIVITY_TIMEOUT_MS = 8_000
export async function testMcpServer(
server: ResolvedMcpServer
server: ResolvedMcpServer,
signal?: AbortSignal
): Promise<McpServerTestResult> {
const client = new Client({
name: 'goodbuddy',
@@ -14,19 +16,45 @@ export async function testMcpServer(
})
const transport = createMcpTransport(server)
const controller = new AbortController()
let timedOut = false
const abortFromCaller = (): void => {
controller.abort()
}
signal?.addEventListener('abort', abortFromCaller, { once: true })
if (signal?.aborted) {
abortFromCaller()
}
const timeout = setTimeout(() => {
timedOut = true
controller.abort(new Error('MCP 连接测试超时'))
}, MCP_TEST_TIMEOUT_MS)
}, MCP_TEST_TOTAL_TIMEOUT_MS)
const runWithInactivityLimit = async <T>(
operation: () => Promise<T>
): Promise<T> => {
const inactivityTimeout = setTimeout(() => {
timedOut = true
controller.abort(new Error('MCP 连接测试超时'))
}, MCP_TEST_INACTIVITY_TIMEOUT_MS)
try {
return await operation()
} finally {
clearTimeout(inactivityTimeout)
}
}
try {
await client.connect(transport, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
const result = await client.listTools(undefined, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
await runWithInactivityLimit(() =>
client.connect(transport, {
timeout: MCP_TEST_INACTIVITY_TIMEOUT_MS,
signal: controller.signal
})
)
const result = await runWithInactivityLimit(() =>
client.listTools(undefined, {
timeout: MCP_TEST_INACTIVITY_TIMEOUT_MS,
signal: controller.signal
})
)
const version = client.getServerVersion()
return {
serverName: version?.name.slice(0, 120),
@@ -38,7 +66,10 @@ export async function testMcpServer(
}))
}
} catch (error) {
if (controller.signal.aborted) {
if (signal?.aborted && !timedOut) {
throw new Error('MCP 连接测试已取消', { cause: error })
}
if (timedOut) {
throw new Error('MCP 连接测试超时', { cause: error })
}
throw new Error(
@@ -50,6 +81,7 @@ export async function testMcpServer(
)
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromCaller)
await client.close().catch(() => undefined)
}
}
+13
View File
@@ -0,0 +1,13 @@
import type {
ComputerControlApprovalRequest,
ComputerControlApprovalResult
} from '../../shared/computer-control-contracts'
export const COMPUTER_CONTROL_APPROVAL_DEADLINE_MS = 120_000
export interface ComputerControlApprovalProvider {
request(
request: ComputerControlApprovalRequest,
signal: AbortSignal
): Promise<ComputerControlApprovalResult>
}
+65
View File
@@ -0,0 +1,65 @@
import { createHash } from 'node:crypto'
import type {
ComputerControlErrorCode,
ComputerControlRisk
} from '../../shared/computer-control-contracts'
export type ComputerControlAuditEvent = {
timestamp: number
taskId: string
conversationId: string
leaseId: string
commandId: string
action:
| 'observe'
| 'activate'
| 'replace_text'
| 'select_option'
| 'scroll'
risk: ComputerControlRisk
outcome: 'completed' | 'denied' | 'failed' | 'outcome_unknown'
errorCode?: ComputerControlErrorCode
textLength?: number
textDigest?: string
}
export interface ComputerControlAuditSink {
write(event: ComputerControlAuditEvent): void | Promise<void>
}
export const digestComputerControlText = (text: string): string =>
createHash('sha256').update(text, 'utf8').digest('hex')
export class InMemoryComputerControlAudit
implements ComputerControlAuditSink
{
private readonly records: ComputerControlAuditEvent[] = []
constructor(private readonly maximumRecords = 1_000) {
if (
!Number.isInteger(maximumRecords) ||
maximumRecords < 1 ||
maximumRecords > 10_000
) {
throw new Error('Invalid computer control audit capacity')
}
}
write(event: ComputerControlAuditEvent): void {
const bounded: ComputerControlAuditEvent = {
...event,
taskId: event.taskId.slice(0, 128),
conversationId: event.conversationId.slice(0, 128),
leaseId: event.leaseId.slice(0, 160),
commandId: event.commandId.slice(0, 160)
}
this.records.push(Object.freeze(bounded))
if (this.records.length > this.maximumRecords) {
this.records.splice(0, this.records.length - this.maximumRecords)
}
}
entries(): readonly ComputerControlAuditEvent[] {
return this.records.map((record) => ({ ...record }))
}
}
+896
View File
@@ -0,0 +1,896 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ComputerControlAction,
ComputerControlApprovalRequest,
ComputerControlApprovalResult,
ComputerControlCommandResult,
ComputerControlObservation
} from '../../shared/computer-control-contracts'
import { InMemoryComputerControlAudit } from './audit'
import type { ComputerControlApprovalProvider } from './approval'
import {
ComputerControlBroker,
type ComputerControlBrokerOptions
} from './broker'
import type {
ComputerControlDriver,
DriverElement,
DriverObservation,
DriverWindowIdentity,
NativeElementIdentity
} from './driver'
import type { ComputerControlLeaseBinding } from './lease-store'
const binding: ComputerControlLeaseBinding = {
taskId: 'task-1',
conversationId: 'conversation-1',
pid: 42,
processStartTime: 100,
windowIdentity: 'window-1'
}
const commandId = (suffix: string): string =>
`command_identifier_${suffix.padStart(6, '0')}`
class FakeDriver implements ComputerControlDriver {
readonly available = true
readonly injections: ComputerControlAction[] = []
releaseCount = 0
activeInjections = 0
maximumActiveInjections = 0
foreground: DriverWindowIdentity = {
pid: binding.pid,
processStartTime: binding.processStartTime,
windowIdentity: binding.windowIdentity
}
injectGate?: Promise<void>
foregroundGate?: Promise<void>
constructor(readonly elements: DriverElement[]) {}
async observe(): Promise<DriverObservation> {
await this.foregroundGate
return {
window: { ...this.foreground },
windowTitle: 'Test window',
elements: this.elements.map((element) => ({ ...element }))
}
}
async getForegroundWindow(): Promise<DriverWindowIdentity> {
await this.foregroundGate
return { ...this.foreground }
}
async resolveElement(
nativeIdentity: NativeElementIdentity
): Promise<DriverElement | undefined> {
const element = this.elements.find(
(candidate) => candidate.nativeIdentity === nativeIdentity
)
return element ? { ...element } : undefined
}
async focusElement(
nativeIdentity: NativeElementIdentity
): Promise<boolean> {
const element = this.elements.find(
(candidate) => candidate.nativeIdentity === nativeIdentity
)
if (!element || !element.enabled) {
return false
}
element.focused = true
return true
}
async inject(
nativeIdentity: NativeElementIdentity,
action: ComputerControlAction
): Promise<void> {
void nativeIdentity
this.injections.push(action)
this.activeInjections += 1
this.maximumActiveInjections = Math.max(
this.maximumActiveInjections,
this.activeInjections
)
try {
await this.injectGate
} finally {
this.activeInjections -= 1
}
}
async releaseInjectedInput(): Promise<void> {
this.releaseCount += 1
}
}
class FakeApproval implements ComputerControlApprovalProvider {
readonly requests: ComputerControlApprovalRequest[] = []
handler?: (
request: ComputerControlApprovalRequest,
signal: AbortSignal
) => Promise<ComputerControlApprovalResult>
async request(
request: ComputerControlApprovalRequest,
signal: AbortSignal
): Promise<ComputerControlApprovalResult> {
this.requests.push(request)
if (this.handler) {
return this.handler(request, signal)
}
return {
approvalId: request.approvalId,
decision: 'approve_once'
}
}
}
const makeElement = (
role: DriverElement['role'],
targetKind: DriverElement['targetKind'] = 'standard',
name = 'Target'
): DriverElement => ({
nativeIdentity: {},
role,
name,
enabled: true,
focused: false,
targetKind
})
const makeHarness = (
elements: DriverElement[],
options: {
now?: () => number
driverDeadlineMs?: number
approvalDeadlineMs?: number
audit?: ComputerControlBrokerOptions['audit']
fallbackAudit?: ComputerControlBrokerOptions['fallbackAudit']
} = {}
) => {
let idSequence = 0
const driver = new FakeDriver(elements)
const approval = new FakeApproval()
const audit = new InMemoryComputerControlAudit()
const broker = new ComputerControlBroker({
driver,
approval,
audit: options.audit ?? audit,
fallbackAudit: options.fallbackAudit,
now: options.now,
driverDeadlineMs: options.driverDeadlineMs,
approvalDeadlineMs: options.approvalDeadlineMs,
createId: () =>
`generated_identifier_${String(++idSequence).padStart(6, '0')}`
})
return { broker, driver, approval, audit }
}
const observe = async (
broker: ComputerControlBroker,
leaseId: string,
suffix: string,
context = binding
): Promise<ComputerControlObservation> => {
const result = await broker.execute(
{
kind: 'observe',
commandId: commandId(suffix),
leaseId
},
context,
new AbortController().signal
)
if (result.status !== 'observed') {
throw new Error(`Observation failed: ${JSON.stringify(result)}`)
}
return result.observation
}
const expectErrorCode = (
result: ComputerControlCommandResult,
code: string
): void => {
expect(result).toMatchObject({
status: 'error',
error: { code }
})
}
describe('ComputerControlBroker', () => {
it('rejects stale and consumed refs while preserving command idempotency', async () => {
let now = 1_000
const { broker, driver } = makeHarness([makeElement('link')], {
now: () => now
})
const lease = broker.createLease(binding)
const staleObservation = await observe(
broker,
lease.leaseId,
'stale-observe'
)
now += 3_000
const stale = await broker.execute(
{
kind: 'act',
commandId: commandId('stale-act'),
leaseId: lease.leaseId,
observationId: staleObservation.observationId,
revision: staleObservation.revision,
action: {
kind: 'activate',
elementRef: staleObservation.elements[0]?.ref
}
},
binding,
new AbortController().signal
)
expectErrorCode(stale, 'observation_stale')
const fresh = await observe(
broker,
lease.leaseId,
'fresh-observe'
)
const action = {
kind: 'act' as const,
commandId: commandId('fresh-act'),
leaseId: lease.leaseId,
observationId: fresh.observationId,
revision: fresh.revision,
action: {
kind: 'activate' as const,
elementRef: fresh.elements[0]?.ref
}
}
const first = await broker.execute(
action,
binding,
new AbortController().signal
)
expect(first.status).toBe('completed')
await expect(
broker.execute(
action,
binding,
new AbortController().signal
)
).resolves.toEqual(first)
expect(driver.injections).toHaveLength(1)
const consumed = await broker.execute(
{ ...action, commandId: commandId('consumed') },
binding,
new AbortController().signal
)
expectErrorCode(consumed, 'observation_consumed')
})
it('rejects command ID reuse with different content', async () => {
const { broker } = makeHarness([makeElement('link')])
const lease = broker.createLease(binding)
await observe(broker, lease.leaseId, 'same-id')
const conflict = await broker.execute(
{
kind: 'observe',
commandId: commandId('same-id'),
leaseId: 'different_lease_identifier'
},
binding,
new AbortController().signal
)
expectErrorCode(conflict, 'command_id_conflict')
})
it.each([
'protected',
'password',
'otp',
'payment',
'account_security',
'privilege',
'os_permission'
] as const)('forbids %s targets before approval or injection', async (targetKind) => {
const { broker, driver, approval } = makeHarness([
makeElement('textbox', targetKind)
])
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
`forbidden-${targetKind}`
)
expect(observation.elements[0]).toMatchObject({
risk: 'forbidden',
blocked: true
})
const result = await broker.execute(
{
kind: 'act',
commandId: commandId(`act-${targetKind}`),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'replace_text',
elementRef: observation.elements[0]?.ref,
text: 'never injected'
}
},
binding,
new AbortController().signal
)
expectErrorCode(result, 'forbidden')
expect(approval.requests).toHaveLength(0)
expect(driver.injections).toHaveLength(0)
})
it('requires a distinct once approval for every input and commit', async () => {
const { broker, approval } = makeHarness([
makeElement('textbox', 'standard', 'Editor'),
makeElement('button', 'standard', 'Submit')
])
const lease = broker.createLease(binding)
const inputObservation = await observe(
broker,
lease.leaseId,
'input-observe'
)
const input = await broker.execute(
{
kind: 'act',
commandId: commandId('input-act'),
leaseId: lease.leaseId,
observationId: inputObservation.observationId,
revision: inputObservation.revision,
action: {
kind: 'replace_text',
elementRef: inputObservation.elements[0]?.ref,
text: 'hello'
}
},
binding,
new AbortController().signal
)
expect(input).toMatchObject({ status: 'completed', risk: 'input' })
const commitObservation = await observe(
broker,
lease.leaseId,
'commit-observe'
)
const commit = await broker.execute(
{
kind: 'act',
commandId: commandId('commit-act'),
leaseId: lease.leaseId,
observationId: commitObservation.observationId,
revision: commitObservation.revision,
action: {
kind: 'activate',
elementRef: commitObservation.elements[1]?.ref
}
},
binding,
new AbortController().signal
)
expect(commit).toMatchObject({
status: 'completed',
risk: 'commit'
})
expect(approval.requests.map((request) => request.risk)).toEqual([
'input',
'commit'
])
expect(
approval.requests.every(
(request) => !('text' in request)
)
).toBe(true)
})
it('handles approval denial, timeout, and cancellation fail-closed', async () => {
vi.useFakeTimers()
try {
const deniedHarness = makeHarness([makeElement('textbox')])
deniedHarness.approval.handler = async (request) => ({
approvalId: request.approvalId,
decision: 'deny'
})
const deniedLease = deniedHarness.broker.createLease(binding)
const deniedObservation = await observe(
deniedHarness.broker,
deniedLease.leaseId,
'denied-observe'
)
const denied = await deniedHarness.broker.execute(
{
kind: 'act',
commandId: commandId('denied-act'),
leaseId: deniedLease.leaseId,
observationId: deniedObservation.observationId,
revision: deniedObservation.revision,
action: {
kind: 'replace_text',
elementRef: deniedObservation.elements[0]?.ref,
text: 'denied'
}
},
binding,
new AbortController().signal
)
expectErrorCode(denied, 'approval_denied')
const timeoutHarness = makeHarness([makeElement('textbox')], {
approvalDeadlineMs: 20
})
timeoutHarness.approval.handler = () =>
new Promise(() => undefined)
const timeoutLease = timeoutHarness.broker.createLease(binding)
const timeoutObservation = await observe(
timeoutHarness.broker,
timeoutLease.leaseId,
'timeout-observe'
)
const timeoutPromise = timeoutHarness.broker.execute(
{
kind: 'act',
commandId: commandId('timeout-act'),
leaseId: timeoutLease.leaseId,
observationId: timeoutObservation.observationId,
revision: timeoutObservation.revision,
action: {
kind: 'replace_text',
elementRef: timeoutObservation.elements[0]?.ref,
text: 'timeout'
}
},
binding,
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(20)
expectErrorCode(await timeoutPromise, 'approval_timeout')
const cancelledHarness = makeHarness([makeElement('textbox')])
cancelledHarness.approval.handler = () =>
new Promise(() => undefined)
const cancelledLease =
cancelledHarness.broker.createLease(binding)
const cancelledObservation = await observe(
cancelledHarness.broker,
cancelledLease.leaseId,
'cancel-observe'
)
const controller = new AbortController()
const cancelledPromise = cancelledHarness.broker.execute(
{
kind: 'act',
commandId: commandId('cancel-act'),
leaseId: cancelledLease.leaseId,
observationId: cancelledObservation.observationId,
revision: cancelledObservation.revision,
action: {
kind: 'replace_text',
elementRef: cancelledObservation.elements[0]?.ref,
text: 'cancelled'
}
},
binding,
controller.signal
)
await vi.waitFor(() => {
expect(cancelledHarness.approval.requests).toHaveLength(1)
})
controller.abort()
expectErrorCode(await cancelledPromise, 'cancelled')
expect(
cancelledHarness.broker.leases.peek(cancelledLease.leaseId)
).toBeUndefined()
expect(cancelledHarness.driver.releaseCount).toBeGreaterThan(0)
} finally {
vi.useRealTimers()
}
})
it('reports outcome_unknown when cancellation races injection', async () => {
let releaseInjection: (() => void) | undefined
const { broker, driver } = makeHarness([makeElement('link')])
driver.injectGate = new Promise<void>((resolve) => {
releaseInjection = resolve
})
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'race-observe'
)
const controller = new AbortController()
const resultPromise = broker.execute(
{
kind: 'act',
commandId: commandId('race-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate',
elementRef: observation.elements[0]?.ref
}
},
binding,
controller.signal
)
await vi.waitFor(() => {
expect(driver.injections).toHaveLength(1)
})
controller.abort()
expectErrorCode(await resultPromise, 'outcome_unknown')
releaseInjection?.()
expect(driver.releaseCount).toBeGreaterThan(0)
})
it('caches outcome_unknown when injection times out before late completion', async () => {
vi.useFakeTimers()
try {
let releaseInjection: (() => void) | undefined
const { broker, driver, audit } = makeHarness(
[makeElement('link')],
{ driverDeadlineMs: 20 }
)
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'injection-timeout-observe'
)
driver.injectGate = new Promise<void>((resolve) => {
releaseInjection = resolve
})
const command = {
kind: 'act' as const,
commandId: commandId('injection-timeout-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate' as const,
elementRef: observation.elements[0]?.ref
}
}
const firstPromise = broker.execute(
command,
binding,
new AbortController().signal
)
await vi.waitFor(() => {
expect(driver.injections).toHaveLength(1)
})
await vi.advanceTimersByTimeAsync(20)
const first = await firstPromise
expectErrorCode(first, 'outcome_unknown')
await expect(
broker.execute(
command,
binding,
new AbortController().signal
)
).resolves.toEqual(first)
expect(driver.injections).toHaveLength(1)
expect(audit.entries().at(-1)).toMatchObject({
outcome: 'outcome_unknown',
errorCode: 'outcome_unknown'
})
releaseInjection?.()
await Promise.resolve()
expect(driver.injections).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('treats an uncertain injection driver failure as outcome_unknown', async () => {
const { broker, driver, audit } = makeHarness([makeElement('link')])
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'injection-failure-observe'
)
driver.inject = vi.fn(async () => {
throw new Error('driver disconnected during injection')
})
const command = {
kind: 'act' as const,
commandId: commandId('injection-failure-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate' as const,
elementRef: observation.elements[0]?.ref
}
}
const first = await broker.execute(
command,
binding,
new AbortController().signal
)
expectErrorCode(first, 'outcome_unknown')
await expect(
broker.execute(
command,
binding,
new AbortController().signal
)
).resolves.toEqual(first)
expect(driver.inject).toHaveBeenCalledOnce()
expect(audit.entries().at(-1)).toMatchObject({
outcome: 'outcome_unknown',
errorCode: 'outcome_unknown'
})
})
it('isolates throwing primary and fallback audit sinks from cached results', async () => {
const primaryWrite = vi.fn(async () => {
throw new Error('primary audit unavailable')
})
const fallbackWrite = vi.fn(async () => {
throw new Error('fallback audit unavailable')
})
const { broker, driver } = makeHarness([makeElement('link')], {
audit: { write: primaryWrite },
fallbackAudit: { write: fallbackWrite }
})
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'throwing-audit-observe'
)
const command = {
kind: 'act' as const,
commandId: commandId('throwing-audit-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate' as const,
elementRef: observation.elements[0]?.ref
}
}
const first = await broker.execute(
command,
binding,
new AbortController().signal
)
expect(first).toMatchObject({ status: 'completed' })
await expect(
broker.execute(
command,
binding,
new AbortController().signal
)
).resolves.toEqual(first)
expect(driver.injections).toHaveLength(1)
expect(primaryWrite).toHaveBeenCalledTimes(2)
expect(fallbackWrite).toHaveBeenCalledTimes(2)
})
it('isolates throwing audit sinks while caching a failed result', async () => {
const primaryWrite = vi.fn(async () => {
throw new Error('primary audit unavailable')
})
const fallbackWrite = vi.fn(async () => {
throw new Error('fallback audit unavailable')
})
const { broker, driver } = makeHarness(
[makeElement('textbox', 'protected')],
{
audit: { write: primaryWrite },
fallbackAudit: { write: fallbackWrite }
}
)
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'throwing-failure-audit-observe'
)
const command = {
kind: 'act' as const,
commandId: commandId('throwing-failure-audit-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'replace_text' as const,
elementRef: observation.elements[0]?.ref,
text: 'not injected'
}
}
const first = await broker.execute(
command,
binding,
new AbortController().signal
)
expectErrorCode(first, 'forbidden')
await expect(
broker.execute(
command,
binding,
new AbortController().signal
)
).resolves.toEqual(first)
expect(driver.injections).toHaveLength(0)
expect(primaryWrite).toHaveBeenCalledTimes(2)
expect(fallbackWrite).toHaveBeenCalledTimes(2)
})
it('serializes actions for leases belonging to the same task', async () => {
let releaseFirst: (() => void) | undefined
const { broker, driver } = makeHarness([makeElement('link')])
driver.injectGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const firstLease = broker.createLease(binding)
const secondLease = broker.createLease(binding)
const firstObservation = await observe(
broker,
firstLease.leaseId,
'serial-observe-1'
)
const secondObservation = await observe(
broker,
secondLease.leaseId,
'serial-observe-2'
)
const actionFor = (
leaseId: string,
observation: ComputerControlObservation,
suffix: string
) =>
broker.execute(
{
kind: 'act',
commandId: commandId(suffix),
leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate',
elementRef: observation.elements[0]?.ref
}
},
binding,
new AbortController().signal
)
const first = actionFor(
firstLease.leaseId,
firstObservation,
'serial-act-1'
)
await vi.waitFor(() => {
expect(driver.injections).toHaveLength(1)
})
const second = actionFor(
secondLease.leaseId,
secondObservation,
'serial-act-2'
)
await Promise.resolve()
expect(driver.injections).toHaveLength(1)
releaseFirst?.()
driver.injectGate = undefined
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ status: 'completed' }),
expect.objectContaining({ status: 'completed' })
])
expect(driver.maximumActiveInjections).toBe(1)
})
it('revokes on foreground identity mismatch and enforces driver deadlines', async () => {
vi.useFakeTimers()
try {
const mismatchHarness = makeHarness([makeElement('link')])
const mismatchLease =
mismatchHarness.broker.createLease(binding)
const observation = await observe(
mismatchHarness.broker,
mismatchLease.leaseId,
'mismatch-observe'
)
mismatchHarness.driver.foreground = {
...mismatchHarness.driver.foreground,
pid: 99
}
const mismatch = await mismatchHarness.broker.execute(
{
kind: 'act',
commandId: commandId('mismatch-act'),
leaseId: mismatchLease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'activate',
elementRef: observation.elements[0]?.ref
}
},
binding,
new AbortController().signal
)
expectErrorCode(mismatch, 'window_not_foreground')
expect(
mismatchHarness.broker.leases.peek(mismatchLease.leaseId)
).toBeUndefined()
const timeoutHarness = makeHarness([makeElement('link')], {
driverDeadlineMs: 20
})
timeoutHarness.driver.foregroundGate = new Promise(
() => undefined
)
const timeoutLease = timeoutHarness.broker.createLease(binding)
const timeoutPromise = timeoutHarness.broker.execute(
{
kind: 'observe',
commandId: commandId('driver-timeout'),
leaseId: timeoutLease.leaseId
},
binding,
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(20)
expectErrorCode(await timeoutPromise, 'driver_timeout')
} finally {
vi.useRealTimers()
}
})
it('audits typed text only as length and digest', async () => {
const { broker, approval, audit } = makeHarness([
makeElement('textbox', 'standard', 'Editor')
])
const lease = broker.createLease(binding)
const observation = await observe(
broker,
lease.leaseId,
'audit-observe'
)
const secretText = 'private typed content'
const result = await broker.execute(
{
kind: 'act',
commandId: commandId('audit-act'),
leaseId: lease.leaseId,
observationId: observation.observationId,
revision: observation.revision,
action: {
kind: 'replace_text',
elementRef: observation.elements[0]?.ref,
text: secretText
}
},
binding,
new AbortController().signal
)
expect(result.status).toBe('completed')
const entry = audit.entries().at(-1)
expect(entry).toMatchObject({
action: 'replace_text',
textLength: secretText.length
})
expect(entry?.textDigest).toMatch(/^[a-f0-9]{64}$/)
expect(JSON.stringify(audit.entries())).not.toContain(secretText)
expect(JSON.stringify(approval.requests)).not.toContain(secretText)
})
})
+729
View File
@@ -0,0 +1,729 @@
import { randomUUID } from 'node:crypto'
import {
computerControlApprovalResultSchema,
computerControlRuntimeCommandSchema,
type ComputerControlAction,
type ComputerControlCommandResult,
type ComputerControlRisk,
type ComputerControlRuntimeCommand
} from '../../shared/computer-control-contracts'
import {
digestComputerControlText,
type ComputerControlAuditEvent,
type ComputerControlAuditSink
} from './audit'
import {
COMPUTER_CONTROL_APPROVAL_DEADLINE_MS,
type ComputerControlApprovalProvider
} from './approval'
import { runWithDeadline } from './deadline'
import {
COMPUTER_CONTROL_DRIVER_DEADLINE_MS,
type ComputerControlDriver,
type DriverElement,
type DriverWindowIdentity
} from './driver'
import {
cancellationFailure,
ComputerControlFailure
} from './errors'
import {
ComputerControlLeaseStore,
type ComputerControlLease,
type ComputerControlLeaseBinding
} from './lease-store'
import { ComputerControlPerceptionStore } from './perception-store'
import {
classifyComputerControlAction,
maximumRisk
} from './risk-policy'
const INVALID_COMMAND_ID = 'invalid_command_000000'
const MAX_CACHED_COMMANDS = 1_000
type BrokerExecutionContext = ComputerControlLeaseBinding
type InFlightCommand = {
fingerprint: string
result: Promise<ComputerControlCommandResult>
}
export type ComputerControlBrokerOptions = {
driver: ComputerControlDriver
approval: ComputerControlApprovalProvider
audit: ComputerControlAuditSink
fallbackAudit?: ComputerControlAuditSink
leases?: ComputerControlLeaseStore
perceptions?: ComputerControlPerceptionStore
driverDeadlineMs?: number
approvalDeadlineMs?: number
classify?: (
action: ComputerControlAction,
element: DriverElement
) => ComputerControlRisk
now?: () => number
createId?: () => string
}
export class ComputerControlBroker {
readonly leases: ComputerControlLeaseStore
readonly perceptions: ComputerControlPerceptionStore
private readonly driver: ComputerControlDriver
private readonly approval: ComputerControlApprovalProvider
private readonly audit: ComputerControlAuditSink
private readonly fallbackAudit: ComputerControlAuditSink | undefined
private readonly driverDeadlineMs: number
private readonly approvalDeadlineMs: number
private readonly classify?: ComputerControlBrokerOptions['classify']
private readonly now: () => number
private readonly createId: () => string
private readonly queues = new Map<string, Promise<void>>()
private readonly completed = new Map<
string,
{ fingerprint: string; result: ComputerControlCommandResult }
>()
private readonly inFlight = new Map<string, InFlightCommand>()
constructor(options: ComputerControlBrokerOptions) {
this.driver = options.driver
this.approval = options.approval
this.audit = options.audit
this.fallbackAudit = options.fallbackAudit
this.now = options.now ?? Date.now
this.createId = options.createId ?? randomUUID
this.leases =
options.leases ??
new ComputerControlLeaseStore(this.now, this.createId)
this.perceptions =
options.perceptions ??
new ComputerControlPerceptionStore(this.now, this.createId)
this.driverDeadlineMs =
options.driverDeadlineMs ?? COMPUTER_CONTROL_DRIVER_DEADLINE_MS
this.approvalDeadlineMs =
options.approvalDeadlineMs ??
COMPUTER_CONTROL_APPROVAL_DEADLINE_MS
this.classify = options.classify
}
createLease(
binding: ComputerControlLeaseBinding
): ComputerControlLease {
return this.leases.create(binding)
}
revoke(leaseId: string): void {
this.leases.revoke(leaseId)
this.perceptions.revokeLease(leaseId)
void this.releaseInjectedInput()
}
revokeTask(taskId: string): void {
const revokedLeaseIds = this.leases.revokeTask(taskId)
for (const leaseId of revokedLeaseIds) {
this.perceptions.revokeLease(leaseId)
}
void this.releaseInjectedInput()
}
async execute(
untrustedCommand: unknown,
context: BrokerExecutionContext,
signal: AbortSignal
): Promise<ComputerControlCommandResult> {
const parsed =
computerControlRuntimeCommandSchema.safeParse(untrustedCommand)
if (!parsed.success) {
return this.errorResult(
INVALID_COMMAND_ID,
new ComputerControlFailure(
'invalid_request',
'Invalid computer control command'
)
)
}
const command = parsed.data
const fingerprint = JSON.stringify({ command, context })
const cached = this.completed.get(command.commandId)
if (cached) {
return cached.fingerprint === fingerprint
? cached.result
: this.errorResult(
command.commandId,
new ComputerControlFailure(
'command_id_conflict',
'Computer control command ID was reused'
)
)
}
const active = this.inFlight.get(command.commandId)
if (active) {
return active.fingerprint === fingerprint
? active.result
: this.errorResult(
command.commandId,
new ComputerControlFailure(
'command_id_conflict',
'Computer control command ID was reused'
)
)
}
const result = this.serialize(context.taskId, () =>
this.executeSerialized(command, context, signal)
)
this.inFlight.set(command.commandId, { fingerprint, result })
result
.then((settled) => {
this.completed.set(command.commandId, {
fingerprint,
result: settled
})
while (this.completed.size > MAX_CACHED_COMMANDS) {
const oldest = this.completed.keys().next().value
if (oldest === undefined) {
break
}
this.completed.delete(oldest)
}
})
.finally(() => {
this.inFlight.delete(command.commandId)
})
.catch(() => {
// executeSerialized always returns a contract result.
})
return result
}
private async executeSerialized(
command: ComputerControlRuntimeCommand,
context: BrokerExecutionContext,
signal: AbortSignal
): Promise<ComputerControlCommandResult> {
let injectionStarted = false
const cancel = (): void => {
this.revoke(command.leaseId)
}
signal.addEventListener('abort', cancel, { once: true })
try {
if (signal.aborted) {
cancel()
throw cancellationFailure()
}
const lease = this.leases.validate(command.leaseId, context)
if (!this.driver.available) {
throw new ComputerControlFailure(
'driver_unavailable',
'Computer control driver is unavailable'
)
}
if (command.kind === 'observe') {
const result = await this.observe(command, lease, signal)
await this.writeAudit({
...this.auditBase(command, lease),
action: 'observe',
risk: 'observe',
outcome: 'completed'
})
return result
}
const result = await this.act(
command,
lease,
signal,
() => {
injectionStarted = true
}
)
return result
} catch (error) {
const failure =
signal.aborted && injectionStarted
? new ComputerControlFailure(
'outcome_unknown',
'Cancellation raced input injection'
)
: this.normalizeFailure(error)
if (command.kind === 'act') {
await this.writeFailureAudit(command, context, failure)
}
return this.errorResult(command.commandId, failure)
} finally {
signal.removeEventListener('abort', cancel)
if (signal.aborted) {
await this.releaseInjectedInput()
}
}
}
private async observe(
command: Extract<ComputerControlRuntimeCommand, { kind: 'observe' }>,
lease: ComputerControlLease,
signal: AbortSignal
): Promise<ComputerControlCommandResult> {
const observation = await this.driverCall(
(driverSignal) => this.driver.observe(driverSignal),
signal
)
this.assertWindowMatches(lease, observation.window)
const contract = this.perceptions.create(
lease.leaseId,
observation,
(element) =>
this.classifyRisk(
{
kind: 'activate',
elementRef: 'opaque_identifier_placeholder'
},
element
)
)
return {
status: 'observed',
commandId: command.commandId,
observation: contract
}
}
private async act(
command: Extract<ComputerControlRuntimeCommand, { kind: 'act' }>,
lease: ComputerControlLease,
signal: AbortSignal,
markInjectionStarted: () => void
): Promise<ComputerControlCommandResult> {
const stored = this.perceptions.resolve(
lease.leaseId,
command.observationId,
command.revision,
command.action.elementRef
)
let resolved = await this.validateTarget(
lease,
stored.driverElement,
stored.nativeIdentity,
signal
)
let risk = this.classifyRisk(command.action, resolved)
if (risk === 'forbidden') {
throw new ComputerControlFailure(
'forbidden',
'Computer control target is protected'
)
}
if (risk === 'input' || risk === 'commit') {
await this.requestApproval(command, lease, resolved, risk, signal)
}
resolved = await this.validateTarget(
lease,
stored.driverElement,
stored.nativeIdentity,
signal
)
const finalRisk = this.classifyRisk(command.action, resolved)
if (finalRisk === 'forbidden') {
throw new ComputerControlFailure(
'forbidden',
'Computer control target is protected'
)
}
const elevatedRisk = maximumRisk(risk, finalRisk)
if (elevatedRisk !== risk) {
if (elevatedRisk === 'input' || elevatedRisk === 'commit') {
await this.requestApproval(
command,
lease,
resolved,
elevatedRisk,
signal
)
resolved = await this.validateTarget(
lease,
stored.driverElement,
stored.nativeIdentity,
signal
)
}
risk = elevatedRisk
}
const injectionRisk = this.classifyRisk(command.action, resolved)
if (injectionRisk === 'forbidden') {
throw new ComputerControlFailure(
'forbidden',
'Computer control target is protected'
)
}
const finalElevatedRisk = maximumRisk(risk, injectionRisk)
if (finalElevatedRisk !== risk) {
throw new ComputerControlFailure(
'approval_denied',
'Computer control risk changed after approval'
)
}
risk = finalElevatedRisk
this.perceptions.consume(command.observationId)
markInjectionStarted()
try {
await this.driverCall(
(driverSignal) =>
this.driver.inject(
stored.nativeIdentity,
command.action,
driverSignal
),
signal
)
} catch {
throw new ComputerControlFailure(
'outcome_unknown',
signal.aborted
? 'Cancellation raced input injection'
: 'Input injection outcome could not be verified'
)
}
if (signal.aborted) {
throw new ComputerControlFailure(
'outcome_unknown',
'Cancellation raced input injection'
)
}
await this.writeAudit({
...this.auditBase(command, lease),
action: command.action.kind,
risk,
outcome: 'completed',
...this.redactedTextMetadata(command.action)
})
if (risk === 'forbidden') {
throw new ComputerControlFailure(
'forbidden',
'Computer control target is protected'
)
}
return {
status: 'completed',
commandId: command.commandId,
risk
}
}
private async requestApproval(
command: Extract<ComputerControlRuntimeCommand, { kind: 'act' }>,
lease: ComputerControlLease,
element: DriverElement,
risk: 'input' | 'commit',
signal: AbortSignal
): Promise<void> {
const approvalId = this.createId()
const response = await runWithDeadline(
(approvalSignal) =>
this.approval.request(
{
approvalId,
leaseId: lease.leaseId,
commandId: command.commandId,
risk,
action:
command.action.kind === 'scroll'
? 'activate'
: command.action.kind,
targetName:
this.boundedDisplayText(element.name, 256) ||
element.role,
...(command.action.kind === 'replace_text'
? { textLength: command.action.text.length }
: {})
},
approvalSignal
),
signal,
this.approvalDeadlineMs,
'approval_timeout'
)
const parsed = computerControlApprovalResultSchema.safeParse(response)
if (!parsed.success || parsed.data.approvalId !== approvalId) {
throw new ComputerControlFailure(
'approval_denied',
'Computer control approval response was invalid'
)
}
if (parsed.data.decision !== 'approve_once') {
throw new ComputerControlFailure(
'approval_denied',
'Computer control approval was denied'
)
}
}
private async validateTarget(
lease: ComputerControlLease,
original: DriverElement,
nativeIdentity: DriverElement['nativeIdentity'],
signal: AbortSignal
): Promise<DriverElement> {
const foreground = await this.driverCall(
(driverSignal) =>
this.driver.getForegroundWindow(driverSignal),
signal
)
this.assertWindowMatches(lease, foreground)
const resolved = await this.driverCall(
(driverSignal) =>
this.driver.resolveElement(nativeIdentity, driverSignal),
signal
)
this.assertElementIdentity(original, resolved, nativeIdentity)
if (!resolved.enabled) {
throw new ComputerControlFailure(
'focus_failed',
'Computer control element is disabled'
)
}
const focused = await this.driverCall(
(driverSignal) =>
this.driver.focusElement(nativeIdentity, driverSignal),
signal
)
if (!focused) {
throw new ComputerControlFailure(
'focus_failed',
'Computer control element could not be focused',
true
)
}
const verified = await this.driverCall(
(driverSignal) =>
this.driver.resolveElement(nativeIdentity, driverSignal),
signal
)
this.assertElementIdentity(original, verified, nativeIdentity)
if (!verified.focused) {
throw new ComputerControlFailure(
'focus_failed',
'Computer control element focus was not verified',
true
)
}
const foregroundAfterFocus = await this.driverCall(
(driverSignal) =>
this.driver.getForegroundWindow(driverSignal),
signal
)
this.assertWindowMatches(lease, foregroundAfterFocus)
return verified
}
private assertElementIdentity(
original: DriverElement,
resolved: DriverElement | undefined,
nativeIdentity: DriverElement['nativeIdentity']
): asserts resolved is DriverElement {
if (
!resolved ||
resolved.nativeIdentity !== nativeIdentity ||
resolved.role !== original.role ||
resolved.name !== original.name
) {
throw new ComputerControlFailure(
'element_identity_changed',
'Computer control element identity changed',
true
)
}
}
private assertWindowMatches(
lease: ComputerControlLease,
window: DriverWindowIdentity
): void {
if (
lease.pid !== window.pid ||
lease.processStartTime !== window.processStartTime ||
lease.windowIdentity !== window.windowIdentity
) {
this.revoke(lease.leaseId)
throw new ComputerControlFailure(
'window_not_foreground',
'Leased window is not the foreground window',
true
)
}
}
private classifyRisk(
action: ComputerControlAction,
element: DriverElement
): ComputerControlRisk {
const baseline = classifyComputerControlAction(action, element)
const additional = this.classify?.(action, element)
return maximumRisk(baseline, additional)
}
private driverCall<T>(
operation: (signal: AbortSignal) => Promise<T>,
signal: AbortSignal
): Promise<T> {
return runWithDeadline(
operation,
signal,
this.driverDeadlineMs,
'driver_timeout'
)
}
private serialize<T>(
key: string,
operation: () => Promise<T>
): Promise<T> {
const previous = this.queues.get(key) ?? Promise.resolve()
const result = previous.then(operation, operation)
const tail = result.then(
() => undefined,
() => undefined
)
this.queues.set(key, tail)
tail.finally(() => {
if (this.queues.get(key) === tail) {
this.queues.delete(key)
}
})
return result
}
private auditBase(
command: ComputerControlRuntimeCommand,
context: BrokerExecutionContext
): Pick<
ComputerControlAuditEvent,
| 'timestamp'
| 'taskId'
| 'conversationId'
| 'leaseId'
| 'commandId'
> {
return {
timestamp: this.now(),
taskId: context.taskId,
conversationId: context.conversationId,
leaseId: command.leaseId,
commandId: command.commandId
}
}
private redactedTextMetadata(
action: ComputerControlAction
): Pick<
ComputerControlAuditEvent,
'textLength' | 'textDigest'
> {
return action.kind === 'replace_text'
? {
textLength: action.text.length,
textDigest: digestComputerControlText(action.text)
}
: {}
}
private async writeFailureAudit(
command: Extract<ComputerControlRuntimeCommand, { kind: 'act' }>,
context: BrokerExecutionContext,
failure: ComputerControlFailure
): Promise<void> {
const risk: ComputerControlRisk =
failure.code === 'forbidden'
? 'forbidden'
: command.action.kind === 'replace_text' ||
command.action.kind === 'select_option'
? 'input'
: command.action.kind === 'activate'
? 'commit'
: 'navigate'
await this.writeAudit({
...this.auditBase(command, context),
action: command.action.kind,
risk,
outcome:
failure.code === 'outcome_unknown'
? 'outcome_unknown'
: failure.code === 'approval_denied' ||
failure.code === 'approval_timeout'
? 'denied'
: 'failed',
errorCode: failure.code,
...this.redactedTextMetadata(command.action)
})
}
private async writeAudit(event: ComputerControlAuditEvent): Promise<void> {
try {
await this.audit.write(event)
return
} catch {
// Audit persistence must not alter the command's cached result.
}
if (!this.fallbackAudit || this.fallbackAudit === this.audit) {
return
}
try {
await this.fallbackAudit.write(event)
} catch {
// The fallback is also best effort and isolated from execution.
}
}
private normalizeFailure(error: unknown): ComputerControlFailure {
if (error instanceof ComputerControlFailure) {
return error
}
return new ComputerControlFailure(
'internal_error',
'Computer control failed safely'
)
}
private boundedDisplayText(
value: string,
maximumLength: number
): string {
return [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return code > 31 && code !== 127
})
.join('')
.trim()
.slice(0, maximumLength)
}
private errorResult(
commandId: string,
failure: ComputerControlFailure
): ComputerControlCommandResult {
return {
status: 'error',
commandId,
error: failure.toContract()
}
}
private async releaseInjectedInput(): Promise<void> {
try {
await runWithDeadline(
(signal) => this.driver.releaseInjectedInput(signal),
new AbortController().signal,
this.driverDeadlineMs,
'driver_timeout'
)
} catch {
// Cancellation cleanup is best effort and must not mask the result.
}
}
}
@@ -0,0 +1,161 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import { AssistantDatabase } from '../assistant/assistant-database'
import type { ComputerControlAuditEvent } from './audit'
import { DatabaseComputerControlAuditSink } from './database-audit'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createDatabase(): Promise<{
database: AssistantDatabase
databasePath: string
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-control-audit-'))
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
return { database, databasePath }
}
const createTask = (database: AssistantDatabase, taskId: string): void => {
database.createTask({
id: taskId,
title: '计算机控制审计',
instructions: '验证持久审计',
workMode: 'execute'
})
}
describe('DatabaseComputerControlAuditSink', () => {
it('persists redacted events idempotently across close and reopen', async () => {
const { database, databasePath } = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000401'
const typedText = 'secret typed content 不得持久化'
const textDigest = createHash('sha256')
.update(typedText, 'utf8')
.digest('hex')
createTask(database, taskId)
const sink = new DatabaseComputerControlAuditSink(database)
const event: ComputerControlAuditEvent = {
timestamp: 1_775_000_000_000,
taskId,
conversationId: 'conversation-control-0001',
leaseId: 'lease_control_000000001',
commandId: 'command_control_0000001',
action: 'replace_text',
risk: 'input',
outcome: 'completed',
textLength: typedText.length,
textDigest
}
sink.write(event)
sink.write({ ...event, timestamp: event.timestamp + 1 })
expect(database.listRecentComputerControlAudit()).toEqual([event])
database.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
expect(reopened.listRecentComputerControlAudit(1)).toEqual([event])
reopened.close()
const raw = new DatabaseSync(databasePath)
const auditRows = raw
.prepare('SELECT * FROM computer_control_actions')
.all()
const taskEvents = raw
.prepare(
`SELECT payload_json FROM task_events
WHERE task_id = ? AND kind = 'computer_control'`
)
.all(taskId) as Array<{ payload_json: string }>
expect(auditRows).toHaveLength(1)
expect(taskEvents).toHaveLength(1)
expect(JSON.stringify(auditRows)).not.toContain(typedText)
expect(JSON.stringify(taskEvents)).not.toContain(typedText)
expect(taskEvents[0]?.payload_json).toContain(textDigest)
raw.close()
expect((await readFile(databasePath)).toString('utf8')).not.toContain(
typedText
)
})
it('enforces task foreign keys and cascades task audit deletion', async () => {
const { database } = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000402'
const base: ComputerControlAuditEvent = {
timestamp: 1_775_000_000_000,
taskId,
conversationId: 'conversation-control-0002',
leaseId: 'lease_control_000000002',
commandId: 'command_control_0000002',
action: 'observe',
risk: 'observe',
outcome: 'completed'
}
expect(() => database.persistComputerControlAudit(base)).toThrow()
createTask(database, taskId)
database.persistComputerControlAudit(base)
database.clearAssistantData()
expect(database.listRecentComputerControlAudit()).toEqual([])
database.close()
})
it('rejects invalid audit fields before persistence', async () => {
const { database } = await createDatabase()
const taskId = '00000000-0000-4000-8000-000000000403'
createTask(database, taskId)
const valid: ComputerControlAuditEvent = {
timestamp: 1_775_000_000_000,
taskId,
conversationId: 'conversation-control-0003',
leaseId: 'lease_control_000000003',
commandId: 'command_control_0000003',
action: 'observe',
risk: 'observe',
outcome: 'completed'
}
const invalidEvents: unknown[] = [
{ ...valid, timestamp: Number.NaN },
{ ...valid, taskId: '' },
{ ...valid, conversationId: 'x'.repeat(129) },
{ ...valid, leaseId: 'short' },
{ ...valid, action: 'type_secret' },
{ ...valid, risk: 'unsafe' },
{ ...valid, outcome: 'failed' },
{
...valid,
action: 'replace_text',
risk: 'input',
textLength: 10,
textDigest: 'not-a-sha256'
},
{ ...valid, textLength: 1, textDigest: 'a'.repeat(64) }
]
for (const invalid of invalidEvents) {
expect(() =>
database.persistComputerControlAudit(
invalid as ComputerControlAuditEvent
)
).toThrow()
}
expect(() => database.listRecentComputerControlAudit(0)).toThrow()
expect(database.listRecentComputerControlAudit()).toEqual([])
database.close()
})
})
@@ -0,0 +1,15 @@
import type { AssistantDatabase } from '../assistant/assistant-database'
import type {
ComputerControlAuditEvent,
ComputerControlAuditSink
} from './audit'
export class DatabaseComputerControlAuditSink
implements ComputerControlAuditSink
{
constructor(private readonly database: AssistantDatabase) {}
write(event: ComputerControlAuditEvent): void {
this.database.persistComputerControlAudit(event)
}
}
+49
View File
@@ -0,0 +1,49 @@
import { cancellationFailure, ComputerControlFailure } from './errors'
export const runWithDeadline = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
parentSignal: AbortSignal,
deadlineMs: number,
timeoutCode: 'driver_timeout' | 'approval_timeout'
): Promise<T> => {
if (parentSignal.aborted) {
throw cancellationFailure()
}
const controller = new AbortController()
let timedOut = false
const cancel = (): void => {
controller.abort(parentSignal.reason)
}
parentSignal.addEventListener('abort', cancel, { once: true })
const timeout = setTimeout(() => {
timedOut = true
controller.abort(new Error('Computer control deadline exceeded'))
}, deadlineMs)
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_resolve, reject) => {
controller.signal.addEventListener(
'abort',
() => {
reject(
timedOut
? new ComputerControlFailure(
timeoutCode,
'Computer control request timed out',
true
)
: cancellationFailure()
)
},
{ once: true }
)
})
])
} finally {
clearTimeout(timeout)
parentSignal.removeEventListener('abort', cancel)
}
}
+89
View File
@@ -0,0 +1,89 @@
import type {
ComputerControlAction,
ComputerControlElementRole
} from '../../shared/computer-control-contracts'
export const COMPUTER_CONTROL_DRIVER_DEADLINE_MS = 2_000
export type NativeElementIdentity = object
export type ComputerControlTargetKind =
| 'standard'
| 'protected'
| 'password'
| 'otp'
| 'payment'
| 'account_security'
| 'privilege'
| 'os_permission'
export type DriverWindowIdentity = {
pid: number
processStartTime: number
windowIdentity: string
}
export type DriverElement = {
nativeIdentity: NativeElementIdentity
role: ComputerControlElementRole
name: string
enabled: boolean
focused: boolean
targetKind: ComputerControlTargetKind
}
export type DriverObservation = {
window: DriverWindowIdentity
windowTitle: string
elements: DriverElement[]
}
export interface ComputerControlDriver {
readonly available: boolean
observe(signal: AbortSignal): Promise<DriverObservation>
getForegroundWindow(signal: AbortSignal): Promise<DriverWindowIdentity>
resolveElement(
nativeIdentity: NativeElementIdentity,
signal: AbortSignal
): Promise<DriverElement | undefined>
focusElement(
nativeIdentity: NativeElementIdentity,
signal: AbortSignal
): Promise<boolean>
inject(
nativeIdentity: NativeElementIdentity,
action: ComputerControlAction,
signal: AbortSignal
): Promise<void>
releaseInjectedInput(signal: AbortSignal): Promise<void>
}
export class UnavailableComputerControlDriver
implements ComputerControlDriver
{
readonly available = false
async observe(): Promise<DriverObservation> {
throw new Error('Computer control driver is unavailable')
}
async getForegroundWindow(): Promise<DriverWindowIdentity> {
throw new Error('Computer control driver is unavailable')
}
async resolveElement(): Promise<DriverElement | undefined> {
throw new Error('Computer control driver is unavailable')
}
async focusElement(): Promise<boolean> {
throw new Error('Computer control driver is unavailable')
}
async inject(): Promise<void> {
throw new Error('Computer control driver is unavailable')
}
async releaseInjectedInput(): Promise<void> {
// There cannot be injected input when no driver exists.
}
}
+31
View File
@@ -0,0 +1,31 @@
import type {
ComputerControlError,
ComputerControlErrorCode
} from '../../shared/computer-control-contracts'
export class ComputerControlFailure extends Error {
readonly code: ComputerControlErrorCode
readonly retryable: boolean
constructor(
code: ComputerControlErrorCode,
message: string,
retryable = false
) {
super(message)
this.name = 'ComputerControlFailure'
this.code = code
this.retryable = retryable
}
toContract(): ComputerControlError {
return {
code: this.code,
message: this.message.slice(0, 256) || 'Computer control failed',
retryable: this.retryable
}
}
}
export const cancellationFailure = (): ComputerControlFailure =>
new ComputerControlFailure('cancelled', 'Computer control was cancelled')
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
COMPUTER_CONTROL_LEASE_ABSOLUTE_MS,
COMPUTER_CONTROL_LEASE_IDLE_MS,
ComputerControlLeaseStore,
type ComputerControlLeaseBinding
} from './lease-store'
const binding: ComputerControlLeaseBinding = {
taskId: 'task-1',
conversationId: 'conversation-1',
pid: 42,
processStartTime: 100,
windowIdentity: 'window-1'
}
describe('ComputerControlLeaseStore', () => {
it('enforces idle and absolute expiry', () => {
let now = 1_000
let sequence = 0
const store = new ComputerControlLeaseStore(
() => now,
() => `lease_identifier_${++sequence}`
)
const idleLease = store.create(binding)
now += COMPUTER_CONTROL_LEASE_IDLE_MS
expect(() =>
store.validate(idleLease.leaseId, binding)
).toThrow('expired')
now = 1_000
const absoluteLease = store.create(binding)
for (let elapsed = 60_000; elapsed < COMPUTER_CONTROL_LEASE_ABSOLUTE_MS; elapsed += 60_000) {
now = 1_000 + elapsed
if (elapsed < COMPUTER_CONTROL_LEASE_ABSOLUTE_MS) {
store.validate(absoluteLease.leaseId, binding)
}
}
now = 1_000 + COMPUTER_CONTROL_LEASE_ABSOLUTE_MS
expect(() =>
store.validate(absoluteLease.leaseId, binding)
).toThrow('expired')
})
it('revokes and fails on task, conversation, PID, process, or window mismatch', () => {
const variants: ComputerControlLeaseBinding[] = [
{ ...binding, taskId: 'task-2' },
{ ...binding, conversationId: 'conversation-2' },
{ ...binding, pid: 43 },
{ ...binding, processStartTime: 101 },
{ ...binding, windowIdentity: 'window-2' }
]
let sequence = 0
const store = new ComputerControlLeaseStore(
() => 1_000,
() => `lease_identifier_${++sequence}`
)
for (const mismatch of variants) {
const lease = store.create(binding)
expect(() =>
store.validate(lease.leaseId, mismatch)
).toThrow('binding changed')
expect(store.peek(lease.leaseId)).toBeUndefined()
}
})
it('supports explicit lease and task revocation', () => {
let sequence = 0
const store = new ComputerControlLeaseStore(
() => 1_000,
() => `lease_identifier_${++sequence}`
)
const first = store.create(binding)
const second = store.create({ ...binding, windowIdentity: 'window-2' })
const other = store.create({ ...binding, taskId: 'task-2' })
store.revoke(first.leaseId)
expect(store.peek(first.leaseId)).toBeUndefined()
store.revokeTask(binding.taskId)
expect(store.peek(second.leaseId)).toBeUndefined()
expect(store.peek(other.leaseId)).toBeDefined()
})
it('prunes all expired leases during create and validate', () => {
let now = 1_000
let sequence = 0
const store = new ComputerControlLeaseStore(
() => now,
() => `lease_identifier_${++sequence}`
)
const expiredBeforeCreate = store.create(binding)
now += COMPUTER_CONTROL_LEASE_IDLE_MS
const current = store.create(binding)
expect(store.peek(expiredBeforeCreate.leaseId)).toBeUndefined()
const expiresBeforeValidate = store.create({
...binding,
taskId: 'task-2'
})
now += COMPUTER_CONTROL_LEASE_IDLE_MS - 1
store.validate(current.leaseId, binding)
now += 1
const newest = store.create({
...binding,
taskId: 'task-3'
})
store.validate(newest.leaseId, {
...binding,
taskId: 'task-3'
})
expect(store.peek(expiresBeforeValidate.leaseId)).toBeUndefined()
})
it('enforces a validated hard lease capacity by evicting oldest entries', () => {
let sequence = 0
const store = new ComputerControlLeaseStore(
() => 1_000,
() => `lease_identifier_${++sequence}`,
{ maximumLeases: 2 }
)
const first = store.create(binding)
const second = store.create({
...binding,
windowIdentity: 'window-2'
})
const third = store.create({
...binding,
windowIdentity: 'window-3'
})
expect(store.peek(first.leaseId)).toBeUndefined()
expect(store.peek(second.leaseId)).toBeDefined()
expect(store.peek(third.leaseId)).toBeDefined()
expect(
() =>
new ComputerControlLeaseStore(Date.now, undefined, {
maximumLeases: 0
})
).toThrow('capacity')
})
})
+145
View File
@@ -0,0 +1,145 @@
import { randomUUID } from 'node:crypto'
import { ComputerControlFailure } from './errors'
import type { DriverWindowIdentity } from './driver'
export const COMPUTER_CONTROL_LEASE_IDLE_MS = 5 * 60 * 1_000
export const COMPUTER_CONTROL_LEASE_ABSOLUTE_MS = 10 * 60 * 1_000
export const COMPUTER_CONTROL_MAXIMUM_LEASES = 1_000
export type ComputerControlLeaseBinding = DriverWindowIdentity & {
taskId: string
conversationId: string
}
export type ComputerControlLease = ComputerControlLeaseBinding & {
leaseId: string
createdAt: number
lastUsedAt: number
}
export type ComputerControlLeaseStoreOptions = {
maximumLeases?: number
}
export class ComputerControlLeaseStore {
private readonly leases = new Map<string, ComputerControlLease>()
private readonly maximumLeases: number
constructor(
private readonly now: () => number = Date.now,
private readonly createId: () => string = randomUUID,
options: ComputerControlLeaseStoreOptions = {}
) {
this.maximumLeases =
options.maximumLeases ?? COMPUTER_CONTROL_MAXIMUM_LEASES
if (
!Number.isSafeInteger(this.maximumLeases) ||
this.maximumLeases < 1 ||
this.maximumLeases > 10_000
) {
throw new Error('Invalid computer control lease capacity')
}
}
create(binding: ComputerControlLeaseBinding): ComputerControlLease {
const timestamp = this.now()
this.pruneExpired(timestamp)
while (this.leases.size >= this.maximumLeases) {
const oldest = this.leases.keys().next().value
if (oldest === undefined) {
break
}
this.leases.delete(oldest)
}
const lease: ComputerControlLease = {
...binding,
leaseId: this.createId(),
createdAt: timestamp,
lastUsedAt: timestamp
}
this.leases.set(lease.leaseId, lease)
return { ...lease }
}
peek(leaseId: string): ComputerControlLease | undefined {
const lease = this.leases.get(leaseId)
return lease ? { ...lease } : undefined
}
validate(
leaseId: string,
binding: ComputerControlLeaseBinding
): ComputerControlLease {
const timestamp = this.now()
const requestedLeaseExpired = this.isExpired(
this.leases.get(leaseId),
timestamp
)
this.pruneExpired(timestamp)
const lease = this.leases.get(leaseId)
if (!lease) {
if (requestedLeaseExpired) {
throw new ComputerControlFailure(
'lease_expired',
'Computer control lease expired'
)
}
throw new ComputerControlFailure(
'lease_not_found',
'Computer control lease was not found'
)
}
if (
lease.taskId !== binding.taskId ||
lease.conversationId !== binding.conversationId ||
lease.pid !== binding.pid ||
lease.processStartTime !== binding.processStartTime ||
lease.windowIdentity !== binding.windowIdentity
) {
this.leases.delete(leaseId)
throw new ComputerControlFailure(
'lease_mismatch',
'Computer control lease binding changed'
)
}
lease.lastUsedAt = timestamp
return { ...lease }
}
revoke(leaseId: string): void {
this.leases.delete(leaseId)
}
revokeTask(taskId: string): string[] {
const revoked: string[] = []
for (const [leaseId, lease] of this.leases) {
if (lease.taskId === taskId) {
this.leases.delete(leaseId)
revoked.push(leaseId)
}
}
return revoked
}
private pruneExpired(timestamp: number): void {
for (const [leaseId, lease] of this.leases) {
if (this.isExpired(lease, timestamp)) {
this.leases.delete(leaseId)
}
}
}
private isExpired(
lease: ComputerControlLease | undefined,
timestamp: number
): boolean {
return (
lease !== undefined &&
(timestamp - lease.lastUsedAt >= COMPUTER_CONTROL_LEASE_IDLE_MS ||
timestamp - lease.createdAt >=
COMPUTER_CONTROL_LEASE_ABSOLUTE_MS)
)
}
}
@@ -0,0 +1,191 @@
import {
createHmac,
randomBytes,
randomUUID
} from 'node:crypto'
import type {
ComputerControlObservation,
ComputerControlRisk
} from '../../shared/computer-control-contracts'
import type {
DriverElement,
DriverObservation,
NativeElementIdentity
} from './driver'
import { ComputerControlFailure } from './errors'
export const COMPUTER_CONTROL_OBSERVATION_FRESH_MS = 3_000
export const COMPUTER_CONTROL_MAX_ELEMENTS = 200
type StoredElement = {
nativeIdentity: NativeElementIdentity
driverElement: DriverElement
risk: ComputerControlRisk
}
type StoredObservation = {
contract: ComputerControlObservation
elements: Map<string, StoredElement>
consumed: boolean
}
const boundedDisplayText = (
value: string,
maximumLength: number,
fallback = ''
): string => {
const clean = [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return code > 31 && code !== 127
})
.join('')
.trim()
.slice(0, maximumLength)
return clean || fallback
}
export class ComputerControlPerceptionStore {
private readonly observations = new Map<string, StoredObservation>()
private readonly revisions = new Map<string, number>()
constructor(
private readonly now: () => number = Date.now,
private readonly createId: () => string = randomUUID,
private readonly hmacKey: Buffer = randomBytes(32)
) {}
create(
leaseId: string,
observation: DriverObservation,
classify: (element: DriverElement) => ComputerControlRisk
): ComputerControlObservation {
if (observation.elements.length > COMPUTER_CONTROL_MAX_ELEMENTS) {
throw new ComputerControlFailure(
'internal_error',
'Driver observation exceeded the element limit'
)
}
const revision = (this.revisions.get(leaseId) ?? 0) + 1
this.revisions.set(leaseId, revision)
this.deleteLeaseObservations(leaseId)
const observationId = this.createId()
const storedElements = new Map<string, StoredElement>()
const elements = observation.elements.map((element, index) => {
const risk = classify(element)
const ref = createHmac('sha256', this.hmacKey)
.update(`${observationId}\u0000${revision}\u0000${index}`)
.digest('base64url')
storedElements.set(ref, {
nativeIdentity: element.nativeIdentity,
driverElement: element,
risk
})
return {
ref,
role: element.role,
name: boundedDisplayText(element.name, 256, element.role),
enabled: element.enabled,
focused: element.focused,
risk,
blocked: risk === 'forbidden'
}
})
const contract: ComputerControlObservation = {
observationId,
leaseId,
revision,
capturedAt: this.now(),
windowTitle: boundedDisplayText(
observation.windowTitle,
256
),
elements
}
this.observations.set(observationId, {
contract,
elements: storedElements,
consumed: false
})
return structuredClone(contract)
}
resolve(
leaseId: string,
observationId: string,
revision: number,
elementRef: string
): StoredElement {
const observation = this.observations.get(observationId)
if (!observation || observation.contract.leaseId !== leaseId) {
throw new ComputerControlFailure(
'observation_not_found',
'Computer control observation was not found'
)
}
if (observation.contract.revision !== revision) {
throw new ComputerControlFailure(
'observation_stale',
'Computer control observation revision changed',
true
)
}
if (
this.now() - observation.contract.capturedAt >=
COMPUTER_CONTROL_OBSERVATION_FRESH_MS
) {
throw new ComputerControlFailure(
'observation_stale',
'Computer control observation is stale',
true
)
}
if (observation.consumed) {
throw new ComputerControlFailure(
'observation_consumed',
'Computer control observation was already used'
)
}
const element = observation.elements.get(elementRef)
if (!element) {
throw new ComputerControlFailure(
'element_not_found',
'Computer control element was not found'
)
}
return element
}
consume(observationId: string): void {
const observation = this.observations.get(observationId)
if (!observation) {
throw new ComputerControlFailure(
'observation_not_found',
'Computer control observation was not found'
)
}
if (observation.consumed) {
throw new ComputerControlFailure(
'observation_consumed',
'Computer control observation was already used'
)
}
observation.consumed = true
}
revokeLease(leaseId: string): void {
this.deleteLeaseObservations(leaseId)
this.revisions.delete(leaseId)
}
private deleteLeaseObservations(leaseId: string): void {
for (const [observationId, observation] of this.observations) {
if (observation.contract.leaseId === leaseId) {
this.observations.delete(observationId)
}
}
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { DriverElement } from './driver'
import {
classifyComputerControlAction,
maximumRisk
} from './risk-policy'
const identity = {}
const element: DriverElement = {
nativeIdentity: identity,
role: 'link',
name: 'Next page',
enabled: true,
focused: true,
targetKind: 'standard'
}
describe('computer control risk policy', () => {
it('classifies semantic actions and lets classification only raise risk', () => {
expect(
classifyComputerControlAction(
{ kind: 'activate', elementRef: 'opaque_identifier_123456' },
element
)
).toBe('navigate')
expect(
maximumRisk('commit', 'observe')
).toBe('commit')
expect(maximumRisk('input', 'commit')).toBe('commit')
})
it.each([
'protected',
'password',
'otp',
'payment',
'account_security',
'privilege',
'os_permission'
] as const)('forbids %s targets', (targetKind) => {
expect(
classifyComputerControlAction(
{ kind: 'activate', elementRef: 'opaque_identifier_123456' },
{ ...element, targetKind }
)
).toBe('forbidden')
})
})
+79
View File
@@ -0,0 +1,79 @@
import type {
ComputerControlAction,
ComputerControlElementRole,
ComputerControlRisk
} from '../../shared/computer-control-contracts'
import type {
ComputerControlTargetKind,
DriverElement
} from './driver'
const RISK_ORDER: Record<ComputerControlRisk, number> = {
observe: 0,
navigate: 1,
input: 2,
commit: 3,
forbidden: 4
}
const FORBIDDEN_TARGETS: ReadonlySet<ComputerControlTargetKind> =
new Set([
'protected',
'password',
'otp',
'payment',
'account_security',
'privilege',
'os_permission'
])
const activationRisk = (
role: ComputerControlElementRole
): ComputerControlRisk => {
switch (role) {
case 'button':
case 'checkbox':
case 'radio':
return 'commit'
case 'textbox':
case 'combobox':
case 'scrollarea':
return 'observe'
default:
return 'navigate'
}
}
export const maximumRisk = (
baseline: ComputerControlRisk,
classification?: ComputerControlRisk
): ComputerControlRisk =>
classification &&
RISK_ORDER[classification] > RISK_ORDER[baseline]
? classification
: baseline
export const classifyComputerControlAction = (
action: ComputerControlAction,
element: DriverElement,
driverClassification?: ComputerControlRisk
): ComputerControlRisk => {
if (FORBIDDEN_TARGETS.has(element.targetKind)) {
return 'forbidden'
}
let baseline: ComputerControlRisk
switch (action.kind) {
case 'replace_text':
case 'select_option':
baseline = 'input'
break
case 'scroll':
baseline = 'navigate'
break
case 'activate':
baseline = activationRisk(element.role)
break
}
return maximumRisk(baseline, driverClassification)
}
+30 -12
View File
@@ -37,6 +37,7 @@ import type {
ContinueHostLauncher
} from './agent/continue-host-adapter'
import { resolvePortableUserDataPath } from './portable-user-data'
import { BrowserService } from './browser/browser-service'
const shortcut = 'CommandOrControl+Shift+Space'
const portableUserDataPath = resolvePortableUserDataPath({
@@ -63,6 +64,7 @@ let removeIpcHandlers: (() => Promise<void>) | undefined
let runtime: AgentRuntimeController | undefined
let knowledgeService: KnowledgeService | undefined
let assistantDatabase: AssistantDatabase | undefined
let browserService: BrowserService | undefined
function createEmbeddingProvider(
settings: ResolvedRuntimeSettings
@@ -227,6 +229,7 @@ if (hasSingleInstanceLock) {
join(app.getPath('userData'), 'skills', 'imported'),
secureCipher
)
browserService = new BrowserService()
const bundledRuntimePaths = resolveBundledRuntimePaths({
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
@@ -261,15 +264,21 @@ if (hasSingleInstanceLock) {
: useOpenCode
? ('opencode' as const)
: ('model' as const)
const [skillInstructions, mcpServers] = await Promise.all([
capabilityService.getSkillInstructions(
target,
target === 'continue' ? 12_000 : 48_000
),
target === 'model'
? capabilityService.getResolvedMcpServers('model')
: Promise.resolve([])
])
const [skillInstructions, mcpServers, browserCapability] =
await Promise.all([
capabilityService.getSkillInstructions(
target,
target === 'continue' ? 12_000 : 48_000
),
target === 'model'
? capabilityService.getResolvedMcpServers('model')
: Promise.resolve([]),
target === 'model'
? capabilityService.getComputerCapabilityStatus(
'host-browser-control'
)
: Promise.resolve(undefined)
])
return createAgentRuntime(defaultWorkspace, settings, {
skillInstructions,
mcpServers,
@@ -278,7 +287,11 @@ if (hasSingleInstanceLock) {
'continue-host'
),
bundledRuntimePaths,
continueHostLauncher: launchContinueHost
continueHostLauncher: launchContinueHost,
browserService:
browserCapability?.enabled && browserCapability.supported
? browserService
: undefined
})
}
runtime = new AgentRuntimeController(
@@ -316,7 +329,11 @@ if (hasSingleInstanceLock) {
await createConfiguredRuntime()
)
}
}
},
async () => {
await browserService?.clearSessions()
},
browserService
)
loadMainWindow(mainWindow)
@@ -354,7 +371,8 @@ app.on('before-quit', (event) => {
tray?.destroy()
await Promise.allSettled([
runtime?.dispose(),
knowledgeService?.dispose()
knowledgeService?.dispose(),
browserService?.dispose()
])
} finally {
assistantDatabase?.close()
+352 -15
View File
@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type { BrowserLiveState } from '../shared/contracts'
import { registerIpcHandlers } from './ipc'
type InvokeHandler = (event: unknown, input?: unknown) => unknown
@@ -20,6 +21,131 @@ const electronMocks = vi.hoisted(() => {
}
})
describe('registerIpcHandlers computer capabilities', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
skills: [],
mcpServers: [],
computerCapabilities: [],
browserProfiles: { profiles: [], defaultProfileId: null }
}
const capabilityService = {
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot),
diagnoseComputerCapability: vi.fn(async () => ({
capabilityId: 'host-browser-control',
status: 'disabled',
checkedAt: '2026-08-05T00:00:00.000Z',
checks: []
}))
}
const onRuntimeSettingsChanged = vi.fn(async () => {})
const releaseConversation = vi.fn(async () => {})
let browserStateListener:
| ((state: BrowserLiveState) => void)
| undefined
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
capabilityService as never,
{ clear: vi.fn() } as never,
{} as never,
{ claimDueSchedules: vi.fn(() => []) } as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
{
releaseConversation,
onState: (listener) => {
browserStateListener = listener
return vi.fn()
}
}
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleComputer
)?.(event, {
capabilityId: 'host-browser-control',
enabled: true
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setComputerCapabilityEnabled
).toHaveBeenCalledWith('host-browser-control', true)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'ready',
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.browserState,
expect.objectContaining({
conversationId: 'browser-conversation',
status: 'ready'
})
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation'
)
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置',
executable: 'C:\\unsafe.exe'
})
).toThrow()
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesDiagnoseComputer
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
'host-browser-control'
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'GoodBuddy'),
@@ -273,14 +399,19 @@ describe('registerIpcHandlers agent terminal state', () => {
vi.clearAllMocks()
})
function createHarness(runtime: Record<string, unknown>) {
function createHarness(
runtime: Record<string, unknown>,
onBeforeClearLocalData?: () => Promise<void>,
toolApproval: 'always' | 'policy' = 'always'
) {
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
createTask: vi.fn(),
appendTaskEvent: vi.fn(),
updateTaskStatus: vi.fn(),
createTextArtifact: vi.fn(),
upsertModelUsageCall: vi.fn()
upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn()
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -309,7 +440,7 @@ describe('registerIpcHandlers agent terminal state', () => {
'CommandOrControl+Shift+Space',
{
getResolvedSettings: vi.fn(async () => ({
toolApproval: 'always'
toolApproval
}))
} as never,
{} as never,
@@ -318,12 +449,16 @@ describe('registerIpcHandlers agent terminal state', () => {
assistantDatabase as never,
approvalBroker as never,
{} as never,
vi.fn(async () => {})
vi.fn(async () => {}),
onBeforeClearLocalData
)
return {
approvalBroker,
assistantDatabase,
dispose,
clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData
),
handler: electronMocks.handlers.get(ipcChannels.agentRun),
webContents
}
@@ -336,6 +471,62 @@ describe('registerIpcHandlers agent terminal state', () => {
senderFrame: webContents.mainFrame
})
it('aborts active work and clears browser sessions before assistant data', async () => {
const lifecycle: string[] = []
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
_request: unknown,
signal: AbortSignal
): AsyncGenerator<never, void, void> {
markStarted()
await new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
lifecycle.push('aborted')
reject(signal.reason)
},
{ once: true }
)
})
yield undefined as never
}
}
const harness = createHarness(runtime, async () => {
lifecycle.push('browser-cleared')
})
vi.mocked(
harness.assistantDatabase.clearAssistantData
).mockImplementation(() => {
lifecycle.push('assistant-cleared')
})
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-clear',
prompt: 'keep running',
workMode: 'execute'
})
await started
await harness.clearHandler?.(trustedEvent(harness.webContents))
expect(lifecycle).toEqual([
'aborted',
'browser-cleared',
'assistant-cleared'
])
await harness.dispose()
})
it('marks a request failed when a tool fails before runtime done', async () => {
const runtime = {
capability: 'chat',
@@ -386,6 +577,51 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('allows a completed request after a recoverable tool failure', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-recoverable',
name: '浏览器输入',
state: 'recoverable',
summary: '直连模型工具需要刷新后重试:浏览器输入'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'retry browser input',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
await harness.dispose()
})
it.each(['opencode', 'continue'] as const)(
'normalizes interactive %s requests to Execute without GoodBuddy approval',
async (runtimeId) => {
@@ -438,6 +674,55 @@ describe('registerIpcHandlers agent terminal state', () => {
}
)
it.each(['model', 'opencode'] as const)(
'normalizes legacy interactive Plan requests to Ask for %s',
async (runtimeId) => {
let receivedRequest:
| { requestId: string; prompt: string; workMode?: string }
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: string
workMode?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'draft a plan',
workMode: 'plan'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest?.workMode).toBe('ask')
expect(receivedRequest?.prompt).toContain('Work mode: Ask.')
expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.')
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId, workMode: 'ask' })
)
await harness.dispose()
}
)
it('rejects Execute before creating a task on an unsupported runtime', async () => {
const runtime = {
capability: 'chat',
@@ -461,7 +746,7 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('routes direct-model tool calls through the GoodBuddy approval broker', async () => {
it('authorizes direct-model Execute tools without approval events or broker prompts', async () => {
let receivedAuthorize:
| ((
request: {
@@ -471,6 +756,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}
) => Promise<string>)
| undefined
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
@@ -484,7 +770,7 @@ describe('registerIpcHandlers agent terminal state', () => {
authorize: typeof receivedAuthorize
) {
receivedAuthorize = authorize
await authorize?.({
decision = await authorize?.({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
@@ -501,7 +787,6 @@ describe('registerIpcHandlers agent terminal state', () => {
}
}
const harness = createHarness(runtime)
harness.approvalBroker.request.mockResolvedValue('once')
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
@@ -517,14 +802,66 @@ describe('registerIpcHandlers agent terminal state', () => {
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedAuthorize).toEqual(expect.any(Function))
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
expect.objectContaining({
requestId,
conversationId: 'conversation-1',
scopeKey: 'model:builtin:workspace_read_text'
}),
expect.any(AbortSignal),
expect.any(Function)
expect(decision).toBe('once')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('denies direct-model Execute tools when the deny-all policy is selected', async () => {
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
decision = await authorize({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
})
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime, undefined, 'policy')
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: '读取文件',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(decision).toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
+153 -46
View File
@@ -12,6 +12,7 @@ import { z } from 'zod'
import {
approvalDecisionSchema,
agentRequestSchema,
browserStopRequestSchema,
knowledgeCreateSchema,
knowledgeEntityUpdateSchema,
knowledgeIdSchema,
@@ -26,23 +27,32 @@ import {
type AgentRuntimeDetection,
type AgentEvent,
type AppInfo,
type BrowserLiveState,
type KnowledgeSnapshot,
type RuntimeSettings
} from '../shared/contracts'
import { ipcChannels } from '../shared/ipc-channels'
import {
browserProfileCreateInputSchema,
browserProfileRenameInputSchema,
browserProfileSelectionInputSchema,
computerCapabilityConfigInputSchema,
computerCapabilityIdSchema,
computerCapabilityToggleInputSchema,
mcpServerIdSchema,
mcpServerInputSchema,
skillAssignmentsInputSchema,
skillIdSchema,
skillToggleInputSchema,
type CapabilitySnapshot,
type CapabilityDiagnosticReport,
type McpServerTestResult
} from '../shared/capability-contracts'
import {
assistantIdSchema,
conversationSnapshotsSchema,
memoryCreateSchema,
normalizeInteractiveWorkMode,
projectCreateSchema,
scheduleCreateSchema,
expertCreateSchema,
@@ -131,6 +141,12 @@ const taskStatusRequestSchema = z
status: z.enum(['completed', 'cancelled'])
})
.strict()
const expertUpdateRequestSchema = z
.object({
expertId: assistantIdSchema,
input: expertCreateSchema
})
.strict()
const imageMimeTypes: Record<
string,
@@ -346,7 +362,12 @@ export function registerIpcHandlers(
assistantDatabase: AssistantDatabase,
approvalBroker: ToolApprovalBroker,
bundledRuntimePaths: BundledRuntimePaths,
onRuntimeSettingsChanged: () => Promise<void>
onRuntimeSettingsChanged: () => Promise<void>,
onBeforeClearLocalData?: () => Promise<void>,
browserControl?: {
releaseConversation(conversationId: string): Promise<void>
onState(listener: (state: BrowserLiveState) => void): () => void
}
): () => Promise<void> {
const activeRequests = new Map<string, AbortController>()
const heartbeatControllers = new Set<AbortController>()
@@ -364,6 +385,7 @@ export function registerIpcHandlers(
const channels = Object.values(ipcChannels).filter(
(channel) =>
channel !== ipcChannels.agentEvent &&
channel !== ipcChannels.browserState &&
channel !== ipcChannels.conversationNew &&
channel !== ipcChannels.settingsOpen &&
channel !== ipcChannels.windowMaximizedChanged
@@ -383,6 +405,11 @@ export function registerIpcHandlers(
}
window.on('maximize', notifyMaximizedChanged)
window.on('unmaximize', notifyMaximizedChanged)
const removeBrowserStateListener = browserControl?.onState((state) => {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.browserState, state)
}
})
const abortActiveRequests = (reason: string): void => {
for (const controller of activeRequests.values()) {
@@ -936,6 +963,7 @@ export function registerIpcHandlers(
heartbeatControllers.clear()
approvalBroker.clear()
await Promise.allSettled([...activeExecutions])
await onBeforeClearLocalData?.()
assistantDatabase.clearAssistantData()
} finally {
executionPaused = false
@@ -947,18 +975,27 @@ export function registerIpcHandlers(
return runtime.getStatus()
})
ipcMain.handle(ipcChannels.browserStop, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserStopRequestSchema.parse(input)
await browserControl?.releaseConversation(request.conversationId)
})
ipcMain.handle(ipcChannels.agentRun, (event, input: unknown) => {
assertTrustedSender(event, window)
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新任务')
}
const parsedInput = agentRequestSchema.parse(input)
const normalizedWorkMode = normalizeInteractiveWorkMode(
parsedInput.workMode
)
const agentRuntimeSelected = isAgentRuntime(runtime)
const parsedRequest = {
...parsedInput,
workMode: agentRuntimeSelected
workMode: agentRuntimeSelected && parsedInput.workMode !== 'plan'
? ('execute' as const)
: (parsedInput.workMode ?? ('ask' as const))
: normalizedWorkMode
}
if (
parsedRequest.workMode === 'execute' &&
@@ -977,13 +1014,11 @@ export function registerIpcHandlers(
? ''
: enrichedRequest.workMode === 'ask'
? 'Work mode: Ask. Do not call tools or make changes. Answer using only the explicitly supplied context.'
: enrichedRequest.workMode === 'plan'
? 'Work mode: Plan. Do not call tools or make changes. Produce a concrete reviewable plan and wait for user confirmation.'
: enrichedRequest.workMode === 'execute'
? agentRuntimeSelected
? 'Work mode: Execute. Follow the user request. Agent Runtime tool calls execute without GoodBuddy approval and must remain visible in runtime activity.'
: 'Work mode: Execute. Follow the approved request; all tool actions remain subject to GoodBuddy permission controls.'
: ''
: enrichedRequest.workMode === 'execute'
? agentRuntimeSelected
? 'Work mode: Execute. Follow the user request. Agent Runtime tool calls execute without GoodBuddy approval and must remain visible in runtime activity.'
: 'Work mode: Execute. Follow the approved request. Enabled direct-model tools are authorized for this interactive run and must remain visible in runtime activity.'
: ''
const expertInstruction =
enrichedRequest.expertId && !imageGeneration
? `Selected expert role:\n${
@@ -1024,41 +1059,17 @@ export function registerIpcHandlers(
Extract<AgentEvent, { type: 'tool' }>
>()
try {
const authorize: RuntimeAuthorizer = async (approvalRequest) => {
assistantDatabase.updateTaskStatus(
request.requestId,
'waiting_approval'
)
const settings = await settingsStore.getResolvedSettings()
try {
return await approvalBroker.request(
{
...approvalRequest,
policy:
settings.toolApproval === 'policy'
? 'policy'
: undefined,
requestId: request.requestId,
conversationId: request.conversationId
},
controller.signal,
(approvalEvent) => {
if (!window.isDestroyed()) {
window.webContents.send(
ipcChannels.agentEvent,
approvalEvent
)
}
}
)
} finally {
if (!controller.signal.aborted) {
assistantDatabase.updateTaskStatus(
request.requestId,
'running'
)
}
}
controller.signal.throwIfAborted()
const executeToolPolicy =
request.workMode === 'execute' && !agentRuntimeSelected
? (await settingsStore.getResolvedSettings()).toolApproval
: 'policy'
const authorize: RuntimeAuthorizer = async () => {
controller.signal.throwIfAborted()
return request.workMode === 'execute' &&
executeToolPolicy !== 'policy'
? 'once'
: 'deny'
}
const eventStream = request.teamMode
? runExpertTeam(request, controller.signal)
@@ -1105,7 +1116,9 @@ export function registerIpcHandlers(
}
if (publicEvent.type === 'done') {
const unsuccessfulTool = [...toolStates.values()].find(
(tool) => tool.state !== 'completed'
(tool) =>
tool.state !== 'completed' &&
tool.state !== 'recoverable'
)
if (unsuccessfulTool) {
throw new Error(
@@ -1623,6 +1636,17 @@ export function registerIpcHandlers(
return assistantDatabase.createExpert(expertCreateSchema.parse(input))
})
ipcMain.handle(ipcChannels.expertsUpdate, (event, input: unknown) => {
assertTrustedSender(event, window)
const value = expertUpdateRequestSchema.parse(input)
return assistantDatabase.updateExpert(value.expertId, value.input)
})
ipcMain.handle(ipcChannels.expertsRemove, (event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.removeExpert(assistantIdSchema.parse(input))
})
ipcMain.handle(
ipcChannels.capabilitiesSnapshot,
(event): Promise<CapabilitySnapshot> => {
@@ -1721,6 +1745,88 @@ export function registerIpcHandlers(
}
)
ipcMain.handle(
ipcChannels.capabilitiesToggleComputer,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = computerCapabilityToggleInputSchema.parse(input)
return refreshCapabilities(
capabilityService.setComputerCapabilityEnabled(
value.capabilityId,
value.enabled
)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesConfigureComputer,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = computerCapabilityConfigInputSchema.parse(input)
return refreshCapabilities(
capabilityService.setComputerCapabilityBrowserProfile(
value.capabilityId,
value.browserProfileId
)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesDiagnoseComputer,
(event, input: unknown): Promise<CapabilityDiagnosticReport> => {
assertTrustedSender(event, window)
return capabilityService.diagnoseComputerCapability(
computerCapabilityIdSchema.parse(input)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesCreateBrowserProfile,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = browserProfileCreateInputSchema.parse(input)
return refreshCapabilities(
capabilityService.createBrowserProfile(value.name)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesRenameBrowserProfile,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = browserProfileRenameInputSchema.parse(input)
return refreshCapabilities(
capabilityService.renameBrowserProfile(value.profileId, value.name)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesDefaultBrowserProfile,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = browserProfileSelectionInputSchema.parse(input)
return refreshCapabilities(
capabilityService.setDefaultBrowserProfile(value.profileId)
)
}
)
ipcMain.handle(
ipcChannels.capabilitiesRemoveBrowserProfile,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = browserProfileSelectionInputSchema.parse(input)
return refreshCapabilities(
capabilityService.removeBrowserProfile(value.profileId)
)
}
)
ipcMain.handle(ipcChannels.contextSelectFiles, (event) => {
assertTrustedSender(event, window)
return contextManager.selectFiles(window)
@@ -2033,6 +2139,7 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
removeBrowserStateListener?.()
clearInterval(scheduleInterval)
remoteDelegation?.stop()
abortActiveRequests('应用正在退出')
@@ -0,0 +1,280 @@
import { describe, expect, it, vi } from 'vitest'
import {
AtspiSemanticCore,
type AtspiRawNode,
type AtspiTransport
} from './atspi-semantic-core'
const transportFor = (
nodes: Record<string, AtspiRawNode>,
children: Record<string, string[]>
): AtspiTransport => ({
readNode: vi.fn(async (reference) => {
const node = nodes[reference]
if (!node) {
throw new Error('missing node')
}
return node
}),
listChildren: vi.fn(async (reference) => children[reference] ?? []),
invoke: vi.fn(async () => true),
setText: vi.fn(async () => true),
select: vi.fn(async () => true),
focus: vi.fn(async () => true)
})
const node = (
nativeReference: string,
overrides: Partial<AtspiRawNode> = {}
): AtspiRawNode => ({
nativeReference,
owner: 'private-owner',
window: 'private-window',
role: 'push button',
name: 'Action',
states: ['enabled', 'enabled'],
actions: ['Click'],
geometry: { x: -10, y: 20, width: 100, height: 30 },
...overrides
})
describe('AtspiSemanticCore', () => {
it('traverses a cyclic tree safely and returns only normalized opaque data', async () => {
const transport = transportFor(
{
'/raw/root': node('/raw/root', {
role: 'Application',
name: ' Demo\u0000 app '
}),
'/raw/child': node('/raw/child')
},
{
'/raw/root': ['/raw/child'],
'/raw/child': ['/raw/root']
}
)
let token = 0
const core = new AtspiSemanticCore(transport, {
createToken: () => `opaque-${++token}`
})
const tree = await core.snapshot('/raw/root', new AbortController().signal)
const serialized = JSON.stringify(tree)
expect(tree.truncated).toBe(true)
expect(tree.root).toMatchObject({
ref: 'opaque-1',
role: 'application',
name: 'Demo app',
states: ['enabled'],
children: [
{
ref: 'opaque-2',
role: 'push-button',
actions: ['click'],
children: []
}
]
})
expect(serialized).not.toContain('/raw/')
expect(serialized).not.toContain('private-owner')
expect(serialized).not.toContain('private-window')
})
it('bounds node count, depth, children, text, and invalid geometry', async () => {
const transport = transportFor(
{
root: node('root', {
text: 'abcdef',
geometry: { x: 0, y: 0, width: -1, height: 10 }
}),
one: node('one'),
two: node('two')
},
{ root: ['one', 'two'] }
)
let token = 0
const core = new AtspiSemanticCore(transport, {
createToken: () => `ref-${++token}`,
maximumNodes: 2,
maximumChildrenPerNode: 1,
maximumTextLength: 3
})
const tree = await core.snapshot('root', new AbortController().signal)
expect(tree.truncated).toBe(true)
expect(tree.root.text).toBe('abc')
expect(tree.root.geometry).toBeUndefined()
expect(tree.root.children).toHaveLength(1)
})
it('redacts and refuses protected and password elements', async () => {
const transport = transportFor(
{
password: node('password', {
role: 'password text',
name: 'bank password',
text: 'hunter2',
value: 123,
password: true
})
},
{}
)
const core = new AtspiSemanticCore(transport, {
createToken: () => 'protected-ref'
})
const tree = await core.snapshot(
'password',
new AbortController().signal
)
expect(tree.root).toMatchObject({
ref: 'protected-ref',
name: '受保护内容',
actions: [],
protected: true
})
expect(tree.root.text).toBeUndefined()
expect(tree.root.value).toBeUndefined()
await expect(
core.focus('protected-ref', new AbortController().signal)
).rejects.toThrow('Protected')
expect(transport.focus).not.toHaveBeenCalled()
})
it('routes semantic operations and rejects stale or unsupported references', async () => {
let now = 10
let token = 0
const transport = transportFor(
{
first: node('first', { owner: 'owner-a', window: 'window-a' }),
second: node('second', { owner: 'owner-b', window: 'window-b' })
},
{}
)
const core = new AtspiSemanticCore(transport, {
now: () => now,
referenceTtlMs: 50,
createToken: () => `ref-${++token}`
})
await core.snapshot('first', new AbortController().signal)
await expect(
core.invoke('ref-1', 'CLICK', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.setText('ref-1', 'new text', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.select('ref-1', new AbortController().signal)
).resolves.toBe(true)
await expect(
core.invoke('ref-1', 'delete', new AbortController().signal)
).rejects.toThrow('unavailable')
core.invalidateWindow('owner-a', 'window-a')
await expect(
core.focus('ref-1', new AbortController().signal)
).rejects.toThrow('stale')
await core.snapshot('second', new AbortController().signal)
core.invalidateOwner('owner-b')
await expect(
core.focus('ref-2', new AbortController().signal)
).rejects.toThrow('stale')
await core.snapshot('first', new AbortController().signal)
now = 60
await expect(
core.focus('ref-3', new AbortController().signal)
).rejects.toThrow('stale')
now = 10
await core.snapshot('first', new AbortController().signal)
core.invalidateRegistryOwner()
await expect(
core.focus('ref-4', new AbortController().signal)
).rejects.toThrow('stale')
})
it('invalidates every reference from snapshot N when snapshot N+1 starts', async () => {
let token = 0
const core = new AtspiSemanticCore(
transportFor(
{
first: node('first'),
second: node('second')
},
{}
),
{ createToken: () => `generation-ref-${++token}` }
)
await core.snapshot('first', new AbortController().signal)
await core.snapshot('second', new AbortController().signal)
await expect(
core.focus('generation-ref-1', new AbortController().signal)
).rejects.toThrow('stale')
await expect(
core.focus('generation-ref-2', new AbortController().signal)
).resolves.toBe(true)
})
it('keeps the opaque reference registry hard bounded across snapshots', async () => {
let token = 0
const core = new AtspiSemanticCore(
transportFor(
{
root: node('root'),
child: node('child'),
extra: node('extra')
},
{ root: ['child', 'extra'] }
),
{
maximumReferences: 2,
createToken: () => `bounded-ref-${++token}`
}
)
for (let index = 0; index < 50; index += 1) {
const tree = await core.snapshot(
'root',
new AbortController().signal
)
expect(tree.truncated).toBe(true)
expect(
(
core as unknown as {
references: Map<string, unknown>
}
).references.size
).toBeLessThanOrEqual(2)
}
})
it('does not surface raw transport errors or accept duplicate opaque tokens', async () => {
const failedTransport = transportFor({ root: node('root') }, {})
failedTransport.readNode = vi.fn(async () => {
throw new Error('private bus path /org/a11y/atspi/accessible/123')
})
const failedCore = new AtspiSemanticCore(failedTransport)
await expect(
failedCore.snapshot('root', new AbortController().signal)
).rejects.toThrow('could not be read')
await expect(
failedCore.snapshot('root', new AbortController().signal)
).rejects.not.toThrow('/org/a11y/')
const duplicateCore = new AtspiSemanticCore(
transportFor(
{ root: node('root'), child: node('child') },
{ root: ['child'] }
),
{ createToken: () => 'duplicate-token' }
)
await expect(
duplicateCore.snapshot('root', new AbortController().signal)
).rejects.toThrow('reference creation failed')
})
})
@@ -0,0 +1,442 @@
import { randomBytes } from 'node:crypto'
export type AtspiNativeReference = string
export type AtspiRect = {
x: number
y: number
width: number
height: number
}
export type AtspiRawNode = {
nativeReference: AtspiNativeReference
owner: string
window: string
role?: string
name?: string
states?: string[]
actions?: string[]
text?: string
value?: number
geometry?: AtspiRect
protected?: boolean
password?: boolean
}
export interface AtspiTransport {
readNode(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<AtspiRawNode>
listChildren(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<AtspiNativeReference[]>
invoke(
reference: AtspiNativeReference,
action: string,
signal: AbortSignal
): Promise<boolean>
setText(
reference: AtspiNativeReference,
text: string,
signal: AbortSignal
): Promise<boolean>
select(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<boolean>
focus(
reference: AtspiNativeReference,
signal: AbortSignal
): Promise<boolean>
}
export type SemanticElement = {
ref: string
role: string
name: string
states: string[]
actions: string[]
text?: string
value?: number
geometry?: AtspiRect
protected: boolean
children: SemanticElement[]
}
export type SemanticTree = {
generation: number
truncated: boolean
root: SemanticElement
}
export type AtspiSemanticCoreOptions = {
now?: () => number
createToken?: () => string
referenceTtlMs?: number
maximumNodes?: number
maximumDepth?: number
maximumChildrenPerNode?: number
maximumTextLength?: number
maximumReferences?: number
}
type StoredReference = {
nativeReference: AtspiNativeReference
owner: string
window: string
generation: number
expiresAt: number
protected: boolean
actions: Set<string>
}
const cleanText = (
value: string | undefined,
maximumLength: number,
fallback = ''
): string => {
if (!value) {
return fallback
}
const clean = [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
.trim()
return clean.slice(0, maximumLength) || fallback
}
const normalizeIdentifier = (
value: string | undefined,
fallback: string
): string => {
const normalized = cleanText(value, 64, fallback)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || fallback
}
const normalizeGeometry = (geometry: AtspiRect | undefined): AtspiRect | undefined => {
if (
!geometry ||
!Number.isFinite(geometry.x) ||
!Number.isFinite(geometry.y) ||
!Number.isFinite(geometry.width) ||
!Number.isFinite(geometry.height) ||
geometry.width < 0 ||
geometry.height < 0
) {
return undefined
}
return {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height
}
}
const sensitiveRoles = new Set([
'password-text',
'password',
'secret',
'credential'
])
export class AtspiSemanticCore {
private readonly now: () => number
private readonly createToken: () => string
private readonly referenceTtlMs: number
private readonly maximumNodes: number
private readonly maximumDepth: number
private readonly maximumChildrenPerNode: number
private readonly maximumTextLength: number
private readonly maximumReferences: number
private readonly references = new Map<string, StoredReference>()
private generation = 0
constructor(
private readonly transport: AtspiTransport,
options: AtspiSemanticCoreOptions = {}
) {
this.now = options.now ?? Date.now
this.createToken =
options.createToken ?? (() => randomBytes(24).toString('base64url'))
this.referenceTtlMs = options.referenceTtlMs ?? 3_000
this.maximumNodes = options.maximumNodes ?? 500
this.maximumDepth = options.maximumDepth ?? 24
this.maximumChildrenPerNode = options.maximumChildrenPerNode ?? 100
this.maximumTextLength = options.maximumTextLength ?? 4_096
this.maximumReferences =
options.maximumReferences ?? this.maximumNodes
if (
!Number.isSafeInteger(this.maximumReferences) ||
this.maximumReferences < 1 ||
this.maximumReferences > 10_000
) {
throw new Error('Invalid semantic reference capacity')
}
}
async snapshot(
rootReference: AtspiNativeReference,
signal: AbortSignal
): Promise<SemanticTree> {
this.generation += 1
const snapshotGeneration = this.generation
this.pruneReferences()
const visited = new Set<AtspiNativeReference>()
let remaining = Math.min(
this.maximumNodes,
this.maximumReferences
)
let truncated = false
const visit = async (
reference: AtspiNativeReference,
depth: number
): Promise<SemanticElement | undefined> => {
if (
signal.aborted ||
visited.has(reference) ||
depth > this.maximumDepth ||
remaining <= 0
) {
truncated = true
return undefined
}
visited.add(reference)
remaining -= 1
let raw: AtspiRawNode
try {
raw = await this.transport.readNode(reference, signal)
} catch {
throw new Error('Accessibility element could not be read')
}
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
const role = normalizeIdentifier(raw.role, 'unknown')
const isProtected =
raw.protected === true ||
raw.password === true ||
sensitiveRoles.has(role)
const actions = [
...new Set(
(raw.actions ?? []).map((action) =>
normalizeIdentifier(action, 'action')
)
)
].slice(0, 32)
const states = [
...new Set(
(raw.states ?? []).map((state) =>
normalizeIdentifier(state, 'state')
)
)
].slice(0, 64)
const opaqueReference = this.createToken()
if (
!/^[A-Za-z0-9_-]{5,256}$/.test(opaqueReference) ||
this.references.has(opaqueReference)
) {
throw new Error('Opaque accessibility reference creation failed')
}
while (this.references.size >= this.maximumReferences) {
const oldest = this.references.keys().next().value
if (oldest === undefined) {
break
}
this.references.delete(oldest)
truncated = true
}
this.references.set(opaqueReference, {
nativeReference: raw.nativeReference,
owner: raw.owner,
window: raw.window,
generation: snapshotGeneration,
expiresAt: this.now() + this.referenceTtlMs,
protected: isProtected,
actions: new Set(actions)
})
const element: SemanticElement = {
ref: opaqueReference,
role,
name: isProtected
? '受保护内容'
: cleanText(raw.name, 256, role),
states,
actions: isProtected ? [] : actions,
protected: isProtected,
children: []
}
if (!isProtected) {
const text = cleanText(raw.text, this.maximumTextLength)
if (text) {
element.text = text
}
if (raw.value !== undefined && Number.isFinite(raw.value)) {
element.value = raw.value
}
}
const geometry = normalizeGeometry(raw.geometry)
if (geometry) {
element.geometry = geometry
}
if (depth === this.maximumDepth || remaining <= 0) {
truncated = true
return element
}
let children: AtspiNativeReference[]
try {
children = await this.transport.listChildren(reference, signal)
} catch {
throw new Error('Accessibility children could not be read')
}
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
if (children.length > this.maximumChildrenPerNode) {
truncated = true
}
for (const child of children.slice(0, this.maximumChildrenPerNode)) {
const normalized = await visit(child, depth + 1)
if (normalized) {
element.children.push(normalized)
}
}
return element
}
const root = await visit(rootReference, 0)
if (snapshotGeneration !== this.generation) {
throw new Error('Accessibility snapshot was superseded')
}
if (!root) {
throw new Error('Accessibility tree root is unavailable')
}
return {
generation: snapshotGeneration,
truncated,
root
}
}
invalidateRegistryOwner(): void {
this.generation += 1
this.references.clear()
}
invalidateOwner(owner: string): void {
for (const [reference, stored] of this.references) {
if (stored.owner === owner) {
this.references.delete(reference)
}
}
}
invalidateWindow(owner: string, window: string): void {
for (const [reference, stored] of this.references) {
if (stored.owner === owner && stored.window === window) {
this.references.delete(reference)
}
}
}
async invoke(
reference: string,
action: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
const normalizedAction = normalizeIdentifier(action, 'action')
if (!stored.actions.has(normalizedAction)) {
throw new Error('Semantic action is unavailable')
}
try {
return await this.transport.invoke(
stored.nativeReference,
normalizedAction,
signal
)
} catch {
throw new Error('Semantic action failed')
}
}
async setText(
reference: string,
text: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
if (text.length > this.maximumTextLength) {
throw new Error('Text exceeds the semantic input limit')
}
try {
return await this.transport.setText(stored.nativeReference, text, signal)
} catch {
throw new Error('Semantic text update failed')
}
}
async select(
reference: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
try {
return await this.transport.select(stored.nativeReference, signal)
} catch {
throw new Error('Semantic selection failed')
}
}
async focus(
reference: string,
signal: AbortSignal
): Promise<boolean> {
const stored = this.resolve(reference)
try {
return await this.transport.focus(stored.nativeReference, signal)
} catch {
throw new Error('Semantic focus failed')
}
}
private resolve(reference: string): StoredReference {
this.pruneReferences()
const stored = this.references.get(reference)
if (
!stored ||
stored.generation !== this.generation ||
this.now() >= stored.expiresAt
) {
this.references.delete(reference)
throw new Error('Semantic element is stale')
}
if (stored.protected) {
throw new Error('Protected semantic element cannot be controlled')
}
return stored
}
private pruneReferences(): void {
const timestamp = this.now()
for (const [reference, stored] of this.references) {
if (
stored.generation !== this.generation ||
timestamp >= stored.expiresAt
) {
this.references.delete(reference)
}
}
}
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
probeLinuxDesktopCapabilities,
type LinuxCapabilityProbes
} from './capability-probe'
const probes = (
overrides: Partial<LinuxCapabilityProbes> = {}
): LinuxCapabilityProbes => ({
sessionBus: async () => true,
accessibilityBus: async () => ({
busOwner: true,
registryOwner: true
}),
portalVersions: async () => ({ screenCast: 4, remoteDesktop: 2 }),
pipeWire: async () => true,
eis: async () => true,
x11: async () => false,
xTest: async () => false,
ydotool: async () => false,
electronCapture: async () => true,
...overrides
})
describe('probeLinuxDesktopCapabilities', () => {
it('reports concrete protocol checks without using desktop labels', async () => {
const result = await probeLinuxDesktopCapabilities(probes(), {
XDG_CURRENT_DESKTOP: 'GNOME:Treeland'
})
expect(result.overall).toBe('supported')
expect(result.sessionLabels).toEqual(['GNOME', 'Treeland'])
expect(result.checks).toContainEqual({
capability: 'portal-remote-desktop',
status: 'supported',
diagnostic: 'RemoteDesktop portal version 2 responded'
})
expect(result.checks.find((check) => check.capability === 'xtest')?.status)
.toBe('unavailable')
})
it('does not infer availability from a known desktop product name', async () => {
const unavailable = probes({
sessionBus: async () => false,
accessibilityBus: async () => ({
busOwner: false,
registryOwner: false
}),
portalVersions: async () => ({}),
pipeWire: async () => false,
eis: async () => false,
electronCapture: async () => false
})
const result = await probeLinuxDesktopCapabilities(unavailable, {
XDG_CURRENT_DESKTOP: 'KDE'
})
expect(result.overall).toBe('unavailable')
expect(result.sessionLabels).toEqual(['KDE'])
expect(result.checks.every((check) => check.status === 'unavailable')).toBe(
true
)
})
it('contains bounded diagnostics rather than thrown details or environment values', async () => {
const result = await probeLinuxDesktopCapabilities(
probes({
sessionBus: async () => {
throw new Error(
'address=unix:path=/run/user/1000/bus token=very-secret'
)
},
ydotool: async () => true,
accessibilityBus: async () => ({
busOwner: true,
registryOwner: false
})
}),
{ DBUS_SESSION_BUS_ADDRESS: 'private-address' }
)
const diagnostics = result.checks
.map((check) => check.diagnostic)
.join(' ')
expect(diagnostics).not.toContain('/run/')
expect(diagnostics).not.toContain('secret')
expect(diagnostics).not.toContain('private-address')
expect(
result.checks.find((check) => check.capability === 'accessibility')
?.status
).toBe('degraded')
expect(
result.checks.find((check) => check.capability === 'ydotool')?.status
).toBe('supported')
})
})
+237
View File
@@ -0,0 +1,237 @@
export type CapabilityStatus = 'supported' | 'degraded' | 'unavailable'
export type CapabilityCheck = {
capability:
| 'session-bus'
| 'accessibility'
| 'portal-screen-cast'
| 'portal-remote-desktop'
| 'pipewire'
| 'eis'
| 'x11'
| 'xtest'
| 'ydotool'
| 'electron-capture'
status: CapabilityStatus
diagnostic: string
}
export type LinuxDesktopCapabilities = {
overall: CapabilityStatus
checks: CapabilityCheck[]
sessionLabels: string[]
}
export interface LinuxCapabilityProbes {
sessionBus(): Promise<boolean>
accessibilityBus(): Promise<{
busOwner: boolean
registryOwner: boolean
}>
portalVersions(): Promise<{
screenCast?: number
remoteDesktop?: number
}>
pipeWire(): Promise<boolean>
eis(): Promise<boolean>
x11(): Promise<boolean>
xTest(): Promise<boolean>
ydotool(): Promise<boolean>
electronCapture(): Promise<boolean>
}
const diagnostic = (
available: boolean,
positive: string,
negative: string
): string => (available ? positive : negative)
const statusRank: Record<CapabilityStatus, number> = {
unavailable: 0,
degraded: 1,
supported: 2
}
const settle = async <T>(
probe: () => Promise<T>,
fallback: T
): Promise<T> => {
try {
return await probe()
} catch {
return fallback
}
}
const sessionLabels = (source: NodeJS.ProcessEnv): string[] => {
const text = [
source.XDG_CURRENT_DESKTOP,
source.XDG_SESSION_DESKTOP,
source.DESKTOP_SESSION
]
.filter((value): value is string => typeof value === 'string')
.join(':')
.toLowerCase()
const labels = [
['gnome', 'GNOME'],
['kde', 'KDE'],
['deepin', 'DDE'],
['dde', 'DDE'],
['treeland', 'Treeland'],
['ukui', 'UKUI']
] as const
return [
...new Set(
labels
.filter(([needle]) => text.includes(needle))
.map(([, label]) => label)
)
]
}
/**
* Probes protocols and concrete operations. Desktop names are intentionally
* excluded from capability decisions and are returned only as diagnostics.
*/
export async function probeLinuxDesktopCapabilities(
probes: LinuxCapabilityProbes,
environment: NodeJS.ProcessEnv = process.env
): Promise<LinuxDesktopCapabilities> {
const [
bus,
accessibility,
portals,
pipeWire,
eis,
x11,
xTest,
ydotool,
electronCapture
] = await Promise.all([
settle(() => probes.sessionBus(), false),
settle(() => probes.accessibilityBus(), {
busOwner: false,
registryOwner: false
}),
settle(() => probes.portalVersions(), {}),
settle(() => probes.pipeWire(), false),
settle(() => probes.eis(), false),
settle(() => probes.x11(), false),
settle(() => probes.xTest(), false),
settle(() => probes.ydotool(), false),
settle(() => probes.electronCapture(), false)
])
const screenCast = portals.screenCast ?? 0
const remoteDesktop = portals.remoteDesktop ?? 0
const checks: CapabilityCheck[] = [
{
capability: 'session-bus',
status: bus ? 'supported' : 'unavailable',
diagnostic: diagnostic(
bus,
'Session bus responded',
'Session bus did not respond'
)
},
{
capability: 'accessibility',
status:
accessibility.busOwner && accessibility.registryOwner
? 'supported'
: accessibility.busOwner
? 'degraded'
: 'unavailable',
diagnostic:
accessibility.busOwner && accessibility.registryOwner
? 'Accessibility bus and registry responded'
: accessibility.busOwner
? 'Accessibility bus responded but registry is unavailable'
: 'Accessibility bus is unavailable'
},
{
capability: 'portal-screen-cast',
status: screenCast > 0 ? 'supported' : 'unavailable',
diagnostic:
screenCast > 0
? `ScreenCast portal version ${screenCast} responded`
: 'ScreenCast portal is unavailable'
},
{
capability: 'portal-remote-desktop',
status: remoteDesktop > 0 ? 'supported' : 'unavailable',
diagnostic:
remoteDesktop > 0
? `RemoteDesktop portal version ${remoteDesktop} responded`
: 'RemoteDesktop portal is unavailable'
},
{
capability: 'pipewire',
status: pipeWire ? 'supported' : 'unavailable',
diagnostic: diagnostic(
pipeWire,
'PipeWire connection succeeded',
'PipeWire connection failed'
)
},
{
capability: 'eis',
status: eis ? 'supported' : 'unavailable',
diagnostic: diagnostic(
eis,
'EIS helper handshake succeeded',
'EIS helper handshake failed'
)
},
{
capability: 'x11',
status: x11 ? 'supported' : 'unavailable',
diagnostic: diagnostic(
x11,
'X11 connection succeeded',
'X11 connection failed'
)
},
{
capability: 'xtest',
status: x11 && xTest ? 'supported' : 'unavailable',
diagnostic:
x11 && xTest
? 'XTest extension query succeeded'
: 'XTest is unavailable on the proven display connection'
},
{
capability: 'ydotool',
status: ydotool ? 'supported' : 'unavailable',
diagnostic: ydotool
? 'Optional ydotool helper passed its direct probe'
: 'Optional ydotool helper is unavailable'
},
{
capability: 'electron-capture',
status: electronCapture ? 'supported' : 'unavailable',
diagnostic: diagnostic(
electronCapture,
'Electron one-shot capture succeeded',
'Electron one-shot capture failed'
)
}
]
const usable = checks.filter(
(check) =>
check.capability !== 'session-bus' &&
check.capability !== 'ydotool'
)
const best = usable.reduce<CapabilityStatus>(
(current, check) =>
statusRank[check.status] > statusRank[current] ? check.status : current,
'unavailable'
)
return {
overall: best,
checks,
sessionLabels: sessionLabels(environment)
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
CoordinateMapper,
type CapturedFrameLayout,
type DesktopLayout
} from './coordinate-mapper'
const layout: DesktopLayout = {
revision: 7,
capturedAt: 1_000,
displays: [
{
id: 'left',
logicalBounds: { x: -1024, y: -100, width: 1024, height: 768 },
scale: 1.25,
rotation: 0
},
{
id: 'rotated',
logicalBounds: { x: 0, y: 0, width: 800, height: 600 },
scale: 2,
rotation: 90
}
]
}
const frame = (
overrides: Partial<CapturedFrameLayout> = {}
): CapturedFrameLayout => ({
frameId: 'frame-1',
displayId: 'left',
sourceBounds: { x: -1024, y: -100, width: 1024, height: 768 },
width: 1280,
height: 960,
layoutRevision: 7,
capturedAt: 1_001,
expiresAt: 2_000,
...overrides
})
describe('CoordinateMapper', () => {
it('maps negative origins and fractional display scale', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(mapper.mapPoint({ x: -512, y: 284 }, frame())).toEqual({
frameId: 'frame-1',
x: 640,
y: 480
})
})
it('maps rotated displays into their physical frame orientation', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
const rotated = frame({
frameId: 'rotated-frame',
displayId: 'rotated',
sourceBounds: { x: 0, y: 0, width: 800, height: 600 },
width: 1200,
height: 1600
})
expect(mapper.mapPoint({ x: 400, y: 300 }, rotated)).toEqual({
frameId: 'rotated-frame',
x: 600,
y: 800
})
expect(mapper.mapPoint({ x: 0, y: 0 }, rotated)).toEqual({
frameId: 'rotated-frame',
x: 1199,
y: 0
})
})
it('maps bounded rectangles without crossing frame edges', () => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(
mapper.mapRect(
{ x: -1024, y: -100, width: 1024, height: 768 },
frame()
)
).toEqual({ x: 0, y: 0, width: 1280, height: 960 })
})
it.each([
frame({ layoutRevision: 6 }),
frame({ expiresAt: 1_100 }),
frame({ width: 1270 }),
frame({
sourceBounds: { x: -2000, y: -100, width: 1024, height: 768 }
})
])('rejects stale or inconsistent captured frame layout', (captured) => {
const mapper = new CoordinateMapper(() => 1_100)
mapper.updateLayout(layout)
expect(() => mapper.mapPoint({ x: -512, y: 284 }, captured)).toThrow()
})
it('rejects stale and non-monotonic desktop layouts', () => {
const mapper = new CoordinateMapper(() => 7_000)
mapper.updateLayout(layout)
expect(() => mapper.mapPoint({ x: -512, y: 284 }, frame())).toThrow(
'stale'
)
expect(() => mapper.updateLayout(layout)).toThrow('revision is stale')
})
})
+212
View File
@@ -0,0 +1,212 @@
export type DesktopPoint = {
x: number
y: number
}
export type DesktopRect = DesktopPoint & {
width: number
height: number
}
export type DisplayRotation = 0 | 90 | 180 | 270
export type DisplayLayout = {
id: string
logicalBounds: DesktopRect
scale: number
rotation: DisplayRotation
}
export type DesktopLayout = {
revision: number
capturedAt: number
displays: DisplayLayout[]
}
export type CapturedFrameLayout = {
frameId: string
displayId: string
sourceBounds: DesktopRect
width: number
height: number
layoutRevision: number
capturedAt: number
expiresAt: number
}
export type FramePoint = {
frameId: string
x: number
y: number
}
const finiteRect = (rect: DesktopRect): boolean =>
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.width) &&
Number.isFinite(rect.height) &&
rect.width > 0 &&
rect.height > 0
const containsPoint = (rect: DesktopRect, point: DesktopPoint): boolean =>
point.x >= rect.x &&
point.y >= rect.y &&
point.x < rect.x + rect.width &&
point.y < rect.y + rect.height
const containsRect = (outer: DesktopRect, inner: DesktopRect): boolean =>
inner.x >= outer.x &&
inner.y >= outer.y &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y + inner.height <= outer.y + outer.height
export class CoordinateMapper {
private layout: DesktopLayout | undefined
constructor(
private readonly now: () => number = Date.now,
private readonly maximumLayoutAgeMs = 5_000
) {}
updateLayout(layout: DesktopLayout): void {
if (
!Number.isSafeInteger(layout.revision) ||
layout.revision < 1 ||
!Number.isFinite(layout.capturedAt) ||
layout.displays.length === 0 ||
layout.displays.some(
(display) =>
!display.id ||
!finiteRect(display.logicalBounds) ||
!Number.isFinite(display.scale) ||
display.scale <= 0 ||
display.scale > 8
) ||
new Set(layout.displays.map((display) => display.id)).size !==
layout.displays.length
) {
throw new Error('Invalid desktop layout')
}
if (this.layout && layout.revision <= this.layout.revision) {
throw new Error('Desktop layout revision is stale')
}
this.layout = structuredClone(layout)
}
mapPoint(
point: DesktopPoint,
frame: CapturedFrameLayout
): FramePoint {
const { display } = this.validateFrame(frame)
if (
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
!containsPoint(frame.sourceBounds, point)
) {
throw new Error('Desktop point is outside the captured frame')
}
const u = (point.x - frame.sourceBounds.x) / frame.sourceBounds.width
const v = (point.y - frame.sourceBounds.y) / frame.sourceBounds.height
const rotated = this.rotate(u, v, display.rotation)
return {
frameId: frame.frameId,
x: Math.min(frame.width - 1, Math.max(0, Math.floor(rotated.x * frame.width))),
y: Math.min(
frame.height - 1,
Math.max(0, Math.floor(rotated.y * frame.height))
)
}
}
mapRect(rect: DesktopRect, frame: CapturedFrameLayout): DesktopRect {
if (!finiteRect(rect) || !containsRect(frame.sourceBounds, rect)) {
throw new Error('Desktop rectangle is outside the captured frame')
}
const right = rect.x + rect.width
const bottom = rect.y + rect.height
const xInset = Math.min(rect.width / 2, Math.max(1e-9, rect.width * 1e-9))
const yInset = Math.min(
rect.height / 2,
Math.max(1e-9, rect.height * 1e-9)
)
const points = [
this.mapPoint({ x: rect.x, y: rect.y }, frame),
this.mapPoint({ x: right - xInset, y: rect.y }, frame),
this.mapPoint({ x: rect.x, y: bottom - yInset }, frame),
this.mapPoint(
{ x: right - xInset, y: bottom - yInset },
frame
)
]
const xs = points.map((point) => point.x)
const ys = points.map((point) => point.y)
const x = Math.min(...xs)
const y = Math.min(...ys)
return {
x,
y,
width: Math.max(...xs) - x + 1,
height: Math.max(...ys) - y + 1
}
}
private validateFrame(frame: CapturedFrameLayout): {
display: DisplayLayout
} {
const layout = this.layout
if (!layout) {
throw new Error('Desktop layout is unavailable')
}
if (
this.now() - layout.capturedAt >= this.maximumLayoutAgeMs ||
frame.layoutRevision !== layout.revision ||
this.now() >= frame.expiresAt ||
frame.capturedAt < layout.capturedAt
) {
throw new Error('Desktop layout or captured frame is stale')
}
const display = layout.displays.find(
(candidate) => candidate.id === frame.displayId
)
if (
!display ||
!frame.frameId ||
!finiteRect(frame.sourceBounds) ||
!containsRect(display.logicalBounds, frame.sourceBounds) ||
!Number.isSafeInteger(frame.width) ||
frame.width < 1 ||
!Number.isSafeInteger(frame.height) ||
frame.height < 1
) {
throw new Error('Invalid captured frame layout')
}
const rotated = display.rotation === 90 || display.rotation === 270
const expectedWidth =
(rotated ? frame.sourceBounds.height : frame.sourceBounds.width) *
display.scale
const expectedHeight =
(rotated ? frame.sourceBounds.width : frame.sourceBounds.height) *
display.scale
if (
Math.abs(frame.width - expectedWidth) > 1 ||
Math.abs(frame.height - expectedHeight) > 1
) {
throw new Error('Captured frame scale does not match the display')
}
return { display }
}
private rotate(u: number, v: number, rotation: DisplayRotation): DesktopPoint {
switch (rotation) {
case 0:
return { x: u, y: v }
case 90:
return { x: 1 - v, y: u }
case 180:
return { x: 1 - u, y: 1 - v }
case 270:
return { x: v, y: 1 - u }
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest'
import {
LinuxDesktopInputRouter,
type InjectedInputBackend,
type SemanticInput,
type YdotoolInputBackend
} from './input-router'
const semantic = (result: boolean): SemanticInput => ({
perform: vi.fn(async () => result)
})
const backend = (): InjectedInputBackend => ({
inject: vi.fn(async () => undefined),
releasePressedInput: vi.fn(async () => undefined)
})
describe('LinuxDesktopInputRouter', () => {
it('always attempts the semantic action before native injection', async () => {
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(true),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(
{ type: 'text', text: 'hello' },
new AbortController().signal
)
).resolves.toEqual({ route: 'semantic' })
expect(portal.inject).not.toHaveBeenCalled()
})
it('uses portal input only while user consent is active', async () => {
let consent = false
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => consent,
provenX11: () => false
})
await expect(
router.route(
{ type: 'pointer-move', x: 1, y: 2 },
new AbortController().signal,
{ backend: 'portal' }
)
).rejects.toThrow('active user consent')
consent = true
await expect(
router.route(
{ type: 'pointer-move', x: 1, y: 2 },
new AbortController().signal
)
).resolves.toEqual({ route: 'portal' })
})
it('uses XTest only for a proven X11 connection', async () => {
let proven = false
const xTest = backend()
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
xTest,
provenX11: () => proven
})
await expect(
router.route(
{ type: 'key', key: 'Enter', pressed: true },
new AbortController().signal,
{ backend: 'xtest' }
)
).rejects.toThrow('proven X11')
proven = true
await expect(
router.route(
{ type: 'key', key: 'Enter', pressed: true },
new AbortController().signal
)
).resolves.toEqual({ route: 'xtest' })
})
it('never silently selects ydotool and requires its exact fixed executable', async () => {
const ydotool: YdotoolInputBackend = {
...backend(),
executablePath: '/usr/bin/ydotool'
}
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
provenX11: () => false,
ydotool,
ydotoolOptIn: true,
fixedYdotoolExecutable: '/usr/bin/ydotool'
})
await expect(
router.route(
{ type: 'scroll', deltaX: 0, deltaY: 1 },
new AbortController().signal
)
).rejects.toThrow('No consented')
await expect(
router.route(
{ type: 'scroll', deltaX: 0, deltaY: 1 },
new AbortController().signal,
{ backend: 'ydotool' }
)
).resolves.toEqual({ route: 'ydotool' })
expect(
() =>
new LinuxDesktopInputRouter({
semantic: semantic(false),
portalConsentActive: () => false,
provenX11: () => false,
ydotoolOptIn: true,
fixedYdotoolExecutable: 'ydotool'
})
).toThrow('absolute fixed executable')
})
it('releases pressed input best-effort when an operation is aborted', async () => {
const controller = new AbortController()
const portal = backend()
portal.inject = vi.fn(
async (_action, signal) =>
new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true
})
})
)
portal.releasePressedInput = vi.fn(async () => {
throw new Error('release failed')
})
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
const routed = router.route(
{ type: 'pointer-button', button: 1, pressed: true },
controller.signal
)
await Promise.resolve()
controller.abort()
await expect(routed).rejects.toThrow()
expect(portal.releasePressedInput).toHaveBeenCalled()
})
it('releases partially pressed input after a non-abort injection failure', async () => {
const portal = backend()
portal.inject = vi.fn(async () => {
throw new Error('partial native injection')
})
portal.releasePressedInput = vi.fn(async () => {
throw new Error('best-effort release failed')
})
const router = new LinuxDesktopInputRouter({
semantic: semantic(false),
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(
{ type: 'key', key: 'Control_L', pressed: true },
new AbortController().signal
)
).rejects.toThrow('partial native injection')
expect(portal.releasePressedInput).toHaveBeenCalledOnce()
})
it.each([
{ type: 'pointer-move', x: Number.NaN, y: 0 } as const,
{ type: 'pointer-button', button: 0, pressed: true } as const,
{ type: 'scroll', deltaX: 0, deltaY: Number.POSITIVE_INFINITY } as const,
{ type: 'key', key: '\n', pressed: true } as const,
{ type: 'text', text: '\u0000secret' } as const
])('rejects malformed actions before any backend sees them', async (action) => {
const semanticInput = semantic(false)
const portal = backend()
const router = new LinuxDesktopInputRouter({
semantic: semanticInput,
portal,
portalConsentActive: () => true,
provenX11: () => false
})
await expect(
router.route(action, new AbortController().signal)
).rejects.toThrow('Invalid')
expect(semanticInput.perform).not.toHaveBeenCalled()
expect(portal.inject).not.toHaveBeenCalled()
})
})
+194
View File
@@ -0,0 +1,194 @@
import { isAbsolute } from 'node:path'
export type DesktopInputAction =
| { type: 'pointer-move'; x: number; y: number }
| { type: 'pointer-button'; button: number; pressed: boolean }
| { type: 'scroll'; deltaX: number; deltaY: number }
| { type: 'key'; key: string; pressed: boolean }
| { type: 'text'; text: string }
export type InputBackendName = 'portal' | 'xtest' | 'ydotool'
export interface SemanticInput {
perform(action: DesktopInputAction, signal: AbortSignal): Promise<boolean>
}
export interface InjectedInputBackend {
inject(action: DesktopInputAction, signal: AbortSignal): Promise<void>
releasePressedInput(): Promise<void>
}
export interface YdotoolInputBackend extends InjectedInputBackend {
readonly executablePath: string
}
export type InputRouterOptions = {
semantic: SemanticInput
portal?: InjectedInputBackend
portalConsentActive: () => boolean
xTest?: InjectedInputBackend
provenX11: () => boolean
ydotool?: YdotoolInputBackend
ydotoolOptIn?: boolean
fixedYdotoolExecutable?: string
}
export type RouteInputOptions = {
backend?: 'auto' | InputBackendName
}
export type RoutedInputResult = {
route: 'semantic' | InputBackendName
}
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const validateAction = (action: DesktopInputAction): void => {
switch (action.type) {
case 'pointer-move':
if (!Number.isFinite(action.x) || !Number.isFinite(action.y)) {
throw new Error('Invalid pointer coordinates')
}
return
case 'pointer-button':
if (
!Number.isSafeInteger(action.button) ||
action.button < 1 ||
action.button > 32
) {
throw new Error('Invalid pointer button')
}
return
case 'scroll':
if (
!Number.isFinite(action.deltaX) ||
!Number.isFinite(action.deltaY) ||
Math.abs(action.deltaX) > 100_000 ||
Math.abs(action.deltaY) > 100_000
) {
throw new Error('Invalid scroll delta')
}
return
case 'key':
if (
action.key.length < 1 ||
action.key.length > 64 ||
hasControlCharacters(action.key)
) {
throw new Error('Invalid key identifier')
}
return
case 'text':
if (
action.text.length > 4_096 ||
action.text.includes('\u0000')
) {
throw new Error('Invalid text input')
}
}
}
export class LinuxDesktopInputRouter {
private readonly usedBackends = new Set<InjectedInputBackend>()
constructor(private readonly options: InputRouterOptions) {
if (
options.ydotoolOptIn &&
(!options.fixedYdotoolExecutable ||
!isAbsolute(options.fixedYdotoolExecutable))
) {
throw new Error('ydotool requires an absolute fixed executable path')
}
}
async route(
action: DesktopInputAction,
signal: AbortSignal,
routeOptions: RouteInputOptions = {}
): Promise<RoutedInputResult> {
validateAction(action)
if (signal.aborted) {
throw signal.reason
}
if (await this.options.semantic.perform(action, signal)) {
return { route: 'semantic' }
}
const selected = this.selectBackend(routeOptions.backend ?? 'auto')
this.usedBackends.add(selected.backend)
let released = false
let releasePromise: Promise<void> | undefined
const releaseOnAbort = (): void => {
if (!released) {
released = true
releasePromise = this.releasePressedInput()
}
}
signal.addEventListener('abort', releaseOnAbort, { once: true })
try {
await selected.backend.inject(action, signal)
if (signal.aborted) {
releaseOnAbort()
throw signal.reason
}
return { route: selected.name }
} catch (error) {
await (releasePromise ?? this.releasePressedInput())
throw error
} finally {
signal.removeEventListener('abort', releaseOnAbort)
}
}
async releasePressedInput(): Promise<void> {
const backends = [...this.usedBackends]
this.usedBackends.clear()
await Promise.allSettled(
backends.map((backend) => backend.releasePressedInput())
)
}
private selectBackend(preference: 'auto' | InputBackendName): {
name: InputBackendName
backend: InjectedInputBackend
} {
if (preference === 'portal') {
if (!this.options.portal || !this.options.portalConsentActive()) {
throw new Error('Portal input requires active user consent')
}
return { name: 'portal', backend: this.options.portal }
}
if (preference === 'xtest') {
if (!this.options.xTest || !this.options.provenX11()) {
throw new Error('XTest requires a proven X11 connection')
}
return { name: 'xtest', backend: this.options.xTest }
}
if (preference === 'ydotool') {
const fixed = this.options.fixedYdotoolExecutable
if (
!this.options.ydotoolOptIn ||
!fixed ||
!this.options.ydotool ||
this.options.ydotool.executablePath !== fixed
) {
throw new Error(
'ydotool requires explicit opt-in and the fixed executable'
)
}
return { name: 'ydotool', backend: this.options.ydotool }
}
if (this.options.portal && this.options.portalConsentActive()) {
return { name: 'portal', backend: this.options.portal }
}
if (this.options.xTest && this.options.provenX11()) {
return { name: 'xtest', backend: this.options.xTest }
}
throw new Error('No consented or proven input backend is available')
}
}
@@ -0,0 +1,366 @@
import {
afterEach,
describe,
expect,
it,
vi,
type Mock
} from 'vitest'
import {
PortalDesktopSession,
PortalSessionError,
validatePipeWireFrameMetadata,
type ClosableResource,
type PortalResponse,
type PortalTransport
} from './portal-session'
type MockPortal = {
transport: PortalTransport
pipeWireClose: Mock<() => void>
eisClose: Mock<() => void>
}
const mockPortal = (
responseOverrides: Record<string, Partial<PortalResponse>> = {}
): MockPortal => {
const pipeWireClose = vi.fn<() => void>()
const eisClose = vi.fn<() => void>()
const resource = (close: Mock<() => void>): ClosableResource => ({
close: () => close()
})
const transport: PortalTransport = {
createSession: vi.fn(async () => ({ requestHandle: 'create' })),
selectDevices: vi.fn(async () => ({ requestHandle: 'devices' })),
selectSources: vi.fn(async () => ({ requestHandle: 'sources' })),
start: vi.fn(async () => ({ requestHandle: 'start' })),
waitForResponse: vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create'
? { session_handle: 'private-session' }
: {},
...responseOverrides[requestHandle]
})),
openPipeWireRemote: vi.fn(async () => resource(pipeWireClose)),
connectEis: vi.fn(async () => resource(eisClose)),
closeRequest: vi.fn(async () => undefined),
closeSession: vi.fn(async () => undefined)
}
return { transport, pipeWireClose, eisClose }
}
afterEach(() => {
vi.useRealTimers()
})
describe('PortalDesktopSession', () => {
it('performs the portal protocol in order and cleans every resource on stop', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true, parentWindow: 'window-token' },
new AbortController().signal
)
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.selectDevices).toHaveBeenCalledWith(
'private-session',
expect.any(AbortSignal)
)
expect(mock.transport.selectSources).toHaveBeenCalled()
expect(mock.transport.start).toHaveBeenCalledWith(
'private-session',
'window-token',
expect.any(AbortSignal)
)
await session.stop()
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeRequest).toHaveBeenCalledTimes(4)
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
})
it.each([
[1, 'cancelled'],
[2, 'denied']
] as const)('handles portal consent response %s as %s', async (code, reason) => {
const mock = mockPortal({
sources: { response: code }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: false, sources: true },
new AbortController().signal
)
).rejects.toMatchObject({ reason })
expect(session.state).toBe('failed')
expect(mock.transport.closeRequest).toHaveBeenCalledWith('sources')
expect(mock.transport.closeSession).toHaveBeenCalledWith('private-session')
expect(mock.transport.openPipeWireRemote).not.toHaveBeenCalled()
})
it('rejects mismatched response handles and closes the request', async () => {
const mock = mockPortal({
create: { requestHandle: 'unrelated-response' }
})
const session = new PortalDesktopSession(mock.transport)
await expect(
session.open(
{ devices: true, sources: false },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('aborts a pending request on timeout and performs cleanup', async () => {
vi.useFakeTimers()
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false, timeoutMs: 10 },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'timeout'
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('cleans active descriptors and revokes consent on portal owner loss', async () => {
const mock = mockPortal()
const session = new PortalDesktopSession(mock.transport)
await session.open(
{ devices: true, sources: true },
new AbortController().signal
)
await session.portalOwnerLost()
expect(session.state).toBe('failed')
expect(session.hasActiveConsent).toBe(false)
expect(mock.pipeWireClose).toHaveBeenCalledOnce()
expect(mock.eisClose).toHaveBeenCalledOnce()
expect(mock.transport.closeSession).toHaveBeenCalledOnce()
})
it('stops an opening session without leaving requests or consent active', async () => {
const mock = mockPortal()
mock.transport.waitForResponse = vi.fn(
async (_handle, signal) =>
new Promise<PortalResponse>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const session = new PortalDesktopSession(mock.transport)
const opening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'aborted'
})
await Promise.resolve()
await session.stop()
await rejection
expect(session.state).toBe('stopped')
expect(session.hasActiveConsent).toBe(false)
expect(mock.transport.closeRequest).toHaveBeenCalledWith('create')
})
it('prevents a late stopped open from mutating a reopened session', async () => {
const mock = mockPortal()
let resolveFirst:
| ((request: { requestHandle: string }) => void)
| undefined
const firstCreate = new Promise<{ requestHandle: string }>((resolve) => {
resolveFirst = resolve
})
let createCount = 0
mock.transport.createSession = vi.fn(async () => {
createCount += 1
if (createCount === 1) {
return firstCreate
}
return { requestHandle: 'create-second' }
})
mock.transport.waitForResponse = vi.fn(async (requestHandle) => ({
requestHandle,
response: 0,
results:
requestHandle === 'create-second'
? { session_handle: 'session-second' }
: {}
}))
const session = new PortalDesktopSession(mock.transport)
const firstOpening = session.open(
{ devices: true, sources: false },
new AbortController().signal
)
const firstRejection = expect(firstOpening).rejects.toMatchObject({
reason: 'aborted'
})
await vi.waitFor(() => {
expect(mock.transport.createSession).toHaveBeenCalledOnce()
})
await session.stop()
await firstRejection
await session.open(
{ devices: true, sources: false },
new AbortController().signal
)
expect(session.state).toBe('active')
resolveFirst?.({ requestHandle: 'late-first-create' })
await vi.waitFor(() => {
expect(mock.transport.closeRequest).toHaveBeenCalledWith(
'late-first-create'
)
})
expect(session.state).toBe('active')
expect(session.hasActiveConsent).toBe(true)
expect(mock.transport.closeSession).not.toHaveBeenCalledWith(
'session-second'
)
})
it.each(['stop', 'owner-loss'] as const)(
'bounds hung cleanup during %s',
async (lifecycle) => {
const mock = mockPortal()
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
mock.transport.openPipeWireRemote = vi.fn(async () => ({
close: never
}))
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
await session.open(
{ devices: false, sources: true },
new AbortController().signal
)
vi.useFakeTimers()
const cleanup =
lifecycle === 'stop'
? session.stop()
: session.portalOwnerLost()
await vi.advanceTimersByTimeAsync(10)
await expect(cleanup).resolves.toBeUndefined()
expect(session.state).toBe(
lifecycle === 'stop' ? 'stopped' : 'failed'
)
}
)
it('bounds hung cleanup after a failed open', async () => {
vi.useFakeTimers()
const mock = mockPortal({
sources: { response: 2 }
})
const never = () => new Promise<void>(() => undefined)
mock.transport.closeRequest = vi.fn(never)
mock.transport.closeSession = vi.fn(never)
const session = new PortalDesktopSession(mock.transport, {
cleanupTimeoutMs: 10
})
const opening = session.open(
{ devices: false, sources: true },
new AbortController().signal
)
const rejection = expect(opening).rejects.toMatchObject({
reason: 'denied'
})
await vi.waitFor(() => {
expect(mock.transport.closeSession).toHaveBeenCalled()
})
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(session.state).toBe('failed')
})
it('requires at least one consented portal capability', async () => {
const session = new PortalDesktopSession(mockPortal().transport)
await expect(
session.open(
{ devices: false, sources: false },
new AbortController().signal
)
).rejects.toBeInstanceOf(PortalSessionError)
await expect(
session.open(
{ devices: true, sources: false, timeoutMs: 0 },
new AbortController().signal
)
).rejects.toMatchObject({ reason: 'protocol' })
})
})
describe('validatePipeWireFrameMetadata', () => {
const valid = {
width: 1920,
height: 1080,
stride: 7680,
planes: [{ offset: 0, stride: 7680, bytes: 8_294_400 }],
byteLength: 8_294_400,
fpsNumerator: 60,
fpsDenominator: 1
}
it('accepts bounded, internally consistent frame metadata', () => {
expect(validatePipeWireFrameMetadata(valid)).toEqual(valid)
})
it.each([
{ ...valid, width: 0 },
{ ...valid, height: 20_000 },
{ ...valid, stride: 100 },
{ ...valid, fpsDenominator: 0 },
{ ...valid, fpsNumerator: 241 },
{ ...valid, byteLength: 10 },
{
...valid,
planes: [{ offset: 1, stride: 7680, bytes: 8_294_399 }]
},
{
...valid,
planes: [
{ offset: 0, stride: 7680, bytes: 100 },
{ offset: 50, stride: 960, bytes: 8_294_350 }
]
}
])('rejects unsafe or inconsistent metadata', (metadata) => {
expect(() => validatePipeWireFrameMetadata(metadata)).toThrow()
})
})
+564
View File
@@ -0,0 +1,564 @@
export type PortalSessionState =
| 'idle'
| 'creating'
| 'selecting'
| 'starting'
| 'active'
| 'stopping'
| 'stopped'
| 'failed'
export type PortalRequest = {
requestHandle: string
}
export type PortalResponse = {
requestHandle: string
response: number
results: Readonly<Record<string, unknown>>
}
export interface ClosableResource {
close(): void | Promise<void>
}
export interface PortalTransport {
createSession(signal: AbortSignal): Promise<PortalRequest>
selectDevices(
sessionHandle: string,
signal: AbortSignal
): Promise<PortalRequest>
selectSources(
sessionHandle: string,
signal: AbortSignal
): Promise<PortalRequest>
start(
sessionHandle: string,
parentWindow: string,
signal: AbortSignal
): Promise<PortalRequest>
waitForResponse(
requestHandle: string,
signal: AbortSignal
): Promise<PortalResponse>
openPipeWireRemote(
sessionHandle: string,
signal: AbortSignal
): Promise<ClosableResource>
connectEis?(
sessionHandle: string,
signal: AbortSignal
): Promise<ClosableResource>
closeRequest(requestHandle: string): Promise<void>
closeSession(sessionHandle: string): Promise<void>
}
export type PortalSessionOptions = {
devices: boolean
sources: boolean
parentWindow?: string
timeoutMs?: number
}
export type PortalDesktopSessionOptions = {
cleanupTimeoutMs?: number
}
export class PortalSessionError extends Error {
constructor(
readonly reason:
| 'cancelled'
| 'denied'
| 'protocol'
| 'timeout'
| 'aborted'
| 'owner-lost',
message: string
) {
super(message)
this.name = 'PortalSessionError'
}
}
const getSessionHandle = (response: PortalResponse): string => {
const handle = response.results.session_handle
if (typeof handle !== 'string' || handle.length === 0) {
throw new PortalSessionError(
'protocol',
'Portal did not return a session handle'
)
}
return handle
}
const throwForResponse = (response: PortalResponse): void => {
if (response.response === 0) {
return
}
if (response.response === 1) {
throw new PortalSessionError('cancelled', 'Portal consent was cancelled')
}
if (response.response === 2) {
throw new PortalSessionError('denied', 'Portal consent was denied')
}
throw new PortalSessionError('protocol', 'Portal returned an unknown response')
}
export class PortalDesktopSession {
private stateValue: PortalSessionState = 'idle'
private generation = 0
private operation: PortalOperation | undefined
private openingPromise: Promise<void> | undefined
private readonly cleanupTimeoutMs: number
constructor(
private readonly transport: PortalTransport,
options: PortalDesktopSessionOptions = {}
) {
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? 1_000
if (
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
this.cleanupTimeoutMs < 1 ||
this.cleanupTimeoutMs > 30_000
) {
throw new Error('Invalid portal cleanup timeout')
}
}
get state(): PortalSessionState {
return this.stateValue
}
get hasActiveConsent(): boolean {
return (
this.stateValue === 'active' &&
this.operation !== undefined &&
!this.operation.ownerLost
)
}
async open(
options: PortalSessionOptions,
outerSignal: AbortSignal
): Promise<void> {
if (this.stateValue !== 'idle' && this.stateValue !== 'stopped') {
throw new PortalSessionError('protocol', 'Portal session is already open')
}
if (!options.devices && !options.sources) {
throw new PortalSessionError(
'protocol',
'Portal session must request a concrete capability'
)
}
const timeoutMs = options.timeoutMs ?? 30_000
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 120_000
) {
throw new PortalSessionError('protocol', 'Invalid portal timeout')
}
this.stateValue = 'creating'
const operation: PortalOperation = {
generation: ++this.generation,
controller: new AbortController(),
requestHandles: new Set(),
resources: new Set(),
ownerLost: false,
stopRequested: false
}
this.operation = operation
const opening = this.openOperation(
operation,
options,
outerSignal,
timeoutMs
)
this.openingPromise = opening
opening
.finally(() => {
if (this.openingPromise === opening) {
this.openingPromise = undefined
}
})
.catch(() => {
// The caller receives the original open rejection.
})
return opening
}
private async openOperation(
operation: PortalOperation,
options: PortalSessionOptions,
outerSignal: AbortSignal,
timeoutMs: number
): Promise<void> {
const controller = operation.controller
const abort = (): void => controller.abort(outerSignal.reason)
outerSignal.addEventListener('abort', abort, { once: true })
if (outerSignal.aborted) {
abort()
}
const timer = setTimeout(
() => controller.abort(new PortalSessionError('timeout', 'Portal timed out')),
timeoutMs
)
try {
const created = await this.request(
operation,
() => this.transport.createSession(controller.signal),
controller.signal
)
operation.sessionHandle = getSessionHandle(created)
this.setOwnedState(operation, 'selecting')
if (options.devices) {
await this.request(
operation,
() =>
this.transport.selectDevices(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
}
if (options.sources) {
await this.request(
operation,
() =>
this.transport.selectSources(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
}
this.setOwnedState(operation, 'starting')
await this.request(
operation,
() =>
this.transport.start(
this.requireSessionHandle(operation),
options.parentWindow ?? '',
controller.signal
),
controller.signal
)
if (options.sources) {
operation.resources.add(
await this.acquireResource(
this.transport.openPipeWireRemote(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
)
}
if (options.devices && this.transport.connectEis) {
operation.resources.add(
await this.acquireResource(
this.transport.connectEis(
this.requireSessionHandle(operation),
controller.signal
),
controller.signal
)
)
}
if (controller.signal.aborted) {
throw controller.signal.reason
}
this.setOwnedState(operation, 'active')
} catch (error) {
await this.cleanup(operation)
this.setOwnedState(
operation,
operation.stopRequested ? 'stopped' : 'failed'
)
if (operation.ownerLost) {
throw new PortalSessionError('owner-lost', 'Portal owner disappeared')
}
if (controller.signal.aborted) {
const reason = controller.signal.reason
if (reason instanceof PortalSessionError) {
throw reason
}
throw new PortalSessionError('aborted', 'Portal session was aborted')
}
throw error
} finally {
clearTimeout(timer)
outerSignal.removeEventListener('abort', abort)
}
}
async stop(): Promise<void> {
if (this.stateValue === 'stopped' || this.stateValue === 'idle') {
this.stateValue = 'stopped'
return
}
this.stateValue = 'stopping'
const operation = this.operation
if (!operation) {
this.stateValue = 'stopped'
return
}
operation.stopRequested = true
operation.controller.abort(
new PortalSessionError('aborted', 'Portal session stopped')
)
await this.cleanup(operation)
await this.awaitOpening(operation)
if (this.operation === operation) {
this.operation = undefined
this.stateValue = 'stopped'
}
}
async portalOwnerLost(): Promise<void> {
const operation = this.operation
if (!operation) {
this.stateValue = 'failed'
return
}
operation.ownerLost = true
operation.controller.abort(
new PortalSessionError('owner-lost', 'Portal owner disappeared')
)
await this.cleanup(operation)
await this.awaitOpening(operation)
if (this.operation === operation) {
this.operation = undefined
this.stateValue = 'failed'
}
}
private async request(
operation: PortalOperation,
initiate: () => Promise<PortalRequest>,
signal: AbortSignal
): Promise<PortalResponse> {
const initiating = Promise.resolve().then(initiate)
let request: PortalRequest
try {
request = await this.abortable(initiating, signal)
} catch (error) {
void initiating
.then((lateRequest) =>
this.runCleanup([
() => this.transport.closeRequest(lateRequest.requestHandle)
])
)
.catch(() => undefined)
throw error
}
operation.requestHandles.add(request.requestHandle)
const response = await this.abortable(
this.transport.waitForResponse(request.requestHandle, signal),
signal
)
if (response.requestHandle !== request.requestHandle) {
throw new PortalSessionError(
'protocol',
'Portal response handle did not match its request'
)
}
throwForResponse(response)
return response
}
private requireSessionHandle(operation: PortalOperation): string {
if (!operation.sessionHandle) {
throw new PortalSessionError(
'protocol',
'Portal session handle is unavailable'
)
}
return operation.sessionHandle
}
private async cleanup(operation: PortalOperation): Promise<void> {
if (operation.cleanupPromise) {
return operation.cleanupPromise
}
const resources = [...operation.resources]
const requests = [...operation.requestHandles]
const sessionHandle = operation.sessionHandle
operation.resources.clear()
operation.requestHandles.clear()
operation.sessionHandle = undefined
operation.cleanupPromise = this.runCleanup([
...resources.map((resource) => () => resource.close()),
...requests.map(
(handle) => () => this.transport.closeRequest(handle)
),
...(sessionHandle
? [() => this.transport.closeSession(sessionHandle)]
: [])
])
return operation.cleanupPromise
}
private async acquireResource(
resourcePromise: Promise<ClosableResource>,
signal: AbortSignal
): Promise<ClosableResource> {
try {
return await this.abortable(resourcePromise, signal)
} catch (error) {
void resourcePromise
.then((resource) =>
this.runCleanup([() => resource.close()])
)
.catch(() => undefined)
throw error
}
}
private abortable<T>(
promise: Promise<T>,
signal: AbortSignal
): Promise<T> {
if (signal.aborted) {
promise.catch(() => undefined)
return Promise.reject(signal.reason)
}
return new Promise<T>((resolve, reject) => {
const abort = (): void => {
promise.catch(() => undefined)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', abort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
private async runCleanup(
operations: Array<() => void | Promise<void>>
): Promise<void> {
const cleanup = Promise.allSettled(
operations.map((operation) =>
Promise.resolve().then(operation)
)
).then(() => undefined)
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.cleanupTimeoutMs)
})
await Promise.race([cleanup, timeout])
if (timer !== undefined) {
clearTimeout(timer)
}
}
private async awaitOpening(operation: PortalOperation): Promise<void> {
const opening = this.openingPromise
if (!opening || this.operation !== operation) {
return
}
await opening.catch(() => undefined)
}
private setOwnedState(
operation: PortalOperation,
state: PortalSessionState
): void {
if (
this.operation === operation &&
this.operation.generation === operation.generation
) {
this.stateValue = state
}
}
}
type PortalOperation = {
generation: number
controller: AbortController
sessionHandle?: string
requestHandles: Set<string>
resources: Set<ClosableResource>
ownerLost: boolean
stopRequested: boolean
cleanupPromise?: Promise<void>
}
export type PipeWirePlaneMetadata = {
offset: number
stride: number
bytes: number
}
export type PipeWireFrameMetadata = {
width: number
height: number
stride: number
planes: PipeWirePlaneMetadata[]
byteLength: number
fpsNumerator: number
fpsDenominator: number
}
const isPositiveInteger = (value: number): boolean =>
Number.isSafeInteger(value) && value > 0
export function validatePipeWireFrameMetadata(
metadata: PipeWireFrameMetadata
): PipeWireFrameMetadata {
if (
!isPositiveInteger(metadata.width) ||
!isPositiveInteger(metadata.height) ||
metadata.width > 16_384 ||
metadata.height > 16_384 ||
!isPositiveInteger(metadata.stride) ||
metadata.stride < metadata.width ||
metadata.stride > 1_048_576 ||
!isPositiveInteger(metadata.byteLength) ||
metadata.byteLength > 256 * 1024 * 1024 ||
!isPositiveInteger(metadata.fpsNumerator) ||
!isPositiveInteger(metadata.fpsDenominator) ||
metadata.fpsNumerator / metadata.fpsDenominator > 240 ||
metadata.planes.length < 1 ||
metadata.planes.length > 4
) {
throw new Error('Invalid PipeWire frame metadata')
}
let previousEnd = 0
for (const [index, plane] of metadata.planes.entries()) {
if (
!Number.isSafeInteger(plane.offset) ||
plane.offset < 0 ||
!isPositiveInteger(plane.stride) ||
plane.stride > 1_048_576 ||
!isPositiveInteger(plane.bytes) ||
(index === 0 && plane.offset !== 0) ||
plane.offset < previousEnd ||
plane.offset + plane.bytes > metadata.byteLength ||
(index === 0 &&
(plane.stride !== metadata.stride ||
plane.bytes < metadata.stride * metadata.height))
) {
throw new Error('Invalid PipeWire plane metadata')
}
previousEnd = plane.offset + plane.bytes
}
if (previousEnd !== metadata.byteLength) {
throw new Error('PipeWire plane metadata does not cover the frame buffer')
}
return structuredClone(metadata)
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
buildDesktopHelperEnvironment,
type SecurePathMetadata,
type SessionEnvironmentFileSystem
} from './session-environment'
const safeMetadata = (
canonicalPath: string,
kind: 'file' | 'directory'
): SecurePathMetadata => ({
canonicalPath,
uid: 1000,
mode: kind === 'file' ? 0o600 : 0o700,
isDirectory: kind === 'directory',
isFile: kind === 'file',
isSymbolicLink: false
})
const fileSystem = (
overrides: Partial<SecurePathMetadata> = {}
): SessionEnvironmentFileSystem => ({
inspect: async (path) => ({
...safeMetadata(
path,
path.endsWith('.Xauthority') ? 'file' : 'directory'
),
...overrides
})
})
describe('buildDesktopHelperEnvironment', () => {
it('constructs a minimal fresh helper environment', async () => {
const environment = await buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source: {
PATH: '/usr/bin:/bin',
LANG: 'zh_CN.UTF-8',
DISPLAY: ':0.0',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
XAUTHORITY: '/home/user/.Xauthority',
NO_AT_BRIDGE: '1',
LD_PRELOAD: '/tmp/inject.so',
NODE_OPTIONS: '--require /tmp/inject.js',
GTK_MODULES: 'inject',
QT_PLUGIN_PATH: '/tmp/plugins',
HTTPS_PROXY: 'http://proxy.invalid',
API_KEY: 'secret'
}
})
expect(environment).toEqual({
PATH: '/usr/bin:/bin',
LANG: 'zh_CN.UTF-8',
DISPLAY: ':0.0',
WAYLAND_DISPLAY: 'wayland-1',
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
XAUTHORITY: '/home/user/.Xauthority',
NO_AT_BRIDGE: '1'
})
})
it.each([
['symbolic link', { isSymbolicLink: true }],
['world writable', { mode: 0o707 }],
['group writable', { mode: 0o720 }],
['wrong owner', { uid: 1001 }],
['non-canonical path', { canonicalPath: '/different' }]
])('rejects an unsafe %s runtime path', async (_name, override) => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(override),
source: { XDG_RUNTIME_DIR: '/run/user/1000' }
})
).rejects.toThrow('Unsafe directory path')
})
it.each([
{ DISPLAY: 'host:abc' },
{ WAYLAND_DISPLAY: '../wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' },
{ WAYLAND_DISPLAY: 'wayland-0' },
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus,bad' },
{ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus;tcp:' },
{ LANG: 'en_US.UTF-8\nINJECTED=1' },
{ PATH: '/usr/bin:relative/bin' },
{ PATH: '/usr/bin::/bin' }
])('rejects malformed desktop environment input', async (source) => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source
})
).rejects.toThrow()
})
it('does not forward values that merely resemble safe opt-ins', async () => {
await expect(
buildDesktopHelperEnvironment({
uid: 1000,
fileSystem: fileSystem(),
source: { NO_AT_BRIDGE: '0', LD_LIBRARY_PATH: '/tmp' }
})
).resolves.toEqual({})
})
})
@@ -0,0 +1,198 @@
import { isAbsolute, basename, posix } from 'node:path'
export type SecurePathMetadata = {
canonicalPath: string
uid: number
mode: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}
export interface SessionEnvironmentFileSystem {
inspect(path: string): Promise<SecurePathMetadata>
}
export type SessionEnvironmentOptions = {
source?: NodeJS.ProcessEnv
uid: number
fileSystem: SessionEnvironmentFileSystem
}
const DISPLAY_PATTERN = /^(?:(?:[A-Za-z0-9._-]+)?):\d+(?:\.\d+)?$/
const WAYLAND_BASENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
const LOCALE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/
const DBUS_VALUE_PATTERN = /^(?:[A-Za-z0-9._~:/@+-]|%[0-9A-Fa-f]{2})+$/
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const isSafePathText = (value: string): boolean =>
value.length >= 1 &&
value.length <= 8192 &&
!hasControlCharacters(value)
const assertSafePath = async (
path: string,
expectedKind: 'directory' | 'file',
options: SessionEnvironmentOptions
): Promise<string> => {
if (!isAbsolute(path) || !isSafePathText(path)) {
throw new Error(`Invalid ${expectedKind} path`)
}
const metadata = await options.fileSystem.inspect(path)
if (
metadata.isSymbolicLink ||
metadata.canonicalPath !== path ||
metadata.uid !== options.uid ||
(metadata.mode & 0o022) !== 0 ||
(expectedKind === 'directory'
? !metadata.isDirectory
: !metadata.isFile)
) {
throw new Error(`Unsafe ${expectedKind} path`)
}
return path
}
const validateBusAddress = (address: string): string => {
if (
address.length === 0 ||
address.length > 4096 ||
hasControlCharacters(address)
) {
throw new Error('Invalid session bus address')
}
const alternatives = address.split(';')
if (alternatives.some((entry) => entry.length === 0)) {
throw new Error('Invalid session bus address')
}
for (const entry of alternatives) {
const separator = entry.indexOf(':')
if (separator <= 0 || !/^[a-z][a-z0-9_-]*$/.test(entry.slice(0, separator))) {
throw new Error('Invalid session bus address')
}
const properties = entry.slice(separator + 1).split(',')
if (properties.some((property) => property.length === 0)) {
throw new Error('Invalid session bus address')
}
const seen = new Set<string>()
for (const property of properties) {
const equals = property.indexOf('=')
const key = property.slice(0, equals)
const value = property.slice(equals + 1)
if (
equals <= 0 ||
!/^[a-z][a-z0-9_-]*$/.test(key) ||
!DBUS_VALUE_PATTERN.test(value) ||
seen.has(key)
) {
throw new Error('Invalid session bus address')
}
seen.add(key)
}
}
return address
}
const copySafeValue = (
target: NodeJS.ProcessEnv,
source: NodeJS.ProcessEnv,
name: string,
pattern: RegExp
): void => {
const value = source[name]
if (value !== undefined) {
if (!pattern.test(value)) {
throw new Error(`Invalid ${name}`)
}
target[name] = value
}
}
const copySafePath = (
target: NodeJS.ProcessEnv,
source: NodeJS.ProcessEnv
): void => {
const value = source.PATH
if (value === undefined) {
return
}
const entries = value.split(':')
if (
!isSafePathText(value) ||
entries.length === 0 ||
entries.some((entry) => !posix.isAbsolute(entry))
) {
throw new Error('Invalid PATH')
}
target.PATH = value
}
/**
* Builds a new environment rather than filtering in place. Native desktop
* helpers must never inherit loader hooks, provider credentials, or proxies.
*/
export async function buildDesktopHelperEnvironment(
options: SessionEnvironmentOptions
): Promise<NodeJS.ProcessEnv> {
const source = options.source ?? process.env
const environment: NodeJS.ProcessEnv = {}
copySafePath(environment, source)
copySafeValue(environment, source, 'LANG', LOCALE_PATTERN)
copySafeValue(environment, source, 'LC_ALL', LOCALE_PATTERN)
copySafeValue(environment, source, 'LC_CTYPE', LOCALE_PATTERN)
const display = source.DISPLAY
if (display !== undefined) {
if (!DISPLAY_PATTERN.test(display)) {
throw new Error('Invalid DISPLAY')
}
environment.DISPLAY = display
}
const runtimeDirectory = source.XDG_RUNTIME_DIR
if (runtimeDirectory !== undefined) {
environment.XDG_RUNTIME_DIR = await assertSafePath(
runtimeDirectory,
'directory',
options
)
}
const waylandDisplay = source.WAYLAND_DISPLAY
if (waylandDisplay !== undefined) {
if (
basename(waylandDisplay) !== waylandDisplay ||
!WAYLAND_BASENAME_PATTERN.test(waylandDisplay) ||
runtimeDirectory === undefined
) {
throw new Error('Invalid WAYLAND_DISPLAY')
}
environment.WAYLAND_DISPLAY = waylandDisplay
}
const busAddress = source.DBUS_SESSION_BUS_ADDRESS
if (busAddress !== undefined) {
environment.DBUS_SESSION_BUS_ADDRESS = validateBusAddress(busAddress)
}
const authority = source.XAUTHORITY
if (authority !== undefined) {
environment.XAUTHORITY = await assertSafePath(
authority,
'file',
options
)
}
if (source.NO_AT_BRIDGE === '1') {
environment.NO_AT_BRIDGE = '1'
}
return environment
}
+73 -3
View File
@@ -6,6 +6,7 @@ import {
type AgentRuntimeDetection,
type AgentRuntimeStatus,
type AppInfo,
type BrowserLiveState,
type ContextAttachment,
type DesktopApi,
type KnowledgeLibrary,
@@ -17,7 +18,11 @@ import {
} from '../shared/contracts'
import { ipcChannels } from '../shared/ipc-channels'
import type {
BrowserProfileCreateInput,
BrowserProfileRenameInput,
CapabilityDiagnosticReport,
CapabilitySnapshot,
ComputerCapabilityId,
McpServerTestResult
} from '../shared/capability-contracts'
import type {
@@ -40,7 +45,8 @@ import type {
ScheduleCreateInput,
HeartbeatCreateInput,
HeartbeatUpdateInput,
ExpertCreateInput
ExpertCreateInput,
ExpertUpdateInput
} from '../shared/assistant-contracts'
const desktopApi: DesktopApi = {
@@ -116,6 +122,23 @@ const desktopApi: DesktopApi = {
return () => ipcRenderer.removeListener(ipcChannels.agentEvent, handler)
}
},
browser: {
stop: async (conversationId: string) => {
await ipcRenderer.invoke(
ipcChannels.browserStop,
{ conversationId }
)
},
onState: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: BrowserLiveState
): void => listener(payload)
ipcRenderer.on(ipcChannels.browserState, handler)
return () =>
ipcRenderer.removeListener(ipcChannels.browserState, handler)
}
},
settings: {
getRuntime: () =>
ipcRenderer.invoke(
@@ -329,7 +352,15 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.expertsCreate,
input
) as Promise<AssistantExpert>
) as Promise<AssistantExpert>,
update: (expertId: string, input: ExpertUpdateInput) =>
ipcRenderer.invoke(ipcChannels.expertsUpdate, {
expertId,
input
}) as Promise<AssistantExpert>,
remove: async (expertId: string) => {
await ipcRenderer.invoke(ipcChannels.expertsRemove, expertId)
}
},
capabilities: {
getSnapshot: () =>
@@ -369,7 +400,46 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.capabilitiesTestMcp,
serverId
) as Promise<McpServerTestResult>
) as Promise<McpServerTestResult>,
setComputerCapabilityEnabled: (
capabilityId: ComputerCapabilityId,
enabled: boolean
) =>
ipcRenderer.invoke(ipcChannels.capabilitiesToggleComputer, {
capabilityId,
enabled
}) as Promise<CapabilitySnapshot>,
setComputerCapabilityBrowserProfile: (
capabilityId: ComputerCapabilityId,
browserProfileId: string | null
) =>
ipcRenderer.invoke(ipcChannels.capabilitiesConfigureComputer, {
capabilityId,
browserProfileId
}) as Promise<CapabilitySnapshot>,
diagnoseComputerCapability: (capabilityId: ComputerCapabilityId) =>
ipcRenderer.invoke(
ipcChannels.capabilitiesDiagnoseComputer,
capabilityId
) as Promise<CapabilityDiagnosticReport>,
createBrowserProfile: (input: BrowserProfileCreateInput) =>
ipcRenderer.invoke(
ipcChannels.capabilitiesCreateBrowserProfile,
input
) as Promise<CapabilitySnapshot>,
renameBrowserProfile: (input: BrowserProfileRenameInput) =>
ipcRenderer.invoke(
ipcChannels.capabilitiesRenameBrowserProfile,
input
) as Promise<CapabilitySnapshot>,
setDefaultBrowserProfile: (profileId: string) =>
ipcRenderer.invoke(ipcChannels.capabilitiesDefaultBrowserProfile, {
profileId
}) as Promise<CapabilitySnapshot>,
removeBrowserProfile: (profileId: string) =>
ipcRenderer.invoke(ipcChannels.capabilitiesRemoveBrowserProfile, {
profileId
}) as Promise<CapabilitySnapshot>
},
context: {
selectFiles: () =>
+25
View File
@@ -11,4 +11,29 @@ describe('sandboxed preload', () => {
expect(source).not.toMatch(/\bfrom\s+['"]node:/u)
expect(source).not.toMatch(/\brequire\(\s*['"]node:/u)
})
it('does not load runtime schema libraries in the Electron sandbox', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).not.toMatch(/\b\w+Schema\b/u)
expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u)
})
it('exposes only explicit computer capability and managed profile IPC methods', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain('setComputerCapabilityEnabled:')
expect(source).toContain('diagnoseComputerCapability:')
expect(source).toContain('createBrowserProfile:')
expect(source).toContain('renameBrowserProfile:')
expect(source).toContain('setDefaultBrowserProfile:')
expect(source).toContain('removeBrowserProfile:')
expect(source).not.toMatch(
/(?:setComputerCapability|BrowserProfile).{0,80}(?:executablePath|command|env|args)/su
)
})
})
+131 -2
View File
@@ -8,10 +8,15 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
import type {
AgentEvent,
BrowserLiveState,
DesktopApi
} from '../../shared/contracts'
import App from './App'
let agentListener: ((event: AgentEvent) => void) | undefined
let browserListener: ((state: BrowserLiveState) => void) | undefined
let newConversationListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
const removeMaximizedChangedListener = vi.fn()
@@ -75,6 +80,15 @@ const api: DesktopApi = {
}
})
},
browser: {
stop: vi.fn(async () => {}),
onState: vi.fn((listener) => {
browserListener = listener
return () => {
browserListener = undefined
}
})
},
settings: {
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
provider: 'auto',
@@ -319,7 +333,15 @@ const api: DesktopApi = {
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
}))
})),
update: vi.fn(async (expertId, input) => ({
...input,
id: expertId,
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
})),
remove: vi.fn(async () => {})
},
capabilities: {
getSnapshot: vi.fn(async () => ({
@@ -413,6 +435,7 @@ describe('App', () => {
document.documentElement.style.colorScheme = ''
vi.clearAllMocks()
newConversationListener = undefined
browserListener = undefined
maximizedChangedListener = undefined
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
@@ -452,6 +475,22 @@ describe('App', () => {
expect(removeMaximizedChangedListener).toHaveBeenCalledOnce()
})
it('keeps rendering when an older preload has no browser bridge', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
...api,
browser: undefined
}
})
render(<App />)
expect(
await screen.findByLabelText('向 GoodBuddy 提问')
).toBeInTheDocument()
})
it('keeps conversation actions in the conversation list', async () => {
const { container } = render(<App />)
const topbar = container.querySelector<HTMLElement>('.topbar')
@@ -938,6 +977,55 @@ describe('App', () => {
expect(within(stats).getByText('345')).toBeInTheDocument()
})
it('offers only Ask and Execute in visible work mode controls', async () => {
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(
within(mode)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const defaultMode = within(dialog).getByRole('combobox', {
name: '默认模式'
})
expect(
within(defaultMode)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
it('normalizes a legacy Plan project default to Ask', async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
...project,
defaultWorkMode: 'plan'
}
])
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '制定发布方案' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '制定发布方案',
workMode: 'ask'
})
)
)
})
it.each([
['opencode', 'OpenCode'],
['continue', 'Continue CLI']
@@ -1349,6 +1437,47 @@ describe('App', () => {
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('opens the live browser tab for the active conversation and can stop it', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '打开示例网页' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const conversationId = run.mock.calls[0]?.[0].conversationId
expect(conversationId).toBeTruthy()
act(() => {
browserListener?.({
conversationId: conversationId ?? '',
status: 'ready',
url: 'https://example.com/',
frameDataUrl: 'data:image/png;base64,iVBORw0KGgo=',
updatedAt: Date.now()
})
})
expect(screen.getByLabelText('助手工作栏')).toHaveClass(
'assistant-sidebar--open'
)
expect(
screen.getByRole('tab', { name: '浏览器' })
).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByAltText('Agent 实时浏览器画面')
).toHaveAttribute(
'src',
'data:image/png;base64,iVBORw0KGgo='
)
fireEvent.click(
screen.getByRole('button', { name: '停止浏览器' })
)
await waitFor(() =>
expect(api.browser.stop).toHaveBeenCalledWith(conversationId)
)
})
it('opens Smart Heartbeat as a first-class workspace', async () => {
render(<App />)
+114 -26
View File
@@ -40,6 +40,7 @@ import type {
AgentEvent,
AgentRuntimeStatus,
AppInfo,
BrowserLiveState,
ContextAttachment,
KnowledgeSearchReference,
KnowledgeSnapshot,
@@ -60,9 +61,13 @@ import type {
TokenUsageSummary,
ConversationSnapshot,
ProjectCreateInput,
WorkMode,
InteractiveWorkMode,
WorkspaceChanges
} from '../../shared/assistant-contracts'
import {
interactiveWorkModes,
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
loadActivityRecords,
@@ -110,6 +115,7 @@ type ToolActivity = {
| 'running'
| 'completed'
| 'failed'
| 'recoverable'
| 'cancelled'
| 'interrupted'
summary: string
@@ -194,6 +200,7 @@ const toolStateLabels: Record<ToolActivity['state'], string> = {
running: '进行中',
completed: '已完成',
failed: '失败',
recoverable: '可重试',
cancelled: '已取消',
interrupted: '已中断'
}
@@ -552,7 +559,8 @@ function App(): React.JSX.Element {
const workspaceChangesRequestRef = useRef(0)
const viewRef = useRef<WorkspaceView>('chat')
const heartbeatLoadRequestRef = useRef(0)
const [workMode, setWorkMode] = useState<WorkMode>('ask')
const [workMode, setWorkMode] =
useState<InteractiveWorkMode>('ask')
const [input, setInput] = useState('')
const [voiceListening, setVoiceListening] = useState(false)
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
@@ -585,6 +593,9 @@ function App(): React.JSX.Element {
)
const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>('tasks')
const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState>
>({})
const [view, setView] = useState<WorkspaceView>('chat')
const [searchQuery, setSearchQuery] = useState('')
const [conversationActionsId, setConversationActionsId] = useState('')
@@ -1286,7 +1297,9 @@ function App(): React.JSX.Element {
const project = value[0]!
setProjects(value)
setActiveProjectId(project.id)
setWorkMode(project.defaultWorkMode)
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
let nextConversations: Conversation[] =
persistedConversations.length > 0
? persistedConversations
@@ -1665,6 +1678,41 @@ function App(): React.JSX.Element {
}
}, [handleAgentEvent])
useEffect(
() => {
const browserApi = window.goodbuddy.browser
if (!browserApi) {
return
}
return browserApi.onState((state) => {
setBrowserStates((current) => {
const previous = current[state.conversationId]
return {
...current,
[state.conversationId]:
state.status === 'stopped' ||
state.frameDataUrl ||
!previous?.frameDataUrl
? state
: {
...state,
frameDataUrl: previous.frameDataUrl
}
}
})
if (
state.status !== 'stopped' &&
state.conversationId ===
conversationNavigationRef.current.activeId
) {
setAssistantSidebarOpen(true)
setAssistantSidebarTab('browser')
}
})
},
[]
)
useEffect(
() =>
window.goodbuddy.app.onNewConversation(() => {
@@ -1689,7 +1737,7 @@ function App(): React.JSX.Element {
return
}
setActiveProjectId(projectId)
setWorkMode(project.defaultWorkMode)
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
const conversation = conversations.find(
(candidate) => candidate.projectId === projectId
)
@@ -1709,7 +1757,7 @@ function App(): React.JSX.Element {
const project = await window.goodbuddy.projects.create(input)
setProjects((current) => [project, ...current])
setActiveProjectId(project.id)
setWorkMode(project.defaultWorkMode)
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
const conversation = createConversation(project.id)
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
@@ -1747,7 +1795,7 @@ function App(): React.JSX.Element {
const useHeartbeatTask = (task: AssistantTask): void => {
newConversation()
setWorkMode('plan')
setWorkMode('ask')
setInput(
[
'请根据以下智能心跳建议制定可执行方案:',
@@ -1790,6 +1838,17 @@ function App(): React.JSX.Element {
if (activeRequest) {
void window.goodbuddy.agent.cancel(activeRequest)
}
const browserStop = window.goodbuddy.browser?.stop(conversationId)
if (browserStop) {
void browserStop.catch(() => {
setNotice('关闭已删除对话的浏览器失败')
})
}
setBrowserStates((current) => {
const next = { ...current }
delete next[conversationId]
return next
})
const remaining = conversations.filter(
(conversation) => conversation.id !== conversationId
)
@@ -2232,7 +2291,9 @@ function App(): React.JSX.Element {
)
setActiveProjectId(conversation.projectId)
if (project) {
setWorkMode(project.defaultWorkMode)
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
}
setActiveId(conversationId)
@@ -3129,24 +3190,24 @@ function App(): React.JSX.Element {
aria-label="工作模式"
disabled={agentRuntimeSelected}
onChange={(event) =>
setWorkMode(event.target.value as WorkMode)
setWorkMode(
event.target.value as InteractiveWorkMode
)
}
value={effectiveWorkMode}
>
{Object.entries(workModeLabels).map(
([value, label]) => (
<option
disabled={
value === 'execute' &&
!runtime?.supportsToolExecution
}
key={value}
value={value}
>
{label}
</option>
)
)}
{interactiveWorkModes.map((value) => (
<option
disabled={
value === 'execute' &&
!runtime?.supportsToolExecution
}
key={value}
value={value}
>
{workModeLabels[value]}
</option>
))}
</select>
</label>
<div className="runtime-picker">
@@ -3288,10 +3349,8 @@ function App(): React.JSX.Element {
: agentRuntimeSelected
? `${runtime.label} 固定为 Execute,工具调用不会弹出 GoodBuddy 审批,并会记录到活动。`
: effectiveWorkMode === 'ask'
? 'Ask 模式:只读问答,不会调用工具或修改文件。'
: effectiveWorkMode === 'plan'
? 'Plan 模式:只读制定计划,不会调用工具或修改文件。'
: 'Execute 模式:可执行工具,调用前请检查参数和权限。')}
? 'Ask 模式:只读问答,不会调用工具或修改文件。'
: 'Execute 模式:已启用工具自动授权,调用仍会记录到活动。')}
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
@@ -3471,6 +3530,19 @@ function App(): React.JSX.Element {
onClearLocalData={clearLocalData}
onClose={() => setView('chat')}
onCreateHeartbeat={createHeartbeat}
onExpertsChanged={(experts) => {
setAssistantExperts(experts)
if (
(selectedExpertId === 'team' && experts.length < 2) ||
(selectedExpertId &&
selectedExpertId !== 'team' &&
!experts.some(
(expert) => expert.id === selectedExpertId
))
) {
setSelectedExpertId('')
}
}}
onRemoveHeartbeat={removeHeartbeat}
onRunHeartbeat={runHeartbeat}
onSaved={(settings) => {
@@ -3511,11 +3583,27 @@ function App(): React.JSX.Element {
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
attachments={attachments}
browserState={browserStates[activeId]}
enabledLibraries={enabledSidebarLibraries}
heartbeatEntries={heartbeatEntries}
heartbeats={assistantHeartbeats}
memories={assistantMemories}
onClose={() => setAssistantSidebarOpen(false)}
onStopBrowser={async () => {
if (!activeId) {
return
}
const browserApi = window.goodbuddy.browser
if (!browserApi) {
setNotice('浏览器控制组件尚未加载,请重启 GoodBuddy')
return
}
try {
await browserApi.stop(activeId)
} catch {
setNotice('停止浏览器失败,请重试')
}
}}
onOpenHeartbeat={() => setView('heartbeat')}
onCreateMemory={async (content) => {
const memory = await window.goodbuddy.memory.create({
+353 -5
View File
@@ -1,15 +1,23 @@
import {
CircleAlert,
FlaskConical,
Globe2,
MonitorCog,
Network,
Pencil,
Plus,
RefreshCw,
Trash2,
Wrench,
X
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type {
CapabilityDiagnosticReport,
CapabilityAssignments,
CapabilitySnapshot,
ComputerCapabilityId,
McpServerInput,
McpServerSummary,
McpServerTestResult,
@@ -23,6 +31,15 @@ const runtimeLabels: Record<RuntimeTarget, string> = {
continue: 'Continue'
}
const configurableMcpTargets: RuntimeTarget[] = ['model']
const diagnosticStatusLabels: Record<
CapabilityDiagnosticReport['status'],
string
> = {
available: '可用',
degraded: '部分可用',
unavailable: '不可用',
disabled: '未启用'
}
type McpEditor = {
id?: string
@@ -77,6 +94,13 @@ export function McpSettingsSection(): React.JSX.Element {
const [testResults, setTestResults] = useState<
Record<string, McpServerTestResult>
>({})
const [diagnostics, setDiagnostics] = useState<
Partial<Record<ComputerCapabilityId, CapabilityDiagnosticReport>>
>({})
const [newProfileName, setNewProfileName] = useState('')
const [profileNames, setProfileNames] = useState<Record<string, string>>(
{}
)
useEffect(() => {
void window.goodbuddy.capabilities
@@ -97,13 +121,51 @@ export function McpSettingsSection(): React.JSX.Element {
setSnapshot(await operation())
return true
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'MCP 操作失败')
setError(reason instanceof Error ? reason.message : '能力设置操作失败')
return false
} finally {
setBusy(undefined)
}
}
const diagnose = async (
capabilityId: ComputerCapabilityId
): Promise<void> => {
setBusy(`diagnose:${capabilityId}`)
setError(undefined)
try {
const diagnoseCapability =
window.goodbuddy.capabilities.diagnoseComputerCapability
if (!diagnoseCapability) {
throw new Error('当前版本不支持电脑控制能力诊断')
}
const report = await diagnoseCapability(capabilityId)
setDiagnostics((current) => ({
...current,
[capabilityId]: report
}))
} catch (reason) {
setError(reason instanceof Error ? reason.message : '能力诊断失败')
} finally {
setBusy(undefined)
}
}
const createProfile = async (): Promise<void> => {
const name = newProfileName.trim()
if (!name) {
return
}
if (
await run('profile:create', () =>
window.goodbuddy.capabilities.createBrowserProfile?.({ name }) ??
Promise.reject(new Error('当前版本不支持托管浏览器配置'))
)
) {
setNewProfileName('')
}
}
const save = async (): Promise<void> => {
if (!editor) {
return
@@ -178,13 +240,19 @@ export function McpSettingsSection(): React.JSX.Element {
})
}
const computerCapabilities = snapshot?.computerCapabilities ?? []
const browserProfiles = snapshot?.browserProfiles ?? {
profiles: [],
defaultProfileId: null
}
return (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
<Network size={17} />
<div>
<strong>MCP Servers</strong>
<small> stdioStreamable HTTP SSE</small>
<strong> MCP</strong>
<small> MCP Server</small>
</div>
<button
className="secondary-button"
@@ -198,11 +266,281 @@ export function McpSettingsSection(): React.JSX.Element {
</div>
<p className="settings-notice">
MCP Server 访
Execute MCP GoodBuddy
GoodBuddy MCP Server MCP Server
访
Execute
GoodBuddy
</p>
{error && <p className="settings-warning">{error}</p>}
<section
aria-labelledby="computer-capabilities-heading"
className="mcp-tool-section"
>
<div className="mcp-subsection-heading">
<div>
<MonitorCog size={15} />
<strong id="computer-capabilities-heading"></strong>
</div>
<small></small>
</div>
<div className="capability-list">
{computerCapabilities.map((capability) => {
const report = diagnostics[capability.id]
return (
<article className="capability-card" key={capability.id}>
<div className="capability-card__header">
<div>
<strong>{capability.name}</strong>
<small>
{capability.supported ? '当前设备支持' : '当前设备不支持'} ·{' '}
{capability.enabled ? '已启用' : '已停用'}
</small>
</div>
<label className="capability-switch">
<input
aria-label={`启用 ${capability.name}`}
checked={capability.enabled}
disabled={Boolean(busy) || !capability.supported}
onChange={(event) =>
void run(`computer:${capability.id}`, () =>
window.goodbuddy.capabilities.setComputerCapabilityEnabled?.(
capability.id,
event.target.checked
) ??
Promise.reject(
new Error('当前版本不支持电脑控制能力')
)
)
}
type="checkbox"
/>
<span>{capability.enabled ? '已启用' : '已停用'}</span>
</label>
</div>
<p>{capability.description}</p>
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{capability.riskSummary}
</p>
{capability.id === 'host-browser-control' && (
<label className="field computer-capability-profile">
<span></span>
<select
aria-label="浏览器控制使用的托管配置"
disabled={Boolean(busy)}
onChange={(event) =>
void run('computer:profile', () =>
window.goodbuddy.capabilities.setComputerCapabilityBrowserProfile?.(
capability.id,
event.target.value || null
) ??
Promise.reject(
new Error('当前版本不支持托管浏览器配置')
)
)
}
value={capability.browserProfileId ?? ''}
>
<option value="">使</option>
{browserProfiles.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
</label>
)}
<div className="capability-diagnostic">
<button
aria-label={`诊断 ${capability.name}`}
className="secondary-button"
disabled={Boolean(busy)}
onClick={() => void diagnose(capability.id)}
type="button"
>
<RefreshCw size={13} />
{busy === `diagnose:${capability.id}`
? '诊断中…'
: '运行诊断'}
</button>
{report && (
<div aria-live="polite" className="capability-diagnostic__result">
<strong>
{diagnosticStatusLabels[report.status]}
</strong>
{report.checks.map((check) => (
<p key={check.id}>
{check.summary}
{check.remedy ? ` 处理建议:${check.remedy}` : ''}
</p>
))}
</div>
)}
</div>
</article>
)
})}
</div>
</section>
<section
aria-labelledby="browser-profiles-heading"
className="mcp-tool-section"
>
<div className="mcp-subsection-heading">
<div>
<Globe2 size={15} />
<strong id="browser-profiles-heading"></strong>
</div>
<small>{browserProfiles.profiles.length} </small>
</div>
<p className="settings-notice">
使 GoodBuddy
</p>
<div className="browser-profile-create">
<label className="field">
<span></span>
<input
onChange={(event) => setNewProfileName(event.target.value)}
placeholder="例如:工作网站"
value={newProfileName}
/>
</label>
<button
className="secondary-button"
disabled={Boolean(busy) || !newProfileName.trim()}
onClick={() => void createProfile()}
type="button"
>
<Plus size={13} />
</button>
</div>
<div className="browser-profile-list">
{browserProfiles.profiles.length === 0 && (
<p className="settings-empty"></p>
)}
{browserProfiles.profiles.map((profile) => {
const referenced = computerCapabilities.some(
(capability) =>
capability.browserProfileId === profile.id
)
return (
<article className="browser-profile-row" key={profile.id}>
<label className="field">
<span></span>
<input
aria-label={`配置名称 ${profile.name}`}
onChange={(event) =>
setProfileNames((current) => ({
...current,
[profile.id]: event.target.value
}))
}
value={profileNames[profile.id] ?? profile.name}
/>
</label>
<label className="browser-profile-default">
<input
aria-label={`设为默认配置 ${profile.name}`}
checked={
browserProfiles.defaultProfileId === profile.id
}
disabled={Boolean(busy)}
name="default-browser-profile"
onChange={() =>
void run(`profile:default:${profile.id}`, () =>
window.goodbuddy.capabilities.setDefaultBrowserProfile?.(
profile.id
) ??
Promise.reject(
new Error('当前版本不支持托管浏览器配置')
)
)
}
type="radio"
/>
</label>
<button
aria-label={`重命名配置 ${profile.name}`}
className="secondary-button"
disabled={
Boolean(busy) ||
!(profileNames[profile.id] ?? '').trim() ||
profileNames[profile.id] === profile.name
}
onClick={() =>
void run(`profile:rename:${profile.id}`, () =>
window.goodbuddy.capabilities.renameBrowserProfile?.({
profileId: profile.id,
name: profileNames[profile.id] ?? profile.name
}) ??
Promise.reject(
new Error('当前版本不支持托管浏览器配置')
)
)
}
type="button"
>
<Pencil size={13} />
</button>
<button
aria-label={`删除配置 ${profile.name}`}
className="danger-ghost"
disabled={Boolean(busy) || referenced}
onClick={() =>
void run(`profile:remove:${profile.id}`, () =>
window.goodbuddy.capabilities.removeBrowserProfile?.(
profile.id
) ??
Promise.reject(
new Error('当前版本不支持托管浏览器配置')
)
)
}
title={referenced ? '此配置正被电脑控制能力使用' : undefined}
type="button"
>
<Trash2 size={13} />
</button>
</article>
)
})}
</div>
</section>
<div className="mcp-tool-section">
<div className="mcp-subsection-heading">
<div>
<Wrench size={15} />
<strong></strong>
</div>
<small>{builtinModelTools.length} </small>
</div>
<div className="capability-list capability-list--tools">
{builtinModelTools.map((tool) => (
<article className="capability-card" key={tool.name}>
<div className="capability-card__header">
<div>
<strong>{tool.displayName}</strong>
<small>
GoodBuddy ·{' '}
{tool.access === 'write' ? '写入工具' : '只读工具'}
</small>
</div>
<span className="builtin-tool-badge"></span>
</div>
<p>{tool.description}</p>
<code>{tool.name}</code>
</article>
))}
</div>
</div>
{editor && (
<div className="mcp-editor">
<div className="mcp-editor__header">
@@ -381,6 +719,16 @@ export function McpSettingsSection(): React.JSX.Element {
</div>
)}
<div className="mcp-subsection-heading">
<div>
<Network size={15} />
<strong> MCP Servers</strong>
</div>
<small>{snapshot?.mcpServers.length ?? 0} </small>
</div>
<p className="settings-notice">
stdio MCP 使
</p>
<div className="capability-list">
{snapshot?.mcpServers.length === 0 && !editor && (
<p className="settings-empty"> MCP Server</p>
+5 -4
View File
@@ -2,9 +2,11 @@ import { Archive, FolderOpen, Plus, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type {
AssistantProject,
InteractiveWorkMode,
ProjectCreateInput,
WorkMode
} from '../../shared/assistant-contracts'
import { interactiveWorkModes } from '../../shared/assistant-contracts'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -15,9 +17,8 @@ type ProjectSwitcherProps = {
onSelectRoot: () => Promise<string | undefined>
}
export const workModeLabels: Record<WorkMode, string> = {
export const workModeLabels: Record<InteractiveWorkMode, string> = {
ask: 'Ask · 只读问答',
plan: 'Plan · 先审计划',
execute: 'Execute · 受控执行'
}
@@ -215,9 +216,9 @@ export function ProjectSwitcher({
}
value={draft.defaultWorkMode}
>
{Object.entries(workModeLabels).map(([value, label]) => (
{interactiveWorkModes.map((value) => (
<option key={value} value={value}>
{label}
{workModeLabels[value]}
</option>
))}
</select>
@@ -0,0 +1,151 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RightAssistantSidebar } from './RightAssistantSidebar'
afterEach(cleanup)
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
})
})
function renderSidebar(): HTMLElement {
render(
<RightAssistantSidebar
activities={[]}
approvals={[]}
artifacts={[]}
attachments={[]}
enabledLibraries={[]}
heartbeatEntries={[]}
heartbeats={[]}
memories={[]}
onClose={vi.fn()}
onCreateHeartbeat={vi.fn(async () => undefined)}
onCreateMemory={vi.fn(async () => undefined)}
onCreateSchedule={vi.fn(async () => undefined)}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
path,
entries: [],
truncated: false
}))}
onLoadArtifact={vi.fn(async () => undefined)}
onLoadWorkspaceFile={vi.fn()}
onOpenConversation={vi.fn()}
onOpenHeartbeat={vi.fn()}
onRefreshChanges={vi.fn(async () => undefined)}
onRemoveAttachment={vi.fn()}
onRemoveHeartbeat={vi.fn(async () => undefined)}
onRemoveMemory={vi.fn(async () => undefined)}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunHeartbeat={vi.fn(async () => undefined)}
onRunSchedule={vi.fn(async () => undefined)}
onSetHeartbeatPaused={vi.fn(async () => undefined)}
onSetMemoryStatus={vi.fn(async () => undefined)}
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
schedules={[]}
tab="context"
tasks={[]}
/>
)
return screen.getByRole('complementary', {
name: '助手工作栏'
})
}
describe('RightAssistantSidebar resizing', () => {
it('resizes with pointer capture and clamps the resulting width', () => {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
})
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
Object.defineProperties(separator, {
setPointerCapture: { value: setPointerCapture },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: releasePointerCapture }
})
fireEvent.pointerDown(separator, {
button: 0,
clientX: 900,
pointerId: 7
})
fireEvent.pointerMove(separator, {
clientX: 600,
pointerId: 7
})
expect(setPointerCapture).toHaveBeenCalledWith(7)
expect(sidebar).toHaveClass('assistant-sidebar--resizing')
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('640px')
fireEvent.pointerUp(separator, { pointerId: 7 })
expect(releasePointerCapture).toHaveBeenCalledWith(7)
expect(sidebar).not.toHaveClass('assistant-sidebar--resizing')
})
it('supports arrow, Home, and End keyboard resizing', () => {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
})
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('366px')
expect(separator).toHaveAttribute('aria-valuenow', '366')
fireEvent.keyDown(separator, { key: 'Home' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('300px')
fireEvent.keyDown(separator, { key: 'End' })
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('640px')
})
it('remains resizable when the sidebar overlays a medium window', () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1024
})
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
name: '调整助手工作栏宽度'
})
Object.defineProperties(separator, {
setPointerCapture: { value: vi.fn() },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: vi.fn() }
})
expect(separator).toHaveAttribute('tabindex', '0')
fireEvent.pointerDown(separator, {
button: 0,
clientX: 674,
pointerId: 8
})
fireEvent.pointerMove(separator, {
clientX: 600,
pointerId: 8
})
expect(
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('424px')
})
})
+262 -12
View File
@@ -5,6 +5,7 @@ import {
FileText,
FolderTree,
Hourglass,
Monitor,
PanelRightClose,
PlayCircle,
RefreshCw,
@@ -13,7 +14,7 @@ import {
X,
XCircle
} from 'lucide-react'
import { useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
AssistantMemory,
AssistantSchedule,
@@ -29,6 +30,7 @@ import type {
import { MarkdownRenderer } from './MarkdownRenderer'
import type {
ApprovalDecision,
BrowserLiveState,
ContextAttachment,
KnowledgeLibrary
} from '../../shared/contracts'
@@ -41,6 +43,7 @@ export type AssistantSidebarTab =
| 'context'
| 'artifacts'
| 'changes'
| 'browser'
| 'preview'
export type SidebarArtifact = {
@@ -75,7 +78,9 @@ type RightAssistantSidebarProps = {
heartbeatEntries: AssistantHeartbeatEntry[]
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
onClose: () => void
onStopBrowser: () => Promise<void>
onOpenHeartbeat: () => void
onOpenConversation: (conversationId: string) => void
onImportArtifacts: () => Promise<void>
@@ -117,15 +122,44 @@ const tabs: Array<{
{ id: 'context', label: '上下文' },
{ id: 'artifacts', label: '成果' },
{ id: 'changes', label: '更改' },
{ id: 'browser', label: '浏览器' },
{ id: 'preview', label: '预览' }
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarWidth = 350
const minimumSidebarWidth = 300
const maximumSidebarWidth = 640
const minimumRemainingAppWidth = 520
const compactSidebarBreakpoint = 720
const keyboardResizeStep = 16
const sidebarTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
function getSidebarWidthLimits(viewportWidth: number): {
minimum: number
maximum: number
} {
return {
minimum: minimumSidebarWidth,
maximum: Math.max(
minimumSidebarWidth,
Math.min(
maximumSidebarWidth,
viewportWidth - minimumRemainingAppWidth
)
)
}
}
function clampSidebarWidth(width: number, viewportWidth: number): number {
const limits = getSidebarWidthLimits(viewportWidth)
return Math.min(limits.maximum, Math.max(limits.minimum, width))
}
function formatTime(timestamp: number | string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}).format(new Date(timestamp))
return sidebarTimeFormatter.format(new Date(timestamp))
}
export function RightAssistantSidebar({
@@ -143,7 +177,9 @@ export function RightAssistantSidebar({
heartbeatEntries,
workspaceChanges,
workspaceProjectId,
browserState,
onClose,
onStopBrowser,
onOpenHeartbeat,
onOpenConversation,
onImportArtifacts,
@@ -165,6 +201,12 @@ export function RightAssistantSidebar({
onRespondApproval,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
const [viewportWidth, setViewportWidth] = useState(window.innerWidth)
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const liveSidebarWidth = useRef(defaultSidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined)
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
const [workspacePreview, setWorkspacePreview] = useState<
| {
@@ -198,12 +240,20 @@ export function RightAssistantSidebar({
const [scheduleRecurrence, setScheduleRecurrence] = useState<
ScheduleCreateInput['recurrence']
>('once')
const recentTasks = activities
.filter((activity) => activity.kind === 'request')
.slice(0, 20)
const changes = activities
.filter((activity) => activity.kind === 'tool')
.slice(0, 30)
const recentTasks = useMemo(
() =>
activities
.filter((activity) => activity.kind === 'request')
.slice(0, 20),
[activities]
)
const changes = useMemo(
() =>
activities
.filter((activity) => activity.kind === 'tool')
.slice(0, 30),
[activities]
)
const artifactPreview =
artifacts.find((artifact) => artifact.id === selectedArtifactId) ??
artifacts[0]
@@ -211,6 +261,86 @@ export function RightAssistantSidebar({
workspacePreview?.projectId === workspaceProjectId
? workspacePreview
: undefined
const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth)
const canResize =
open && viewportWidth >= compactSidebarBreakpoint
useEffect(() => {
const handleViewportResize = (): void => {
setViewportWidth(window.innerWidth)
setSidebarWidth((currentWidth) => {
const width = clampSidebarWidth(
currentWidth,
window.innerWidth
)
liveSidebarWidth.current = width
return width
})
}
window.addEventListener('resize', handleViewportResize)
return () => window.removeEventListener('resize', handleViewportResize)
}, [])
const resizeFromClientX = (
clientX: number,
commit: boolean
): void => {
const width = clampSidebarWidth(
window.innerWidth - clientX,
window.innerWidth
)
liveSidebarWidth.current = width
if (commit) {
setSidebarWidth(width)
return
}
sidebarRef.current?.style.setProperty(
'--assistant-sidebar-width',
`${width}px`
)
}
const finishResize = (
event: React.PointerEvent<HTMLDivElement>
): void => {
if (resizePointerId.current !== event.pointerId) {
return
}
resizePointerId.current = undefined
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setSidebarWidth(liveSidebarWidth.current)
setIsResizing(false)
}
const resizeWithKeyboard = (
event: React.KeyboardEvent<HTMLDivElement>
): void => {
if (!canResize) {
return
}
const limits = getSidebarWidthLimits(window.innerWidth)
const nextWidth =
event.key === 'Home'
? limits.minimum
: event.key === 'End'
? limits.maximum
: event.key === 'ArrowLeft'
? sidebarWidth + keyboardResizeStep
: event.key === 'ArrowRight'
? sidebarWidth - keyboardResizeStep
: undefined
if (nextWidth === undefined) {
return
}
event.preventDefault()
setSidebarWidth(
clampSidebarWidth(nextWidth, window.innerWidth)
)
}
const openWorkspaceFile = (path: string): void => {
const requestId = workspacePreviewRequest.current + 1
@@ -276,15 +406,65 @@ export function RightAssistantSidebar({
return (
<aside
ref={sidebarRef}
aria-label="助手工作栏"
aria-hidden={!open}
className={
open
? 'assistant-sidebar assistant-sidebar--open'
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
: 'assistant-sidebar'
}
inert={!open}
style={
{
'--assistant-sidebar-width': `${sidebarWidth}px`
} as React.CSSProperties
}
>
<div
aria-controls="assistant-sidebar-panel"
aria-label="调整助手工作栏宽度"
aria-orientation="vertical"
aria-valuemax={sidebarWidthLimits.maximum}
aria-valuemin={sidebarWidthLimits.minimum}
aria-valuenow={sidebarWidth}
aria-valuetext={`${sidebarWidth} 像素`}
aria-disabled={!canResize}
className="assistant-sidebar__resize-handle"
onKeyDown={resizeWithKeyboard}
onLostPointerCapture={(event) => {
if (resizePointerId.current === event.pointerId) {
resizePointerId.current = undefined
setSidebarWidth(liveSidebarWidth.current)
setIsResizing(false)
}
}}
onPointerCancel={finishResize}
onPointerDown={(event) => {
if (event.button !== 0 || !canResize) {
return
}
event.preventDefault()
resizePointerId.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
resizeFromClientX(event.clientX, true)
setIsResizing(true)
}}
onPointerMove={(event) => {
if (resizePointerId.current !== event.pointerId) {
return
}
if (!canResize) {
finishResize(event)
return
}
event.preventDefault()
resizeFromClientX(event.clientX, false)
}}
onPointerUp={finishResize}
role="separator"
tabIndex={canResize ? 0 : -1}
/>
<header className="assistant-sidebar__header">
<strong></strong>
<button
@@ -800,6 +980,76 @@ export function RightAssistantSidebar({
</>
)}
{tab === 'browser' && (
<section className="assistant-sidebar__browser">
<header>
<span>
<Monitor size={15} />
<strong></strong>
</span>
{browserState &&
browserState.status !== 'stopped' && (
<button
className="secondary-button"
onClick={() => void onStopBrowser()}
type="button"
>
</button>
)}
</header>
{!browserState ? (
<p className="assistant-sidebar__empty">
Agent
</p>
) : (
<>
<div
aria-live="polite"
className={`assistant-sidebar__browser-status assistant-sidebar__browser-status--${browserState.status}`}
role="status"
>
{browserState.status === 'creating'
? '正在启动浏览器…'
: browserState.status === 'loading'
? '正在加载页面…'
: browserState.status === 'acting'
? 'Agent 正在操作页面…'
: browserState.status === 'ready'
? '浏览器已就绪'
: browserState.status === 'failed'
? browserState.error ?? '浏览器操作失败'
: '浏览器已停止'}
</div>
{browserState.url && (
<div
className="assistant-sidebar__browser-url"
title={browserState.url}
>
{browserState.url}
</div>
)}
{browserState.frameDataUrl ? (
<img
alt="Agent 实时浏览器画面"
className="assistant-sidebar__browser-frame"
src={browserState.frameDataUrl}
/>
) : (
<div className="assistant-sidebar__browser-placeholder">
<Monitor size={28} />
<span>
{browserState.status === 'failed'
? '未能获取页面画面'
: '等待首个页面画面…'}
</span>
</div>
)}
</>
)}
</section>
)}
{tab === 'preview' && (
<section className="assistant-sidebar__preview">
{currentWorkspacePreview ? (
@@ -0,0 +1,303 @@
import { Bot, Plus, Save, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
AssistantExpert,
ExpertCreateInput
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
type ExpertDraft = ExpertCreateInput & {
id?: string
}
type RolePromptSettingsSectionProps = {
onChanged: (experts: AssistantExpert[]) => void
}
const emptyDraft: ExpertDraft = {
name: '',
description: '',
systemInstructions: ''
}
function draftFromExpert(expert: AssistantExpert): ExpertDraft {
return {
id: expert.id,
name: expert.name,
description: expert.description,
systemInstructions: expert.systemInstructions
}
}
function sortExperts(experts: AssistantExpert[]): AssistantExpert[] {
return [...experts].sort((left, right) =>
left.name.localeCompare(right.name, 'zh-CN')
)
}
export function RolePromptSettingsSection({
onChanged
}: RolePromptSettingsSectionProps): React.JSX.Element {
const [experts, setExperts] = useState<AssistantExpert[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [draft, setDraft] = useState<ExpertDraft>()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState(false)
useEffect(() => {
void window.goodbuddy.experts
.list()
.then((items) => {
const sorted = sortExperts(items)
setExperts(sorted)
if (sorted[0]) {
setSelectedId(sorted[0].id)
setDraft(draftFromExpert(sorted[0]))
}
})
.catch((reason: unknown) => {
setError(
reason instanceof Error ? reason.message : '读取角色失败'
)
})
}, [])
const selectExpert = (expert: AssistantExpert): void => {
setSelectedId(expert.id)
setDraft(draftFromExpert(expert))
setConfirmingRemove(false)
setError(undefined)
}
const createDraft = (): void => {
setSelectedId(undefined)
setDraft({ ...emptyDraft })
setConfirmingRemove(false)
setError(undefined)
}
const save = async (): Promise<void> => {
if (!draft) {
return
}
setBusy(true)
setError(undefined)
try {
const input: ExpertCreateInput = {
name: draft.name,
description: draft.description,
systemInstructions: draft.systemInstructions
}
const saved = draft.id
? await window.goodbuddy.experts.update(draft.id, input)
: await window.goodbuddy.experts.create(input)
const next = sortExperts(
draft.id
? experts.map((expert) =>
expert.id === saved.id ? saved : expert
)
: [...experts, saved]
)
setExperts(next)
setSelectedId(saved.id)
setDraft(draftFromExpert(saved))
onChanged(next)
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '保存角色失败'
)
} finally {
setBusy(false)
}
}
const remove = async (): Promise<void> => {
if (!draft?.id) {
return
}
setBusy(true)
setError(undefined)
try {
await window.goodbuddy.experts.remove(draft.id)
const next = experts.filter((expert) => expert.id !== draft.id)
setExperts(next)
setConfirmingRemove(false)
if (next[0]) {
setSelectedId(next[0].id)
setDraft(draftFromExpert(next[0]))
} else {
setSelectedId(undefined)
setDraft(undefined)
}
onChanged(next)
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '删除角色失败'
)
} finally {
setBusy(false)
}
}
return (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
<Bot size={17} />
<div>
<strong></strong>
<small></small>
</div>
<button
className="secondary-button role-prompt-add"
disabled={busy}
onClick={createDraft}
type="button"
>
<Plus size={14} />
</button>
</div>
<p className="settings-notice">
使
3 使
</p>
{error && <p className="settings-warning" role="alert">{error}</p>}
<div className="model-connection-manager role-prompt-manager">
<aside
aria-label="角色列表"
className="model-connection-list"
>
<div className="model-connection-list__header">
<strong></strong>
<span>{experts.length}</span>
</div>
<div role="list">
{experts.map((expert) => (
<div key={expert.id} role="listitem">
<button
aria-current={
selectedId === expert.id ? 'page' : undefined
}
aria-label={`编辑角色 ${expert.name}`}
onClick={() => selectExpert(expert)}
type="button"
>
<span className="model-connection-list__name">
<strong>{expert.name}</strong>
<small>{expert.description || '暂无说明'}</small>
</span>
</button>
</div>
))}
</div>
</aside>
{draft ? (
<div className="model-connection-detail role-prompt-detail">
<div className="settings-section__title">
<div>
<strong>{draft.id ? draft.name : '新建角色'}</strong>
<small></small>
</div>
</div>
<label className="field">
<span></span>
<input
maxLength={80}
onChange={(event) =>
setDraft({ ...draft, name: event.target.value })
}
value={draft.name}
/>
</label>
<label className="field">
<span></span>
<textarea
maxLength={500}
onChange={(event) =>
setDraft({
...draft,
description: event.target.value
})
}
rows={3}
value={draft.description}
/>
</label>
<label className="field">
<span></span>
<textarea
aria-label="系统提示词"
aria-describedby="role-system-prompt-help"
className="role-prompt-detail__prompt"
maxLength={20_000}
onChange={(event) =>
setDraft({
...draft,
systemInstructions: event.target.value
})
}
rows={12}
value={draft.systemInstructions}
/>
<small id="role-system-prompt-help">
API Key
{draft.systemInstructions.length.toLocaleString()} /
20,000
</small>
</label>
<div className="role-prompt-detail__actions">
{draft.id ? (
<DestructiveConfirmActions
confirmAriaLabel={`确认删除角色 ${draft.name}`}
confirmLabel="删除角色"
confirming={confirmingRemove}
disabled={busy}
icon={<Trash2 size={13} />}
message="删除后,该角色将从聊天选择和专家团队中移除。"
onCancel={() => setConfirmingRemove(false)}
onConfirm={() => void remove()}
onRequestConfirm={() => setConfirmingRemove(true)}
triggerAriaLabel={`删除角色 ${draft.name}`}
triggerLabel="删除角色"
/>
) : (
<button
className="secondary-button"
disabled={busy}
onClick={() => {
const first = experts[0]
if (first) {
selectExpert(first)
} else {
setDraft(undefined)
}
}}
type="button"
>
</button>
)}
<button
className="primary-button"
disabled={busy}
onClick={() => void save()}
type="button"
>
<Save size={14} />
{busy ? '保存中…' : draft.id ? '保存角色' : '创建角色'}
</button>
</div>
</div>
) : (
<p className="settings-empty role-prompt-empty">
</p>
)}
</div>
</div>
)
}
+341 -107
View File
@@ -7,13 +7,16 @@ import {
within
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type {
DesktopApi,
RuntimeSettings
} from '../../shared/contracts'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const browserProfileId = '00000000-0000-4000-8000-000000000201'
const runtimeSettings: RuntimeSettings = {
provider: 'auto',
modelBaseUrl: 'https://bigtoken.ai',
@@ -116,7 +119,37 @@ const capabilitySnapshot = {
)[]
}
],
mcpServers: []
mcpServers: [],
computerCapabilities: [
{
id: 'host-browser-control' as const,
name: '浏览器控制',
description: '使用隔离的托管浏览器配置执行网页操作。',
enabled: false,
supported: true,
browserProfileId: null,
riskSummary: '可读取网页内容并代表用户操作网站。'
},
{
id: 'linux-desktop-control' as const,
name: 'Linux 桌面控制',
description: '在受支持的 Linux 桌面会话中执行桌面操作。',
enabled: false,
supported: false,
browserProfileId: null,
riskSummary: '可观察并操作桌面应用。'
}
],
browserProfiles: {
profiles: [
{
id: browserProfileId,
name: '工作网站',
mode: 'managed-isolated' as const
}
],
defaultProfileId: browserProfileId
}
}
const getCapabilitySnapshot = vi.fn(async () => capabilitySnapshot)
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
@@ -126,6 +159,34 @@ const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
enabled
}))
}))
const setComputerCapabilityEnabled = vi.fn(
async (_capabilityId: string, enabled: boolean) => ({
...capabilitySnapshot,
computerCapabilities: capabilitySnapshot.computerCapabilities.map(
(capability) =>
capability.id === 'host-browser-control'
? { ...capability, enabled }
: capability
)
})
)
const diagnoseComputerCapability = vi.fn(async () => ({
capabilityId: 'host-browser-control' as const,
status: 'degraded' as const,
checkedAt: '2026-08-05T12:00:00.000Z',
checks: [
{
id: 'managed-profile-root',
status: 'degraded' as const,
summary: '托管配置可用,但尚未选择默认网站。',
remedy: '先创建并选择托管配置。'
}
]
}))
const createBrowserProfile = vi.fn(async () => capabilitySnapshot)
const renameBrowserProfile = vi.fn(async () => capabilitySnapshot)
const setDefaultBrowserProfile = vi.fn(async () => capabilitySnapshot)
const removeBrowserProfile = vi.fn(async () => capabilitySnapshot)
const heartbeatSettingsProps = {
heartbeats: [],
onCreateHeartbeat: vi.fn(async () => {}),
@@ -133,6 +194,39 @@ const heartbeatSettingsProps = {
onRemoveHeartbeat: vi.fn(async () => {}),
onRunHeartbeat: vi.fn(async () => {})
}
const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101',
name: '研究分析专家',
description: '负责资料分析',
systemInstructions: 'Separate evidence from assumptions.',
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const listExperts = vi.fn<DesktopApi['experts']['list']>(
async () => [assistantExpert]
)
const createExpert = vi.fn<DesktopApi['experts']['create']>(
async (input) => ({
...input,
id: '00000000-0000-4000-8000-000000000102',
enabled: true,
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
})
)
const updateExpert = vi.fn<DesktopApi['experts']['update']>(
async (expertId, input) => ({
...input,
id: expertId,
enabled: true,
createdAt: assistantExpert.createdAt,
updatedAt: '2026-08-04T00:00:00.000Z'
})
)
const removeExpert = vi.fn<DesktopApi['experts']['remove']>(
async () => {}
)
describe('SettingsPanel runtime files', () => {
beforeEach(() => {
@@ -165,7 +259,22 @@ describe('SettingsPanel runtime files', () => {
testMcpServer: vi.fn(async () => ({
toolCount: 0,
tools: []
}))
})),
setComputerCapabilityEnabled,
setComputerCapabilityBrowserProfile: vi.fn(
async () => capabilitySnapshot
),
diagnoseComputerCapability,
createBrowserProfile,
renameBrowserProfile,
setDefaultBrowserProfile,
removeBrowserProfile
},
experts: {
list: listExperts,
create: createExpert,
update: updateExpert,
remove: removeExpert
}
} as unknown as DesktopApi
})
@@ -197,6 +306,47 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('explains automatic Execute authorization and the deny-all policy', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
const policy = await screen.findByLabelText(
'直连模型工具安全策略'
)
expect(
within(policy).getByRole('option', {
name: 'Execute 自动授权已启用的工具'
})
).toBeInTheDocument()
expect(
within(policy).getByRole('option', {
name: '禁止所有工具执行'
})
).toBeInTheDocument()
expect(
screen.getByText(/选择 Execute 即授权当前交互运行自动调用这些工具/)
).toBeInTheDocument()
expect(
screen.getByText(/禁止策略会拒绝所有工具调用/)
).toBeInTheDocument()
fireEvent.change(policy, { target: { value: 'policy' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({ toolApproval: 'policy' })
)
)
})
it('automatically detects runtimes and displays path, version, and detail', async () => {
render(
<SettingsPanel
@@ -287,17 +437,38 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
expect(
screen.getByLabelText('模型连接列表')
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: '编辑模型连接 默认模型'
})
).toHaveAttribute('aria-current', 'page')
fireEvent.click(
screen.getByRole('button', { name: '添加自定义' })
)
const nameInputs = screen.getAllByLabelText('名称')
fireEvent.change(nameInputs[1]!, {
expect(screen.getAllByLabelText('名称')).toHaveLength(1)
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: 'OpenCode 独立模型' }
})
const radios = screen.getAllByRole('radio', {
name: '默认连接'
})
fireEvent.click(radios[1]!)
expect(
screen.getByRole('button', {
name: '编辑模型连接 OpenCode 独立模型'
})
).toHaveAttribute('aria-current', 'page')
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(
screen.getByRole('button', {
name: '编辑模型连接 默认模型'
})
)
expect(screen.getByLabelText('名称')).toHaveValue('默认模型')
fireEvent.click(
screen.getByRole('button', {
name: '编辑模型连接 OpenCode 独立模型'
})
)
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
const sourceSelect = screen.getAllByLabelText('模型连接')[0]!
const sourceOptions = within(sourceSelect).getAllByRole('option')
@@ -323,7 +494,7 @@ describe('SettingsPanel runtime files', () => {
)
})
it('adds the Ollama preset with OpenAI protocol and no authentication', async () => {
it('moves the detail selection after deleting a model connection', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
@@ -335,73 +506,38 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const preset = await screen.findByLabelText('模型预设')
fireEvent.change(preset, { target: { value: 'ollama' } })
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '从预设添加' })
screen.getByRole('button', { name: '添加自定义' })
)
expect(
screen
.getAllByLabelText('名称')
.some((input) => (input as HTMLInputElement).value === 'Ollama(本机)')
).toBe(true)
expect(
screen.getByDisplayValue('http://127.0.0.1:11434/v1')
).toBeInTheDocument()
expect(
screen.getByLabelText('接口协议 Ollama(本机)')
).toHaveValue('openai-chat-completions')
expect(
screen.getByLabelText('认证方式 Ollama(本机)')
).toHaveValue('none')
expect(
screen.getByText('无需认证,不会发送 API Key')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: 'Ollama(本机)',
protocol: 'openai-chat-completions',
authentication: 'none',
apiKey: { action: 'keep' }
})
])
})
)
)
})
it('uses Responses for the official OpenAI preset', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
fireEvent.change(await screen.findByLabelText('模型预设'), {
target: { value: 'openai' }
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '备用模型' }
})
fireEvent.click(
screen.getByRole('button', { name: '从预设添加' })
screen.getByRole('button', { name: '添加自定义' })
)
expect(screen.getByLabelText('名称')).toHaveValue('模型连接 3')
fireEvent.click(
screen.getByRole('button', {
name: '删除模型连接 模型连接 3'
})
)
const protocol = screen.getByLabelText('接口协议 OpenAI')
expect(protocol).toHaveValue('openai-responses')
expect(screen.getByLabelText('名称')).toHaveValue('备用模型')
expect(
within(protocol).getByRole('option', { name: 'OpenAI Responses' })
).toBeInTheDocument()
screen.queryByRole('button', {
name: '编辑模型连接 模型连接 3'
})
).not.toBeInTheDocument()
expect(
screen.getByRole('button', {
name: '编辑模型连接 备用模型'
})
).toHaveAttribute('aria-current', 'page')
})
it('marks the BigToken gpt-image-2 preset as image generation', async () => {
it('uses only custom model connections and supports image generation', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
@@ -413,49 +549,17 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const preset = await screen.findByLabelText('模型预设')
fireEvent.change(preset, {
target: { value: 'bigtoken-gpt-image-2' }
})
fireEvent.click(
screen.getByRole('button', { name: '从预设添加' })
)
await screen.findByDisplayValue('默认模型')
expect(screen.queryByLabelText('模型预设')).not.toBeInTheDocument()
expect(
screen.getByLabelText('接口协议 BigToken GPT Image 2')
).toHaveValue('openai-images-generations')
screen.queryByRole('button', { name: '从预设添加' })
).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('接口协议 默认模型'), {
target: { value: 'openai-images-generations' }
})
expect(screen.getByText('图像生成', { selector: 'span' }))
.toBeInTheDocument()
const defaultConnections = screen.getAllByRole('radio')
fireEvent.click(defaultConnections.at(-1)!)
vi.mocked(window.goodbuddy.settings.testRuntime).mockResolvedValueOnce({
id: 'model',
label: 'gpt-image-2',
available: true,
supportsToolExecution: false,
detail: '图像接口将在发送提示词时实际验证',
capability: 'image-generation'
})
fireEvent.click(screen.getByRole('button', { name: '保存并测试' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: 'BigToken GPT Image 2',
modelName: 'gpt-image-2',
protocol: 'openai-images-generations'
})
])
})
)
)
expect(
await screen.findByText('图像接口将在发送提示词时实际验证')
).toBeInTheDocument()
expect(screen.queryByText('连接成功:gpt-image-2'))
.not.toBeInTheDocument()
})
it('manages heartbeat automation from Settings', async () => {
@@ -609,16 +713,146 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
await waitFor(() =>
expect(setComputerCapabilityEnabled).toHaveBeenCalledWith(
'host-browser-control',
true
)
)
fireEvent.click(screen.getByRole('button', { name: '诊断 浏览器控制' }))
expect(
await screen.findByText('诊断结果:部分可用')
).toBeInTheDocument()
expect(diagnoseComputerCapability).toHaveBeenCalledWith(
'host-browser-control'
)
fireEvent.change(screen.getByLabelText('新配置名称'), {
target: { value: '购物网站' }
})
fireEvent.click(
screen.getByRole('button', { name: '创建托管配置' })
)
await waitFor(() =>
expect(createBrowserProfile).toHaveBeenCalledWith({
name: '购物网站'
})
)
fireEvent.change(screen.getByLabelText('配置名称 工作网站'), {
target: { value: '工作站点' }
})
fireEvent.click(
screen.getByRole('button', { name: '重命名配置 工作网站' })
)
await waitFor(() =>
expect(renameBrowserProfile).toHaveBeenCalledWith({
profileId: browserProfileId,
name: '工作站点'
})
)
fireEvent.click(
screen.getByRole('button', { name: '删除配置 工作网站' })
)
await waitFor(() =>
expect(removeBrowserProfile).toHaveBeenCalledWith(browserProfileId)
)
expect(
await screen.findByText('读取工作区文本')
).toBeInTheDocument()
expect(screen.getByText('列出工作区目录')).toBeInTheDocument()
expect(screen.getByText('写入工作区文本')).toBeInTheDocument()
expect(screen.getAllByText('直连模型')).toHaveLength(
builtinModelTools.length
)
expect(
await screen.findByText('尚未配置 MCP Server')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /添加 Server/ })
).toBeInTheDocument()
expect(
screen.getByText(/自定义 stdio MCP 会以受限环境启动/)
).toHaveTextContent('不会获得桌面会话变量')
fireEvent.click(screen.getByRole('button', { name: /添加 Server/ }))
expect(screen.getByLabelText('模型')).toBeChecked()
expect(
screen.queryByLabelText('OpenCode')
).not.toBeInTheDocument()
})
it('creates, updates, and removes roles with system prompts', async () => {
const onExpertsChanged = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onExpertsChanged={onExpertsChanged}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: '角色与提示词' })
)
await screen.findByRole('button', {
name: '编辑角色 研究分析专家'
})
fireEvent.change(screen.getByLabelText('系统提示词'), {
target: { value: 'Use evidence and state uncertainty.' }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(updateExpert).toHaveBeenCalledWith(
assistantExpert.id,
expect.objectContaining({
systemInstructions: 'Use evidence and state uncertainty.'
})
)
)
fireEvent.click(screen.getByRole('button', { name: '新建角色' }))
fireEvent.change(screen.getByLabelText('角色名称'), {
target: { value: '代码审查专家' }
})
fireEvent.change(screen.getByLabelText('角色说明'), {
target: { value: '检查代码正确性' }
})
fireEvent.change(screen.getByLabelText('系统提示词'), {
target: { value: 'Review code and report actionable bugs.' }
})
fireEvent.click(screen.getByRole('button', { name: '创建角色' }))
await waitFor(() =>
expect(createExpert).toHaveBeenCalledWith({
name: '代码审查专家',
description: '检查代码正确性',
systemInstructions: 'Review code and report actionable bugs.'
})
)
expect(onExpertsChanged).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({ name: '代码审查专家' })
])
)
fireEvent.click(
screen.getByRole('button', {
name: '删除角色 代码审查专家'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '确认删除角色 代码审查专家'
})
)
await waitFor(() =>
expect(removeExpert).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000102'
)
)
})
})
+116 -87
View File
@@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
AssistantExpert,
AssistantHeartbeatConfig,
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
@@ -22,11 +23,8 @@ import type {
RuntimeModelSource
} from '../../shared/contracts'
import { defaultRuntimeSettings } from '../../shared/contracts'
import {
modelProfilePresets,
type ModelProfilePreset
} from '../../shared/model-presets'
import { McpSettingsSection } from './McpSettingsSection'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import type { AppearanceTheme } from './theme'
@@ -37,6 +35,7 @@ type SettingsTab =
| 'runtime'
| 'security'
| 'automation'
| 'roles'
| 'skills'
| 'mcp'
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
@@ -49,6 +48,7 @@ type SettingsPanelProps = {
presentation?: 'modal' | 'page'
onClose: () => void
onSaved: (settings: RuntimeSettings) => void
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
heartbeats: AssistantHeartbeatConfig[]
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
@@ -92,6 +92,7 @@ export function SettingsPanel({
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat,
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
@@ -101,9 +102,8 @@ export function SettingsPanel({
defaultRuntimeSettings.provider
)
const [modelProfiles, setModelProfiles] = useState<ModelProfileDraft[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<string>(
modelProfilePresets[0].id
)
const [selectedModelProfileId, setSelectedModelProfileId] =
useState('')
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
const [opencodeModelSource, setOpencodeModelSource] =
useState<RuntimeModelSource>({ kind: 'platform' })
@@ -176,6 +176,13 @@ export function SettingsPanel({
setSettings(value)
setProvider(value.provider)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId(
value.modelProfiles.some(
(profile) => profile.id === value.defaultModelProfileId
)
? value.defaultModelProfileId
: value.modelProfiles[0]?.id ?? ''
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
@@ -281,6 +288,11 @@ export function SettingsPanel({
})
setSettings(value)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId((selectedId) =>
value.modelProfiles.some((profile) => profile.id === selectedId)
? selectedId
: value.defaultModelProfileId
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
@@ -396,37 +408,7 @@ export function SettingsPanel({
if (!defaultModelProfileId) {
setDefaultModelProfileId(id)
}
}
const addPresetProfile = (preset: ModelProfilePreset): void => {
const id = crypto.randomUUID()
setModelProfiles((profiles) => {
const usedNames = new Set(profiles.map((profile) => profile.name))
let name = preset.name
let suffix = 2
while (usedNames.has(name)) {
name = `${preset.name} ${suffix}`
suffix += 1
}
return [
...profiles,
{
id,
name,
baseUrl: preset.baseUrl,
modelName: preset.modelName,
protocol: preset.protocol,
authentication: preset.authentication,
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
clearApiKey: false
}
]
})
if (!defaultModelProfileId) {
setDefaultModelProfileId(id)
}
setSelectedModelProfileId(id)
}
const removeModelProfile = (id: string): void => {
@@ -434,8 +416,17 @@ export function SettingsPanel({
setError('请至少保留一个模型连接')
return
}
const removedIndex = modelProfiles.findIndex(
(profile) => profile.id === id
)
const remaining = modelProfiles.filter((profile) => profile.id !== id)
setModelProfiles(remaining)
if (selectedModelProfileId === id) {
setSelectedModelProfileId(
remaining[Math.min(removedIndex, remaining.length - 1)]?.id ??
remaining[0]!.id
)
}
if (defaultModelProfileId === id) {
setDefaultModelProfileId(remaining[0]!.id)
}
@@ -470,6 +461,11 @@ export function SettingsPanel({
profile.protocol === 'anthropic-messages' ||
profile.protocol === 'openai-chat-completions'
const selectedModelProfile =
modelProfiles.find(
(profile) => profile.id === selectedModelProfileId
) ?? modelProfiles[0]
const detectionSummary = (
value: AgentRuntimeDetection['opencode'] | undefined
): React.JSX.Element => (
@@ -564,7 +560,7 @@ export function SettingsPanel({
type="button"
>
<strong></strong>
<small></small>
<small></small>
</button>
<button
aria-label="自动化"
@@ -576,6 +572,16 @@ export function SettingsPanel({
<strong></strong>
<small></small>
</button>
<button
aria-label="角色与提示词"
aria-selected={activeTab === 'roles'}
onClick={() => setActiveTab('roles')}
role="tab"
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-label="Skills"
aria-selected={activeTab === 'skills'}
@@ -998,7 +1004,7 @@ export function SettingsPanel({
{activeTab === 'model' && (
<>
<div className="settings-section">
<div className="settings-section__title">
<div className="settings-section__title settings-section__title--actions">
<KeyRound size={17} />
<div>
<strong></strong>
@@ -1009,7 +1015,7 @@ export function SettingsPanel({
</small>
</div>
<button
className="secondary-button"
className="secondary-button model-connection-add"
onClick={addModelProfile}
type="button"
>
@@ -1017,52 +1023,65 @@ export function SettingsPanel({
</button>
</div>
<div className="runtime-note">
<label className="field">
<span></span>
<select
aria-label="模型预设"
onChange={(event) =>
setSelectedPresetId(event.target.value)
}
value={selectedPresetId}
>
{modelProfilePresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
<small>
{
modelProfilePresets.find(
(preset) => preset.id === selectedPresetId
)?.description
}
</small>
</label>
<button
className="secondary-button"
onClick={() => {
const preset = modelProfilePresets.find(
(candidate) => candidate.id === selectedPresetId
)
if (preset) {
addPresetProfile(preset)
}
}}
type="button"
<div className="model-connection-manager">
<aside
aria-label="模型连接列表"
className="model-connection-list"
>
<Plus size={14} />
</button>
</div>
{modelProfiles.map((profile) => {
<div className="model-connection-list__header">
<strong></strong>
<span>{modelProfiles.length}</span>
</div>
<div role="list">
{modelProfiles.map((profile) => (
<div key={profile.id} role="listitem">
<button
aria-current={
selectedModelProfile?.id === profile.id
? 'page'
: undefined
}
aria-label={`编辑模型连接 ${profile.name}`}
onClick={() =>
setSelectedModelProfileId(profile.id)
}
type="button"
>
<span className="model-connection-list__name">
<strong>{profile.name}</strong>
<small>{profile.modelName}</small>
</span>
<span className="model-connection-list__badges">
{defaultModelProfileId === profile.id && (
<span></span>
)}
{profile.protocol ===
'openai-images-generations' && (
<span></span>
)}
</span>
</button>
</div>
))}
</div>
</aside>
{selectedModelProfile && (() => {
const profile = selectedModelProfile
const environmentManaged =
profile.credentialSource === 'environment'
return (
<div className="runtime-note" key={profile.id}>
<div
aria-labelledby={`model-connection-${profile.id}`}
className="model-connection-detail"
key={profile.id}
>
<div className="settings-section__title">
<div>
<strong id={`model-connection-${profile.id}`}>
{profile.name}
</strong>
<small></small>
</div>
<label className="check-field">
<input
checked={defaultModelProfileId === profile.id}
@@ -1248,7 +1267,7 @@ export function SettingsPanel({
<span> API Key</span>
</div>
)}
<small>
<small className="model-connection-detail__compatibility">
{profile.protocol === 'openai-images-generations'
? '图像生成'
@@ -1262,7 +1281,8 @@ export function SettingsPanel({
</small>
</div>
)
})}
})()}
</div>
{settings && !settings.secureStorageAvailable && (
<p className="settings-warning">
使
@@ -1299,6 +1319,7 @@ export function SettingsPanel({
<label className="field">
<span></span>
<select
aria-label="直连模型工具安全策略"
value={toolApproval}
onChange={(event) =>
setToolApproval(
@@ -1306,13 +1327,16 @@ export function SettingsPanel({
)
}
>
<option value="always"></option>
<option value="always">
Execute
</option>
<option value="policy"></option>
</select>
<small>
Execute 使
MCP OpenCode Continue
使
MCP Execute
OpenCode
Continue 使
</small>
</label>
@@ -1416,6 +1440,11 @@ export function SettingsPanel({
/>
</div>
)}
{activeTab === 'roles' && (
<RolePromptSettingsSection
onChanged={onExpertsChanged}
/>
)}
{activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && <McpSettingsSection />}
</div>
+505 -13
View File
@@ -567,6 +567,7 @@ textarea:focus-visible {
}
.assistant-sidebar {
position: relative;
display: flex;
width: 0;
flex: 0 0 0;
@@ -583,12 +584,55 @@ textarea:focus-visible {
}
.assistant-sidebar--open {
width: 350px;
flex-basis: 350px;
width: var(--assistant-sidebar-width, 350px);
flex-basis: var(--assistant-sidebar-width, 350px);
border-left-width: 1px;
opacity: 1;
}
.assistant-sidebar--resizing {
transition: none;
}
.assistant-sidebar__resize-handle {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: -5px;
display: none;
width: 10px;
padding: 0;
cursor: col-resize;
touch-action: none;
}
.assistant-sidebar--open > .assistant-sidebar__resize-handle {
display: block;
}
.assistant-sidebar__resize-handle::after {
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 2px;
background: transparent;
content: '';
transition: background var(--motion-fast, 120ms) ease-out;
}
.assistant-sidebar__resize-handle:hover::after,
.assistant-sidebar__resize-handle:focus-visible::after,
.assistant-sidebar--resizing > .assistant-sidebar__resize-handle::after {
background: var(--accent);
}
.assistant-sidebar__resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.assistant-sidebar__header {
display: flex;
height: 58px;
@@ -608,7 +652,7 @@ textarea:focus-visible {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
gap: 3px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.assistant-sidebar__tab {
@@ -1190,6 +1234,84 @@ textarea:focus-visible {
padding: 16px;
}
.assistant-sidebar__browser {
display: flex;
min-width: 0;
padding: var(--space-4);
flex-direction: column;
gap: var(--space-3);
}
.assistant-sidebar__browser > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.assistant-sidebar__browser > header > span {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.assistant-sidebar__browser > header .secondary-button {
min-height: 28px;
padding: 4px 8px;
flex: none;
font-size: 10px;
}
.assistant-sidebar__browser-status {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: 10px;
line-height: 1.5;
}
.assistant-sidebar__browser-status--failed {
border-color: var(--danger-border);
background: var(--danger-subtle);
color: var(--danger);
}
.assistant-sidebar__browser-url {
overflow: hidden;
color: var(--text-muted);
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-sidebar__browser-frame,
.assistant-sidebar__browser-placeholder {
width: 100%;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.assistant-sidebar__browser-frame {
display: block;
height: auto;
}
.assistant-sidebar__browser-placeholder {
display: flex;
min-height: 220px;
align-items: center;
justify-content: center;
color: var(--text-muted);
flex-direction: column;
gap: var(--space-2);
font-size: 10px;
}
.assistant-sidebar__preview > header {
display: flex;
padding-bottom: 12px;
@@ -1968,12 +2090,6 @@ textarea:focus-visible {
font: inherit;
}
.composer__mode--plan {
border-color: #b7eb8f;
background: #f6ffed;
color: #237804;
}
.composer__mode--execute {
border-color: #ffd591;
background: #fff7e6;
@@ -2182,7 +2298,7 @@ textarea:focus-visible {
padding: 3px;
border-radius: 9px;
background: #f5f5f5;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(8, 1fr);
}
.settings-tabs button {
@@ -2308,6 +2424,187 @@ textarea:focus-visible {
white-space: nowrap;
}
.model-connection-manager {
display: grid;
min-width: 0;
align-items: start;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: var(--space-4);
}
.model-connection-list,
.model-connection-detail {
min-width: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.model-connection-list {
overflow: hidden;
}
.model-connection-list__header {
display: flex;
min-height: 40px;
padding: 0 var(--space-3);
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
font-size: var(--font-body);
}
.model-connection-list__header > span {
display: inline-flex;
min-width: 22px;
min-height: 22px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.model-connection-list > [role='list'] {
max-height: 480px;
overflow-y: auto;
}
.model-connection-list [role='listitem'] + [role='listitem'] {
border-top: 1px solid var(--border-subtle);
}
.model-connection-list button {
display: flex;
width: 100%;
min-height: 58px;
padding: var(--space-2) var(--space-3);
align-items: center;
justify-content: space-between;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
gap: var(--space-2);
}
.model-connection-list button:hover {
background: var(--surface-subtle);
}
.model-connection-list button:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.model-connection-list button[aria-current='page'] {
background: var(--accent-subtle);
box-shadow: inset 3px 0 0 var(--accent);
color: var(--accent);
}
.model-connection-list__name {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: var(--space-1);
}
.model-connection-list__name strong,
.model-connection-list__name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-connection-list__name strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.model-connection-list__name small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.model-connection-list__badges {
display: flex;
flex-shrink: 0;
align-items: flex-end;
flex-direction: column;
gap: var(--space-1);
}
.model-connection-list__badges > span {
padding: 2px var(--space-1);
border-radius: 999px;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.4;
}
.model-connection-detail {
display: flex;
padding: var(--space-4);
flex-direction: column;
gap: var(--space-3);
}
.model-connection-detail > .settings-section__title {
min-height: 32px;
flex-wrap: wrap;
}
.model-connection-detail > .settings-section__title > div:first-child {
min-width: 120px;
flex: 1;
}
.model-connection-detail__compatibility {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.role-prompt-detail__prompt {
min-height: 240px;
font-family: inherit;
line-height: 1.6;
}
.role-prompt-detail__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.role-prompt-detail__actions > .danger-button,
.role-prompt-detail__actions > .danger-confirm {
margin-right: auto;
}
.role-prompt-detail__actions .primary-button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.role-prompt-empty {
min-height: 180px;
padding: var(--space-6);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.field {
display: flex;
flex-direction: column;
@@ -2410,6 +2707,157 @@ textarea:focus-visible {
gap: 10px;
}
.capability-list--tools {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mcp-tool-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.computer-capability-risk {
display: flex;
align-items: flex-start;
color: var(--warning) !important;
gap: var(--space-2);
}
.computer-capability-risk svg {
flex: 0 0 auto;
margin-top: 1px;
}
.computer-capability-profile {
max-width: 360px;
}
.capability-diagnostic {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.capability-diagnostic > button {
flex: 0 0 auto;
}
.capability-diagnostic__result {
display: flex;
flex: 1;
flex-direction: column;
padding: var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-1);
}
.capability-diagnostic__result strong {
color: var(--text-primary);
font-size: var(--font-caption);
}
.browser-profile-create,
.browser-profile-row {
display: flex;
align-items: flex-end;
gap: var(--space-2);
}
.browser-profile-create .field,
.browser-profile-row .field {
flex: 1;
margin: 0;
}
.browser-profile-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.browser-profile-row {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.browser-profile-default {
display: flex;
align-items: center;
min-height: 32px;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.browser-profile-row .danger-ghost {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 var(--space-2);
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
background: transparent;
color: var(--danger);
cursor: pointer;
font-size: var(--font-caption);
gap: var(--space-1);
}
.browser-profile-row .danger-ghost:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 720px) {
.capability-diagnostic,
.browser-profile-create,
.browser-profile-row {
align-items: stretch;
flex-direction: column;
}
}
.mcp-subsection-heading,
.mcp-subsection-heading > div {
display: flex;
align-items: center;
}
.mcp-subsection-heading {
justify-content: space-between;
color: var(--text-secondary);
gap: var(--space-2);
}
.mcp-subsection-heading > div {
gap: var(--space-2);
}
.mcp-subsection-heading strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.mcp-subsection-heading small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.builtin-tool-badge {
padding: 2px var(--space-2);
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent);
font-size: var(--font-caption);
white-space: nowrap;
}
.capability-card {
display: flex;
flex-direction: column;
@@ -4800,7 +5248,6 @@ textarea:focus-visible {
.nav-item--active,
.brand__mark,
.composer__mode--ask,
.composer__mode--plan,
.runtime-capability-badge,
.model-capability-badge
) {
@@ -4968,6 +5415,17 @@ textarea:focus-visible {
bottom: 0;
box-shadow: -12px 0 30px rgb(0 0 0 / 10%);
}
.assistant-sidebar--open {
width: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
flex-basis: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
}
}
@media (max-width: 1020px) {
@@ -4986,8 +5444,14 @@ textarea:focus-visible {
}
.assistant-sidebar--open {
width: min(390px, calc(100vw - 36px));
flex-basis: min(390px, calc(100vw - 36px));
width: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
flex-basis: min(
var(--assistant-sidebar-width, 350px),
calc(100vw - 36px)
);
}
.settings-page .settings-panel__body {
@@ -5020,11 +5484,29 @@ textarea:focus-visible {
}
}
@media (max-width: 719px) {
.assistant-sidebar__resize-handle {
display: none;
}
}
@media (max-width: 720px) {
.workspace-panel-scroll {
padding: 20px;
}
.model-connection-manager {
grid-template-columns: 1fr;
}
.model-connection-list > [role='list'] {
max-height: 180px;
}
.capability-list--tools {
grid-template-columns: 1fr;
}
.page-header:not(.page-header--compact) {
flex-direction: column;
}
@@ -5054,6 +5536,16 @@ textarea:focus-visible {
padding: 16px;
}
.model-connection-add {
width: 100%;
justify-content: center;
}
.role-prompt-add {
width: 100%;
justify-content: center;
}
.heartbeat-center__metrics {
grid-template-columns: 1fr;
}
+12 -1
View File
@@ -2,6 +2,16 @@ import { z } from 'zod'
export const assistantIdSchema = z.string().uuid()
export const workModeSchema = z.enum(['ask', 'plan', 'execute'])
export const interactiveWorkModes = ['ask', 'execute'] as const
export type WorkMode = z.infer<typeof workModeSchema>
export type InteractiveWorkMode = (typeof interactiveWorkModes)[number]
export function normalizeInteractiveWorkMode(
workMode: WorkMode | undefined
): InteractiveWorkMode {
return workMode === 'execute' ? 'execute' : 'ask'
}
export const projectCreateSchema = z
.object({
@@ -14,7 +24,6 @@ export const projectCreateSchema = z
export const projectUpdateSchema = projectCreateSchema
export type WorkMode = z.infer<typeof workModeSchema>
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>
export const conversationSnapshotSchema = z
@@ -44,6 +53,7 @@ export const conversationSnapshotSchema = z
'running',
'completed',
'failed',
'recoverable',
'cancelled',
'interrupted'
]),
@@ -433,6 +443,7 @@ export const expertCreateSchema = z
.strict()
export type ExpertCreateInput = z.infer<typeof expertCreateSchema>
export type ExpertUpdateInput = ExpertCreateInput
export type AssistantExpert = ExpertCreateInput & {
id: string
+70
View File
@@ -0,0 +1,70 @@
export type BuiltinModelToolSummary = {
name: string
displayName: string
description: string
access: 'read' | 'write'
}
export const builtinModelTools = [
{
name: 'workspace_read_text',
displayName: '读取工作区文本',
description: '读取当前工作区内不超过 256KB 的 UTF-8 文本文件。',
access: 'read'
},
{
name: 'workspace_list_directory',
displayName: '列出工作区目录',
description: '列出当前工作区内目录的直属内容,最多返回 200 项。',
access: 'read'
},
{
name: 'workspace_write_text',
displayName: '写入工作区文本',
description:
'在当前工作区内新建或覆盖不超过 512KB 的 UTF-8 文本文件,父目录必须已存在。',
access: 'write'
},
{
name: 'browser_navigate',
displayName: '浏览器导航',
description: '在隔离浏览器中打开公开的 HTTP(S) 页面。',
access: 'write'
},
{
name: 'browser_snapshot',
displayName: '读取浏览器快照',
description: '读取当前页面的有界可访问性快照;可编辑值会被隐藏。',
access: 'read'
},
{
name: 'browser_click',
displayName: '点击浏览器元素',
description: '点击最近一次浏览器快照中的可见且未受保护元素。',
access: 'write'
},
{
name: 'browser_type',
displayName: '输入浏览器文本',
description: '向可编辑且未受保护的页面元素输入文本;不支持上传文件。',
access: 'write'
},
{
name: 'browser_select',
displayName: '选择浏览器选项',
description: '在最近一次快照标识的原生选择控件中选择值。',
access: 'write'
},
{
name: 'browser_back',
displayName: '浏览器返回',
description: '在隔离浏览器的历史记录中返回上一页。',
access: 'write'
},
{
name: 'browser_screenshot',
displayName: '截取浏览器页面',
description: '截取当前可见页面区域的有界 PNG 图片。',
access: 'read'
}
] as const satisfies readonly BuiltinModelToolSummary[]
+130 -1
View File
@@ -80,6 +80,130 @@ export const skillSummarySchema = z
.strict()
export type SkillSummary = z.infer<typeof skillSummarySchema>
export const computerCapabilityIdSchema = z.enum([
'host-browser-control',
'linux-desktop-control'
])
export type ComputerCapabilityId = z.infer<
typeof computerCapabilityIdSchema
>
export const browserProfileIdSchema = z.string().uuid()
export const browserProfileNameSchema = controlCharacterFreeString(80)
export const browserProfileCreateInputSchema = z
.object({
name: browserProfileNameSchema
})
.strict()
export type BrowserProfileCreateInput = z.infer<
typeof browserProfileCreateInputSchema
>
export const browserProfileRenameInputSchema = z
.object({
profileId: browserProfileIdSchema,
name: browserProfileNameSchema
})
.strict()
export type BrowserProfileRenameInput = z.infer<
typeof browserProfileRenameInputSchema
>
export const browserProfileSelectionInputSchema = z
.object({
profileId: browserProfileIdSchema
})
.strict()
export const browserProfileSummarySchema = z
.object({
id: browserProfileIdSchema,
name: browserProfileNameSchema,
mode: z.literal('managed-isolated')
})
.strict()
export type BrowserProfileSummary = z.infer<
typeof browserProfileSummarySchema
>
export const browserProfilesSummarySchema = z
.object({
profiles: z.array(browserProfileSummarySchema).max(32),
defaultProfileId: browserProfileIdSchema.nullable()
})
.strict()
export type BrowserProfilesSummary = z.infer<
typeof browserProfilesSummarySchema
>
export const computerCapabilityToggleInputSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
enabled: z.boolean()
})
.strict()
export const computerCapabilityConfigInputSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
browserProfileId: browserProfileIdSchema.nullable()
})
.strict()
export const computerCapabilityConfigSummarySchema = z
.object({
id: computerCapabilityIdSchema,
name: z.string().min(1).max(80),
description: z.string().min(1).max(500),
enabled: z.boolean(),
supported: z.boolean(),
browserProfileId: browserProfileIdSchema.nullable(),
riskSummary: z.string().min(1).max(500)
})
.strict()
export type ComputerCapabilityConfigSummary = z.infer<
typeof computerCapabilityConfigSummarySchema
>
export const capabilityDiagnosticStatusSchema = z.enum([
'available',
'degraded',
'unavailable',
'disabled'
])
export type CapabilityDiagnosticStatus = z.infer<
typeof capabilityDiagnosticStatusSchema
>
export const capabilityDiagnosticCheckStatusSchema =
capabilityDiagnosticStatusSchema.exclude(['disabled'])
export const capabilityDiagnosticCheckSchema = z
.object({
id: z
.string()
.min(1)
.max(80)
.regex(/^[a-z][a-z0-9-]*$/u),
status: capabilityDiagnosticCheckStatusSchema,
summary: z.string().min(1).max(240),
remedy: z.string().min(1).max(400).optional()
})
.strict()
export const capabilityDiagnosticReportSchema = z
.object({
capabilityId: computerCapabilityIdSchema,
status: capabilityDiagnosticStatusSchema,
checkedAt: z.string().datetime(),
checks: z.array(capabilityDiagnosticCheckSchema).max(16)
})
.strict()
export type CapabilityDiagnosticReport = z.infer<
typeof capabilityDiagnosticReportSchema
>
export const mcpTransportSchema = z.enum(['stdio', 'http', 'sse'])
export type McpTransport = z.infer<typeof mcpTransportSchema>
@@ -185,7 +309,12 @@ export type McpServerSummary = z.infer<typeof mcpServerSummarySchema>
export const capabilitySnapshotSchema = z
.object({
skills: z.array(skillSummarySchema).max(256),
mcpServers: z.array(mcpServerSummarySchema).max(64)
mcpServers: z.array(mcpServerSummarySchema).max(64),
computerCapabilities: z
.array(computerCapabilityConfigSummarySchema)
.max(2)
.optional(),
browserProfiles: browserProfilesSummarySchema.optional()
})
.strict()
export type CapabilitySnapshot = z.infer<typeof capabilitySnapshotSchema>
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import {
computerControlActionSchema,
computerControlApprovalResultSchema,
computerControlObservationSchema,
computerControlRuntimeCommandSchema
} from './computer-control-contracts'
const id = 'opaque_identifier_123456'
describe('computer control contracts', () => {
it('accepts only the bounded semantic action vocabulary', () => {
expect(
computerControlActionSchema.parse({
kind: 'replace_text',
elementRef: id,
text: 'hello'
})
).toEqual({
kind: 'replace_text',
elementRef: id,
text: 'hello'
})
for (const action of [
{ kind: 'click_at', x: 10, y: 20 },
{ kind: 'key_chord', keys: ['CTRL', 'A'] },
{ kind: 'launch_process', command: 'cmd.exe' },
{ kind: 'clipboard_write', text: 'secret' },
{ kind: 'open_file_picker', path: 'C:\\private.txt' }
]) {
expect(computerControlActionSchema.safeParse(action).success).toBe(
false
)
}
})
it('rejects unbounded and unknown command fields', () => {
expect(
computerControlRuntimeCommandSchema.safeParse({
kind: 'act',
commandId: id,
leaseId: id,
observationId: id,
revision: 1,
action: {
kind: 'replace_text',
elementRef: id,
text: 'x'.repeat(4_097)
}
}).success
).toBe(false)
expect(
computerControlRuntimeCommandSchema.safeParse({
kind: 'observe',
commandId: id,
leaseId: id,
coordinates: [10, 20]
}).success
).toBe(false)
})
it('bounds observations and excludes element values', () => {
const element = {
ref: id,
role: 'textbox',
name: 'Search',
enabled: true,
focused: false,
risk: 'input',
blocked: false
}
expect(
computerControlObservationSchema.safeParse({
observationId: id,
leaseId: id,
revision: 1,
capturedAt: 1,
windowTitle: 'Window',
elements: Array.from({ length: 201 }, () => element)
}).success
).toBe(false)
expect(
computerControlObservationSchema.safeParse({
observationId: id,
leaseId: id,
revision: 1,
capturedAt: 1,
windowTitle: 'Window',
elements: [{ ...element, value: 'password' }]
}).success
).toBe(false)
})
it('allows approvals only once and never as a broad grant', () => {
expect(
computerControlApprovalResultSchema.parse({
approvalId: id,
decision: 'approve_once'
}).decision
).toBe('approve_once')
expect(
computerControlApprovalResultSchema.safeParse({
approvalId: id,
decision: 'approve_session'
}).success
).toBe(false)
})
})
+237
View File
@@ -0,0 +1,237 @@
import { z } from 'zod'
const boundedText = (maximumLength: number) =>
z
.string()
.trim()
.min(1)
.max(maximumLength)
.refine(
(value) =>
[...value].every((character) => {
const code = character.charCodeAt(0)
return code > 31 && code !== 127
}),
'值包含控制字符'
)
const opaqueIdSchema = z
.string()
.min(16)
.max(160)
.regex(/^[A-Za-z0-9_-]+$/)
export const computerControlRiskSchema = z.enum([
'observe',
'navigate',
'input',
'commit',
'forbidden'
])
export type ComputerControlRisk = z.infer<
typeof computerControlRiskSchema
>
export const computerControlElementRoleSchema = z.enum([
'button',
'link',
'textbox',
'checkbox',
'radio',
'combobox',
'option',
'menuitem',
'tab',
'listitem',
'scrollarea'
])
export type ComputerControlElementRole = z.infer<
typeof computerControlElementRoleSchema
>
export const computerControlElementSchema = z
.object({
ref: opaqueIdSchema,
role: computerControlElementRoleSchema,
name: boundedText(256),
enabled: z.boolean(),
focused: z.boolean(),
risk: computerControlRiskSchema,
blocked: z.boolean()
})
.strict()
export type ComputerControlElement = z.infer<
typeof computerControlElementSchema
>
export const computerControlObservationSchema = z
.object({
observationId: opaqueIdSchema,
leaseId: opaqueIdSchema,
revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
capturedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
windowTitle: z.string().trim().max(256),
elements: z.array(computerControlElementSchema).max(200)
})
.strict()
export type ComputerControlObservation = z.infer<
typeof computerControlObservationSchema
>
export const computerControlActionSchema = z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('activate'),
elementRef: opaqueIdSchema
})
.strict(),
z
.object({
kind: z.literal('replace_text'),
elementRef: opaqueIdSchema,
text: z.string().max(4_096)
})
.strict(),
z
.object({
kind: z.literal('select_option'),
elementRef: opaqueIdSchema,
optionName: boundedText(256)
})
.strict(),
z
.object({
kind: z.literal('scroll'),
elementRef: opaqueIdSchema,
direction: z.enum(['up', 'down', 'page_up', 'page_down'])
})
.strict()
])
export type ComputerControlAction = z.infer<
typeof computerControlActionSchema
>
const commandBase = {
commandId: opaqueIdSchema,
leaseId: opaqueIdSchema
}
export const computerControlRuntimeCommandSchema = z.discriminatedUnion(
'kind',
[
z
.object({
...commandBase,
kind: z.literal('observe')
})
.strict(),
z
.object({
...commandBase,
kind: z.literal('act'),
observationId: opaqueIdSchema,
revision: z
.number()
.int()
.positive()
.max(Number.MAX_SAFE_INTEGER),
action: computerControlActionSchema
})
.strict()
]
)
export type ComputerControlRuntimeCommand = z.infer<
typeof computerControlRuntimeCommandSchema
>
export const computerControlErrorCodeSchema = z.enum([
'invalid_request',
'driver_unavailable',
'driver_timeout',
'lease_not_found',
'lease_expired',
'lease_mismatch',
'observation_not_found',
'observation_stale',
'observation_consumed',
'element_not_found',
'window_not_foreground',
'element_identity_changed',
'focus_failed',
'forbidden',
'approval_denied',
'approval_timeout',
'cancelled',
'command_id_conflict',
'outcome_unknown',
'internal_error'
])
export type ComputerControlErrorCode = z.infer<
typeof computerControlErrorCodeSchema
>
export const computerControlErrorSchema = z
.object({
code: computerControlErrorCodeSchema,
message: boundedText(256),
retryable: z.boolean()
})
.strict()
export type ComputerControlError = z.infer<
typeof computerControlErrorSchema
>
export const computerControlApprovalRequestSchema = z
.object({
approvalId: opaqueIdSchema,
leaseId: opaqueIdSchema,
commandId: opaqueIdSchema,
risk: z.enum(['input', 'commit']),
action: z.enum(['activate', 'replace_text', 'select_option']),
targetName: boundedText(256),
textLength: z.number().int().nonnegative().max(4_096).optional()
})
.strict()
export type ComputerControlApprovalRequest = z.infer<
typeof computerControlApprovalRequestSchema
>
export const computerControlApprovalResultSchema = z
.object({
approvalId: opaqueIdSchema,
decision: z.enum(['approve_once', 'deny'])
})
.strict()
export type ComputerControlApprovalResult = z.infer<
typeof computerControlApprovalResultSchema
>
export const computerControlCommandResultSchema = z.discriminatedUnion(
'status',
[
z
.object({
status: z.literal('observed'),
commandId: opaqueIdSchema,
observation: computerControlObservationSchema
})
.strict(),
z
.object({
status: z.literal('completed'),
commandId: opaqueIdSchema,
risk: computerControlRiskSchema.exclude(['forbidden'])
})
.strict(),
z
.object({
status: z.literal('error'),
commandId: opaqueIdSchema,
error: computerControlErrorSchema
})
.strict()
]
)
export type ComputerControlCommandResult = z.infer<
typeof computerControlCommandResultSchema
>
+80 -3
View File
@@ -1,7 +1,11 @@
import { z } from 'zod'
import type {
BrowserProfileCreateInput,
BrowserProfileRenameInput,
CapabilityDiagnosticReport,
CapabilityAssignments,
CapabilitySnapshot,
ComputerCapabilityId,
McpServerInput,
McpServerTestResult
} from './capability-contracts'
@@ -27,7 +31,8 @@ import {
type ScheduleCreateInput,
type HeartbeatCreateInput,
type HeartbeatUpdateInput,
type ExpertCreateInput
type ExpertCreateInput,
type ExpertUpdateInput
} from './assistant-contracts'
export const workspaceRelativePathSchema = z
@@ -61,10 +66,12 @@ export const workspaceFileRequestSchema = z
})
.strict()
export const conversationIdSchema = z.string().min(1).max(128)
export const agentRequestSchema = z
.object({
requestId: z.string().uuid(),
conversationId: z.string().min(1).max(128),
conversationId: conversationIdSchema,
projectId: z.string().uuid().optional(),
expertId: z.string().uuid().optional(),
teamMode: z.boolean().optional(),
@@ -550,7 +557,12 @@ export type AgentEvent =
type: 'tool'
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
state:
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'recoverable'
summary: string
}
| {
@@ -590,6 +602,39 @@ export type AppInfo = {
shortcut: string
}
export const browserLiveStateSchema = z
.object({
conversationId: conversationIdSchema,
status: z.enum([
'creating',
'loading',
'ready',
'acting',
'failed',
'stopped'
]),
url: z.string().max(2_048).optional(),
frameDataUrl: z
.string()
.max(7_000_000)
.refine(
(value) => value.startsWith('data:image/png;base64,'),
'浏览器画面格式无效'
)
.optional(),
error: z.string().min(1).max(240).optional(),
updatedAt: z.number().int().nonnegative()
})
.strict()
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
export const browserStopRequestSchema = z
.object({
conversationId: conversationIdSchema
})
.strict()
export const knowledgeIdSchema = z.string().uuid()
export const knowledgeCreateSchema = z
.object({
@@ -749,6 +794,10 @@ export type DesktopApi = {
) => Promise<void>
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
browser: {
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
settings: {
getRuntime: () => Promise<RuntimeSettings>
updateRuntime: (input: RuntimeSettingsInput) => Promise<RuntimeSettings>
@@ -836,6 +885,11 @@ export type DesktopApi = {
experts: {
list: () => Promise<AssistantExpert[]>
create: (input: ExpertCreateInput) => Promise<AssistantExpert>
update: (
expertId: string,
input: ExpertUpdateInput
) => Promise<AssistantExpert>
remove: (expertId: string) => Promise<void>
}
capabilities: {
getSnapshot: () => Promise<CapabilitySnapshot>
@@ -855,6 +909,29 @@ export type DesktopApi = {
) => Promise<CapabilitySnapshot>
removeMcpServer: (serverId: string) => Promise<CapabilitySnapshot>
testMcpServer: (serverId: string) => Promise<McpServerTestResult>
setComputerCapabilityEnabled?: (
capabilityId: ComputerCapabilityId,
enabled: boolean
) => Promise<CapabilitySnapshot>
setComputerCapabilityBrowserProfile?: (
capabilityId: ComputerCapabilityId,
browserProfileId: string | null
) => Promise<CapabilitySnapshot>
diagnoseComputerCapability?: (
capabilityId: ComputerCapabilityId
) => Promise<CapabilityDiagnosticReport>
createBrowserProfile?: (
input: BrowserProfileCreateInput
) => Promise<CapabilitySnapshot>
renameBrowserProfile?: (
input: BrowserProfileRenameInput
) => Promise<CapabilitySnapshot>
setDefaultBrowserProfile?: (
profileId: string
) => Promise<CapabilitySnapshot>
removeBrowserProfile?: (
profileId: string
) => Promise<CapabilitySnapshot>
}
context: {
selectFiles: () => Promise<ContextAttachment[]>
+11
View File
@@ -15,6 +15,8 @@ export const ipcChannels = {
agentCancel: 'agent:cancel',
agentApprovalRespond: 'agent:approval:respond',
agentEvent: 'agent:event',
browserStop: 'browser:stop',
browserState: 'browser:state',
runtimeSettingsGet: 'settings:runtime:get',
runtimeSettingsUpdate: 'settings:runtime:update',
runtimeSettingsSelectWorkspace: 'settings:runtime:select-workspace',
@@ -54,6 +56,8 @@ export const ipcChannels = {
heartbeatsHistory: 'heartbeats:history',
expertsList: 'experts:list',
expertsCreate: 'experts:create',
expertsUpdate: 'experts:update',
expertsRemove: 'experts:remove',
capabilitiesSnapshot: 'capabilities:snapshot',
capabilitiesImportSkill: 'capabilities:skill:import',
capabilitiesRemoveSkill: 'capabilities:skill:remove',
@@ -62,6 +66,13 @@ export const ipcChannels = {
capabilitiesSaveMcp: 'capabilities:mcp:save',
capabilitiesRemoveMcp: 'capabilities:mcp:remove',
capabilitiesTestMcp: 'capabilities:mcp:test',
capabilitiesToggleComputer: 'capabilities:computer:toggle',
capabilitiesConfigureComputer: 'capabilities:computer:configure',
capabilitiesDiagnoseComputer: 'capabilities:computer:diagnose',
capabilitiesCreateBrowserProfile: 'capabilities:browser-profile:create',
capabilitiesRenameBrowserProfile: 'capabilities:browser-profile:rename',
capabilitiesDefaultBrowserProfile: 'capabilities:browser-profile:default',
capabilitiesRemoveBrowserProfile: 'capabilities:browser-profile:remove',
contextSelectFiles: 'context:select-files',
contextCaptureScreen: 'context:capture-screen',
contextCaptureWindow: 'context:capture-window',
-60
View File
@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest'
import { modelProfilePresets } from './model-presets'
describe('modelProfilePresets', () => {
it('includes domestic, local, and generic protocol presets', () => {
expect(modelProfilePresets.map((preset) => preset.id)).toEqual(
expect.arrayContaining([
'bigtoken-gpt-image-2',
'deepseek',
'qwen',
'glm',
'kimi',
'minimax',
'siliconflow',
'volcengine-ark',
'hunyuan-deployment',
'huawei-deployment',
'ollama',
'openai',
'openai-compatible',
'anthropic-compatible'
])
)
expect(
modelProfilePresets.find((preset) => preset.id === 'ollama')
).toMatchObject({
baseUrl: 'http://127.0.0.1:11434/v1',
protocol: 'openai-chat-completions',
authentication: 'none'
})
expect(
modelProfilePresets.find(
(preset) => preset.id === 'bigtoken-gpt-image-2'
)
).toMatchObject({
baseUrl: 'https://bigtoken.ai/v1',
modelName: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key'
})
expect(
modelProfilePresets.find((preset) => preset.id === 'openai')
).toMatchObject({
baseUrl: 'https://api.openai.com/v1',
protocol: 'openai-responses',
authentication: 'api-key'
})
})
it('does not invent universal Hunyuan or Huawei endpoints', () => {
for (const id of ['hunyuan-deployment', 'huawei-deployment']) {
expect(
modelProfilePresets.find((preset) => preset.id === id)
).toMatchObject({
baseUrl: '',
requiresDeploymentUrl: true
})
}
})
})
-148
View File
@@ -1,148 +0,0 @@
import type {
ModelAuthentication,
ModelProtocol
} from './contracts'
export type ModelProfilePreset = {
id: string
name: string
description: string
baseUrl: string
modelName: string
protocol: ModelProtocol
authentication: ModelAuthentication
requiresDeploymentUrl?: boolean
}
export const modelProfilePresets = [
{
id: 'bigtoken-gpt-image-2',
name: 'BigToken GPT Image 2',
description: 'BigToken 图像生成接口,生成结果直接显示在会话中',
baseUrl: 'https://bigtoken.ai/v1',
modelName: 'gpt-image-2',
protocol: 'openai-images-generations',
authentication: 'api-key'
},
{
id: 'deepseek',
name: 'DeepSeek',
description: 'DeepSeek 官方 OpenAI 兼容接口',
baseUrl: 'https://api.deepseek.com/v1',
modelName: 'deepseek-chat',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'qwen',
name: 'QwenDashScope',
description: '阿里云百炼 DashScope OpenAI 兼容接口',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelName: 'qwen-plus',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'glm',
name: 'GLM(智谱)',
description: '智谱 AI OpenAI 兼容接口',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
modelName: 'glm-4.5',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'kimi',
name: 'Kimi(月之暗面)',
description: 'Moonshot OpenAI 兼容接口',
baseUrl: 'https://api.moonshot.cn/v1',
modelName: 'moonshot-v1-8k',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'minimax',
name: 'MiniMax',
description: 'MiniMax 国内 OpenAI 兼容接口',
baseUrl: 'https://api.minimaxi.com/v1',
modelName: 'MiniMax-M2.1',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'siliconflow',
name: 'SiliconFlow(硅基流动)',
description: 'SiliconFlow OpenAI 兼容接口',
baseUrl: 'https://api.siliconflow.cn/v1',
modelName: 'deepseek-ai/DeepSeek-V3.2',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'volcengine-ark',
name: '火山引擎方舟',
description: '方舟 OpenAI 兼容接口;模型填写推理接入点 ID',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
modelName: 'ep-your-endpoint-id',
protocol: 'openai-chat-completions',
authentication: 'api-key'
},
{
id: 'hunyuan-deployment',
name: '腾讯混元(自定义部署)',
description: '填写部署文档提供的专属 API Root 和模型或部署 ID',
baseUrl: '',
modelName: 'deployment-id',
protocol: 'openai-chat-completions',
authentication: 'api-key',
requiresDeploymentUrl: true
},
{
id: 'huawei-deployment',
name: '华为云模型(自定义部署)',
description: '填写部署所在区域提供的专属 API Root 和部署 ID',
baseUrl: '',
modelName: 'deployment-id',
protocol: 'openai-chat-completions',
authentication: 'api-key',
requiresDeploymentUrl: true
},
{
id: 'ollama',
name: 'Ollama(本机)',
description: '本机 Ollama OpenAI 兼容接口,无需 API Key',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'llama3.2',
protocol: 'openai-chat-completions',
authentication: 'none'
},
{
id: 'openai',
name: 'OpenAI',
description: 'OpenAI Responses API',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4.1',
protocol: 'openai-responses',
authentication: 'api-key'
},
{
id: 'openai-compatible',
name: 'OpenAI 兼容(自定义)',
description: '填写服务商提供的 API Root 和模型名称',
baseUrl: '',
modelName: 'model-name',
protocol: 'openai-chat-completions',
authentication: 'api-key',
requiresDeploymentUrl: true
},
{
id: 'anthropic-compatible',
name: 'Anthropic Messages 兼容(自定义)',
description: '填写服务商提供的 API Root 和模型名称',
baseUrl: '',
modelName: 'model-name',
protocol: 'anthropic-messages',
authentication: 'api-key',
requiresDeploymentUrl: true
}
] as const satisfies readonly ModelProfilePreset[]