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:
mesalogo
2026-08-16 11:47:11 +08:00
parent 9e6f664e06
commit ff61b5f81d
67 changed files with 9337 additions and 443 deletions
+61 -2
View File
@@ -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')
+45 -10
View File
@@ -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
}
: {})
})
+79
View File
@@ -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({
+43 -1
View File
@@ -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 未返回内容')
+7 -2
View File
@@ -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
})
}
+224 -7
View File
@@ -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',
+5 -1
View File
@@ -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()
})
})
+717
View File
@@ -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()
}
})
})
+538 -43
View File
@@ -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
+63
View File
@@ -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' }
}
+8 -51
View File
@@ -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}」返回了无效工具名称`)
+141
View File
@@ -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([
{
+67 -1
View File
@@ -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)
}
}
}
+216 -7
View File
@@ -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([])
})
})
+689
View File
@@ -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 () => {
+2 -30
View File
@@ -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 -2
View File
@@ -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) => {
+45
View File
@@ -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-'))
+39 -4
View File
@@ -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
View File
@@ -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)
+143
View File
@@ -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
View File
@@ -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> => {
+15
View File
@@ -114,6 +114,10 @@ import type {
KnowledgeRetrieveInput,
KnowledgeSettingsUpdateInput
} from '../shared/knowledge-contracts'
import type {
RuntimeExtensionAction,
RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts'
const desktopApi: DesktopApi = {
app: {
@@ -861,6 +865,17 @@ const desktopApi: DesktopApi = {
profileId
}) as Promise<CapabilitySnapshot>
},
runtimeExtensions: {
getSnapshot: () =>
ipcRenderer.invoke(
ipcChannels.runtimeExtensionsSnapshot
) as Promise<RuntimeExtensionMarketplaceSnapshot>,
apply: (action: RuntimeExtensionAction) =>
ipcRenderer.invoke(
ipcChannels.runtimeExtensionsApply,
action
) as Promise<RuntimeExtensionMarketplaceSnapshot>
},
context: {
selectFiles: () =>
ipcRenderer.invoke(
+12
View File
@@ -482,6 +482,18 @@ const api: DesktopApi = {
tools: []
}))
},
runtimeExtensions: {
getSnapshot: vi.fn(async () => ({
marketplaceEnabled: false,
catalog: [],
installed: []
})),
apply: vi.fn(async () => ({
marketplaceEnabled: false,
catalog: [],
installed: []
}))
},
context: {
selectFiles: vi.fn(async () => []),
onFileSelectionProgress: vi.fn((listener) => {
@@ -0,0 +1,389 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi } from '../../shared/contracts'
import type {
RuntimeExtensionCatalogEntry,
RuntimeExtensionMarketplaceInstalledState,
RuntimeExtensionMarketplaceSnapshot
} from '../../shared/runtime-extension-contracts'
import { DshMarketplaceSection } from './DshMarketplaceSection'
const greet: RuntimeExtensionCatalogEntry = {
id: 'dsh-plugin-greet',
package: {
name: 'dsh-plugin-greet',
version: '0.1.0'
},
displayName: 'Greet',
description: 'A deterministic greeting tool.',
license: 'MIT'
}
const finder: RuntimeExtensionCatalogEntry = {
id: 'dsh-find-plugin',
package: {
name: 'dsh-find-plugin',
version: '0.3.6'
},
displayName: 'Plugin Finder',
description: 'Find DSH plugins.',
license: 'MIT'
}
const installedGreet: RuntimeExtensionMarketplaceInstalledState = {
id: greet.id,
package: greet.package,
installedAt: '2026-08-16T00:00:00.000Z',
enabled: true,
configuration: {}
}
function marketplaceSnapshot(
installed: RuntimeExtensionMarketplaceInstalledState[] = [],
marketplaceEnabled = true
): RuntimeExtensionMarketplaceSnapshot {
return {
marketplaceEnabled,
catalog: [greet, finder],
installed
}
}
let getSnapshot: ReturnType<typeof vi.fn>
let apply: ReturnType<typeof vi.fn>
beforeEach(() => {
getSnapshot = vi.fn(async () => marketplaceSnapshot())
apply = vi.fn(async () => marketplaceSnapshot([installedGreet]))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
runtimeExtensions: {
getSnapshot,
apply
}
} as unknown as DesktopApi
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('DshMarketplaceSection', () => {
it('starts off, loads no catalog UI, and can be enabled explicitly', async () => {
const disabledSnapshot = {
...marketplaceSnapshot([], false),
catalog: []
}
getSnapshot
.mockResolvedValueOnce(disabledSnapshot)
.mockResolvedValueOnce(marketplaceSnapshot())
apply
.mockResolvedValueOnce(marketplaceSnapshot())
.mockResolvedValueOnce(disabledSnapshot)
render(<DshMarketplaceSection onNotify={vi.fn()} />)
const marketplaceSwitch = await screen.findByRole('switch', {
name: '启用 DSH 插件市场'
})
expect(marketplaceSwitch).not.toBeChecked()
expect(
screen.getByText(/插件市场默认关闭/)
).toBeInTheDocument()
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
fireEvent.click(marketplaceSwitch)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
type: 'set-marketplace-enabled',
enabled: true
})
)
expect(await screen.findByRole('searchbox')).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledTimes(2)
fireEvent.click(
screen.getByRole('switch', {
name: '启用 DSH 插件市场'
})
)
await waitFor(() =>
expect(apply).toHaveBeenLastCalledWith({
type: 'set-marketplace-enabled',
enabled: false
})
)
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
expect(
screen.getByText(/不会停用或卸载已有插件/)
).toBeInTheDocument()
})
it('loads the catalog, filters locally, and shows startup failures', async () => {
getSnapshot.mockResolvedValueOnce(
marketplaceSnapshot([
{
...installedGreet,
enabled: false,
lastError: 'Extension failed to start.'
}
])
)
render(<DshMarketplaceSection onNotify={vi.fn()} />)
expect(
await screen.findByRole('heading', {
name: 'DSH 插件市场'
})
).toBeInTheDocument()
expect(
screen.getByText(/第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行/)
).toBeInTheDocument()
expect(screen.getByText('Greet')).toBeInTheDocument()
expect(screen.getByText('Plugin Finder')).toBeInTheDocument()
expect(
screen.getByText(/插件上次启动失败,已自动停用/)
).toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), {
target: { value: 'finder' }
})
expect(screen.queryByText('Greet')).not.toBeInTheDocument()
expect(screen.getByText('Plugin Finder')).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledOnce()
})
it('requires explicit current-user permission confirmation before install', async () => {
const onNotify = vi.fn()
render(<DshMarketplaceSection onNotify={onNotify} />)
await screen.findByText('Greet')
fireEvent.click(
screen.getAllByRole('button', {
name: '安装并启用'
})[0]!
)
const confirm = screen.getByRole('button', {
name: '确认安装'
})
expect(confirm).toBeDisabled()
expect(
screen.getByText(/npm 会运行该包及其依赖声明的安装脚本/)
).toBeInTheDocument()
fireEvent.click(
screen.getByLabelText(
'我信任 dsh-plugin-greet@0.1.0,并了解其代码将以当前用户权限运行。'
)
)
fireEvent.click(
screen.getByRole('button', {
name: '刷新 DSH 插件市场'
})
)
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(2))
expect(
screen.queryByRole('button', {
name: '确认安装'
})
).not.toBeInTheDocument()
fireEvent.click(
screen.getAllByRole('button', {
name: '安装并启用'
})[0]!
)
const refreshedConfirm = screen.getByRole('button', {
name: '确认安装'
})
expect(refreshedConfirm).toBeDisabled()
fireEvent.click(
screen.getByLabelText(
'我信任 dsh-plugin-greet@0.1.0,并了解其代码将以当前用户权限运行。'
)
)
fireEvent.click(refreshedConfirm)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
type: 'install',
extensionId: greet.id,
package: greet.package
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '已安装并启用 Greet'
})
)
})
it('toggles, configures, and removes installed plugins', async () => {
getSnapshot.mockResolvedValueOnce(
marketplaceSnapshot([installedGreet])
)
apply.mockImplementation(async (action) => {
if (action.type === 'remove') {
return marketplaceSnapshot()
}
return marketplaceSnapshot([
{
...installedGreet,
enabled:
action.type === 'set-enabled'
? action.enabled
: installedGreet.enabled,
configuration:
action.type === 'configure'
? action.configuration
: installedGreet.configuration
}
])
})
render(<DshMarketplaceSection onNotify={vi.fn()} />)
const toggle = await screen.findByRole('switch', {
name: '启用 Greet'
})
fireEvent.click(toggle)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
type: 'set-enabled',
extensionId: greet.id,
enabled: false
})
)
fireEvent.click(screen.getByRole('button', { name: '配置' }))
const editor = screen.getByRole('textbox', {
name: 'Greet 配置 JSON'
})
fireEvent.change(editor, { target: { value: '[]' } })
fireEvent.click(
screen.getByRole('button', { name: '保存配置' })
)
expect(
screen.getByText('配置必须是有效的 JSON 对象。')
).toBeInTheDocument()
fireEvent.change(editor, {
target: { value: '{"salutation":"你好"}' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存配置' })
)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
type: 'configure',
extensionId: greet.id,
configuration: { salutation: '你好' }
})
)
fireEvent.click(
screen.getByRole('button', { name: '移除 Greet' })
)
expect(
screen.getByRole('alertdialog', { name: '移除 Greet' })
).toHaveAccessibleDescription(
'移除 Greet 及其由 GoodBuddy 托管的文件?'
)
fireEvent.click(
screen.getByRole('button', { name: '移除 Greet' })
)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
type: 'remove',
extensionId: greet.id
})
)
})
it('keeps actions responsive through the Strict Mode effect cycle', async () => {
const onNotify = vi.fn()
getSnapshot.mockResolvedValue(
marketplaceSnapshot([installedGreet])
)
apply.mockResolvedValue(
marketplaceSnapshot([
{ ...installedGreet, enabled: false }
])
)
render(
<StrictMode>
<DshMarketplaceSection onNotify={onNotify} />
</StrictMode>
)
fireEvent.click(
await screen.findByRole('switch', {
name: '启用 Greet'
})
)
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success'
})
)
)
expect(
screen.getByRole('switch', { name: '启用 Greet' })
).not.toBeDisabled()
})
it('keeps a failed catalog load recoverable', async () => {
getSnapshot
.mockRejectedValueOnce(new Error('npm registry unavailable'))
.mockResolvedValueOnce(marketplaceSnapshot())
render(<DshMarketplaceSection onNotify={vi.fn()} />)
expect(
await screen.findByRole('alert')
).toHaveTextContent('npm registry unavailable')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('Greet')).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledTimes(2)
})
it('keeps installed plugins manageable when npm catalog refresh fails', async () => {
getSnapshot.mockResolvedValueOnce({
marketplaceEnabled: true,
catalog: [],
installed: [installedGreet],
catalogError: 'npm registry unavailable'
})
render(<DshMarketplaceSection onNotify={vi.fn()} />)
expect(
await screen.findByRole('alert')
).toHaveTextContent(
'无法刷新 npm 插件目录:npm registry unavailable。已安装插件仍可管理。'
)
expect(
screen.getByRole('switch', { name: '启用 dsh-plugin-greet' })
).toBeChecked()
expect(
screen.getByRole('button', {
name: '移除 dsh-plugin-greet'
})
).toBeEnabled()
})
})
+824
View File
@@ -0,0 +1,824 @@
import {
Package,
RefreshCw,
Search,
Settings2,
Trash2
} from 'lucide-react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import {
legacyRuntimeExtensionStartupFailure,
runtimeExtensionConfigurationSchema,
runtimeExtensionStartupFailureCode,
type RuntimeExtensionAction,
type RuntimeExtensionCatalogEntry,
type RuntimeExtensionConfiguration,
type RuntimeExtensionMarketplaceInstalledState,
type RuntimeExtensionMarketplaceSnapshot
} from '../../shared/runtime-extension-contracts'
import type { AppNotificationInput } from './notifications'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
const maximumVisibleEntries = 40
type DshMarketplaceSectionProps = {
onNotify: (notification: AppNotificationInput) => void
}
function packagesMatch(
catalog: RuntimeExtensionCatalogEntry,
installed: RuntimeExtensionMarketplaceInstalledState
): boolean {
return (
catalog.package.name === installed.package.name &&
catalog.package.version === installed.package.version
)
}
function packageLabel(entry: RuntimeExtensionCatalogEntry): string {
return `${entry.package.name}@${entry.package.version}`
}
function installConfirmationIdentity(
entry: RuntimeExtensionCatalogEntry
): string {
return `${entry.id}:${packageLabel(entry)}`
}
function actionIdentity(action: RuntimeExtensionAction): string {
return action.type === 'set-marketplace-enabled'
? 'marketplace'
: action.extensionId
}
export function DshMarketplaceSection({
onNotify
}: DshMarketplaceSectionProps): React.JSX.Element {
const { t } = useTranslation('settings')
const [snapshot, setSnapshot] =
useState<RuntimeExtensionMarketplaceSnapshot>()
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string>()
const [busy, setBusy] = useState<string>()
const [query, setQuery] = useState('')
const [confirmingInstall, setConfirmingInstall] = useState<string>()
const [installConfirmed, setInstallConfirmed] = useState(false)
const [confirmingRemove, setConfirmingRemove] = useState<string>()
const [configuring, setConfiguring] = useState<string>()
const [configurationDraft, setConfigurationDraft] = useState('')
const [configurationError, setConfigurationError] = useState<string>()
const mountedRef = useRef(true)
const installConfirmationRef = useRef<HTMLInputElement>(null)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const readSnapshot = useCallback((): Promise<RuntimeExtensionMarketplaceSnapshot> => {
const api = window.goodbuddy.runtimeExtensions
return api
? api.getSnapshot()
: Promise.reject(
new Error(
t(
'runtime.deepseekHarness.marketplace.errors.unavailable'
)
)
)
}, [t])
const load = useCallback(async (): Promise<void> => {
setConfirmingInstall(undefined)
setInstallConfirmed(false)
setLoading(true)
setLoadError(undefined)
try {
const next = await readSnapshot()
if (mountedRef.current) {
setSnapshot(next)
}
} catch (reason) {
if (mountedRef.current) {
setLoadError(
reason instanceof Error
? reason.message
: t('runtime.deepseekHarness.marketplace.errors.readFailed')
)
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
}, [readSnapshot, t])
useEffect(() => {
let active = true
void readSnapshot()
.then((next) => {
if (active) {
setSnapshot(next)
}
})
.catch((reason: unknown) => {
if (active) {
setLoadError(
reason instanceof Error
? reason.message
: t(
'runtime.deepseekHarness.marketplace.errors.readFailed'
)
)
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [readSnapshot, t])
useEffect(() => {
if (confirmingInstall) {
installConfirmationRef.current?.focus()
}
}, [confirmingInstall])
const apply = async (
key: string,
action: RuntimeExtensionAction,
successMessage: string
): Promise<boolean> => {
const api = window.goodbuddy.runtimeExtensions
if (!api) {
onNotify({
tone: 'error',
message: t(
'runtime.deepseekHarness.marketplace.errors.unavailable'
),
dedupeKey: 'dsh-marketplace-unavailable'
})
return false
}
setBusy(key)
try {
const next = await api.apply(action)
if (mountedRef.current) {
setSnapshot(next)
onNotify({
tone: 'success',
message: successMessage,
dedupeKey: `dsh-marketplace-${action.type}-${actionIdentity(action)}`
})
}
return true
} catch (reason) {
if (mountedRef.current) {
onNotify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t(
'runtime.deepseekHarness.marketplace.errors.operationFailed'
),
dedupeKey: `dsh-marketplace-error-${action.type}-${actionIdentity(action)}`
})
}
return false
} finally {
if (mountedRef.current) {
setBusy(undefined)
}
}
}
const setMarketplaceEnabled = async (
enabled: boolean
): Promise<void> => {
if (!enabled) {
setConfirmingInstall(undefined)
setInstallConfirmed(false)
}
const changed = await apply(
'marketplace',
{
type: 'set-marketplace-enabled',
enabled
},
t(
enabled
? 'runtime.deepseekHarness.marketplace.notifications.marketplaceEnabled'
: 'runtime.deepseekHarness.marketplace.notifications.marketplaceDisabled'
)
)
if (changed && enabled && mountedRef.current) {
await load()
}
}
const installedById = useMemo(
() =>
new Map(
(snapshot?.installed ?? []).map((extension) => [
extension.id,
extension
])
),
[snapshot]
)
const entries = useMemo(() => {
if (!snapshot) {
return []
}
const knownIds = new Set(snapshot.catalog.map((entry) => entry.id))
const installedWithoutCatalog = snapshot.installed
.filter((extension) => !knownIds.has(extension.id))
.map<RuntimeExtensionCatalogEntry>((extension) => ({
id: extension.id,
package: extension.package,
displayName: extension.package.name,
description: t(
'runtime.deepseekHarness.marketplace.notInCatalog'
)
}))
const normalizedQuery = query.trim().toLocaleLowerCase()
return [...snapshot.catalog, ...installedWithoutCatalog]
.filter((entry) => {
if (!normalizedQuery) {
return true
}
return [
entry.displayName,
entry.description,
entry.package.name,
entry.license ?? ''
].some((value) =>
value.toLocaleLowerCase().includes(normalizedQuery)
)
})
.sort((left, right) => {
const leftInstalled = installedById.has(left.id) ? 0 : 1
const rightInstalled = installedById.has(right.id) ? 0 : 1
return (
leftInstalled - rightInstalled ||
left.displayName.localeCompare(right.displayName)
)
})
}, [installedById, query, snapshot, t])
const visibleEntries = entries.slice(0, maximumVisibleEntries)
const beginConfiguration = (
extension: RuntimeExtensionMarketplaceInstalledState
): void => {
setConfiguring(extension.id)
setConfigurationDraft(
JSON.stringify(extension.configuration, null, 2)
)
setConfigurationError(undefined)
}
const saveConfiguration = async (
extension: RuntimeExtensionMarketplaceInstalledState
): Promise<void> => {
let configuration: RuntimeExtensionConfiguration
try {
const parsed = runtimeExtensionConfigurationSchema.safeParse(
JSON.parse(configurationDraft) as unknown
)
if (!parsed.success) {
throw new Error('not-an-object')
}
configuration = parsed.data
} catch {
setConfigurationError(
t(
'runtime.deepseekHarness.marketplace.configuration.invalid'
)
)
return
}
setConfigurationError(undefined)
const saved = await apply(
`configure:${extension.id}`,
{
type: 'configure',
extensionId: extension.id,
configuration
},
t('runtime.deepseekHarness.marketplace.notifications.configured', {
name: extension.package.name
})
)
if (saved && mountedRef.current) {
setConfiguring(undefined)
}
}
return (
<section
aria-labelledby="dsh-marketplace-heading"
className="settings-section runtime-extension-marketplace"
>
<div className="settings-section__title settings-section__title--actions">
<Package aria-hidden="true" size={17} />
<div>
<strong
aria-level={3}
id="dsh-marketplace-heading"
role="heading"
>
{t('runtime.deepseekHarness.marketplace.title')}
</strong>
<small>
{t('runtime.deepseekHarness.marketplace.previewDescription')}
</small>
</div>
<span className="runtime-extension-marketplace__header-actions">
<label className="toggle-row runtime-extension-marketplace__master-toggle">
<span>
{t(
snapshot?.marketplaceEnabled
? 'runtime.deepseekHarness.marketplace.switch.enabled'
: 'runtime.deepseekHarness.marketplace.switch.disabled'
)}
</span>
<input
aria-label={t(
'runtime.deepseekHarness.marketplace.switch.aria'
)}
checked={snapshot?.marketplaceEnabled ?? false}
disabled={!snapshot || loading || Boolean(busy)}
onChange={(event) =>
void setMarketplaceEnabled(event.target.checked)
}
role="switch"
type="checkbox"
/>
</label>
{snapshot?.marketplaceEnabled && (
<button
aria-label={t(
'runtime.deepseekHarness.marketplace.refreshAria'
)}
className="secondary-button"
disabled={loading || Boolean(busy)}
onClick={() => void load()}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{t('runtime.deepseekHarness.marketplace.refresh')}
</button>
)}
</span>
</div>
{loadError && (
<div className="runtime-extension-marketplace__load-error">
<p className="settings-warning" role="alert">
{loadError}
</p>
<button
className="secondary-button"
disabled={loading}
onClick={() => void load()}
type="button"
>
{t('runtime.deepseekHarness.marketplace.retry')}
</button>
</div>
)}
{snapshot && !snapshot.marketplaceEnabled && (
<p className="settings-notice">
{t('runtime.deepseekHarness.marketplace.disabledDescription')}
</p>
)}
{snapshot?.marketplaceEnabled && (
<>
<p className="settings-warning">
{t(
'runtime.deepseekHarness.marketplace.permissionNotice'
)}
</p>
<label className="field runtime-extension-marketplace__search">
<span>
{t(
'runtime.deepseekHarness.marketplace.searchLabel'
)}
</span>
<span className="runtime-extension-marketplace__search-input">
<Search aria-hidden="true" size={14} />
<input
onChange={(event) =>
setQuery(event.currentTarget.value)
}
placeholder={t(
'runtime.deepseekHarness.marketplace.searchPlaceholder'
)}
type="search"
value={query}
/>
</span>
</label>
{snapshot.catalogError && (
<div className="runtime-extension-marketplace__load-error">
<p className="settings-warning" role="alert">
{t(
'runtime.deepseekHarness.marketplace.catalogUnavailable',
{ detail: snapshot.catalogError }
)}
</p>
<button
className="secondary-button"
disabled={loading || Boolean(busy)}
onClick={() => void load()}
type="button"
>
{t('runtime.deepseekHarness.marketplace.retry')}
</button>
</div>
)}
<p className="settings-notice" role="status">
{t('runtime.deepseekHarness.marketplace.results', {
shown: visibleEntries.length,
total: entries.length
})}
</p>
{entries.length === 0 ? (
<p className="settings-empty">
{query.trim()
? t('runtime.deepseekHarness.marketplace.noResults')
: t('runtime.deepseekHarness.marketplace.empty')}
</p>
) : (
<div className="runtime-extension-marketplace__list">
{visibleEntries.map((entry) => {
const installed = installedById.get(entry.id)
const updateAvailable =
Boolean(installed) &&
!packagesMatch(entry, installed!)
const installPanelOpen =
confirmingInstall ===
installConfirmationIdentity(entry)
const configurationOpen = configuring === entry.id
const installBusy = busy === `install:${entry.id}`
return (
<article
className="runtime-extension-card"
key={entry.id}
>
<header className="runtime-extension-card__header">
<div>
<strong>{entry.displayName}</strong>
<code>{packageLabel(entry)}</code>
</div>
<div className="runtime-extension-card__tags">
{installed && (
<span>
{t(
'runtime.deepseekHarness.marketplace.installed'
)}
</span>
)}
{entry.license && <span>{entry.license}</span>}
</div>
</header>
<p>{entry.description}</p>
{installed?.lastError && (
<p className="settings-warning" role="alert">
{installed.lastError ===
runtimeExtensionStartupFailureCode ||
installed.lastError ===
legacyRuntimeExtensionStartupFailure
? t(
'runtime.deepseekHarness.marketplace.startupFailure'
)
: installed.lastError}
</p>
)}
<div className="runtime-extension-card__actions">
{installed && (
<>
<label className="toggle-row runtime-extension-card__toggle">
<span>
{installed.enabled
? t(
'runtime.deepseekHarness.marketplace.enabled'
)
: t(
'runtime.deepseekHarness.marketplace.disabled'
)}
</span>
<input
aria-label={t(
'runtime.deepseekHarness.marketplace.enableAria',
{ name: entry.displayName }
)}
checked={installed.enabled}
disabled={Boolean(busy)}
onChange={(event) => {
const enabled = event.target.checked
void apply(
`toggle:${entry.id}`,
{
type: 'set-enabled',
extensionId: entry.id,
enabled
},
t(
enabled
? 'runtime.deepseekHarness.marketplace.notifications.enabled'
: 'runtime.deepseekHarness.marketplace.notifications.disabled',
{ name: entry.displayName }
)
)
}}
role="switch"
type="checkbox"
/>
</label>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
configurationOpen
? setConfiguring(undefined)
: beginConfiguration(installed)
}
type="button"
>
<Settings2 aria-hidden="true" size={13} />
{configurationOpen
? t(
'runtime.deepseekHarness.marketplace.configuration.close'
)
: t(
'runtime.deepseekHarness.marketplace.configuration.open'
)}
</button>
<DestructiveConfirmActions
confirmAriaLabel={t(
'runtime.deepseekHarness.marketplace.removeAria',
{ name: entry.displayName }
)}
confirmLabel={t(
'runtime.deepseekHarness.marketplace.confirmRemove'
)}
confirming={
confirmingRemove === entry.id
}
disabled={Boolean(busy)}
icon={<Trash2 size={13} />}
message={t(
'runtime.deepseekHarness.marketplace.removeMessage',
{ name: entry.displayName }
)}
onCancel={() =>
setConfirmingRemove(undefined)
}
onConfirm={() => {
void apply(
`remove:${entry.id}`,
{
type: 'remove',
extensionId: entry.id
},
t(
'runtime.deepseekHarness.marketplace.notifications.removed',
{ name: entry.displayName }
)
).then((removed) => {
if (removed && mountedRef.current) {
setConfirmingRemove(undefined)
setConfiguring((current) =>
current === entry.id
? undefined
: current
)
}
})
}}
onRequestConfirm={() =>
setConfirmingRemove(entry.id)
}
triggerAriaLabel={t(
'runtime.deepseekHarness.marketplace.removeAria',
{ name: entry.displayName }
)}
triggerLabel={t(
'runtime.deepseekHarness.marketplace.remove'
)}
/>
</>
)}
{(!installed || updateAvailable) && (
<button
className="primary-button"
disabled={Boolean(busy)}
onClick={() => {
setConfirmingInstall(
installConfirmationIdentity(entry)
)
setInstallConfirmed(false)
}}
type="button"
>
{updateAvailable
? t(
'runtime.deepseekHarness.marketplace.update',
{ version: entry.package.version }
)
: t(
'runtime.deepseekHarness.marketplace.install'
)}
</button>
)}
</div>
{installPanelOpen && (
<fieldset className="runtime-extension-install-confirmation">
<legend>
{t(
'runtime.deepseekHarness.marketplace.installConfirmationTitle',
{ name: entry.displayName }
)}
</legend>
<p>
{t(
'runtime.deepseekHarness.marketplace.installConfirmation'
)}
</p>
<label>
<input
checked={installConfirmed}
disabled={installBusy}
onChange={(event) =>
setInstallConfirmed(event.target.checked)
}
ref={installConfirmationRef}
type="checkbox"
/>
<span>
{t(
'runtime.deepseekHarness.marketplace.trustConfirmation',
{ package: packageLabel(entry) }
)}
</span>
</label>
<div>
<button
className="secondary-button"
disabled={installBusy}
onClick={() => {
setConfirmingInstall(undefined)
setInstallConfirmed(false)
}}
type="button"
>
{t(
'runtime.deepseekHarness.marketplace.cancel'
)}
</button>
<button
className="primary-button"
disabled={!installConfirmed || installBusy}
onClick={() => {
void apply(
`install:${entry.id}`,
{
type: 'install',
extensionId: entry.id,
package: entry.package
},
t(
updateAvailable
? 'runtime.deepseekHarness.marketplace.notifications.updated'
: 'runtime.deepseekHarness.marketplace.notifications.installed',
{ name: entry.displayName }
)
).then((installedSuccessfully) => {
if (
installedSuccessfully &&
mountedRef.current
) {
setConfirmingInstall(undefined)
setInstallConfirmed(false)
}
})
}}
type="button"
>
{installBusy
? t(
'runtime.deepseekHarness.marketplace.installing'
)
: t(
'runtime.deepseekHarness.marketplace.confirmInstall'
)}
</button>
</div>
</fieldset>
)}
{configurationOpen && installed && (
<div className="runtime-extension-configuration">
<label className="field">
<span>
{t(
'runtime.deepseekHarness.marketplace.configuration.label',
{ name: entry.displayName }
)}
</span>
<textarea
aria-label={t(
'runtime.deepseekHarness.marketplace.configuration.label',
{ name: entry.displayName }
)}
aria-invalid={Boolean(configurationError)}
disabled={Boolean(busy)}
onChange={(event) => {
setConfigurationDraft(
event.currentTarget.value
)
setConfigurationError(undefined)
}}
spellCheck={false}
value={configurationDraft}
/>
<small>
{t(
'runtime.deepseekHarness.marketplace.configuration.help'
)}
</small>
{configurationError && (
<small className="field-error" role="alert">
{configurationError}
</small>
)}
</label>
<button
className="primary-button"
disabled={Boolean(busy)}
onClick={() =>
void saveConfiguration(installed)
}
type="button"
>
{t(
'runtime.deepseekHarness.marketplace.configuration.save'
)}
</button>
</div>
)}
</article>
)
})}
</div>
)}
{entries.length > maximumVisibleEntries && (
<p className="settings-notice">
{t(
'runtime.deepseekHarness.marketplace.refineSearch',
{ count: maximumVisibleEntries }
)}
</p>
)}
</>
)}
{loading && !snapshot && (
<p className="settings-empty" role="status">
{t('runtime.deepseekHarness.marketplace.loading')}
</p>
)}
</section>
)
}
+3 -3
View File
@@ -39,6 +39,8 @@ import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = [
'model',
'opencode',
'continue',
'deepseek-harness'
]
type McpSettingsTab = 'builtin' | 'computer' | 'custom'
@@ -79,9 +81,7 @@ function editorFromServer(server: McpServerSummary): McpEditor {
description: server.description,
enabled: server.enabled,
allowDynamicTools: server.allowDynamicTools,
assignments: server.assignments.filter((target) =>
configurableMcpTargets.includes(target)
),
assignments: server.assignments,
transport: server.transport,
command: server.transport === 'stdio' ? server.command : '',
args: server.transport === 'stdio' ? server.args.join('\n') : '',
+38 -7
View File
@@ -424,6 +424,17 @@ const selectSpeechModel = vi.fn<
speechModelSnapshot = createSpeechModelSnapshot(modelId)
return speechModelSnapshot
})
const runtimeExtensionSnapshot = {
marketplaceEnabled: true,
catalog: [],
installed: []
}
const getRuntimeExtensionSnapshot = vi.fn(
async () => runtimeExtensionSnapshot
)
const applyRuntimeExtension = vi.fn(
async () => runtimeExtensionSnapshot
)
describe('SettingsPanel runtime files', () => {
beforeEach(async () => {
@@ -496,6 +507,10 @@ describe('SettingsPanel runtime files', () => {
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
},
runtimeExtensions: {
getSnapshot: getRuntimeExtensionSnapshot,
apply: applyRuntimeExtension
},
updates: {
getSettings: getApplicationSettings,
updateSettings: updateApplicationSettings,
@@ -1579,6 +1594,19 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.getByText('开发者预览 · OpenAI 兼容')
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: 'DSH 插件市场' })
).toBeInTheDocument()
expect(
await screen.findByText(
/第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行/
)
).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: '启用 DSH 插件市场'
})
).toBeChecked()
const harnessOverview = screen
.getByText('GoodBuddy 内置 DeepSeek Harness')
.closest<HTMLElement>('.runtime-overview')
@@ -1607,7 +1635,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByText('高级设置'))
expect(
screen.getByText(
/始终使用 GoodBuddy 内置并固定版本的 Host/
/已启用的市场插件由 GoodBuddy 托管并随 Host 启动/
)
).toBeInTheDocument()
expect(
@@ -2838,7 +2866,9 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Skills' }))
expect(await screen.findByText('文档写作')).toBeInTheDocument()
expect(
screen.getByText('支持直连模型、OpenCode 和 Continue')
screen.getByText(
'支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
)
).toBeInTheDocument()
expect(
screen.getByText(/新导入的 Skill 默认启用/)
@@ -3046,10 +3076,12 @@ describe('SettingsPanel runtime files', () => {
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
)
expect(
screen.getByText(/自定义 MCP 可分配给直连模型或 DeepSeek Harness/)
screen.getByText(
/自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness/
)
).toHaveTextContent('新建时默认分配给直连模型')
expect(
screen.getByText(/服务凭据不会进入 Harness Utility/)
screen.getByText(/服务地址、命令和凭据始终由 GoodBuddy 主进程保管/)
).toBeInTheDocument()
expect(
await screen.findByText('尚未配置 MCP Server')
@@ -3079,9 +3111,8 @@ describe('SettingsPanel runtime files', () => {
expect(
within(dialog).getByLabelText('DeepSeek Harness')
).not.toBeChecked()
expect(
within(dialog).queryByLabelText('OpenCode')
).not.toBeInTheDocument()
expect(within(dialog).getByLabelText('OpenCode')).not.toBeChecked()
expect(within(dialog).getByLabelText('Continue')).not.toBeChecked()
await waitFor(() =>
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
)
+4
View File
@@ -45,6 +45,7 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
@@ -2192,6 +2193,9 @@ export function SettingsPanel({
</button>
</details>
</div>
)}
{agentRuntimeType === 'deepseek-harness' && (
<DshMarketplaceSection onNotify={onNotify} />
)}
</>
)}
@@ -179,7 +179,7 @@ export const integrations = {
custom: 'Custom MCP'
},
customNotice:
'Custom MCP can be assigned to direct models or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. GoodBuddy proxies Harness tools in the main process, so server credentials never enter the Harness Utility.',
'Custom MCP can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. Agent runtimes receive only a request-scoped loopback capability; GoodBuddy keeps server addresses, commands, and credentials in the main process.',
securityNotice:
'Built-in tools are provided by GoodBuddy and are not MCP servers. Custom MCP servers and tools run with the current users permissions, so add only trusted services. Remote access tokens are encrypted in secure system storage, and tool calls still require GoodBuddy approval.',
computer: {
@@ -71,7 +71,8 @@ export const settings = {
skills: {
label: 'Skills',
navigationDescription: 'Built-in and custom capabilities',
description: 'Works with direct models, OpenCode, and Continue'
description:
'Works with direct models, OpenCode, Continue, and DeepSeek Harness'
},
mcp: {
label: 'MCP',
@@ -246,7 +247,7 @@ export const settings = {
title: 'DeepSeek Harness',
previewDescription: 'Developer preview · OpenAI-compatible',
description:
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask limits model tool calls to read-only tools, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
managedSource:
'Administrator-provided OpenAI-compatible connection',
connection: 'OpenAI-compatible model connection',
@@ -255,7 +256,81 @@ export const settings = {
connectionDescription:
'Choose a GoodBuddy model connection. It must use OpenAI Chat Completions with API-key authentication.',
advancedDescription:
'This Runtime always uses GoodBuddys bundled, version-pinned Host. It does not load external DSH plugins, marketplace packages, user profiles, or custom Hosts.'
'This Runtime always uses GoodBuddys bundled, version-pinned Host and does not load user profiles or custom Hosts. GoodBuddy manages enabled marketplace plugins and loads them with the Host.',
marketplace: {
title: 'DSH plugin marketplace',
previewDescription: 'Preview · public npm registry',
switch: {
aria: 'Enable the DSH plugin marketplace',
enabled: 'On',
disabled: 'Off'
},
disabledDescription:
'The plugin marketplace is off by default. Turn it on to connect to the public npm catalog and show its management interface. Turning off the marketplace does not disable or uninstall existing plugins.',
permissionNotice:
'Third-party install scripts, initialization code, and tools run with your user permissions. Ask limits the model from calling non-read-only tools, but cannot limit plugin initialization code. Execute can call every tool from enabled plugins. Install only packages you trust.',
refresh: 'Refresh',
refreshAria: 'Refresh the DSH plugin marketplace',
searchLabel: 'Search plugins',
searchPlaceholder:
'Filter by name, package, description, or license',
retry: 'Try again',
loading: 'Loading the plugin catalog…',
catalogUnavailable:
'Could not refresh the npm plugin catalog: {{detail}}. Installed plugins remain manageable.',
results: 'Showing {{shown}} of {{total}} plugins',
noResults: 'No plugins match your search.',
empty: 'No DSH plugins were found in the public npm registry.',
refineSearch:
'Only the first {{count}} plugins are shown. Refine your search to see others.',
notInCatalog:
'This installed plugin is not currently in the npm marketplace catalog.',
installed: 'Installed',
enabled: 'Enabled',
disabled: 'Disabled',
enableAria: 'Enable {{name}}',
install: 'Install and enable',
update: 'Update to {{version}}',
installing: 'Installing…',
installConfirmationTitle: 'Install {{name}}',
installConfirmation:
'npm runs install scripts declared by this package and its dependencies. After installation, plugin initialization code runs when DeepSeek Harness starts.',
trustConfirmation:
'I trust {{package}} and understand that its code runs with my user permissions.',
confirmInstall: 'Confirm install',
cancel: 'Cancel',
remove: 'Remove',
removeAria: 'Remove {{name}}',
confirmRemove: 'Confirm removal',
removeMessage:
'Remove {{name}} and its GoodBuddy-managed files?',
startupFailure:
'The plugin failed on its last startup and was disabled automatically. Check its configuration or version before enabling it again.',
configuration: {
open: 'Configure',
close: 'Close configuration',
label: '{{name}} configuration JSON',
help: 'Save a JSON object and restart the current Runtime to pass it to the plugin.',
save: 'Save configuration',
invalid: 'Configuration must be a valid JSON object.'
},
errors: {
unavailable:
'The DSH plugin marketplace is unavailable in this version',
readFailed: 'Could not load the DSH plugin marketplace',
operationFailed: 'The DSH plugin operation failed'
},
notifications: {
marketplaceEnabled: 'Enabled the DSH plugin marketplace',
marketplaceDisabled: 'Disabled the DSH plugin marketplace',
installed: 'Installed and enabled {{name}}',
updated: 'Updated {{name}}',
enabled: 'Enabled {{name}}',
disabled: 'Disabled {{name}}',
configured: 'Saved the configuration for {{name}}',
removed: 'Removed {{name}}'
}
}
}
},
documentParsing: {
@@ -166,7 +166,7 @@ export const integrations = {
custom: '自定义 MCP'
},
customNotice:
'自定义 MCP 可分配给直连模型或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Harness 工具由 GoodBuddy 主进程代理,服务凭据不会进入 Harness Utility。',
'自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Agent Runtime 只接收按请求签发的本机回环权限;服务地址、命令和凭据始终由 GoodBuddy 主进程保管。',
securityNotice:
'内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。',
computer: {
@@ -61,7 +61,7 @@ export const settings = {
skills: {
label: 'Skills',
navigationDescription: '内置与自定义能力',
description: '支持直连模型、OpenCodeContinue'
description: '支持直连模型、OpenCodeContinue 和 DeepSeek Harness'
},
mcp: {
label: 'MCP',
@@ -223,14 +223,83 @@ export const settings = {
title: 'DeepSeek Harness',
previewDescription: '开发者预览 · OpenAI 兼容',
description:
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 仅允许模型调用只读工具,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
managedSource: '管理员预置的 OpenAI 兼容连接',
connection: 'OpenAI 兼容模型连接',
connectionPlaceholder: '选择 OpenAI 兼容模型连接',
connectionDescription:
'从 GoodBuddy 模型连接中选择;协议必须为 OpenAI Chat Completions,并使用 API Key。',
advancedDescription:
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载外部 DSH 插件、市场包、用户 profile 或自定义 Host。'
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载用户 profile 或自定义 Host;已启用的市场插件由 GoodBuddy 托管并随 Host 启动。',
marketplace: {
title: 'DSH 插件市场',
previewDescription: '预览 · npm 公共仓库',
switch: {
aria: '启用 DSH 插件市场',
enabled: '已开启',
disabled: '已关闭'
},
disabledDescription:
'插件市场默认关闭。开启后才会连接公共 npm 目录并显示管理界面;关闭市场不会停用或卸载已有插件。',
permissionNotice:
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。Ask 只限制模型调用非只读工具,无法限制插件初始化代码;Execute 可调用已启用插件提供的全部工具。请仅安装可信包。',
refresh: '刷新',
refreshAria: '刷新 DSH 插件市场',
searchLabel: '搜索插件',
searchPlaceholder: '按名称、包名、描述或许可证筛选',
retry: '重试',
loading: '正在加载插件目录…',
catalogUnavailable:
'无法刷新 npm 插件目录:{{detail}}。已安装插件仍可管理。',
results: '显示 {{shown}} / {{total}} 个插件',
noResults: '没有匹配的插件。',
empty: 'npm 公共仓库中暂未找到 DSH 插件。',
refineSearch: '当前最多显示 {{count}} 个插件,请缩小搜索范围。',
notInCatalog: '此已安装插件目前不在 npm 市场目录中。',
installed: '已安装',
enabled: '已启用',
disabled: '已停用',
enableAria: '启用 {{name}}',
install: '安装并启用',
update: '更新到 {{version}}',
installing: '正在安装…',
installConfirmationTitle: '安装 {{name}}',
installConfirmation:
'npm 会运行该包及其依赖声明的安装脚本。安装后,插件初始化代码会随 DeepSeek Harness 启动。',
trustConfirmation:
'我信任 {{package}},并了解其代码将以当前用户权限运行。',
confirmInstall: '确认安装',
cancel: '取消',
remove: '移除',
removeAria: '移除 {{name}}',
confirmRemove: '确认移除',
removeMessage: '移除 {{name}} 及其由 GoodBuddy 托管的文件?',
startupFailure:
'插件上次启动失败,已自动停用。确认配置或版本后可重新启用。',
configuration: {
open: '配置',
close: '收起配置',
label: '{{name}} 配置 JSON',
help: '保存一个 JSON 对象并重启当前 Runtime,使配置传给插件。',
save: '保存配置',
invalid: '配置必须是有效的 JSON 对象。'
},
errors: {
unavailable: '当前版本未提供 DSH 插件市场服务',
readFailed: '读取 DSH 插件市场失败',
operationFailed: 'DSH 插件操作失败'
},
notifications: {
marketplaceEnabled: '已开启 DSH 插件市场',
marketplaceDisabled: '已关闭 DSH 插件市场',
installed: '已安装并启用 {{name}}',
updated: '已更新 {{name}}',
enabled: '已启用 {{name}}',
disabled: '已停用 {{name}}',
configured: '已保存 {{name}} 的配置',
removed: '已移除 {{name}}'
}
}
}
},
documentParsing: {
+236
View File
@@ -5262,6 +5262,242 @@ button > svg {
color: var(--text-secondary) !important;
}
.runtime-extension-marketplace {
min-width: 0;
}
.runtime-extension-marketplace__header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.runtime-extension-marketplace__master-toggle {
min-height: 30px;
padding: 0;
border-bottom: 0;
white-space: nowrap;
}
.runtime-extension-marketplace__search-input {
position: relative;
display: block;
}
.runtime-extension-marketplace__search-input > svg {
position: absolute;
z-index: 1;
color: var(--text-muted);
left: 11px;
pointer-events: none;
top: 50%;
transform: translateY(-50%);
}
.runtime-extension-marketplace__search-input > input {
padding-left: 34px;
}
.runtime-extension-marketplace__load-error {
display: flex;
align-items: center;
gap: var(--space-2);
}
.runtime-extension-marketplace__load-error > p {
flex: 1;
}
.runtime-extension-marketplace__list {
display: grid;
min-width: 0;
gap: var(--space-3);
}
.runtime-extension-card {
display: grid;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
gap: var(--space-3);
}
.runtime-extension-card__header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.runtime-extension-card__header > div:first-child {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.runtime-extension-card__header strong {
color: var(--text-primary);
font-size: var(--font-body);
overflow-wrap: anywhere;
}
.runtime-extension-card__header code {
color: var(--text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.runtime-extension-card__tags {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-1);
}
.runtime-extension-card__tags span {
padding: 2px 6px;
border: 1px solid var(--border-subtle);
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
white-space: nowrap;
}
.runtime-extension-card > p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.6;
overflow-wrap: anywhere;
}
.runtime-extension-card__actions {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.runtime-extension-card__actions > button,
.runtime-extension-card__actions > .danger-confirm,
.runtime-extension-configuration > button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
}
.runtime-extension-card__actions > .primary-button {
margin-left: auto;
}
.runtime-extension-card__toggle {
min-height: 30px;
padding: 0;
border-top: 0;
justify-content: flex-start;
font-size: var(--font-caption);
}
.runtime-extension-install-confirmation {
display: grid;
min-width: 0;
margin: 0;
padding: var(--space-3);
border: 1px solid var(--warning-border);
border-radius: var(--radius-control);
background: var(--warning-subtle);
gap: var(--space-2);
}
.runtime-extension-install-confirmation legend {
padding: 0 var(--space-1);
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
.runtime-extension-install-confirmation p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.runtime-extension-install-confirmation > label {
display: grid;
align-items: start;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-caption);
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-2);
line-height: 1.55;
}
.runtime-extension-install-confirmation > label input {
margin: 2px 0 0;
}
.runtime-extension-install-confirmation > div {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.runtime-extension-configuration {
display: grid;
min-width: 0;
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
gap: var(--space-2);
}
.runtime-extension-configuration textarea {
min-height: 120px;
font-family: var(--font-family-mono);
line-height: 1.5;
}
.runtime-extension-configuration > button {
width: fit-content;
justify-self: end;
}
@media (max-width: 720px) {
.runtime-extension-marketplace
> .settings-section__title--actions {
align-items: flex-start;
flex-wrap: wrap;
}
.runtime-extension-marketplace__header-actions {
width: 100%;
padding-left: 26px;
flex-wrap: wrap;
}
.runtime-extension-card__header,
.runtime-extension-marketplace__load-error {
align-items: stretch;
flex-direction: column;
}
.runtime-extension-card__tags {
justify-content: flex-start;
}
.runtime-extension-card__actions > .primary-button {
margin-left: 0;
}
}
details.settings-section {
padding: 0;
gap: 0;
+10
View File
@@ -94,6 +94,10 @@ import type {
DocumentParsingTestPurpose
} from './document-parsing-contracts'
import type { SettingsWarning } from './settings-warning-contracts'
import type {
RuntimeExtensionAction,
RuntimeExtensionMarketplaceSnapshot
} from './runtime-extension-contracts'
import type {
KnowledgeChunkDeleteInput,
KnowledgeChunkPage,
@@ -1518,6 +1522,12 @@ export type DesktopApi = {
profileId: string
) => Promise<CapabilitySnapshot>
}
runtimeExtensions: {
getSnapshot: () => Promise<RuntimeExtensionMarketplaceSnapshot>
apply: (
action: RuntimeExtensionAction
) => Promise<RuntimeExtensionMarketplaceSnapshot>
}
context: {
selectFiles: () => Promise<ContextAttachment[]>
onFileSelectionProgress: (
+2
View File
@@ -136,6 +136,8 @@ export const ipcChannels = {
capabilitiesRenameBrowserProfile: 'capabilities:browser-profile:rename',
capabilitiesDefaultBrowserProfile: 'capabilities:browser-profile:default',
capabilitiesRemoveBrowserProfile: 'capabilities:browser-profile:remove',
runtimeExtensionsSnapshot: 'runtime-extensions:snapshot',
runtimeExtensionsApply: 'runtime-extensions:apply',
contextSelectFiles: 'context:select-files',
contextFileSelectionProgress: 'context:file-selection-progress',
contextAddPastedImage: 'context:add-pasted-image',
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import {
runtimeExtensionActionSchema,
runtimeExtensionCatalogEntrySchema,
runtimeExtensionMarketplaceSnapshotSchema
} from './runtime-extension-contracts'
const catalogEntry = {
id: 'web-research',
package: {
name: '@goodbuddy/dsh-web-research',
version: '1.2.3'
},
displayName: 'Web research',
description: 'Researches public web pages.',
repository: 'https://example.com/goodbuddy/web-research',
license: 'MIT'
}
describe('runtime extension contracts', () => {
it('accepts the minimal catalog metadata', () => {
expect(runtimeExtensionCatalogEntrySchema.parse(catalogEntry)).toEqual(
catalogEntry
)
})
it('requires exact semantic versions', () => {
expect(
runtimeExtensionActionSchema.safeParse({
type: 'install',
extensionId: catalogEntry.id,
package: { ...catalogEntry.package, version: '^1.2.3' }
}).success
).toBe(false)
})
it('rejects removed policy fields and rollback actions', () => {
expect(
runtimeExtensionCatalogEntrySchema.safeParse({
...catalogEntry,
permissions: []
}).success
).toBe(false)
expect(
runtimeExtensionActionSchema.safeParse({
type: 'rollback',
extensionId: catalogEntry.id
}).success
).toBe(false)
})
it('models snapshots with JSON-like configuration', () => {
const snapshot = {
marketplaceEnabled: true,
catalog: [catalogEntry],
installed: [
{
id: catalogEntry.id,
package: catalogEntry.package,
installedAt: '2026-08-16T00:00:00.000Z',
enabled: true,
integrity: `sha512-${Buffer.from('digest').toString(
'base64'
)}`,
configuration: {
resultLimit: 10,
filters: { domains: ['example.com'], exact: true },
optional: null
}
}
]
}
expect(
runtimeExtensionMarketplaceSnapshotSchema.parse(snapshot)
).toEqual(snapshot)
expect(
runtimeExtensionMarketplaceSnapshotSchema.safeParse({
...snapshot,
installed: [
{
...snapshot.installed[0],
entrypoint:
'C:\\Users\\tester\\runtime-extensions\\extensions\\web-research\\dist\\index.js'
}
]
}).success
).toBe(false)
expect(
runtimeExtensionMarketplaceSnapshotSchema.safeParse({
...snapshot,
warnings: []
}).success
).toBe(false)
})
it('supports only the explicit marketplace switch action', () => {
expect(
runtimeExtensionActionSchema.parse({
type: 'set-marketplace-enabled',
enabled: true
})
).toEqual({
type: 'set-marketplace-enabled',
enabled: true
})
expect(
runtimeExtensionActionSchema.safeParse({
type: 'set-marketplace-enabled',
enabled: true,
extensionId: 'unexpected'
}).success
).toBe(false)
})
it('bounds configuration size, depth, and collection width', () => {
let deeplyNested: Record<string, unknown> = {}
for (let depth = 0; depth < 18; depth += 1) {
deeplyNested = { nested: deeplyNested }
}
const action = {
type: 'configure',
extensionId: catalogEntry.id
}
expect(
runtimeExtensionActionSchema.safeParse({
...action,
configuration: { value: 'x'.repeat(65 * 1_024) }
}).success
).toBe(false)
expect(
runtimeExtensionActionSchema.safeParse({
...action,
configuration: deeplyNested
}).success
).toBe(false)
expect(
runtimeExtensionActionSchema.safeParse({
...action,
configuration: {
values: Array.from({ length: 257 }, () => true)
}
}).success
).toBe(false)
})
})
+260
View File
@@ -0,0 +1,260 @@
import { z } from 'zod'
export const runtimeExtensionStartupFailureCode = 'startup-failed'
export const legacyRuntimeExtensionStartupFailure =
'Extension failed to start.'
export const runtimeExtensionIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/u)
export const runtimeExtensionPackageNameSchema = z
.string()
.min(1)
.max(214)
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u)
export const runtimeExtensionVersionSchema = z
.string()
.min(1)
.max(64)
.regex(
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
)
export const runtimeExtensionExactPackageSchema = z
.object({
name: runtimeExtensionPackageNameSchema,
version: runtimeExtensionVersionSchema
})
.strict()
export const runtimeExtensionIntegritySchema = z
.string()
.min(1)
.max(1_024)
.regex(
/^(?:sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})(?:\s+sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2})*$/u
)
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue }
type JsonObject = { [key: string]: JsonValue }
const MAXIMUM_CONFIGURATION_BYTES = 64 * 1024
const MAXIMUM_CONFIGURATION_DEPTH = 16
const MAXIMUM_CONFIGURATION_NODES = 4_096
const MAXIMUM_CONFIGURATION_ENTRIES = 256
const MAXIMUM_CONFIGURATION_KEY_LENGTH = 256
const MAXIMUM_CONFIGURATION_STRING_LENGTH = 32_768
function isBoundedJsonConfiguration(value: unknown): value is JsonObject {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return false
}
const pending: Array<{ value: unknown; depth: number }> = [
{ value, depth: 0 }
]
const seen = new Set<object>()
let nodes = 0
while (pending.length > 0) {
const current = pending.pop()!
nodes += 1
if (
nodes > MAXIMUM_CONFIGURATION_NODES ||
current.depth > MAXIMUM_CONFIGURATION_DEPTH
) {
return false
}
if (
current.value === null ||
typeof current.value === 'boolean'
) {
continue
}
if (typeof current.value === 'number') {
if (!Number.isFinite(current.value)) {
return false
}
continue
}
if (typeof current.value === 'string') {
if (
current.value.length >
MAXIMUM_CONFIGURATION_STRING_LENGTH
) {
return false
}
continue
}
if (
!current.value ||
typeof current.value !== 'object' ||
seen.has(current.value)
) {
return false
}
seen.add(current.value)
if (Array.isArray(current.value)) {
if (
current.value.length > MAXIMUM_CONFIGURATION_ENTRIES
) {
return false
}
for (const item of current.value) {
pending.push({
value: item,
depth: current.depth + 1
})
}
continue
}
const entries = Object.entries(current.value)
if (entries.length > MAXIMUM_CONFIGURATION_ENTRIES) {
return false
}
for (const [key, item] of entries) {
if (key.length > MAXIMUM_CONFIGURATION_KEY_LENGTH) {
return false
}
pending.push({
value: item,
depth: current.depth + 1
})
}
}
try {
return (
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
MAXIMUM_CONFIGURATION_BYTES
)
} catch {
return false
}
}
export const runtimeExtensionConfigurationSchema =
z.custom<JsonObject>(
isBoundedJsonConfiguration,
'Extension configuration must be a bounded JSON object'
)
export const runtimeExtensionCatalogEntrySchema = z
.object({
id: runtimeExtensionIdSchema,
package: runtimeExtensionExactPackageSchema,
displayName: z.string().trim().min(1).max(128),
description: z.string().trim().min(1).max(2_000),
repository: z.string().url().max(2_048).optional(),
license: z.string().trim().min(1).max(128).optional()
})
.strict()
export const runtimeExtensionInstalledStateSchema = z
.object({
id: runtimeExtensionIdSchema,
package: runtimeExtensionExactPackageSchema,
entrypoint: z.string().min(1).max(32_768),
installedAt: z.string().datetime({ offset: true }),
enabled: z.boolean(),
configuration: runtimeExtensionConfigurationSchema,
integrity: runtimeExtensionIntegritySchema.optional(),
lastError: z.string().trim().min(1).max(1_000).optional()
})
.strict()
export const runtimeExtensionMarketplaceInstalledStateSchema =
runtimeExtensionInstalledStateSchema.omit({
entrypoint: true
})
export const runtimeExtensionMarketplaceSnapshotSchema = z
.object({
marketplaceEnabled: z.boolean(),
catalog: z.array(runtimeExtensionCatalogEntrySchema),
installed: z.array(
runtimeExtensionMarketplaceInstalledStateSchema
),
catalogError: z.string().trim().min(1).max(1_000).optional()
})
.strict()
export const runtimeExtensionInstallActionSchema = z
.object({
type: z.literal('install'),
extensionId: runtimeExtensionIdSchema,
package: runtimeExtensionExactPackageSchema
})
.strict()
export const runtimeExtensionEnableActionSchema = z
.object({
type: z.literal('set-enabled'),
extensionId: runtimeExtensionIdSchema,
enabled: z.boolean()
})
.strict()
export const runtimeExtensionRemoveActionSchema = z
.object({
type: z.literal('remove'),
extensionId: runtimeExtensionIdSchema
})
.strict()
export const runtimeExtensionConfigureActionSchema = z
.object({
type: z.literal('configure'),
extensionId: runtimeExtensionIdSchema,
configuration: runtimeExtensionConfigurationSchema
})
.strict()
export const runtimeExtensionMarketplaceEnableActionSchema = z
.object({
type: z.literal('set-marketplace-enabled'),
enabled: z.boolean()
})
.strict()
export const runtimeExtensionActionSchema = z.discriminatedUnion('type', [
runtimeExtensionMarketplaceEnableActionSchema,
runtimeExtensionInstallActionSchema,
runtimeExtensionEnableActionSchema,
runtimeExtensionRemoveActionSchema,
runtimeExtensionConfigureActionSchema
])
export type RuntimeExtensionExactPackage = z.infer<
typeof runtimeExtensionExactPackageSchema
>
export type RuntimeExtensionCatalogEntry = z.infer<
typeof runtimeExtensionCatalogEntrySchema
>
export type RuntimeExtensionInstalledState = z.infer<
typeof runtimeExtensionInstalledStateSchema
>
export type RuntimeExtensionMarketplaceInstalledState = z.infer<
typeof runtimeExtensionMarketplaceInstalledStateSchema
>
export type RuntimeExtensionMarketplaceSnapshot = z.infer<
typeof runtimeExtensionMarketplaceSnapshotSchema
>
export type RuntimeExtensionAction = z.infer<
typeof runtimeExtensionActionSchema
>
export type RuntimeExtensionConfiguration = z.infer<
typeof runtimeExtensionConfigurationSchema
>