feat: let agents read supervisor story graphs
Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes. Make Continue discussion show the complete editable message with a separated destination and shared action layout. Sending uses the visible draft without reattaching removed context. Validation: existing focused MCP, runtime, controlled Linux Host, discussion component and Electron layout checks plus typechecks; staged diff check passed. Cross-scope fact reuse, historical reconstruction and full package acceptance remain deferred. Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
This commit is contained in:
@@ -1188,8 +1188,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
raw = await this.awaitOperation(
|
||||
context,
|
||||
'启动远端请求',
|
||||
request.imageToolBinding
|
||||
? context.channel.preparePrompt(preparation, request.imageToolBinding, signal)
|
||||
request.imageToolBinding || request.storyGraphBinding
|
||||
? context.channel.preparePrompt(preparation, request.imageToolBinding, signal, request.storyGraphBinding)
|
||||
: context.channel.preparePrompt(preparation),
|
||||
signal
|
||||
)
|
||||
|
||||
@@ -1270,9 +1270,7 @@ export class ContinueHostAdapter {
|
||||
)
|
||||
}
|
||||
const capabilityServers = [
|
||||
...(runOptions.workMode === 'execute'
|
||||
? runOptions.sessionMcpServers ?? []
|
||||
: []),
|
||||
...(runOptions.sessionMcpServers ?? []),
|
||||
...(knowledgeCapability
|
||||
? [
|
||||
createLoopbackMcpServer(
|
||||
@@ -1340,7 +1338,7 @@ export class ContinueHostAdapter {
|
||||
)
|
||||
}
|
||||
const retainedServers =
|
||||
runOptions.workMode === 'ask' && Boolean(knowledgeCapability)
|
||||
runOptions.workMode === 'ask' && Boolean(knowledgeCapability || runOptions.sessionMcpServers?.length)
|
||||
? []
|
||||
: servers.filter(
|
||||
(server) =>
|
||||
@@ -1524,7 +1522,7 @@ export class ContinueHostAdapter {
|
||||
}
|
||||
if (
|
||||
runOptions.workMode === 'ask' &&
|
||||
runOptions.knowledgeCapability
|
||||
(runOptions.knowledgeCapability || runOptions.sessionMcpServers?.length)
|
||||
) {
|
||||
for (const toolName of scopedReadToolNames) {
|
||||
args.push('--allow', toolName)
|
||||
|
||||
@@ -43,6 +43,8 @@ import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ImageGenerationService } from './image-generation-service'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
|
||||
vi.mock('electron', () => ({ nativeImage: {} }))
|
||||
|
||||
@@ -101,6 +103,41 @@ it('generates and edits durable images through the controlled Harness Main proxy
|
||||
}, 60_000)
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
|
||||
it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and Execute, then removes it when disabled', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-graph-')))
|
||||
const db = new AssistantDatabase(':memory:'); db.initialize(root)
|
||||
let enabled = true
|
||||
const projectId = db.listProjects()[0]!.id
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
|
||||
available: async () => enabled, read: db.readStoryGraph.bind(db)
|
||||
} })
|
||||
const signal = new AbortController().signal
|
||||
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
|
||||
{ projectId, runtimeTarget: 'deepseek-harness' })!
|
||||
const inProcess = createInProcessLaunch(root, { stream(options) {
|
||||
const prompt = latestUserText(options)
|
||||
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
|
||||
if (!enabled) { expect(tool).toBeUndefined(); return textResponse('DISABLED') }
|
||||
expect(tool).toBeDefined()
|
||||
if (!toolResultText(options, prompt)) return toolCall(prompt, 'story_graph_search', { query: 'Decision' })
|
||||
expect(toolResultText(options, prompt)).toContain(projectId)
|
||||
return textResponse('GRAPH_READ')
|
||||
} })
|
||||
const provider = new ModelToolProvider(root, [], undefined, gateway)
|
||||
const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: root, baseUrl: 'https://chat.test/v1', model: 'fixture',
|
||||
launch: inProcess.launch, credentialRefs: { [CREDENTIAL_REF]: 'fixture' }, toolProvider: provider,
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000 })
|
||||
try {
|
||||
for (const mode of ['ask', 'execute', 'disabled'] as const) {
|
||||
enabled = mode !== 'disabled'
|
||||
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode,
|
||||
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
|
||||
}
|
||||
} finally { await runtime.dispose(); await provider.dispose(); await gateway.dispose(); db.close(); await rm(root, { recursive: true, force: true }) }
|
||||
}, 60_000)
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
const MCP_CALL_ID = 'e2e-mcp-call'
|
||||
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
|
||||
|
||||
@@ -350,6 +350,14 @@ function proxyToolInputSchema(
|
||||
ReturnType<ModelToolProviderLike['listTools']>
|
||||
>[number]
|
||||
): Record<string, unknown> {
|
||||
if (isStoryGraphTool(tool.name)) {
|
||||
const schema = structuredClone(tool.inputSchema)
|
||||
const scope = (schema.properties as Record<string, Record<string, unknown>>).scope!
|
||||
// Scope is a discriminated union: Global and projects are mutually exclusive.
|
||||
scope.oneOf = scope.anyOf
|
||||
delete scope.anyOf
|
||||
return dshCompatibleMainInputSchema(schema)
|
||||
}
|
||||
return isMainWebTool(tool) || tool.name === 'generate_image'
|
||||
? dshCompatibleMainInputSchema(tool.inputSchema)
|
||||
: tool.inputSchema
|
||||
@@ -367,7 +375,7 @@ function boundedProxyToolCatalog(
|
||||
}> {
|
||||
const catalog = tools.filter(
|
||||
(tool) =>
|
||||
isMainWebTool(tool) ||
|
||||
isMainWebTool(tool) || isStoryGraphTool(tool.name) ||
|
||||
(workMode === 'execute' && (tool.source === 'mcp' || tool.name === 'generate_image'))
|
||||
)
|
||||
if (catalog.length > MAX_MCP_PROXY_TOOLS) {
|
||||
@@ -856,7 +864,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
const catalog = boundedProxyToolCatalog(tools, context.workMode)
|
||||
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
|
||||
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || tool.name === 'generate_image'
|
||||
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || tool.name === 'generate_image'
|
||||
))
|
||||
return { tools: catalog }
|
||||
}
|
||||
@@ -897,7 +905,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
const tool = tools.find(
|
||||
(candidate) =>
|
||||
candidate.name === name &&
|
||||
(candidate.source === 'mcp' || candidate.name === 'generate_image' ||
|
||||
(candidate.source === 'mcp' || candidate.name === 'generate_image' || isStoryGraphTool(candidate.name) ||
|
||||
isMainWebTool(candidate))
|
||||
)
|
||||
if (!tool) {
|
||||
@@ -907,7 +915,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const isWebTool = isMainWebTool(tool)
|
||||
if (
|
||||
!isWebTool &&
|
||||
!isWebTool && !isStoryGraphTool(name) &&
|
||||
(context.workMode !== 'execute' || !run.authorize)
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -937,7 +945,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
.slice(0, 1_000)}`
|
||||
)
|
||||
}
|
||||
if (!isWebTool) {
|
||||
if (!isWebTool && !isStoryGraphTool(name)) {
|
||||
const approval =
|
||||
run.toolProvider.getApproval(
|
||||
tool,
|
||||
@@ -1758,3 +1766,4 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
|
||||
import { isStoryGraphTool } from '../../shared/story-graph-tools'
|
||||
|
||||
@@ -922,7 +922,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
definition,
|
||||
dispose
|
||||
})
|
||||
if (MAIN_WEB_TOOL_NAMES.has(tool.name)) {
|
||||
if (MAIN_WEB_TOOL_NAMES.has(tool.name) || isStoryGraphTool(tool.name)) {
|
||||
record.askToolDefinitions.set(tool.name, definition)
|
||||
}
|
||||
}
|
||||
@@ -1454,3 +1454,4 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
}
|
||||
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
|
||||
import { isStoryGraphTool } from '../../shared/story-graph-tools'
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
||||
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
||||
|
||||
export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget }
|
||||
export type StoryGraphRemoteBinding = {
|
||||
available(): Promise<boolean>
|
||||
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
||||
}
|
||||
export type StoryGraphService = {
|
||||
available(binding: StoryGraphBinding): Promise<boolean>
|
||||
read(name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record<string, unknown>
|
||||
}
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
|
||||
import {
|
||||
createServer,
|
||||
@@ -191,6 +203,7 @@ export type KnowledgeLibraryListItem = {
|
||||
}
|
||||
|
||||
type Capability = {
|
||||
storyGraph?: StoryGraphBinding
|
||||
imageToolBinding?: ImageToolBinding
|
||||
requestId: string
|
||||
libraryIds: readonly string[]
|
||||
@@ -242,6 +255,7 @@ type DownstreamMcpSession = {
|
||||
}
|
||||
|
||||
export type KnowledgeMcpGatewayOptions = {
|
||||
storyGraphService?: StoryGraphService
|
||||
capabilityTtlMs?: number
|
||||
maximumBodyBytes?: number
|
||||
now?: () => number
|
||||
@@ -364,6 +378,7 @@ async function readBoundedJson(
|
||||
}
|
||||
|
||||
export class KnowledgeMcpGateway {
|
||||
private readonly storyGraphService?: StoryGraphService
|
||||
private readonly capabilities = new Map<string, Capability>()
|
||||
private readonly downstreamMcpSessions = new Map<
|
||||
string,
|
||||
@@ -407,6 +422,7 @@ export class KnowledgeMcpGateway {
|
||||
this.maximumBodyBytes =
|
||||
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
|
||||
this.now = options.now ?? Date.now
|
||||
this.storyGraphService = options.storyGraphService
|
||||
this.magicNotesDatabase = options.magicNotesDatabase
|
||||
this.configService = options.configService
|
||||
this.obsidianService = options.obsidianService
|
||||
@@ -466,7 +482,8 @@ export class KnowledgeMcpGateway {
|
||||
browserConversationId?: string,
|
||||
browserTabId?: BrowserTabId,
|
||||
browserUsageLease?: BrowserTabUsageLease,
|
||||
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
|
||||
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' },
|
||||
storyGraph?: StoryGraphBinding
|
||||
): string | undefined {
|
||||
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
||||
? magicNotesAccess
|
||||
@@ -516,7 +533,8 @@ export class KnowledgeMcpGateway {
|
||||
effectiveMagicNotesAccess === 'none' &&
|
||||
effectiveConfigAccess === 'none' &&
|
||||
!effectiveBrowserConversationId &&
|
||||
!effectiveObsidian
|
||||
!effectiveObsidian &&
|
||||
!(storyGraph && this.storyGraphService)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
@@ -529,6 +547,7 @@ export class KnowledgeMcpGateway {
|
||||
magicNotesAccess: effectiveMagicNotesAccess,
|
||||
configAccess: effectiveConfigAccess,
|
||||
obsidian: effectiveObsidian,
|
||||
storyGraph: this.storyGraphService ? storyGraph : undefined,
|
||||
browserConversationId: effectiveBrowserConversationId,
|
||||
browserTabId: effectiveBrowserTabId,
|
||||
browserUsageLease: effectiveBrowserUsageLease,
|
||||
@@ -799,6 +818,7 @@ export class KnowledgeMcpGateway {
|
||||
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
|
||||
const capability = this.getCapability(token)
|
||||
return [
|
||||
...(capability.storyGraph ? storyGraphToolNames : []),
|
||||
...(capability.imageToolBinding ? ['generate_image' as const] : []),
|
||||
...(capability.libraryIds.length > 0
|
||||
? knowledgeToolNames
|
||||
@@ -1544,6 +1564,29 @@ export class KnowledgeMcpGateway {
|
||||
return { deleted: true, noteId: parsed.noteId }
|
||||
}
|
||||
|
||||
async isStoryGraphAvailable(token: string): Promise<boolean> {
|
||||
const binding = this.getCapability(token).storyGraph
|
||||
return Boolean(binding && this.storyGraphService && await this.storyGraphService.available(binding))
|
||||
}
|
||||
|
||||
bindRemoteStoryGraph(token: string): StoryGraphRemoteBinding | undefined {
|
||||
if (!this.getCapability(token).storyGraph) return undefined
|
||||
return { available: () => this.isStoryGraphAvailable(token), call: (name, input, signal) => this.callStoryGraphTool(token, name, input, signal) }
|
||||
}
|
||||
|
||||
async callStoryGraphTool(token: string, name: StoryGraphToolName, input: unknown, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
||||
const capability = this.getCapability(token)
|
||||
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
|
||||
effectiveSignal.throwIfAborted()
|
||||
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
|
||||
effectiveSignal.throwIfAborted()
|
||||
const result = this.storyGraphService!.read(name, input, capability.storyGraph!.projectId, effectiveSignal)
|
||||
effectiveSignal.throwIfAborted()
|
||||
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
|
||||
effectiveSignal.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
|
||||
private async callScopedTool(
|
||||
token: string,
|
||||
name: ScopedDataToolName,
|
||||
@@ -1551,6 +1594,10 @@ export class KnowledgeMcpGateway {
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (name) {
|
||||
case 'story_graph_search':
|
||||
case 'story_graph_get_context':
|
||||
case 'story_graph_read_source':
|
||||
return this.callStoryGraphTool(token, name, input, signal)
|
||||
case 'knowledge_list':
|
||||
return { libraries: this.listLibraries(token, input) }
|
||||
case 'knowledge_search':
|
||||
@@ -1651,7 +1698,8 @@ export class KnowledgeMcpGateway {
|
||||
token,
|
||||
extra.signal
|
||||
)
|
||||
const scopedTools = [...availableTools].flatMap(
|
||||
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
|
||||
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
|
||||
(name): Tool[] => {
|
||||
const definition = scopedDataToolByName.get(
|
||||
name as ScopedDataToolName
|
||||
@@ -1724,6 +1772,10 @@ export class KnowledgeMcpGateway {
|
||||
async (call, extra) => {
|
||||
const name = call.params.name
|
||||
const input = call.params.arguments ?? {}
|
||||
if (isStoryGraphTool(name)) {
|
||||
try { return { content: [{ type: 'text' as const, text: JSON.stringify(await this.callStoryGraphTool(token, name, input, extra.signal)) }] } }
|
||||
catch (error) { return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
|
||||
}
|
||||
if (name === 'generate_image') {
|
||||
const capability = this.getCapability(token)
|
||||
if (!capability.imageToolBinding) throw new Error('Image capability is unavailable')
|
||||
|
||||
@@ -1015,6 +1015,7 @@ describe('ModelToolProvider', () => {
|
||||
listLibraries: vi.fn(() => []),
|
||||
search: vi.fn(async () => []),
|
||||
searchMagicNotes: vi.fn(() => []),
|
||||
isStoryGraphAvailable: vi.fn(async () => true),
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const context = {
|
||||
@@ -1072,6 +1073,7 @@ describe('ModelToolProvider', () => {
|
||||
|
||||
it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => {
|
||||
const gateway = {
|
||||
isStoryGraphAvailable: vi.fn(async () => true),
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)],
|
||||
@@ -1117,6 +1119,7 @@ describe('ModelToolProvider', () => {
|
||||
|
||||
it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => {
|
||||
const gateway = {
|
||||
isStoryGraphAvailable: vi.fn(async () => true),
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({
|
||||
|
||||
@@ -1252,7 +1252,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
signal: AbortSignal
|
||||
): Promise<ModelToolDefinition[]> {
|
||||
signal.throwIfAborted()
|
||||
const scopedTools = this.getScopedTools(context)
|
||||
const grantedTools = this.getScopedTools(context)
|
||||
const storyGraphAvailable = grantedTools.some(tool => tool.name.startsWith('story_graph_')) && context.knowledgeCapabilityToken && this.knowledgeGateway
|
||||
? await this.knowledgeGateway.isStoryGraphAvailable(context.knowledgeCapabilityToken) : false
|
||||
const scopedTools = grantedTools.filter(tool => !tool.name.startsWith('story_graph_') || storyGraphAvailable)
|
||||
const imageTool = context.workMode === 'execute' ? await imageToolDefinition(context.imageToolBinding) : undefined
|
||||
const webTools = this.webSearchEnabled
|
||||
? this.getWebSearchDefinitions()
|
||||
@@ -1406,6 +1409,12 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
): Promise<ModelToolResult> {
|
||||
signal.throwIfAborted()
|
||||
assertToolAuthorizedForWorkMode(name, context)
|
||||
if (name.startsWith('story_graph_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
|
||||
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) throw new Error('Story Graph capability is unavailable')
|
||||
return createTextToolResult(JSON.stringify(await this.knowledgeGateway.callStoryGraphTool(
|
||||
context.knowledgeCapabilityToken, name as import('../../shared/story-graph-tools').StoryGraphToolName, argumentsValue, signal
|
||||
)))
|
||||
}
|
||||
if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
|
||||
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) {
|
||||
throw new Error('Obsidian 工具授权不可用')
|
||||
|
||||
@@ -101,7 +101,7 @@ export class NativeClientCoordinator {
|
||||
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
||||
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
||||
workMode, skills, mcpServers, builtin,
|
||||
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, obsidian,
|
||||
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, supervisor: application.heartbeatEnabled, obsidian,
|
||||
node: application.localToolEnvironment.node,
|
||||
binary: selected.target === 'continue' ? selected.settings.continueBinaryPath
|
||||
: selected.target === 'opencode' ? selected.settings.opencodeBinaryPath : undefined,
|
||||
@@ -162,9 +162,11 @@ export class NativeClientCoordinator {
|
||||
const token = gateway.grant(requestId, builtin.includes('knowledge-base') ? conversation.knowledgeLibraryIds ?? [] : [], signal,
|
||||
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
||||
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined)
|
||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
|
||||
builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined)
|
||||
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` } }] : [])
|
||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
|
||||
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
|
||||
if (selected.target === 'deepseek-harness') {
|
||||
const profile = selected.settings.deepseekHarnessModelProfile
|
||||
if (!profile) throw new Error('DS Web requires a text model connection')
|
||||
|
||||
@@ -91,4 +91,18 @@ describe('native terminal client', () => {
|
||||
expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp')
|
||||
expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute')
|
||||
})
|
||||
|
||||
it('allows only the bound read tools through native OpenCode Ask permissions and execution hook', async () => {
|
||||
const { client, input } = await fixture()
|
||||
await client.open(1, { ...input, mcpServers: [{ name: 'goodbuddy-0', url: 'http://127.0.0.1:12345/mcp', headers: {},
|
||||
readOnlyTools: ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source'] }] })
|
||||
const config = JSON.parse(launches[0]!.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.mcp['goodbuddy-0']).toBeDefined()
|
||||
const hooks = await (await import(/* @vite-ignore */ new URL(config.plugin[0]).href)).default()
|
||||
for (const name of ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']) {
|
||||
expect(config.permission[`goodbuddy-0_${name}`]).toBe('allow')
|
||||
await expect(hooks['tool.execute.before']({ tool: `goodbuddy-0_${name}` })).resolves.toBeUndefined()
|
||||
}
|
||||
await expect(hooks['tool.execute.before']({ tool: 'goodbuddy-0_generate_image' })).rejects.toThrow('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ export type NativeTerminalClientInput = {
|
||||
size?: TerminalSize
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
/** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */
|
||||
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string> }>
|
||||
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string>; readOnlyTools?: readonly string[] }>
|
||||
}
|
||||
|
||||
export type NativeTerminalClientOptions = {
|
||||
@@ -141,7 +141,9 @@ export class NativeTerminalClient {
|
||||
await mkdir(history, { recursive: true })
|
||||
const origin = await proxy.listen()
|
||||
const skills = input.skillPackages ?? []
|
||||
const mcp = input.workMode === 'execute' ? input.mcpServers ?? [] : []
|
||||
const mcp = input.mcpServers ?? []
|
||||
const allowedContinueTools = [...continueReadTools, ...mcp.flatMap(server => server.readOnlyTools ?? [])]
|
||||
const allowedOpenCodeTools = [...openCodeReadTools, 'skill', ...mcp.flatMap(server => (server.readOnlyTools ?? []).map(name => `${server.name}_${name}`))]
|
||||
if (input.runtime === 'continue') {
|
||||
// Reuse the pinned adapter's protocol, permission-order and Windows fixes.
|
||||
const adapter = new ContinueHostAdapter({ binaryPath: detection.path,
|
||||
@@ -151,7 +153,7 @@ export class NativeTerminalClient {
|
||||
const bundle = await readFile(bundlePath, 'utf8')
|
||||
const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){'
|
||||
if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible')
|
||||
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
|
||||
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(allowedContinueTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
|
||||
args[0] = prepared.entryPath
|
||||
const configured = input.settings.continueConfigPath.trim()
|
||||
? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {}
|
||||
@@ -172,7 +174,7 @@ export class NativeTerminalClient {
|
||||
Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' })
|
||||
args.push('--config', configPath)
|
||||
if (input.workMode === 'execute') args.push('--auto')
|
||||
else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*')
|
||||
else args.push('--readonly', ...allowedContinueTools.flatMap(name => ['--allow', name]), '--exclude', '*')
|
||||
} else {
|
||||
const configDirectory = join(temporary, 'config')
|
||||
if (this.options.bundledRuntimePaths.opencodeConfig) {
|
||||
@@ -184,11 +186,11 @@ export class NativeTerminalClient {
|
||||
loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode
|
||||
})
|
||||
const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([
|
||||
['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow']
|
||||
['*', 'deny'], ...allowedOpenCodeTools.map(name => [name, 'allow'])
|
||||
])
|
||||
// A tool hook keeps the launch mode fixed even when the TUI changes agents.
|
||||
const pluginPath = join(temporary, 'work-mode.mjs')
|
||||
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
|
||||
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify(allowedOpenCodeTools)}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
|
||||
Object.assign(env, {
|
||||
OPENCODE_CONFIG_DIR: configDirectory,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission,
|
||||
|
||||
@@ -114,7 +114,8 @@ export interface RemoteRuntimeChannel {
|
||||
preparePrompt(
|
||||
preparation: RemotePromptOperationPreparation,
|
||||
imageToolBinding?: ImageToolBinding,
|
||||
waitSignal?: AbortSignal
|
||||
waitSignal?: AbortSignal,
|
||||
storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding
|
||||
): Promise<RemotePromptOperationAcceptance>
|
||||
/** Starts an Agent-owned ACP prompt exactly once for its stable operation. */
|
||||
startOwnedPrompt?(
|
||||
|
||||
@@ -146,6 +146,7 @@ export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
|
||||
trustedInstructions?: string
|
||||
/** Main-process-only request-scoped authorization for built-in data tools. */
|
||||
knowledgeCapabilityToken?: string
|
||||
storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding
|
||||
imageToolBinding?: ImageToolBinding
|
||||
/** Main-process-only browser tab fixed for this request. */
|
||||
browserTabId?: BrowserTabId
|
||||
|
||||
Reference in New Issue
Block a user