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
|
||||
|
||||
Reference in New Issue
Block a user