feat: share built-in browser across runtimes

The built-in browser was limited to direct-model tools, and its Agent switch also blocked manual Browser workbar actions. It is now a request-scoped GoodBuddy capability assignable to direct models, managed OpenCode, and Continue, while manual browser controls remain available when Agent access is off.

Existing browser enablement migrates to capability settings version 6. DeepSeek Harness remains unsupported.

Release note: 内置浏览器现在可分配给直连模型、OpenCode 和 Continue;关闭 Agent 浏览器能力不会影响用户在浏览器工作栏中的手动操作。
This commit is contained in:
mesalogo
2026-09-03 19:36:06 +08:00
parent 70ec2bf792
commit feaebdf221
23 changed files with 958 additions and 289 deletions
@@ -18,6 +18,7 @@ import {
import type { KnowledgeService } from '../knowledge/knowledge-service'
import { AssistantDatabase } from '../assistant/assistant-database'
import {
browserToolNames,
KnowledgeMcpGateway,
type MagicNotesDatabase
} from './knowledge-mcp-gateway'
@@ -207,6 +208,81 @@ afterEach(async () => {
})
describe('KnowledgeMcpGateway', () => {
it('exposes the assigned browser through a request-scoped conversation', async () => {
const { service } = createService()
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({
url: 'https://example.com/',
origin: 'https://example.com'
})),
snapshot: vi.fn(),
click: vi.fn(),
type: vi.fn(),
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const gateway = new KnowledgeMcpGateway(service, {
browserService
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant(
'browser-request',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation'
)!
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
const client = new Client({
name: 'browser-loopback-test',
version: '1.0.0'
})
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
const listed = await client.listTools()
expect(listed.tools.map((tool) => tool.name)).toEqual(
browserToolNames
)
await expect(
client.callTool({
name: 'browser_navigate',
arguments: { url: 'https://example.com' }
})
).resolves.toMatchObject({
content: [
{
type: 'text',
text: JSON.stringify({
url: 'https://example.com/',
origin: 'https://example.com'
})
}
]
})
expect(browserService.navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com',
expect.any(AbortSignal)
)
} finally {
await client.close()
}
})
it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => {
const { service } = createService()
const configService = {
+117 -23
View File
@@ -64,6 +64,14 @@ import {
normalizeMcpToolSchema
} from './mcp-tool-utils'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import {
BrowserModelTools,
browserToolNames,
type BrowserToolName,
type BrowserToolService
} from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { safeToolErrorDetail } from './approval-summary'
const MAX_REQUEST_BODY_BYTES = 64 * 1024
const MAX_RESULT_BYTES = 128 * 1024
@@ -79,6 +87,7 @@ const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export {
browserToolNames,
knowledgeToolNames,
magicNoteReadToolNames,
magicNoteWriteToolNames,
@@ -86,6 +95,10 @@ export {
scopedReadToolNames
}
export type GoodBuddyBuiltinToolName =
| ScopedDataToolName
| BrowserToolName
const {
knowledge_list: knowledgeListTool,
knowledge_search: knowledgeSearchTool
@@ -171,6 +184,7 @@ type Capability = {
configAccess: MagicNotesCapabilityAccess
configWorkspacePath?: string
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
browserConversationId?: string
expiresAt: number
signal: AbortSignal
brokerController: AbortController
@@ -216,6 +230,7 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
browserService?: BrowserToolService
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
@@ -341,6 +356,7 @@ export class KnowledgeMcpGateway {
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly browserService?: BrowserToolService
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private server?: Server
private endpoint?: string
@@ -363,6 +379,7 @@ export class KnowledgeMcpGateway {
this.now = options.now ?? Date.now
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
}
@@ -414,7 +431,8 @@ export class KnowledgeMcpGateway {
access: MagicNotesCapabilityAccess
workspacePath: string
authorizeApply?: GoodBuddyConfigApplyAuthorizer
}
},
browserConversationId?: string
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -422,10 +440,14 @@ export class KnowledgeMcpGateway {
const effectiveConfigAccess = this.configService
? config?.access ?? 'none'
: 'none'
const effectiveBrowserConversationId = this.browserService
? browserConversationId
: undefined
if (
authorizedLibraryIds.length === 0 &&
effectiveMagicNotesAccess === 'none' &&
effectiveConfigAccess === 'none'
effectiveConfigAccess === 'none' &&
!effectiveBrowserConversationId
) {
return undefined
}
@@ -435,6 +457,7 @@ export class KnowledgeMcpGateway {
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
@@ -683,7 +706,7 @@ export class KnowledgeMcpGateway {
return libraries
}
getAvailableToolNames(token: string): ScopedDataToolName[] {
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
const capability = this.getCapability(token)
return [
...(capability.libraryIds.length > 0
@@ -700,6 +723,9 @@ export class KnowledgeMcpGateway {
: []),
...(capability.configAccess === 'write'
? goodbuddyConfigWriteToolNames
: []),
...(capability.browserConversationId
? browserToolNames
: [])
]
}
@@ -1413,7 +1439,7 @@ export class KnowledgeMcpGateway {
private createDownstreamMcpSession(
token: string,
availableScopedTools: ReadonlySet<ScopedDataToolName>
availableTools: ReadonlySet<GoodBuddyBuiltinToolName>
): DownstreamMcpSession {
const mcp = new McpProtocolServer(
{
@@ -1461,9 +1487,11 @@ export class KnowledgeMcpGateway {
token,
extra.signal
)
const scopedTools = [...availableScopedTools].flatMap(
const scopedTools = [...availableTools].flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(name)
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
@@ -1489,10 +1517,34 @@ export class KnowledgeMcpGateway {
]
}
)
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
@@ -1505,27 +1557,69 @@ export class KnowledgeMcpGateway {
async (call, extra) => {
const name = call.params.name
const input = call.params.arguments ?? {}
if (availableScopedTools.has(name as ScopedDataToolName)) {
if (availableTools.has(name as ScopedDataToolName)) {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
throw new Error('GoodBuddy 工具不存在')
}
const parsedInput = definition.inputSchema.parse(input)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput
if (definition) {
const parsedInput = definition.inputSchema.parse(input)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput
)
)
}
]
}
}
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
})
: undefined
if (browserTools?.ownsTool(name)) {
try {
const result = await browserTools.callTool(
name,
input,
extra.signal
)
return {
content: result.parts.map((part) =>
part.type === 'text'
? { type: 'text' as const, text: part.text }
: {
type: 'image' as const,
data: part.data,
mimeType: part.mimeType
}
)
}
]
} catch (error) {
const detail =
safeToolErrorDetail(error, 2_000) ??
'内置浏览器工具执行失败'
return {
isError: true,
content: [
{
type: 'text' as const,
text:
error instanceof BrowserStaleReferenceError
? `${detail}。请调用 browser_snapshot 获取新快照,然后使用新引用重试。`
: detail
}
]
}
}
}
}
const binding = (
@@ -1643,12 +1737,12 @@ export class KnowledgeMcpGateway {
})
return
}
const availableScopedTools = new Set(
const availableTools = new Set(
this.getAvailableToolNames(token)
)
session = this.createDownstreamMcpSession(
token,
availableScopedTools
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
+5 -1
View File
@@ -8,7 +8,8 @@ import {
magicNoteWriteToolNames,
maximumScopedToolCount,
scopedDataToolByName,
scopedReadToolNames
scopedReadToolNames,
type ScopedDataToolName
} from '../../shared/scoped-data-tools'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import { createMcpTransport } from '../capabilities/mcp-client-transport'
@@ -639,6 +640,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
const available = new Set(
this.knowledgeGateway.getAvailableToolNames(
context.knowledgeCapabilityToken
).filter(
(name): name is ScopedDataToolName =>
scopedDataToolByName.has(name as ScopedDataToolName)
)
)
const tools = [...available].flatMap(
+166 -1
View File
@@ -1,7 +1,8 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import {
defaultContextCompressionSettings,
@@ -65,6 +66,55 @@ const portableRoot = process.env.GOODBUDDY_E2E_PACKAGED_ROOT
'win-unpacked'
)
const bundledOpenCodePath = existsSync(
join(
portableRoot,
'resources',
'runtimes',
'opencode',
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
)
? join(
portableRoot,
'resources',
'runtimes',
'opencode',
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
: join(
process.cwd(),
'.runtime-resources',
process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
const bundledContinuePath = existsSync(
join(
portableRoot,
'resources',
'runtimes',
'continue',
'dist',
'cn.js'
)
)
? join(
portableRoot,
'resources',
'runtimes',
'continue',
'dist',
'cn.js'
)
: join(
process.cwd(),
'node_modules',
'@continuedev',
'cli',
'dist',
'cn.js'
)
async function collectText(
events: AsyncGenerator<RuntimeEvent, void, void>
): Promise<string> {
@@ -1351,4 +1401,119 @@ describe.runIf(enabled)('runtime end-to-end', () => {
},
180_000
)
it.each([
['OpenCode', 'opencode-browser-e2e', 'OPENCODE_BROWSER_E2E_OK'],
['Continue', 'continue-browser-e2e', 'CONTINUE_BROWSER_E2E_OK']
] as const)(
'calls the assigned built-in browser through bundled %s',
async (runtimeName, conversationId, completionMarker) => {
const navigate = vi.fn(async (_conversationId: string, url: string) => ({
url,
origin: new URL(url).origin
}))
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate,
snapshot: vi.fn(),
click: vi.fn(),
type: vi.fn(),
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const gateway = new KnowledgeMcpGateway({} as never, {
browserService: browserService as never
})
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const capabilityToken = gateway.grant(
requestId,
[],
controller.signal,
'none',
undefined,
conversationId
)!
const common = {
modelProfile: {
id: crypto.randomUUID(),
name: 'E2E model',
baseUrl,
modelName,
apiKey,
protocol,
authentication: 'api-key' as const
},
defaultWorkspace: workspace,
knowledgeGateway: gateway
}
const runtime = new AgentRuntimeController(
runtimeName === 'OpenCode'
? new OpenCodeRuntime({
embedded: true,
binaryPath: '',
bundledBinaryPath: bundledOpenCodePath,
configPath: '',
...common
})
: new ContinueAgentRuntime({
binaryPath: '',
bundledBinaryPath: bundledContinuePath,
configPath: '',
hostCacheRoot: join(
workspace,
'.continue-browser-host'
),
...common
})
)
const targetUrl = `https://example.com/${conversationId}`
try {
const events = await collectEvents(
runtime.run(
{
requestId,
conversationId,
workMode: 'execute',
knowledgeCapabilityToken: capabilityToken,
prompt:
`Call browser_navigate exactly once with ${targetUrl}. ` +
`After the tool succeeds, reply with exactly ${completionMarker}.`
},
controller.signal,
async () => 'once'
)
)
expect(navigate).toHaveBeenCalledOnce()
expect(navigate).toHaveBeenCalledWith(
conversationId,
targetUrl,
expect.any(AbortSignal)
)
expect(
events
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain(completionMarker)
const modelCalls = events.filter(
(event) => event.type === 'model-usage'
).length
expect(modelCalls).toBeGreaterThanOrEqual(1)
console.info(
`REAL_${runtimeName.toUpperCase()}_BROWSER_MODEL_CALLS=${modelCalls}`
)
} finally {
controller.abort()
await runtime.dispose()
await gateway.dispose()
}
},
180_000
)
})