feat: add DeepSeek Harness runtime
This commit is contained in:
@@ -28,6 +28,13 @@ describe('bundled runtime paths', () => {
|
||||
'cli',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
'workspace',
|
||||
'app',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +62,14 @@ describe('bundled runtime paths', () => {
|
||||
'continue',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,12 @@ import { join } from 'node:path'
|
||||
export type BundledRuntimePaths = {
|
||||
opencode: string
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
|
||||
export const bundledContinueVersion = '1.5.47'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.0-rc.6'
|
||||
|
||||
export function resolveBundledRuntimePaths(input: {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
@@ -29,6 +33,13 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'continue',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
input.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +59,12 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'cli',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
input.appPath,
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,38 @@ function settings(
|
||||
}
|
||||
|
||||
describe('createAgentRuntime model compatibility', () => {
|
||||
it('does not treat the default model profile as the platform DeepSeek source', () => {
|
||||
const defaultProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Default DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const,
|
||||
apiKey: 'default-deepseek-key'
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
createAgentRuntime(
|
||||
process.cwd(),
|
||||
settings({
|
||||
provider: 'deepseek-harness',
|
||||
modelBaseUrl: defaultProfile.baseUrl,
|
||||
modelName: defaultProfile.modelName,
|
||||
modelProtocol: defaultProfile.protocol,
|
||||
modelAuthentication: defaultProfile.authentication,
|
||||
apiKey: defaultProfile.apiKey,
|
||||
modelProfiles: [defaultProfile],
|
||||
runtimeSandboxMode: 'auto'
|
||||
}),
|
||||
{ deepseekHarnessLauncher: vi.fn() }
|
||||
)
|
||||
).toThrow(
|
||||
'DeepSeek Harness 需要 api.deepseek.com 的 OpenAI Chat Completions 模型连接'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates an available direct runtime for a no-auth model', async () => {
|
||||
const runtime = createAgentRuntime(process.cwd(), settings())
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ModelAgentRuntime } from './model-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessRuntimeOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import type { AgentRuntime } from './runtime'
|
||||
import { UnconfiguredAgentRuntime } from './unconfigured-runtime'
|
||||
import type {
|
||||
@@ -9,6 +13,7 @@ import type {
|
||||
} from '../runtime-settings-store'
|
||||
import {
|
||||
defaultRuntimeSettings,
|
||||
isDeepSeekHarnessModelProfile,
|
||||
isAgentRuntimeModelProtocol
|
||||
} from '../../shared/contracts'
|
||||
import type {
|
||||
@@ -21,6 +26,7 @@ import { resolveRuntimeSandbox } from './runtime-sandbox'
|
||||
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'
|
||||
|
||||
const noSubagentTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -41,6 +47,7 @@ export type AgentCapabilityContext = {
|
||||
continueHostCacheRoot?: string
|
||||
bundledRuntimePaths?: BundledRuntimePaths
|
||||
continueHostLauncher?: ContinueHostLauncher
|
||||
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
|
||||
browserService?: BrowserToolService
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
webSearchEnabled?: boolean
|
||||
@@ -102,6 +109,43 @@ export function createAgentRuntime(
|
||||
settings?.runtimeSandboxMode ??
|
||||
defaultRuntimeSettings.runtimeSandboxMode
|
||||
|
||||
if (provider === 'deepseek-harness') {
|
||||
const profile = settings?.deepseekHarnessModelProfile
|
||||
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 需要 api.deepseek.com 的 OpenAI Chat Completions 模型连接'
|
||||
)
|
||||
}
|
||||
if (!profile.apiKey) {
|
||||
throw new Error('DeepSeek Harness 模型连接未配置 API Key')
|
||||
}
|
||||
if (!capabilities.deepseekHarnessLauncher) {
|
||||
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
|
||||
}
|
||||
if (sandboxMode === 'off') {
|
||||
throw new Error('DeepSeek Harness Execute 需要启用 Runtime 沙箱')
|
||||
}
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
launch: capabilities.deepseekHarnessLauncher,
|
||||
credentialRefs: {
|
||||
GOODBUDDY_DEEPSEEK_API_KEY: profile.apiKey
|
||||
},
|
||||
requiredSandboxEnforcement:
|
||||
sandboxMode === 'strict' ? 'full' : 'partial',
|
||||
skillPackages: capabilities.skillPackages,
|
||||
toolProvider: new ModelToolProvider(
|
||||
workspace,
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
false
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (provider === 'continue') {
|
||||
if (
|
||||
settings?.continueModelProfile &&
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CallId,
|
||||
type GenerateOptions,
|
||||
type StreamChunk
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
type ModelToolCallContext
|
||||
} from './model-tool-provider'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
startControlledDeepSeekHarnessHost,
|
||||
type ControlledHarnessHost
|
||||
} from '../deepseek-harness-host'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessChild,
|
||||
type DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
||||
|
||||
const MAX_FRAME_BYTES = 1024 * 1024
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_DEEPSEEK_API_KEY'
|
||||
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
|
||||
|
||||
function expectedSandbox() {
|
||||
return process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' as const }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' as const }
|
||||
: { provider: 'local-linux', enforcement: 'full' as const }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolvePromise!: (value: T) => void
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
function toolResultText(
|
||||
options: GenerateOptions,
|
||||
callId: string
|
||||
): string | undefined {
|
||||
for (const message of options.messages) {
|
||||
for (const block of message.content) {
|
||||
if (
|
||||
block.type !== 'tool-result' ||
|
||||
block.toolCallId !== callId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return block.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof block.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
.map((content) => content.text)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function latestUserText(options: GenerateOptions): string {
|
||||
return options.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.role === 'user' &&
|
||||
message.source.kind === 'user'
|
||||
)
|
||||
.flatMap((message) =>
|
||||
message.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof message.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
.map((content) => content.text)
|
||||
)
|
||||
.at(-1) ?? ''
|
||||
}
|
||||
|
||||
async function* toolCall(
|
||||
callId: string,
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const id = CallId(callId)
|
||||
const argumentsText = JSON.stringify(argumentsValue)
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'tool-call'
|
||||
}
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id,
|
||||
name,
|
||||
argumentsDelta: argumentsText
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'tool-call',
|
||||
id,
|
||||
name,
|
||||
arguments: argumentsText
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'tool-calls' }
|
||||
}
|
||||
}
|
||||
|
||||
async function* textResponse(
|
||||
text: string
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'text'
|
||||
}
|
||||
yield {
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'text', text }
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' }
|
||||
}
|
||||
}
|
||||
|
||||
async function* microDeltaResponse(): AsyncGenerator<StreamChunk> {
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'reasoning'
|
||||
}
|
||||
for (let index = 0; index < MICRO_DELTA_COUNT; index += 1) {
|
||||
yield {
|
||||
type: 'reasoning-delta',
|
||||
index: 0,
|
||||
text: String(index % 10)
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'reasoning',
|
||||
text: Array.from(
|
||||
{ length: MICRO_DELTA_COUNT },
|
||||
(_value, index) => String(index % 10)
|
||||
).join('')
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 8_000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' }
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGameModel {
|
||||
mcpToolName?: string
|
||||
skillResult?: string
|
||||
blueprint?: Record<string, unknown>
|
||||
askToolResult?: string
|
||||
executeToolNames: string[] = []
|
||||
askToolNames: string[] = []
|
||||
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const prompt = latestUserText(options)
|
||||
const toolNames = options.tools?.map((tool) => tool.name) ?? []
|
||||
|
||||
if (prompt.includes('ASK_BOUNDARY_PROBE')) {
|
||||
this.askToolNames = toolNames
|
||||
const result = toolResultText(options, ASK_MCP_CALL_ID)
|
||||
if (!result) {
|
||||
if (!this.mcpToolName) {
|
||||
throw new Error('Fake model has no prior MCP tool identity')
|
||||
}
|
||||
return toolCall(ASK_MCP_CALL_ID, this.mcpToolName, {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'ask-must-not-execute',
|
||||
targetCount: 5
|
||||
})
|
||||
}
|
||||
this.askToolResult = result
|
||||
return textResponse('Ask mode MCP proxy unavailable as required.')
|
||||
}
|
||||
|
||||
this.executeToolNames = toolNames
|
||||
const skillResult = toolResultText(options, SKILL_CALL_ID)
|
||||
if (!skillResult) {
|
||||
return toolCall(SKILL_CALL_ID, 'skill', {
|
||||
name: 'web-3d-game'
|
||||
})
|
||||
}
|
||||
this.skillResult = skillResult
|
||||
|
||||
const blueprintResult = toolResultText(options, MCP_CALL_ID)
|
||||
if (!blueprintResult) {
|
||||
const mcpTool = options.tools?.find((tool) =>
|
||||
tool.name.endsWith('_create_game_blueprint')
|
||||
)
|
||||
if (!mcpTool) {
|
||||
throw new Error(
|
||||
'Main-mediated 3D blueprint MCP tool was not exposed'
|
||||
)
|
||||
}
|
||||
this.mcpToolName = mcpTool.name
|
||||
return toolCall(MCP_CALL_ID, mcpTool.name, {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
})
|
||||
}
|
||||
this.blueprint = JSON.parse(
|
||||
blueprintResult
|
||||
) as Record<string, unknown>
|
||||
return textResponse(
|
||||
'Loaded the Web 3D Game Skill and the approved Prism Relay blueprint.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type HarnessModel = {
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
}
|
||||
|
||||
async function collect(
|
||||
stream: AsyncGenerator<RuntimeEvent, void, void>
|
||||
): Promise<RuntimeEvent[]> {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function createInProcessLaunch(
|
||||
dshHome: string,
|
||||
model: HarnessModel
|
||||
): {
|
||||
launch(
|
||||
options: DeepSeekHarnessLaunchOptions
|
||||
): Promise<DeepSeekHarnessChild>
|
||||
hosts: ControlledHarnessHost[]
|
||||
} {
|
||||
const hosts: ControlledHarnessHost[] = []
|
||||
return {
|
||||
hosts,
|
||||
async launch(options) {
|
||||
const clientToHost =
|
||||
new TransformStream<Uint8Array, Uint8Array>()
|
||||
const hostToClient =
|
||||
new TransformStream<Uint8Array, Uint8Array>()
|
||||
const exited = deferred<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: options.cwd,
|
||||
dshHome,
|
||||
baseUrl: options.baseUrl,
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox(),
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
stream: createBoundedNdJsonStream(
|
||||
hostToClient.writable,
|
||||
clientToHost.readable,
|
||||
MAX_FRAME_BYTES
|
||||
)
|
||||
})
|
||||
hosts.push(host)
|
||||
host.context.on(
|
||||
'llm/stream',
|
||||
(request) => model.stream(request),
|
||||
{ global: true, prepend: true }
|
||||
)
|
||||
let terminated = false
|
||||
return {
|
||||
stdin: clientToHost.writable,
|
||||
stdout: hostToClient.readable,
|
||||
exited: exited.promise,
|
||||
async terminate() {
|
||||
if (terminated) {
|
||||
return
|
||||
}
|
||||
terminated = true
|
||||
await host.dispose().catch(() => undefined)
|
||||
await Promise.allSettled([
|
||||
clientToHost.writable.close(),
|
||||
hostToClient.writable.close()
|
||||
])
|
||||
exited.resolve({ exitCode: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it(
|
||||
'coalesces micro reasoning deltas without losing content and caps each model step',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-deltas-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
let observedRequest: GenerateOptions | undefined
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream(options) {
|
||||
observedRequest = options
|
||||
return microDeltaResponse()
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 20_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
const events = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-deltas',
|
||||
conversationId: 'acp-deltas',
|
||||
prompt: 'Return the deterministic reasoning stream.',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const reasoning = events.filter(
|
||||
(
|
||||
event
|
||||
): event is Extract<
|
||||
RuntimeEvent,
|
||||
{ type: 'reasoning' }
|
||||
> => event.type === 'reasoning'
|
||||
)
|
||||
|
||||
expect(observedRequest?.maxTokens).toBe(
|
||||
GOODBUDDY_HARNESS_MAX_STEP_TOKENS
|
||||
)
|
||||
expect(observedRequest?.system).toContain(
|
||||
'act through the available tools'
|
||||
)
|
||||
expect(reasoning).toHaveLength(8)
|
||||
expect(
|
||||
reasoning.map((event) => event.delta).join('')
|
||||
).toBe(
|
||||
Array.from(
|
||||
{ length: MICRO_DELTA_COUNT },
|
||||
(_value, index) => String(index % 10)
|
||||
).join('')
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it(
|
||||
'rejects the ACP prompt with a bounded model turn error',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-error-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream() {
|
||||
throw new Error('synthetic model turn failed')
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 2_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-error',
|
||||
conversationId: 'acp-error',
|
||||
prompt: 'Trigger the synthetic model failure.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
).rejects.toThrow('synthetic model turn failed')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it(
|
||||
'loads a native Skill, calls an approved real MCP, forwards events, and removes MCP in Ask',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-e2e-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([
|
||||
mkdir(workspace),
|
||||
mkdir(dshHome)
|
||||
])
|
||||
const provider = new ModelToolProvider(workspace, [
|
||||
{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54',
|
||||
name: 'Local 3D Game Blueprint',
|
||||
description: 'Deterministic integration fixture',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolve(
|
||||
'tests',
|
||||
'fixtures',
|
||||
'web-3d-game-mcp.mjs'
|
||||
)
|
||||
]
|
||||
} satisfies ResolvedMcpServer
|
||||
])
|
||||
const callTool = vi.spyOn(provider, 'callTool')
|
||||
const listTools = vi.spyOn(provider, 'listTools')
|
||||
const fakeModel = new FakeGameModel()
|
||||
const inProcess = createInProcessLaunch(dshHome, fakeModel)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
skillPackages: [
|
||||
{
|
||||
id: 'web-3d-game',
|
||||
directory: resolve(
|
||||
'resources',
|
||||
'skills',
|
||||
'web-3d-game'
|
||||
)
|
||||
}
|
||||
],
|
||||
toolProvider: provider,
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 20_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
const authorize = vi.fn(
|
||||
async (
|
||||
request: Parameters<
|
||||
NonNullable<
|
||||
Parameters<DeepSeekHarnessRuntime['run']>[2]
|
||||
>
|
||||
>[0]
|
||||
) =>
|
||||
request.scopeKey.startsWith('model:mcp:')
|
||||
? ('once' as const)
|
||||
: ('deny' as const)
|
||||
)
|
||||
|
||||
try {
|
||||
const executeEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-execute',
|
||||
conversationId: 'acp-e2e',
|
||||
prompt:
|
||||
'Use the Web 3D Game Skill and assigned blueprint MCP.',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
|
||||
expect(fakeModel.executeToolNames).toContain('skill')
|
||||
expect(fakeModel.mcpToolName).toMatch(
|
||||
/_create_game_blueprint$/u
|
||||
)
|
||||
expect(fakeModel.skillResult).toContain(
|
||||
'window.__GOODBUDDY_GAME__'
|
||||
)
|
||||
expect(fakeModel.blueprint).toMatchObject({
|
||||
title: 'Prism Relay',
|
||||
objective: { targetCount: 5 },
|
||||
acceptance: {
|
||||
testSurface: 'window.__GOODBUDDY_GAME__'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledOnce()
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
fakeModel.mcpToolName,
|
||||
{
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
},
|
||||
expect.any(AbortSignal),
|
||||
{
|
||||
conversationId: 'acp-e2e',
|
||||
workMode: 'execute',
|
||||
knowledgeCapabilityToken: undefined
|
||||
} satisfies ModelToolCallContext
|
||||
)
|
||||
expect(executeEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: SKILL_CALL_ID,
|
||||
name: 'skill',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: SKILL_CALL_ID,
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: MCP_CALL_ID,
|
||||
name: fakeModel.mcpToolName,
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: MCP_CALL_ID,
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: expect.stringContaining('Prism Relay')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'model-usage',
|
||||
runtime: 'deepseek-harness'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'done',
|
||||
sessionId: expect.any(String)
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(
|
||||
executeEvents.filter(
|
||||
(event) =>
|
||||
event.type === 'tool' &&
|
||||
event.state === 'running'
|
||||
)
|
||||
).toHaveLength(0)
|
||||
|
||||
const callsBeforeAsk = callTool.mock.calls.length
|
||||
const listsBeforeAsk = listTools.mock.calls.length
|
||||
const approvalsBeforeAsk = authorize.mock.calls.length
|
||||
const askEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-ask',
|
||||
conversationId: 'acp-e2e',
|
||||
prompt:
|
||||
'ASK_BOUNDARY_PROBE: attempt the previous MCP tool.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
|
||||
expect(fakeModel.askToolNames).not.toContain(
|
||||
fakeModel.mcpToolName
|
||||
)
|
||||
expect(fakeModel.askToolResult).toContain('unknown tool')
|
||||
expect(callTool).toHaveBeenCalledTimes(callsBeforeAsk)
|
||||
expect(listTools).toHaveBeenCalledTimes(listsBeforeAsk)
|
||||
expect(authorize).toHaveBeenCalledTimes(approvalsBeforeAsk)
|
||||
expect(askEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: ASK_MCP_CALL_ID,
|
||||
name: fakeModel.mcpToolName,
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: ASK_MCP_CALL_ID,
|
||||
state: 'failed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: expect.stringContaining(
|
||||
'MCP proxy unavailable'
|
||||
)
|
||||
}),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,920 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
type ModelToolDefinition,
|
||||
type ModelToolProviderLike
|
||||
} from './model-tool-provider'
|
||||
import type {
|
||||
ResolvedMcpServer
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
harnessPromptError,
|
||||
type DeepSeekHarnessAcpSdk,
|
||||
type DeepSeekHarnessChild
|
||||
} from './deepseek-harness-runtime'
|
||||
import { RequestError } from '@agentclientprotocol/sdk'
|
||||
|
||||
type Permission = Parameters<
|
||||
ReturnType<
|
||||
ConstructorParameters<
|
||||
DeepSeekHarnessAcpSdk['ClientSideConnection']
|
||||
>[0]
|
||||
>['requestPermission']
|
||||
>[0]
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
toolProvider?: ModelToolProviderLike
|
||||
promptTimeoutMs?: number
|
||||
maxEventCharacters?: number
|
||||
maxRequestOutputCharacters?: number
|
||||
} = {}
|
||||
) {
|
||||
const exit = deferred<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>()
|
||||
const stderr = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const child: DeepSeekHarnessChild = {
|
||||
stdin: new WritableStream<Uint8Array>(),
|
||||
stdout: new ReadableStream<Uint8Array>(),
|
||||
stderr: stderr.readable,
|
||||
exited: exit.promise,
|
||||
terminate: vi.fn()
|
||||
}
|
||||
let permissionHandler:
|
||||
| ((params: Permission) => Promise<unknown>)
|
||||
| undefined
|
||||
let updateHandler:
|
||||
| ((context: {
|
||||
sessionId: string
|
||||
update: Record<string, unknown>
|
||||
}) => Promise<void>)
|
||||
| undefined
|
||||
let extensionHandler:
|
||||
| ((
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>)
|
||||
| undefined
|
||||
const requests: Array<{
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}> = []
|
||||
const notifications: Array<{
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}> = []
|
||||
const promptGates: Array<ReturnType<typeof deferred<{ stopReason: string }>>> =
|
||||
[]
|
||||
let sessionIndex = 0
|
||||
const connectionClosed = deferred<void>()
|
||||
const connectionController = new AbortController()
|
||||
const requestAgent = async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => {
|
||||
requests.push({ method, params })
|
||||
if (method === 'initialize') {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {}
|
||||
}
|
||||
}
|
||||
if (method === 'session/new') {
|
||||
sessionIndex += 1
|
||||
return { sessionId: `session-${sessionIndex}` }
|
||||
}
|
||||
if (method === 'session/prompt') {
|
||||
const gate = deferred<{ stopReason: string }>()
|
||||
promptGates.push(gate)
|
||||
return gate.promise
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
}
|
||||
const notifyAgent = async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => {
|
||||
notifications.push({ method, params })
|
||||
}
|
||||
const agent = {
|
||||
initialize: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('initialize', params)
|
||||
),
|
||||
newSession: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('session/new', params)
|
||||
),
|
||||
prompt: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('session/prompt', params)
|
||||
),
|
||||
cancel: vi.fn((params: Record<string, unknown>) =>
|
||||
notifyAgent('session/cancel', params)
|
||||
),
|
||||
extMethod: vi.fn(
|
||||
async (method: string, params: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
if (method === 'goodbuddy/handshake') {
|
||||
return {
|
||||
controlProtocolVersion: 1,
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
acpProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
sessionRelease: true,
|
||||
oneShotApproval: true,
|
||||
reasoningEvents: true,
|
||||
toolEvents: true,
|
||||
usageEvents: true,
|
||||
credentialResolution: true
|
||||
},
|
||||
sandbox: {
|
||||
provider: 'test',
|
||||
enforcement: 'full'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'goodbuddy/session/prepare') {
|
||||
return { prepared: true }
|
||||
}
|
||||
if (method === 'goodbuddy/session/release') {
|
||||
return { released: true }
|
||||
}
|
||||
if (method === 'goodbuddy/shutdown') {
|
||||
return { shutdown: true }
|
||||
}
|
||||
throw new Error(`unexpected extension: ${method}`)
|
||||
}
|
||||
),
|
||||
extNotification: vi.fn()
|
||||
}
|
||||
const connection = {
|
||||
...agent,
|
||||
signal: connectionController.signal,
|
||||
closed: connectionClosed.promise
|
||||
}
|
||||
const ClientSideConnection = vi.fn(function (
|
||||
this: unknown,
|
||||
toClient: (
|
||||
connectedAgent: typeof agent
|
||||
) => {
|
||||
requestPermission: typeof permissionHandler
|
||||
sessionUpdate: typeof updateHandler
|
||||
extMethod: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>
|
||||
extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<void>
|
||||
}
|
||||
) {
|
||||
const client = toClient(agent)
|
||||
permissionHandler = client.requestPermission
|
||||
updateHandler = client.sessionUpdate
|
||||
extensionHandler = client.extMethod
|
||||
agent.extNotification.mockImplementation(
|
||||
async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => client.extNotification(method, params)
|
||||
)
|
||||
return connection
|
||||
})
|
||||
const sdk = {
|
||||
PROTOCOL_VERSION: 1,
|
||||
ClientSideConnection,
|
||||
ndJsonStream: vi.fn(() => ({ stream: true }))
|
||||
} as unknown as DeepSeekHarnessAcpSdk
|
||||
const launch = vi.fn(async () => child)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: 'C:\\workspace',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch,
|
||||
loadAcpSdk: async () => sdk,
|
||||
initializationTimeoutMs: 100,
|
||||
promptTimeoutMs: options.promptTimeoutMs ?? 100,
|
||||
shutdownTimeoutMs: 10,
|
||||
maxStderrBytes: 16,
|
||||
maxEventCharacters: options.maxEventCharacters,
|
||||
maxRequestOutputCharacters:
|
||||
options.maxRequestOutputCharacters,
|
||||
toolProvider: options.toolProvider
|
||||
})
|
||||
const emit = async (
|
||||
sessionId: string,
|
||||
update: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
await updateHandler?.({ sessionId, update })
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
child,
|
||||
stderr,
|
||||
exit,
|
||||
sdk,
|
||||
launch,
|
||||
requests,
|
||||
notifications,
|
||||
promptGates,
|
||||
agent,
|
||||
permission: async (request: Permission) =>
|
||||
permissionHandler?.(request),
|
||||
extension: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => extensionHandler?.(method, params),
|
||||
notify: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => agent.extNotification(method, params),
|
||||
emit
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(
|
||||
stream: AsyncGenerator<RuntimeEvent, void, void>
|
||||
): Promise<RuntimeEvent[]> {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function request(
|
||||
conversationId: string,
|
||||
workMode: 'ask' | 'execute' = 'execute'
|
||||
) {
|
||||
return {
|
||||
requestId: `request-${conversationId}`,
|
||||
conversationId,
|
||||
prompt: 'hello',
|
||||
workMode
|
||||
} as const
|
||||
}
|
||||
|
||||
function permission(sessionId: string): Permission {
|
||||
return {
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: 'call-1',
|
||||
title: 'Run tests',
|
||||
name: 'shell',
|
||||
kind: 'execute',
|
||||
rawInput: { command: 'npm test' }
|
||||
},
|
||||
options: [
|
||||
{
|
||||
optionId: 'allow-once',
|
||||
name: 'Allow once',
|
||||
kind: 'allow_once'
|
||||
},
|
||||
{
|
||||
optionId: 'allow-always',
|
||||
name: 'Always allow',
|
||||
kind: 'allow_always'
|
||||
},
|
||||
{
|
||||
optionId: 'reject',
|
||||
name: 'Reject',
|
||||
kind: 'reject_once'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function mcpTool(
|
||||
name = 'mcp_deadbeef_cafebabe_game_asset'
|
||||
): ModelToolDefinition {
|
||||
return {
|
||||
name,
|
||||
displayName: 'Local Game Assets / game_asset',
|
||||
description: 'Returns a deterministic local game asset manifest.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string' }
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false
|
||||
},
|
||||
source: 'mcp',
|
||||
serverName: 'Local Game Assets'
|
||||
}
|
||||
}
|
||||
|
||||
function toolProvider(
|
||||
tools: ModelToolDefinition[] = [mcpTool()]
|
||||
): ModelToolProviderLike {
|
||||
return {
|
||||
listTools: vi.fn(async () => tools),
|
||||
getApproval: vi.fn((tool, _arguments, summary) => ({
|
||||
scopeKey: `model:mcp:${tool.name}`,
|
||||
title: `允许调用 MCP 工具「${tool.displayName}」?`,
|
||||
description: '调用本地测试 MCP。',
|
||||
toolName: tool.displayName,
|
||||
argumentSummary: summary,
|
||||
allowPermanent: false
|
||||
})),
|
||||
callTool: vi.fn(async () => ({
|
||||
parts: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: '{"asset":"cube"}'
|
||||
}
|
||||
],
|
||||
contextBytes: 16
|
||||
})),
|
||||
releaseConversation: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeekHarnessRuntime', () => {
|
||||
it('surfaces bounded internal Harness details from ACP errors', () => {
|
||||
expect(
|
||||
harnessPromptError(
|
||||
RequestError.internalError({
|
||||
details: 'DeepSeek provider rejected the request'
|
||||
})
|
||||
)
|
||||
).toEqual(
|
||||
new Error('DeepSeek provider rejected the request')
|
||||
)
|
||||
expect(
|
||||
harnessPromptError(
|
||||
RequestError.internalError({ unrelated: 'hidden' })
|
||||
)
|
||||
).toBeInstanceOf(RequestError)
|
||||
})
|
||||
|
||||
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
|
||||
const harness = setup()
|
||||
const first = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.emit('session-1', {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'hello ' }
|
||||
})
|
||||
await harness.emit('session-1', {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'world' }
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
expect(await first).toEqual([
|
||||
expect.objectContaining({ type: 'status' }),
|
||||
expect.objectContaining({ type: 'text', delta: 'hello ' }),
|
||||
expect.objectContaining({ type: 'text', delta: 'world' }),
|
||||
expect.objectContaining({
|
||||
type: 'done',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
])
|
||||
expect(harness.sdk.ndJsonStream).toHaveBeenCalledWith(
|
||||
harness.child.stdin,
|
||||
harness.child.stdout
|
||||
)
|
||||
expect(harness.launch).toHaveBeenCalledWith({
|
||||
cwd: 'C:\\workspace',
|
||||
signal: expect.any(AbortSignal),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
credentialRefs: [],
|
||||
requiredSandboxEnforcement: undefined,
|
||||
skillPackages: []
|
||||
})
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/prepare',
|
||||
params: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-one',
|
||||
mode: 'execute'
|
||||
}
|
||||
})
|
||||
|
||||
const second = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(2)
|
||||
)
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
await second
|
||||
expect(
|
||||
harness.requests.filter(({ method }) => method === 'session/new')
|
||||
).toHaveLength(1)
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('enforces the cumulative bridge limit against complete wire events', async () => {
|
||||
const harness = setup({
|
||||
maxEventCharacters: 1_000,
|
||||
maxRequestOutputCharacters: 180
|
||||
})
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('output-limit'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await harness.notify('goodbuddy/session/event', {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-output-limit',
|
||||
type: 'reasoning',
|
||||
delta: 'x'.repeat(40)
|
||||
})
|
||||
await harness.notify('goodbuddy/session/event', {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-output-limit',
|
||||
type: 'reasoning',
|
||||
delta: 'y'.repeat(40)
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
await expect(running).rejects.toThrow(
|
||||
'请求累计输出超过安全限制'
|
||||
)
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps independent conversation sessions distinct', async () => {
|
||||
const harness = setup()
|
||||
const first = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const second = collect(
|
||||
harness.runtime.run(
|
||||
request('two'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(2)
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
await Promise.all([first, second])
|
||||
|
||||
const prompts = harness.requests.filter(
|
||||
({ method }) => method === 'session/prompt'
|
||||
)
|
||||
expect(prompts.map(({ params }) => params.sessionId).sort()).toEqual([
|
||||
'session-1',
|
||||
'session-2'
|
||||
])
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails Ask closed and never calls the authorizer', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('ask', 'ask'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.permission(permission('session-1'))
|
||||
).resolves.toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'reject' }
|
||||
})
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/prepare',
|
||||
params: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-ask',
|
||||
mode: 'ask'
|
||||
}
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('authorizes Execute but can select only allow-once', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('always')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('execute'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.permission(permission('session-1'))
|
||||
).resolves.toEqual({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: 'allow-once'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
scopeKey: 'deepseek-harness:shell',
|
||||
title: 'Run tests',
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
toolName: 'shell',
|
||||
argumentSummary: '{\n "command": "npm test"\n}',
|
||||
allowPermanent: false
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('lists only bounded MCP schemas without exposing server secrets', async () => {
|
||||
const provider = toolProvider([
|
||||
mcpTool(),
|
||||
{
|
||||
...mcpTool('workspace_read_text'),
|
||||
source: 'builtin'
|
||||
}
|
||||
])
|
||||
const harness = setup({ toolProvider: provider })
|
||||
await harness.runtime.getStatus()
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tools: [
|
||||
{
|
||||
name: mcpTool().name,
|
||||
description: mcpTool().description,
|
||||
inputSchema: mcpTool().inputSchema
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(
|
||||
JSON.stringify(
|
||||
await harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
})
|
||||
)
|
||||
).not.toContain('secret')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('rejects MCP calls in Ask mode without approval or execution', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-ask', 'ask'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).rejects.toThrow('需要 Execute 模式')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('requires one-time approval before calling an assigned MCP tool', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-execute'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
content: [
|
||||
{ type: 'text', text: '{"asset":"cube"}' }
|
||||
]
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledTimes(1)
|
||||
expect(provider.callTool).toHaveBeenCalledWith(
|
||||
mcpTool().name,
|
||||
{ kind: 'cube' },
|
||||
expect.any(AbortSignal),
|
||||
expect.objectContaining({
|
||||
conversationId: 'mcp-execute',
|
||||
workMode: 'execute'
|
||||
})
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('lists and calls a real local stdio MCP through the Main proxy', async () => {
|
||||
const provider = new ModelToolProvider(process.cwd(), [
|
||||
{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54',
|
||||
name: 'Local 3D Game Blueprint',
|
||||
description: 'Deterministic integration fixture',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||
]
|
||||
} satisfies ResolvedMcpServer
|
||||
])
|
||||
const harness = setup({
|
||||
toolProvider: provider,
|
||||
promptTimeoutMs: 10_000
|
||||
})
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('real-mcp'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
try {
|
||||
const catalog = await harness.extension(
|
||||
'goodbuddy/tools/list',
|
||||
{ sessionId: 'session-1' }
|
||||
)
|
||||
const tool = (
|
||||
catalog as {
|
||||
tools: Array<{
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
).tools.find((candidate) =>
|
||||
candidate.name.endsWith('_create_game_blueprint')
|
||||
)
|
||||
expect(tool).toMatchObject({
|
||||
description: expect.stringContaining(
|
||||
'offline WebGL game design'
|
||||
),
|
||||
inputSchema: expect.objectContaining({ type: 'object' })
|
||||
})
|
||||
|
||||
const result = await harness.extension(
|
||||
'goodbuddy/tools/call',
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
name: tool!.name,
|
||||
arguments: {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining('"title":"Prism Relay"')
|
||||
}
|
||||
]
|
||||
})
|
||||
const blueprint = JSON.parse(
|
||||
(
|
||||
result as {
|
||||
content: [{ type: 'text'; text: string }]
|
||||
}
|
||||
).content[0].text
|
||||
) as Record<string, unknown>
|
||||
expect(blueprint).toMatchObject({
|
||||
acceptance: {
|
||||
testSurface: 'window.__GOODBUDDY_GAME__'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
harness.promptGates[0]?.resolve({ stopReason: 'end_turn' })
|
||||
await running.catch(() => undefined)
|
||||
await harness.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not execute an MCP tool when authorization is denied', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('deny')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-denied'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).rejects.toThrow('未获执行授权')
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('validates MCP arguments before requesting authorization', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-invalid'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: {}
|
||||
})
|
||||
).rejects.toThrow('MCP 工具参数无效')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('translates AbortSignal to session/cancel', async () => {
|
||||
const harness = setup()
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(request('abort'), controller.signal)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'cancelled' })
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails on bounded stderr overflow without exposing stderr text', async () => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('stderr'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
const writer = harness.stderr.writable.getWriter()
|
||||
await writer.write(
|
||||
new TextEncoder().encode('private-secret-is-too-long')
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
await expect(running).rejects.toThrow('stderr 超过 16 字节')
|
||||
await expect(running).rejects.not.toThrow('private-secret')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('reports process exit and fully disposes the connection and child', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
harness.exit.resolve({ exitCode: 9 })
|
||||
await vi.waitFor(async () => {
|
||||
const status = await harness.runtime.getStatus()
|
||||
expect(status).toMatchObject({
|
||||
available: false,
|
||||
detail: 'DeepSeek Harness 进程意外退出(code 9)'
|
||||
})
|
||||
})
|
||||
|
||||
await harness.runtime.dispose()
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the required bridge handshake is unavailable', async () => {
|
||||
const harness = setup()
|
||||
harness.agent.extMethod.mockRejectedValueOnce(
|
||||
new Error('method not found')
|
||||
)
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'method not found'
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out a prompt, cancels it, and bounds disposal wait', async () => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('timeout'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await expect(running).rejects.toThrow(
|
||||
'DeepSeek Harness 请求超时'
|
||||
)
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
await expect(harness.runtime.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||
createDeepSeekHarnessUtilityLauncher,
|
||||
parseHarnessControlMessage
|
||||
} from './deepseek-harness-utility-launcher'
|
||||
|
||||
class FakeUtility extends EventEmitter {
|
||||
readonly messages: unknown[] = []
|
||||
readonly stderr = new PassThrough()
|
||||
readonly pid = 123
|
||||
killed = false
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.messages.push(message)
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
const hostPath = join(
|
||||
root,
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
await Promise.all([
|
||||
mkdir(workspace),
|
||||
mkdir(dshHome),
|
||||
writeFile(hostPath, '', 'utf8')
|
||||
])
|
||||
return {
|
||||
dshHome,
|
||||
hostPath,
|
||||
launchOptions: {
|
||||
cwd: workspace,
|
||||
signal: new AbortController().signal,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness utility launcher', () => {
|
||||
it('accepts only strict control messages and secret-free config', () => {
|
||||
expect(
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
).toMatchObject({ type: 'ready' })
|
||||
expect(
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
apiKey: 'must-not-pass'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for Host readiness and sends no credential value', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
const fork = vi.fn(() => utility as never)
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: { PATH: 'C:\\Tools' },
|
||||
fork
|
||||
})
|
||||
|
||||
const launching = launcher(launchOptions)
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
expect(JSON.stringify(utility.messages[0])).not.toContain(
|
||||
'secret'
|
||||
)
|
||||
expect(utility.messages[0]).toMatchObject({
|
||||
type: 'start',
|
||||
config: {
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
||||
}
|
||||
})
|
||||
utility.emit('message', {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
|
||||
await expect(launching).resolves.toMatchObject({
|
||||
stdin: expect.any(WritableStream),
|
||||
stdout: expect.any(ReadableStream)
|
||||
})
|
||||
expect(fork).toHaveBeenCalledWith(
|
||||
hostPath,
|
||||
[],
|
||||
expect.objectContaining({
|
||||
cwd: launchOptions.cwd,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on an invalid Host startup message', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
const terminateProcess = vi.fn(() => {
|
||||
utility.killed = true
|
||||
})
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: {},
|
||||
fork: () => utility as never,
|
||||
terminateProcess
|
||||
})
|
||||
|
||||
const launching = launcher(launchOptions)
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
utility.emit('message', { type: 'ready' })
|
||||
|
||||
await expect(launching).rejects.toThrow('启动协议无效')
|
||||
expect(terminateProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
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 type {
|
||||
DeepSeekHarnessChild,
|
||||
DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { createDeepSeekHarnessUtilityChild } from './deepseek-harness-utility-transport'
|
||||
|
||||
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_DEEPSEEK_API_KEY'
|
||||
|
||||
const sandboxSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1).max(64),
|
||||
enforcement: z.enum(['full', 'partial'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
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((value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.hostname.toLowerCase() === 'api.deepseek.com' &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
}),
|
||||
api: z.literal('openai-completions'),
|
||||
provider: z.literal('goodbuddy'),
|
||||
model: z.string().min(1).max(128),
|
||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||
sandbox: sandboxSchema,
|
||||
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 type DeepSeekHarnessFork = (
|
||||
modulePath: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
serviceName: string
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
}
|
||||
) => UtilityProcess
|
||||
|
||||
export type DeepSeekHarnessUtilityLauncherOptions = {
|
||||
bundledHostPath: string
|
||||
dshHome: string
|
||||
environment: NodeJS.ProcessEnv
|
||||
fork: DeepSeekHarnessFork
|
||||
terminateProcess?: (utility: UtilityProcess) => void
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
function expectedSandbox(): ControlledHarnessBootstrapConfig['sandbox'] {
|
||||
return process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' }
|
||||
: { provider: 'local-linux', enforcement: 'full' }
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (
|
||||
codePoint !== undefined &&
|
||||
(codePoint <= 0x1f || codePoint === 0x7f)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function createDeepSeekHarnessUtilityLauncher(
|
||||
launcherOptions: DeepSeekHarnessUtilityLauncherOptions
|
||||
): (options: DeepSeekHarnessLaunchOptions) => Promise<DeepSeekHarnessChild> {
|
||||
return async (options) => {
|
||||
options.signal.throwIfAborted()
|
||||
const hostPath = launcherOptions.bundledHostPath
|
||||
if (!isAbsolute(hostPath)) {
|
||||
throw new Error('DeepSeek Harness Host 路径必须为绝对路径')
|
||||
}
|
||||
if (!isAbsolute(options.cwd) || !isAbsolute(launcherOptions.dshHome)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 工作区和隔离目录必须为绝对路径'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.model.length === 0 ||
|
||||
options.model.length > 128 ||
|
||||
hasControlCharacter(options.model)
|
||||
) {
|
||||
throw new Error('DeepSeek Harness 模型名称无效')
|
||||
}
|
||||
const canonicalSkillPackages = await Promise.all(
|
||||
options.skillPackages.map(async (skill) => {
|
||||
const directory = await realpath(skill.directory)
|
||||
const metadata = await stat(directory)
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness Skill 路径必须为目录'
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: skill.id,
|
||||
directory
|
||||
}
|
||||
})
|
||||
)
|
||||
const [canonicalHostPath, canonicalWorkspace, canonicalDshHome] =
|
||||
await Promise.all([
|
||||
realpath(hostPath),
|
||||
realpath(options.cwd),
|
||||
realpath(launcherOptions.dshHome)
|
||||
])
|
||||
const [hostMetadata, workspaceMetadata, homeMetadata] =
|
||||
await Promise.all([
|
||||
stat(canonicalHostPath),
|
||||
stat(canonicalWorkspace),
|
||||
stat(canonicalDshHome)
|
||||
])
|
||||
if (
|
||||
!hostMetadata.isFile() ||
|
||||
!workspaceMetadata.isDirectory() ||
|
||||
!homeMetadata.isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness Host、工作区或隔离目录类型无效'
|
||||
)
|
||||
}
|
||||
const sandbox = expectedSandbox()
|
||||
if (
|
||||
options.requiredSandboxEnforcement === 'full' &&
|
||||
sandbox.enforcement !== 'full'
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 当前平台只能提供部分沙箱强制'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.baseUrl !== 'https://api.deepseek.com' &&
|
||||
options.baseUrl !== 'https://api.deepseek.com/'
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 仅允许 api.deepseek.com'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.credentialRefs.length !== 1 ||
|
||||
options.credentialRefs[0] !==
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF
|
||||
) {
|
||||
throw new Error('DeepSeek Harness 凭据引用不受信任')
|
||||
}
|
||||
options.signal.throwIfAborted()
|
||||
const utility = launcherOptions.fork(canonicalHostPath, [], {
|
||||
cwd: canonicalWorkspace,
|
||||
env: launcherOptions.environment,
|
||||
serviceName: 'GoodBuddy DeepSeek Harness Host',
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
let terminated = false
|
||||
const terminate = (): void => {
|
||||
if (terminated) {
|
||||
return
|
||||
}
|
||||
terminated = true
|
||||
if (launcherOptions.terminateProcess) {
|
||||
launcherOptions.terminateProcess(utility)
|
||||
} else {
|
||||
utility.kill()
|
||||
}
|
||||
}
|
||||
const startupTimeoutMs =
|
||||
launcherOptions.startupTimeoutMs ?? 10_000
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let onAbort: (() => void) | undefined
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (onAbort) {
|
||||
options.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
utility.removeListener('message', onMessage)
|
||||
utility.removeListener('exit', onExit)
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
cleanup()
|
||||
terminate()
|
||||
reject(error)
|
||||
}
|
||||
const onMessage = (message: unknown): void => {
|
||||
const control = parseHarnessControlMessage(message)
|
||||
if (!control) {
|
||||
fail(new Error('DeepSeek Harness Host 启动协议无效'))
|
||||
return
|
||||
}
|
||||
if (control.type === 'ready') {
|
||||
cleanup()
|
||||
resolve()
|
||||
} else if (control.type === 'fatal') {
|
||||
fail(
|
||||
new Error(
|
||||
`DeepSeek Harness Host 启动失败(${control.code})`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
const onExit = (exitCode: number): void => {
|
||||
fail(
|
||||
new Error(
|
||||
`DeepSeek Harness Host 启动前退出(code ${exitCode})`
|
||||
)
|
||||
)
|
||||
}
|
||||
onAbort = () => {
|
||||
fail(
|
||||
options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('DeepSeek Harness Host 启动已取消')
|
||||
)
|
||||
}
|
||||
utility.on('message', onMessage)
|
||||
utility.on('exit', onExit)
|
||||
options.signal.addEventListener('abort', onAbort, {
|
||||
once: true
|
||||
})
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
fail(new Error('DeepSeek Harness Host 启动握手超时')),
|
||||
startupTimeoutMs
|
||||
)
|
||||
const config = controlledHarnessHostConfigSchema.parse({
|
||||
workspace: canonicalWorkspace,
|
||||
dshHome: canonicalDshHome,
|
||||
baseUrl: options.baseUrl,
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
sandbox,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: canonicalSkillPackages,
|
||||
maxFrameBytes: 1024 * 1024
|
||||
})
|
||||
utility.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config
|
||||
} satisfies DeepSeekHarnessControlMessage)
|
||||
})
|
||||
return createDeepSeekHarnessUtilityChild(utility, {
|
||||
stderrToWeb: (stderr) =>
|
||||
Readable.toWeb(stderr) as ReadableStream<Uint8Array>,
|
||||
terminateProcess: terminate
|
||||
})
|
||||
} catch (error) {
|
||||
terminate()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
DEEPSEEK_HARNESS_MAX_CHUNK_BYTES,
|
||||
createDeepSeekHarnessHostTransport,
|
||||
createDeepSeekHarnessUtilityChild,
|
||||
type DeepSeekHarnessParentPortLike
|
||||
} from './deepseek-harness-utility-transport'
|
||||
|
||||
type Listener = (value: unknown) => void
|
||||
|
||||
class LinkedPort {
|
||||
peer?: LinkedPort
|
||||
readonly sent: unknown[] = []
|
||||
private readonly listeners = new Set<Listener>()
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.sent.push(message)
|
||||
queueMicrotask(() => {
|
||||
for (const listener of this.peer?.listeners ?? []) {
|
||||
listener(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeUtility {
|
||||
readonly port = new LinkedPort()
|
||||
readonly stderr = 'node-stderr'
|
||||
readonly kill = vi.fn(() => true)
|
||||
private readonly listeners = {
|
||||
message: new Set<(message: unknown) => void>(),
|
||||
exit: new Set<(exitCode: number) => void>()
|
||||
}
|
||||
|
||||
constructor(hostPort: LinkedPort) {
|
||||
this.port.peer = hostPort
|
||||
hostPort.peer = this.port
|
||||
this.port.subscribe((message) => {
|
||||
for (const listener of this.listeners.message) {
|
||||
listener(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.port.postMessage(message)
|
||||
}
|
||||
|
||||
on(event: 'message', listener: (message: unknown) => void): void
|
||||
on(event: 'exit', listener: (exitCode: number) => void): void
|
||||
on(
|
||||
event: keyof typeof this.listeners,
|
||||
listener: ((message: unknown) => void) | ((exitCode: number) => void)
|
||||
): void {
|
||||
if (event === 'message') {
|
||||
this.listeners.message.add(listener as (message: unknown) => void)
|
||||
} else {
|
||||
this.listeners.exit.add(listener as (exitCode: number) => void)
|
||||
}
|
||||
}
|
||||
|
||||
removeListener(event: 'message', listener: (message: unknown) => void): void
|
||||
removeListener(event: 'exit', listener: (exitCode: number) => void): void
|
||||
removeListener(
|
||||
event: keyof typeof this.listeners,
|
||||
listener: ((message: unknown) => void) | ((exitCode: number) => void)
|
||||
): void {
|
||||
if (event === 'message') {
|
||||
this.listeners.message.delete(listener as (message: unknown) => void)
|
||||
} else {
|
||||
this.listeners.exit.delete(listener as (exitCode: number) => void)
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(message: unknown): void {
|
||||
for (const listener of this.listeners.message) {
|
||||
listener(message)
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(exitCode: number): void {
|
||||
for (const listener of this.listeners.exit) {
|
||||
listener(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asParentPort(port: LinkedPort): DeepSeekHarnessParentPortLike {
|
||||
const wrapped = new Map<Listener, () => void>()
|
||||
return {
|
||||
postMessage: (message) => port.postMessage(message),
|
||||
on: (_event, listener) => {
|
||||
const adapter: Listener = (data) => listener({ data })
|
||||
wrapped.set(listener as Listener, port.subscribe(adapter))
|
||||
},
|
||||
removeListener: (_event, listener) => {
|
||||
wrapped.get(listener as Listener)?.()
|
||||
wrapped.delete(listener as Listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const hostPort = new LinkedPort()
|
||||
const utility = new FakeUtility(hostPort)
|
||||
const stderr = new ReadableStream<Uint8Array>()
|
||||
const stderrToWeb = vi.fn(() => stderr)
|
||||
const child = createDeepSeekHarnessUtilityChild(utility, { stderrToWeb })
|
||||
const host = createDeepSeekHarnessHostTransport(asParentPort(hostPort))
|
||||
return { child, host, hostPort, utility, stderr, stderrToWeb }
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
||||
|
||||
describe('DeepSeek Harness utility byte transport', () => {
|
||||
it('ignores trusted control-plane messages that share the UtilityProcess port', async () => {
|
||||
const { child, hostPort, utility } = setup()
|
||||
await tick()
|
||||
utility.kill.mockClear()
|
||||
utility.emitMessage({
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready'
|
||||
})
|
||||
|
||||
const reader = child.stdout.getReader()
|
||||
const reading = reader.read()
|
||||
hostPort.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(7)
|
||||
})
|
||||
|
||||
await expect(reading).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(7)
|
||||
})
|
||||
expect(utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed for malformed control-plane lookalikes', async () => {
|
||||
const { child, utility } = setup()
|
||||
const reader = child.stdout.getReader()
|
||||
utility.emitMessage({
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready',
|
||||
unexpected: true
|
||||
})
|
||||
|
||||
await expect(reader.read()).rejects.toThrow('PROTOCOL_VIOLATION')
|
||||
expect(utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('transports bytes in both directions and adapts stderr and exit', async () => {
|
||||
const { child, host, utility, stderr, stderrToWeb } = setup()
|
||||
const childWriter = child.stdin.getWriter()
|
||||
const hostInput = host.stdin.getReader()
|
||||
const hostWriter = host.stdout.getWriter()
|
||||
const childOutput = child.stdout.getReader()
|
||||
|
||||
await childWriter.write(Uint8Array.of(1, 2, 3))
|
||||
await expect(hostInput.read()).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(1, 2, 3)
|
||||
})
|
||||
await hostWriter.write(Uint8Array.of(4, 5))
|
||||
await expect(childOutput.read()).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(4, 5)
|
||||
})
|
||||
|
||||
expect(stderrToWeb).toHaveBeenCalledWith('node-stderr')
|
||||
expect(child.stderr).toBe(stderr)
|
||||
utility.emitExit(7)
|
||||
await expect(child.exited).resolves.toEqual({ exitCode: 7 })
|
||||
})
|
||||
|
||||
it('splits chunks at 64 KiB and waits for ACK backpressure', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
const bytes = new Uint8Array(DEEPSEEK_HARNESS_MAX_CHUNK_BYTES + 3)
|
||||
bytes.fill(9)
|
||||
|
||||
let settled = false
|
||||
const writing = writer.write(bytes).then(() => {
|
||||
settled = true
|
||||
})
|
||||
await tick()
|
||||
expect(settled).toBe(false)
|
||||
expect(utility.port.sent).toHaveLength(1)
|
||||
expect(utility.port.sent[0]).toMatchObject({
|
||||
type: 'data',
|
||||
seq: 0,
|
||||
bytes: expect.objectContaining({
|
||||
byteLength: DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
const reader = host.stdin.getReader()
|
||||
expect((await reader.read()).value).toHaveLength(
|
||||
DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
)
|
||||
await tick()
|
||||
expect(utility.port.sent).toHaveLength(2)
|
||||
expect(utility.port.sent[1]).toMatchObject({
|
||||
type: 'data',
|
||||
seq: 1,
|
||||
bytes: Uint8Array.of(9, 9, 9)
|
||||
})
|
||||
expect((await reader.read()).value).toEqual(Uint8Array.of(9, 9, 9))
|
||||
await writing
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('applies bounded receiver backpressure until the queued chunk is read', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
await writer.write(Uint8Array.of(1))
|
||||
|
||||
let secondSettled = false
|
||||
const second = writer.write(Uint8Array.of(2)).then(() => {
|
||||
secondSettled = true
|
||||
})
|
||||
await tick()
|
||||
expect(secondSettled).toBe(false)
|
||||
expect(utility.port.sent).toHaveLength(2)
|
||||
|
||||
const reader = host.stdin.getReader()
|
||||
await expect(reader.read()).resolves.toMatchObject({
|
||||
value: Uint8Array.of(1)
|
||||
})
|
||||
await tick()
|
||||
await second
|
||||
expect(secondSettled).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unknown message', { surprise: true }],
|
||||
[
|
||||
'unknown type',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'wat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'extra field',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'ack',
|
||||
stream: 'stdin',
|
||||
seq: 0,
|
||||
extra: true
|
||||
}
|
||||
],
|
||||
[
|
||||
'oversized chunk',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: new Uint8Array(DEEPSEEK_HARNESS_MAX_CHUNK_BYTES + 1)
|
||||
}
|
||||
]
|
||||
])('fails closed for %s without including payloads in errors', async (_, message) => {
|
||||
const { child, utility } = setup()
|
||||
const reader = child.stdout.getReader()
|
||||
utility.emitMessage(message)
|
||||
|
||||
await expect(reader.read()).rejects.toThrow(
|
||||
'DeepSeek Harness byte transport failed (PROTOCOL_VIOLATION)'
|
||||
)
|
||||
expect(utility.kill).toHaveBeenCalledTimes(1)
|
||||
expect(String(await reader.closed.catch((error) => error))).not.toContain(
|
||||
'surprise'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed for duplicate and out-of-order sequence numbers', async () => {
|
||||
const first = setup()
|
||||
first.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 1,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await expect(first.child.stdout.getReader().read()).rejects.toThrow(
|
||||
'PROTOCOL_VIOLATION'
|
||||
)
|
||||
expect(first.utility.kill).toHaveBeenCalledOnce()
|
||||
|
||||
const second = setup()
|
||||
const reader = second.child.stdout.getReader()
|
||||
second.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await reader.read()
|
||||
second.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await expect(reader.read()).rejects.toThrow('PROTOCOL_VIOLATION')
|
||||
expect(second.utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('propagates close and cancellation idempotently', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
const reader = host.stdin.getReader()
|
||||
const closing = writer.close()
|
||||
await expect(reader.read()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined
|
||||
})
|
||||
await closing
|
||||
|
||||
const childOutput = child.stdout.getReader()
|
||||
await childOutput.cancel()
|
||||
const hostWriter = host.stdout.getWriter()
|
||||
await expect(hostWriter.write(Uint8Array.of(8))).rejects.toThrow(
|
||||
'REMOTE_CANCELLED'
|
||||
)
|
||||
|
||||
child.terminate()
|
||||
child.terminate()
|
||||
expect(utility.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels a chunk waiting behind the bounded readable queue', async () => {
|
||||
const { child, host } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
await writer.write(Uint8Array.of(1))
|
||||
const pendingWrite = writer.write(Uint8Array.of(2))
|
||||
await tick()
|
||||
|
||||
await host.stdin.cancel()
|
||||
await expect(pendingWrite).rejects.toThrow('REMOTE_CANCELLED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,678 @@
|
||||
import type { DeepSeekHarnessChild } from './deepseek-harness-runtime'
|
||||
|
||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.byte-stream'
|
||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION = 1
|
||||
export const DEEPSEEK_HARNESS_MAX_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
type StreamName = 'stdin' | 'stdout'
|
||||
type ForwardType = 'data' | 'close' | 'abort'
|
||||
|
||||
type MessageBase = {
|
||||
protocol: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION
|
||||
stream: StreamName
|
||||
seq: number
|
||||
}
|
||||
|
||||
type ProtocolMessage =
|
||||
| (MessageBase & {
|
||||
type: 'data'
|
||||
bytes: Uint8Array
|
||||
})
|
||||
| (MessageBase & { type: 'close' })
|
||||
| (MessageBase & { type: 'abort' })
|
||||
| (MessageBase & { type: 'ack' })
|
||||
| (MessageBase & { type: 'cancel' })
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION
|
||||
type: 'fail'
|
||||
}
|
||||
|
||||
type Deferred = {
|
||||
readonly promise: Promise<void>
|
||||
resolve(): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
type PendingSend = {
|
||||
readonly seq: number
|
||||
readonly deferred: Deferred
|
||||
}
|
||||
|
||||
type SenderState = {
|
||||
readonly stream: StreamName
|
||||
nextSeq: number
|
||||
pending?: PendingSend
|
||||
finished: boolean
|
||||
cancelled: boolean
|
||||
controller?: WritableStreamDefaultController
|
||||
}
|
||||
|
||||
type ReceiverState = {
|
||||
readonly stream: StreamName
|
||||
nextSeq: number
|
||||
pendingBytes?: Uint8Array
|
||||
finished: boolean
|
||||
cancelled: boolean
|
||||
controller?: ReadableStreamDefaultController<Uint8Array>
|
||||
}
|
||||
|
||||
type MessagePortAdapter = {
|
||||
postMessage(message: ProtocolMessage): void
|
||||
subscribe(listener: (message: unknown) => void): () => void
|
||||
}
|
||||
|
||||
type EndpointOptions = {
|
||||
readonly senderStream: StreamName
|
||||
readonly receiverStream: StreamName
|
||||
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
|
||||
const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER
|
||||
|
||||
class ByteTransportError extends Error {
|
||||
constructor(code: string) {
|
||||
super(`DeepSeek Harness byte transport failed (${code})`)
|
||||
this.name = 'ByteTransportError'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolvePromise: (() => void) | undefined
|
||||
let rejectPromise: ((error: Error) => void) | undefined
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolvePromise?.(),
|
||||
reject: (error) => rejectPromise?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[]
|
||||
): boolean {
|
||||
const keys = Object.keys(value)
|
||||
return (
|
||||
keys.length === expected.length &&
|
||||
expected.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
||||
)
|
||||
}
|
||||
|
||||
function isSequence(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0 &&
|
||||
value <= MAX_SEQUENCE
|
||||
)
|
||||
}
|
||||
|
||||
function parseMessage(value: unknown): ProtocolMessage | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.protocol !== DEEPSEEK_HARNESS_BYTE_PROTOCOL ||
|
||||
value.version !== DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value.type === 'fail') {
|
||||
return hasExactKeys(value, PROTOCOL_KEYS)
|
||||
? (value as ProtocolMessage)
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (
|
||||
!['data', 'close', 'abort', 'ack', 'cancel'].includes(value.type) ||
|
||||
(value.stream !== 'stdin' && value.stream !== 'stdout') ||
|
||||
!isSequence(value.seq)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value.type === 'data') {
|
||||
if (
|
||||
!hasExactKeys(value, DATA_KEYS) ||
|
||||
!(value.bytes instanceof Uint8Array) ||
|
||||
value.bytes.byteLength === 0 ||
|
||||
value.bytes.byteLength > DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return value as ProtocolMessage
|
||||
}
|
||||
|
||||
return hasExactKeys(value, STREAM_KEYS)
|
||||
? (value as 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)
|
||||
)
|
||||
}
|
||||
|
||||
class ByteTransportEndpoint {
|
||||
readonly writable: WritableStream<Uint8Array>
|
||||
readonly readable: ReadableStream<Uint8Array>
|
||||
|
||||
private readonly sender: SenderState
|
||||
private readonly receiver: ReceiverState
|
||||
private readonly unsubscribe: () => void
|
||||
private failed = false
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly port: MessagePortAdapter,
|
||||
private readonly options: EndpointOptions
|
||||
) {
|
||||
this.sender = {
|
||||
stream: options.senderStream,
|
||||
nextSeq: 0,
|
||||
finished: false,
|
||||
cancelled: false
|
||||
}
|
||||
this.receiver = {
|
||||
stream: options.receiverStream,
|
||||
nextSeq: 0,
|
||||
finished: false,
|
||||
cancelled: false
|
||||
}
|
||||
|
||||
this.writable = new WritableStream<Uint8Array>(
|
||||
{
|
||||
start: (controller) => {
|
||||
this.sender.controller = controller
|
||||
},
|
||||
write: async (chunk) => {
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
throw new ByteTransportError('INVALID_WRITE')
|
||||
}
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < chunk.byteLength;
|
||||
offset += DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
) {
|
||||
const bytes = chunk.slice(
|
||||
offset,
|
||||
offset + DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
)
|
||||
await this.sendForward('data', bytes)
|
||||
}
|
||||
},
|
||||
close: () => this.sendForward('close'),
|
||||
abort: () => this.sendForward('abort')
|
||||
},
|
||||
new CountQueuingStrategy({ highWaterMark: 1 })
|
||||
)
|
||||
|
||||
this.readable = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start: (controller) => {
|
||||
this.receiver.controller = controller
|
||||
},
|
||||
pull: () => {
|
||||
this.flushReceiver()
|
||||
},
|
||||
cancel: () => {
|
||||
this.cancelReceiver()
|
||||
}
|
||||
},
|
||||
new CountQueuingStrategy({ highWaterMark: 1 })
|
||||
)
|
||||
|
||||
this.unsubscribe = this.port.subscribe((message) => {
|
||||
if (isControlMessage(message)) {
|
||||
return
|
||||
}
|
||||
this.handleMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(code = 'CLOSED'): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.unsubscribe()
|
||||
const error = new ByteTransportError(code)
|
||||
this.sender.pending?.deferred.reject(error)
|
||||
this.sender.pending = undefined
|
||||
try {
|
||||
this.sender.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
try {
|
||||
this.receiver.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
}
|
||||
|
||||
private fail(code: string, notifyPeer: boolean): void {
|
||||
if (this.failed || this.disposed) {
|
||||
return
|
||||
}
|
||||
this.failed = true
|
||||
if (notifyPeer) {
|
||||
try {
|
||||
this.port.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'fail'
|
||||
})
|
||||
} catch {
|
||||
// The local endpoint still closes if peer notification fails.
|
||||
}
|
||||
}
|
||||
this.dispose(code)
|
||||
this.options.onFailure?.()
|
||||
}
|
||||
|
||||
private post(message: ProtocolMessage): boolean {
|
||||
if (this.failed || this.disposed) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
this.port.postMessage(message)
|
||||
return true
|
||||
} catch {
|
||||
this.fail('CHANNEL_FAILURE', false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async sendForward(
|
||||
type: ForwardType,
|
||||
bytes?: Uint8Array
|
||||
): Promise<void> {
|
||||
if (
|
||||
this.failed ||
|
||||
this.disposed ||
|
||||
this.sender.finished ||
|
||||
this.sender.cancelled
|
||||
) {
|
||||
throw new ByteTransportError(
|
||||
this.sender.cancelled ? 'REMOTE_CANCELLED' : 'CLOSED'
|
||||
)
|
||||
}
|
||||
if (this.sender.pending || this.sender.nextSeq > MAX_SEQUENCE) {
|
||||
this.fail('LOCAL_STATE', true)
|
||||
throw new ByteTransportError('LOCAL_STATE')
|
||||
}
|
||||
|
||||
const waiting = deferred()
|
||||
const seq = this.sender.nextSeq
|
||||
this.sender.pending = { seq, deferred: waiting }
|
||||
const message: ProtocolMessage =
|
||||
type === 'data'
|
||||
? {
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type,
|
||||
stream: this.sender.stream,
|
||||
seq,
|
||||
bytes: bytes as Uint8Array
|
||||
}
|
||||
: {
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type,
|
||||
stream: this.sender.stream,
|
||||
seq
|
||||
}
|
||||
|
||||
if (!this.post(message)) {
|
||||
await waiting.promise
|
||||
return
|
||||
}
|
||||
await waiting.promise
|
||||
if (type !== 'data') {
|
||||
this.sender.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(rawMessage: unknown): void {
|
||||
const message = parseMessage(rawMessage)
|
||||
if (!message) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
if (message.type === 'fail') {
|
||||
this.fail('REMOTE_FAILURE', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'ack') {
|
||||
this.handleAck(message)
|
||||
return
|
||||
}
|
||||
if (message.type === 'cancel') {
|
||||
this.handleCancel(message)
|
||||
return
|
||||
}
|
||||
this.handleForward(message)
|
||||
}
|
||||
|
||||
private handleAck(
|
||||
message: MessageBase & { type: 'ack' }
|
||||
): void {
|
||||
const pending = this.sender.pending
|
||||
if (
|
||||
message.stream !== this.sender.stream ||
|
||||
!pending ||
|
||||
message.seq !== pending.seq
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.sender.pending = undefined
|
||||
this.sender.nextSeq += 1
|
||||
pending.deferred.resolve()
|
||||
}
|
||||
|
||||
private handleCancel(
|
||||
message: MessageBase & { type: 'cancel' }
|
||||
): void {
|
||||
const pending = this.sender.pending
|
||||
if (
|
||||
message.stream !== this.sender.stream ||
|
||||
this.sender.finished ||
|
||||
this.sender.cancelled ||
|
||||
message.seq !== (pending?.seq ?? this.sender.nextSeq)
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.sender.cancelled = true
|
||||
this.sender.pending = undefined
|
||||
const error = new ByteTransportError('REMOTE_CANCELLED')
|
||||
pending?.deferred.reject(error)
|
||||
try {
|
||||
this.sender.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
}
|
||||
|
||||
private handleForward(
|
||||
message: Extract<ProtocolMessage, { type: ForwardType }>
|
||||
): void {
|
||||
if (
|
||||
message.stream !== this.receiver.stream ||
|
||||
this.receiver.finished ||
|
||||
this.receiver.cancelled ||
|
||||
message.seq !== this.receiver.nextSeq
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.receiver.nextSeq += 1
|
||||
|
||||
if (message.type === 'data') {
|
||||
if (this.receiver.pendingBytes) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.receiver.pendingBytes = message.bytes.slice()
|
||||
this.flushReceiver()
|
||||
return
|
||||
}
|
||||
|
||||
this.receiver.finished = true
|
||||
if (message.type === 'close') {
|
||||
try {
|
||||
this.receiver.controller?.close()
|
||||
} catch {
|
||||
this.fail('LOCAL_STATE', true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
this.receiver.controller?.error(
|
||||
new ByteTransportError('REMOTE_ABORTED')
|
||||
)
|
||||
} catch {
|
||||
// The stream may already have been cancelled.
|
||||
}
|
||||
}
|
||||
this.sendAck(message.seq)
|
||||
}
|
||||
|
||||
private flushReceiver(): void {
|
||||
const controller = this.receiver.controller
|
||||
const bytes = this.receiver.pendingBytes
|
||||
if (
|
||||
!controller ||
|
||||
!bytes ||
|
||||
this.receiver.cancelled ||
|
||||
this.receiver.finished ||
|
||||
(controller.desiredSize ?? 0) <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.receiver.pendingBytes = undefined
|
||||
controller.enqueue(bytes)
|
||||
this.sendAck(this.receiver.nextSeq - 1)
|
||||
}
|
||||
|
||||
private sendAck(seq: number): void {
|
||||
this.post({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'ack',
|
||||
stream: this.receiver.stream,
|
||||
seq
|
||||
})
|
||||
}
|
||||
|
||||
private cancelReceiver(): void {
|
||||
if (
|
||||
this.receiver.cancelled ||
|
||||
this.receiver.finished ||
|
||||
this.failed ||
|
||||
this.disposed
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.receiver.cancelled = true
|
||||
const cancelSeq = this.receiver.pendingBytes
|
||||
? this.receiver.nextSeq - 1
|
||||
: this.receiver.nextSeq
|
||||
this.receiver.pendingBytes = undefined
|
||||
this.post({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'cancel',
|
||||
stream: this.receiver.stream,
|
||||
seq: cancelSeq
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessUtilityProcessLike<Stderr = unknown> = {
|
||||
postMessage(message: unknown): void
|
||||
on(event: 'message', listener: (message: unknown) => void): unknown
|
||||
on(event: 'exit', listener: (exitCode: number) => void): unknown
|
||||
removeListener(
|
||||
event: 'message',
|
||||
listener: (message: unknown) => void
|
||||
): unknown
|
||||
removeListener(event: 'exit', listener: (exitCode: number) => void): unknown
|
||||
kill(): boolean
|
||||
readonly pid?: number
|
||||
readonly stderr?: Stderr | null
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessUtilityChildOptions<Stderr> = {
|
||||
stderrToWeb?: (stderr: Stderr) => ReadableStream<Uint8Array>
|
||||
terminateProcess?: (
|
||||
utilityProcess: DeepSeekHarnessUtilityProcessLike<Stderr>
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an Electron UtilityProcess without importing Electron at runtime.
|
||||
* Configure the utility process with piped stderr and inject Node's
|
||||
* Readable.toWeb when stderr capture is required.
|
||||
*/
|
||||
export function createDeepSeekHarnessUtilityChild<Stderr = unknown>(
|
||||
utilityProcess: DeepSeekHarnessUtilityProcessLike<Stderr>,
|
||||
options: DeepSeekHarnessUtilityChildOptions<Stderr> = {}
|
||||
): DeepSeekHarnessChild {
|
||||
let killed = false
|
||||
const killOnce = (): void => {
|
||||
if (killed) {
|
||||
return
|
||||
}
|
||||
killed = true
|
||||
if (options.terminateProcess) {
|
||||
options.terminateProcess(utilityProcess)
|
||||
} else {
|
||||
utilityProcess.kill()
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = new ByteTransportEndpoint(
|
||||
{
|
||||
postMessage: (message) => utilityProcess.postMessage(message),
|
||||
subscribe: (listener) => {
|
||||
const onMessage = (message: unknown): void => listener(message)
|
||||
utilityProcess.on('message', onMessage)
|
||||
return () => utilityProcess.removeListener('message', onMessage)
|
||||
}
|
||||
},
|
||||
{
|
||||
senderStream: 'stdin',
|
||||
receiverStream: 'stdout',
|
||||
onFailure: killOnce
|
||||
}
|
||||
)
|
||||
|
||||
let settleExit:
|
||||
| ((result: { exitCode: number | null; signal?: string | null }) => void)
|
||||
| undefined
|
||||
const exited = new Promise<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>((resolve) => {
|
||||
settleExit = resolve
|
||||
})
|
||||
let exitedSettled = false
|
||||
const onExit = (exitCode: number): void => {
|
||||
if (exitedSettled) {
|
||||
return
|
||||
}
|
||||
exitedSettled = true
|
||||
killed = true
|
||||
endpoint.dispose('PROCESS_EXITED')
|
||||
settleExit?.({ exitCode })
|
||||
}
|
||||
utilityProcess.on('exit', onExit)
|
||||
|
||||
const stderr =
|
||||
utilityProcess.stderr != null && options.stderrToWeb
|
||||
? options.stderrToWeb(utilityProcess.stderr)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
stdin: endpoint.writable,
|
||||
stdout: endpoint.readable,
|
||||
stderr,
|
||||
exited,
|
||||
terminate: () => {
|
||||
endpoint.dispose('TERMINATED')
|
||||
killOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ParentPortMessageEvent = {
|
||||
readonly data: unknown
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessParentPortLike = {
|
||||
postMessage(message: unknown): void
|
||||
on(
|
||||
event: 'message',
|
||||
listener: (event: ParentPortMessageEvent) => void
|
||||
): unknown
|
||||
removeListener(
|
||||
event: 'message',
|
||||
listener: (event: ParentPortMessageEvent) => void
|
||||
): unknown
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessHostTransport = {
|
||||
readonly stdin: ReadableStream<Uint8Array>
|
||||
readonly stdout: WritableStream<Uint8Array>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Creates the host-side streams backed by process.parentPort-like messaging. */
|
||||
export function createDeepSeekHarnessHostTransport(
|
||||
parentPort: DeepSeekHarnessParentPortLike
|
||||
): DeepSeekHarnessHostTransport {
|
||||
const endpoint = new ByteTransportEndpoint(
|
||||
{
|
||||
postMessage: (message) => parentPort.postMessage(message),
|
||||
subscribe: (listener) => {
|
||||
const onMessage = (event: ParentPortMessageEvent): void =>
|
||||
listener(event.data)
|
||||
parentPort.on('message', onMessage)
|
||||
return () => parentPort.removeListener('message', onMessage)
|
||||
}
|
||||
},
|
||||
{
|
||||
senderStream: 'stdout',
|
||||
receiverStream: 'stdin'
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
stdin: endpoint.readable,
|
||||
stdout: endpoint.writable,
|
||||
dispose: () => endpoint.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import { resolve } from 'node:path'
|
||||
import {
|
||||
GOODBUDDY_HANDSHAKE,
|
||||
GOODBUDDY_PREPARE,
|
||||
GoodBuddyCredentialProvider,
|
||||
GoodBuddyHarnessControlPlane,
|
||||
GoodBuddySandboxRetryLedger,
|
||||
createBoundedAcpStream
|
||||
} from './goodbuddy-harness-control-plane'
|
||||
|
||||
function execution(
|
||||
callId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
) {
|
||||
return {
|
||||
callId,
|
||||
rootCallId: callId,
|
||||
name,
|
||||
arguments: args,
|
||||
signal: new AbortController().signal,
|
||||
token: Symbol('execution')
|
||||
} as never
|
||||
}
|
||||
|
||||
const sandboxDenied = {
|
||||
isError: false,
|
||||
value: {
|
||||
sandbox: {
|
||||
denied: true
|
||||
}
|
||||
},
|
||||
content: []
|
||||
} as const
|
||||
|
||||
function controlPlane() {
|
||||
return new GoodBuddyHarnessControlPlane({} as Context, {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
})
|
||||
}
|
||||
|
||||
function stubAgentContext() {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
(...args: unknown[]) => unknown
|
||||
>()
|
||||
const extNotification = vi.fn(async () => undefined)
|
||||
const handle = {
|
||||
agent: {
|
||||
session: {
|
||||
id: 'session-output',
|
||||
header: { id: 'session-output' },
|
||||
events: []
|
||||
},
|
||||
cancel: vi.fn()
|
||||
}
|
||||
}
|
||||
const ctx = {
|
||||
on: vi.fn(
|
||||
(
|
||||
name: string,
|
||||
listener: (...args: unknown[]) => unknown
|
||||
) => {
|
||||
listeners.set(name, listener)
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
} as unknown as Context
|
||||
const subject = new GoodBuddyHarnessControlPlane(ctx, {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: [],
|
||||
maxEventCharacters: 10_000,
|
||||
maxRequestCharacters: 180
|
||||
})
|
||||
const internals = subject as unknown as {
|
||||
connection: {
|
||||
extNotification: typeof extNotification
|
||||
}
|
||||
sessions: Map<
|
||||
string,
|
||||
{
|
||||
handle: typeof handle
|
||||
inflight: {
|
||||
requestId: string
|
||||
messageId: string
|
||||
resolve: (reason: string) => void
|
||||
reject: (error: unknown) => void
|
||||
emittedCharacters: number
|
||||
eventTail: Promise<void>
|
||||
eventError?: unknown
|
||||
}
|
||||
}
|
||||
>
|
||||
observeSessions(): void
|
||||
}
|
||||
internals.connection = { extNotification }
|
||||
internals.sessions.set('session-output', {
|
||||
handle,
|
||||
inflight: {
|
||||
requestId: 'request-output',
|
||||
messageId: 'message-output',
|
||||
resolve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
emittedCharacters: 0,
|
||||
eventTail: Promise.resolve()
|
||||
}
|
||||
})
|
||||
internals.observeSessions()
|
||||
return { listeners, extNotification, handle, internals }
|
||||
}
|
||||
|
||||
describe('GoodBuddy Harness internal control plane', () => {
|
||||
it('requires a versioned handshake before privileged extensions', async () => {
|
||||
const subject = controlPlane()
|
||||
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_PREPARE, {
|
||||
sessionId: 'session',
|
||||
requestId: 'request',
|
||||
mode: 'execute'
|
||||
})
|
||||
).rejects.toThrow('GoodBuddy handshake is required')
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_HANDSHAKE, {
|
||||
controlProtocolVersion: 9
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'incompatible GoodBuddy Harness control protocol'
|
||||
)
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_HANDSHAKE, {
|
||||
controlProtocolVersion: 1
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
controlProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
sessionRelease: true,
|
||||
oneShotApproval: true,
|
||||
credentialResolution: true
|
||||
},
|
||||
sandbox: { enforcement: 'full' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps credentials memory-only, allowlisted, and read-only', async () => {
|
||||
const provider = new GoodBuddyCredentialProvider(
|
||||
new Context(),
|
||||
new Set(['GOODBUDDY_API_KEY'])
|
||||
)
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockResolvedValue('secret-from-main')
|
||||
provider.bind(resolver)
|
||||
|
||||
await expect(
|
||||
provider.resolve('GOODBUDDY_API_KEY' as never)
|
||||
).resolves.toEqual({
|
||||
value: 'secret-from-main',
|
||||
source: 'goodbuddy-main'
|
||||
})
|
||||
await expect(
|
||||
provider.resolve('OTHER_KEY' as never)
|
||||
).resolves.toBeUndefined()
|
||||
expect(resolver).toHaveBeenCalledTimes(1)
|
||||
await expect(
|
||||
provider.set('GOODBUDDY_API_KEY' as never, 'x')
|
||||
).rejects.toThrow('read-only')
|
||||
})
|
||||
|
||||
it('fails closed on oversized inbound and outbound ACP frames', async () => {
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const stream = createBoundedAcpStream(
|
||||
({
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as unknown as Stream),
|
||||
16
|
||||
)
|
||||
const inputWriter = inbound.writable.getWriter()
|
||||
const reader = stream.readable.getReader()
|
||||
const read = reader.read()
|
||||
await inputWriter.write({ value: 'too-long-for-frame' })
|
||||
await expect(read).rejects.toThrow('input frame exceeds')
|
||||
|
||||
const writer = stream.writable.getWriter()
|
||||
await expect(
|
||||
writer.write({ value: 'too-long-for-frame' } as never)
|
||||
).rejects.toThrow('output frame exceeds')
|
||||
})
|
||||
|
||||
it('counts the complete emitted envelope against the request limit', async () => {
|
||||
const { listeners, extNotification, handle, internals } =
|
||||
stubAgentContext()
|
||||
const sessionEvent = listeners.get('session/event')!
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
await internals.sessions.get('session-output')!.inflight.eventTail
|
||||
|
||||
expect(extNotification).toHaveBeenCalledTimes(1)
|
||||
expect(handle.agent.cancel).toHaveBeenCalledWith({
|
||||
kind: 'user'
|
||||
})
|
||||
expect(
|
||||
internals.sessions.get('session-output')!.inflight.eventError
|
||||
).toEqual(
|
||||
new Error(
|
||||
'GoodBuddy Harness control request output exceeds safety limit'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
internals.sessions.get('session-output')!.inflight.emittedCharacters
|
||||
).toBeGreaterThan(180)
|
||||
})
|
||||
|
||||
it('requires a matching real denial and consumes it once', () => {
|
||||
const ledger = new GoodBuddySandboxRetryLedger()
|
||||
const deniedArguments = {
|
||||
command: 'type C:\\outside\\file.txt',
|
||||
description: 'Read an outside file'
|
||||
}
|
||||
const retry = {
|
||||
...deniedArguments,
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
}
|
||||
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
|
||||
ledger.record(
|
||||
execution('denial-1', 'pwsh', deniedArguments),
|
||||
sandboxDenied as never
|
||||
)
|
||||
expect(
|
||||
ledger.consumeRetry('pwsh', {
|
||||
...retry,
|
||||
command: 'type C:\\different\\file.txt'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(ledger.consumeRetry('bash', retry)).toBe(false)
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(true)
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-denials, narrow escalation, and reordered ambiguity', () => {
|
||||
const ledger = new GoodBuddySandboxRetryLedger()
|
||||
const deniedArguments = {
|
||||
description: 'Read an outside file',
|
||||
command: 'cat /outside/file'
|
||||
}
|
||||
ledger.record(execution('success', 'bash', deniedArguments), {
|
||||
isError: false,
|
||||
value: {},
|
||||
content: []
|
||||
} as never)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
ledger.record(
|
||||
execution('denial-2', 'bash', deniedArguments),
|
||||
sandboxDenied as never
|
||||
)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'Retry in workspace-write.'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildExplicitProfileRuntimeEnvironment,
|
||||
buildControlledHarnessEnvironment,
|
||||
buildRuntimeEnvironment
|
||||
} from './process-environment'
|
||||
|
||||
@@ -89,4 +90,30 @@ describe('buildRuntimeEnvironment', () => {
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a credential-free, telemetry-disabled Harness environment', () => {
|
||||
expect(
|
||||
buildControlledHarnessEnvironment('C:\\isolated-dsh', {
|
||||
PATH: 'C:\\Tools',
|
||||
TEMP: 'C:\\Temp',
|
||||
OPENAI_API_KEY: 'must-not-leak',
|
||||
DEEPSEEK_API_KEY: 'must-not-leak',
|
||||
DSH_HOME: 'C:\\user-dsh',
|
||||
NODE_OPTIONS: '--require malicious.js'
|
||||
})
|
||||
).toMatchObject({
|
||||
PATH: 'C:\\Tools',
|
||||
TEMP: 'C:\\Temp',
|
||||
DSH_HOME: 'C:\\isolated-dsh',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DO_NOT_TRACK: '1',
|
||||
OTEL_SDK_DISABLED: 'true'
|
||||
})
|
||||
expect(
|
||||
buildControlledHarnessEnvironment('C:\\isolated-dsh', {
|
||||
OPENAI_API_KEY: 'must-not-leak',
|
||||
DEEPSEEK_API_KEY: 'must-not-leak'
|
||||
})
|
||||
).not.toHaveProperty('OPENAI_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,3 +92,20 @@ export function buildExplicitProfileRuntimeEnvironment(
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
export function buildControlledHarnessEnvironment(
|
||||
dshHome: string,
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment = buildExplicitProfileRuntimeEnvironment(
|
||||
{
|
||||
DSH_HOME: dshHome,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
...runtimePrivacyEnvironment
|
||||
},
|
||||
undefined,
|
||||
source
|
||||
)
|
||||
delete environment.NODE_TLS_REJECT_UNAUTHORIZED
|
||||
return environment
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'configured'
|
||||
})
|
||||
expect(detection.version).toMatch(/^\d+\.\d+\.\d+/u)
|
||||
})
|
||||
@@ -69,7 +70,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'automatic'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +85,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'configured'
|
||||
})
|
||||
expect(detection.detail).not.toContain('内置')
|
||||
})
|
||||
@@ -101,7 +104,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
})
|
||||
@@ -115,15 +119,57 @@ describe('runtime discovery', () => {
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '1.5.47',
|
||||
binaryNames: ['goodbuddy-runtime-that-does-not-exist'],
|
||||
label: 'Script Runtime'
|
||||
})
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript)
|
||||
path: await realpath(bundledScript),
|
||||
version: '1.5.47',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toBe(
|
||||
'内置 Script Runtime 1.5.47 已就绪'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a controlled bundled harness when no custom host is configured', async () => {
|
||||
const bundledScript = fileURLToPath(import.meta.url)
|
||||
const detection = await detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '0.1.0-rc.6',
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript),
|
||||
version: '0.1.0-rc.6',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
})
|
||||
|
||||
it('does not discover arbitrary DeepSeek Harness hosts from PATH', async () => {
|
||||
process.env.PATH = dirname(process.execPath)
|
||||
process.env.Path = dirname(process.execPath)
|
||||
|
||||
await expect(
|
||||
detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
allowAutomaticDiscovery: false,
|
||||
binaryNames: [basename(process.execPath)],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
available: false,
|
||||
detail: expect.stringContaining('未自动检测到')
|
||||
})
|
||||
expect(detection.detail).toBe('内置 Script Runtime 已就绪')
|
||||
})
|
||||
|
||||
it('returns both runtime detections without exposing PATH contents', async () => {
|
||||
@@ -144,6 +190,7 @@ describe('runtime discovery', () => {
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
})
|
||||
expect(result.deepseekHarness.available).toBe(false)
|
||||
expect(JSON.stringify(result)).not.toContain(privatePathValue)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export type RuntimeBinaryDiscoveryInput = {
|
||||
binaryPath: string
|
||||
bundledPath?: string
|
||||
bundledValidation?: 'execute' | 'canonical-file'
|
||||
bundledVersion?: string
|
||||
allowAutomaticDiscovery?: boolean
|
||||
binaryNames: readonly string[]
|
||||
label: string
|
||||
}
|
||||
@@ -246,13 +248,14 @@ function availableDetection(
|
||||
label: string,
|
||||
path: string,
|
||||
version?: string,
|
||||
bundled = false
|
||||
source: 'bundled' | 'configured' | 'automatic' = 'automatic'
|
||||
): RuntimeBinaryDetection {
|
||||
return {
|
||||
available: true,
|
||||
path,
|
||||
version,
|
||||
detail: `${bundled ? '内置 ' : ''}${label}${
|
||||
source,
|
||||
detail: `${source === 'bundled' ? '内置 ' : ''}${label}${
|
||||
version ? ` ${version}` : ''
|
||||
} 已就绪`
|
||||
}
|
||||
@@ -264,6 +267,36 @@ export async function detectRuntimeBinary(
|
||||
const configuredPath = input.binaryPath.trim()
|
||||
let configuredPathProblem: 'relative' | 'invalid' | 'validation' | undefined
|
||||
|
||||
const detectBundled = async (): Promise<
|
||||
RuntimeBinaryDetection | undefined
|
||||
> => {
|
||||
const bundledPath = input.bundledPath?.trim()
|
||||
if (!bundledPath) {
|
||||
return undefined
|
||||
}
|
||||
const canonicalPath = await canonicalFile(bundledPath)
|
||||
if (!canonicalPath) {
|
||||
return undefined
|
||||
}
|
||||
if (input.bundledValidation === 'canonical-file') {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
input.bundledVersion,
|
||||
'bundled'
|
||||
)
|
||||
}
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
return validation.valid
|
||||
? availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version,
|
||||
'bundled'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (configuredPath) {
|
||||
if (!isAbsolute(configuredPath)) {
|
||||
configuredPathProblem = 'relative'
|
||||
@@ -277,7 +310,8 @@ export async function detectRuntimeBinary(
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version
|
||||
validation.version,
|
||||
'configured'
|
||||
)
|
||||
}
|
||||
configuredPathProblem = 'validation'
|
||||
@@ -285,47 +319,31 @@ export async function detectRuntimeBinary(
|
||||
}
|
||||
}
|
||||
|
||||
const bundledPath = input.bundledPath?.trim()
|
||||
if (bundledPath) {
|
||||
const canonicalPath = await canonicalFile(bundledPath)
|
||||
if (canonicalPath) {
|
||||
if (input.bundledValidation === 'canonical-file') {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
const bundled = await detectBundled()
|
||||
if (bundled) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let foundAutomaticCandidate = false
|
||||
if (input.allowAutomaticDiscovery !== false) {
|
||||
for (const candidate of automaticCandidates(input.binaryNames)) {
|
||||
const canonicalPath = await canonicalFile(candidate)
|
||||
if (!canonicalPath) {
|
||||
continue
|
||||
}
|
||||
foundAutomaticCandidate = true
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
if (validation.valid) {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version,
|
||||
true
|
||||
'automatic'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let foundAutomaticCandidate = false
|
||||
for (const candidate of automaticCandidates(input.binaryNames)) {
|
||||
const canonicalPath = await canonicalFile(candidate)
|
||||
if (!canonicalPath) {
|
||||
continue
|
||||
}
|
||||
foundAutomaticCandidate = true
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
if (validation.valid) {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let detail: string
|
||||
if (foundAutomaticCandidate || configuredPathProblem === 'validation') {
|
||||
detail = `${input.label} 候选未通过 --version 安全验证`
|
||||
@@ -349,9 +367,14 @@ export async function detectAgentRuntimes(input: {
|
||||
bundledPaths?: {
|
||||
opencode: string
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
bundledVersions?: {
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
}): Promise<AgentRuntimeDetection> {
|
||||
const [opencode, continueRuntime] = await Promise.all([
|
||||
const [opencode, continueRuntime, deepseekHarness] = await Promise.all([
|
||||
detectRuntimeBinary({
|
||||
binaryPath: input.opencodeBinaryPath,
|
||||
bundledPath: input.bundledPaths?.opencode,
|
||||
@@ -362,13 +385,24 @@ export async function detectAgentRuntimes(input: {
|
||||
binaryPath: input.continueBinaryPath,
|
||||
bundledPath: input.bundledPaths?.continue,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: input.bundledVersions?.continue,
|
||||
binaryNames: ['cn'],
|
||||
label: 'Continue CLI'
|
||||
}),
|
||||
detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
bundledPath: input.bundledPaths?.deepseekHarness,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: input.bundledVersions?.deepseekHarness,
|
||||
allowAutomaticDiscovery: false,
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
opencode,
|
||||
continue: continueRuntime
|
||||
continue: continueRuntime,
|
||||
deepseekHarness
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const defaultProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const secondProfileId = '00000000-0000-4000-8000-000000000002'
|
||||
const responsesProfileId = '00000000-0000-4000-8000-000000000003'
|
||||
const imageProfileId = '00000000-0000-4000-8000-000000000004'
|
||||
const deepseekProfileId = '00000000-0000-4000-8000-000000000005'
|
||||
|
||||
function settings(
|
||||
overrides: Partial<ResolvedRuntimeSettings> = {}
|
||||
@@ -62,6 +63,16 @@ function settings(
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: 'image-key'
|
||||
},
|
||||
{
|
||||
id: deepseekProfileId,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: 'deepseek-key'
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
@@ -154,13 +165,62 @@ describe('runtime selection', () => {
|
||||
).toThrow('自动启动')
|
||||
})
|
||||
|
||||
it('selects DeepSeek Harness only with an official compatible profile', () => {
|
||||
const selected = applyRuntimeSelection(settings(), {
|
||||
provider: 'deepseek-harness',
|
||||
profileId: deepseekProfileId
|
||||
})
|
||||
expect(selected.target).toBe('deepseek-harness')
|
||||
expect(selected.settings).toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: { id: deepseekProfileId }
|
||||
})
|
||||
expect(() =>
|
||||
applyRuntimeSelection(settings(), {
|
||||
provider: 'deepseek-harness',
|
||||
profileId: secondProfileId
|
||||
})
|
||||
).toThrow('api.deepseek.com')
|
||||
})
|
||||
|
||||
it('keeps the controlled platform DeepSeek profile when selected without a profile ID', () => {
|
||||
const base = settings()
|
||||
const platformProfile = {
|
||||
...base.modelProfiles[4]!,
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek',
|
||||
modelName: 'deepseek-v4-flash'
|
||||
}
|
||||
const selected = applyRuntimeSelection(
|
||||
settings({ deepseekHarnessModelProfile: platformProfile }),
|
||||
{ provider: 'deepseek-harness' }
|
||||
)
|
||||
|
||||
expect(selected.settings).toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
modelName: 'deepseek-v4-flash'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves Agent Runtime backends from the global Runtime configuration', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
opencodeModelProfile: base.modelProfiles[1],
|
||||
continueModelProfile: base.modelProfiles[2]
|
||||
continueModelProfile: base.modelProfiles[2],
|
||||
deepseekHarnessModelProfile: base.modelProfiles[4]
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'deepseek-harness',
|
||||
profileId: deepseekProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'opencode',
|
||||
@@ -189,6 +249,23 @@ describe('runtime selection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the controlled platform DeepSeek source profile-free across configured selection repair', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
deepseekHarnessModelProfile: {
|
||||
...base.modelProfiles[4]!,
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek'
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
).toEqual({ provider: 'deepseek-harness' })
|
||||
})
|
||||
|
||||
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
|
||||
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
|
||||
expect(
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { isAgentRuntimeModelProtocol } from '../../shared/contracts'
|
||||
import {
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type {
|
||||
ResolvedModelProfile,
|
||||
ResolvedRuntimeSettings
|
||||
} from '../runtime-settings-store'
|
||||
|
||||
export type SelectedRuntimeTarget = 'model' | 'opencode' | 'continue'
|
||||
export type SelectedRuntimeTarget =
|
||||
| 'model'
|
||||
| 'opencode'
|
||||
| 'continue'
|
||||
| 'deepseek-harness'
|
||||
|
||||
function requireProfile(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
@@ -26,6 +33,9 @@ export function getConfiguredRuntimeTarget(
|
||||
if (settings.provider === 'continue') {
|
||||
return 'continue'
|
||||
}
|
||||
if (settings.provider === 'deepseek-harness') {
|
||||
return 'deepseek-harness'
|
||||
}
|
||||
if (
|
||||
settings.provider === 'opencode' ||
|
||||
settings.provider === 'auto'
|
||||
@@ -41,17 +51,24 @@ export function resolveConfiguredAgentRuntimeSelection(
|
||||
): AgentRuntimeSelection {
|
||||
if (
|
||||
selection.provider !== 'opencode' &&
|
||||
selection.provider !== 'continue'
|
||||
selection.provider !== 'continue' &&
|
||||
selection.provider !== 'deepseek-harness'
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
const profile =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelProfile
|
||||
: settings.continueModelProfile
|
||||
: selection.provider === 'continue'
|
||||
? settings.continueModelProfile
|
||||
: settings.deepseekHarnessModelProfile
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(profile ? { profileId: profile.id } : {})
|
||||
...(profile && settings.modelProfiles.some(
|
||||
(candidate) => candidate.id === profile.id
|
||||
)
|
||||
? { profileId: profile.id }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +131,27 @@ export function applyRuntimeSelection(
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
const selectedProfile =
|
||||
profile ?? settings.deepseekHarnessModelProfile
|
||||
if (
|
||||
selectedProfile &&
|
||||
!isDeepSeekHarnessModelProfile(selectedProfile)
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 独立模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
||||
)
|
||||
}
|
||||
return {
|
||||
target: 'deepseek-harness',
|
||||
settings: {
|
||||
...settings,
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: selectedProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
profile &&
|
||||
!isAgentRuntimeModelProtocol(profile.protocol)
|
||||
|
||||
@@ -32,7 +32,7 @@ export type RuntimeModelUsageEvent = {
|
||||
requestId: string
|
||||
type: 'model-usage'
|
||||
callId: string
|
||||
runtime: 'model' | 'continue' | 'opencode'
|
||||
runtime: 'model' | 'continue' | 'opencode' | 'deepseek-harness'
|
||||
provider: string
|
||||
model: string
|
||||
inputTokens: number
|
||||
|
||||
@@ -63,6 +63,13 @@ describe('bundled skills', () => {
|
||||
expect(snapshot.skills.map((skill) => skill.id)).toContain(
|
||||
'product-marketing'
|
||||
)
|
||||
expect(snapshot.skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'web-3d-game',
|
||||
name: 'Web 3D Game',
|
||||
assignments: expect.arrayContaining(['deepseek-harness'])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('injects every enabled bundled skill with its resolved directory', async () => {
|
||||
@@ -76,4 +83,19 @@ describe('bundled skills', () => {
|
||||
expect(instructions).toContain(join(builtinSkillsRoot, skill.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the 3D game Skill as a native Harness package', async () => {
|
||||
const service = await createService()
|
||||
|
||||
await expect(
|
||||
service.getRuntimeSkillContext('deepseek-harness')
|
||||
).resolves.toMatchObject({
|
||||
packages: expect.arrayContaining([
|
||||
{
|
||||
id: 'web-3d-game',
|
||||
directory: join(builtinSkillsRoot, 'web-3d-game')
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -246,7 +246,12 @@ describe('CapabilityService', () => {
|
||||
id: 'document-writing',
|
||||
source: 'builtin',
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue']
|
||||
assignments: [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -625,9 +630,31 @@ describe('CapabilityService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects MCP assignments to Agent Runtimes', async () => {
|
||||
it('allows Harness MCP assignment and rejects unsupported Agent Runtimes', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Harness MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({
|
||||
assignments: ['deepseek-harness']
|
||||
})
|
||||
]
|
||||
})
|
||||
await expect(
|
||||
service.getResolvedMcpServers('deepseek-harness')
|
||||
).resolves.toHaveLength(1)
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Agent MCP',
|
||||
@@ -640,7 +667,7 @@ describe('CapabilityService', () => {
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).rejects.toThrow('只能分配给直连模型')
|
||||
).rejects.toThrow('只能分配给直连模型或 DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
||||
|
||||
@@ -269,7 +269,12 @@ function emptyStoredCapabilities(
|
||||
function defaultSkillState(): z.infer<typeof skillStateSchema> {
|
||||
return {
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue']
|
||||
assignments: [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1606,10 +1611,14 @@ export class CapabilityService {
|
||||
const value = mcpServerInputSchema.parse(input)
|
||||
if (
|
||||
value.assignments.some(
|
||||
(assignment) => assignment !== 'model'
|
||||
(assignment) =>
|
||||
assignment !== 'model' &&
|
||||
assignment !== 'deepseek-harness'
|
||||
)
|
||||
) {
|
||||
throw new Error('当前版本的 MCP Server 只能分配给直连模型')
|
||||
throw new Error(
|
||||
'当前版本的 MCP Server 只能分配给直连模型或 DeepSeek Harness'
|
||||
)
|
||||
}
|
||||
const state = await this.load()
|
||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||
@@ -1809,7 +1818,7 @@ export class CapabilityService {
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
if (target !== 'model') {
|
||||
if (target !== 'model' && target !== 'deepseek-harness') {
|
||||
return []
|
||||
}
|
||||
const state = await this.load()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
parseHarnessControlMessage,
|
||||
type DeepSeekHarnessControlMessage
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { createDeepSeekHarnessHostTransport } from './agent/deepseek-harness-utility-transport'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
ControlledHarnessHostStartupError,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost,
|
||||
type ControlledHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
|
||||
const parentPort = process.parentPort
|
||||
const restoreDiagnostics = installHarnessDiagnosticGuard()
|
||||
// The Windows ACL sandbox launches its JavaScript runner through
|
||||
// `process.execPath`. Inside an Electron UtilityProcess that path is Electron,
|
||||
// so descendants must opt into Electron's supported Node execution mode.
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ELECTRON_RUN_AS_NODE = '1'
|
||||
}
|
||||
let host: ControlledHarnessHost | undefined
|
||||
let transport:
|
||||
| ReturnType<typeof createDeepSeekHarnessHostTransport>
|
||||
| undefined
|
||||
let starting = false
|
||||
let closed = false
|
||||
|
||||
function post(message: DeepSeekHarnessControlMessage): void {
|
||||
if (!closed) {
|
||||
parentPort.postMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
await host?.dispose().catch(() => undefined)
|
||||
transport?.dispose()
|
||||
restoreDiagnostics()
|
||||
}
|
||||
|
||||
function fatal(code: string): void {
|
||||
post({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'fatal',
|
||||
code
|
||||
})
|
||||
void close().finally(() => {
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
parentPort.on('message', (event) => {
|
||||
const message = parseHarnessControlMessage(event.data)
|
||||
if (!message) {
|
||||
// Once the byte transport is installed, non-control messages belong to
|
||||
// that transport's listener on the shared UtilityProcess port.
|
||||
if (transport) {
|
||||
return
|
||||
}
|
||||
fatal('INVALID_START')
|
||||
return
|
||||
}
|
||||
if (message.type !== 'start') {
|
||||
fatal('INVALID_START')
|
||||
return
|
||||
}
|
||||
if (starting || host || closed) {
|
||||
fatal('DUPLICATE_START')
|
||||
return
|
||||
}
|
||||
starting = true
|
||||
transport = createDeepSeekHarnessHostTransport(parentPort)
|
||||
void startControlledDeepSeekHarnessHost({
|
||||
...message.config,
|
||||
stream: createBoundedNdJsonStream(
|
||||
transport.stdout,
|
||||
transport.stdin,
|
||||
message.config.maxFrameBytes
|
||||
)
|
||||
})
|
||||
.then((startedHost) => {
|
||||
host = startedHost
|
||||
starting = false
|
||||
post({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
transport?.dispose()
|
||||
fatal(
|
||||
error instanceof ControlledHarnessHostStartupError
|
||||
? error.code
|
||||
: 'HOST_START_FAILED'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
process.once('disconnect', () => {
|
||||
void close()
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void close()
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
import { vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import type {
|
||||
Agent,
|
||||
CreateAgentOptions
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './agent/goodbuddy-harness-control-plane'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
|
||||
const expectedSandbox =
|
||||
process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' as const }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' as const }
|
||||
: { provider: 'local-linux', enforcement: 'full' as const }
|
||||
|
||||
async function readAllMessages(
|
||||
readable: ReadableStream<unknown>
|
||||
): Promise<unknown[]> {
|
||||
const values: unknown[] = []
|
||||
for await (const value of readable) {
|
||||
values.push(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
describe('controlled DeepSeek Harness host', () => {
|
||||
it('rejects unsupported endpoint protocols before Cordis starts', async () => {
|
||||
await expect(
|
||||
startControlledDeepSeekHarnessHost({
|
||||
workspace: 'C:\\workspace',
|
||||
baseUrl: 'file:///private/config',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
dshHome: 'C:\\controlled-dsh-home',
|
||||
skillPackages: []
|
||||
})
|
||||
).rejects.toThrow('trusted HTTPS DeepSeek endpoint')
|
||||
})
|
||||
|
||||
it('suppresses console payloads instead of contaminating stdout', () => {
|
||||
const restore = installHarnessDiagnosticGuard()
|
||||
const originalWrite = process.stderr.write
|
||||
const writes: string[] = []
|
||||
process.stderr.write = ((value: string | Uint8Array) => {
|
||||
writes.push(String(value))
|
||||
return true
|
||||
}) as typeof process.stderr.write
|
||||
try {
|
||||
console.log('prompt and secret must not reach protocol stdout')
|
||||
expect(writes.join('')).toBe(
|
||||
'DeepSeek Harness diagnostic suppressed\n'
|
||||
)
|
||||
expect(writes.join('')).not.toContain('secret')
|
||||
} finally {
|
||||
process.stderr.write = originalWrite
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies the real local sandbox before advertising capabilities', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
|
||||
)
|
||||
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',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('canonicalizes workspace aliases before binding the host', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
|
||||
)
|
||||
const alias = join(root, '..', basename(root))
|
||||
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: alias,
|
||||
dshHome: alias,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
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-'))
|
||||
)
|
||||
const skillDirectory = join(root, 'web-3d-game')
|
||||
await mkdir(skillDirectory)
|
||||
await writeFile(
|
||||
join(skillDirectory, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: web-3d-game',
|
||||
'description: Build a playable browser 3D game.',
|
||||
'---',
|
||||
'',
|
||||
'# Web 3D game',
|
||||
'',
|
||||
'Create and validate a playable project.'
|
||||
].join('\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',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [
|
||||
{ id: 'web-3d-game', directory: skillDirectory }
|
||||
],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
let createdContext: typeof host.context | undefined
|
||||
let createdAgent: Agent | undefined
|
||||
const create = vi
|
||||
.spyOn(host.context.agents, 'create')
|
||||
.mockImplementation(async (options: CreateAgentOptions) => {
|
||||
const agentContext = host.context.extend({
|
||||
isolate: ['skills', 'tools']
|
||||
})
|
||||
createdContext = agentContext
|
||||
await options.setup?.(agentContext)
|
||||
const agent = {
|
||||
options: options.agentOptions ?? {},
|
||||
session: {
|
||||
id: options.sessionId,
|
||||
header: { cwd: options.meta?.cwd ?? root },
|
||||
events: [],
|
||||
append: vi.fn()
|
||||
},
|
||||
ctx: agentContext,
|
||||
cancel: vi.fn()
|
||||
}
|
||||
createdAgent = agent as never
|
||||
return {
|
||||
agent,
|
||||
dispose: async () => {
|
||||
await agentContext.fiber.dispose()
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
const api = (
|
||||
host.controlPlane as unknown as {
|
||||
createAgentApi(): {
|
||||
newSession(params: {
|
||||
cwd: string
|
||||
mcpServers: never[]
|
||||
}): Promise<{ sessionId: string }>
|
||||
}
|
||||
}
|
||||
).createAgentApi()
|
||||
const session = await api.newSession({
|
||||
cwd: root,
|
||||
mcpServers: []
|
||||
})
|
||||
|
||||
expect(session.sessionId).toBeTruthy()
|
||||
expect(
|
||||
(
|
||||
await createdContext!.skills.list({
|
||||
cwd: root,
|
||||
scope: createdAgent
|
||||
})
|
||||
).map((skill) => skill.name)
|
||||
).toEqual(['web-3d-game'])
|
||||
expect(
|
||||
createdContext!.tools
|
||||
.schemas(createdAgent)
|
||||
.map((tool) => tool.name)
|
||||
).toContain('skill')
|
||||
const loadedSkill = await createdContext!.tools.execute({
|
||||
callId: 'skill-call',
|
||||
name: 'skill',
|
||||
arguments: { name: 'web-3d-game' },
|
||||
agent: createdAgent,
|
||||
signal: new AbortController().signal
|
||||
} as never)
|
||||
expect(loadedSkill).toMatchObject({
|
||||
isError: false,
|
||||
value: {
|
||||
name: 'web-3d-game',
|
||||
content: expect.stringContaining(
|
||||
'Create and validate a playable project.'
|
||||
)
|
||||
}
|
||||
})
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentOptions: {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
maxTokens: GOODBUDDY_HARNESS_MAX_STEP_TOKENS
|
||||
}
|
||||
})
|
||||
)
|
||||
const assembly = await createdContext!.systemPrompt.assemble({
|
||||
agent: createdAgent,
|
||||
scope: createdAgent
|
||||
})
|
||||
expect(
|
||||
assembly.sections.find(
|
||||
(section) =>
|
||||
section.name === 'goodbuddy:controlled-execution'
|
||||
)?.text
|
||||
).toContain('create or update the requested workspace files promptly')
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('frames fragmented and coalesced ACP messages individually', async () => {
|
||||
const inbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const outbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const stream = createBoundedNdJsonStream(
|
||||
outbound.writable,
|
||||
inbound.readable,
|
||||
24
|
||||
)
|
||||
const reading = readAllMessages(stream.readable)
|
||||
const writer = inbound.writable.getWriter()
|
||||
const encoder = new TextEncoder()
|
||||
await writer.write(encoder.encode('{"text":"你'))
|
||||
await writer.write(
|
||||
encoder.encode('好"}\n{"value":"1234567890"}\n')
|
||||
)
|
||||
await writer.close()
|
||||
|
||||
await expect(reading).resolves.toEqual([
|
||||
{ text: '你好' },
|
||||
{ value: '1234567890' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects oversized ACP frames at EOF in both directions', async () => {
|
||||
const inbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const outbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const stream = createBoundedNdJsonStream(
|
||||
outbound.writable,
|
||||
inbound.readable,
|
||||
8
|
||||
)
|
||||
const reading = readAllMessages(stream.readable)
|
||||
const inputWriter = inbound.writable.getWriter()
|
||||
await inputWriter.write(
|
||||
new TextEncoder().encode('{"value":"too large"}')
|
||||
)
|
||||
await inputWriter.close()
|
||||
await expect(reading).rejects.toThrow('input frame exceeds')
|
||||
|
||||
const outputWriter = stream.writable.getWriter()
|
||||
await expect(
|
||||
outputWriter.write({ value: 'too large' } as never)
|
||||
).rejects.toThrow('output frame exceeds')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,580 @@
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SandboxedBash from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import SandboxedPwsh from '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import * as PiAiLlm from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import LocalSandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
|
||||
import {
|
||||
GoodBuddyCredentialProvider,
|
||||
GoodBuddyHarnessControlPlane,
|
||||
createBoundedAcpStream,
|
||||
type GoodBuddyHarnessControlConfig
|
||||
} from './agent/goodbuddy-harness-control-plane'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import type { SandboxEnforcement } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024
|
||||
const MAX_DIAGNOSTIC_BYTES = 64 * 1024
|
||||
|
||||
export type ControlledHarnessHostConfig = Omit<
|
||||
GoodBuddyHarnessControlConfig,
|
||||
'stream' | 'skills'
|
||||
> & {
|
||||
workspace: string
|
||||
baseUrl: string
|
||||
api: 'openai-completions'
|
||||
maxFrameBytes?: number
|
||||
stream?: Stream
|
||||
dshHome: string
|
||||
skillPackages: readonly {
|
||||
id: string
|
||||
directory: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ControlledHarnessHost = {
|
||||
readonly context: Context
|
||||
readonly controlPlane: GoodBuddyHarnessControlPlane
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export type ControlledHarnessHostStartupCode =
|
||||
| 'HOST_PLUGIN_GRAPH_FAILED'
|
||||
| 'HOST_SANDBOX_CONFIGURATION_FAILED'
|
||||
| 'HOST_SANDBOX_EXECUTION_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_ABORTED'
|
||||
| 'HOST_SANDBOX_PROBE_EXIT_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_RUNNER_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_TIMED_OUT'
|
||||
| 'HOST_CONTROL_PLANE_FAILED'
|
||||
|
||||
export class ControlledHarnessHostStartupError extends Error {
|
||||
constructor(
|
||||
readonly code: ControlledHarnessHostStartupCode,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(code, options)
|
||||
this.name = 'ControlledHarnessHostStartupError'
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySandboxExecution(
|
||||
ctx: Context,
|
||||
expected: GoodBuddyHarnessControlConfig['sandbox'],
|
||||
workspace: string
|
||||
): Promise<void> {
|
||||
const result = await ctx.shell.run(
|
||||
ctx.shell.resolve({
|
||||
command:
|
||||
process.platform === 'win32'
|
||||
? 'Write-Output goodbuddy-sandbox-probe'
|
||||
: 'printf goodbuddy-sandbox-probe',
|
||||
workdir: workspace,
|
||||
timeoutMs: 10_000,
|
||||
stdoutMaxBytes: 1_024,
|
||||
sandboxPolicy: {
|
||||
mode: 'read-only',
|
||||
workspaceRoot: workspace
|
||||
}
|
||||
})
|
||||
)
|
||||
if (
|
||||
result.sandbox?.enforcement !== expected.enforcement
|
||||
) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox execution probe failed'
|
||||
)
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_TIMED_OUT'
|
||||
)
|
||||
}
|
||||
if (result.aborted) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_ABORTED'
|
||||
)
|
||||
}
|
||||
if (result.sandbox?.runnerFailed) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_RUNNER_FAILED'
|
||||
)
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_EXIT_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type PluginSpec = {
|
||||
plugin: Parameters<Context['plugin']>[0]
|
||||
config?: unknown
|
||||
}
|
||||
|
||||
function validateHostConfig(
|
||||
config: ControlledHarnessHostConfig
|
||||
): void {
|
||||
const endpoint = URL.canParse(config.baseUrl)
|
||||
? new URL(config.baseUrl)
|
||||
: undefined
|
||||
if (
|
||||
config.api !== 'openai-completions' ||
|
||||
!endpoint ||
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.hostname.toLowerCase() !== 'api.deepseek.com' ||
|
||||
endpoint.username ||
|
||||
endpoint.password
|
||||
) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires the trusted HTTPS DeepSeek endpoint'
|
||||
)
|
||||
}
|
||||
if (!config.credentialRefs.length) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires a Main-side credential reference'
|
||||
)
|
||||
}
|
||||
if (!isAbsolute(config.workspace) || !isAbsolute(config.dshHome)) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires absolute workspace and home paths'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalizeHostConfig(
|
||||
config: ControlledHarnessHostConfig
|
||||
): Promise<ControlledHarnessHostConfig> {
|
||||
const [workspace, dshHome] = await Promise.all([
|
||||
realpath(config.workspace),
|
||||
realpath(config.dshHome)
|
||||
])
|
||||
const [workspaceMetadata, homeMetadata] = await Promise.all([
|
||||
stat(workspace),
|
||||
stat(dshHome)
|
||||
])
|
||||
if (!workspaceMetadata.isDirectory() || !homeMetadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'Controlled Harness workspace and home must be directories'
|
||||
)
|
||||
}
|
||||
const skillPackages = await Promise.all(
|
||||
config.skillPackages.map(async (skill) => {
|
||||
const directory = await realpath(skill.directory)
|
||||
const metadata = await stat(directory)
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'Controlled Harness Skill path must be a directory'
|
||||
)
|
||||
}
|
||||
return { ...skill, directory }
|
||||
})
|
||||
)
|
||||
return { ...config, workspace, dshHome, skillPackages }
|
||||
}
|
||||
|
||||
async function loadControlledSkills(
|
||||
skillPackages: ControlledHarnessHostConfig['skillPackages']
|
||||
): Promise<GoodBuddyHarnessControlConfig['skills']> {
|
||||
return Promise.all(
|
||||
skillPackages.map(async (skill) => {
|
||||
const manifest = await readFile(
|
||||
join(skill.directory, 'SKILL.md'),
|
||||
'utf8'
|
||||
)
|
||||
if (Buffer.byteLength(manifest, 'utf8') > 2 * 1024 * 1024) {
|
||||
throw new Error('Controlled Harness Skill is too large')
|
||||
}
|
||||
const match =
|
||||
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(
|
||||
manifest
|
||||
)
|
||||
if (!match?.[1] || !match[2]?.trim()) {
|
||||
throw new Error('Controlled Harness Skill manifest is invalid')
|
||||
}
|
||||
const metadata = parseYaml(match[1]) as Record<string, unknown>
|
||||
const name =
|
||||
typeof metadata.id === 'string'
|
||||
? metadata.id
|
||||
: metadata.name
|
||||
const description = metadata.description
|
||||
if (
|
||||
name !== skill.id ||
|
||||
typeof name !== 'string' ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) ||
|
||||
typeof description !== 'string'
|
||||
) {
|
||||
throw new Error('Controlled Harness Skill metadata is invalid')
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description: description
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, 500),
|
||||
content: match[2].trim(),
|
||||
directory: skill.directory
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function sandboxProviderName(): string {
|
||||
return process.platform === 'win32'
|
||||
? 'windows-acl'
|
||||
: process.platform === 'darwin'
|
||||
? 'seatbelt'
|
||||
: 'local-linux'
|
||||
}
|
||||
|
||||
function verifySandbox(
|
||||
sandbox: {
|
||||
confine(
|
||||
argv: readonly string[],
|
||||
policy: {
|
||||
mode: 'read-only'
|
||||
workspaceRoot: string
|
||||
}
|
||||
): {
|
||||
enforcement: SandboxEnforcement
|
||||
}
|
||||
},
|
||||
config: ControlledHarnessHostConfig
|
||||
): GoodBuddyHarnessControlConfig['sandbox'] {
|
||||
const expectedEnforcement: SandboxEnforcement =
|
||||
process.platform === 'win32' ? 'partial' : 'full'
|
||||
const probe = sandbox.confine(
|
||||
process.platform === 'win32'
|
||||
? ['cmd.exe', '/d', '/s', '/c', 'exit 0']
|
||||
: ['/usr/bin/env', 'true'],
|
||||
{
|
||||
mode: 'read-only',
|
||||
workspaceRoot: config.workspace
|
||||
}
|
||||
)
|
||||
if (probe.enforcement !== expectedEnforcement) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox enforcement probe returned an unexpected result'
|
||||
)
|
||||
}
|
||||
if (config.sandbox.enforcement !== probe.enforcement) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox capability does not match the verified provider'
|
||||
)
|
||||
}
|
||||
return {
|
||||
provider: sandboxProviderName(),
|
||||
enforcement: probe.enforcement
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function startControlledDeepSeekHarnessHost(
|
||||
input: ControlledHarnessHostConfig
|
||||
): Promise<ControlledHarnessHost> {
|
||||
validateHostConfig(input)
|
||||
const config = await canonicalizeHostConfig(input)
|
||||
const skills = await loadControlledSkills(config.skillPackages)
|
||||
process.env.DSH_TELEMETRY_DISABLED = '1'
|
||||
const ctx = new Context()
|
||||
const specs: PluginSpec[] = [
|
||||
{ plugin: LlmRuntime },
|
||||
{ plugin: SessionStore },
|
||||
{ plugin: SkillRegistry },
|
||||
{
|
||||
plugin: SystemPrompt,
|
||||
config: {
|
||||
persona: '',
|
||||
includeHarnessIdentity: false,
|
||||
includeRuntimeContext: true
|
||||
}
|
||||
},
|
||||
{ plugin: ToolRuntime, config: { mode: 'native' } },
|
||||
{ plugin: AgentRegistry },
|
||||
{
|
||||
plugin: GoodBuddyCredentialProvider,
|
||||
config: new Set(config.credentialRefs)
|
||||
},
|
||||
{
|
||||
plugin: PiAiLlm,
|
||||
config: {
|
||||
providers: {
|
||||
[config.provider]: {
|
||||
apiKeyEnv: config.credentialRefs[0],
|
||||
api: config.api,
|
||||
baseURL: config.baseUrl,
|
||||
models: [{ id: config.model, input: ['text'] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
plugin: SandboxPolicy,
|
||||
config: {
|
||||
mode: 'read-only',
|
||||
workspaceRoot: config.workspace
|
||||
}
|
||||
},
|
||||
{ plugin: ApprovalService, config: { policy: 'never' } },
|
||||
{ plugin: LocalSubprocess },
|
||||
{ plugin: LocalSandbox },
|
||||
{ plugin: SandboxedFileSystem, config: { cwd: config.workspace } },
|
||||
{ plugin: ShellEnv, config: { dshHome: config.dshHome } },
|
||||
{
|
||||
plugin:
|
||||
process.platform === 'win32'
|
||||
? SandboxedPwsh
|
||||
: SandboxedBash,
|
||||
config: { timeoutMs: 60_000 }
|
||||
},
|
||||
{ plugin: ToolFs },
|
||||
{
|
||||
plugin:
|
||||
process.platform === 'win32' ? ToolPwsh : ToolBash,
|
||||
config: { enableRunInBackground: false }
|
||||
},
|
||||
{ plugin: TokenMeter, config: {} },
|
||||
{
|
||||
plugin: AgentLoop,
|
||||
config: { agents: [], maxParallelToolCalls: 10 }
|
||||
}
|
||||
]
|
||||
const fibers: Fiber[] = []
|
||||
let startupCode: ControlledHarnessHostStartupCode =
|
||||
'HOST_PLUGIN_GRAPH_FAILED'
|
||||
try {
|
||||
for (const spec of specs) {
|
||||
fibers.push(
|
||||
ctx.plugin(
|
||||
spec.plugin,
|
||||
...(spec.config === undefined ? [] : [spec.config])
|
||||
)
|
||||
)
|
||||
}
|
||||
await Promise.all(fibers)
|
||||
const credentialProvider = ctx.credentials
|
||||
if (!(credentialProvider instanceof GoodBuddyCredentialProvider)) {
|
||||
throw new Error(
|
||||
'Controlled Harness credential provider failed to start'
|
||||
)
|
||||
}
|
||||
startupCode = 'HOST_SANDBOX_CONFIGURATION_FAILED'
|
||||
const verifiedSandbox = verifySandbox(ctx.sandbox, config)
|
||||
startupCode = 'HOST_SANDBOX_EXECUTION_FAILED'
|
||||
await verifySandboxExecution(
|
||||
ctx,
|
||||
verifiedSandbox,
|
||||
config.workspace
|
||||
)
|
||||
startupCode = 'HOST_CONTROL_PLANE_FAILED'
|
||||
const rawStream =
|
||||
config.stream ??
|
||||
createBoundedNdJsonStream(
|
||||
stdoutStream(),
|
||||
stdinStream(),
|
||||
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES
|
||||
)
|
||||
const controlPlane = new GoodBuddyHarnessControlPlane(ctx, {
|
||||
...config,
|
||||
skills,
|
||||
sandbox: verifiedSandbox,
|
||||
stream: createBoundedAcpStream(
|
||||
rawStream,
|
||||
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES
|
||||
)
|
||||
})
|
||||
controlPlane.bindCredentialProvider(credentialProvider)
|
||||
controlPlane.start()
|
||||
return {
|
||||
context: ctx,
|
||||
controlPlane,
|
||||
async dispose() {
|
||||
await controlPlane.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await ctx.fiber.dispose().catch(() => undefined)
|
||||
if (error instanceof ControlledHarnessHostStartupError) {
|
||||
throw error
|
||||
}
|
||||
throw new ControlledHarnessHostStartupError(startupCode, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createBoundedNdJsonStream(
|
||||
output: WritableStream<Uint8Array>,
|
||||
input: ReadableStream<Uint8Array>,
|
||||
maxFrameBytes: number
|
||||
): Stream {
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
const encoder = new TextEncoder()
|
||||
return {
|
||||
readable: new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = input.getReader()
|
||||
let pending = ''
|
||||
const emitCompleteFrames = (): void => {
|
||||
let newline = pending.indexOf('\n')
|
||||
while (newline >= 0) {
|
||||
const line = pending.slice(0, newline).trim()
|
||||
pending = pending.slice(newline + 1)
|
||||
if (
|
||||
line &&
|
||||
Buffer.byteLength(line, 'utf8') > maxFrameBytes
|
||||
) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
if (line) {
|
||||
controller.enqueue(JSON.parse(line))
|
||||
}
|
||||
newline = pending.indexOf('\n')
|
||||
}
|
||||
if (Buffer.byteLength(pending, 'utf8') > maxFrameBytes) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
pending += decoder.decode()
|
||||
emitCompleteFrames()
|
||||
break
|
||||
}
|
||||
pending += decoder.decode(value, { stream: true })
|
||||
emitCompleteFrames()
|
||||
}
|
||||
const line = pending.trim()
|
||||
if (line) {
|
||||
if (Buffer.byteLength(line, 'utf8') > maxFrameBytes) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
controller.enqueue(JSON.parse(line))
|
||||
}
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
}),
|
||||
writable: new WritableStream({
|
||||
async write(message) {
|
||||
const serialized = JSON.stringify(message)
|
||||
if (
|
||||
Buffer.byteLength(serialized, 'utf8') >
|
||||
maxFrameBytes
|
||||
) {
|
||||
throw new Error('ACP output frame exceeds safety limit')
|
||||
}
|
||||
const bytes = encoder.encode(`${serialized}\n`)
|
||||
const writer = output.getWriter()
|
||||
try {
|
||||
await writer.write(bytes)
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
const writer = output.getWriter()
|
||||
try {
|
||||
await writer.close()
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
}
|
||||
},
|
||||
async abort(reason) {
|
||||
await output.abort(reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function stdoutStream(): WritableStream<Uint8Array> {
|
||||
return new WritableStream({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) =>
|
||||
error ? reject(error) : resolve()
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stdinStream(): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
process.stdin.on('data', (chunk: Buffer) =>
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
)
|
||||
process.stdin.once('end', () => controller.close())
|
||||
process.stdin.once('error', (error) =>
|
||||
controller.error(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep diagnostics bounded and protocol-free. Call this in the utility entry
|
||||
* before Cordis plugins start; no user content or secret is forwarded.
|
||||
*/
|
||||
export function installHarnessDiagnosticGuard(): () => void {
|
||||
let bytes = 0
|
||||
const original = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug
|
||||
}
|
||||
const diagnostic = (): void => {
|
||||
const line = 'DeepSeek Harness diagnostic suppressed\n'
|
||||
const size = Buffer.byteLength(line)
|
||||
if (bytes + size <= MAX_DIAGNOSTIC_BYTES) {
|
||||
bytes += size
|
||||
process.stderr.write(line)
|
||||
}
|
||||
}
|
||||
console.log = diagnostic
|
||||
console.info = diagnostic
|
||||
console.warn = diagnostic
|
||||
console.error = diagnostic
|
||||
console.debug = diagnostic
|
||||
return () => {
|
||||
console.log = original.log
|
||||
console.info = original.info
|
||||
console.warn = original.warn
|
||||
console.error = original.error
|
||||
console.debug = original.debug
|
||||
}
|
||||
}
|
||||
+58
-2
@@ -10,8 +10,10 @@ import {
|
||||
utilityProcess
|
||||
} from 'electron'
|
||||
import { homedir } from 'node:os'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import {
|
||||
createAgentRuntime,
|
||||
@@ -75,6 +77,11 @@ import { DocumentOcrBroker } from './document-ocr-broker'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
import { ReleaseNotesService } from './release-notes-service'
|
||||
import { GoodBuddyConfigService } from './goodbuddy-config-service'
|
||||
import {
|
||||
createDeepSeekHarnessUtilityLauncher,
|
||||
type DeepSeekHarnessFork
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||
|
||||
const shortcut = 'CommandOrControl+Shift+Space'
|
||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -209,6 +216,36 @@ const launchContinueHost: ContinueHostLauncher = (
|
||||
return child
|
||||
}
|
||||
|
||||
const forkDeepSeekHarness: DeepSeekHarnessFork = (
|
||||
modulePath,
|
||||
args,
|
||||
options
|
||||
) =>
|
||||
utilityProcess.fork(modulePath, args, {
|
||||
...options,
|
||||
allowLoadingUnsignedLibraries: false,
|
||||
disclaim: false
|
||||
})
|
||||
|
||||
function terminateHarnessUtilityProcess(
|
||||
child: ReturnType<DeepSeekHarnessFork>
|
||||
): void {
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
const killer = spawn(
|
||||
'taskkill.exe',
|
||||
['/PID', String(child.pid), '/T', '/F'],
|
||||
{
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
killer.unref()
|
||||
return
|
||||
}
|
||||
child.kill()
|
||||
}
|
||||
|
||||
const launchWechatSidecar: WechatSidecarLauncher = () => {
|
||||
const utilityChild = utilityProcess.fork(
|
||||
join(mainModuleDirectory, 'wechat-sidecar.js'),
|
||||
@@ -403,6 +440,24 @@ if (hasSingleInstanceLock) {
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged
|
||||
})
|
||||
const deepSeekHarnessHome = join(
|
||||
app.getPath('userData'),
|
||||
'deepseek-harness'
|
||||
)
|
||||
await mkdir(deepSeekHarnessHome, {
|
||||
recursive: true,
|
||||
mode: 0o700
|
||||
})
|
||||
const launchDeepSeekHarness =
|
||||
createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||
dshHome: deepSeekHarnessHome,
|
||||
environment: buildControlledHarnessEnvironment(
|
||||
deepSeekHarnessHome
|
||||
),
|
||||
fork: forkDeepSeekHarness,
|
||||
terminateProcess: terminateHarnessUtilityProcess
|
||||
})
|
||||
knowledgeService = new KnowledgeService({
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||
@@ -465,8 +520,8 @@ if (hasSingleInstanceLock) {
|
||||
] =
|
||||
await Promise.all([
|
||||
capabilityService.getRuntimeSkillContext(target),
|
||||
target === 'model'
|
||||
? capabilityService.getResolvedMcpServers('model')
|
||||
target === 'model' || target === 'deepseek-harness'
|
||||
? capabilityService.getResolvedMcpServers(target)
|
||||
: Promise.resolve([]),
|
||||
target === 'model'
|
||||
? capabilityService.getComputerCapabilityStatus(
|
||||
@@ -487,6 +542,7 @@ if (hasSingleInstanceLock) {
|
||||
),
|
||||
bundledRuntimePaths,
|
||||
continueHostLauncher: launchContinueHost,
|
||||
deepseekHarnessLauncher: launchDeepSeekHarness,
|
||||
browserService:
|
||||
browserCapability?.enabled && browserCapability.supported
|
||||
? browserService
|
||||
|
||||
+74
-1
@@ -2935,6 +2935,78 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps Ask fail-closed and auto-allows DeepSeek Harness Execute tools', async () => {
|
||||
const receivedAuthorizers: unknown[] = []
|
||||
const executeDecisions: string[] = []
|
||||
const runtime = {
|
||||
runtimeId: 'deepseek-harness',
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
async *run(
|
||||
request: { requestId: string; workMode?: string },
|
||||
_signal: AbortSignal,
|
||||
authorize?: (request: {
|
||||
scopeKey: string
|
||||
title: string
|
||||
description: string
|
||||
}) => Promise<string>
|
||||
) {
|
||||
receivedAuthorizers.push(authorize)
|
||||
if (request.workMode === 'execute') {
|
||||
executeDecisions.push(
|
||||
(await authorize?.({
|
||||
scopeKey: 'deepseek-harness:write_file',
|
||||
title: '写入文件',
|
||||
description: '一次性沙箱升级'
|
||||
})) ?? 'missing'
|
||||
)
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
harness.approvalBroker.request.mockResolvedValue('once')
|
||||
|
||||
for (const [index, workMode] of (
|
||||
['ask', 'execute'] as const
|
||||
).entries()) {
|
||||
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: `conversation-${index}`,
|
||||
prompt: 'run the task',
|
||||
workMode
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'completed')
|
||||
)
|
||||
}
|
||||
|
||||
expect(receivedAuthorizers).toEqual([
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
])
|
||||
expect(executeDecisions).toEqual(['once'])
|
||||
await expect(
|
||||
(
|
||||
receivedAuthorizers[0] as (
|
||||
request: Record<string, string>
|
||||
) => Promise<string>
|
||||
)({
|
||||
scopeKey: 'deepseek-harness:write_file',
|
||||
title: '写入文件',
|
||||
description: 'must be denied'
|
||||
})
|
||||
).resolves.toBe('deny')
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['model', 'opencode'] as const)(
|
||||
'normalizes legacy interactive Plan requests to Ask for %s',
|
||||
async (runtimeId) => {
|
||||
@@ -3936,7 +4008,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
harness.getResolvedSettings.mockResolvedValue({
|
||||
toolApproval: 'always',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
continueModelProfile: { id: configuredProfileId }
|
||||
continueModelProfile: { id: configuredProfileId },
|
||||
modelProfiles: [{ id: configuredProfileId }]
|
||||
})
|
||||
vi.mocked(
|
||||
harness.assistantDatabase.listProjects
|
||||
|
||||
+31
-6
@@ -163,7 +163,11 @@ import {
|
||||
import { resolveConfiguredAgentRuntimeSelection } from './agent/runtime-selection'
|
||||
import { safeToolErrorDetail } from './agent/approval-summary'
|
||||
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
|
||||
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
|
||||
import {
|
||||
bundledContinueVersion,
|
||||
bundledDeepSeekHarnessVersion,
|
||||
type BundledRuntimePaths
|
||||
} from './agent/bundled-runtimes'
|
||||
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
||||
import {
|
||||
type MagicNotesCapabilityAccess,
|
||||
@@ -1258,6 +1262,8 @@ export function registerIpcHandlers(
|
||||
!agentRuntimeSelected
|
||||
? (await settingsStore.getPolicySettings()).toolApproval
|
||||
: undefined
|
||||
const automaticHarnessRuntime =
|
||||
requestRuntime.runtimeId === 'deepseek-harness'
|
||||
const authorize: RuntimeAuthorizer = async (approvalRequest) => {
|
||||
controller.signal.throwIfAborted()
|
||||
if (schedule.workMode !== 'execute') {
|
||||
@@ -1266,6 +1272,9 @@ export function registerIpcHandlers(
|
||||
if (origin === 'delegation') {
|
||||
return 'deny'
|
||||
}
|
||||
if (automaticHarnessRuntime) {
|
||||
return 'once'
|
||||
}
|
||||
if (origin === 'channel') {
|
||||
return channelToolPolicy === 'policy' ? 'deny' : 'once'
|
||||
}
|
||||
@@ -2456,16 +2465,26 @@ export function registerIpcHandlers(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const automaticHarnessRuntime =
|
||||
selectedRuntime.runtimeId === 'deepseek-harness'
|
||||
const executeToolPolicy =
|
||||
request.workMode === 'execute' && !agentRuntimeSelected
|
||||
? (await settingsStore.getPolicySettings()).toolApproval
|
||||
: 'policy'
|
||||
const authorize: RuntimeAuthorizer = async () => {
|
||||
controller.signal.throwIfAborted()
|
||||
return request.workMode === 'execute' &&
|
||||
if (
|
||||
request.workMode !== 'execute'
|
||||
) {
|
||||
return 'deny'
|
||||
}
|
||||
if (
|
||||
automaticHarnessRuntime ||
|
||||
executeToolPolicy !== 'policy'
|
||||
? 'once'
|
||||
: 'deny'
|
||||
) {
|
||||
return 'once'
|
||||
}
|
||||
return 'deny'
|
||||
}
|
||||
let smartRoute:
|
||||
| ReturnType<typeof routeSubagent>
|
||||
@@ -2771,7 +2790,11 @@ export function registerIpcHandlers(
|
||||
return detectAgentRuntimes({
|
||||
opencodeBinaryPath: settings.opencodeBinaryPath,
|
||||
continueBinaryPath: settings.continueBinaryPath,
|
||||
bundledPaths: bundledRuntimePaths
|
||||
bundledPaths: bundledRuntimePaths,
|
||||
bundledVersions: {
|
||||
continue: bundledContinueVersion,
|
||||
deepseekHarness: bundledDeepSeekHarnessVersion
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -2781,7 +2804,9 @@ export function registerIpcHandlers(
|
||||
async (event, input: unknown): Promise<string | undefined> => {
|
||||
assertTrustedSender(event, window)
|
||||
const kind = runtimeFileSelectionKindSchema.parse(input)
|
||||
const binary = kind.endsWith('Binary')
|
||||
const binary =
|
||||
kind === 'opencodeBinary' ||
|
||||
kind === 'continueBinary'
|
||||
const configRuntime =
|
||||
kind === 'opencodeConfig'
|
||||
? 'opencode'
|
||||
|
||||
@@ -42,6 +42,7 @@ function settings(
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
runtimeSandboxMode: 'auto',
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
@@ -103,6 +104,199 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates DeepSeek Harness to controlled platform mode and stores an official profile', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const versionFourteen = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
versionFourteen.version = 14
|
||||
delete versionFourteen.deepseekHarnessModelSource
|
||||
delete versionFourteen.deepseekHarnessBinaryPath
|
||||
await writeFile(filePath, JSON.stringify(versionFourteen), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
deepseekHarnessModelProfile: undefined
|
||||
})
|
||||
|
||||
const profileId = '00000000-0000-4000-8000-000000000044'
|
||||
await migrated.update(
|
||||
settings({
|
||||
provider: 'deepseek-harness',
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'replace', value: 'deepseek-secret' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
)
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: profileId,
|
||||
apiKey: 'deepseek-secret'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a controlled platform DeepSeek profile without exposing its credential', async () => {
|
||||
const apiKey = 'platform-deepseek-secret'
|
||||
const { store } = await createStore({
|
||||
GOODBUDDY_MODEL_API_KEY: apiKey,
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com/',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-v4-flash'
|
||||
})
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProtocol: 'anthropic-messages',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/',
|
||||
modelName: 'deepseek-v4-flash',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
|
||||
const publicSettings = await store.getPublicSettings()
|
||||
expect(JSON.stringify(publicSettings)).not.toContain(apiKey)
|
||||
expect(publicSettings.modelProtocol).toBe('anthropic-messages')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a non-DeepSeek endpoint',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://deepseek.example',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'an insecure DeepSeek endpoint',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'http://api.deepseek.com',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a DeepSeek endpoint path',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a missing API key',
|
||||
{
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
]
|
||||
])('does not resolve platform DeepSeek from %s', async (_, environment) => {
|
||||
const { store } = await createStore(environment)
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
deepseekHarnessModelProfile: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the legacy custom Harness Host path and ignores its environment override', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const versionFifteen = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
versionFifteen.version = 15
|
||||
versionFifteen.deepseekHarnessBinaryPath =
|
||||
'C:\\untrusted\\custom-harness.js'
|
||||
await writeFile(filePath, JSON.stringify(versionFifteen), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_DEEPSEEK_HARNESS_BINARY:
|
||||
'C:\\environment\\custom-harness.js'
|
||||
})
|
||||
const publicSettings = await migrated.getPublicSettings()
|
||||
const resolvedSettings = await migrated.getResolvedSettings()
|
||||
expect(publicSettings).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
expect(publicSettings.configured).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
expect(resolvedSettings).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
await migrated.update(settings())
|
||||
const persisted = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incompatible DeepSeek Harness model profiles', () => {
|
||||
const profileId = '00000000-0000-4000-8000-000000000045'
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'Other compatible API',
|
||||
baseUrl: 'https://other.example/v1',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'DeepSeek without API key',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'clear' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('always enables bundled OpenCode when the Server address is blank', async () => {
|
||||
const { filePath, store } = await createStore({
|
||||
GOODBUDDY_OPENCODE_EMBEDDED: 'false'
|
||||
@@ -331,7 +525,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
})
|
||||
|
||||
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
|
||||
@@ -351,7 +545,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
intranetCompatibilityEnabled?: boolean
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
|
||||
})
|
||||
|
||||
@@ -940,7 +1134,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted.modelProfiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: imageId,
|
||||
@@ -1186,7 +1380,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
unknown
|
||||
>
|
||||
expect(saved).toMatchObject({
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: 'model',
|
||||
continueBinaryPath: '',
|
||||
continueMode: 'chat',
|
||||
@@ -1465,7 +1659,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defaultRuntimeSettings,
|
||||
imageGenerationQualitySchema,
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile,
|
||||
modelAuthenticationSchema,
|
||||
modelProtocolSchema,
|
||||
runtimeModelSourceSchema,
|
||||
@@ -164,7 +165,7 @@ const version13StoredSettingsSchema = version12StoredSettingsSchema
|
||||
.max(20)
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version13StoredSettingsSchema
|
||||
const version14StoredSettingsSchema = version13StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(14),
|
||||
@@ -185,7 +186,24 @@ const storedSettingsSchema = version13StoredSettingsSchema
|
||||
knowledgeRerankCredential: credentialSchema
|
||||
})
|
||||
|
||||
const version15StoredSettingsSchema = version14StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(15),
|
||||
deepseekHarnessModelSource: runtimeModelSourceSchema,
|
||||
deepseekHarnessBinaryPath: runtimePathSchema.default('')
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version15StoredSettingsSchema
|
||||
.omit({ version: true, deepseekHarnessBinaryPath: true })
|
||||
.extend({
|
||||
version: z.literal(16)
|
||||
})
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type Version15StoredSettings = z.infer<
|
||||
typeof version15StoredSettingsSchema
|
||||
>
|
||||
type Version10StoredSettings = z.infer<
|
||||
typeof version10StoredSettingsSchema
|
||||
>
|
||||
@@ -198,6 +216,9 @@ type Version12StoredSettings = z.infer<
|
||||
type Version13StoredSettings = z.infer<
|
||||
typeof version13StoredSettingsSchema
|
||||
>
|
||||
type Version14StoredSettings = z.infer<
|
||||
typeof version14StoredSettingsSchema
|
||||
>
|
||||
|
||||
const version3StoredSettingsSchema = version4StoredSettingsSchema
|
||||
.omit({ version: true, continueMode: true })
|
||||
@@ -242,6 +263,7 @@ const embeddingCredentialPayloadSchema = z.object({
|
||||
})
|
||||
|
||||
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
||||
const platformDeepSeekProfileId = 'goodbuddy-platform-deepseek'
|
||||
|
||||
export type CredentialCipher = SettingsCredentialCipher
|
||||
|
||||
@@ -258,6 +280,7 @@ export type ResolvedRuntimeSettings = {
|
||||
defaultModelProfileId: string
|
||||
opencodeModelProfile?: ResolvedModelProfile
|
||||
continueModelProfile?: ResolvedModelProfile
|
||||
deepseekHarnessModelProfile?: ResolvedModelProfile
|
||||
opencodeBaseUrl: string
|
||||
opencodeEmbedded: boolean
|
||||
opencodeBinaryPath: string
|
||||
@@ -297,7 +320,7 @@ export type ResolvedModelProfile = {
|
||||
}
|
||||
|
||||
const defaultSettings: StoredSettings = {
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: defaultRuntimeSettings.provider,
|
||||
modelProfiles: [
|
||||
{
|
||||
@@ -328,6 +351,7 @@ const defaultSettings: StoredSettings = {
|
||||
continueBinaryPath: defaultRuntimeSettings.continueBinaryPath,
|
||||
continueConfigPath: defaultRuntimeSettings.continueConfigPath,
|
||||
continueMode: defaultRuntimeSettings.continueMode,
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode,
|
||||
subagentSmartRoutingEnabled:
|
||||
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
||||
@@ -411,7 +435,7 @@ function migrateVersion12(
|
||||
function migrateVersion13(
|
||||
settings: Version13StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
return migrateVersion14({
|
||||
...settings,
|
||||
version: 14,
|
||||
knowledgeRerankEnabled:
|
||||
@@ -421,6 +445,30 @@ function migrateVersion13(
|
||||
knowledgeRerankModel:
|
||||
defaultRuntimeSettings.knowledgeRerankModel,
|
||||
knowledgeRerankCredential: undefined
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion14(
|
||||
settings: Version14StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
...settings,
|
||||
version: 16,
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
}
|
||||
}
|
||||
|
||||
function migrateVersion15(
|
||||
settings: Version15StoredSettings
|
||||
): StoredSettings {
|
||||
const {
|
||||
deepseekHarnessBinaryPath: _obsolete,
|
||||
...current
|
||||
} = settings
|
||||
void _obsolete
|
||||
return {
|
||||
...current,
|
||||
version: 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +531,19 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
? { kind: 'profile', profileId: fallbackProfileId }
|
||||
: { kind: 'platform' }
|
||||
}
|
||||
const normalizeDeepSeekHarnessSource = (
|
||||
source: RuntimeSettings['deepseekHarnessModelSource']
|
||||
): NonNullable<RuntimeSettings['deepseekHarnessModelSource']> => {
|
||||
if (!source || source.kind === 'platform') {
|
||||
return { kind: 'platform' }
|
||||
}
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) => candidate.id === source.profileId
|
||||
)
|
||||
return profile && isDeepSeekHarnessModelProfile(profile)
|
||||
? source
|
||||
: { kind: 'platform' }
|
||||
}
|
||||
const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
|
||||
if (opencodeBaseUrl) {
|
||||
const url = new URL(opencodeBaseUrl)
|
||||
@@ -508,6 +569,9 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
continueModelSource: normalizeSource(
|
||||
settings.continueModelSource
|
||||
),
|
||||
deepseekHarnessModelSource: normalizeDeepSeekHarnessSource(
|
||||
settings.deepseekHarnessModelSource
|
||||
),
|
||||
opencodeBaseUrl,
|
||||
opencodeEmbedded: !opencodeBaseUrl
|
||||
}
|
||||
@@ -683,7 +747,7 @@ export class RuntimeSettingsStore {
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
14,
|
||||
16,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
@@ -691,110 +755,122 @@ export class RuntimeSettingsStore {
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
} else {
|
||||
const version13 =
|
||||
version13StoredSettingsSchema.safeParse(parsed)
|
||||
if (version13.success) {
|
||||
this.settings = migrateVersion13(version13.data)
|
||||
const version15 =
|
||||
version15StoredSettingsSchema.safeParse(parsed)
|
||||
if (version15.success) {
|
||||
this.settings = migrateVersion15(version15.data)
|
||||
} else {
|
||||
const version12 =
|
||||
version12StoredSettingsSchema.safeParse(parsed)
|
||||
if (version12.success) {
|
||||
this.settings = migrateVersion12(version12.data)
|
||||
const version14 =
|
||||
version14StoredSettingsSchema.safeParse(parsed)
|
||||
if (version14.success) {
|
||||
this.settings = migrateVersion14(version14.data)
|
||||
} else {
|
||||
const version11 =
|
||||
version11StoredSettingsSchema.safeParse(parsed)
|
||||
if (version11.success) {
|
||||
this.settings = migrateVersion11(version11.data)
|
||||
const version13 =
|
||||
version13StoredSettingsSchema.safeParse(parsed)
|
||||
if (version13.success) {
|
||||
this.settings = migrateVersion13(version13.data)
|
||||
} else {
|
||||
const version10 =
|
||||
version10StoredSettingsSchema.safeParse(parsed)
|
||||
if (version10.success) {
|
||||
this.settings = migrateVersion10(version10.data)
|
||||
const version12 =
|
||||
version12StoredSettingsSchema.safeParse(parsed)
|
||||
if (version12.success) {
|
||||
this.settings = migrateVersion12(version12.data)
|
||||
} else {
|
||||
const version9 =
|
||||
version9StoredSettingsSchema.safeParse(parsed)
|
||||
if (version9.success) {
|
||||
this.settings = migrateVersion9(version9.data)
|
||||
const version11 =
|
||||
version11StoredSettingsSchema.safeParse(parsed)
|
||||
if (version11.success) {
|
||||
this.settings = migrateVersion11(version11.data)
|
||||
} else {
|
||||
const version8 =
|
||||
version8StoredSettingsSchema.safeParse(parsed)
|
||||
if (version8.success) {
|
||||
this.settings = migrateVersion8(version8.data)
|
||||
const version10 =
|
||||
version10StoredSettingsSchema.safeParse(parsed)
|
||||
if (version10.success) {
|
||||
this.settings = migrateVersion10(version10.data)
|
||||
} else {
|
||||
const version7 =
|
||||
version7StoredSettingsSchema.safeParse(parsed)
|
||||
if (version7.success) {
|
||||
this.settings = migrateVersion7(version7.data)
|
||||
const version9 =
|
||||
version9StoredSettingsSchema.safeParse(parsed)
|
||||
if (version9.success) {
|
||||
this.settings = migrateVersion9(version9.data)
|
||||
} else {
|
||||
const version6 =
|
||||
version6StoredSettingsSchema.safeParse(parsed)
|
||||
if (version6.success) {
|
||||
this.settings = migrateVersion6(version6.data)
|
||||
const version8 =
|
||||
version8StoredSettingsSchema.safeParse(parsed)
|
||||
if (version8.success) {
|
||||
this.settings = migrateVersion8(version8.data)
|
||||
} else {
|
||||
const version5 =
|
||||
version5StoredSettingsSchema.safeParse(parsed)
|
||||
if (version5.success) {
|
||||
this.settings = migrateVersion5(version5.data)
|
||||
const version7 =
|
||||
version7StoredSettingsSchema.safeParse(parsed)
|
||||
if (version7.success) {
|
||||
this.settings = migrateVersion7(version7.data)
|
||||
} else {
|
||||
const version4 =
|
||||
version4StoredSettingsSchema.safeParse(parsed)
|
||||
if (version4.success) {
|
||||
this.settings = migrateVersion4(version4.data)
|
||||
const version6 =
|
||||
version6StoredSettingsSchema.safeParse(parsed)
|
||||
if (version6.success) {
|
||||
this.settings = migrateVersion6(version6.data)
|
||||
} else {
|
||||
const version3 =
|
||||
version3StoredSettingsSchema.safeParse(parsed)
|
||||
if (version3.success) {
|
||||
this.settings = migrateVersion4({
|
||||
...version3.data,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
const version5 =
|
||||
version5StoredSettingsSchema.safeParse(parsed)
|
||||
if (version5.success) {
|
||||
this.settings = migrateVersion5(version5.data)
|
||||
} else {
|
||||
const version2 =
|
||||
version2StoredSettingsSchema.safeParse(parsed)
|
||||
if (version2.success) {
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider: version2.data.provider,
|
||||
modelBaseUrl: version2.data.modelBaseUrl,
|
||||
modelName: version2.data.modelName,
|
||||
opencodeBaseUrl: version2.data.opencodeBaseUrl,
|
||||
opencodeEmbedded: version2.data.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
version2.data.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: version2.data.workspacePath,
|
||||
credential: version2.data.credential,
|
||||
toolApproval: version2.data.toolApproval
|
||||
})
|
||||
const version4 =
|
||||
version4StoredSettingsSchema.safeParse(parsed)
|
||||
if (version4.success) {
|
||||
this.settings = migrateVersion4(version4.data)
|
||||
} else {
|
||||
const legacy =
|
||||
legacyStoredSettingsSchema.parse(parsed)
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
legacy.provider === 'bigtoken'
|
||||
? 'model'
|
||||
: legacy.provider,
|
||||
modelBaseUrl: legacy.bigtokenBaseUrl,
|
||||
modelName: legacy.bigtokenModel,
|
||||
opencodeBaseUrl: legacy.opencodeBaseUrl,
|
||||
opencodeEmbedded: legacy.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
legacy.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: legacy.workspacePath,
|
||||
credential: legacy.credential,
|
||||
toolApproval: legacy.toolApproval
|
||||
})
|
||||
const version3 =
|
||||
version3StoredSettingsSchema.safeParse(parsed)
|
||||
if (version3.success) {
|
||||
this.settings = migrateVersion4({
|
||||
...version3.data,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
} else {
|
||||
const version2 =
|
||||
version2StoredSettingsSchema.safeParse(parsed)
|
||||
if (version2.success) {
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider: version2.data.provider,
|
||||
modelBaseUrl: version2.data.modelBaseUrl,
|
||||
modelName: version2.data.modelName,
|
||||
opencodeBaseUrl: version2.data.opencodeBaseUrl,
|
||||
opencodeEmbedded: version2.data.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
version2.data.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: version2.data.workspacePath,
|
||||
credential: version2.data.credential,
|
||||
toolApproval: version2.data.toolApproval
|
||||
})
|
||||
} else {
|
||||
const legacy =
|
||||
legacyStoredSettingsSchema.parse(parsed)
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
legacy.provider === 'bigtoken'
|
||||
? 'model'
|
||||
: legacy.provider,
|
||||
modelBaseUrl: legacy.bigtokenBaseUrl,
|
||||
modelName: legacy.bigtokenModel,
|
||||
opencodeBaseUrl: legacy.opencodeBaseUrl,
|
||||
opencodeEmbedded: legacy.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
legacy.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: legacy.workspacePath,
|
||||
credential: legacy.credential,
|
||||
toolApproval: legacy.toolApproval
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -965,6 +1041,43 @@ export class RuntimeSettingsStore {
|
||||
)
|
||||
}
|
||||
|
||||
private resolvePlatformDeepSeekProfile(): ResolvedModelProfile | undefined {
|
||||
const apiKey = this.environment.GOODBUDDY_MODEL_API_KEY?.trim()
|
||||
const baseUrl = this.environment.GOODBUDDY_MODEL_BASE_URL?.trim()
|
||||
const modelName = this.environment.GOODBUDDY_MODEL_NAME?.trim()
|
||||
if (!apiKey || !baseUrl || !modelName) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const endpoint = new URL(baseUrl)
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.hostname.toLowerCase() !== 'api.deepseek.com' ||
|
||||
endpoint.port ||
|
||||
endpoint.pathname !== '/' ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
endpoint.username ||
|
||||
endpoint.password
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
id: platformDeepSeekProfileId,
|
||||
name: '平台 DeepSeek',
|
||||
baseUrl,
|
||||
modelName,
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEffectiveModelSettings(settings: StoredSettings): {
|
||||
apiKey?: string
|
||||
baseUrl: string
|
||||
@@ -1235,6 +1348,8 @@ export class RuntimeSettingsStore {
|
||||
? { kind: 'platform' }
|
||||
: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
settings.deepseekHarnessModelSource,
|
||||
secureStorageAvailable: this.cipher.isAvailable(),
|
||||
toolApproval: settings.toolApproval,
|
||||
configured: {
|
||||
@@ -1246,7 +1361,9 @@ export class RuntimeSettingsStore {
|
||||
continueConfigPath: settings.continueConfigPath,
|
||||
workspacePath: settings.workspacePath || homedir(),
|
||||
opencodeModelSource: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource
|
||||
continueModelSource: settings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
settings.deepseekHarnessModelSource
|
||||
},
|
||||
...(this.loadWarnings.length > 0
|
||||
? { warnings: [...this.loadWarnings] }
|
||||
@@ -1284,6 +1401,12 @@ export class RuntimeSettingsStore {
|
||||
settings.continueModelSource.kind === 'profile'
|
||||
? profilesById.get(settings.continueModelSource.profileId)
|
||||
: undefined
|
||||
const deepseekHarnessModelProfile =
|
||||
settings.deepseekHarnessModelSource.kind === 'profile'
|
||||
? profilesById.get(
|
||||
settings.deepseekHarnessModelSource.profileId
|
||||
)
|
||||
: this.resolvePlatformDeepSeekProfile()
|
||||
return {
|
||||
provider: settings.provider,
|
||||
modelBaseUrl: effective.baseUrl,
|
||||
@@ -1297,6 +1420,7 @@ export class RuntimeSettingsStore {
|
||||
defaultModelProfileId: settings.defaultModelProfileId,
|
||||
opencodeModelProfile,
|
||||
continueModelProfile,
|
||||
deepseekHarnessModelProfile,
|
||||
...agent,
|
||||
subagentSmartRoutingEnabled:
|
||||
settings.subagentSmartRoutingEnabled,
|
||||
@@ -1589,15 +1713,34 @@ export class RuntimeSettingsStore {
|
||||
: repairRuntimeSource(current.continueModelSource)
|
||||
validateRuntimeSource(opencodeModelSource, 'OpenCode')
|
||||
validateRuntimeSource(continueModelSource, 'Continue')
|
||||
const requestedDeepSeekHarnessSource =
|
||||
input.deepseekHarnessModelSource ??
|
||||
current.deepseekHarnessModelSource
|
||||
if (requestedDeepSeekHarnessSource.kind === 'profile') {
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === requestedDeepSeekHarnessSource.profileId
|
||||
)
|
||||
if (!profile) {
|
||||
throw new Error('DeepSeek Harness 引用的模型连接不存在')
|
||||
}
|
||||
if (!isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: input.provider,
|
||||
modelProfiles,
|
||||
defaultModelProfileId,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
requestedDeepSeekHarnessSource,
|
||||
opencodeBaseUrl,
|
||||
opencodeEmbedded: !opencodeBaseUrl,
|
||||
opencodeBinaryPath,
|
||||
|
||||
Reference in New Issue
Block a user