feat: add DSH plugin marketplace and shared MCP
GoodBuddy could share Skills across runtimes, but custom MCP remained limited and DeepSeek Harness could not manage third-party extensions. The app now provides a default-off DSH npm marketplace with managed installation, configuration, failure isolation, and packaged npm support, while assigned custom MCP is available to managed OpenCode, Continue Agent, and DeepSeek Harness in Execute. Third-party DSH install scripts, initialization, and tools run with the current user's permissions. Ask remains read-only at dispatch, and turning off the marketplace hides management without disabling installed plugins. Release note: 新增默认关闭的 DSH 插件市场,并让自定义 MCP 可分配给 OpenCode、Continue 和 DeepSeek Harness;安装第三方插件前会明确提示当前用户权限边界。
This commit is contained in:
@@ -313,6 +313,41 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(launchHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a custom MCP loopback capability outside Continue Agent Execute mode', async () => {
|
||||
const launchHost = vi.fn()
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: 'C:\\unused\\cn.js',
|
||||
configPath: '',
|
||||
workspace: process.cwd(),
|
||||
cacheRoot: 'C:\\unused\\cache',
|
||||
launchHost: launchHost as unknown as ContinueHostLauncher,
|
||||
modelProfile: {
|
||||
id: '00000000-0000-4000-8000-000000000097',
|
||||
name: 'Local model',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
async () => 'deny',
|
||||
{
|
||||
workMode: 'ask',
|
||||
customMcpCapability: {
|
||||
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||
token: 'request-token'
|
||||
}
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('仅允许在 Agent Execute 模式')
|
||||
expect(launchHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('launches the prepared host through the injected launcher', async () => {
|
||||
const distribution = await createDistribution()
|
||||
const skillDirectory = join(
|
||||
@@ -470,7 +505,18 @@ describe('ContinueHostAdapter', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.run('hello', new AbortController().signal, async () => 'deny')
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
async () => 'deny',
|
||||
{
|
||||
workMode: 'execute',
|
||||
customMcpCapability: {
|
||||
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||
token: 'request-scoped-custom-token'
|
||||
}
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
text: 'HOST_LAUNCH_OK',
|
||||
usage: {
|
||||
@@ -486,7 +532,7 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(launch?.args).toEqual([
|
||||
'--config',
|
||||
expect.stringContaining('model-config-'),
|
||||
'--readonly',
|
||||
'--auto',
|
||||
'serve',
|
||||
'--port',
|
||||
expect.any(String),
|
||||
@@ -531,6 +577,19 @@ describe('ContinueHostAdapter', () => {
|
||||
apiKey: '${{ secrets.ANTHROPIC_API_KEY }}',
|
||||
model: 'private-model'
|
||||
}
|
||||
],
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'goodbuddy-custom-mcp',
|
||||
type: 'streamable-http',
|
||||
url: 'http://127.0.0.1:4567/mcp',
|
||||
requestOptions: {
|
||||
headers: {
|
||||
Authorization:
|
||||
'Bearer request-scoped-custom-token'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(generatedConfig).not.toContain('private-key')
|
||||
|
||||
@@ -54,6 +54,7 @@ const maximumStreamEvents = 5_000
|
||||
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||
const maximumExecutionMilliseconds = 10 * 60_000
|
||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||
const customMcpName = 'goodbuddy-custom-mcp'
|
||||
export const continueConfigurationRequiredMessage =
|
||||
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||
const utilityBootstrap = [
|
||||
@@ -200,6 +201,10 @@ export type ContinueHostRunOptions = {
|
||||
endpoint: string
|
||||
token: string
|
||||
}
|
||||
customMcpCapability?: {
|
||||
endpoint: string
|
||||
token: string
|
||||
}
|
||||
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -207,11 +212,12 @@ type KnowledgeCapability = NonNullable<
|
||||
ContinueHostRunOptions['knowledgeCapability']
|
||||
>
|
||||
|
||||
function createKnowledgeMcpServer(
|
||||
function createLoopbackMcpServer(
|
||||
name: string,
|
||||
capability: KnowledgeCapability
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
name: knowledgeMcpName,
|
||||
name,
|
||||
type: 'streamable-http',
|
||||
url: capability.endpoint,
|
||||
requestOptions: {
|
||||
@@ -914,8 +920,35 @@ export class ContinueHostAdapter {
|
||||
runOptions: ContinueHostRunOptions
|
||||
): Promise<string | undefined> {
|
||||
const knowledgeCapability = runOptions.knowledgeCapability
|
||||
const customMcpCapability = runOptions.customMcpCapability
|
||||
if (
|
||||
customMcpCapability &&
|
||||
runOptions.workMode !== 'execute'
|
||||
) {
|
||||
throw new Error(
|
||||
'Continue 自定义 MCP 仅允许在 Agent Execute 模式使用'
|
||||
)
|
||||
}
|
||||
const capabilityServers = [
|
||||
...(knowledgeCapability
|
||||
? [
|
||||
createLoopbackMcpServer(
|
||||
knowledgeMcpName,
|
||||
knowledgeCapability
|
||||
)
|
||||
]
|
||||
: []),
|
||||
...(customMcpCapability
|
||||
? [
|
||||
createLoopbackMcpServer(
|
||||
customMcpName,
|
||||
customMcpCapability
|
||||
)
|
||||
]
|
||||
: [])
|
||||
]
|
||||
if (!this.options.modelProfile) {
|
||||
if (!knowledgeCapability) {
|
||||
if (capabilityServers.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const configured = await loadContinueConfig(
|
||||
@@ -942,10 +975,14 @@ export class ContinueHostAdapter {
|
||||
: servers.filter(
|
||||
(server) =>
|
||||
!isRecord(server) ||
|
||||
server.name !== knowledgeMcpName
|
||||
(
|
||||
server.name !== knowledgeMcpName &&
|
||||
server.name !== customMcpName
|
||||
)
|
||||
)
|
||||
if (
|
||||
retainedServers.length >= maximumConfiguredMcpServers
|
||||
retainedServers.length + capabilityServers.length >
|
||||
maximumConfiguredMcpServers
|
||||
) {
|
||||
throw new Error(
|
||||
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
|
||||
@@ -955,7 +992,7 @@ export class ContinueHostAdapter {
|
||||
...configured,
|
||||
mcpServers: [
|
||||
...retainedServers,
|
||||
createKnowledgeMcpServer(knowledgeCapability)
|
||||
...capabilityServers
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -994,11 +1031,9 @@ export class ContinueHostAdapter {
|
||||
version: '1.0.0',
|
||||
schema: 'v1',
|
||||
models: [modelConfig],
|
||||
...(knowledgeCapability
|
||||
...(capabilityServers.length > 0
|
||||
? {
|
||||
mcpServers: [
|
||||
createKnowledgeMcpServer(knowledgeCapability)
|
||||
]
|
||||
mcpServers: capabilityServers
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
@@ -298,6 +298,85 @@ describe('ContinueAgentRuntime', () => {
|
||||
await expect(authorize?.({ toolName: 'Bash' })).resolves.toBe('deny')
|
||||
})
|
||||
|
||||
it('shares assigned custom MCP with Continue Agent only in Execute through a scoped loopback token', async () => {
|
||||
const gateway = {
|
||||
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||
prepareCustomMcpTools: vi.fn(async () => [
|
||||
{
|
||||
name: 'mcp_12345678_abcdef01_private_tool',
|
||||
inputSchema: { type: 'object' }
|
||||
}
|
||||
]),
|
||||
revoke: vi.fn()
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
configPath: 'C:\\safe config\\continue.yaml',
|
||||
defaultWorkspace: process.cwd(),
|
||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000094',
|
||||
name: 'Private MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['continue'],
|
||||
secretConfigured: true,
|
||||
secret: 'must-stay-in-main',
|
||||
transport: 'http',
|
||||
url: 'https://private.example/mcp'
|
||||
}
|
||||
],
|
||||
createHostAdapter: () => ({
|
||||
getPreparedHost: mocks.prepareHost,
|
||||
run: mocks.runHost,
|
||||
dispose: mocks.disposeHost
|
||||
})
|
||||
})
|
||||
|
||||
await collectEvents(runtime, 'execute')
|
||||
|
||||
expect(gateway.grantCustomMcp).toHaveBeenCalledWith(
|
||||
'3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
expect.any(Array),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.runHost).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function),
|
||||
{
|
||||
workMode: 'execute',
|
||||
customMcpCapability: {
|
||||
endpoint: 'http://127.0.0.1:4567/mcp',
|
||||
token: 'custom-capability'
|
||||
},
|
||||
onEvent: expect.any(Function)
|
||||
}
|
||||
)
|
||||
expect(JSON.stringify(mocks.runHost.mock.calls)).not.toContain(
|
||||
'must-stay-in-main'
|
||||
)
|
||||
expect(JSON.stringify(mocks.runHost.mock.calls)).not.toContain(
|
||||
'private.example'
|
||||
)
|
||||
expect(gateway.revoke).toHaveBeenCalledWith('custom-capability')
|
||||
|
||||
vi.clearAllMocks()
|
||||
mocks.detectRuntimeBinary.mockResolvedValue({
|
||||
available: true,
|
||||
path: 'C:\\canonical\\cn.cmd',
|
||||
version: '1.5.47',
|
||||
detail: 'Continue CLI 1.5.47 已就绪'
|
||||
})
|
||||
mocks.runHost.mockResolvedValue({ text: 'Continue response' })
|
||||
await collectEvents(runtime, 'ask')
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
||||
let hostOptions: ContinueHostAdapterOptions | undefined
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
} from './runtime'
|
||||
import { detectRuntimeBinary } from './runtime-discovery'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type {
|
||||
ResolvedMcpServer,
|
||||
RuntimeSkillPackage
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
scopedReadToolNames,
|
||||
type KnowledgeMcpGateway
|
||||
@@ -39,6 +42,7 @@ export type ContinueRuntimeOptions = {
|
||||
launchHost?: ContinueHostLauncher
|
||||
modelProfile?: ResolvedModelProfile
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
createHostAdapter?: (
|
||||
options: ContinueHostAdapterOptions
|
||||
) => Pick<
|
||||
@@ -293,6 +297,35 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
token: request.knowledgeCapabilityToken
|
||||
}
|
||||
: undefined
|
||||
let customMcpCapability:
|
||||
| { endpoint: string; token: string }
|
||||
| undefined
|
||||
if (
|
||||
execute &&
|
||||
knowledgeEndpoint &&
|
||||
this.options.mcpServers?.length
|
||||
) {
|
||||
const token = this.options.knowledgeGateway?.grantCustomMcp(
|
||||
request.requestId,
|
||||
this.options.mcpServers,
|
||||
signal
|
||||
)
|
||||
if (token) {
|
||||
customMcpCapability = {
|
||||
endpoint: knowledgeEndpoint,
|
||||
token
|
||||
}
|
||||
try {
|
||||
await this.options.knowledgeGateway?.prepareCustomMcpTools(
|
||||
token,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
this.options.knowledgeGateway?.revoke(token)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
let result: ContinueHostRunResult
|
||||
const emittedTools = new Map<string, ContinueHostTool>()
|
||||
try {
|
||||
@@ -336,6 +369,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
workMode: request.workMode,
|
||||
images: request.images,
|
||||
...(knowledgeCapability ? { knowledgeCapability } : {}),
|
||||
...(customMcpCapability
|
||||
? { customMcpCapability }
|
||||
: {}),
|
||||
onEvent
|
||||
}
|
||||
)
|
||||
@@ -408,6 +444,12 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (customMcpCapability) {
|
||||
this.options.knowledgeGateway?.revoke(
|
||||
customMcpCapability.token
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!result.text) {
|
||||
throw new Error('Continue CLI 未返回内容')
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelToolProvider } from './model-tool-provider'
|
||||
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||
|
||||
const noSubagentTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -50,6 +51,7 @@ export type AgentCapabilityContext = {
|
||||
bundledRuntimePaths?: BundledRuntimePaths
|
||||
continueHostLauncher?: ContinueHostLauncher
|
||||
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
|
||||
deepseekHarnessExtensions?: ControlledHarnessExtensionPackage[]
|
||||
browserService?: BrowserToolService
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
webSearchEnabled?: boolean
|
||||
@@ -174,6 +176,7 @@ export function createAgentRuntime(
|
||||
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
|
||||
},
|
||||
skillPackages: capabilities.skillPackages,
|
||||
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||
toolProvider: new ModelToolProvider(
|
||||
workspace,
|
||||
capabilities.mcpServers,
|
||||
@@ -215,7 +218,8 @@ export function createAgentRuntime(
|
||||
process.env.GOODBUDDY_CONTINUE_HOST_CACHE?.trim() ??
|
||||
'',
|
||||
launchHost: capabilities.continueHostLauncher,
|
||||
knowledgeGateway: capabilities.knowledgeGateway
|
||||
knowledgeGateway: capabilities.knowledgeGateway,
|
||||
mcpServers: capabilities.mcpServers
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,7 +250,8 @@ export function createAgentRuntime(
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
skillPackages: capabilities.skillPackages,
|
||||
defaultWorkspace: workspace,
|
||||
knowledgeGateway: capabilities.knowledgeGateway
|
||||
knowledgeGateway: capabilities.knowledgeGateway,
|
||||
mcpServers: capabilities.mcpServers
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
||||
import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
|
||||
|
||||
const MAX_FRAME_BYTES = 1024 * 1024
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
@@ -31,6 +32,13 @@ const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
const MCP_CALL_ID = 'e2e-mcp-call'
|
||||
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
|
||||
const MICRO_DELTA_COUNT = 30_000
|
||||
const liveModelEnabled =
|
||||
process.env.GOODBUDDY_DSH_MODEL_E2E === '1'
|
||||
const liveApiKey = process.env.GOODBUDDY_DSH_API_KEY ?? ''
|
||||
const liveBaseUrl =
|
||||
process.env.GOODBUDDY_DSH_BASE_URL ?? 'https://api.deepseek.com'
|
||||
const liveModel =
|
||||
process.env.GOODBUDDY_DSH_MODEL ?? 'deepseek-chat'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolvePromise!: (value: T) => void
|
||||
@@ -286,7 +294,8 @@ async function collect(
|
||||
|
||||
function createInProcessLaunch(
|
||||
dshHome: string,
|
||||
model: HarnessModel
|
||||
model?: HarnessModel,
|
||||
observeStream?: (options: GenerateOptions) => void
|
||||
): {
|
||||
launch(
|
||||
options: DeepSeekHarnessLaunchOptions
|
||||
@@ -315,6 +324,7 @@ function createInProcessLaunch(
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
extensionPackages: options.extensionPackages,
|
||||
stream: createBoundedNdJsonStream(
|
||||
hostToClient.writable,
|
||||
clientToHost.readable,
|
||||
@@ -322,11 +332,23 @@ function createInProcessLaunch(
|
||||
)
|
||||
})
|
||||
hosts.push(host)
|
||||
host.context.on(
|
||||
'llm/stream',
|
||||
(request) => model.stream(request),
|
||||
{ global: true, prepend: true }
|
||||
)
|
||||
if (observeStream) {
|
||||
host.context.on(
|
||||
'llm/stream',
|
||||
(request, next) => {
|
||||
observeStream(request)
|
||||
return next()
|
||||
},
|
||||
{ global: true, prepend: true }
|
||||
)
|
||||
}
|
||||
if (model) {
|
||||
host.context.on(
|
||||
'llm/stream',
|
||||
(request) => model.stream(request),
|
||||
{ global: true, prepend: true }
|
||||
)
|
||||
}
|
||||
let terminated = false
|
||||
return {
|
||||
stdin: clientToHost.writable,
|
||||
@@ -661,7 +683,9 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
expect(fakeModel.askToolNames).not.toContain(
|
||||
fakeModel.mcpToolName
|
||||
)
|
||||
expect(fakeModel.askToolResult).toContain('unknown tool')
|
||||
expect(fakeModel.askToolResult).toContain(
|
||||
'Ask 模式不允许执行非只读工具'
|
||||
)
|
||||
expect(callTool).toHaveBeenCalledTimes(callsBeforeAsk)
|
||||
expect(listTools).toHaveBeenCalledTimes(listsBeforeAsk)
|
||||
expect(authorize).toHaveBeenCalledTimes(approvalsBeforeAsk)
|
||||
@@ -697,4 +721,197 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
},
|
||||
60_000
|
||||
)
|
||||
|
||||
it.runIf(liveModelEnabled)(
|
||||
'rejects a real npm plugin in Ask and lets a real model call it in Execute',
|
||||
async () => {
|
||||
if (!liveApiKey) {
|
||||
throw new Error(
|
||||
'GOODBUDDY_DSH_API_KEY is required for live DSH model E2E'
|
||||
)
|
||||
}
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-plugin-model-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
const installation = join(root, 'extension')
|
||||
await Promise.all([
|
||||
mkdir(workspace),
|
||||
mkdir(dshHome),
|
||||
mkdir(installation)
|
||||
])
|
||||
const entry = {
|
||||
id: 'dsh-plugin-greet-live',
|
||||
package: {
|
||||
name: 'dsh-plugin-greet',
|
||||
version: '0.1.0'
|
||||
},
|
||||
displayName: 'dsh-plugin-greet',
|
||||
description: 'Reviewed minimal live DSH plugin fixture.'
|
||||
}
|
||||
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
|
||||
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
|
||||
: resolve(
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
const nodeExecutablePath =
|
||||
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||
? resolve(process.env.GOODBUDDY_DSH_NODE_EXECUTABLE)
|
||||
: undefined
|
||||
const installer = new DshNpmExtensionInstaller({
|
||||
dshHome,
|
||||
npmCliPath,
|
||||
...(nodeExecutablePath ? { nodeExecutablePath } : {})
|
||||
})
|
||||
const installed = await installer.install({
|
||||
entry,
|
||||
destinationDirectory: installation
|
||||
})
|
||||
const observedRequests: GenerateOptions[] = []
|
||||
const inProcess = createInProcessLaunch(
|
||||
dshHome,
|
||||
undefined,
|
||||
(options) => observedRequests.push(options)
|
||||
)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: liveBaseUrl,
|
||||
model: liveModel,
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: liveApiKey
|
||||
},
|
||||
extensionPackages: [
|
||||
{
|
||||
id: entry.id,
|
||||
entrypoint: join(
|
||||
installation,
|
||||
...installed.entrypoint.split('/')
|
||||
),
|
||||
configuration: {}
|
||||
}
|
||||
],
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 120_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
const askEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-live-plugin-ask',
|
||||
conversationId: 'live-plugin-ask',
|
||||
prompt:
|
||||
'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const askRequests = observedRequests.filter((options) =>
|
||||
latestUserText(options).includes(
|
||||
'DSH_ASK_PLUGIN_PROBE'
|
||||
)
|
||||
)
|
||||
expect(askRequests.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
askRequests.flatMap(
|
||||
(options) =>
|
||||
options.tools?.map((tool) => tool.name) ?? []
|
||||
)
|
||||
).toContain('greet')
|
||||
expect(askEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
name: 'greet',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
state: 'failed',
|
||||
output: expect.stringContaining(
|
||||
'Ask 模式不允许执行非只读工具'
|
||||
)
|
||||
}),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
)
|
||||
expect(
|
||||
askEvents.some(
|
||||
(event) =>
|
||||
event.type === 'tool' &&
|
||||
event.state === 'completed'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
askEvents
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
).toContain('DSH_ASK_PLUGIN_BLOCKED')
|
||||
|
||||
const executeEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-live-plugin-execute',
|
||||
conversationId: 'live-plugin-execute',
|
||||
prompt:
|
||||
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const executeRequests = observedRequests.filter((options) =>
|
||||
latestUserText(options).includes(
|
||||
'DSH_EXECUTE_PLUGIN_PROBE'
|
||||
)
|
||||
)
|
||||
expect(executeRequests.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
executeRequests.some((options) =>
|
||||
options.tools?.some((tool) => tool.name === 'greet')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(executeEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
name: 'greet',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
state: 'completed',
|
||||
output: expect.stringContaining(
|
||||
'Hello, GoodBuddyLive!'
|
||||
)
|
||||
}),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
)
|
||||
expect(
|
||||
executeEvents
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
).toContain('DSH_EXECUTE_PLUGIN_OK')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
180_000
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
||||
import {
|
||||
runtimeExtensionConfigurationSchema,
|
||||
runtimeExtensionIdSchema
|
||||
} from '../../shared/runtime-extension-contracts'
|
||||
|
||||
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.control'
|
||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1
|
||||
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
|
||||
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
||||
'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
|
||||
const skillPackageSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||
directory: z.string().min(1).max(32_768).refine(isAbsolute)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const extensionPackageSchema = z
|
||||
.object({
|
||||
id: runtimeExtensionIdSchema,
|
||||
entrypoint: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
configuration: runtimeExtensionConfigurationSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const controlledHarnessHostConfigSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
dshHome: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
baseUrl: z
|
||||
.url()
|
||||
.max(2_048)
|
||||
.refine(isDeepSeekHarnessCompatibleBaseUrl),
|
||||
api: z.literal('openai-completions'),
|
||||
provider: z.literal('goodbuddy'),
|
||||
model: z.string().min(1).max(128),
|
||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||
credentialRefs: z
|
||||
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
||||
.readonly(),
|
||||
skillPackages: z.array(skillPackageSchema).max(64),
|
||||
extensionPackages: z.array(extensionPackageSchema).max(64),
|
||||
maxFrameBytes: z.literal(1024 * 1024)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ControlledHarnessBootstrapConfig = z.infer<
|
||||
typeof controlledHarnessHostConfigSchema
|
||||
>
|
||||
|
||||
export type DeepSeekHarnessControlMessage =
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'start'
|
||||
config: ControlledHarnessBootstrapConfig
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'ready'
|
||||
failedExtensionIds: readonly string[]
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'fatal'
|
||||
code: string
|
||||
}
|
||||
|
||||
export function parseHarnessControlMessage(
|
||||
value: unknown
|
||||
): DeepSeekHarnessControlMessage | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
record.protocol !== DEEPSEEK_HARNESS_CONTROL_PROTOCOL ||
|
||||
record.version !== DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
record.type === 'ready' &&
|
||||
Object.keys(record).length === 4 &&
|
||||
Array.isArray(record.failedExtensionIds)
|
||||
) {
|
||||
const failedExtensionIds = z
|
||||
.array(runtimeExtensionIdSchema)
|
||||
.max(64)
|
||||
.safeParse(record.failedExtensionIds)
|
||||
return failedExtensionIds.success
|
||||
? {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
failedExtensionIds: failedExtensionIds.data
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
if (
|
||||
record.type === 'fatal' &&
|
||||
Object.keys(record).length === 4 &&
|
||||
typeof record.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(record.code)
|
||||
) {
|
||||
return record as DeepSeekHarnessControlMessage
|
||||
}
|
||||
if (
|
||||
record.type === 'start' &&
|
||||
Object.keys(record).length === 4
|
||||
) {
|
||||
const parsed = controlledHarnessHostConfigSchema.safeParse(
|
||||
record.config
|
||||
)
|
||||
return parsed.success
|
||||
? {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config: parsed.data
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
loadControlledHarnessExtensions,
|
||||
type ControlledHarnessExtensionPackage
|
||||
} from './deepseek-harness-extension-loader'
|
||||
|
||||
function extension(
|
||||
id: string
|
||||
): ControlledHarnessExtensionPackage {
|
||||
return {
|
||||
id,
|
||||
entrypoint: `C:\\extensions\\${id}\\index.js`,
|
||||
configuration: {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness extension loader', () => {
|
||||
it('loads named Cordis plugin exports and keeps working extensions active', async () => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn()
|
||||
const result = await loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
[extension('greet')],
|
||||
{
|
||||
importModule: vi.fn(async () => ({
|
||||
name: 'greet',
|
||||
apply
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
loadedIds: ['greet'],
|
||||
failedIds: [],
|
||||
failures: []
|
||||
})
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('continues after one extension fails to import', async () => {
|
||||
const ctx = new Context()
|
||||
const importModule = vi.fn(async (url: string) => {
|
||||
if (url.includes('broken')) {
|
||||
throw new Error('broken extension')
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
apply() {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
[extension('broken'), extension('working')],
|
||||
{ importModule }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
loadedIds: ['working'],
|
||||
failedIds: ['broken'],
|
||||
failures: [
|
||||
{
|
||||
id: 'broken',
|
||||
message: 'broken extension'
|
||||
}
|
||||
]
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposes an extension whose activation fails', async () => {
|
||||
const ctx = new Context()
|
||||
const dispose = vi.spyOn(ctx.fiber, 'dispose')
|
||||
|
||||
const result = await loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
[extension('broken')],
|
||||
{
|
||||
importModule: async () => ({
|
||||
apply() {
|
||||
throw new Error('activation failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
loadedIds: [],
|
||||
failedIds: ['broken'],
|
||||
failures: [
|
||||
{
|
||||
id: 'broken',
|
||||
message: 'activation failed'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('bounds the complete extension startup sequence', async () => {
|
||||
const ctx = new Context()
|
||||
const importModule = vi.fn(
|
||||
() => new Promise<never>(() => undefined)
|
||||
)
|
||||
|
||||
const result = await loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
[extension('slow'), extension('later')],
|
||||
{
|
||||
activationTimeoutMs: 1_000,
|
||||
totalActivationTimeoutMs: 20,
|
||||
importModule
|
||||
}
|
||||
)
|
||||
|
||||
expect(importModule).toHaveBeenCalledOnce()
|
||||
expect(result.loadedIds).toEqual([])
|
||||
expect(result.failedIds).toEqual(['slow', 'later'])
|
||||
expect(result.failures[0]?.message).toContain('timed out')
|
||||
expect(result.failures[1]?.message).toContain(
|
||||
'startup deadline exceeded'
|
||||
)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not activate an import that resolves after its timeout', async () => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn()
|
||||
let resolveImport!: (module: {
|
||||
apply: typeof apply
|
||||
}) => void
|
||||
const imported = new Promise<{ apply: typeof apply }>(
|
||||
(resolve) => {
|
||||
resolveImport = resolve
|
||||
}
|
||||
)
|
||||
|
||||
await loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
[extension('late')],
|
||||
{
|
||||
activationTimeoutMs: 10,
|
||||
totalActivationTimeoutMs: 100,
|
||||
importModule: () => imported
|
||||
}
|
||||
)
|
||||
resolveImport({ apply })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const DEFAULT_ACTIVATION_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS = 90_000
|
||||
const DISPOSAL_TIMEOUT_MS = 1_000
|
||||
|
||||
export type ControlledHarnessExtensionPackage = {
|
||||
id: string
|
||||
entrypoint: string
|
||||
configuration: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ControlledHarnessExtensionLoadResult = {
|
||||
loadedIds: string[]
|
||||
failedIds: string[]
|
||||
failures: Array<{ id: string; message: string }>
|
||||
}
|
||||
|
||||
type ExtensionModule = {
|
||||
default?: unknown
|
||||
apply?: unknown
|
||||
}
|
||||
|
||||
// Keep the import native so Vite does not try to resolve userData file URLs
|
||||
// while bundling or running Vitest.
|
||||
const nativeImportModule = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)'
|
||||
) as (specifier: string) => Promise<ExtensionModule>
|
||||
const requireExtension = createRequire(import.meta.url)
|
||||
|
||||
async function defaultImportModule(
|
||||
specifier: string
|
||||
): Promise<ExtensionModule> {
|
||||
try {
|
||||
return requireExtension(
|
||||
fileURLToPath(specifier)
|
||||
) as ExtensionModule
|
||||
} catch (error) {
|
||||
const code =
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: undefined
|
||||
if (
|
||||
code !== 'ERR_REQUIRE_ASYNC_MODULE' &&
|
||||
code !== 'ERR_REQUIRE_ESM'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
return nativeImportModule(specifier)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlugin(value: unknown): value is Plugin {
|
||||
return (
|
||||
typeof value === 'function' ||
|
||||
(value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as { apply?: unknown }).apply === 'function')
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePlugin(module: ExtensionModule): Plugin {
|
||||
if (isPlugin(module)) {
|
||||
return module
|
||||
}
|
||||
if (isPlugin(module.default)) {
|
||||
return module.default
|
||||
}
|
||||
throw new Error(
|
||||
'DeepSeek Harness extension must export a Cordis plugin'
|
||||
)
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
operation: PromiseLike<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
onTimeout?: () => void
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve(operation),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
onTimeout?.()
|
||||
reject(new Error(message))
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadControlledHarnessExtensions(
|
||||
ctx: Context,
|
||||
extensions: readonly ControlledHarnessExtensionPackage[],
|
||||
options: {
|
||||
activationTimeoutMs?: number
|
||||
totalActivationTimeoutMs?: number
|
||||
importModule?: (url: string) => Promise<ExtensionModule>
|
||||
} = {}
|
||||
): Promise<ControlledHarnessExtensionLoadResult> {
|
||||
const loadedIds: string[] = []
|
||||
const failedIds: string[] = []
|
||||
const failures: Array<{ id: string; message: string }> = []
|
||||
const activationTimeoutMs =
|
||||
options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS
|
||||
const deadline =
|
||||
Date.now() +
|
||||
(options.totalActivationTimeoutMs ??
|
||||
DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS)
|
||||
const importModule = options.importModule ?? defaultImportModule
|
||||
|
||||
for (const extension of extensions) {
|
||||
let fiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||
let acceptActivation = true
|
||||
try {
|
||||
const remainingMs = deadline - Date.now()
|
||||
if (remainingMs <= 0) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness extension startup deadline exceeded'
|
||||
)
|
||||
}
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
const module = await importModule(
|
||||
pathToFileURL(extension.entrypoint).href
|
||||
)
|
||||
if (!acceptActivation) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness extension activation timed out'
|
||||
)
|
||||
}
|
||||
const plugin = resolvePlugin(module)
|
||||
fiber = ctx.plugin(plugin, extension.configuration)
|
||||
await Promise.resolve(fiber)
|
||||
})(),
|
||||
Math.max(1, Math.min(activationTimeoutMs, remainingMs)),
|
||||
'DeepSeek Harness extension activation timed out',
|
||||
() => {
|
||||
acceptActivation = false
|
||||
}
|
||||
)
|
||||
loadedIds.push(extension.id)
|
||||
} catch (error) {
|
||||
if (fiber) {
|
||||
await withTimeout(
|
||||
fiber.dispose(),
|
||||
DISPOSAL_TIMEOUT_MS,
|
||||
'DeepSeek Harness extension disposal timed out'
|
||||
).catch(() => undefined)
|
||||
}
|
||||
failedIds.push(extension.id)
|
||||
failures.push({
|
||||
id: extension.id,
|
||||
message:
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message.slice(0, 1_000)
|
||||
: 'DeepSeek Harness extension failed to start'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { loadedIds, failedIds, failures }
|
||||
}
|
||||
@@ -400,7 +400,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
credentialRefs: [],
|
||||
skillPackages: []
|
||||
skillPackages: [],
|
||||
extensionPackages: []
|
||||
})
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/prepare',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from './runtime'
|
||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||
import {
|
||||
assertObjectJsonSchema,
|
||||
validateJsonSchemaValue
|
||||
@@ -137,6 +138,7 @@ export type DeepSeekHarnessLaunchOptions = {
|
||||
model: string
|
||||
credentialRefs: readonly string[]
|
||||
skillPackages: readonly RuntimeSkillPackage[]
|
||||
extensionPackages: readonly ControlledHarnessExtensionPackage[]
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessRuntimeOptions = {
|
||||
@@ -154,6 +156,7 @@ export type DeepSeekHarnessRuntimeOptions = {
|
||||
maxRequestOutputCharacters?: number
|
||||
credentialRefs?: Readonly<Record<string, string>>
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
extensionPackages?: ControlledHarnessExtensionPackage[]
|
||||
toolProvider?: ModelToolProviderLike
|
||||
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
||||
}
|
||||
@@ -694,7 +697,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
credentialRefs: Object.keys(
|
||||
this.options.credentialRefs ?? {}
|
||||
),
|
||||
skillPackages: this.options.skillPackages ?? []
|
||||
skillPackages: this.options.skillPackages ?? [],
|
||||
extensionPackages: this.options.extensionPackages ?? []
|
||||
}),
|
||||
this.initializationTimeoutMs,
|
||||
'启动'
|
||||
|
||||
@@ -44,6 +44,7 @@ async function fixture() {
|
||||
writeFile(hostPath, '', 'utf8')
|
||||
])
|
||||
return {
|
||||
root,
|
||||
dshHome,
|
||||
hostPath,
|
||||
launchOptions: {
|
||||
@@ -52,7 +53,8 @@ async function fixture() {
|
||||
baseUrl: 'https://gateway.example/openai/v1',
|
||||
model: 'qwen-plus',
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: []
|
||||
skillPackages: [],
|
||||
extensionPackages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +65,8 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
type: 'ready',
|
||||
failedExtensionIds: []
|
||||
})
|
||||
).toMatchObject({ type: 'ready' })
|
||||
expect(
|
||||
@@ -71,6 +74,7 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
failedExtensionIds: [],
|
||||
apiKey: 'must-not-pass'
|
||||
})
|
||||
).toBeUndefined()
|
||||
@@ -105,7 +109,8 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
utility.emit('message', {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
type: 'ready',
|
||||
failedExtensionIds: []
|
||||
})
|
||||
|
||||
await expect(launching).resolves.toMatchObject({
|
||||
@@ -122,6 +127,87 @@ describe('DeepSeek Harness utility launcher', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('persists extension startup failures before exposing the child', async () => {
|
||||
const { root, dshHome, hostPath, launchOptions } =
|
||||
await fixture()
|
||||
const entrypoint = join(root, 'greet.mjs')
|
||||
await writeFile(entrypoint, 'export function apply() {}\n', 'utf8')
|
||||
const utility = new FakeUtility()
|
||||
const onExtensionStartupFailures = vi.fn(async () => undefined)
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: {},
|
||||
fork: () => utility as never,
|
||||
onExtensionStartupFailures
|
||||
})
|
||||
|
||||
const launching = launcher({
|
||||
...launchOptions,
|
||||
extensionPackages: [
|
||||
{
|
||||
id: 'greet',
|
||||
entrypoint,
|
||||
configuration: {}
|
||||
}
|
||||
]
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
utility.emit('message', {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
failedExtensionIds: ['greet']
|
||||
})
|
||||
|
||||
await expect(launching).resolves.toBeDefined()
|
||||
expect(onExtensionStartupFailures).toHaveBeenCalledWith([
|
||||
'greet'
|
||||
])
|
||||
})
|
||||
|
||||
it('fails when the Host exits while startup failures are being persisted', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
let finishPersistence!: () => void
|
||||
const persistence = new Promise<void>((resolve) => {
|
||||
finishPersistence = resolve
|
||||
})
|
||||
const onExtensionStartupFailures = vi.fn(() => persistence)
|
||||
const terminateProcess = vi.fn()
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: {},
|
||||
fork: () => utility as never,
|
||||
terminateProcess,
|
||||
onExtensionStartupFailures
|
||||
})
|
||||
|
||||
const launching = launcher(launchOptions)
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
utility.emit('message', {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
failedExtensionIds: ['greet']
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(onExtensionStartupFailures).toHaveBeenCalledOnce()
|
||||
)
|
||||
utility.emit('exit', 9)
|
||||
|
||||
await expect(launching).rejects.toThrow(
|
||||
'Host 启动前退出(code 9)'
|
||||
)
|
||||
expect(terminateProcess).toHaveBeenCalledOnce()
|
||||
finishPersistence()
|
||||
})
|
||||
|
||||
it('fails closed on an invalid Host startup message', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
|
||||
@@ -2,121 +2,34 @@ import { Readable } from 'node:stream'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import type { UtilityProcess } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
|
||||
import type {
|
||||
DeepSeekHarnessChild,
|
||||
DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { createDeepSeekHarnessUtilityChild } from './deepseek-harness-utility-transport'
|
||||
import {
|
||||
controlledHarnessHostConfigSchema,
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||
DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
parseHarnessControlMessage,
|
||||
type DeepSeekHarnessControlMessage as HarnessControlMessage
|
||||
} from './deepseek-harness-control-protocol'
|
||||
|
||||
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.control'
|
||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1
|
||||
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
|
||||
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
||||
'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
|
||||
const skillPackageSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||
directory: z.string().min(1).max(32_768).refine(isAbsolute)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const controlledHarnessHostConfigSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
dshHome: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
baseUrl: z
|
||||
.url()
|
||||
.max(2_048)
|
||||
.refine(isDeepSeekHarnessCompatibleBaseUrl),
|
||||
api: z.literal('openai-completions'),
|
||||
provider: z.literal('goodbuddy'),
|
||||
model: z.string().min(1).max(128),
|
||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||
credentialRefs: z
|
||||
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
||||
.readonly(),
|
||||
skillPackages: z.array(skillPackageSchema).max(64),
|
||||
maxFrameBytes: z.literal(1024 * 1024)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ControlledHarnessBootstrapConfig = z.infer<
|
||||
typeof controlledHarnessHostConfigSchema
|
||||
>
|
||||
|
||||
export type DeepSeekHarnessControlMessage =
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'start'
|
||||
config: ControlledHarnessBootstrapConfig
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'ready'
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'fatal'
|
||||
code: string
|
||||
}
|
||||
|
||||
export function parseHarnessControlMessage(
|
||||
value: unknown
|
||||
): DeepSeekHarnessControlMessage | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
record.protocol !== DEEPSEEK_HARNESS_CONTROL_PROTOCOL ||
|
||||
record.version !== DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (record.type === 'ready' && Object.keys(record).length === 3) {
|
||||
return record as DeepSeekHarnessControlMessage
|
||||
}
|
||||
if (
|
||||
record.type === 'fatal' &&
|
||||
Object.keys(record).length === 4 &&
|
||||
typeof record.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(record.code)
|
||||
) {
|
||||
return record as DeepSeekHarnessControlMessage
|
||||
}
|
||||
if (
|
||||
record.type === 'start' &&
|
||||
Object.keys(record).length === 4
|
||||
) {
|
||||
const parsed = controlledHarnessHostConfigSchema.safeParse(
|
||||
record.config
|
||||
)
|
||||
return parsed.success
|
||||
? ({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config: parsed.data
|
||||
} satisfies DeepSeekHarnessControlMessage)
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
export {
|
||||
controlledHarnessHostConfigSchema,
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||
DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
parseHarnessControlMessage
|
||||
} from './deepseek-harness-control-protocol'
|
||||
export type {
|
||||
ControlledHarnessBootstrapConfig,
|
||||
DeepSeekHarnessControlMessage
|
||||
} from './deepseek-harness-control-protocol'
|
||||
|
||||
export type DeepSeekHarnessFork = (
|
||||
modulePath: string,
|
||||
@@ -135,6 +48,9 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
|
||||
environment: NodeJS.ProcessEnv
|
||||
fork: DeepSeekHarnessFork
|
||||
terminateProcess?: (utility: UtilityProcess) => void
|
||||
onExtensionStartupFailures?: (
|
||||
extensionIds: readonly string[]
|
||||
) => Promise<void>
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -187,6 +103,21 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
}
|
||||
})
|
||||
)
|
||||
const canonicalExtensionPackages = await Promise.all(
|
||||
options.extensionPackages.map(async (extension) => {
|
||||
const entrypoint = await realpath(extension.entrypoint)
|
||||
const metadata = await stat(entrypoint)
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 插件入口必须为文件'
|
||||
)
|
||||
}
|
||||
return {
|
||||
...extension,
|
||||
entrypoint
|
||||
}
|
||||
})
|
||||
)
|
||||
const [canonicalHostPath, canonicalWorkspace, canonicalDshHome] =
|
||||
await Promise.all([
|
||||
realpath(hostPath),
|
||||
@@ -240,11 +171,16 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
}
|
||||
}
|
||||
const startupTimeoutMs =
|
||||
launcherOptions.startupTimeoutMs ?? 10_000
|
||||
launcherOptions.startupTimeoutMs ??
|
||||
Math.min(
|
||||
120_000,
|
||||
10_000 + canonicalExtensionPackages.length * 5_000
|
||||
)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let onAbort: (() => void) | undefined
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
@@ -256,10 +192,22 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
utility.removeListener('exit', onExit)
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
terminate()
|
||||
reject(error)
|
||||
}
|
||||
const succeed = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onMessage = (message: unknown): void => {
|
||||
const control = parseHarnessControlMessage(message)
|
||||
if (!control) {
|
||||
@@ -267,8 +215,26 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
return
|
||||
}
|
||||
if (control.type === 'ready') {
|
||||
cleanup()
|
||||
resolve()
|
||||
utility.removeListener('message', onMessage)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
void (
|
||||
control.failedExtensionIds.length > 0
|
||||
? launcherOptions.onExtensionStartupFailures?.(
|
||||
control.failedExtensionIds
|
||||
) ?? Promise.resolve()
|
||||
: Promise.resolve()
|
||||
).then(succeed, (error: unknown) => {
|
||||
fail(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(
|
||||
'DeepSeek Harness 插件失败状态保存失败'
|
||||
)
|
||||
)
|
||||
})
|
||||
} else if (control.type === 'fatal') {
|
||||
fail(
|
||||
new Error(
|
||||
@@ -311,6 +277,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: canonicalSkillPackages,
|
||||
extensionPackages: canonicalExtensionPackages,
|
||||
maxFrameBytes: 1024 * 1024
|
||||
})
|
||||
utility.postMessage({
|
||||
@@ -318,7 +285,7 @@ export function createDeepSeekHarnessUtilityLauncher(
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config
|
||||
} satisfies DeepSeekHarnessControlMessage)
|
||||
} satisfies HarnessControlMessage)
|
||||
})
|
||||
return createDeepSeekHarnessUtilityChild(utility, {
|
||||
stderrToWeb: (stderr) =>
|
||||
|
||||
@@ -127,7 +127,8 @@ describe('DeepSeek Harness utility byte transport', () => {
|
||||
utility.emitMessage({
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready'
|
||||
type: 'ready',
|
||||
failedExtensionIds: []
|
||||
})
|
||||
|
||||
const reader = child.stdout.getReader()
|
||||
@@ -155,6 +156,7 @@ describe('DeepSeek Harness utility byte transport', () => {
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready',
|
||||
failedExtensionIds: [],
|
||||
unexpected: true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeepSeekHarnessChild } from './deepseek-harness-runtime'
|
||||
import { parseHarnessControlMessage } from './deepseek-harness-control-protocol'
|
||||
|
||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.byte-stream'
|
||||
@@ -70,7 +71,6 @@ type EndpointOptions = {
|
||||
readonly onFailure?: () => void
|
||||
}
|
||||
|
||||
const CONTROL_PROTOCOL = 'goodbuddy.deepseek-harness.control'
|
||||
const PROTOCOL_KEYS = ['protocol', 'version', 'type'] as const
|
||||
const STREAM_KEYS = [...PROTOCOL_KEYS, 'stream', 'seq'] as const
|
||||
const DATA_KEYS = [...STREAM_KEYS, 'bytes'] as const
|
||||
@@ -167,29 +167,7 @@ function parseMessage(value: unknown): ProtocolMessage | undefined {
|
||||
}
|
||||
|
||||
function isControlMessage(value: unknown): boolean {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.protocol !== CONTROL_PROTOCOL ||
|
||||
value.version !== 1 ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (value.type === 'ready') {
|
||||
return hasExactKeys(value, PROTOCOL_KEYS)
|
||||
}
|
||||
if (value.type === 'fatal') {
|
||||
return (
|
||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'code']) &&
|
||||
typeof value.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.code)
|
||||
)
|
||||
}
|
||||
return (
|
||||
value.type === 'start' &&
|
||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'config']) &&
|
||||
isRecord(value.config)
|
||||
)
|
||||
return parseHarnessControlMessage(value) !== undefined
|
||||
}
|
||||
|
||||
class ByteTransportEndpoint {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startControlledDeepSeekHarnessHost } from '../deepseek-harness-host'
|
||||
import {
|
||||
DshNpmExtensionInstaller,
|
||||
DshNpmMarketplaceCatalog
|
||||
} from './dsh-extension-marketplace'
|
||||
import { RuntimeExtensionStore } from './runtime-extension-store'
|
||||
|
||||
const enabled =
|
||||
process.env.GOODBUDDY_DSH_MARKETPLACE_E2E === '1'
|
||||
|
||||
describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
|
||||
it(
|
||||
'searches, installs, enables, loads, and calls a real npm plugin',
|
||||
async () => {
|
||||
const userDataPath = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-dsh-marketplace-live-')
|
||||
)
|
||||
try {
|
||||
const market = new DshNpmMarketplaceCatalog()
|
||||
const greet = (await market.list()).find(
|
||||
(entry) => entry.package.name === 'dsh-plugin-greet'
|
||||
)
|
||||
expect(greet).toBeDefined()
|
||||
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
|
||||
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
|
||||
: resolve(
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
const nodeExecutablePath =
|
||||
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||
? resolve(
|
||||
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
|
||||
)
|
||||
: undefined
|
||||
const installer = new DshNpmExtensionInstaller({
|
||||
dshHome: userDataPath,
|
||||
npmCliPath,
|
||||
...(nodeExecutablePath ? { nodeExecutablePath } : {})
|
||||
})
|
||||
const store = new RuntimeExtensionStore(userDataPath, {
|
||||
catalog: {
|
||||
list: async () => [greet!]
|
||||
},
|
||||
install: (input) => installer.install(input)
|
||||
})
|
||||
await store.apply({
|
||||
type: 'set-marketplace-enabled',
|
||||
enabled: true
|
||||
})
|
||||
const installed = await store.apply({
|
||||
type: 'install',
|
||||
extensionId: greet!.id,
|
||||
package: greet!.package
|
||||
})
|
||||
expect(installed.installed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: greet!.id,
|
||||
package: greet!.package,
|
||||
enabled: true,
|
||||
integrity: expect.stringMatching(/^sha512-/u)
|
||||
})
|
||||
])
|
||||
|
||||
const extensions = await store.getEnabledExtensions()
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: userDataPath,
|
||||
dshHome: userDataPath,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: extensions,
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
expect(host.extensionFailures).toEqual([])
|
||||
await expect(
|
||||
host.context.tools.execute({
|
||||
callId: 'marketplace-live-greet',
|
||||
name: 'greet',
|
||||
arguments: { name: 'GoodBuddy' },
|
||||
signal: new AbortController().signal
|
||||
} as never)
|
||||
).resolves.toMatchObject({
|
||||
isError: false,
|
||||
value: 'Hello, GoodBuddy!'
|
||||
})
|
||||
await host.dispose()
|
||||
} finally {
|
||||
await rm(userDataPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100
|
||||
})
|
||||
}
|
||||
},
|
||||
120_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DshNpmExtensionInstaller,
|
||||
DshNpmMarketplaceCatalog,
|
||||
type PackageManagerRunner
|
||||
} from './dsh-extension-marketplace'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function response(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('DSH npm marketplace', () => {
|
||||
it('loads every npm search page and keeps only DSH plugin packages', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
const from = Number(new URL(String(input)).searchParams.get('from'))
|
||||
return response({
|
||||
total: 251,
|
||||
objects:
|
||||
from === 0
|
||||
? [
|
||||
{
|
||||
package: {
|
||||
name: 'dsh-plugin-greet',
|
||||
version: '0.1.0',
|
||||
description: 'A greeting tool.',
|
||||
keywords: ['dsh-plugin'],
|
||||
license: 'MIT',
|
||||
links: {
|
||||
repository:
|
||||
'git+https://github.com/example/greet.git'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
package: {
|
||||
name: 'not-a-plugin',
|
||||
version: '1.0.0',
|
||||
keywords: ['unrelated']
|
||||
}
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
package: {
|
||||
name: 'dsh-second-plugin',
|
||||
version: '2.0.0',
|
||||
keywords: ['dsh-plugin']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const catalog = new DshNpmMarketplaceCatalog({
|
||||
fetcher,
|
||||
cacheTtlMs: 60_000
|
||||
})
|
||||
|
||||
const entries = await catalog.list()
|
||||
|
||||
expect(entries.map((entry) => entry.package.name)).toEqual([
|
||||
'dsh-plugin-greet',
|
||||
'dsh-second-plugin'
|
||||
])
|
||||
expect(entries[0]).toMatchObject({
|
||||
description: 'A greeting tool.',
|
||||
repository: 'https://github.com/example/greet.git'
|
||||
})
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
await catalog.list()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('coalesces concurrent catalog loads', async () => {
|
||||
let resolveFetch: ((response: Response) => void) | undefined
|
||||
const fetcher = vi.fn<typeof fetch>(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
)
|
||||
const catalog = new DshNpmMarketplaceCatalog({ fetcher })
|
||||
|
||||
const first = catalog.list()
|
||||
const second = catalog.list()
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
resolveFetch?.(
|
||||
response({
|
||||
total: 1,
|
||||
objects: [
|
||||
{
|
||||
package: {
|
||||
name: 'dsh-plugin-greet',
|
||||
version: '0.1.0',
|
||||
keywords: ['dsh-plugin']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
package: expect.objectContaining({
|
||||
name: 'dsh-plugin-greet'
|
||||
})
|
||||
})
|
||||
],
|
||||
[
|
||||
expect.objectContaining({
|
||||
package: expect.objectContaining({
|
||||
name: 'dsh-plugin-greet'
|
||||
})
|
||||
})
|
||||
]
|
||||
])
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses bundled npm to install the exact package and verifies its entrypoint', async () => {
|
||||
const destinationDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-dsh-npm-installer-')
|
||||
)
|
||||
temporaryDirectories.push(destinationDirectory)
|
||||
const integrity = `sha512-${Buffer.from('verified').toString(
|
||||
'base64'
|
||||
)}`
|
||||
const packageName = 'dsh-plugin-greet'
|
||||
const version = '0.1.0'
|
||||
const manifest = {
|
||||
name: packageName,
|
||||
version,
|
||||
main: 'index.js',
|
||||
dist: { integrity },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } }
|
||||
}
|
||||
const npmCliPath = join(destinationDirectory, 'npm-cli.js')
|
||||
await writeFile(npmCliPath, '// bundled npm fixture\n', 'utf8')
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
response({
|
||||
versions: {
|
||||
[version]: manifest
|
||||
}
|
||||
})
|
||||
)
|
||||
const runner: PackageManagerRunner = vi.fn(
|
||||
async (_command, _args, options) => {
|
||||
const installedDirectory = join(
|
||||
options.cwd,
|
||||
'node_modules',
|
||||
packageName
|
||||
)
|
||||
await mkdir(installedDirectory, { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(installedDirectory, 'package.json'),
|
||||
JSON.stringify(manifest),
|
||||
'utf8'
|
||||
),
|
||||
writeFile(
|
||||
join(installedDirectory, 'index.js'),
|
||||
'export function apply() {}\n',
|
||||
'utf8'
|
||||
),
|
||||
writeFile(
|
||||
join(options.cwd, 'package-lock.json'),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
[`node_modules/${packageName}`]: { integrity }
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
])
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}
|
||||
)
|
||||
const installer = new DshNpmExtensionInstaller({
|
||||
dshHome: destinationDirectory,
|
||||
npmCliPath,
|
||||
fetcher,
|
||||
runner,
|
||||
environment: { PATH: 'C:\\Node' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
installer.install({
|
||||
entry: {
|
||||
id: 'greet',
|
||||
package: { name: packageName, version },
|
||||
displayName: packageName,
|
||||
description: 'A greeting tool.'
|
||||
},
|
||||
destinationDirectory
|
||||
})
|
||||
).resolves.toEqual({
|
||||
entrypoint: `node_modules/${packageName}/index.js`,
|
||||
integrity
|
||||
})
|
||||
expect(runner).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
npmCliPath,
|
||||
'install',
|
||||
'--save-exact',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--dangerously-allow-all-scripts',
|
||||
'--loglevel=error',
|
||||
`${packageName}@${version}`
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: destinationDirectory,
|
||||
env: expect.objectContaining({
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
npm_execpath: npmCliPath,
|
||||
npm_node_execpath: process.execPath
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(
|
||||
(
|
||||
await stat(
|
||||
join(
|
||||
destinationDirectory,
|
||||
'package-manager-bin',
|
||||
process.platform === 'win32' ? 'node.cmd' : 'node'
|
||||
)
|
||||
)
|
||||
).isFile()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects packages that do not declare a DSH bundle', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-dsh-not-plugin-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const installer = new DshNpmExtensionInstaller({
|
||||
dshHome: directory,
|
||||
fetcher: vi.fn<typeof fetch>(async () =>
|
||||
response({
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: 'not-a-dsh-plugin',
|
||||
version: '1.0.0',
|
||||
main: 'index.js',
|
||||
dist: {
|
||||
integrity: `sha512-${Buffer.from('verified').toString(
|
||||
'base64'
|
||||
)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
),
|
||||
runner: vi.fn()
|
||||
})
|
||||
|
||||
await expect(
|
||||
installer.install({
|
||||
entry: {
|
||||
id: 'not-plugin',
|
||||
package: {
|
||||
name: 'not-a-dsh-plugin',
|
||||
version: '1.0.0'
|
||||
},
|
||||
displayName: 'Not a plugin',
|
||||
description: 'Missing DSH bundle metadata.'
|
||||
},
|
||||
destinationDirectory: directory
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,717 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import {
|
||||
delimiter,
|
||||
join,
|
||||
posix,
|
||||
relative
|
||||
} from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
runtimeExtensionCatalogEntrySchema,
|
||||
runtimeExtensionIntegritySchema,
|
||||
runtimeExtensionPackageNameSchema,
|
||||
runtimeExtensionVersionSchema,
|
||||
type RuntimeExtensionCatalogEntry
|
||||
} from '../../shared/runtime-extension-contracts'
|
||||
import { buildControlledHarnessEnvironment } from './process-environment'
|
||||
import type {
|
||||
RuntimeExtensionCatalog,
|
||||
RuntimeExtensionStoreDependencies
|
||||
} from './runtime-extension-store'
|
||||
|
||||
const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
|
||||
const NPM_SEARCH_PAGE_SIZE = 250
|
||||
const MAXIMUM_CATALOG_ENTRIES = 1_000
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000
|
||||
const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024
|
||||
|
||||
const npmSearchPackageSchema = z
|
||||
.object({
|
||||
name: runtimeExtensionPackageNameSchema,
|
||||
version: runtimeExtensionVersionSchema,
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
license: z.string().optional(),
|
||||
links: z
|
||||
.object({
|
||||
homepage: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
npm: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const npmSearchResponseSchema = z
|
||||
.object({
|
||||
total: z.number().int().nonnegative(),
|
||||
objects: z.array(
|
||||
z
|
||||
.object({
|
||||
package: npmSearchPackageSchema
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const npmDistributionSchema = z
|
||||
.object({
|
||||
integrity: runtimeExtensionIntegritySchema
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const npmInstalledManifestSchema = z
|
||||
.object({
|
||||
name: runtimeExtensionPackageNameSchema,
|
||||
version: runtimeExtensionVersionSchema,
|
||||
main: z.string().optional(),
|
||||
exports: z.unknown().optional(),
|
||||
dsh: z
|
||||
.object({
|
||||
bundle: z
|
||||
.object({
|
||||
patch: z.string().min(1)
|
||||
})
|
||||
.passthrough()
|
||||
})
|
||||
.passthrough()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const npmVersionManifestSchema = npmInstalledManifestSchema.extend({
|
||||
dist: npmDistributionSchema
|
||||
})
|
||||
|
||||
const npmPackumentSchema = z
|
||||
.object({
|
||||
versions: z.record(z.string(), npmVersionManifestSchema)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
type NpmVersionManifest = z.infer<typeof npmVersionManifestSchema>
|
||||
|
||||
export type PackageManagerRunResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export type PackageManagerRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeoutMs: number
|
||||
}
|
||||
) => Promise<PackageManagerRunResult>
|
||||
|
||||
function waitForProcessClose(
|
||||
child: ReturnType<typeof spawn>
|
||||
): Promise<void> {
|
||||
if (child.exitCode !== null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, 5_000)
|
||||
child.once('close', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function terminatePackageManager(
|
||||
child: ReturnType<typeof spawn>
|
||||
): Promise<void> {
|
||||
const closed = waitForProcessClose(child)
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
const killer = spawn(
|
||||
'taskkill.exe',
|
||||
['/PID', String(child.pid), '/T', '/F'],
|
||||
{
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 5_000)
|
||||
const finish = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
killer.once('close', finish)
|
||||
killer.once('error', finish)
|
||||
})
|
||||
} else if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
} catch {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
await closed
|
||||
}
|
||||
|
||||
function boundedAppend(current: string, chunk: unknown): string {
|
||||
const next = current + String(chunk)
|
||||
return next.length <= MAXIMUM_PROCESS_OUTPUT_CHARACTERS
|
||||
? next
|
||||
: next.slice(-MAXIMUM_PROCESS_OUTPUT_CHARACTERS)
|
||||
}
|
||||
|
||||
export const runPackageManager: PackageManagerRunner = (
|
||||
command,
|
||||
args,
|
||||
options
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: process.platform !== 'win32',
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
void terminatePackageManager(child).then(
|
||||
() => reject(new Error('DSH 插件安装超时')),
|
||||
() => reject(new Error('DSH 插件安装超时'))
|
||||
)
|
||||
}, options.timeoutMs)
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout = boundedAppend(stdout, chunk)
|
||||
})
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = boundedAppend(stderr, chunk)
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.once('close', (code) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function publicHttpUrl(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/^git\+/u, '')
|
||||
.replace(/^git:\/\/github\.com\//u, 'https://github.com/')
|
||||
.replace(/^git@github\.com:/u, 'https://github.com/')
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||
? url.toString()
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extensionId(packageName: string): string {
|
||||
const slug = packageName
|
||||
.toLowerCase()
|
||||
.replace(/^@/u, '')
|
||||
.replace(/[^a-z0-9]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '')
|
||||
.slice(0, 100)
|
||||
const digest = createHash('sha256')
|
||||
.update(packageName)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
return `${slug || 'extension'}-${digest}`
|
||||
}
|
||||
|
||||
function catalogEntry(
|
||||
packageMetadata: z.infer<typeof npmSearchPackageSchema>
|
||||
): RuntimeExtensionCatalogEntry {
|
||||
const repository =
|
||||
publicHttpUrl(packageMetadata.links?.repository) ??
|
||||
publicHttpUrl(packageMetadata.links?.homepage) ??
|
||||
publicHttpUrl(packageMetadata.links?.npm)
|
||||
return runtimeExtensionCatalogEntrySchema.parse({
|
||||
id: extensionId(packageMetadata.name),
|
||||
package: {
|
||||
name: packageMetadata.name,
|
||||
version: packageMetadata.version
|
||||
},
|
||||
displayName: packageMetadata.name,
|
||||
description:
|
||||
packageMetadata.description?.trim().slice(0, 2_000) ||
|
||||
`DeepSeek Harness plugin ${packageMetadata.name}`,
|
||||
...(repository ? { repository } : {}),
|
||||
...(packageMetadata.license?.trim()
|
||||
? { license: packageMetadata.license.trim().slice(0, 128) }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
fetcher: typeof fetch,
|
||||
url: URL,
|
||||
timeoutMs: number
|
||||
): Promise<unknown> {
|
||||
const response = await fetcher(url, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'GoodBuddy-DSH-Marketplace/1'
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`DSH 插件市场请求失败(HTTP ${response.status})`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export class DshNpmMarketplaceCatalog
|
||||
implements RuntimeExtensionCatalog
|
||||
{
|
||||
private cache?: {
|
||||
expiresAt: number
|
||||
entries: RuntimeExtensionCatalogEntry[]
|
||||
}
|
||||
private inFlight?: Promise<
|
||||
readonly RuntimeExtensionCatalogEntry[]
|
||||
>
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
fetcher?: typeof fetch
|
||||
registryUrl?: string
|
||||
requestTimeoutMs?: number
|
||||
cacheTtlMs?: number
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
async list(): Promise<readonly RuntimeExtensionCatalogEntry[]> {
|
||||
const now = Date.now()
|
||||
if (this.cache && this.cache.expiresAt > now) {
|
||||
return this.cache.entries
|
||||
}
|
||||
if (this.inFlight) {
|
||||
return this.inFlight
|
||||
}
|
||||
const request = this.load()
|
||||
this.inFlight = request
|
||||
try {
|
||||
return await request
|
||||
} finally {
|
||||
if (this.inFlight === request) {
|
||||
this.inFlight = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<
|
||||
readonly RuntimeExtensionCatalogEntry[]
|
||||
> {
|
||||
const fetcher = this.options.fetcher ?? fetch
|
||||
const registryUrl = (
|
||||
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||
).replace(/\/$/u, '')
|
||||
const timeoutMs =
|
||||
this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||
const first = await this.fetchPage(
|
||||
fetcher,
|
||||
registryUrl,
|
||||
0,
|
||||
timeoutMs
|
||||
)
|
||||
const total = Math.min(
|
||||
first.total,
|
||||
MAXIMUM_CATALOG_ENTRIES
|
||||
)
|
||||
const offsets: number[] = []
|
||||
for (
|
||||
let offset = NPM_SEARCH_PAGE_SIZE;
|
||||
offset < total;
|
||||
offset += NPM_SEARCH_PAGE_SIZE
|
||||
) {
|
||||
offsets.push(offset)
|
||||
}
|
||||
const remaining = await Promise.all(
|
||||
offsets.map((offset) =>
|
||||
this.fetchPage(fetcher, registryUrl, offset, timeoutMs)
|
||||
)
|
||||
)
|
||||
const packages = [first, ...remaining].flatMap((page) =>
|
||||
page.objects.map((item) => item.package)
|
||||
)
|
||||
const entries = [
|
||||
...new Map(
|
||||
packages
|
||||
.filter((item) =>
|
||||
item.keywords?.some(
|
||||
(keyword) => keyword.toLowerCase() === 'dsh-plugin'
|
||||
)
|
||||
)
|
||||
.map((item) => [item.name, catalogEntry(item)] as const)
|
||||
).values()
|
||||
].sort((left, right) =>
|
||||
left.displayName.localeCompare(right.displayName, 'en')
|
||||
)
|
||||
this.cache = {
|
||||
expiresAt:
|
||||
Date.now() +
|
||||
(this.options.cacheTtlMs ?? 5 * 60_000),
|
||||
entries
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private async fetchPage(
|
||||
fetcher: typeof fetch,
|
||||
registryUrl: string,
|
||||
from: number,
|
||||
timeoutMs: number
|
||||
): Promise<z.infer<typeof npmSearchResponseSchema>> {
|
||||
const url = new URL(`${registryUrl}/-/v1/search`)
|
||||
url.searchParams.set('text', 'keywords:dsh-plugin')
|
||||
url.searchParams.set('size', String(NPM_SEARCH_PAGE_SIZE))
|
||||
url.searchParams.set('from', String(from))
|
||||
return npmSearchResponseSchema.parse(
|
||||
await fetchJson(fetcher, url, timeoutMs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function packageDirectory(
|
||||
destinationDirectory: string,
|
||||
packageName: string
|
||||
): string {
|
||||
return join(
|
||||
destinationDirectory,
|
||||
'node_modules',
|
||||
...packageName.split('/')
|
||||
)
|
||||
}
|
||||
|
||||
function exportsEntrypoint(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
exportsEntrypoint(record['.']) ??
|
||||
exportsEntrypoint(record.import) ??
|
||||
exportsEntrypoint(record.default) ??
|
||||
exportsEntrypoint(record.require)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeEntrypoint(
|
||||
manifest: z.infer<typeof npmInstalledManifestSchema>
|
||||
): string {
|
||||
const entrypoint =
|
||||
manifest.main ??
|
||||
exportsEntrypoint(manifest.exports) ??
|
||||
'index.js'
|
||||
const normalized = posix.normalize(entrypoint.replaceAll('\\', '/'))
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === '.' ||
|
||||
normalized === '..' ||
|
||||
normalized.startsWith('../') ||
|
||||
normalized.startsWith('/') ||
|
||||
/^[A-Za-z]:/u.test(normalized)
|
||||
) {
|
||||
throw new Error('DSH 插件入口无效')
|
||||
}
|
||||
return normalized.replace(/^\.\//u, '')
|
||||
}
|
||||
|
||||
function packageManagerError(error: unknown): Error {
|
||||
const code =
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: undefined
|
||||
return code === 'ENOENT'
|
||||
? new Error('DSH 插件安装 Runtime 不可用')
|
||||
: error instanceof Error
|
||||
? error
|
||||
: new Error('DSH 插件安装失败')
|
||||
}
|
||||
|
||||
function quotePosixShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function prepareNodeCommand(
|
||||
directory: string,
|
||||
executablePath: string
|
||||
): Promise<string> {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
if (process.platform === 'win32') {
|
||||
const commandPath = join(directory, 'node.cmd')
|
||||
await writeFile(
|
||||
commandPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "ELECTRON_RUN_AS_NODE=1"',
|
||||
`"${executablePath.replaceAll('%', '%%')}" %*`,
|
||||
''
|
||||
].join('\r\n'),
|
||||
{ encoding: 'utf8', mode: 0o700 }
|
||||
)
|
||||
return commandPath
|
||||
}
|
||||
const commandPath = join(directory, 'node')
|
||||
await writeFile(
|
||||
commandPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`ELECTRON_RUN_AS_NODE=1 exec ${quotePosixShell(executablePath)} "$@"`,
|
||||
''
|
||||
].join('\n'),
|
||||
{ encoding: 'utf8', mode: 0o700 }
|
||||
)
|
||||
await chmod(commandPath, 0o700)
|
||||
return commandPath
|
||||
}
|
||||
|
||||
export class DshNpmExtensionInstaller {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
dshHome: string
|
||||
npmCliPath?: string
|
||||
nodeExecutablePath?: string
|
||||
fetcher?: typeof fetch
|
||||
registryUrl?: string
|
||||
runner?: PackageManagerRunner
|
||||
requestTimeoutMs?: number
|
||||
installTimeoutMs?: number
|
||||
environment?: NodeJS.ProcessEnv
|
||||
}
|
||||
) {}
|
||||
|
||||
async install(
|
||||
input: Parameters<
|
||||
RuntimeExtensionStoreDependencies['install']
|
||||
>[0]
|
||||
): Promise<{
|
||||
entrypoint: string
|
||||
integrity?: string
|
||||
}> {
|
||||
const manifest = await this.resolveManifest(input.entry)
|
||||
await writeFile(
|
||||
join(input.destinationDirectory, 'package.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: 'goodbuddy-dsh-extension-host',
|
||||
private: true,
|
||||
version: '1.0.0'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const environment =
|
||||
this.options.environment ??
|
||||
buildControlledHarnessEnvironment(this.options.dshHome)
|
||||
const runner = this.options.runner ?? runPackageManager
|
||||
const npmCliPath = this.options.npmCliPath
|
||||
const nodeExecutablePath =
|
||||
this.options.nodeExecutablePath ?? process.execPath
|
||||
let command = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
let prefixArgs: string[] = []
|
||||
let packageManagerEnvironment = environment
|
||||
if (npmCliPath) {
|
||||
const npmCli = await stat(npmCliPath).catch(() => undefined)
|
||||
if (!npmCli?.isFile()) {
|
||||
throw new Error('GoodBuddy 内置 npm Runtime 缺失')
|
||||
}
|
||||
const runtimeBin = join(
|
||||
this.options.dshHome,
|
||||
'package-manager-bin'
|
||||
)
|
||||
await prepareNodeCommand(runtimeBin, nodeExecutablePath)
|
||||
const inheritedPath =
|
||||
environment.PATH ?? environment.Path ?? ''
|
||||
const runtimePath = inheritedPath
|
||||
? `${runtimeBin}${delimiter}${inheritedPath}`
|
||||
: runtimeBin
|
||||
command = nodeExecutablePath
|
||||
prefixArgs = [npmCliPath]
|
||||
packageManagerEnvironment = {
|
||||
...environment,
|
||||
PATH: runtimePath,
|
||||
Path: runtimePath,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
npm_execpath: npmCliPath,
|
||||
npm_node_execpath: nodeExecutablePath
|
||||
}
|
||||
}
|
||||
let result: PackageManagerRunResult
|
||||
try {
|
||||
result = await runner(
|
||||
command,
|
||||
[
|
||||
...prefixArgs,
|
||||
'install',
|
||||
'--save-exact',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--dangerously-allow-all-scripts',
|
||||
'--loglevel=error',
|
||||
`${input.entry.package.name}@${input.entry.package.version}`
|
||||
],
|
||||
{
|
||||
cwd: input.destinationDirectory,
|
||||
env: {
|
||||
...packageManagerEnvironment,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
npm_config_progress: 'false',
|
||||
npm_config_update_notifier: 'false',
|
||||
npm_config_registry:
|
||||
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||
},
|
||||
timeoutMs:
|
||||
this.options.installTimeoutMs ??
|
||||
DEFAULT_INSTALL_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
throw packageManagerError(error)
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
const detail =
|
||||
result.stderr.trim() ||
|
||||
result.stdout.trim() ||
|
||||
`exit code ${result.exitCode}`
|
||||
throw new Error(
|
||||
`DSH 插件安装失败:${detail.slice(0, 4_000)}`
|
||||
)
|
||||
}
|
||||
|
||||
const installedDirectory = packageDirectory(
|
||||
input.destinationDirectory,
|
||||
input.entry.package.name
|
||||
)
|
||||
const installedManifest = npmInstalledManifestSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(join(installedDirectory, 'package.json'), 'utf8')
|
||||
) as unknown
|
||||
)
|
||||
if (
|
||||
installedManifest.name !== input.entry.package.name ||
|
||||
installedManifest.version !== input.entry.package.version
|
||||
) {
|
||||
throw new Error('DSH 插件安装版本与市场选择不一致')
|
||||
}
|
||||
const entrypoint = join(
|
||||
installedDirectory,
|
||||
normalizeEntrypoint(installedManifest)
|
||||
)
|
||||
if (!(await stat(entrypoint)).isFile()) {
|
||||
throw new Error('DSH 插件入口文件不存在')
|
||||
}
|
||||
const lock = JSON.parse(
|
||||
await readFile(
|
||||
join(input.destinationDirectory, 'package-lock.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
packages?: Record<string, { integrity?: unknown }>
|
||||
}
|
||||
const lockKey = relative(
|
||||
input.destinationDirectory,
|
||||
installedDirectory
|
||||
).replaceAll('\\', '/')
|
||||
const installedIntegrity =
|
||||
lock.packages?.[lockKey]?.integrity
|
||||
if (
|
||||
installedIntegrity !== manifest.dist.integrity
|
||||
) {
|
||||
throw new Error('DSH 插件 npm 完整性校验不一致')
|
||||
}
|
||||
return {
|
||||
entrypoint: relative(
|
||||
input.destinationDirectory,
|
||||
entrypoint
|
||||
).replaceAll('\\', '/'),
|
||||
integrity: manifest.dist.integrity
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveManifest(
|
||||
entry: RuntimeExtensionCatalogEntry
|
||||
): Promise<NpmVersionManifest> {
|
||||
const registryUrl = (
|
||||
this.options.registryUrl ?? NPM_REGISTRY_URL
|
||||
).replace(/\/$/u, '')
|
||||
const url = new URL(
|
||||
`${registryUrl}/${encodeURIComponent(entry.package.name)}`
|
||||
)
|
||||
const packument = npmPackumentSchema.parse(
|
||||
await fetchJson(
|
||||
this.options.fetcher ?? fetch,
|
||||
url,
|
||||
this.options.requestTimeoutMs ??
|
||||
DEFAULT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
)
|
||||
const manifest = packument.versions[entry.package.version]
|
||||
if (!manifest) {
|
||||
throw new Error('DSH 插件精确版本未发布')
|
||||
}
|
||||
if (
|
||||
manifest.name !== entry.package.name ||
|
||||
manifest.version !== entry.package.version
|
||||
) {
|
||||
throw new Error('DSH 插件 npm 元数据不一致')
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
).toBeGreaterThan(180)
|
||||
})
|
||||
|
||||
it('blocks mutating and shell tools in Ask while allowing reads', async () => {
|
||||
it('allows only the known read-only tools in Ask', async () => {
|
||||
const { listeners, handle } = stubAgentContext()
|
||||
const executeTool = listeners.get('tools/execute')!
|
||||
const next = vi.fn(async () => ({
|
||||
@@ -249,13 +249,46 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
agent: handle.agent
|
||||
})
|
||||
|
||||
for (const name of ['write', 'edit', 'bash', 'pwsh']) {
|
||||
for (const name of [
|
||||
'write',
|
||||
'edit',
|
||||
'bash',
|
||||
'pwsh',
|
||||
'third_party_deploy'
|
||||
]) {
|
||||
await expect(
|
||||
Promise.resolve(executeTool(request(name), next))
|
||||
).rejects.toThrow('Ask 模式不允许')
|
||||
}
|
||||
for (const name of ['read', 'skill']) {
|
||||
await expect(
|
||||
Promise.resolve(executeTool(request(name), next))
|
||||
).resolves.toMatchObject({ isError: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows every registered tool in Execute', async () => {
|
||||
const { listeners, handle, internals } = stubAgentContext()
|
||||
internals.sessions.get('session-output')!.inflight.mode =
|
||||
'execute'
|
||||
const executeTool = listeners.get('tools/execute')!
|
||||
const next = vi.fn(async () => ({
|
||||
isError: false,
|
||||
value: {},
|
||||
content: []
|
||||
}))
|
||||
|
||||
await expect(
|
||||
Promise.resolve(executeTool(request('read'), next))
|
||||
Promise.resolve(
|
||||
executeTool(
|
||||
{
|
||||
name: 'third_party_deploy',
|
||||
agent: handle.agent
|
||||
},
|
||||
next
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ isError: false })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,12 +45,7 @@ const DELTA_BATCH_CHARACTERS = 4 * 1024
|
||||
const DELTA_BATCH_INTERVAL_MS = 100
|
||||
const MAX_SUMMARY_CHARACTERS = 4_000
|
||||
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
|
||||
const ASK_BLOCKED_TOOL_NAMES = new Set([
|
||||
'bash',
|
||||
'pwsh',
|
||||
'write',
|
||||
'edit'
|
||||
])
|
||||
const ASK_READ_ONLY_TOOL_NAMES = new Set(['read', 'skill'])
|
||||
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
||||
'GoodBuddy controlled execution rules:',
|
||||
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
||||
@@ -656,10 +651,10 @@ export class GoodBuddyHarnessControlPlane {
|
||||
record &&
|
||||
record.handle.agent === exec.agent &&
|
||||
record.inflight?.mode === 'ask' &&
|
||||
ASK_BLOCKED_TOOL_NAMES.has(exec.name)
|
||||
!ASK_READ_ONLY_TOOL_NAMES.has(exec.name)
|
||||
) {
|
||||
throw new Error(
|
||||
`Ask 模式不允许执行修改或命令工具:${exec.name}`
|
||||
`Ask 模式不允许执行非只读工具:${exec.name}`
|
||||
)
|
||||
}
|
||||
return next()
|
||||
@@ -710,7 +705,10 @@ export class GoodBuddyHarnessControlPlane {
|
||||
type: 'tool',
|
||||
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
||||
name: 'tool',
|
||||
state: event.data.error ? 'failed' : 'completed',
|
||||
state:
|
||||
event.data.error || toolResult?.isError === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
output: boundedJson(event.data.message.content)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import {
|
||||
@@ -64,6 +70,7 @@ function createService() {
|
||||
const gateways: KnowledgeMcpGateway[] = []
|
||||
const databases: AssistantDatabase[] = []
|
||||
const temporaryDirectories: string[] = []
|
||||
const httpServers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
|
||||
@@ -75,6 +82,12 @@ afterEach(async () => {
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
await Promise.all(
|
||||
httpServers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('KnowledgeMcpGateway', () => {
|
||||
@@ -456,4 +469,122 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
expect(oversized.status).toBe(413)
|
||||
})
|
||||
|
||||
it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => {
|
||||
const upstreamAuthorizations: Array<string | undefined> = []
|
||||
const upstream = createServer(async (request, response) => {
|
||||
upstreamAuthorizations.push(request.headers.authorization)
|
||||
if (request.method !== 'POST') {
|
||||
response.writeHead(405)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
const body = JSON.parse(
|
||||
Buffer.concat(chunks).toString('utf8')
|
||||
) as unknown
|
||||
const mcp = new McpServer({
|
||||
name: 'private-upstream',
|
||||
version: '1.0.0'
|
||||
})
|
||||
mcp.registerTool(
|
||||
'echo_private',
|
||||
{
|
||||
description: 'Echo through the private server',
|
||||
inputSchema: {
|
||||
value: z.string().max(100)
|
||||
}
|
||||
},
|
||||
async ({ value }) => ({
|
||||
content: [{ type: 'text', text: `upstream:${value}` }]
|
||||
})
|
||||
)
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
})
|
||||
await mcp.connect(transport)
|
||||
await transport.handleRequest(request, response, body)
|
||||
await Promise.allSettled([transport.close(), mcp.close()])
|
||||
})
|
||||
httpServers.push(upstream)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once('error', reject)
|
||||
upstream.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = upstream.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('upstream did not bind')
|
||||
}
|
||||
|
||||
const { service } = createService()
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
await gateway.start()
|
||||
const controller = new AbortController()
|
||||
const token = gateway.grantCustomMcp(
|
||||
'custom-request',
|
||||
[
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000091',
|
||||
name: 'Private MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: true,
|
||||
assignments: ['opencode'],
|
||||
secretConfigured: true,
|
||||
secret: 'upstream-secret',
|
||||
transport: 'http',
|
||||
url: `http://127.0.0.1:${address.port}/mcp`
|
||||
}
|
||||
],
|
||||
controller.signal
|
||||
)!
|
||||
const client = new Client({
|
||||
name: 'loopback-test-client',
|
||||
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).toEqual([
|
||||
expect.objectContaining({
|
||||
name: expect.stringMatching(
|
||||
/^mcp_[a-f0-9]{8}_[a-f0-9]{8}_echo_private$/u
|
||||
),
|
||||
description: expect.stringContaining('Private MCP')
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(listed)).not.toContain('upstream-secret')
|
||||
expect(JSON.stringify(listed)).not.toContain(
|
||||
`127.0.0.1:${address.port}`
|
||||
)
|
||||
const result = await client.callTool({
|
||||
name: listed.tools[0]!.name,
|
||||
arguments: { value: 'hello' }
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
content: [{ type: 'text', text: 'upstream:hello' }]
|
||||
})
|
||||
expect(upstreamAuthorizations).toContain(
|
||||
'Bearer upstream-secret'
|
||||
)
|
||||
} finally {
|
||||
gateway.revoke(token)
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,17 @@ import {
|
||||
type Server,
|
||||
type ServerResponse
|
||||
} from 'node:http'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
CallToolResultSchema,
|
||||
ListToolsRequestSchema,
|
||||
type CallToolResult,
|
||||
type Tool
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
||||
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||
import {
|
||||
@@ -39,9 +48,26 @@ import type {
|
||||
GoodBuddyConfigApplyAuthorizer,
|
||||
GoodBuddyConfigService
|
||||
} from '../goodbuddy-config-service'
|
||||
import {
|
||||
createMcpTransport
|
||||
} from '../capabilities/mcp-client-transport'
|
||||
import type {
|
||||
ResolvedMcpServer
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
createMcpToolName,
|
||||
isValidMcpToolName,
|
||||
normalizeMcpToolSchema
|
||||
} from './mcp-tool-utils'
|
||||
|
||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
const MAX_RESULT_BYTES = 128 * 1024
|
||||
const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024
|
||||
const MAX_CUSTOM_MCP_SERVERS = 16
|
||||
const MAX_CUSTOM_MCP_TOOLS = 100
|
||||
const CUSTOM_MCP_TIMEOUT_MS = 30_000
|
||||
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
||||
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
|
||||
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
|
||||
|
||||
@@ -140,10 +166,29 @@ type Capability = {
|
||||
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
||||
expiresAt: number
|
||||
signal: AbortSignal
|
||||
brokerController: AbortController
|
||||
customMcpServers: readonly ResolvedMcpServer[]
|
||||
customMcpConnections?: Promise<CustomMcpConnection[]>
|
||||
references: Map<string, KnowledgeSearchReference>
|
||||
removeAbortListener: () => void
|
||||
}
|
||||
|
||||
type CustomMcpBinding = {
|
||||
client: Client
|
||||
server: ResolvedMcpServer
|
||||
originalName: string
|
||||
exposedTool: Tool
|
||||
taskSupport?: 'forbidden' | 'optional' | 'required'
|
||||
}
|
||||
|
||||
type CustomMcpConnection = {
|
||||
client: Client
|
||||
server: ResolvedMcpServer
|
||||
bindings: CustomMcpBinding[]
|
||||
dynamicToolsSupported: boolean
|
||||
dynamicToolsChanged: boolean
|
||||
}
|
||||
|
||||
export type KnowledgeMcpGatewayOptions = {
|
||||
capabilityTtlMs?: number
|
||||
maximumBodyBytes?: number
|
||||
@@ -184,6 +229,38 @@ function referenceKey(reference: KnowledgeSearchReference): string {
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
|
||||
const normalized =
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
'toolResult' in result
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(
|
||||
(result as { toolResult: unknown }).toolResult
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
: result
|
||||
const parsed = CallToolResultSchema.parse(normalized)
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(parsed)
|
||||
} catch (error) {
|
||||
throw new Error('MCP 工具结果无法序列化', { cause: error })
|
||||
}
|
||||
if (
|
||||
!serialized ||
|
||||
Buffer.byteLength(serialized) > MAX_CUSTOM_MCP_RESULT_BYTES
|
||||
) {
|
||||
throw new Error('MCP 工具结果超过 256KB 安全限制')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
response: ServerResponse,
|
||||
status: number,
|
||||
@@ -231,6 +308,7 @@ async function readBoundedJson(
|
||||
|
||||
export class KnowledgeMcpGateway {
|
||||
private readonly capabilities = new Map<string, Capability>()
|
||||
private readonly customMcpCleanups = new Set<Promise<void>>()
|
||||
private readonly now: () => number
|
||||
private readonly capabilityTtlMs: number
|
||||
private readonly maximumBodyBytes: number
|
||||
@@ -323,15 +401,9 @@ export class KnowledgeMcpGateway {
|
||||
return undefined
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const libraryIds = Object.freeze([...new Set(authorizedLibraryIds)])
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const abort = (): void => {
|
||||
this.revoke(token)
|
||||
}
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
this.capabilities.set(token, {
|
||||
return this.storeCapability({
|
||||
requestId,
|
||||
libraryIds,
|
||||
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
||||
magicNotesAccess: effectiveMagicNotesAccess,
|
||||
configAccess: effectiveConfigAccess,
|
||||
...(effectiveConfigAccess !== 'none'
|
||||
@@ -340,11 +412,67 @@ export class KnowledgeMcpGateway {
|
||||
authorizeConfigApply: config?.authorizeApply
|
||||
}
|
||||
: {}),
|
||||
expiresAt: this.now() + this.capabilityTtlMs,
|
||||
signal,
|
||||
customMcpServers: []
|
||||
})
|
||||
}
|
||||
|
||||
grantCustomMcp(
|
||||
requestId: string,
|
||||
servers: readonly ResolvedMcpServer[],
|
||||
signal: AbortSignal
|
||||
): string | undefined {
|
||||
if (servers.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (servers.length > MAX_CUSTOM_MCP_SERVERS) {
|
||||
throw new Error(
|
||||
`Agent Runtime 最多可加载 ${MAX_CUSTOM_MCP_SERVERS} 个 MCP Server`
|
||||
)
|
||||
}
|
||||
if (
|
||||
servers.some(
|
||||
(server) =>
|
||||
!server.enabled ||
|
||||
server.assignments.length === 0
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent Runtime MCP 授权包含无效 Server')
|
||||
}
|
||||
return this.storeCapability({
|
||||
requestId,
|
||||
libraryIds: [],
|
||||
magicNotesAccess: 'none',
|
||||
configAccess: 'none',
|
||||
signal,
|
||||
customMcpServers: Object.freeze([...servers])
|
||||
})
|
||||
}
|
||||
|
||||
private storeCapability(
|
||||
value: Omit<
|
||||
Capability,
|
||||
| 'expiresAt'
|
||||
| 'references'
|
||||
| 'removeAbortListener'
|
||||
| 'brokerController'
|
||||
| 'customMcpConnections'
|
||||
>
|
||||
): string {
|
||||
value.signal.throwIfAborted()
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const brokerController = new AbortController()
|
||||
const abort = (): void => {
|
||||
this.revoke(token)
|
||||
}
|
||||
value.signal.addEventListener('abort', abort, { once: true })
|
||||
this.capabilities.set(token, {
|
||||
...value,
|
||||
expiresAt: this.now() + this.capabilityTtlMs,
|
||||
brokerController,
|
||||
references: new Map(),
|
||||
removeAbortListener: () =>
|
||||
signal.removeEventListener('abort', abort)
|
||||
value.signal.removeEventListener('abort', abort)
|
||||
})
|
||||
return token
|
||||
}
|
||||
@@ -359,7 +487,17 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
capability.removeAbortListener()
|
||||
this.capabilities.delete(token)
|
||||
this.configService?.revokeRequest(capability.requestId)
|
||||
capability.brokerController.abort(
|
||||
new Error('MCP capability was revoked')
|
||||
)
|
||||
if (capability.configAccess !== 'none') {
|
||||
this.configService?.revokeRequest(capability.requestId)
|
||||
}
|
||||
const cleanup = this.closeCustomMcpConnections(capability)
|
||||
this.customMcpCleanups.add(cleanup)
|
||||
void cleanup.finally(() => {
|
||||
this.customMcpCleanups.delete(cleanup)
|
||||
})
|
||||
}
|
||||
|
||||
drainReferences(
|
||||
@@ -514,6 +652,291 @@ export class KnowledgeMcpGateway {
|
||||
]
|
||||
}
|
||||
|
||||
private createCustomMcpBindings(
|
||||
client: Client,
|
||||
server: ResolvedMcpServer,
|
||||
tools: Awaited<ReturnType<Client['listTools']>>['tools']
|
||||
): CustomMcpBinding[] {
|
||||
if (tools.length > MAX_CUSTOM_MCP_TOOLS) {
|
||||
throw new Error(
|
||||
`MCP Server「${server.name}」提供的工具数量超过安全限制`
|
||||
)
|
||||
}
|
||||
return tools.map((tool) => {
|
||||
if (!isValidMcpToolName(tool.name)) {
|
||||
throw new Error(
|
||||
`MCP Server「${server.name}」返回了无效工具名称`
|
||||
)
|
||||
}
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
originalName: tool.name,
|
||||
taskSupport: tool.execution?.taskSupport,
|
||||
exposedTool: {
|
||||
name: createMcpToolName(server.id, tool.name),
|
||||
title: `${server.name} / ${tool.name}`.slice(0, 200),
|
||||
description: [
|
||||
`GoodBuddy 代理的自定义 MCP Server「${server.name}」工具。`,
|
||||
tool.description
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, 1_000),
|
||||
inputSchema: normalizeMcpToolSchema(tool.inputSchema),
|
||||
annotations: tool.annotations
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async connectCustomMcpServer(
|
||||
capability: Capability,
|
||||
server: ResolvedMcpServer
|
||||
): Promise<CustomMcpConnection> {
|
||||
let connection: CustomMcpConnection | undefined
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'goodbuddy-main-mcp-broker',
|
||||
version: '1.0.0'
|
||||
},
|
||||
server.allowDynamicTools
|
||||
? {
|
||||
listChanged: {
|
||||
tools: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error) => {
|
||||
if (!error && connection) {
|
||||
connection.dynamicToolsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
const signal = AbortSignal.any([
|
||||
capability.signal,
|
||||
capability.brokerController.signal
|
||||
])
|
||||
try {
|
||||
await client.connect(createMcpTransport(server), {
|
||||
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||
signal
|
||||
})
|
||||
const result = await client.listTools(undefined, {
|
||||
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||
signal
|
||||
})
|
||||
connection = {
|
||||
client,
|
||||
server,
|
||||
bindings: this.createCustomMcpBindings(
|
||||
client,
|
||||
server,
|
||||
result.tools
|
||||
),
|
||||
dynamicToolsSupported:
|
||||
server.allowDynamicTools &&
|
||||
client.getServerCapabilities()?.tools?.listChanged === true,
|
||||
dynamicToolsChanged: false
|
||||
}
|
||||
return connection
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined)
|
||||
throw new Error(
|
||||
`无法加载 MCP Server「${server.name}」的工具`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async getCustomMcpBindings(
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
refreshDynamic = true
|
||||
): Promise<Map<string, CustomMcpBinding>> {
|
||||
const capability = this.getCapability(token)
|
||||
if (capability.customMcpServers.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
if (!capability.customMcpConnections) {
|
||||
capability.customMcpConnections = (async () => {
|
||||
const results = await Promise.allSettled(
|
||||
capability.customMcpServers.map((server) =>
|
||||
this.connectCustomMcpServer(capability, server)
|
||||
)
|
||||
)
|
||||
const connections = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
const failure = results.find(
|
||||
(result) => result.status === 'rejected'
|
||||
)
|
||||
if (failure?.status === 'rejected') {
|
||||
await Promise.allSettled(
|
||||
connections.map((connection) => connection.client.close())
|
||||
)
|
||||
throw failure.reason
|
||||
}
|
||||
return connections
|
||||
})()
|
||||
}
|
||||
let connections: CustomMcpConnection[]
|
||||
try {
|
||||
connections = await capability.customMcpConnections
|
||||
} catch (error) {
|
||||
capability.customMcpConnections = undefined
|
||||
throw error
|
||||
}
|
||||
if (refreshDynamic) {
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([
|
||||
signal,
|
||||
capability.signal,
|
||||
capability.brokerController.signal
|
||||
])
|
||||
: AbortSignal.any([
|
||||
capability.signal,
|
||||
capability.brokerController.signal
|
||||
])
|
||||
for (const connection of connections) {
|
||||
if (
|
||||
!connection.dynamicToolsSupported ||
|
||||
!connection.dynamicToolsChanged
|
||||
) {
|
||||
continue
|
||||
}
|
||||
connection.dynamicToolsChanged = false
|
||||
try {
|
||||
const result = await connection.client.listTools(undefined, {
|
||||
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||
signal: effectiveSignal
|
||||
})
|
||||
connection.bindings = this.createCustomMcpBindings(
|
||||
connection.client,
|
||||
connection.server,
|
||||
result.tools
|
||||
)
|
||||
} catch (error) {
|
||||
connection.dynamicToolsChanged = true
|
||||
throw new Error(
|
||||
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const bindings = new Map<string, CustomMcpBinding>()
|
||||
for (const connection of connections) {
|
||||
for (const binding of connection.bindings) {
|
||||
if (bindings.size >= MAX_CUSTOM_MCP_TOOLS) {
|
||||
throw new Error('Agent Runtime MCP 工具总数超过 100 个安全限制')
|
||||
}
|
||||
if (bindings.has(binding.exposedTool.name)) {
|
||||
throw new Error('Agent Runtime MCP 工具名称发生冲突')
|
||||
}
|
||||
bindings.set(binding.exposedTool.name, binding)
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
async prepareCustomMcpTools(
|
||||
token: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Tool[]> {
|
||||
return [
|
||||
...(await this.getCustomMcpBindings(token, signal)).values()
|
||||
].map((binding) => binding.exposedTool)
|
||||
}
|
||||
|
||||
private async callCustomMcpTool(
|
||||
token: string,
|
||||
binding: CustomMcpBinding,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
signal: AbortSignal
|
||||
): Promise<CallToolResult> {
|
||||
const capability = this.getCapability(token)
|
||||
const effectiveSignal = AbortSignal.any([
|
||||
signal,
|
||||
capability.signal,
|
||||
capability.brokerController.signal
|
||||
])
|
||||
const params = {
|
||||
name: binding.originalName,
|
||||
arguments: argumentsValue
|
||||
}
|
||||
const options = {
|
||||
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
||||
signal: effectiveSignal,
|
||||
onprogress: () => undefined,
|
||||
resetTimeoutOnProgress: true,
|
||||
maxTotalTimeout: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
|
||||
}
|
||||
try {
|
||||
if (binding.taskSupport !== 'required') {
|
||||
return ensureBoundedCustomMcpResult(
|
||||
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 ensureBoundedCustomMcpResult(message.result)
|
||||
} else if (message.type === 'error') {
|
||||
throw message.error
|
||||
}
|
||||
}
|
||||
throw new Error('MCP 任务工具未返回最终结果')
|
||||
} catch (error) {
|
||||
if (taskId) {
|
||||
await binding.client.experimental.tasks
|
||||
.cancelTask(taskId, {
|
||||
timeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS,
|
||||
maxTotalTimeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (effectiveSignal.aborted) {
|
||||
throw effectiveSignal.reason
|
||||
}
|
||||
throw new Error(
|
||||
`MCP Server「${binding.server.name}」工具调用失败`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async closeCustomMcpConnections(
|
||||
capability: Capability
|
||||
): Promise<void> {
|
||||
const pending = capability.customMcpConnections
|
||||
capability.customMcpConnections = undefined
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
const connections = await pending.catch(() => [])
|
||||
await Promise.allSettled(
|
||||
connections.map((connection) => connection.client.close())
|
||||
)
|
||||
}
|
||||
|
||||
private requireConfig(
|
||||
token: string,
|
||||
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
||||
@@ -841,40 +1264,111 @@ export class KnowledgeMcpGateway {
|
||||
return
|
||||
}
|
||||
|
||||
const mcp = new McpServer({
|
||||
name: 'goodbuddy-scoped-knowledge',
|
||||
version: '1.0.0'
|
||||
})
|
||||
const availableTools = this.getAvailableToolNames(token)
|
||||
for (const name of availableTools) {
|
||||
const definition = scopedDataToolByName.get(name)
|
||||
if (!definition) {
|
||||
continue
|
||||
const availableScopedTools = new Set(
|
||||
this.getAvailableToolNames(token)
|
||||
)
|
||||
const mcp = new McpProtocolServer(
|
||||
{
|
||||
name: 'goodbuddy-request-scoped-capabilities',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
mcp.registerTool(
|
||||
name,
|
||||
{
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: definition.access === 'read',
|
||||
destructiveHint:
|
||||
name === 'goodbuddy_config_apply' ||
|
||||
name === 'note_delete' ||
|
||||
name === 'note_entry_delete'
|
||||
}
|
||||
},
|
||||
async (input: Record<string, unknown>) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(await this.callScopedTool(token, name, input))
|
||||
)
|
||||
mcp.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (_request, extra) => {
|
||||
const customBindings = await this.getCustomMcpBindings(
|
||||
token,
|
||||
extra.signal
|
||||
)
|
||||
const scopedTools = [...availableScopedTools].flatMap(
|
||||
(name): Tool[] => {
|
||||
const definition = scopedDataToolByName.get(
|
||||
name as ScopedDataToolName
|
||||
)
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
const inputSchema = z.toJSONSchema(
|
||||
definition.inputSchema,
|
||||
{ target: 'draft-7' }
|
||||
) as Tool['inputSchema'] & { $schema?: string }
|
||||
Reflect.deleteProperty(inputSchema, '$schema')
|
||||
return [
|
||||
{
|
||||
name,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: definition.access === 'read',
|
||||
destructiveHint:
|
||||
name === 'goodbuddy_config_apply' ||
|
||||
name === 'note_delete' ||
|
||||
name === 'note_entry_delete'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
return {
|
||||
tools: [
|
||||
...scopedTools,
|
||||
...[...customBindings.values()].map(
|
||||
(binding) => binding.exposedTool
|
||||
)
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
mcp.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (call, extra) => {
|
||||
const name = call.params.name
|
||||
const input = call.params.arguments ?? {}
|
||||
if (
|
||||
availableScopedTools.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
|
||||
)
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const binding = (
|
||||
await this.getCustomMcpBindings(token, extra.signal)
|
||||
).get(name)
|
||||
if (!binding) {
|
||||
throw new Error('GoodBuddy MCP 工具不存在或已失效')
|
||||
}
|
||||
return this.callCustomMcpTool(
|
||||
token,
|
||||
binding,
|
||||
input,
|
||||
extra.signal
|
||||
)
|
||||
}
|
||||
)
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
})
|
||||
@@ -897,6 +1391,7 @@ export class KnowledgeMcpGateway {
|
||||
for (const token of [...this.capabilities.keys()]) {
|
||||
this.revoke(token)
|
||||
}
|
||||
await Promise.allSettled([...this.customMcpCleanups])
|
||||
const server = this.server
|
||||
this.server = undefined
|
||||
this.endpoint = undefined
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024
|
||||
|
||||
export function isValidMcpToolName(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= 128 &&
|
||||
![...value].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 31 || code === 127
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function createMcpToolName(
|
||||
serverId: string,
|
||||
originalName: string
|
||||
): string {
|
||||
const serverHash = createHash('sha256')
|
||||
.update(serverId)
|
||||
.digest('hex')
|
||||
.slice(0, 8)
|
||||
const toolHash = createHash('sha256')
|
||||
.update(originalName)
|
||||
.digest('hex')
|
||||
.slice(0, 8)
|
||||
const readable =
|
||||
originalName
|
||||
.replace(/[^a-zA-Z0-9_-]+/gu, '_')
|
||||
.replace(/^_+|_+$/gu, '')
|
||||
.slice(0, 36) || 'tool'
|
||||
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
|
||||
}
|
||||
|
||||
export function normalizeMcpToolSchema(
|
||||
value: unknown
|
||||
): Record<string, unknown> & { type: 'object' } {
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
throw new Error('MCP 工具参数结构无效', { cause: error })
|
||||
}
|
||||
if (
|
||||
!serialized ||
|
||||
Buffer.byteLength(serialized) >
|
||||
MAXIMUM_MCP_TOOL_SCHEMA_BYTES
|
||||
) {
|
||||
throw new Error('MCP 工具参数结构超过 32KB 安全限制')
|
||||
}
|
||||
const schema = JSON.parse(serialized) as unknown
|
||||
if (
|
||||
!schema ||
|
||||
typeof schema !== 'object' ||
|
||||
Array.isArray(schema) ||
|
||||
(schema as Record<string, unknown>).type !== 'object'
|
||||
) {
|
||||
throw new Error('MCP 工具参数必须使用 object JSON Schema')
|
||||
}
|
||||
return schema as Record<string, unknown> & { type: 'object' }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
@@ -38,10 +38,14 @@ import {
|
||||
} from '../browser/browser-model-tools'
|
||||
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import {
|
||||
createMcpToolName,
|
||||
isValidMcpToolName,
|
||||
normalizeMcpToolSchema
|
||||
} from './mcp-tool-utils'
|
||||
|
||||
const MAX_MODEL_TOOLS = 100
|
||||
const MAX_MCP_SERVERS = 16
|
||||
const MAX_TOOL_SCHEMA_BYTES = 32 * 1024
|
||||
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
||||
const MAX_READ_BYTES = 256 * 1024
|
||||
const MAX_WRITE_BYTES = 512 * 1024
|
||||
@@ -293,47 +297,6 @@ function boundedJson(value: unknown, errorMessage: string): string {
|
||||
return serialized
|
||||
}
|
||||
|
||||
function normalizeToolSchema(value: unknown): Record<string, unknown> {
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
throw new Error('MCP 工具参数结构无效', { cause: error })
|
||||
}
|
||||
if (
|
||||
!serialized ||
|
||||
Buffer.byteLength(serialized) > MAX_TOOL_SCHEMA_BYTES
|
||||
) {
|
||||
throw new Error('MCP 工具参数结构超过 32KB 安全限制')
|
||||
}
|
||||
const schema = JSON.parse(serialized) as unknown
|
||||
if (
|
||||
!schema ||
|
||||
typeof schema !== 'object' ||
|
||||
Array.isArray(schema) ||
|
||||
(schema as Record<string, unknown>).type !== 'object'
|
||||
) {
|
||||
throw new Error('MCP 工具参数必须使用 object JSON Schema')
|
||||
}
|
||||
return schema as Record<string, unknown>
|
||||
}
|
||||
|
||||
function createMcpToolName(serverId: string, originalName: string): string {
|
||||
const serverHash = createHash('sha256')
|
||||
.update(serverId)
|
||||
.digest('hex')
|
||||
.slice(0, 8)
|
||||
const toolHash = createHash('sha256')
|
||||
.update(originalName)
|
||||
.digest('hex')
|
||||
.slice(0, 8)
|
||||
const readable = originalName
|
||||
.replace(/[^a-zA-Z0-9_-]+/gu, '_')
|
||||
.replace(/^_+|_+$/gu, '')
|
||||
.slice(0, 36) || 'tool'
|
||||
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
|
||||
}
|
||||
|
||||
function createTextToolResult(text: string): ModelToolResult {
|
||||
const contextBytes = Buffer.byteLength(text)
|
||||
if (contextBytes > MAX_TOOL_RESULT_BYTES) {
|
||||
@@ -852,7 +815,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, 1_000),
|
||||
inputSchema: normalizeToolSchema(tool.inputSchema),
|
||||
inputSchema: normalizeMcpToolSchema(tool.inputSchema),
|
||||
source: 'mcp',
|
||||
serverName: server.name,
|
||||
taskSupport: tool.execution?.taskSupport
|
||||
@@ -860,13 +823,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
}))
|
||||
if (
|
||||
bindings.some(
|
||||
(tool) =>
|
||||
!tool.originalName ||
|
||||
tool.originalName.length > 128 ||
|
||||
[...tool.originalName].some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code <= 31 || code === 127
|
||||
})
|
||||
(tool) => !isValidMcpToolName(tool.originalName)
|
||||
)
|
||||
) {
|
||||
throw new Error(`MCP Server「${server.name}」返回了无效工具名称`)
|
||||
|
||||
@@ -1307,6 +1307,147 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
|
||||
describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
it('shares assigned custom MCP only with embedded Execute through a scoped loopback token', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const gateway = {
|
||||
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||
prepareCustomMcpTools: vi.fn(async () => [
|
||||
{
|
||||
name: 'mcp_12345678_abcdef01_private_tool',
|
||||
inputSchema: { type: 'object' }
|
||||
}
|
||||
]),
|
||||
revoke: vi.fn()
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000092',
|
||||
name: 'Private MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['opencode'],
|
||||
secretConfigured: true,
|
||||
secret: 'must-stay-in-main',
|
||||
transport: 'http',
|
||||
url: 'https://private.example/mcp'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await collectRun(runtime, 'execute')
|
||||
|
||||
expect(gateway.grantCustomMcp).toHaveBeenCalledWith(
|
||||
'3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
expect.any(Array),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(setup.client.mcp.add).toHaveBeenCalledWith({
|
||||
directory: process.cwd(),
|
||||
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
|
||||
config: {
|
||||
type: 'remote',
|
||||
url: 'http://127.0.0.1:4567/mcp',
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: 'Bearer custom-capability'
|
||||
},
|
||||
oauth: false
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(
|
||||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
|
||||
.mock.calls
|
||||
)).not.toContain('must-stay-in-main')
|
||||
expect(JSON.stringify(
|
||||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
|
||||
.mock.calls
|
||||
)).not.toContain('private.example')
|
||||
expect(gateway.revoke).toHaveBeenCalledWith('custom-capability')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ask', true] as const,
|
||||
['execute', false] as const
|
||||
])(
|
||||
'does not share custom MCP with OpenCode in %s mode when embedded is %s',
|
||||
async (workMode, embedded) => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const gateway = {
|
||||
getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||
prepareCustomMcpTools: vi.fn(async () => []),
|
||||
revoke: vi.fn()
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const runtime = embedded
|
||||
? embeddedRuntime(setup.client, {
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000093',
|
||||
name: 'Private MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['opencode'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: 'private-command',
|
||||
args: []
|
||||
}
|
||||
]
|
||||
})
|
||||
: new OpenCodeRuntime(
|
||||
options({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
embedded: false,
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000093',
|
||||
name: 'Private MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['opencode'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: 'private-command',
|
||||
args: []
|
||||
}
|
||||
]
|
||||
}),
|
||||
dependencies(fakeChild(), {
|
||||
createClient: vi.fn(
|
||||
() => setup.client
|
||||
) as unknown as typeof createOpencodeClient
|
||||
}).deps
|
||||
)
|
||||
|
||||
await collectRun(runtime, workMode)
|
||||
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
expect(setup.client.mcp.add).not.toHaveBeenCalled()
|
||||
await runtime.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('parses OpenCode questions and sends the selected answers back', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
boundedToolDetail,
|
||||
safeToolErrorDetail
|
||||
} from './approval-summary'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type {
|
||||
ResolvedMcpServer,
|
||||
RuntimeSkillPackage
|
||||
} from '../capabilities/capability-service'
|
||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||
|
||||
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
|
||||
@@ -385,6 +388,7 @@ export type OpenCodeRuntimeOptions = {
|
||||
skillInstructions?: string
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
}
|
||||
|
||||
function createSkillPermissionRules(
|
||||
@@ -1082,6 +1086,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
createSkillPermissionRules(nativeSkillIds)
|
||||
let knowledgeMcpName: string | undefined
|
||||
let knowledgeToolIds: string[] = []
|
||||
let customMcpName: string | undefined
|
||||
let customMcpToken: string | undefined
|
||||
try {
|
||||
if (
|
||||
request.knowledgeCapabilityToken &&
|
||||
@@ -1122,6 +1128,58 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
.getAvailableToolNames(request.knowledgeCapabilityToken)
|
||||
.map((toolName) => `${knowledgeMcpName}_${toolName}`)
|
||||
}
|
||||
if (
|
||||
request.workMode === 'execute' &&
|
||||
this.usesEmbeddedPermissionMediation() &&
|
||||
this.options.knowledgeGateway?.getEndpoint() &&
|
||||
this.options.mcpServers?.length
|
||||
) {
|
||||
customMcpToken = this.options.knowledgeGateway.grantCustomMcp(
|
||||
request.requestId,
|
||||
this.options.mcpServers,
|
||||
signal
|
||||
)
|
||||
if (customMcpToken) {
|
||||
const tools =
|
||||
await this.options.knowledgeGateway.prepareCustomMcpTools(
|
||||
customMcpToken,
|
||||
signal
|
||||
)
|
||||
customMcpName = `goodbuddy-custom-${createHash('sha256')
|
||||
.update(`${request.conversationId}\0${request.requestId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 20)}`
|
||||
const added = await client.mcp.add({
|
||||
directory,
|
||||
name: customMcpName,
|
||||
config: {
|
||||
type: 'remote',
|
||||
url: this.options.knowledgeGateway.getEndpoint()!,
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${customMcpToken}`
|
||||
},
|
||||
oauth: false
|
||||
}
|
||||
})
|
||||
const addedStatus = added.data?.[customMcpName]
|
||||
if (
|
||||
added.error ||
|
||||
!added.data ||
|
||||
!addedStatus ||
|
||||
addedStatus.status !== 'connected'
|
||||
) {
|
||||
throw new Error(
|
||||
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? 'unknown'})`
|
||||
)
|
||||
}
|
||||
knowledgeToolIds.push(
|
||||
...tools.map(
|
||||
(tool) => `${customMcpName}_${tool.name}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
const permission =
|
||||
request.workMode === 'execute'
|
||||
? [
|
||||
@@ -1618,6 +1676,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
.disconnect({ name: knowledgeMcpName, directory })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (customMcpName) {
|
||||
await client.mcp
|
||||
.disconnect({ name: customMcpName, directory })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (customMcpToken) {
|
||||
this.options.knowledgeGateway?.revoke(customMcpToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
} from '../capabilities/browser-profile-service'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher
|
||||
type CapabilityCipher,
|
||||
type ResolvedMcpServer
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
goodbuddyConfigToolByName,
|
||||
goodbuddyConfigTools
|
||||
} from '../../shared/goodbuddy-config-tools'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
|
||||
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
|
||||
const apiKey =
|
||||
@@ -53,11 +55,14 @@ const protocol = modelProtocolSchema
|
||||
.parse(
|
||||
process.env.GOODBUDDY_E2E_PROTOCOL ?? 'anthropic-messages'
|
||||
)
|
||||
const portableRoot = join(
|
||||
process.cwd(),
|
||||
'dist',
|
||||
'GoodBuddy-0.1.0-win-x64-portable'
|
||||
)
|
||||
const portableRoot = process.env.GOODBUDDY_E2E_PACKAGED_ROOT
|
||||
? resolve(process.env.GOODBUDDY_E2E_PACKAGED_ROOT)
|
||||
: join(
|
||||
process.cwd(),
|
||||
'dist',
|
||||
'harness-package-probe',
|
||||
'win-unpacked'
|
||||
)
|
||||
|
||||
async function collectText(
|
||||
events: AsyncGenerator<RuntimeEvent, void, void>
|
||||
@@ -71,6 +76,38 @@ async function collectText(
|
||||
return output
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
events: AsyncGenerator<RuntimeEvent, void, void>
|
||||
): Promise<RuntimeEvent[]> {
|
||||
const collected: RuntimeEvent[] = []
|
||||
for await (const event of events) {
|
||||
collected.push(event)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
function customMcpServer(
|
||||
assignment: 'opencode' | 'continue'
|
||||
): ResolvedMcpServer {
|
||||
return {
|
||||
id:
|
||||
assignment === 'opencode'
|
||||
? '00000000-0000-4000-8000-0000000000e1'
|
||||
: '00000000-0000-4000-8000-0000000000e2',
|
||||
name: 'Live Blueprint MCP',
|
||||
description: 'Deterministic local Runtime E2E fixture',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: [assignment],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function textResult(value: unknown): ModelToolResult {
|
||||
const text = JSON.stringify(value)
|
||||
return {
|
||||
@@ -898,4 +935,176 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it(
|
||||
'calls a Main-brokered custom MCP through bundled OpenCode',
|
||||
async () => {
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
const runtime = new AgentRuntimeController(
|
||||
new OpenCodeRuntime({
|
||||
embedded: true,
|
||||
binaryPath: '',
|
||||
bundledBinaryPath: join(
|
||||
portableRoot,
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode',
|
||||
'opencode.exe'
|
||||
),
|
||||
configPath: '',
|
||||
defaultWorkspace: workspace,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'E2E model',
|
||||
baseUrl,
|
||||
modelName,
|
||||
apiKey,
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
},
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [customMcpServer('opencode')]
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const events = await collectEvents(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute',
|
||||
prompt:
|
||||
'Use the assigned custom MCP tool to create a neon-ruins game blueprint with seed opencode-live and targetCount 5. Then reply with OPENCODE_MCP_E2E_OK and the blueprint title.'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async (request) =>
|
||||
[
|
||||
request.scopeKey,
|
||||
request.title,
|
||||
request.description,
|
||||
request.toolName ?? ''
|
||||
].some((value) =>
|
||||
value.includes('create_game_blueprint')
|
||||
)
|
||||
? 'once'
|
||||
: 'deny'
|
||||
)
|
||||
)
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
name: expect.stringContaining(
|
||||
'create_game_blueprint'
|
||||
),
|
||||
state: 'completed'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(
|
||||
events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
).toContain('OPENCODE_MCP_E2E_OK')
|
||||
expect(
|
||||
events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
).toContain('Prism Relay')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
}
|
||||
},
|
||||
180_000
|
||||
)
|
||||
|
||||
it(
|
||||
'calls a Main-brokered custom MCP through bundled Continue',
|
||||
async () => {
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
const runtime = new AgentRuntimeController(
|
||||
new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
bundledBinaryPath: join(
|
||||
portableRoot,
|
||||
'resources',
|
||||
'runtimes',
|
||||
'continue',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
configPath: '',
|
||||
defaultWorkspace: workspace,
|
||||
hostCacheRoot: join(workspace, '.continue-mcp-host'),
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'E2E model',
|
||||
baseUrl,
|
||||
modelName,
|
||||
apiKey,
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
},
|
||||
knowledgeGateway: gateway,
|
||||
mcpServers: [customMcpServer('continue')]
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const events = await collectEvents(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute',
|
||||
prompt:
|
||||
'Use the assigned custom MCP tool to create a neon-ruins game blueprint with seed continue-live and targetCount 5. Then reply with CONTINUE_MCP_E2E_OK and the blueprint title.'
|
||||
},
|
||||
new AbortController().signal,
|
||||
async (request) =>
|
||||
[
|
||||
request.scopeKey,
|
||||
request.title,
|
||||
request.description,
|
||||
request.toolName ?? ''
|
||||
].some((value) =>
|
||||
value.includes('create_game_blueprint')
|
||||
)
|
||||
? 'once'
|
||||
: 'deny'
|
||||
)
|
||||
)
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
name: expect.stringContaining(
|
||||
'create_game_blueprint'
|
||||
),
|
||||
state: 'completed'
|
||||
})
|
||||
])
|
||||
)
|
||||
const output = events
|
||||
.flatMap((event) =>
|
||||
event.type === 'text' ? [event.delta] : []
|
||||
)
|
||||
.join('')
|
||||
expect(output).toContain('CONTINUE_MCP_E2E_OK')
|
||||
expect(output).toContain('Prism Relay')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
}
|
||||
},
|
||||
180_000
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeExtensionCatalogEntry } from '../../shared/runtime-extension-contracts'
|
||||
import {
|
||||
RuntimeExtensionStore,
|
||||
type RuntimeExtensionStoreDependencies
|
||||
} from './runtime-extension-store'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function catalogEntry(version = '1.0.0'): RuntimeExtensionCatalogEntry {
|
||||
return {
|
||||
id: 'test-extension',
|
||||
package: {
|
||||
name: '@goodbuddy/test-extension',
|
||||
version
|
||||
},
|
||||
displayName: 'Test extension',
|
||||
description: 'A deterministic extension store fixture.'
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultInstall(input: {
|
||||
destinationDirectory: string
|
||||
}): Promise<{ entrypoint: string; integrity: string }> {
|
||||
const distribution = join(input.destinationDirectory, 'dist')
|
||||
await mkdir(distribution, { recursive: true })
|
||||
await writeFile(join(distribution, 'index.js'), 'export default {}')
|
||||
return {
|
||||
entrypoint: 'dist/index.js',
|
||||
integrity: `sha512-${Buffer.from('verified').toString('base64')}`
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(input: {
|
||||
entries?: RuntimeExtensionCatalogEntry[]
|
||||
install?: RuntimeExtensionStoreDependencies['install']
|
||||
temporaryIds?: string[]
|
||||
marketplaceEnabled?: boolean
|
||||
} = {}): Promise<{
|
||||
userDataPath: string
|
||||
store: RuntimeExtensionStore
|
||||
dependencies: RuntimeExtensionStoreDependencies
|
||||
}> {
|
||||
const userDataPath = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-extension-store-')
|
||||
)
|
||||
temporaryDirectories.push(userDataPath)
|
||||
const entries = input.entries ?? [catalogEntry()]
|
||||
const temporaryIds = input.temporaryIds ?? ['install-one']
|
||||
const dependencies: RuntimeExtensionStoreDependencies = {
|
||||
catalog: {
|
||||
list: vi.fn(async () => entries)
|
||||
},
|
||||
install: vi.fn(input.install ?? defaultInstall),
|
||||
now: () => new Date('2026-08-16T00:00:00.000Z'),
|
||||
temporaryId: () => {
|
||||
const id = temporaryIds.shift()
|
||||
if (!id) {
|
||||
throw new Error('No fixture temporary ID remains')
|
||||
}
|
||||
return id
|
||||
}
|
||||
}
|
||||
const store = new RuntimeExtensionStore(userDataPath, dependencies)
|
||||
if (input.marketplaceEnabled ?? true) {
|
||||
await store.apply({
|
||||
type: 'set-marketplace-enabled',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
return {
|
||||
userDataPath,
|
||||
dependencies,
|
||||
store
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('RuntimeExtensionStore', () => {
|
||||
it('keeps a fresh marketplace disabled without loading the catalog', async () => {
|
||||
const { store, dependencies } = await fixture({
|
||||
marketplaceEnabled: false
|
||||
})
|
||||
const entry = catalogEntry()
|
||||
|
||||
await expect(store.getSnapshot()).resolves.toEqual({
|
||||
marketplaceEnabled: false,
|
||||
catalog: [],
|
||||
installed: []
|
||||
})
|
||||
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
).rejects.toThrow('marketplace is disabled')
|
||||
|
||||
await expect(
|
||||
store.applyWithResult({
|
||||
type: 'set-marketplace-enabled',
|
||||
enabled: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
changed: true,
|
||||
snapshot: { marketplaceEnabled: true }
|
||||
})
|
||||
await expect(store.getSnapshot()).resolves.toMatchObject({
|
||||
marketplaceEnabled: true,
|
||||
catalog: [entry]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the marketplace enabled when migrating installed version 1 state', async () => {
|
||||
const { userDataPath, dependencies } = await fixture({
|
||||
marketplaceEnabled: false
|
||||
})
|
||||
const entry = catalogEntry()
|
||||
const extensionDirectory = join(
|
||||
userDataPath,
|
||||
'runtime-extensions',
|
||||
'extensions',
|
||||
entry.id
|
||||
)
|
||||
await mkdir(join(extensionDirectory, 'dist'), { recursive: true })
|
||||
const entrypoint = join(extensionDirectory, 'dist', 'index.js')
|
||||
await writeFile(entrypoint, 'export default {}')
|
||||
await writeFile(
|
||||
join(userDataPath, 'runtime-extensions', 'store.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
installed: [
|
||||
{
|
||||
id: entry.id,
|
||||
package: entry.package,
|
||||
entrypoint,
|
||||
installedAt: '2026-08-16T00:00:00.000Z',
|
||||
enabled: true,
|
||||
configuration: {}
|
||||
}
|
||||
]
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const migrated = new RuntimeExtensionStore(
|
||||
userDataPath,
|
||||
dependencies
|
||||
)
|
||||
await expect(migrated.getSnapshot()).resolves.toMatchObject({
|
||||
marketplaceEnabled: true,
|
||||
installed: [
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
enabled: true
|
||||
})
|
||||
]
|
||||
})
|
||||
await expect(
|
||||
readFile(
|
||||
join(userDataPath, 'runtime-extensions', 'store.json'),
|
||||
'utf8'
|
||||
).then((value) => JSON.parse(value) as unknown)
|
||||
).resolves.toMatchObject({
|
||||
version: 2,
|
||||
marketplaceEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('installs one exact, integrity-verified package directory', async () => {
|
||||
const { userDataPath, store, dependencies } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
|
||||
const snapshot = await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
|
||||
const extensionDirectory = join(
|
||||
userDataPath,
|
||||
'runtime-extensions',
|
||||
'extensions',
|
||||
entry.id
|
||||
)
|
||||
expect(snapshot.installed).toEqual([
|
||||
{
|
||||
id: entry.id,
|
||||
package: entry.package,
|
||||
installedAt: '2026-08-16T00:00:00.000Z',
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
integrity: `sha512-${Buffer.from('verified').toString('base64')}`
|
||||
}
|
||||
])
|
||||
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
entrypoint: join(extensionDirectory, 'dist', 'index.js')
|
||||
})
|
||||
])
|
||||
await expect(
|
||||
readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8')
|
||||
).resolves.toBe('export default {}')
|
||||
expect(dependencies.install).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
destinationDirectory: expect.stringMatching(
|
||||
/\.staging[\\/]install-one$/u
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the marketplace without disabling installed plugins', async () => {
|
||||
const { store, dependencies } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
vi.mocked(dependencies.catalog.list).mockClear()
|
||||
|
||||
await expect(
|
||||
store.apply({
|
||||
type: 'set-marketplace-enabled',
|
||||
enabled: false
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
marketplaceEnabled: false,
|
||||
catalog: [],
|
||||
installed: [
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
enabled: true
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||
expect.objectContaining({ id: entry.id })
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves an existing installation untouched when an upgrade fails', async () => {
|
||||
const first = catalogEntry('1.0.0')
|
||||
const second = catalogEntry('2.0.0')
|
||||
const { store, dependencies } = await fixture({
|
||||
entries: [first],
|
||||
temporaryIds: ['install-one', 'install-two']
|
||||
})
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: first.id,
|
||||
package: first.package
|
||||
})
|
||||
await store.apply({
|
||||
type: 'configure',
|
||||
extensionId: first.id,
|
||||
configuration: { nested: { value: 1 } }
|
||||
})
|
||||
await store.apply({
|
||||
type: 'set-enabled',
|
||||
extensionId: first.id,
|
||||
enabled: true
|
||||
})
|
||||
vi.mocked(dependencies.catalog.list).mockResolvedValue([second])
|
||||
vi.mocked(dependencies.install).mockRejectedValueOnce(
|
||||
new Error('Entrypoint contract mismatch')
|
||||
)
|
||||
|
||||
await expect(
|
||||
store.apply({
|
||||
type: 'install',
|
||||
extensionId: second.id,
|
||||
package: second.package
|
||||
})
|
||||
).rejects.toThrow('Entrypoint contract mismatch')
|
||||
|
||||
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||
package: first.package,
|
||||
enabled: true,
|
||||
configuration: { nested: { value: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects installer entrypoints outside the managed package', async () => {
|
||||
const entry = catalogEntry()
|
||||
const fixtureValue = await fixture({
|
||||
entries: [entry],
|
||||
install: async () => ({ entrypoint: '../outside.js' })
|
||||
})
|
||||
await expect(
|
||||
fixtureValue.store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
).rejects.toThrow('invalid entrypoint')
|
||||
expect(
|
||||
(await fixtureValue.store.getSnapshot()).installed
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('configures, launches, and disables startup failures', async () => {
|
||||
const { store } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
await store.apply({
|
||||
type: 'configure',
|
||||
extensionId: entry.id,
|
||||
configuration: {
|
||||
endpoint: 'https://example.com',
|
||||
options: { retries: 2, tags: ['one', 'two'] }
|
||||
}
|
||||
})
|
||||
await expect(store.getEnabledExtensions()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
configuration: {
|
||||
endpoint: 'https://example.com',
|
||||
options: { retries: 2, tags: ['one', 'two'] }
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
await store.markStartupFailed([entry.id, 'not-installed'])
|
||||
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||
enabled: false,
|
||||
lastError: 'startup-failed'
|
||||
})
|
||||
await expect(store.getEnabledExtensions()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('reports semantic no-op mutations without refreshing the catalog', async () => {
|
||||
const { store, dependencies } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
await store.apply({
|
||||
type: 'configure',
|
||||
extensionId: entry.id,
|
||||
configuration: { first: 1, second: 2 }
|
||||
})
|
||||
vi.mocked(dependencies.catalog.list).mockClear()
|
||||
|
||||
await expect(
|
||||
store.applyWithResult({
|
||||
type: 'configure',
|
||||
extensionId: entry.id,
|
||||
configuration: { second: 2, first: 1 }
|
||||
})
|
||||
).resolves.toMatchObject({ changed: false })
|
||||
await expect(
|
||||
store.applyWithResult({
|
||||
type: 'set-enabled',
|
||||
extensionId: entry.id,
|
||||
enabled: true
|
||||
})
|
||||
).resolves.toMatchObject({ changed: false })
|
||||
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps installed extensions manageable while the catalog is offline', async () => {
|
||||
const { store, dependencies } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
vi.mocked(dependencies.catalog.list).mockRejectedValue(
|
||||
new Error('npm registry unavailable')
|
||||
)
|
||||
|
||||
await expect(store.getSnapshot()).resolves.toMatchObject({
|
||||
catalog: [],
|
||||
catalogError: 'npm registry unavailable',
|
||||
installed: [
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
enabled: true
|
||||
})
|
||||
]
|
||||
})
|
||||
vi.mocked(dependencies.catalog.list).mockClear()
|
||||
await expect(
|
||||
store.apply({
|
||||
type: 'set-enabled',
|
||||
extensionId: entry.id,
|
||||
enabled: false
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
catalog: [],
|
||||
catalogError: 'npm registry unavailable',
|
||||
installed: [
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
enabled: false
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(dependencies.catalog.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes mutations and removes only its managed extension directory', async () => {
|
||||
const { userDataPath, store } = await fixture()
|
||||
const entry = catalogEntry()
|
||||
const outsidePath = join(userDataPath, 'outside.txt')
|
||||
await writeFile(outsidePath, 'preserve me')
|
||||
await store.apply({
|
||||
type: 'install',
|
||||
extensionId: entry.id,
|
||||
package: entry.package
|
||||
})
|
||||
|
||||
const configured = store.apply({
|
||||
type: 'configure',
|
||||
extensionId: entry.id,
|
||||
configuration: { order: 1 }
|
||||
})
|
||||
const enabled = store.apply({
|
||||
type: 'set-enabled',
|
||||
extensionId: entry.id,
|
||||
enabled: true
|
||||
})
|
||||
await Promise.all([configured, enabled])
|
||||
expect((await store.getSnapshot()).installed[0]).toMatchObject({
|
||||
enabled: true,
|
||||
configuration: { order: 1 }
|
||||
})
|
||||
|
||||
await store.apply({ type: 'remove', extensionId: entry.id })
|
||||
await expect(readFile(outsidePath, 'utf8')).resolves.toBe('preserve me')
|
||||
expect((await store.getSnapshot()).installed).toEqual([])
|
||||
await expect(
|
||||
readdir(join(userDataPath, 'runtime-extensions', 'extensions'))
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,689 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rmdir,
|
||||
unlink
|
||||
} from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
runtimeExtensionActionSchema,
|
||||
runtimeExtensionCatalogEntrySchema,
|
||||
runtimeExtensionIdSchema,
|
||||
runtimeExtensionInstalledStateSchema,
|
||||
runtimeExtensionStartupFailureCode,
|
||||
type RuntimeExtensionAction,
|
||||
type RuntimeExtensionCatalogEntry,
|
||||
type RuntimeExtensionConfiguration,
|
||||
type RuntimeExtensionExactPackage,
|
||||
type RuntimeExtensionInstalledState,
|
||||
type RuntimeExtensionMarketplaceInstalledState,
|
||||
type RuntimeExtensionMarketplaceSnapshot
|
||||
} from '../../shared/runtime-extension-contracts'
|
||||
import {
|
||||
isMissingFileError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
|
||||
const managedDirectoryName = 'runtime-extensions'
|
||||
const stateFileName = 'store.json'
|
||||
|
||||
const version1StoredStateSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
installed: z.array(runtimeExtensionInstalledStateSchema)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedStateSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
marketplaceEnabled: z.boolean(),
|
||||
installed: z.array(runtimeExtensionInstalledStateSchema)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedStateFileSchema = z.union([
|
||||
storedStateSchema,
|
||||
version1StoredStateSchema
|
||||
])
|
||||
|
||||
type StoredState = z.infer<typeof storedStateSchema>
|
||||
|
||||
export interface RuntimeExtensionCatalog {
|
||||
list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
|
||||
}
|
||||
|
||||
export interface RuntimeExtensionStoreDependencies {
|
||||
catalog: RuntimeExtensionCatalog
|
||||
install(input: {
|
||||
entry: RuntimeExtensionCatalogEntry
|
||||
destinationDirectory: string
|
||||
}): Promise<{
|
||||
entrypoint: string
|
||||
integrity?: string
|
||||
}>
|
||||
now?: () => Date
|
||||
temporaryId?: () => string
|
||||
}
|
||||
|
||||
export interface EnabledRuntimeExtension {
|
||||
id: string
|
||||
entrypoint: string
|
||||
configuration: RuntimeExtensionConfiguration
|
||||
}
|
||||
|
||||
export type RuntimeExtensionApplyResult = {
|
||||
snapshot: RuntimeExtensionMarketplaceSnapshot
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
function emptyState(): StoredState {
|
||||
return {
|
||||
version: 2,
|
||||
marketplaceEnabled: false,
|
||||
installed: []
|
||||
}
|
||||
}
|
||||
|
||||
function compareIds(
|
||||
left: { id: string },
|
||||
right: { id: string }
|
||||
): number {
|
||||
return left.id.localeCompare(right.id, 'en')
|
||||
}
|
||||
|
||||
function packagesEqual(
|
||||
left: RuntimeExtensionExactPackage,
|
||||
right: RuntimeExtensionExactPackage
|
||||
): boolean {
|
||||
return left.name === right.name && left.version === right.version
|
||||
}
|
||||
|
||||
function marketplaceInstalledState(
|
||||
extension: RuntimeExtensionInstalledState
|
||||
): RuntimeExtensionMarketplaceInstalledState {
|
||||
return {
|
||||
id: extension.id,
|
||||
package: extension.package,
|
||||
installedAt: extension.installedAt,
|
||||
enabled: extension.enabled,
|
||||
configuration: extension.configuration,
|
||||
...(extension.integrity
|
||||
? { integrity: extension.integrity }
|
||||
: {}),
|
||||
...(extension.lastError
|
||||
? { lastError: extension.lastError }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeExtensionStore {
|
||||
readonly managedRoot: string
|
||||
|
||||
private readonly statePath: string
|
||||
private state?: StoredState
|
||||
private stateLoad?: Promise<StoredState>
|
||||
private canonicalRoot?: string
|
||||
private mutationQueue: Promise<void> = Promise.resolve()
|
||||
private catalog: RuntimeExtensionCatalogEntry[] = []
|
||||
private catalogError?: string
|
||||
|
||||
constructor(
|
||||
userDataPath: string,
|
||||
private readonly dependencies: RuntimeExtensionStoreDependencies
|
||||
) {
|
||||
if (!isAbsolute(userDataPath)) {
|
||||
throw new Error('GoodBuddy userData path must be absolute')
|
||||
}
|
||||
this.managedRoot = resolve(userDataPath, managedDirectoryName)
|
||||
this.statePath = join(this.managedRoot, stateFileName)
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
|
||||
const state = await this.load()
|
||||
if (!state.marketplaceEnabled) {
|
||||
this.catalog = []
|
||||
this.catalogError = undefined
|
||||
return this.marketplaceSnapshot(state)
|
||||
}
|
||||
try {
|
||||
await this.loadCatalog()
|
||||
} catch (error) {
|
||||
this.catalog = []
|
||||
this.catalogError =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message.trim().slice(0, 1_000)
|
||||
: 'Extension catalog is unavailable.'
|
||||
}
|
||||
return this.marketplaceSnapshot(state)
|
||||
}
|
||||
|
||||
async apply(
|
||||
action: RuntimeExtensionAction
|
||||
): Promise<RuntimeExtensionMarketplaceSnapshot> {
|
||||
return (await this.applyWithResult(action)).snapshot
|
||||
}
|
||||
|
||||
async applyWithResult(
|
||||
action: RuntimeExtensionAction
|
||||
): Promise<RuntimeExtensionApplyResult> {
|
||||
const parsed = runtimeExtensionActionSchema.parse(action)
|
||||
const changed = await this.serialize(async () => {
|
||||
switch (parsed.type) {
|
||||
case 'set-marketplace-enabled':
|
||||
return this.setMarketplaceEnabled(parsed.enabled)
|
||||
case 'install':
|
||||
await this.install(parsed.extensionId, parsed.package)
|
||||
return true
|
||||
case 'set-enabled':
|
||||
return this.setEnabled(parsed.extensionId, parsed.enabled)
|
||||
case 'remove':
|
||||
await this.remove(parsed.extensionId)
|
||||
return true
|
||||
case 'configure':
|
||||
return this.configure(
|
||||
parsed.extensionId,
|
||||
parsed.configuration
|
||||
)
|
||||
}
|
||||
})
|
||||
return {
|
||||
snapshot: this.marketplaceSnapshot(await this.load()),
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
async getEnabledExtensions(): Promise<EnabledRuntimeExtension[]> {
|
||||
const state = await this.load()
|
||||
return state.installed
|
||||
.filter((extension) => extension.enabled)
|
||||
.sort(compareIds)
|
||||
.map(({ id, entrypoint, configuration }) => ({
|
||||
id,
|
||||
entrypoint,
|
||||
configuration
|
||||
}))
|
||||
}
|
||||
|
||||
markStartupFailed(ids: readonly string[]): Promise<void> {
|
||||
const parsedIds = z.array(runtimeExtensionIdSchema).parse(ids)
|
||||
return this.serialize(async () => {
|
||||
const failed = new Set(parsedIds)
|
||||
const state = await this.load()
|
||||
const installed = state.installed.map((extension) =>
|
||||
failed.has(extension.id)
|
||||
? {
|
||||
...extension,
|
||||
enabled: false,
|
||||
lastError: runtimeExtensionStartupFailureCode
|
||||
}
|
||||
: extension
|
||||
)
|
||||
if (
|
||||
installed.some(
|
||||
(extension, index) => extension !== state.installed[index]
|
||||
)
|
||||
) {
|
||||
await this.persistAndSet({ ...state, installed })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationQueue.then(operation)
|
||||
this.mutationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private load(): Promise<StoredState> {
|
||||
if (this.state) {
|
||||
return Promise.resolve(this.state)
|
||||
}
|
||||
if (!this.stateLoad) {
|
||||
this.stateLoad = this.readState().finally(() => {
|
||||
this.stateLoad = undefined
|
||||
})
|
||||
}
|
||||
return this.stateLoad
|
||||
}
|
||||
|
||||
private async readState(): Promise<StoredState> {
|
||||
await this.initialize()
|
||||
try {
|
||||
const status = await lstat(this.statePath)
|
||||
if (
|
||||
!status.isFile() ||
|
||||
status.isSymbolicLink() ||
|
||||
status.nlink > 1
|
||||
) {
|
||||
throw new Error('Extension store state must be a regular file')
|
||||
}
|
||||
await this.assertExistingPathContained(this.statePath)
|
||||
const stored = storedStateFileSchema.parse(
|
||||
JSON.parse(await readFile(this.statePath, 'utf8')) as unknown
|
||||
)
|
||||
const parsed: StoredState =
|
||||
stored.version === 1
|
||||
? {
|
||||
version: 2,
|
||||
marketplaceEnabled: stored.installed.length > 0,
|
||||
installed: stored.installed
|
||||
}
|
||||
: stored
|
||||
for (const extension of parsed.installed) {
|
||||
this.assertExtensionEntrypoint(extension)
|
||||
}
|
||||
if (stored.version === 1) {
|
||||
await this.persist(parsed)
|
||||
}
|
||||
this.state = parsed
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error
|
||||
}
|
||||
this.state = emptyState()
|
||||
await this.persist(this.state)
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
await mkdir(this.managedRoot, { recursive: true, mode: 0o700 })
|
||||
const status = await lstat(this.managedRoot)
|
||||
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||
throw new Error('Extension managed root must be a real directory')
|
||||
}
|
||||
this.canonicalRoot = await realpath(this.managedRoot)
|
||||
await this.createManagedDirectory('extensions')
|
||||
await this.createManagedDirectory('.staging')
|
||||
}
|
||||
|
||||
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
|
||||
const catalog = (await this.dependencies.catalog.list()).map((entry) =>
|
||||
runtimeExtensionCatalogEntrySchema.parse(entry)
|
||||
)
|
||||
const ids = new Set<string>()
|
||||
for (const entry of catalog) {
|
||||
if (ids.has(entry.id)) {
|
||||
throw new Error(`Duplicate extension catalog ID: ${entry.id}`)
|
||||
}
|
||||
ids.add(entry.id)
|
||||
}
|
||||
this.catalog = catalog.sort(compareIds)
|
||||
this.catalogError = undefined
|
||||
return this.catalog
|
||||
}
|
||||
|
||||
private async install(
|
||||
extensionId: string,
|
||||
requestedPackage: RuntimeExtensionExactPackage
|
||||
): Promise<void> {
|
||||
const state = await this.load()
|
||||
if (!state.marketplaceEnabled) {
|
||||
throw new Error('The DSH plugin marketplace is disabled')
|
||||
}
|
||||
const catalog = await this.loadCatalog()
|
||||
const entry = catalog.find(
|
||||
(candidate) =>
|
||||
candidate.id === extensionId &&
|
||||
packagesEqual(candidate.package, requestedPackage)
|
||||
)
|
||||
if (!entry) {
|
||||
throw new Error('The exact extension package is not in the catalog')
|
||||
}
|
||||
|
||||
const temporaryId =
|
||||
this.dependencies.temporaryId?.() ?? randomUUID()
|
||||
runtimeExtensionIdSchema.parse(temporaryId)
|
||||
const stagedDirectory = await this.createFreshManagedDirectory(
|
||||
'.staging',
|
||||
temporaryId
|
||||
)
|
||||
const backupDirectory = this.managedPath(
|
||||
'.staging',
|
||||
`${temporaryId}-previous`
|
||||
)
|
||||
const finalDirectory = this.extensionDirectory(extensionId)
|
||||
let previousMoved = false
|
||||
let stagedMoved = false
|
||||
try {
|
||||
const installedPackage = await this.dependencies.install({
|
||||
entry,
|
||||
destinationDirectory: stagedDirectory
|
||||
})
|
||||
await this.resolveEntrypoint(
|
||||
stagedDirectory,
|
||||
installedPackage.entrypoint
|
||||
)
|
||||
|
||||
if (await this.pathExists(finalDirectory)) {
|
||||
await rename(finalDirectory, backupDirectory)
|
||||
previousMoved = true
|
||||
}
|
||||
await rename(stagedDirectory, finalDirectory)
|
||||
stagedMoved = true
|
||||
const entrypoint = resolve(
|
||||
finalDirectory,
|
||||
installedPackage.entrypoint
|
||||
)
|
||||
const existing = state.installed.find(
|
||||
(extension) => extension.id === extensionId
|
||||
)
|
||||
const installed: RuntimeExtensionInstalledState = {
|
||||
id: extensionId,
|
||||
package: entry.package,
|
||||
entrypoint,
|
||||
installedAt: (
|
||||
this.dependencies.now?.() ?? new Date()
|
||||
).toISOString(),
|
||||
enabled: existing?.enabled ?? true,
|
||||
configuration: existing?.configuration ?? {},
|
||||
...(installedPackage.integrity
|
||||
? { integrity: installedPackage.integrity }
|
||||
: {})
|
||||
}
|
||||
await this.persistAndSet(
|
||||
this.replaceInstalled(state, installed)
|
||||
)
|
||||
if (previousMoved) {
|
||||
await this.removeManagedTree(backupDirectory).catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
if (stagedMoved) {
|
||||
await this.removeManagedTree(finalDirectory)
|
||||
}
|
||||
if (previousMoved) {
|
||||
await rename(backupDirectory, finalDirectory)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await this.removeManagedTree(stagedDirectory).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async setEnabled(
|
||||
extensionId: string,
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
const state = await this.load()
|
||||
const extension = this.requireInstalled(state, extensionId)
|
||||
if (
|
||||
extension.enabled === enabled &&
|
||||
(!enabled || !extension.lastError)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const updated = {
|
||||
...extension,
|
||||
enabled,
|
||||
...(enabled ? { lastError: undefined } : {})
|
||||
}
|
||||
await this.persistAndSet(this.replaceInstalled(state, updated))
|
||||
return true
|
||||
}
|
||||
|
||||
private async setMarketplaceEnabled(
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
const state = await this.load()
|
||||
if (state.marketplaceEnabled === enabled) {
|
||||
return false
|
||||
}
|
||||
await this.persistAndSet({
|
||||
...state,
|
||||
marketplaceEnabled: enabled
|
||||
})
|
||||
if (!enabled) {
|
||||
this.catalog = []
|
||||
this.catalogError = undefined
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private async configure(
|
||||
extensionId: string,
|
||||
configuration: RuntimeExtensionConfiguration
|
||||
): Promise<boolean> {
|
||||
const state = await this.load()
|
||||
const extension = this.requireInstalled(state, extensionId)
|
||||
if (isDeepStrictEqual(extension.configuration, configuration)) {
|
||||
return false
|
||||
}
|
||||
await this.persistAndSet(
|
||||
this.replaceInstalled(state, { ...extension, configuration })
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private marketplaceSnapshot(
|
||||
state: StoredState
|
||||
): RuntimeExtensionMarketplaceSnapshot {
|
||||
return {
|
||||
marketplaceEnabled: state.marketplaceEnabled,
|
||||
catalog: this.catalog,
|
||||
installed: [...state.installed]
|
||||
.sort(compareIds)
|
||||
.map(marketplaceInstalledState),
|
||||
...(this.catalogError
|
||||
? { catalogError: this.catalogError }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
private async remove(extensionId: string): Promise<void> {
|
||||
const state = await this.load()
|
||||
this.requireInstalled(state, extensionId)
|
||||
const finalDirectory = this.extensionDirectory(extensionId)
|
||||
const trashDirectory = this.managedPath(
|
||||
'.staging',
|
||||
`${randomUUID()}-removed`
|
||||
)
|
||||
let moved = false
|
||||
if (await this.pathExists(finalDirectory)) {
|
||||
await rename(finalDirectory, trashDirectory)
|
||||
moved = true
|
||||
}
|
||||
try {
|
||||
await this.persistAndSet({
|
||||
...state,
|
||||
installed: state.installed.filter(
|
||||
(extension) => extension.id !== extensionId
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
if (moved) {
|
||||
await rename(trashDirectory, finalDirectory)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (moved) {
|
||||
await this.removeManagedTree(trashDirectory).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private requireInstalled(
|
||||
state: StoredState,
|
||||
extensionId: string
|
||||
): RuntimeExtensionInstalledState {
|
||||
const extension = state.installed.find(
|
||||
(candidate) => candidate.id === extensionId
|
||||
)
|
||||
if (!extension) {
|
||||
throw new Error(`Extension is not installed: ${extensionId}`)
|
||||
}
|
||||
return extension
|
||||
}
|
||||
|
||||
private replaceInstalled(
|
||||
state: StoredState,
|
||||
extension: RuntimeExtensionInstalledState
|
||||
): StoredState {
|
||||
return storedStateSchema.parse({
|
||||
...state,
|
||||
installed: state.installed
|
||||
.filter((candidate) => candidate.id !== extension.id)
|
||||
.concat(extension)
|
||||
.sort(compareIds)
|
||||
})
|
||||
}
|
||||
|
||||
private async persistAndSet(state: StoredState): Promise<void> {
|
||||
await this.persist(state)
|
||||
this.state = state
|
||||
}
|
||||
|
||||
private persist(state: StoredState): Promise<void> {
|
||||
return writeJsonFileAtomically(
|
||||
this.statePath,
|
||||
storedStateSchema.parse(state)
|
||||
)
|
||||
}
|
||||
|
||||
private assertExtensionEntrypoint(
|
||||
extension: RuntimeExtensionInstalledState
|
||||
): void {
|
||||
if (!isAbsolute(extension.entrypoint)) {
|
||||
throw new Error('Installed extension entrypoint must be absolute')
|
||||
}
|
||||
const directory = this.extensionDirectory(extension.id)
|
||||
this.assertContained(directory, extension.entrypoint)
|
||||
}
|
||||
|
||||
private extensionDirectory(extensionId: string): string {
|
||||
runtimeExtensionIdSchema.parse(extensionId)
|
||||
return this.managedPath('extensions', extensionId)
|
||||
}
|
||||
|
||||
private managedPath(...segments: string[]): string {
|
||||
const path = resolve(this.managedRoot, ...segments)
|
||||
this.assertContained(this.managedRoot, path)
|
||||
return path
|
||||
}
|
||||
|
||||
private assertContained(root: string, path: string): void {
|
||||
const relativePath = relative(root, path)
|
||||
if (
|
||||
relativePath === '' ||
|
||||
(!relativePath.startsWith(`..${sep}`) &&
|
||||
relativePath !== '..' &&
|
||||
!isAbsolute(relativePath))
|
||||
) {
|
||||
return
|
||||
}
|
||||
throw new Error('Extension path escapes the managed root')
|
||||
}
|
||||
|
||||
private async assertExistingPathContained(path: string): Promise<void> {
|
||||
this.assertContained(this.managedRoot, path)
|
||||
const root = this.canonicalRoot ?? (await realpath(this.managedRoot))
|
||||
this.assertContained(root, await realpath(path))
|
||||
}
|
||||
|
||||
private async createManagedDirectory(
|
||||
...segments: string[]
|
||||
): Promise<string> {
|
||||
let directory = this.managedRoot
|
||||
await this.assertExistingPathContained(directory)
|
||||
for (const segment of segments) {
|
||||
directory = join(directory, segment)
|
||||
this.assertContained(this.managedRoot, directory)
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const status = await lstat(directory)
|
||||
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||
throw new Error('Extension managed path must be a real directory')
|
||||
}
|
||||
await this.assertExistingPathContained(directory)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
private async createFreshManagedDirectory(
|
||||
...segments: string[]
|
||||
): Promise<string> {
|
||||
const leaf = segments.at(-1)
|
||||
if (!leaf) {
|
||||
throw new Error('A managed directory name is required')
|
||||
}
|
||||
const parent = await this.createManagedDirectory(...segments.slice(0, -1))
|
||||
const directory = join(parent, leaf)
|
||||
this.assertContained(this.managedRoot, directory)
|
||||
await mkdir(directory, { mode: 0o700 })
|
||||
await this.assertExistingPathContained(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
private async pathExists(path: string): Promise<boolean> {
|
||||
this.assertContained(this.managedRoot, path)
|
||||
try {
|
||||
await lstat(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveEntrypoint(
|
||||
root: string,
|
||||
relativeEntrypoint: string
|
||||
): Promise<string> {
|
||||
if (
|
||||
!relativeEntrypoint ||
|
||||
relativeEntrypoint.includes('\\') ||
|
||||
relativeEntrypoint.startsWith('/') ||
|
||||
/^[A-Za-z]:/u.test(relativeEntrypoint) ||
|
||||
relativeEntrypoint
|
||||
.split('/')
|
||||
.some((part) => part === '' || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(
|
||||
'Extension installer returned an invalid entrypoint'
|
||||
)
|
||||
}
|
||||
const entrypoint = resolve(root, relativeEntrypoint)
|
||||
this.assertContained(root, entrypoint)
|
||||
const [canonicalRoot, canonicalEntrypoint] = await Promise.all([
|
||||
realpath(root),
|
||||
realpath(entrypoint)
|
||||
])
|
||||
this.assertContained(canonicalRoot, canonicalEntrypoint)
|
||||
const status = await lstat(canonicalEntrypoint)
|
||||
if (!status.isFile()) {
|
||||
throw new Error('Extension entrypoint is not a regular file')
|
||||
}
|
||||
return canonicalEntrypoint
|
||||
}
|
||||
|
||||
private async removeManagedTree(path: string): Promise<void> {
|
||||
this.assertContained(this.managedRoot, path)
|
||||
let status
|
||||
try {
|
||||
status = await lstat(path)
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (status.isDirectory() && !status.isSymbolicLink()) {
|
||||
for (const entry of await readdir(path)) {
|
||||
await this.removeManagedTree(join(path, entry))
|
||||
}
|
||||
await rmdir(path)
|
||||
} else {
|
||||
await unlink(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,7 +630,7 @@ describe('CapabilityService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('allows Harness MCP assignment and rejects unsupported Agent Runtimes', async () => {
|
||||
it('allows MCP assignment to every supported runtime', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
@@ -661,16 +661,28 @@ describe('CapabilityService', () => {
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['opencode'],
|
||||
assignments: ['opencode', 'continue'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).rejects.toThrow('只能分配给直连模型或 DeepSeek Harness')
|
||||
).resolves.toMatchObject({
|
||||
mcpServers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
assignments: ['opencode', 'continue']
|
||||
})
|
||||
])
|
||||
})
|
||||
await expect(
|
||||
service.getResolvedMcpServers('opencode')
|
||||
).resolves.toHaveLength(1)
|
||||
await expect(
|
||||
service.getResolvedMcpServers('continue')
|
||||
).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
||||
it('preserves stored OpenCode MCP assignments', async () => {
|
||||
const { filePath, builtinRoot, importedRoot } = await createService()
|
||||
await writeFile(
|
||||
filePath,
|
||||
@@ -701,14 +713,14 @@ describe('CapabilityService', () => {
|
||||
|
||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({ assignments: ['model'] })
|
||||
expect.objectContaining({ assignments: ['opencode'] })
|
||||
]
|
||||
})
|
||||
expect(await readFile(filePath, 'utf8')).toContain(
|
||||
'"assignments": [\n "model"'
|
||||
'"assignments": [\n "opencode"'
|
||||
)
|
||||
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
|
||||
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
|
||||
await expect(service.getResolvedMcpServers('opencode')).resolves.toHaveLength(1)
|
||||
await expect(service.getResolvedMcpServers('model')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('migrates v1 to v3 without losing skills, MCP configuration, or encrypted secrets', async () => {
|
||||
|
||||
@@ -789,23 +789,9 @@ export class CapabilityService {
|
||||
shouldPersist = true
|
||||
}
|
||||
}
|
||||
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
||||
server.assignments.includes('opencode')
|
||||
)
|
||||
const migrated = migrateMcpAssignments
|
||||
? {
|
||||
...loaded,
|
||||
mcpServers: loaded.mcpServers.map((server) => ({
|
||||
...server,
|
||||
assignments: server.assignments.includes('opencode')
|
||||
? (['model'] as CapabilityAssignments)
|
||||
: server.assignments
|
||||
}))
|
||||
}
|
||||
: loaded
|
||||
this.state = storedCapabilitiesSchema.parse(migrated)
|
||||
this.state = storedCapabilitiesSchema.parse(loaded)
|
||||
await this.validateBrowserProfileReferences(this.state)
|
||||
if (shouldPersist || migrateMcpAssignments) {
|
||||
if (shouldPersist) {
|
||||
await this.persist(this.state)
|
||||
}
|
||||
return this.state
|
||||
@@ -1609,17 +1595,6 @@ export class CapabilityService {
|
||||
): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const value = mcpServerInputSchema.parse(input)
|
||||
if (
|
||||
value.assignments.some(
|
||||
(assignment) =>
|
||||
assignment !== 'model' &&
|
||||
assignment !== 'deepseek-harness'
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'当前版本的 MCP Server 只能分配给直连模型或 DeepSeek Harness'
|
||||
)
|
||||
}
|
||||
const state = await this.load()
|
||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||
const existing = state.mcpServers.find((server) => server.id === id)
|
||||
@@ -1818,9 +1793,6 @@ export class CapabilityService {
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
if (target !== 'model' && target !== 'deepseek-harness') {
|
||||
return []
|
||||
}
|
||||
const state = await this.load()
|
||||
const assigned = state.mcpServers.filter(
|
||||
(server) => server.enabled && server.assignments.includes(target)
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
parseHarnessControlMessage,
|
||||
type DeepSeekHarnessControlMessage
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
} from './agent/deepseek-harness-control-protocol'
|
||||
import { createDeepSeekHarnessHostTransport } from './agent/deepseek-harness-utility-transport'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
@@ -85,7 +85,8 @@ parentPort.on('message', (event) => {
|
||||
post({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
type: 'ready',
|
||||
failedExtensionIds: host.failedExtensionIds
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -147,6 +147,51 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('reports extension startup failures without failing the Host', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
|
||||
)
|
||||
const brokenEntrypoint = join(root, 'broken.mjs')
|
||||
await writeFile(
|
||||
brokenEntrypoint,
|
||||
'export const value = 1\n',
|
||||
'utf8'
|
||||
)
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: root,
|
||||
dshHome: root,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [
|
||||
{
|
||||
id: 'broken',
|
||||
entrypoint: brokenEntrypoint,
|
||||
configuration: {}
|
||||
}
|
||||
],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
expect(host.failedExtensionIds).toEqual(['broken'])
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('loads only explicitly supplied Skill packages into a session scope', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
createBoundedAcpStream,
|
||||
type GoodBuddyHarnessControlConfig
|
||||
} from './agent/goodbuddy-harness-control-plane'
|
||||
import {
|
||||
loadControlledHarnessExtensions,
|
||||
type ControlledHarnessExtensionPackage
|
||||
} from './agent/deepseek-harness-extension-loader'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility'
|
||||
|
||||
@@ -45,11 +49,17 @@ export type ControlledHarnessHostConfig = Omit<
|
||||
id: string
|
||||
directory: string
|
||||
}[]
|
||||
extensionPackages?: readonly ControlledHarnessExtensionPackage[]
|
||||
}
|
||||
|
||||
export type ControlledHarnessHost = {
|
||||
readonly context: Context
|
||||
readonly controlPlane: GoodBuddyHarnessControlPlane
|
||||
readonly failedExtensionIds: readonly string[]
|
||||
readonly extensionFailures: readonly {
|
||||
id: string
|
||||
message: string
|
||||
}[]
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -123,7 +133,25 @@ async function canonicalizeHostConfig(
|
||||
return { ...skill, directory }
|
||||
})
|
||||
)
|
||||
return { ...config, workspace, dshHome, skillPackages }
|
||||
const extensionPackages = await Promise.all(
|
||||
(config.extensionPackages ?? []).map(async (extension) => {
|
||||
const entrypoint = await realpath(extension.entrypoint)
|
||||
const metadata = await stat(entrypoint)
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(
|
||||
'Controlled Harness extension entrypoint must be a file'
|
||||
)
|
||||
}
|
||||
return { ...extension, entrypoint }
|
||||
})
|
||||
)
|
||||
return {
|
||||
...config,
|
||||
workspace,
|
||||
dshHome,
|
||||
skillPackages,
|
||||
extensionPackages
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControlledSkills(
|
||||
@@ -175,9 +203,10 @@ async function loadControlledSkills(
|
||||
/**
|
||||
* Boots a fixed, programmatic Cordis graph. It never imports app-boot, a
|
||||
* profile loader, settings-file, local credentials, persistence, telemetry,
|
||||
* web, HMR, marketplace/plugin discovery, direct MCP clients, jobs,
|
||||
* subagents, hooks, or workflow packages. The control plane registers only
|
||||
* Main-selected Skill snapshots and Main-mediated MCP tool proxies.
|
||||
* web, HMR, marketplace discovery, direct MCP clients, jobs, subagents,
|
||||
* hooks, or workflow packages. The control plane registers Main-selected
|
||||
* Skill snapshots, Main-mediated MCP tool proxies, and explicitly enabled
|
||||
* extension entrypoints.
|
||||
*/
|
||||
export async function startControlledDeepSeekHarnessHost(
|
||||
input: ControlledHarnessHostConfig
|
||||
@@ -256,6 +285,10 @@ export async function startControlledDeepSeekHarnessHost(
|
||||
)
|
||||
}
|
||||
await Promise.all(fibers)
|
||||
const extensions = await loadControlledHarnessExtensions(
|
||||
ctx,
|
||||
config.extensionPackages ?? []
|
||||
)
|
||||
const credentialProvider = ctx.credentials
|
||||
if (!(credentialProvider instanceof GoodBuddyCredentialProvider)) {
|
||||
throw new Error(
|
||||
@@ -284,6 +317,8 @@ export async function startControlledDeepSeekHarnessHost(
|
||||
return {
|
||||
context: ctx,
|
||||
controlPlane,
|
||||
failedExtensionIds: extensions.failedIds,
|
||||
extensionFailures: extensions.failures,
|
||||
async dispose() {
|
||||
await controlPlane.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startControlledDeepSeekHarnessHost } from './deepseek-harness-host'
|
||||
|
||||
const entrypoint =
|
||||
process.env.GOODBUDDY_DSH_PLUGIN_ENTRYPOINT?.trim()
|
||||
|
||||
describe.skipIf(!entrypoint)(
|
||||
'controlled DeepSeek Harness third-party plugin',
|
||||
() => {
|
||||
it('loads and executes a real marketplace tool with full Execute capability', async () => {
|
||||
const workspace = await realpath(
|
||||
await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-harness-marketplace-e2e-')
|
||||
)
|
||||
)
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace,
|
||||
dshHome: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [
|
||||
{
|
||||
id: 'marketplace-e2e',
|
||||
entrypoint: entrypoint!,
|
||||
configuration: {}
|
||||
}
|
||||
],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
expect(host.extensionFailures).toEqual([])
|
||||
expect(
|
||||
host.context.tools.schemas().map((tool) => tool.name)
|
||||
).toContain('greet')
|
||||
await expect(
|
||||
host.context.tools.execute({
|
||||
callId: 'marketplace-greet',
|
||||
name: 'greet',
|
||||
arguments: { name: 'Ada' },
|
||||
signal: new AbortController().signal
|
||||
} as never)
|
||||
).resolves.toMatchObject({
|
||||
isError: false,
|
||||
value: 'Hello, Ada!'
|
||||
})
|
||||
await host.dispose()
|
||||
})
|
||||
}
|
||||
)
|
||||
+43
-7
@@ -83,6 +83,11 @@ import {
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||
import { runStartupPrerequisites } from './startup-prerequisites'
|
||||
import { RuntimeExtensionStore } from './agent/runtime-extension-store'
|
||||
import {
|
||||
DshNpmExtensionInstaller,
|
||||
DshNpmMarketplaceCatalog
|
||||
} from './agent/dsh-extension-marketplace'
|
||||
|
||||
const shortcut = 'CommandOrControl+Shift+Space'
|
||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -447,6 +452,31 @@ if (hasSingleInstanceLock) {
|
||||
app.getPath('userData'),
|
||||
'deepseek-harness'
|
||||
)
|
||||
const dshExtensionInstaller = new DshNpmExtensionInstaller({
|
||||
dshHome: deepSeekHarnessHome,
|
||||
npmCliPath: app.isPackaged
|
||||
? join(
|
||||
process.resourcesPath,
|
||||
'runtimes',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
: join(
|
||||
app.getAppPath(),
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
})
|
||||
const runtimeExtensionStore = new RuntimeExtensionStore(
|
||||
app.getPath('userData'),
|
||||
{
|
||||
catalog: new DshNpmMarketplaceCatalog(),
|
||||
install: (input) => dshExtensionInstaller.install(input)
|
||||
}
|
||||
)
|
||||
const launchDeepSeekHarness =
|
||||
createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||
@@ -455,7 +485,9 @@ if (hasSingleInstanceLock) {
|
||||
deepSeekHarnessHome
|
||||
),
|
||||
fork: forkDeepSeekHarness,
|
||||
terminateProcess: terminateHarnessUtilityProcess
|
||||
terminateProcess: terminateHarnessUtilityProcess,
|
||||
onExtensionStartupFailures: (extensionIds) =>
|
||||
runtimeExtensionStore.markStartupFailed(extensionIds)
|
||||
})
|
||||
const startupKnowledgeService = new KnowledgeService({
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
@@ -488,13 +520,12 @@ if (hasSingleInstanceLock) {
|
||||
skillContext,
|
||||
mcpServers,
|
||||
browserCapability,
|
||||
webSearchCapability
|
||||
webSearchCapability,
|
||||
deepseekHarnessExtensions
|
||||
] =
|
||||
await Promise.all([
|
||||
capabilityService.getRuntimeSkillContext(target),
|
||||
target === 'model' || target === 'deepseek-harness'
|
||||
? capabilityService.getResolvedMcpServers(target)
|
||||
: Promise.resolve([]),
|
||||
capabilityService.getResolvedMcpServers(target),
|
||||
target === 'model'
|
||||
? capabilityService.getComputerCapabilityStatus(
|
||||
'host-browser-control'
|
||||
@@ -502,7 +533,10 @@ if (hasSingleInstanceLock) {
|
||||
: Promise.resolve(undefined),
|
||||
target === 'model'
|
||||
? capabilityService.getWebSearchCapabilityStatus()
|
||||
: Promise.resolve(undefined)
|
||||
: Promise.resolve(undefined),
|
||||
target === 'deepseek-harness'
|
||||
? runtimeExtensionStore.getEnabledExtensions()
|
||||
: Promise.resolve([])
|
||||
])
|
||||
return createAgentRuntime(defaultWorkspace, settings, {
|
||||
skillInstructions: skillContext.instructions,
|
||||
@@ -515,6 +549,7 @@ if (hasSingleInstanceLock) {
|
||||
bundledRuntimePaths,
|
||||
continueHostLauncher: launchContinueHost,
|
||||
deepseekHarnessLauncher: launchDeepSeekHarness,
|
||||
deepseekHarnessExtensions,
|
||||
browserService:
|
||||
browserCapability?.enabled && browserCapability.supported
|
||||
? browserService
|
||||
@@ -674,7 +709,8 @@ if (hasSingleInstanceLock) {
|
||||
documentOcrModelManager,
|
||||
documentOcrBroker,
|
||||
releaseNotesService,
|
||||
goodbuddyConfigService
|
||||
goodbuddyConfigService,
|
||||
runtimeExtensionStore
|
||||
)
|
||||
loadMainWindow(mainWindow)
|
||||
|
||||
|
||||
@@ -357,6 +357,149 @@ vi.mock('./channels/channel-env', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
describe('registerIpcHandlers DSH runtime extensions', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
const snapshot = {
|
||||
marketplaceEnabled: true,
|
||||
catalog: [],
|
||||
installed: []
|
||||
}
|
||||
const runtimeExtensionStore = {
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
applyWithResult: vi.fn(async () => ({
|
||||
snapshot,
|
||||
changed: true
|
||||
}))
|
||||
}
|
||||
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
onRuntimeSettingsChanged,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
runtimeExtensionStore as never
|
||||
)
|
||||
const event = {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
const action = {
|
||||
type: 'set-enabled',
|
||||
extensionId: 'dsh-plugin-greet',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsSnapshot
|
||||
)?.(event)
|
||||
).resolves.toEqual(snapshot)
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsApply
|
||||
)?.(event, action)
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(
|
||||
runtimeExtensionStore.applyWithResult
|
||||
).toHaveBeenCalledWith(action)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
const marketplaceAction = {
|
||||
type: 'set-marketplace-enabled',
|
||||
enabled: false
|
||||
}
|
||||
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
marketplaceEnabled: false
|
||||
},
|
||||
changed: true
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsApply
|
||||
)?.(event, marketplaceAction)
|
||||
).resolves.toEqual({
|
||||
...snapshot,
|
||||
marketplaceEnabled: false
|
||||
})
|
||||
expect(
|
||||
runtimeExtensionStore.applyWithResult
|
||||
).toHaveBeenLastCalledWith(marketplaceAction)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
||||
snapshot,
|
||||
changed: false
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsApply
|
||||
)?.(event, action)
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsApply
|
||||
)?.(event, {
|
||||
...action,
|
||||
extensionId: 'Invalid Extension'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.runtimeExtensionsSnapshot
|
||||
)?.({
|
||||
sender: {},
|
||||
senderFrame: webContents.mainFrame
|
||||
})
|
||||
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerIpcHandlers lifecycle tracking', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
|
||||
+41
-1
@@ -177,6 +177,11 @@ import {
|
||||
import type { CapabilityService } from './capabilities/capability-service'
|
||||
import { testMcpServer } from './capabilities/mcp-tester'
|
||||
import { testWebSearch } from './capabilities/web-search-tester'
|
||||
import {
|
||||
runtimeExtensionActionSchema,
|
||||
type RuntimeExtensionMarketplaceSnapshot
|
||||
} from '../shared/runtime-extension-contracts'
|
||||
import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
|
||||
import type { ContextManager } from './context-manager'
|
||||
import type { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import {
|
||||
@@ -831,7 +836,8 @@ export function registerIpcHandlers(
|
||||
documentOcrModelManager?: DocumentOcrModelManager,
|
||||
documentOcrBroker?: DocumentOcrBroker,
|
||||
releaseNotesService?: ReleaseNotesService,
|
||||
goodbuddyConfigService?: GoodBuddyConfigService
|
||||
goodbuddyConfigService?: GoodBuddyConfigService,
|
||||
runtimeExtensionStore?: RuntimeExtensionStore
|
||||
): () => Promise<void> {
|
||||
const activeRequests = new Map<string, AbortController>()
|
||||
const activeEventBuffers = new Map<string, { flush(): void }>()
|
||||
@@ -4030,6 +4036,40 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.runtimeExtensionsSnapshot,
|
||||
(event): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!runtimeExtensionStore) {
|
||||
throw new Error('DSH 插件市场不可用')
|
||||
}
|
||||
return runtimeExtensionStore.getSnapshot()
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.runtimeExtensionsApply,
|
||||
async (
|
||||
event,
|
||||
input: unknown
|
||||
): Promise<RuntimeExtensionMarketplaceSnapshot> => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!runtimeExtensionStore) {
|
||||
throw new Error('DSH 插件市场不可用')
|
||||
}
|
||||
const action = runtimeExtensionActionSchema.parse(input)
|
||||
const result =
|
||||
await runtimeExtensionStore.applyWithResult(action)
|
||||
if (
|
||||
result.changed &&
|
||||
action.type !== 'set-marketplace-enabled'
|
||||
) {
|
||||
await onRuntimeSettingsChanged()
|
||||
}
|
||||
return result.snapshot
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.capabilitiesImportSkill,
|
||||
async (event, input: unknown): Promise<CapabilitySnapshot> => {
|
||||
|
||||
Reference in New Issue
Block a user