feat: add DeepSeek Harness runtime
This commit is contained in:
@@ -28,6 +28,13 @@ describe('bundled runtime paths', () => {
|
||||
'cli',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
'workspace',
|
||||
'app',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +62,14 @@ describe('bundled runtime paths', () => {
|
||||
'continue',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,12 @@ import { join } from 'node:path'
|
||||
export type BundledRuntimePaths = {
|
||||
opencode: string
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
|
||||
export const bundledContinueVersion = '1.5.47'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.0-rc.6'
|
||||
|
||||
export function resolveBundledRuntimePaths(input: {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
@@ -29,6 +33,13 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'continue',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
input.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +59,12 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'cli',
|
||||
'dist',
|
||||
'cn.js'
|
||||
),
|
||||
deepseekHarness: join(
|
||||
input.appPath,
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,38 @@ function settings(
|
||||
}
|
||||
|
||||
describe('createAgentRuntime model compatibility', () => {
|
||||
it('does not treat the default model profile as the platform DeepSeek source', () => {
|
||||
const defaultProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Default DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const,
|
||||
apiKey: 'default-deepseek-key'
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
createAgentRuntime(
|
||||
process.cwd(),
|
||||
settings({
|
||||
provider: 'deepseek-harness',
|
||||
modelBaseUrl: defaultProfile.baseUrl,
|
||||
modelName: defaultProfile.modelName,
|
||||
modelProtocol: defaultProfile.protocol,
|
||||
modelAuthentication: defaultProfile.authentication,
|
||||
apiKey: defaultProfile.apiKey,
|
||||
modelProfiles: [defaultProfile],
|
||||
runtimeSandboxMode: 'auto'
|
||||
}),
|
||||
{ deepseekHarnessLauncher: vi.fn() }
|
||||
)
|
||||
).toThrow(
|
||||
'DeepSeek Harness 需要 api.deepseek.com 的 OpenAI Chat Completions 模型连接'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates an available direct runtime for a no-auth model', async () => {
|
||||
const runtime = createAgentRuntime(process.cwd(), settings())
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ModelAgentRuntime } from './model-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessRuntimeOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import type { AgentRuntime } from './runtime'
|
||||
import { UnconfiguredAgentRuntime } from './unconfigured-runtime'
|
||||
import type {
|
||||
@@ -9,6 +13,7 @@ import type {
|
||||
} from '../runtime-settings-store'
|
||||
import {
|
||||
defaultRuntimeSettings,
|
||||
isDeepSeekHarnessModelProfile,
|
||||
isAgentRuntimeModelProtocol
|
||||
} from '../../shared/contracts'
|
||||
import type {
|
||||
@@ -21,6 +26,7 @@ import { resolveRuntimeSandbox } from './runtime-sandbox'
|
||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelToolProvider } from './model-tool-provider'
|
||||
|
||||
const noSubagentTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -41,6 +47,7 @@ export type AgentCapabilityContext = {
|
||||
continueHostCacheRoot?: string
|
||||
bundledRuntimePaths?: BundledRuntimePaths
|
||||
continueHostLauncher?: ContinueHostLauncher
|
||||
deepseekHarnessLauncher?: DeepSeekHarnessRuntimeOptions['launch']
|
||||
browserService?: BrowserToolService
|
||||
knowledgeGateway?: KnowledgeMcpGateway
|
||||
webSearchEnabled?: boolean
|
||||
@@ -102,6 +109,43 @@ export function createAgentRuntime(
|
||||
settings?.runtimeSandboxMode ??
|
||||
defaultRuntimeSettings.runtimeSandboxMode
|
||||
|
||||
if (provider === 'deepseek-harness') {
|
||||
const profile = settings?.deepseekHarnessModelProfile
|
||||
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 需要 api.deepseek.com 的 OpenAI Chat Completions 模型连接'
|
||||
)
|
||||
}
|
||||
if (!profile.apiKey) {
|
||||
throw new Error('DeepSeek Harness 模型连接未配置 API Key')
|
||||
}
|
||||
if (!capabilities.deepseekHarnessLauncher) {
|
||||
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
|
||||
}
|
||||
if (sandboxMode === 'off') {
|
||||
throw new Error('DeepSeek Harness Execute 需要启用 Runtime 沙箱')
|
||||
}
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
launch: capabilities.deepseekHarnessLauncher,
|
||||
credentialRefs: {
|
||||
GOODBUDDY_DEEPSEEK_API_KEY: profile.apiKey
|
||||
},
|
||||
requiredSandboxEnforcement:
|
||||
sandboxMode === 'strict' ? 'full' : 'partial',
|
||||
skillPackages: capabilities.skillPackages,
|
||||
toolProvider: new ModelToolProvider(
|
||||
workspace,
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
false
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (provider === 'continue') {
|
||||
if (
|
||||
settings?.continueModelProfile &&
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CallId,
|
||||
type GenerateOptions,
|
||||
type StreamChunk
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
type ModelToolCallContext
|
||||
} from './model-tool-provider'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
startControlledDeepSeekHarnessHost,
|
||||
type ControlledHarnessHost
|
||||
} from '../deepseek-harness-host'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessChild,
|
||||
type DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
|
||||
|
||||
const MAX_FRAME_BYTES = 1024 * 1024
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_DEEPSEEK_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
const MCP_CALL_ID = 'e2e-mcp-call'
|
||||
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
|
||||
const MICRO_DELTA_COUNT = 30_000
|
||||
|
||||
function expectedSandbox() {
|
||||
return process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' as const }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' as const }
|
||||
: { provider: 'local-linux', enforcement: 'full' as const }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolvePromise!: (value: T) => void
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
function toolResultText(
|
||||
options: GenerateOptions,
|
||||
callId: string
|
||||
): string | undefined {
|
||||
for (const message of options.messages) {
|
||||
for (const block of message.content) {
|
||||
if (
|
||||
block.type !== 'tool-result' ||
|
||||
block.toolCallId !== callId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return block.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof block.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
.map((content) => content.text)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function latestUserText(options: GenerateOptions): string {
|
||||
return options.messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.role === 'user' &&
|
||||
message.source.kind === 'user'
|
||||
)
|
||||
.flatMap((message) =>
|
||||
message.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof message.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
.map((content) => content.text)
|
||||
)
|
||||
.at(-1) ?? ''
|
||||
}
|
||||
|
||||
async function* toolCall(
|
||||
callId: string,
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const id = CallId(callId)
|
||||
const argumentsText = JSON.stringify(argumentsValue)
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'tool-call'
|
||||
}
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id,
|
||||
name,
|
||||
argumentsDelta: argumentsText
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'tool-call',
|
||||
id,
|
||||
name,
|
||||
arguments: argumentsText
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'tool-calls' }
|
||||
}
|
||||
}
|
||||
|
||||
async function* textResponse(
|
||||
text: string
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'text'
|
||||
}
|
||||
yield {
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'text', text }
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' }
|
||||
}
|
||||
}
|
||||
|
||||
async function* microDeltaResponse(): AsyncGenerator<StreamChunk> {
|
||||
yield {
|
||||
type: 'block-start',
|
||||
index: 0,
|
||||
blockType: 'reasoning'
|
||||
}
|
||||
for (let index = 0; index < MICRO_DELTA_COUNT; index += 1) {
|
||||
yield {
|
||||
type: 'reasoning-delta',
|
||||
index: 0,
|
||||
text: String(index % 10)
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'reasoning',
|
||||
text: Array.from(
|
||||
{ length: MICRO_DELTA_COUNT },
|
||||
(_value, index) => String(index % 10)
|
||||
).join('')
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 8_000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' }
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGameModel {
|
||||
mcpToolName?: string
|
||||
skillResult?: string
|
||||
blueprint?: Record<string, unknown>
|
||||
askToolResult?: string
|
||||
executeToolNames: string[] = []
|
||||
askToolNames: string[] = []
|
||||
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const prompt = latestUserText(options)
|
||||
const toolNames = options.tools?.map((tool) => tool.name) ?? []
|
||||
|
||||
if (prompt.includes('ASK_BOUNDARY_PROBE')) {
|
||||
this.askToolNames = toolNames
|
||||
const result = toolResultText(options, ASK_MCP_CALL_ID)
|
||||
if (!result) {
|
||||
if (!this.mcpToolName) {
|
||||
throw new Error('Fake model has no prior MCP tool identity')
|
||||
}
|
||||
return toolCall(ASK_MCP_CALL_ID, this.mcpToolName, {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'ask-must-not-execute',
|
||||
targetCount: 5
|
||||
})
|
||||
}
|
||||
this.askToolResult = result
|
||||
return textResponse('Ask mode MCP proxy unavailable as required.')
|
||||
}
|
||||
|
||||
this.executeToolNames = toolNames
|
||||
const skillResult = toolResultText(options, SKILL_CALL_ID)
|
||||
if (!skillResult) {
|
||||
return toolCall(SKILL_CALL_ID, 'skill', {
|
||||
name: 'web-3d-game'
|
||||
})
|
||||
}
|
||||
this.skillResult = skillResult
|
||||
|
||||
const blueprintResult = toolResultText(options, MCP_CALL_ID)
|
||||
if (!blueprintResult) {
|
||||
const mcpTool = options.tools?.find((tool) =>
|
||||
tool.name.endsWith('_create_game_blueprint')
|
||||
)
|
||||
if (!mcpTool) {
|
||||
throw new Error(
|
||||
'Main-mediated 3D blueprint MCP tool was not exposed'
|
||||
)
|
||||
}
|
||||
this.mcpToolName = mcpTool.name
|
||||
return toolCall(MCP_CALL_ID, mcpTool.name, {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
})
|
||||
}
|
||||
this.blueprint = JSON.parse(
|
||||
blueprintResult
|
||||
) as Record<string, unknown>
|
||||
return textResponse(
|
||||
'Loaded the Web 3D Game Skill and the approved Prism Relay blueprint.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type HarnessModel = {
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
}
|
||||
|
||||
async function collect(
|
||||
stream: AsyncGenerator<RuntimeEvent, void, void>
|
||||
): Promise<RuntimeEvent[]> {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function createInProcessLaunch(
|
||||
dshHome: string,
|
||||
model: HarnessModel
|
||||
): {
|
||||
launch(
|
||||
options: DeepSeekHarnessLaunchOptions
|
||||
): Promise<DeepSeekHarnessChild>
|
||||
hosts: ControlledHarnessHost[]
|
||||
} {
|
||||
const hosts: ControlledHarnessHost[] = []
|
||||
return {
|
||||
hosts,
|
||||
async launch(options) {
|
||||
const clientToHost =
|
||||
new TransformStream<Uint8Array, Uint8Array>()
|
||||
const hostToClient =
|
||||
new TransformStream<Uint8Array, Uint8Array>()
|
||||
const exited = deferred<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: options.cwd,
|
||||
dshHome,
|
||||
baseUrl: options.baseUrl,
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox(),
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
stream: createBoundedNdJsonStream(
|
||||
hostToClient.writable,
|
||||
clientToHost.readable,
|
||||
MAX_FRAME_BYTES
|
||||
)
|
||||
})
|
||||
hosts.push(host)
|
||||
host.context.on(
|
||||
'llm/stream',
|
||||
(request) => model.stream(request),
|
||||
{ global: true, prepend: true }
|
||||
)
|
||||
let terminated = false
|
||||
return {
|
||||
stdin: clientToHost.writable,
|
||||
stdout: hostToClient.readable,
|
||||
exited: exited.promise,
|
||||
async terminate() {
|
||||
if (terminated) {
|
||||
return
|
||||
}
|
||||
terminated = true
|
||||
await host.dispose().catch(() => undefined)
|
||||
await Promise.allSettled([
|
||||
clientToHost.writable.close(),
|
||||
hostToClient.writable.close()
|
||||
])
|
||||
exited.resolve({ exitCode: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it(
|
||||
'coalesces micro reasoning deltas without losing content and caps each model step',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-deltas-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
let observedRequest: GenerateOptions | undefined
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream(options) {
|
||||
observedRequest = options
|
||||
return microDeltaResponse()
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 20_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
const events = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-deltas',
|
||||
conversationId: 'acp-deltas',
|
||||
prompt: 'Return the deterministic reasoning stream.',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const reasoning = events.filter(
|
||||
(
|
||||
event
|
||||
): event is Extract<
|
||||
RuntimeEvent,
|
||||
{ type: 'reasoning' }
|
||||
> => event.type === 'reasoning'
|
||||
)
|
||||
|
||||
expect(observedRequest?.maxTokens).toBe(
|
||||
GOODBUDDY_HARNESS_MAX_STEP_TOKENS
|
||||
)
|
||||
expect(observedRequest?.system).toContain(
|
||||
'act through the available tools'
|
||||
)
|
||||
expect(reasoning).toHaveLength(8)
|
||||
expect(
|
||||
reasoning.map((event) => event.delta).join('')
|
||||
).toBe(
|
||||
Array.from(
|
||||
{ length: MICRO_DELTA_COUNT },
|
||||
(_value, index) => String(index % 10)
|
||||
).join('')
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it(
|
||||
'rejects the ACP prompt with a bounded model turn error',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-error-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream() {
|
||||
throw new Error('synthetic model turn failed')
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 2_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-error',
|
||||
conversationId: 'acp-error',
|
||||
prompt: 'Trigger the synthetic model failure.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
).rejects.toThrow('synthetic model turn failed')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it(
|
||||
'loads a native Skill, calls an approved real MCP, forwards events, and removes MCP in Ask',
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-e2e-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh-home')
|
||||
await Promise.all([
|
||||
mkdir(workspace),
|
||||
mkdir(dshHome)
|
||||
])
|
||||
const provider = new ModelToolProvider(workspace, [
|
||||
{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54',
|
||||
name: 'Local 3D Game Blueprint',
|
||||
description: 'Deterministic integration fixture',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolve(
|
||||
'tests',
|
||||
'fixtures',
|
||||
'web-3d-game-mcp.mjs'
|
||||
)
|
||||
]
|
||||
} satisfies ResolvedMcpServer
|
||||
])
|
||||
const callTool = vi.spyOn(provider, 'callTool')
|
||||
const listTools = vi.spyOn(provider, 'listTools')
|
||||
const fakeModel = new FakeGameModel()
|
||||
const inProcess = createInProcessLaunch(dshHome, fakeModel)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch: (options) => inProcess.launch(options),
|
||||
credentialRefs: {
|
||||
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
|
||||
},
|
||||
skillPackages: [
|
||||
{
|
||||
id: 'web-3d-game',
|
||||
directory: resolve(
|
||||
'resources',
|
||||
'skills',
|
||||
'web-3d-game'
|
||||
)
|
||||
}
|
||||
],
|
||||
toolProvider: provider,
|
||||
initializationTimeoutMs: 20_000,
|
||||
promptTimeoutMs: 20_000,
|
||||
shutdownTimeoutMs: 5_000
|
||||
})
|
||||
const authorize = vi.fn(
|
||||
async (
|
||||
request: Parameters<
|
||||
NonNullable<
|
||||
Parameters<DeepSeekHarnessRuntime['run']>[2]
|
||||
>
|
||||
>[0]
|
||||
) =>
|
||||
request.scopeKey.startsWith('model:mcp:')
|
||||
? ('once' as const)
|
||||
: ('deny' as const)
|
||||
)
|
||||
|
||||
try {
|
||||
const executeEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-execute',
|
||||
conversationId: 'acp-e2e',
|
||||
prompt:
|
||||
'Use the Web 3D Game Skill and assigned blueprint MCP.',
|
||||
workMode: 'execute'
|
||||
},
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
|
||||
expect(fakeModel.executeToolNames).toContain('skill')
|
||||
expect(fakeModel.mcpToolName).toMatch(
|
||||
/_create_game_blueprint$/u
|
||||
)
|
||||
expect(fakeModel.skillResult).toContain(
|
||||
'window.__GOODBUDDY_GAME__'
|
||||
)
|
||||
expect(fakeModel.blueprint).toMatchObject({
|
||||
title: 'Prism Relay',
|
||||
objective: { targetCount: 5 },
|
||||
acceptance: {
|
||||
testSurface: 'window.__GOODBUDDY_GAME__'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledOnce()
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
fakeModel.mcpToolName,
|
||||
{
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
},
|
||||
expect.any(AbortSignal),
|
||||
{
|
||||
conversationId: 'acp-e2e',
|
||||
workMode: 'execute',
|
||||
knowledgeCapabilityToken: undefined
|
||||
} satisfies ModelToolCallContext
|
||||
)
|
||||
expect(executeEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: SKILL_CALL_ID,
|
||||
name: 'skill',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: SKILL_CALL_ID,
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: MCP_CALL_ID,
|
||||
name: fakeModel.mcpToolName,
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: MCP_CALL_ID,
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: expect.stringContaining('Prism Relay')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'model-usage',
|
||||
runtime: 'deepseek-harness'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'done',
|
||||
sessionId: expect.any(String)
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(
|
||||
executeEvents.filter(
|
||||
(event) =>
|
||||
event.type === 'tool' &&
|
||||
event.state === 'running'
|
||||
)
|
||||
).toHaveLength(0)
|
||||
|
||||
const callsBeforeAsk = callTool.mock.calls.length
|
||||
const listsBeforeAsk = listTools.mock.calls.length
|
||||
const approvalsBeforeAsk = authorize.mock.calls.length
|
||||
const askEvents = await collect(
|
||||
runtime.run(
|
||||
{
|
||||
requestId: 'request-acp-ask',
|
||||
conversationId: 'acp-e2e',
|
||||
prompt:
|
||||
'ASK_BOUNDARY_PROBE: attempt the previous MCP tool.',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
|
||||
expect(fakeModel.askToolNames).not.toContain(
|
||||
fakeModel.mcpToolName
|
||||
)
|
||||
expect(fakeModel.askToolResult).toContain('unknown tool')
|
||||
expect(callTool).toHaveBeenCalledTimes(callsBeforeAsk)
|
||||
expect(listTools).toHaveBeenCalledTimes(listsBeforeAsk)
|
||||
expect(authorize).toHaveBeenCalledTimes(approvalsBeforeAsk)
|
||||
expect(askEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: ASK_MCP_CALL_ID,
|
||||
name: fakeModel.mcpToolName,
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: ASK_MCP_CALL_ID,
|
||||
state: 'failed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: expect.stringContaining(
|
||||
'MCP proxy unavailable'
|
||||
)
|
||||
}),
|
||||
expect.objectContaining({ type: 'done' })
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await Promise.allSettled(
|
||||
inProcess.hosts.map((host) => host.dispose())
|
||||
)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,920 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
type ModelToolDefinition,
|
||||
type ModelToolProviderLike
|
||||
} from './model-tool-provider'
|
||||
import type {
|
||||
ResolvedMcpServer
|
||||
} from '../capabilities/capability-service'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
harnessPromptError,
|
||||
type DeepSeekHarnessAcpSdk,
|
||||
type DeepSeekHarnessChild
|
||||
} from './deepseek-harness-runtime'
|
||||
import { RequestError } from '@agentclientprotocol/sdk'
|
||||
|
||||
type Permission = Parameters<
|
||||
ReturnType<
|
||||
ConstructorParameters<
|
||||
DeepSeekHarnessAcpSdk['ClientSideConnection']
|
||||
>[0]
|
||||
>['requestPermission']
|
||||
>[0]
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
toolProvider?: ModelToolProviderLike
|
||||
promptTimeoutMs?: number
|
||||
maxEventCharacters?: number
|
||||
maxRequestOutputCharacters?: number
|
||||
} = {}
|
||||
) {
|
||||
const exit = deferred<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>()
|
||||
const stderr = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const child: DeepSeekHarnessChild = {
|
||||
stdin: new WritableStream<Uint8Array>(),
|
||||
stdout: new ReadableStream<Uint8Array>(),
|
||||
stderr: stderr.readable,
|
||||
exited: exit.promise,
|
||||
terminate: vi.fn()
|
||||
}
|
||||
let permissionHandler:
|
||||
| ((params: Permission) => Promise<unknown>)
|
||||
| undefined
|
||||
let updateHandler:
|
||||
| ((context: {
|
||||
sessionId: string
|
||||
update: Record<string, unknown>
|
||||
}) => Promise<void>)
|
||||
| undefined
|
||||
let extensionHandler:
|
||||
| ((
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>)
|
||||
| undefined
|
||||
const requests: Array<{
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}> = []
|
||||
const notifications: Array<{
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}> = []
|
||||
const promptGates: Array<ReturnType<typeof deferred<{ stopReason: string }>>> =
|
||||
[]
|
||||
let sessionIndex = 0
|
||||
const connectionClosed = deferred<void>()
|
||||
const connectionController = new AbortController()
|
||||
const requestAgent = async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => {
|
||||
requests.push({ method, params })
|
||||
if (method === 'initialize') {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {}
|
||||
}
|
||||
}
|
||||
if (method === 'session/new') {
|
||||
sessionIndex += 1
|
||||
return { sessionId: `session-${sessionIndex}` }
|
||||
}
|
||||
if (method === 'session/prompt') {
|
||||
const gate = deferred<{ stopReason: string }>()
|
||||
promptGates.push(gate)
|
||||
return gate.promise
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
}
|
||||
const notifyAgent = async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => {
|
||||
notifications.push({ method, params })
|
||||
}
|
||||
const agent = {
|
||||
initialize: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('initialize', params)
|
||||
),
|
||||
newSession: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('session/new', params)
|
||||
),
|
||||
prompt: vi.fn((params: Record<string, unknown>) =>
|
||||
requestAgent('session/prompt', params)
|
||||
),
|
||||
cancel: vi.fn((params: Record<string, unknown>) =>
|
||||
notifyAgent('session/cancel', params)
|
||||
),
|
||||
extMethod: vi.fn(
|
||||
async (method: string, params: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
if (method === 'goodbuddy/handshake') {
|
||||
return {
|
||||
controlProtocolVersion: 1,
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
acpProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
sessionRelease: true,
|
||||
oneShotApproval: true,
|
||||
reasoningEvents: true,
|
||||
toolEvents: true,
|
||||
usageEvents: true,
|
||||
credentialResolution: true
|
||||
},
|
||||
sandbox: {
|
||||
provider: 'test',
|
||||
enforcement: 'full'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'goodbuddy/session/prepare') {
|
||||
return { prepared: true }
|
||||
}
|
||||
if (method === 'goodbuddy/session/release') {
|
||||
return { released: true }
|
||||
}
|
||||
if (method === 'goodbuddy/shutdown') {
|
||||
return { shutdown: true }
|
||||
}
|
||||
throw new Error(`unexpected extension: ${method}`)
|
||||
}
|
||||
),
|
||||
extNotification: vi.fn()
|
||||
}
|
||||
const connection = {
|
||||
...agent,
|
||||
signal: connectionController.signal,
|
||||
closed: connectionClosed.promise
|
||||
}
|
||||
const ClientSideConnection = vi.fn(function (
|
||||
this: unknown,
|
||||
toClient: (
|
||||
connectedAgent: typeof agent
|
||||
) => {
|
||||
requestPermission: typeof permissionHandler
|
||||
sessionUpdate: typeof updateHandler
|
||||
extMethod: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>
|
||||
extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<void>
|
||||
}
|
||||
) {
|
||||
const client = toClient(agent)
|
||||
permissionHandler = client.requestPermission
|
||||
updateHandler = client.sessionUpdate
|
||||
extensionHandler = client.extMethod
|
||||
agent.extNotification.mockImplementation(
|
||||
async (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => client.extNotification(method, params)
|
||||
)
|
||||
return connection
|
||||
})
|
||||
const sdk = {
|
||||
PROTOCOL_VERSION: 1,
|
||||
ClientSideConnection,
|
||||
ndJsonStream: vi.fn(() => ({ stream: true }))
|
||||
} as unknown as DeepSeekHarnessAcpSdk
|
||||
const launch = vi.fn(async () => child)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: 'C:\\workspace',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
launch,
|
||||
loadAcpSdk: async () => sdk,
|
||||
initializationTimeoutMs: 100,
|
||||
promptTimeoutMs: options.promptTimeoutMs ?? 100,
|
||||
shutdownTimeoutMs: 10,
|
||||
maxStderrBytes: 16,
|
||||
maxEventCharacters: options.maxEventCharacters,
|
||||
maxRequestOutputCharacters:
|
||||
options.maxRequestOutputCharacters,
|
||||
toolProvider: options.toolProvider
|
||||
})
|
||||
const emit = async (
|
||||
sessionId: string,
|
||||
update: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
await updateHandler?.({ sessionId, update })
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
child,
|
||||
stderr,
|
||||
exit,
|
||||
sdk,
|
||||
launch,
|
||||
requests,
|
||||
notifications,
|
||||
promptGates,
|
||||
agent,
|
||||
permission: async (request: Permission) =>
|
||||
permissionHandler?.(request),
|
||||
extension: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => extensionHandler?.(method, params),
|
||||
notify: (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
) => agent.extNotification(method, params),
|
||||
emit
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(
|
||||
stream: AsyncGenerator<RuntimeEvent, void, void>
|
||||
): Promise<RuntimeEvent[]> {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function request(
|
||||
conversationId: string,
|
||||
workMode: 'ask' | 'execute' = 'execute'
|
||||
) {
|
||||
return {
|
||||
requestId: `request-${conversationId}`,
|
||||
conversationId,
|
||||
prompt: 'hello',
|
||||
workMode
|
||||
} as const
|
||||
}
|
||||
|
||||
function permission(sessionId: string): Permission {
|
||||
return {
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: 'call-1',
|
||||
title: 'Run tests',
|
||||
name: 'shell',
|
||||
kind: 'execute',
|
||||
rawInput: { command: 'npm test' }
|
||||
},
|
||||
options: [
|
||||
{
|
||||
optionId: 'allow-once',
|
||||
name: 'Allow once',
|
||||
kind: 'allow_once'
|
||||
},
|
||||
{
|
||||
optionId: 'allow-always',
|
||||
name: 'Always allow',
|
||||
kind: 'allow_always'
|
||||
},
|
||||
{
|
||||
optionId: 'reject',
|
||||
name: 'Reject',
|
||||
kind: 'reject_once'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function mcpTool(
|
||||
name = 'mcp_deadbeef_cafebabe_game_asset'
|
||||
): ModelToolDefinition {
|
||||
return {
|
||||
name,
|
||||
displayName: 'Local Game Assets / game_asset',
|
||||
description: 'Returns a deterministic local game asset manifest.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string' }
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false
|
||||
},
|
||||
source: 'mcp',
|
||||
serverName: 'Local Game Assets'
|
||||
}
|
||||
}
|
||||
|
||||
function toolProvider(
|
||||
tools: ModelToolDefinition[] = [mcpTool()]
|
||||
): ModelToolProviderLike {
|
||||
return {
|
||||
listTools: vi.fn(async () => tools),
|
||||
getApproval: vi.fn((tool, _arguments, summary) => ({
|
||||
scopeKey: `model:mcp:${tool.name}`,
|
||||
title: `允许调用 MCP 工具「${tool.displayName}」?`,
|
||||
description: '调用本地测试 MCP。',
|
||||
toolName: tool.displayName,
|
||||
argumentSummary: summary,
|
||||
allowPermanent: false
|
||||
})),
|
||||
callTool: vi.fn(async () => ({
|
||||
parts: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: '{"asset":"cube"}'
|
||||
}
|
||||
],
|
||||
contextBytes: 16
|
||||
})),
|
||||
releaseConversation: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeekHarnessRuntime', () => {
|
||||
it('surfaces bounded internal Harness details from ACP errors', () => {
|
||||
expect(
|
||||
harnessPromptError(
|
||||
RequestError.internalError({
|
||||
details: 'DeepSeek provider rejected the request'
|
||||
})
|
||||
)
|
||||
).toEqual(
|
||||
new Error('DeepSeek provider rejected the request')
|
||||
)
|
||||
expect(
|
||||
harnessPromptError(
|
||||
RequestError.internalError({ unrelated: 'hidden' })
|
||||
)
|
||||
).toBeInstanceOf(RequestError)
|
||||
})
|
||||
|
||||
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
|
||||
const harness = setup()
|
||||
const first = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.emit('session-1', {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'hello ' }
|
||||
})
|
||||
await harness.emit('session-1', {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'world' }
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
expect(await first).toEqual([
|
||||
expect.objectContaining({ type: 'status' }),
|
||||
expect.objectContaining({ type: 'text', delta: 'hello ' }),
|
||||
expect.objectContaining({ type: 'text', delta: 'world' }),
|
||||
expect.objectContaining({
|
||||
type: 'done',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
])
|
||||
expect(harness.sdk.ndJsonStream).toHaveBeenCalledWith(
|
||||
harness.child.stdin,
|
||||
harness.child.stdout
|
||||
)
|
||||
expect(harness.launch).toHaveBeenCalledWith({
|
||||
cwd: 'C:\\workspace',
|
||||
signal: expect.any(AbortSignal),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
credentialRefs: [],
|
||||
requiredSandboxEnforcement: undefined,
|
||||
skillPackages: []
|
||||
})
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/prepare',
|
||||
params: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-one',
|
||||
mode: 'execute'
|
||||
}
|
||||
})
|
||||
|
||||
const second = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(2)
|
||||
)
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
await second
|
||||
expect(
|
||||
harness.requests.filter(({ method }) => method === 'session/new')
|
||||
).toHaveLength(1)
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('enforces the cumulative bridge limit against complete wire events', async () => {
|
||||
const harness = setup({
|
||||
maxEventCharacters: 1_000,
|
||||
maxRequestOutputCharacters: 180
|
||||
})
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('output-limit'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await harness.notify('goodbuddy/session/event', {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-output-limit',
|
||||
type: 'reasoning',
|
||||
delta: 'x'.repeat(40)
|
||||
})
|
||||
await harness.notify('goodbuddy/session/event', {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-output-limit',
|
||||
type: 'reasoning',
|
||||
delta: 'y'.repeat(40)
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
await expect(running).rejects.toThrow(
|
||||
'请求累计输出超过安全限制'
|
||||
)
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps independent conversation sessions distinct', async () => {
|
||||
const harness = setup()
|
||||
const first = collect(
|
||||
harness.runtime.run(
|
||||
request('one'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
const second = collect(
|
||||
harness.runtime.run(
|
||||
request('two'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(2)
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
await Promise.all([first, second])
|
||||
|
||||
const prompts = harness.requests.filter(
|
||||
({ method }) => method === 'session/prompt'
|
||||
)
|
||||
expect(prompts.map(({ params }) => params.sessionId).sort()).toEqual([
|
||||
'session-1',
|
||||
'session-2'
|
||||
])
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails Ask closed and never calls the authorizer', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('ask', 'ask'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.permission(permission('session-1'))
|
||||
).resolves.toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'reject' }
|
||||
})
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/prepare',
|
||||
params: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-ask',
|
||||
mode: 'ask'
|
||||
}
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('authorizes Execute but can select only allow-once', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('always')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('execute'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.permission(permission('session-1'))
|
||||
).resolves.toEqual({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: 'allow-once'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
scopeKey: 'deepseek-harness:shell',
|
||||
title: 'Run tests',
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
toolName: 'shell',
|
||||
argumentSummary: '{\n "command": "npm test"\n}',
|
||||
allowPermanent: false
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('lists only bounded MCP schemas without exposing server secrets', async () => {
|
||||
const provider = toolProvider([
|
||||
mcpTool(),
|
||||
{
|
||||
...mcpTool('workspace_read_text'),
|
||||
source: 'builtin'
|
||||
}
|
||||
])
|
||||
const harness = setup({ toolProvider: provider })
|
||||
await harness.runtime.getStatus()
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tools: [
|
||||
{
|
||||
name: mcpTool().name,
|
||||
description: mcpTool().description,
|
||||
inputSchema: mcpTool().inputSchema
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(
|
||||
JSON.stringify(
|
||||
await harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
})
|
||||
)
|
||||
).not.toContain('secret')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('rejects MCP calls in Ask mode without approval or execution', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-ask', 'ask'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).rejects.toThrow('需要 Execute 模式')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('requires one-time approval before calling an assigned MCP tool', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-execute'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
content: [
|
||||
{ type: 'text', text: '{"asset":"cube"}' }
|
||||
]
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledTimes(1)
|
||||
expect(provider.callTool).toHaveBeenCalledWith(
|
||||
mcpTool().name,
|
||||
{ kind: 'cube' },
|
||||
expect.any(AbortSignal),
|
||||
expect.objectContaining({
|
||||
conversationId: 'mcp-execute',
|
||||
workMode: 'execute'
|
||||
})
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('lists and calls a real local stdio MCP through the Main proxy', async () => {
|
||||
const provider = new ModelToolProvider(process.cwd(), [
|
||||
{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54',
|
||||
name: 'Local 3D Game Blueprint',
|
||||
description: 'Deterministic integration fixture',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||
]
|
||||
} satisfies ResolvedMcpServer
|
||||
])
|
||||
const harness = setup({
|
||||
toolProvider: provider,
|
||||
promptTimeoutMs: 10_000
|
||||
})
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('real-mcp'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
try {
|
||||
const catalog = await harness.extension(
|
||||
'goodbuddy/tools/list',
|
||||
{ sessionId: 'session-1' }
|
||||
)
|
||||
const tool = (
|
||||
catalog as {
|
||||
tools: Array<{
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
).tools.find((candidate) =>
|
||||
candidate.name.endsWith('_create_game_blueprint')
|
||||
)
|
||||
expect(tool).toMatchObject({
|
||||
description: expect.stringContaining(
|
||||
'offline WebGL game design'
|
||||
),
|
||||
inputSchema: expect.objectContaining({ type: 'object' })
|
||||
})
|
||||
|
||||
const result = await harness.extension(
|
||||
'goodbuddy/tools/call',
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
name: tool!.name,
|
||||
arguments: {
|
||||
theme: 'neon-ruins',
|
||||
seed: 'goodbuddy-0.9.0',
|
||||
targetCount: 5
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining('"title":"Prism Relay"')
|
||||
}
|
||||
]
|
||||
})
|
||||
const blueprint = JSON.parse(
|
||||
(
|
||||
result as {
|
||||
content: [{ type: 'text'; text: string }]
|
||||
}
|
||||
).content[0].text
|
||||
) as Record<string, unknown>
|
||||
expect(blueprint).toMatchObject({
|
||||
acceptance: {
|
||||
testSurface: 'window.__GOODBUDDY_GAME__'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
harness.promptGates[0]?.resolve({ stopReason: 'end_turn' })
|
||||
await running.catch(() => undefined)
|
||||
await harness.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not execute an MCP tool when authorization is denied', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('deny')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-denied'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: { kind: 'cube' }
|
||||
})
|
||||
).rejects.toThrow('未获执行授权')
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('validates MCP arguments before requesting authorization', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ toolProvider: provider })
|
||||
const authorize = vi.fn().mockResolvedValue('once')
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('mcp-invalid'),
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
sessionId: 'session-1',
|
||||
name: mcpTool().name,
|
||||
arguments: {}
|
||||
})
|
||||
).rejects.toThrow('MCP 工具参数无效')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(provider.callTool).not.toHaveBeenCalled()
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('translates AbortSignal to session/cancel', async () => {
|
||||
const harness = setup()
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(request('abort'), controller.signal)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'cancelled' })
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails on bounded stderr overflow without exposing stderr text', async () => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('stderr'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
const writer = harness.stderr.writable.getWriter()
|
||||
await writer.write(
|
||||
new TextEncoder().encode('private-secret-is-too-long')
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
)
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
|
||||
await expect(running).rejects.toThrow('stderr 超过 16 字节')
|
||||
await expect(running).rejects.not.toThrow('private-secret')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('reports process exit and fully disposes the connection and child', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
harness.exit.resolve({ exitCode: 9 })
|
||||
await vi.waitFor(async () => {
|
||||
const status = await harness.runtime.getStatus()
|
||||
expect(status).toMatchObject({
|
||||
available: false,
|
||||
detail: 'DeepSeek Harness 进程意外退出(code 9)'
|
||||
})
|
||||
})
|
||||
|
||||
await harness.runtime.dispose()
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the required bridge handshake is unavailable', async () => {
|
||||
const harness = setup()
|
||||
harness.agent.extMethod.mockRejectedValueOnce(
|
||||
new Error('method not found')
|
||||
)
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'method not found'
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out a prompt, cancels it, and bounds disposal wait', async () => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('timeout'),
|
||||
new AbortController().signal
|
||||
)
|
||||
)
|
||||
await expect(running).rejects.toThrow(
|
||||
'DeepSeek Harness 请求超时'
|
||||
)
|
||||
expect(harness.notifications).toContainEqual({
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
await expect(harness.runtime.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF,
|
||||
createDeepSeekHarnessUtilityLauncher,
|
||||
parseHarnessControlMessage
|
||||
} from './deepseek-harness-utility-launcher'
|
||||
|
||||
class FakeUtility extends EventEmitter {
|
||||
readonly messages: unknown[] = []
|
||||
readonly stderr = new PassThrough()
|
||||
readonly pid = 123
|
||||
killed = false
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.messages.push(message)
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
|
||||
)
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
const hostPath = join(
|
||||
root,
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
await Promise.all([
|
||||
mkdir(workspace),
|
||||
mkdir(dshHome),
|
||||
writeFile(hostPath, '', 'utf8')
|
||||
])
|
||||
return {
|
||||
dshHome,
|
||||
hostPath,
|
||||
launchOptions: {
|
||||
cwd: workspace,
|
||||
signal: new AbortController().signal,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness utility launcher', () => {
|
||||
it('accepts only strict control messages and secret-free config', () => {
|
||||
expect(
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
).toMatchObject({ type: 'ready' })
|
||||
expect(
|
||||
parseHarnessControlMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready',
|
||||
apiKey: 'must-not-pass'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for Host readiness and sends no credential value', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
const fork = vi.fn(() => utility as never)
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: { PATH: 'C:\\Tools' },
|
||||
fork
|
||||
})
|
||||
|
||||
const launching = launcher(launchOptions)
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
expect(JSON.stringify(utility.messages[0])).not.toContain(
|
||||
'secret'
|
||||
)
|
||||
expect(utility.messages[0]).toMatchObject({
|
||||
type: 'start',
|
||||
config: {
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
|
||||
}
|
||||
})
|
||||
utility.emit('message', {
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
|
||||
await expect(launching).resolves.toMatchObject({
|
||||
stdin: expect.any(WritableStream),
|
||||
stdout: expect.any(ReadableStream)
|
||||
})
|
||||
expect(fork).toHaveBeenCalledWith(
|
||||
hostPath,
|
||||
[],
|
||||
expect.objectContaining({
|
||||
cwd: launchOptions.cwd,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on an invalid Host startup message', async () => {
|
||||
const { dshHome, hostPath, launchOptions } = await fixture()
|
||||
const utility = new FakeUtility()
|
||||
const terminateProcess = vi.fn(() => {
|
||||
utility.killed = true
|
||||
})
|
||||
const launcher = createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: hostPath,
|
||||
dshHome,
|
||||
environment: {},
|
||||
fork: () => utility as never,
|
||||
terminateProcess
|
||||
})
|
||||
|
||||
const launching = launcher(launchOptions)
|
||||
await vi.waitFor(() =>
|
||||
expect(utility.messages).toHaveLength(1)
|
||||
)
|
||||
utility.emit('message', { type: 'ready' })
|
||||
|
||||
await expect(launching).rejects.toThrow('启动协议无效')
|
||||
expect(terminateProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import type { UtilityProcess } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
DeepSeekHarnessChild,
|
||||
DeepSeekHarnessLaunchOptions
|
||||
} from './deepseek-harness-runtime'
|
||||
import { createDeepSeekHarnessUtilityChild } from './deepseek-harness-utility-transport'
|
||||
|
||||
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.control'
|
||||
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1
|
||||
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
|
||||
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
|
||||
'GOODBUDDY_DEEPSEEK_API_KEY'
|
||||
|
||||
const sandboxSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1).max(64),
|
||||
enforcement: z.enum(['full', 'partial'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
const skillPackageSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||
directory: z.string().min(1).max(32_768).refine(isAbsolute)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const controlledHarnessHostConfigSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
dshHome: z.string().min(1).max(32_768).refine(isAbsolute),
|
||||
baseUrl: z
|
||||
.url()
|
||||
.max(2_048)
|
||||
.refine((value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.hostname.toLowerCase() === 'api.deepseek.com' &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
}),
|
||||
api: z.literal('openai-completions'),
|
||||
provider: z.literal('goodbuddy'),
|
||||
model: z.string().min(1).max(128),
|
||||
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
|
||||
sandbox: sandboxSchema,
|
||||
credentialRefs: z
|
||||
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
|
||||
.readonly(),
|
||||
skillPackages: z.array(skillPackageSchema).max(64),
|
||||
maxFrameBytes: z.literal(1024 * 1024)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ControlledHarnessBootstrapConfig = z.infer<
|
||||
typeof controlledHarnessHostConfigSchema
|
||||
>
|
||||
|
||||
export type DeepSeekHarnessControlMessage =
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'start'
|
||||
config: ControlledHarnessBootstrapConfig
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'ready'
|
||||
}
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_CONTROL_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
type: 'fatal'
|
||||
code: string
|
||||
}
|
||||
|
||||
export function parseHarnessControlMessage(
|
||||
value: unknown
|
||||
): DeepSeekHarnessControlMessage | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
record.protocol !== DEEPSEEK_HARNESS_CONTROL_PROTOCOL ||
|
||||
record.version !== DEEPSEEK_HARNESS_CONTROL_VERSION
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (record.type === 'ready' && Object.keys(record).length === 3) {
|
||||
return record as DeepSeekHarnessControlMessage
|
||||
}
|
||||
if (
|
||||
record.type === 'fatal' &&
|
||||
Object.keys(record).length === 4 &&
|
||||
typeof record.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(record.code)
|
||||
) {
|
||||
return record as DeepSeekHarnessControlMessage
|
||||
}
|
||||
if (
|
||||
record.type === 'start' &&
|
||||
Object.keys(record).length === 4
|
||||
) {
|
||||
const parsed = controlledHarnessHostConfigSchema.safeParse(
|
||||
record.config
|
||||
)
|
||||
return parsed.success
|
||||
? ({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config: parsed.data
|
||||
} satisfies DeepSeekHarnessControlMessage)
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessFork = (
|
||||
modulePath: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
serviceName: string
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
}
|
||||
) => UtilityProcess
|
||||
|
||||
export type DeepSeekHarnessUtilityLauncherOptions = {
|
||||
bundledHostPath: string
|
||||
dshHome: string
|
||||
environment: NodeJS.ProcessEnv
|
||||
fork: DeepSeekHarnessFork
|
||||
terminateProcess?: (utility: UtilityProcess) => void
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
function expectedSandbox(): ControlledHarnessBootstrapConfig['sandbox'] {
|
||||
return process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' }
|
||||
: { provider: 'local-linux', enforcement: 'full' }
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (
|
||||
codePoint !== undefined &&
|
||||
(codePoint <= 0x1f || codePoint === 0x7f)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function createDeepSeekHarnessUtilityLauncher(
|
||||
launcherOptions: DeepSeekHarnessUtilityLauncherOptions
|
||||
): (options: DeepSeekHarnessLaunchOptions) => Promise<DeepSeekHarnessChild> {
|
||||
return async (options) => {
|
||||
options.signal.throwIfAborted()
|
||||
const hostPath = launcherOptions.bundledHostPath
|
||||
if (!isAbsolute(hostPath)) {
|
||||
throw new Error('DeepSeek Harness Host 路径必须为绝对路径')
|
||||
}
|
||||
if (!isAbsolute(options.cwd) || !isAbsolute(launcherOptions.dshHome)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 工作区和隔离目录必须为绝对路径'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.model.length === 0 ||
|
||||
options.model.length > 128 ||
|
||||
hasControlCharacter(options.model)
|
||||
) {
|
||||
throw new Error('DeepSeek Harness 模型名称无效')
|
||||
}
|
||||
const canonicalSkillPackages = await Promise.all(
|
||||
options.skillPackages.map(async (skill) => {
|
||||
const directory = await realpath(skill.directory)
|
||||
const metadata = await stat(directory)
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness Skill 路径必须为目录'
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: skill.id,
|
||||
directory
|
||||
}
|
||||
})
|
||||
)
|
||||
const [canonicalHostPath, canonicalWorkspace, canonicalDshHome] =
|
||||
await Promise.all([
|
||||
realpath(hostPath),
|
||||
realpath(options.cwd),
|
||||
realpath(launcherOptions.dshHome)
|
||||
])
|
||||
const [hostMetadata, workspaceMetadata, homeMetadata] =
|
||||
await Promise.all([
|
||||
stat(canonicalHostPath),
|
||||
stat(canonicalWorkspace),
|
||||
stat(canonicalDshHome)
|
||||
])
|
||||
if (
|
||||
!hostMetadata.isFile() ||
|
||||
!workspaceMetadata.isDirectory() ||
|
||||
!homeMetadata.isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness Host、工作区或隔离目录类型无效'
|
||||
)
|
||||
}
|
||||
const sandbox = expectedSandbox()
|
||||
if (
|
||||
options.requiredSandboxEnforcement === 'full' &&
|
||||
sandbox.enforcement !== 'full'
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 当前平台只能提供部分沙箱强制'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.baseUrl !== 'https://api.deepseek.com' &&
|
||||
options.baseUrl !== 'https://api.deepseek.com/'
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 仅允许 api.deepseek.com'
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.credentialRefs.length !== 1 ||
|
||||
options.credentialRefs[0] !==
|
||||
DEEPSEEK_HARNESS_CREDENTIAL_REF
|
||||
) {
|
||||
throw new Error('DeepSeek Harness 凭据引用不受信任')
|
||||
}
|
||||
options.signal.throwIfAborted()
|
||||
const utility = launcherOptions.fork(canonicalHostPath, [], {
|
||||
cwd: canonicalWorkspace,
|
||||
env: launcherOptions.environment,
|
||||
serviceName: 'GoodBuddy DeepSeek Harness Host',
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
let terminated = false
|
||||
const terminate = (): void => {
|
||||
if (terminated) {
|
||||
return
|
||||
}
|
||||
terminated = true
|
||||
if (launcherOptions.terminateProcess) {
|
||||
launcherOptions.terminateProcess(utility)
|
||||
} else {
|
||||
utility.kill()
|
||||
}
|
||||
}
|
||||
const startupTimeoutMs =
|
||||
launcherOptions.startupTimeoutMs ?? 10_000
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let onAbort: (() => void) | undefined
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (onAbort) {
|
||||
options.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
utility.removeListener('message', onMessage)
|
||||
utility.removeListener('exit', onExit)
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
cleanup()
|
||||
terminate()
|
||||
reject(error)
|
||||
}
|
||||
const onMessage = (message: unknown): void => {
|
||||
const control = parseHarnessControlMessage(message)
|
||||
if (!control) {
|
||||
fail(new Error('DeepSeek Harness Host 启动协议无效'))
|
||||
return
|
||||
}
|
||||
if (control.type === 'ready') {
|
||||
cleanup()
|
||||
resolve()
|
||||
} else if (control.type === 'fatal') {
|
||||
fail(
|
||||
new Error(
|
||||
`DeepSeek Harness Host 启动失败(${control.code})`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
const onExit = (exitCode: number): void => {
|
||||
fail(
|
||||
new Error(
|
||||
`DeepSeek Harness Host 启动前退出(code ${exitCode})`
|
||||
)
|
||||
)
|
||||
}
|
||||
onAbort = () => {
|
||||
fail(
|
||||
options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('DeepSeek Harness Host 启动已取消')
|
||||
)
|
||||
}
|
||||
utility.on('message', onMessage)
|
||||
utility.on('exit', onExit)
|
||||
options.signal.addEventListener('abort', onAbort, {
|
||||
once: true
|
||||
})
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
fail(new Error('DeepSeek Harness Host 启动握手超时')),
|
||||
startupTimeoutMs
|
||||
)
|
||||
const config = controlledHarnessHostConfigSchema.parse({
|
||||
workspace: canonicalWorkspace,
|
||||
dshHome: canonicalDshHome,
|
||||
baseUrl: options.baseUrl,
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: options.model,
|
||||
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
|
||||
sandbox,
|
||||
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
|
||||
skillPackages: canonicalSkillPackages,
|
||||
maxFrameBytes: 1024 * 1024
|
||||
})
|
||||
utility.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'start',
|
||||
config
|
||||
} satisfies DeepSeekHarnessControlMessage)
|
||||
})
|
||||
return createDeepSeekHarnessUtilityChild(utility, {
|
||||
stderrToWeb: (stderr) =>
|
||||
Readable.toWeb(stderr) as ReadableStream<Uint8Array>,
|
||||
terminateProcess: terminate
|
||||
})
|
||||
} catch (error) {
|
||||
terminate()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
DEEPSEEK_HARNESS_MAX_CHUNK_BYTES,
|
||||
createDeepSeekHarnessHostTransport,
|
||||
createDeepSeekHarnessUtilityChild,
|
||||
type DeepSeekHarnessParentPortLike
|
||||
} from './deepseek-harness-utility-transport'
|
||||
|
||||
type Listener = (value: unknown) => void
|
||||
|
||||
class LinkedPort {
|
||||
peer?: LinkedPort
|
||||
readonly sent: unknown[] = []
|
||||
private readonly listeners = new Set<Listener>()
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.sent.push(message)
|
||||
queueMicrotask(() => {
|
||||
for (const listener of this.peer?.listeners ?? []) {
|
||||
listener(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeUtility {
|
||||
readonly port = new LinkedPort()
|
||||
readonly stderr = 'node-stderr'
|
||||
readonly kill = vi.fn(() => true)
|
||||
private readonly listeners = {
|
||||
message: new Set<(message: unknown) => void>(),
|
||||
exit: new Set<(exitCode: number) => void>()
|
||||
}
|
||||
|
||||
constructor(hostPort: LinkedPort) {
|
||||
this.port.peer = hostPort
|
||||
hostPort.peer = this.port
|
||||
this.port.subscribe((message) => {
|
||||
for (const listener of this.listeners.message) {
|
||||
listener(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.port.postMessage(message)
|
||||
}
|
||||
|
||||
on(event: 'message', listener: (message: unknown) => void): void
|
||||
on(event: 'exit', listener: (exitCode: number) => void): void
|
||||
on(
|
||||
event: keyof typeof this.listeners,
|
||||
listener: ((message: unknown) => void) | ((exitCode: number) => void)
|
||||
): void {
|
||||
if (event === 'message') {
|
||||
this.listeners.message.add(listener as (message: unknown) => void)
|
||||
} else {
|
||||
this.listeners.exit.add(listener as (exitCode: number) => void)
|
||||
}
|
||||
}
|
||||
|
||||
removeListener(event: 'message', listener: (message: unknown) => void): void
|
||||
removeListener(event: 'exit', listener: (exitCode: number) => void): void
|
||||
removeListener(
|
||||
event: keyof typeof this.listeners,
|
||||
listener: ((message: unknown) => void) | ((exitCode: number) => void)
|
||||
): void {
|
||||
if (event === 'message') {
|
||||
this.listeners.message.delete(listener as (message: unknown) => void)
|
||||
} else {
|
||||
this.listeners.exit.delete(listener as (exitCode: number) => void)
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(message: unknown): void {
|
||||
for (const listener of this.listeners.message) {
|
||||
listener(message)
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(exitCode: number): void {
|
||||
for (const listener of this.listeners.exit) {
|
||||
listener(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asParentPort(port: LinkedPort): DeepSeekHarnessParentPortLike {
|
||||
const wrapped = new Map<Listener, () => void>()
|
||||
return {
|
||||
postMessage: (message) => port.postMessage(message),
|
||||
on: (_event, listener) => {
|
||||
const adapter: Listener = (data) => listener({ data })
|
||||
wrapped.set(listener as Listener, port.subscribe(adapter))
|
||||
},
|
||||
removeListener: (_event, listener) => {
|
||||
wrapped.get(listener as Listener)?.()
|
||||
wrapped.delete(listener as Listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const hostPort = new LinkedPort()
|
||||
const utility = new FakeUtility(hostPort)
|
||||
const stderr = new ReadableStream<Uint8Array>()
|
||||
const stderrToWeb = vi.fn(() => stderr)
|
||||
const child = createDeepSeekHarnessUtilityChild(utility, { stderrToWeb })
|
||||
const host = createDeepSeekHarnessHostTransport(asParentPort(hostPort))
|
||||
return { child, host, hostPort, utility, stderr, stderrToWeb }
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
||||
|
||||
describe('DeepSeek Harness utility byte transport', () => {
|
||||
it('ignores trusted control-plane messages that share the UtilityProcess port', async () => {
|
||||
const { child, hostPort, utility } = setup()
|
||||
await tick()
|
||||
utility.kill.mockClear()
|
||||
utility.emitMessage({
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready'
|
||||
})
|
||||
|
||||
const reader = child.stdout.getReader()
|
||||
const reading = reader.read()
|
||||
hostPort.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(7)
|
||||
})
|
||||
|
||||
await expect(reading).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(7)
|
||||
})
|
||||
expect(utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed for malformed control-plane lookalikes', async () => {
|
||||
const { child, utility } = setup()
|
||||
const reader = child.stdout.getReader()
|
||||
utility.emitMessage({
|
||||
protocol: 'goodbuddy.deepseek-harness.control',
|
||||
version: 1,
|
||||
type: 'ready',
|
||||
unexpected: true
|
||||
})
|
||||
|
||||
await expect(reader.read()).rejects.toThrow('PROTOCOL_VIOLATION')
|
||||
expect(utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('transports bytes in both directions and adapts stderr and exit', async () => {
|
||||
const { child, host, utility, stderr, stderrToWeb } = setup()
|
||||
const childWriter = child.stdin.getWriter()
|
||||
const hostInput = host.stdin.getReader()
|
||||
const hostWriter = host.stdout.getWriter()
|
||||
const childOutput = child.stdout.getReader()
|
||||
|
||||
await childWriter.write(Uint8Array.of(1, 2, 3))
|
||||
await expect(hostInput.read()).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(1, 2, 3)
|
||||
})
|
||||
await hostWriter.write(Uint8Array.of(4, 5))
|
||||
await expect(childOutput.read()).resolves.toEqual({
|
||||
done: false,
|
||||
value: Uint8Array.of(4, 5)
|
||||
})
|
||||
|
||||
expect(stderrToWeb).toHaveBeenCalledWith('node-stderr')
|
||||
expect(child.stderr).toBe(stderr)
|
||||
utility.emitExit(7)
|
||||
await expect(child.exited).resolves.toEqual({ exitCode: 7 })
|
||||
})
|
||||
|
||||
it('splits chunks at 64 KiB and waits for ACK backpressure', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
const bytes = new Uint8Array(DEEPSEEK_HARNESS_MAX_CHUNK_BYTES + 3)
|
||||
bytes.fill(9)
|
||||
|
||||
let settled = false
|
||||
const writing = writer.write(bytes).then(() => {
|
||||
settled = true
|
||||
})
|
||||
await tick()
|
||||
expect(settled).toBe(false)
|
||||
expect(utility.port.sent).toHaveLength(1)
|
||||
expect(utility.port.sent[0]).toMatchObject({
|
||||
type: 'data',
|
||||
seq: 0,
|
||||
bytes: expect.objectContaining({
|
||||
byteLength: DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
const reader = host.stdin.getReader()
|
||||
expect((await reader.read()).value).toHaveLength(
|
||||
DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
)
|
||||
await tick()
|
||||
expect(utility.port.sent).toHaveLength(2)
|
||||
expect(utility.port.sent[1]).toMatchObject({
|
||||
type: 'data',
|
||||
seq: 1,
|
||||
bytes: Uint8Array.of(9, 9, 9)
|
||||
})
|
||||
expect((await reader.read()).value).toEqual(Uint8Array.of(9, 9, 9))
|
||||
await writing
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('applies bounded receiver backpressure until the queued chunk is read', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
await writer.write(Uint8Array.of(1))
|
||||
|
||||
let secondSettled = false
|
||||
const second = writer.write(Uint8Array.of(2)).then(() => {
|
||||
secondSettled = true
|
||||
})
|
||||
await tick()
|
||||
expect(secondSettled).toBe(false)
|
||||
expect(utility.port.sent).toHaveLength(2)
|
||||
|
||||
const reader = host.stdin.getReader()
|
||||
await expect(reader.read()).resolves.toMatchObject({
|
||||
value: Uint8Array.of(1)
|
||||
})
|
||||
await tick()
|
||||
await second
|
||||
expect(secondSettled).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unknown message', { surprise: true }],
|
||||
[
|
||||
'unknown type',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'wat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'extra field',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'ack',
|
||||
stream: 'stdin',
|
||||
seq: 0,
|
||||
extra: true
|
||||
}
|
||||
],
|
||||
[
|
||||
'oversized chunk',
|
||||
{
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: new Uint8Array(DEEPSEEK_HARNESS_MAX_CHUNK_BYTES + 1)
|
||||
}
|
||||
]
|
||||
])('fails closed for %s without including payloads in errors', async (_, message) => {
|
||||
const { child, utility } = setup()
|
||||
const reader = child.stdout.getReader()
|
||||
utility.emitMessage(message)
|
||||
|
||||
await expect(reader.read()).rejects.toThrow(
|
||||
'DeepSeek Harness byte transport failed (PROTOCOL_VIOLATION)'
|
||||
)
|
||||
expect(utility.kill).toHaveBeenCalledTimes(1)
|
||||
expect(String(await reader.closed.catch((error) => error))).not.toContain(
|
||||
'surprise'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed for duplicate and out-of-order sequence numbers', async () => {
|
||||
const first = setup()
|
||||
first.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 1,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await expect(first.child.stdout.getReader().read()).rejects.toThrow(
|
||||
'PROTOCOL_VIOLATION'
|
||||
)
|
||||
expect(first.utility.kill).toHaveBeenCalledOnce()
|
||||
|
||||
const second = setup()
|
||||
const reader = second.child.stdout.getReader()
|
||||
second.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await reader.read()
|
||||
second.utility.emitMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
seq: 0,
|
||||
bytes: Uint8Array.of(1)
|
||||
})
|
||||
await expect(reader.read()).rejects.toThrow('PROTOCOL_VIOLATION')
|
||||
expect(second.utility.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('propagates close and cancellation idempotently', async () => {
|
||||
const { child, host, utility } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
const reader = host.stdin.getReader()
|
||||
const closing = writer.close()
|
||||
await expect(reader.read()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined
|
||||
})
|
||||
await closing
|
||||
|
||||
const childOutput = child.stdout.getReader()
|
||||
await childOutput.cancel()
|
||||
const hostWriter = host.stdout.getWriter()
|
||||
await expect(hostWriter.write(Uint8Array.of(8))).rejects.toThrow(
|
||||
'REMOTE_CANCELLED'
|
||||
)
|
||||
|
||||
child.terminate()
|
||||
child.terminate()
|
||||
expect(utility.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels a chunk waiting behind the bounded readable queue', async () => {
|
||||
const { child, host } = setup()
|
||||
const writer = child.stdin.getWriter()
|
||||
await writer.write(Uint8Array.of(1))
|
||||
const pendingWrite = writer.write(Uint8Array.of(2))
|
||||
await tick()
|
||||
|
||||
await host.stdin.cancel()
|
||||
await expect(pendingWrite).rejects.toThrow('REMOTE_CANCELLED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,678 @@
|
||||
import type { DeepSeekHarnessChild } from './deepseek-harness-runtime'
|
||||
|
||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL =
|
||||
'goodbuddy.deepseek-harness.byte-stream'
|
||||
export const DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION = 1
|
||||
export const DEEPSEEK_HARNESS_MAX_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
type StreamName = 'stdin' | 'stdout'
|
||||
type ForwardType = 'data' | 'close' | 'abort'
|
||||
|
||||
type MessageBase = {
|
||||
protocol: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION
|
||||
stream: StreamName
|
||||
seq: number
|
||||
}
|
||||
|
||||
type ProtocolMessage =
|
||||
| (MessageBase & {
|
||||
type: 'data'
|
||||
bytes: Uint8Array
|
||||
})
|
||||
| (MessageBase & { type: 'close' })
|
||||
| (MessageBase & { type: 'abort' })
|
||||
| (MessageBase & { type: 'ack' })
|
||||
| (MessageBase & { type: 'cancel' })
|
||||
| {
|
||||
protocol: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL
|
||||
version: typeof DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION
|
||||
type: 'fail'
|
||||
}
|
||||
|
||||
type Deferred = {
|
||||
readonly promise: Promise<void>
|
||||
resolve(): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
type PendingSend = {
|
||||
readonly seq: number
|
||||
readonly deferred: Deferred
|
||||
}
|
||||
|
||||
type SenderState = {
|
||||
readonly stream: StreamName
|
||||
nextSeq: number
|
||||
pending?: PendingSend
|
||||
finished: boolean
|
||||
cancelled: boolean
|
||||
controller?: WritableStreamDefaultController
|
||||
}
|
||||
|
||||
type ReceiverState = {
|
||||
readonly stream: StreamName
|
||||
nextSeq: number
|
||||
pendingBytes?: Uint8Array
|
||||
finished: boolean
|
||||
cancelled: boolean
|
||||
controller?: ReadableStreamDefaultController<Uint8Array>
|
||||
}
|
||||
|
||||
type MessagePortAdapter = {
|
||||
postMessage(message: ProtocolMessage): void
|
||||
subscribe(listener: (message: unknown) => void): () => void
|
||||
}
|
||||
|
||||
type EndpointOptions = {
|
||||
readonly senderStream: StreamName
|
||||
readonly receiverStream: StreamName
|
||||
readonly onFailure?: () => void
|
||||
}
|
||||
|
||||
const CONTROL_PROTOCOL = 'goodbuddy.deepseek-harness.control'
|
||||
const PROTOCOL_KEYS = ['protocol', 'version', 'type'] as const
|
||||
const STREAM_KEYS = [...PROTOCOL_KEYS, 'stream', 'seq'] as const
|
||||
const DATA_KEYS = [...STREAM_KEYS, 'bytes'] as const
|
||||
const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER
|
||||
|
||||
class ByteTransportError extends Error {
|
||||
constructor(code: string) {
|
||||
super(`DeepSeek Harness byte transport failed (${code})`)
|
||||
this.name = 'ByteTransportError'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolvePromise: (() => void) | undefined
|
||||
let rejectPromise: ((error: Error) => void) | undefined
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolvePromise?.(),
|
||||
reject: (error) => rejectPromise?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[]
|
||||
): boolean {
|
||||
const keys = Object.keys(value)
|
||||
return (
|
||||
keys.length === expected.length &&
|
||||
expected.every((key) => Object.prototype.hasOwnProperty.call(value, key))
|
||||
)
|
||||
}
|
||||
|
||||
function isSequence(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0 &&
|
||||
value <= MAX_SEQUENCE
|
||||
)
|
||||
}
|
||||
|
||||
function parseMessage(value: unknown): ProtocolMessage | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.protocol !== DEEPSEEK_HARNESS_BYTE_PROTOCOL ||
|
||||
value.version !== DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value.type === 'fail') {
|
||||
return hasExactKeys(value, PROTOCOL_KEYS)
|
||||
? (value as ProtocolMessage)
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (
|
||||
!['data', 'close', 'abort', 'ack', 'cancel'].includes(value.type) ||
|
||||
(value.stream !== 'stdin' && value.stream !== 'stdout') ||
|
||||
!isSequence(value.seq)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value.type === 'data') {
|
||||
if (
|
||||
!hasExactKeys(value, DATA_KEYS) ||
|
||||
!(value.bytes instanceof Uint8Array) ||
|
||||
value.bytes.byteLength === 0 ||
|
||||
value.bytes.byteLength > DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return value as ProtocolMessage
|
||||
}
|
||||
|
||||
return hasExactKeys(value, STREAM_KEYS)
|
||||
? (value as ProtocolMessage)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function isControlMessage(value: unknown): boolean {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.protocol !== CONTROL_PROTOCOL ||
|
||||
value.version !== 1 ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (value.type === 'ready') {
|
||||
return hasExactKeys(value, PROTOCOL_KEYS)
|
||||
}
|
||||
if (value.type === 'fatal') {
|
||||
return (
|
||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'code']) &&
|
||||
typeof value.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.code)
|
||||
)
|
||||
}
|
||||
return (
|
||||
value.type === 'start' &&
|
||||
hasExactKeys(value, [...PROTOCOL_KEYS, 'config']) &&
|
||||
isRecord(value.config)
|
||||
)
|
||||
}
|
||||
|
||||
class ByteTransportEndpoint {
|
||||
readonly writable: WritableStream<Uint8Array>
|
||||
readonly readable: ReadableStream<Uint8Array>
|
||||
|
||||
private readonly sender: SenderState
|
||||
private readonly receiver: ReceiverState
|
||||
private readonly unsubscribe: () => void
|
||||
private failed = false
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly port: MessagePortAdapter,
|
||||
private readonly options: EndpointOptions
|
||||
) {
|
||||
this.sender = {
|
||||
stream: options.senderStream,
|
||||
nextSeq: 0,
|
||||
finished: false,
|
||||
cancelled: false
|
||||
}
|
||||
this.receiver = {
|
||||
stream: options.receiverStream,
|
||||
nextSeq: 0,
|
||||
finished: false,
|
||||
cancelled: false
|
||||
}
|
||||
|
||||
this.writable = new WritableStream<Uint8Array>(
|
||||
{
|
||||
start: (controller) => {
|
||||
this.sender.controller = controller
|
||||
},
|
||||
write: async (chunk) => {
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
throw new ByteTransportError('INVALID_WRITE')
|
||||
}
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < chunk.byteLength;
|
||||
offset += DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
) {
|
||||
const bytes = chunk.slice(
|
||||
offset,
|
||||
offset + DEEPSEEK_HARNESS_MAX_CHUNK_BYTES
|
||||
)
|
||||
await this.sendForward('data', bytes)
|
||||
}
|
||||
},
|
||||
close: () => this.sendForward('close'),
|
||||
abort: () => this.sendForward('abort')
|
||||
},
|
||||
new CountQueuingStrategy({ highWaterMark: 1 })
|
||||
)
|
||||
|
||||
this.readable = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start: (controller) => {
|
||||
this.receiver.controller = controller
|
||||
},
|
||||
pull: () => {
|
||||
this.flushReceiver()
|
||||
},
|
||||
cancel: () => {
|
||||
this.cancelReceiver()
|
||||
}
|
||||
},
|
||||
new CountQueuingStrategy({ highWaterMark: 1 })
|
||||
)
|
||||
|
||||
this.unsubscribe = this.port.subscribe((message) => {
|
||||
if (isControlMessage(message)) {
|
||||
return
|
||||
}
|
||||
this.handleMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(code = 'CLOSED'): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.unsubscribe()
|
||||
const error = new ByteTransportError(code)
|
||||
this.sender.pending?.deferred.reject(error)
|
||||
this.sender.pending = undefined
|
||||
try {
|
||||
this.sender.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
try {
|
||||
this.receiver.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
}
|
||||
|
||||
private fail(code: string, notifyPeer: boolean): void {
|
||||
if (this.failed || this.disposed) {
|
||||
return
|
||||
}
|
||||
this.failed = true
|
||||
if (notifyPeer) {
|
||||
try {
|
||||
this.port.postMessage({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'fail'
|
||||
})
|
||||
} catch {
|
||||
// The local endpoint still closes if peer notification fails.
|
||||
}
|
||||
}
|
||||
this.dispose(code)
|
||||
this.options.onFailure?.()
|
||||
}
|
||||
|
||||
private post(message: ProtocolMessage): boolean {
|
||||
if (this.failed || this.disposed) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
this.port.postMessage(message)
|
||||
return true
|
||||
} catch {
|
||||
this.fail('CHANNEL_FAILURE', false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async sendForward(
|
||||
type: ForwardType,
|
||||
bytes?: Uint8Array
|
||||
): Promise<void> {
|
||||
if (
|
||||
this.failed ||
|
||||
this.disposed ||
|
||||
this.sender.finished ||
|
||||
this.sender.cancelled
|
||||
) {
|
||||
throw new ByteTransportError(
|
||||
this.sender.cancelled ? 'REMOTE_CANCELLED' : 'CLOSED'
|
||||
)
|
||||
}
|
||||
if (this.sender.pending || this.sender.nextSeq > MAX_SEQUENCE) {
|
||||
this.fail('LOCAL_STATE', true)
|
||||
throw new ByteTransportError('LOCAL_STATE')
|
||||
}
|
||||
|
||||
const waiting = deferred()
|
||||
const seq = this.sender.nextSeq
|
||||
this.sender.pending = { seq, deferred: waiting }
|
||||
const message: ProtocolMessage =
|
||||
type === 'data'
|
||||
? {
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type,
|
||||
stream: this.sender.stream,
|
||||
seq,
|
||||
bytes: bytes as Uint8Array
|
||||
}
|
||||
: {
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type,
|
||||
stream: this.sender.stream,
|
||||
seq
|
||||
}
|
||||
|
||||
if (!this.post(message)) {
|
||||
await waiting.promise
|
||||
return
|
||||
}
|
||||
await waiting.promise
|
||||
if (type !== 'data') {
|
||||
this.sender.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(rawMessage: unknown): void {
|
||||
const message = parseMessage(rawMessage)
|
||||
if (!message) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
if (message.type === 'fail') {
|
||||
this.fail('REMOTE_FAILURE', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'ack') {
|
||||
this.handleAck(message)
|
||||
return
|
||||
}
|
||||
if (message.type === 'cancel') {
|
||||
this.handleCancel(message)
|
||||
return
|
||||
}
|
||||
this.handleForward(message)
|
||||
}
|
||||
|
||||
private handleAck(
|
||||
message: MessageBase & { type: 'ack' }
|
||||
): void {
|
||||
const pending = this.sender.pending
|
||||
if (
|
||||
message.stream !== this.sender.stream ||
|
||||
!pending ||
|
||||
message.seq !== pending.seq
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.sender.pending = undefined
|
||||
this.sender.nextSeq += 1
|
||||
pending.deferred.resolve()
|
||||
}
|
||||
|
||||
private handleCancel(
|
||||
message: MessageBase & { type: 'cancel' }
|
||||
): void {
|
||||
const pending = this.sender.pending
|
||||
if (
|
||||
message.stream !== this.sender.stream ||
|
||||
this.sender.finished ||
|
||||
this.sender.cancelled ||
|
||||
message.seq !== (pending?.seq ?? this.sender.nextSeq)
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.sender.cancelled = true
|
||||
this.sender.pending = undefined
|
||||
const error = new ByteTransportError('REMOTE_CANCELLED')
|
||||
pending?.deferred.reject(error)
|
||||
try {
|
||||
this.sender.controller?.error(error)
|
||||
} catch {
|
||||
// The stream may already be closed.
|
||||
}
|
||||
}
|
||||
|
||||
private handleForward(
|
||||
message: Extract<ProtocolMessage, { type: ForwardType }>
|
||||
): void {
|
||||
if (
|
||||
message.stream !== this.receiver.stream ||
|
||||
this.receiver.finished ||
|
||||
this.receiver.cancelled ||
|
||||
message.seq !== this.receiver.nextSeq
|
||||
) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.receiver.nextSeq += 1
|
||||
|
||||
if (message.type === 'data') {
|
||||
if (this.receiver.pendingBytes) {
|
||||
this.fail('PROTOCOL_VIOLATION', true)
|
||||
return
|
||||
}
|
||||
this.receiver.pendingBytes = message.bytes.slice()
|
||||
this.flushReceiver()
|
||||
return
|
||||
}
|
||||
|
||||
this.receiver.finished = true
|
||||
if (message.type === 'close') {
|
||||
try {
|
||||
this.receiver.controller?.close()
|
||||
} catch {
|
||||
this.fail('LOCAL_STATE', true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
this.receiver.controller?.error(
|
||||
new ByteTransportError('REMOTE_ABORTED')
|
||||
)
|
||||
} catch {
|
||||
// The stream may already have been cancelled.
|
||||
}
|
||||
}
|
||||
this.sendAck(message.seq)
|
||||
}
|
||||
|
||||
private flushReceiver(): void {
|
||||
const controller = this.receiver.controller
|
||||
const bytes = this.receiver.pendingBytes
|
||||
if (
|
||||
!controller ||
|
||||
!bytes ||
|
||||
this.receiver.cancelled ||
|
||||
this.receiver.finished ||
|
||||
(controller.desiredSize ?? 0) <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.receiver.pendingBytes = undefined
|
||||
controller.enqueue(bytes)
|
||||
this.sendAck(this.receiver.nextSeq - 1)
|
||||
}
|
||||
|
||||
private sendAck(seq: number): void {
|
||||
this.post({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'ack',
|
||||
stream: this.receiver.stream,
|
||||
seq
|
||||
})
|
||||
}
|
||||
|
||||
private cancelReceiver(): void {
|
||||
if (
|
||||
this.receiver.cancelled ||
|
||||
this.receiver.finished ||
|
||||
this.failed ||
|
||||
this.disposed
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.receiver.cancelled = true
|
||||
const cancelSeq = this.receiver.pendingBytes
|
||||
? this.receiver.nextSeq - 1
|
||||
: this.receiver.nextSeq
|
||||
this.receiver.pendingBytes = undefined
|
||||
this.post({
|
||||
protocol: DEEPSEEK_HARNESS_BYTE_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
|
||||
type: 'cancel',
|
||||
stream: this.receiver.stream,
|
||||
seq: cancelSeq
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessUtilityProcessLike<Stderr = unknown> = {
|
||||
postMessage(message: unknown): void
|
||||
on(event: 'message', listener: (message: unknown) => void): unknown
|
||||
on(event: 'exit', listener: (exitCode: number) => void): unknown
|
||||
removeListener(
|
||||
event: 'message',
|
||||
listener: (message: unknown) => void
|
||||
): unknown
|
||||
removeListener(event: 'exit', listener: (exitCode: number) => void): unknown
|
||||
kill(): boolean
|
||||
readonly pid?: number
|
||||
readonly stderr?: Stderr | null
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessUtilityChildOptions<Stderr> = {
|
||||
stderrToWeb?: (stderr: Stderr) => ReadableStream<Uint8Array>
|
||||
terminateProcess?: (
|
||||
utilityProcess: DeepSeekHarnessUtilityProcessLike<Stderr>
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an Electron UtilityProcess without importing Electron at runtime.
|
||||
* Configure the utility process with piped stderr and inject Node's
|
||||
* Readable.toWeb when stderr capture is required.
|
||||
*/
|
||||
export function createDeepSeekHarnessUtilityChild<Stderr = unknown>(
|
||||
utilityProcess: DeepSeekHarnessUtilityProcessLike<Stderr>,
|
||||
options: DeepSeekHarnessUtilityChildOptions<Stderr> = {}
|
||||
): DeepSeekHarnessChild {
|
||||
let killed = false
|
||||
const killOnce = (): void => {
|
||||
if (killed) {
|
||||
return
|
||||
}
|
||||
killed = true
|
||||
if (options.terminateProcess) {
|
||||
options.terminateProcess(utilityProcess)
|
||||
} else {
|
||||
utilityProcess.kill()
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = new ByteTransportEndpoint(
|
||||
{
|
||||
postMessage: (message) => utilityProcess.postMessage(message),
|
||||
subscribe: (listener) => {
|
||||
const onMessage = (message: unknown): void => listener(message)
|
||||
utilityProcess.on('message', onMessage)
|
||||
return () => utilityProcess.removeListener('message', onMessage)
|
||||
}
|
||||
},
|
||||
{
|
||||
senderStream: 'stdin',
|
||||
receiverStream: 'stdout',
|
||||
onFailure: killOnce
|
||||
}
|
||||
)
|
||||
|
||||
let settleExit:
|
||||
| ((result: { exitCode: number | null; signal?: string | null }) => void)
|
||||
| undefined
|
||||
const exited = new Promise<{
|
||||
exitCode: number | null
|
||||
signal?: string | null
|
||||
}>((resolve) => {
|
||||
settleExit = resolve
|
||||
})
|
||||
let exitedSettled = false
|
||||
const onExit = (exitCode: number): void => {
|
||||
if (exitedSettled) {
|
||||
return
|
||||
}
|
||||
exitedSettled = true
|
||||
killed = true
|
||||
endpoint.dispose('PROCESS_EXITED')
|
||||
settleExit?.({ exitCode })
|
||||
}
|
||||
utilityProcess.on('exit', onExit)
|
||||
|
||||
const stderr =
|
||||
utilityProcess.stderr != null && options.stderrToWeb
|
||||
? options.stderrToWeb(utilityProcess.stderr)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
stdin: endpoint.writable,
|
||||
stdout: endpoint.readable,
|
||||
stderr,
|
||||
exited,
|
||||
terminate: () => {
|
||||
endpoint.dispose('TERMINATED')
|
||||
killOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ParentPortMessageEvent = {
|
||||
readonly data: unknown
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessParentPortLike = {
|
||||
postMessage(message: unknown): void
|
||||
on(
|
||||
event: 'message',
|
||||
listener: (event: ParentPortMessageEvent) => void
|
||||
): unknown
|
||||
removeListener(
|
||||
event: 'message',
|
||||
listener: (event: ParentPortMessageEvent) => void
|
||||
): unknown
|
||||
}
|
||||
|
||||
export type DeepSeekHarnessHostTransport = {
|
||||
readonly stdin: ReadableStream<Uint8Array>
|
||||
readonly stdout: WritableStream<Uint8Array>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Creates the host-side streams backed by process.parentPort-like messaging. */
|
||||
export function createDeepSeekHarnessHostTransport(
|
||||
parentPort: DeepSeekHarnessParentPortLike
|
||||
): DeepSeekHarnessHostTransport {
|
||||
const endpoint = new ByteTransportEndpoint(
|
||||
{
|
||||
postMessage: (message) => parentPort.postMessage(message),
|
||||
subscribe: (listener) => {
|
||||
const onMessage = (event: ParentPortMessageEvent): void =>
|
||||
listener(event.data)
|
||||
parentPort.on('message', onMessage)
|
||||
return () => parentPort.removeListener('message', onMessage)
|
||||
}
|
||||
},
|
||||
{
|
||||
senderStream: 'stdout',
|
||||
receiverStream: 'stdin'
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
stdin: endpoint.readable,
|
||||
stdout: endpoint.writable,
|
||||
dispose: () => endpoint.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import { resolve } from 'node:path'
|
||||
import {
|
||||
GOODBUDDY_HANDSHAKE,
|
||||
GOODBUDDY_PREPARE,
|
||||
GoodBuddyCredentialProvider,
|
||||
GoodBuddyHarnessControlPlane,
|
||||
GoodBuddySandboxRetryLedger,
|
||||
createBoundedAcpStream
|
||||
} from './goodbuddy-harness-control-plane'
|
||||
|
||||
function execution(
|
||||
callId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
) {
|
||||
return {
|
||||
callId,
|
||||
rootCallId: callId,
|
||||
name,
|
||||
arguments: args,
|
||||
signal: new AbortController().signal,
|
||||
token: Symbol('execution')
|
||||
} as never
|
||||
}
|
||||
|
||||
const sandboxDenied = {
|
||||
isError: false,
|
||||
value: {
|
||||
sandbox: {
|
||||
denied: true
|
||||
}
|
||||
},
|
||||
content: []
|
||||
} as const
|
||||
|
||||
function controlPlane() {
|
||||
return new GoodBuddyHarnessControlPlane({} as Context, {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
})
|
||||
}
|
||||
|
||||
function stubAgentContext() {
|
||||
const listeners = new Map<
|
||||
string,
|
||||
(...args: unknown[]) => unknown
|
||||
>()
|
||||
const extNotification = vi.fn(async () => undefined)
|
||||
const handle = {
|
||||
agent: {
|
||||
session: {
|
||||
id: 'session-output',
|
||||
header: { id: 'session-output' },
|
||||
events: []
|
||||
},
|
||||
cancel: vi.fn()
|
||||
}
|
||||
}
|
||||
const ctx = {
|
||||
on: vi.fn(
|
||||
(
|
||||
name: string,
|
||||
listener: (...args: unknown[]) => unknown
|
||||
) => {
|
||||
listeners.set(name, listener)
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
} as unknown as Context
|
||||
const subject = new GoodBuddyHarnessControlPlane(ctx, {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: [],
|
||||
maxEventCharacters: 10_000,
|
||||
maxRequestCharacters: 180
|
||||
})
|
||||
const internals = subject as unknown as {
|
||||
connection: {
|
||||
extNotification: typeof extNotification
|
||||
}
|
||||
sessions: Map<
|
||||
string,
|
||||
{
|
||||
handle: typeof handle
|
||||
inflight: {
|
||||
requestId: string
|
||||
messageId: string
|
||||
resolve: (reason: string) => void
|
||||
reject: (error: unknown) => void
|
||||
emittedCharacters: number
|
||||
eventTail: Promise<void>
|
||||
eventError?: unknown
|
||||
}
|
||||
}
|
||||
>
|
||||
observeSessions(): void
|
||||
}
|
||||
internals.connection = { extNotification }
|
||||
internals.sessions.set('session-output', {
|
||||
handle,
|
||||
inflight: {
|
||||
requestId: 'request-output',
|
||||
messageId: 'message-output',
|
||||
resolve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
emittedCharacters: 0,
|
||||
eventTail: Promise.resolve()
|
||||
}
|
||||
})
|
||||
internals.observeSessions()
|
||||
return { listeners, extNotification, handle, internals }
|
||||
}
|
||||
|
||||
describe('GoodBuddy Harness internal control plane', () => {
|
||||
it('requires a versioned handshake before privileged extensions', async () => {
|
||||
const subject = controlPlane()
|
||||
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_PREPARE, {
|
||||
sessionId: 'session',
|
||||
requestId: 'request',
|
||||
mode: 'execute'
|
||||
})
|
||||
).rejects.toThrow('GoodBuddy handshake is required')
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_HANDSHAKE, {
|
||||
controlProtocolVersion: 9
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'incompatible GoodBuddy Harness control protocol'
|
||||
)
|
||||
await expect(
|
||||
subject.extensionMethod(GOODBUDDY_HANDSHAKE, {
|
||||
controlProtocolVersion: 1
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
controlProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
sessionRelease: true,
|
||||
oneShotApproval: true,
|
||||
credentialResolution: true
|
||||
},
|
||||
sandbox: { enforcement: 'full' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps credentials memory-only, allowlisted, and read-only', async () => {
|
||||
const provider = new GoodBuddyCredentialProvider(
|
||||
new Context(),
|
||||
new Set(['GOODBUDDY_API_KEY'])
|
||||
)
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockResolvedValue('secret-from-main')
|
||||
provider.bind(resolver)
|
||||
|
||||
await expect(
|
||||
provider.resolve('GOODBUDDY_API_KEY' as never)
|
||||
).resolves.toEqual({
|
||||
value: 'secret-from-main',
|
||||
source: 'goodbuddy-main'
|
||||
})
|
||||
await expect(
|
||||
provider.resolve('OTHER_KEY' as never)
|
||||
).resolves.toBeUndefined()
|
||||
expect(resolver).toHaveBeenCalledTimes(1)
|
||||
await expect(
|
||||
provider.set('GOODBUDDY_API_KEY' as never, 'x')
|
||||
).rejects.toThrow('read-only')
|
||||
})
|
||||
|
||||
it('fails closed on oversized inbound and outbound ACP frames', async () => {
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const stream = createBoundedAcpStream(
|
||||
({
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as unknown as Stream),
|
||||
16
|
||||
)
|
||||
const inputWriter = inbound.writable.getWriter()
|
||||
const reader = stream.readable.getReader()
|
||||
const read = reader.read()
|
||||
await inputWriter.write({ value: 'too-long-for-frame' })
|
||||
await expect(read).rejects.toThrow('input frame exceeds')
|
||||
|
||||
const writer = stream.writable.getWriter()
|
||||
await expect(
|
||||
writer.write({ value: 'too-long-for-frame' } as never)
|
||||
).rejects.toThrow('output frame exceeds')
|
||||
})
|
||||
|
||||
it('counts the complete emitted envelope against the request limit', async () => {
|
||||
const { listeners, extNotification, handle, internals } =
|
||||
stubAgentContext()
|
||||
const sessionEvent = listeners.get('session/event')!
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
await internals.sessions.get('session-output')!.inflight.eventTail
|
||||
|
||||
expect(extNotification).toHaveBeenCalledTimes(1)
|
||||
expect(handle.agent.cancel).toHaveBeenCalledWith({
|
||||
kind: 'user'
|
||||
})
|
||||
expect(
|
||||
internals.sessions.get('session-output')!.inflight.eventError
|
||||
).toEqual(
|
||||
new Error(
|
||||
'GoodBuddy Harness control request output exceeds safety limit'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
internals.sessions.get('session-output')!.inflight.emittedCharacters
|
||||
).toBeGreaterThan(180)
|
||||
})
|
||||
|
||||
it('requires a matching real denial and consumes it once', () => {
|
||||
const ledger = new GoodBuddySandboxRetryLedger()
|
||||
const deniedArguments = {
|
||||
command: 'type C:\\outside\\file.txt',
|
||||
description: 'Read an outside file'
|
||||
}
|
||||
const retry = {
|
||||
...deniedArguments,
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
}
|
||||
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
|
||||
ledger.record(
|
||||
execution('denial-1', 'pwsh', deniedArguments),
|
||||
sandboxDenied as never
|
||||
)
|
||||
expect(
|
||||
ledger.consumeRetry('pwsh', {
|
||||
...retry,
|
||||
command: 'type C:\\different\\file.txt'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(ledger.consumeRetry('bash', retry)).toBe(false)
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(true)
|
||||
expect(ledger.consumeRetry('pwsh', retry)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-denials, narrow escalation, and reordered ambiguity', () => {
|
||||
const ledger = new GoodBuddySandboxRetryLedger()
|
||||
const deniedArguments = {
|
||||
description: 'Read an outside file',
|
||||
command: 'cat /outside/file'
|
||||
}
|
||||
ledger.record(execution('success', 'bash', deniedArguments), {
|
||||
isError: false,
|
||||
value: {},
|
||||
content: []
|
||||
} as never)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
ledger.record(
|
||||
execution('denial-2', 'bash', deniedArguments),
|
||||
sandboxDenied as never
|
||||
)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'Retry in workspace-write.'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
ledger.consumeRetry('bash', {
|
||||
command: 'cat /outside/file',
|
||||
description: 'Read an outside file',
|
||||
sandbox_permissions: 'danger-full-access',
|
||||
justification: 'The requested file is outside the workspace.'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildExplicitProfileRuntimeEnvironment,
|
||||
buildControlledHarnessEnvironment,
|
||||
buildRuntimeEnvironment
|
||||
} from './process-environment'
|
||||
|
||||
@@ -89,4 +90,30 @@ describe('buildRuntimeEnvironment', () => {
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a credential-free, telemetry-disabled Harness environment', () => {
|
||||
expect(
|
||||
buildControlledHarnessEnvironment('C:\\isolated-dsh', {
|
||||
PATH: 'C:\\Tools',
|
||||
TEMP: 'C:\\Temp',
|
||||
OPENAI_API_KEY: 'must-not-leak',
|
||||
DEEPSEEK_API_KEY: 'must-not-leak',
|
||||
DSH_HOME: 'C:\\user-dsh',
|
||||
NODE_OPTIONS: '--require malicious.js'
|
||||
})
|
||||
).toMatchObject({
|
||||
PATH: 'C:\\Tools',
|
||||
TEMP: 'C:\\Temp',
|
||||
DSH_HOME: 'C:\\isolated-dsh',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DO_NOT_TRACK: '1',
|
||||
OTEL_SDK_DISABLED: 'true'
|
||||
})
|
||||
expect(
|
||||
buildControlledHarnessEnvironment('C:\\isolated-dsh', {
|
||||
OPENAI_API_KEY: 'must-not-leak',
|
||||
DEEPSEEK_API_KEY: 'must-not-leak'
|
||||
})
|
||||
).not.toHaveProperty('OPENAI_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,3 +92,20 @@ export function buildExplicitProfileRuntimeEnvironment(
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
export function buildControlledHarnessEnvironment(
|
||||
dshHome: string,
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment = buildExplicitProfileRuntimeEnvironment(
|
||||
{
|
||||
DSH_HOME: dshHome,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
...runtimePrivacyEnvironment
|
||||
},
|
||||
undefined,
|
||||
source
|
||||
)
|
||||
delete environment.NODE_TLS_REJECT_UNAUTHORIZED
|
||||
return environment
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'configured'
|
||||
})
|
||||
expect(detection.version).toMatch(/^\d+\.\d+\.\d+/u)
|
||||
})
|
||||
@@ -69,7 +70,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'automatic'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +85,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'configured'
|
||||
})
|
||||
expect(detection.detail).not.toContain('内置')
|
||||
})
|
||||
@@ -101,7 +104,8 @@ describe('runtime discovery', () => {
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
path: await realpath(process.execPath),
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
})
|
||||
@@ -115,15 +119,57 @@ describe('runtime discovery', () => {
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '1.5.47',
|
||||
binaryNames: ['goodbuddy-runtime-that-does-not-exist'],
|
||||
label: 'Script Runtime'
|
||||
})
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript)
|
||||
path: await realpath(bundledScript),
|
||||
version: '1.5.47',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toBe(
|
||||
'内置 Script Runtime 1.5.47 已就绪'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a controlled bundled harness when no custom host is configured', async () => {
|
||||
const bundledScript = fileURLToPath(import.meta.url)
|
||||
const detection = await detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '0.1.0-rc.6',
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript),
|
||||
version: '0.1.0-rc.6',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
})
|
||||
|
||||
it('does not discover arbitrary DeepSeek Harness hosts from PATH', async () => {
|
||||
process.env.PATH = dirname(process.execPath)
|
||||
process.env.Path = dirname(process.execPath)
|
||||
|
||||
await expect(
|
||||
detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
allowAutomaticDiscovery: false,
|
||||
binaryNames: [basename(process.execPath)],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
available: false,
|
||||
detail: expect.stringContaining('未自动检测到')
|
||||
})
|
||||
expect(detection.detail).toBe('内置 Script Runtime 已就绪')
|
||||
})
|
||||
|
||||
it('returns both runtime detections without exposing PATH contents', async () => {
|
||||
@@ -144,6 +190,7 @@ describe('runtime discovery', () => {
|
||||
available: true,
|
||||
path: await realpath(process.execPath)
|
||||
})
|
||||
expect(result.deepseekHarness.available).toBe(false)
|
||||
expect(JSON.stringify(result)).not.toContain(privatePathValue)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export type RuntimeBinaryDiscoveryInput = {
|
||||
binaryPath: string
|
||||
bundledPath?: string
|
||||
bundledValidation?: 'execute' | 'canonical-file'
|
||||
bundledVersion?: string
|
||||
allowAutomaticDiscovery?: boolean
|
||||
binaryNames: readonly string[]
|
||||
label: string
|
||||
}
|
||||
@@ -246,13 +248,14 @@ function availableDetection(
|
||||
label: string,
|
||||
path: string,
|
||||
version?: string,
|
||||
bundled = false
|
||||
source: 'bundled' | 'configured' | 'automatic' = 'automatic'
|
||||
): RuntimeBinaryDetection {
|
||||
return {
|
||||
available: true,
|
||||
path,
|
||||
version,
|
||||
detail: `${bundled ? '内置 ' : ''}${label}${
|
||||
source,
|
||||
detail: `${source === 'bundled' ? '内置 ' : ''}${label}${
|
||||
version ? ` ${version}` : ''
|
||||
} 已就绪`
|
||||
}
|
||||
@@ -264,6 +267,36 @@ export async function detectRuntimeBinary(
|
||||
const configuredPath = input.binaryPath.trim()
|
||||
let configuredPathProblem: 'relative' | 'invalid' | 'validation' | undefined
|
||||
|
||||
const detectBundled = async (): Promise<
|
||||
RuntimeBinaryDetection | undefined
|
||||
> => {
|
||||
const bundledPath = input.bundledPath?.trim()
|
||||
if (!bundledPath) {
|
||||
return undefined
|
||||
}
|
||||
const canonicalPath = await canonicalFile(bundledPath)
|
||||
if (!canonicalPath) {
|
||||
return undefined
|
||||
}
|
||||
if (input.bundledValidation === 'canonical-file') {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
input.bundledVersion,
|
||||
'bundled'
|
||||
)
|
||||
}
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
return validation.valid
|
||||
? availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version,
|
||||
'bundled'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (configuredPath) {
|
||||
if (!isAbsolute(configuredPath)) {
|
||||
configuredPathProblem = 'relative'
|
||||
@@ -277,7 +310,8 @@ export async function detectRuntimeBinary(
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version
|
||||
validation.version,
|
||||
'configured'
|
||||
)
|
||||
}
|
||||
configuredPathProblem = 'validation'
|
||||
@@ -285,47 +319,31 @@ export async function detectRuntimeBinary(
|
||||
}
|
||||
}
|
||||
|
||||
const bundledPath = input.bundledPath?.trim()
|
||||
if (bundledPath) {
|
||||
const canonicalPath = await canonicalFile(bundledPath)
|
||||
if (canonicalPath) {
|
||||
if (input.bundledValidation === 'canonical-file') {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
const bundled = await detectBundled()
|
||||
if (bundled) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let foundAutomaticCandidate = false
|
||||
if (input.allowAutomaticDiscovery !== false) {
|
||||
for (const candidate of automaticCandidates(input.binaryNames)) {
|
||||
const canonicalPath = await canonicalFile(candidate)
|
||||
if (!canonicalPath) {
|
||||
continue
|
||||
}
|
||||
foundAutomaticCandidate = true
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
if (validation.valid) {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version,
|
||||
true
|
||||
'automatic'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let foundAutomaticCandidate = false
|
||||
for (const candidate of automaticCandidates(input.binaryNames)) {
|
||||
const canonicalPath = await canonicalFile(candidate)
|
||||
if (!canonicalPath) {
|
||||
continue
|
||||
}
|
||||
foundAutomaticCandidate = true
|
||||
const validation = await validateVersion(canonicalPath)
|
||||
if (validation.valid) {
|
||||
return availableDetection(
|
||||
input.label,
|
||||
canonicalPath,
|
||||
validation.version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let detail: string
|
||||
if (foundAutomaticCandidate || configuredPathProblem === 'validation') {
|
||||
detail = `${input.label} 候选未通过 --version 安全验证`
|
||||
@@ -349,9 +367,14 @@ export async function detectAgentRuntimes(input: {
|
||||
bundledPaths?: {
|
||||
opencode: string
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
bundledVersions?: {
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
}): Promise<AgentRuntimeDetection> {
|
||||
const [opencode, continueRuntime] = await Promise.all([
|
||||
const [opencode, continueRuntime, deepseekHarness] = await Promise.all([
|
||||
detectRuntimeBinary({
|
||||
binaryPath: input.opencodeBinaryPath,
|
||||
bundledPath: input.bundledPaths?.opencode,
|
||||
@@ -362,13 +385,24 @@ export async function detectAgentRuntimes(input: {
|
||||
binaryPath: input.continueBinaryPath,
|
||||
bundledPath: input.bundledPaths?.continue,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: input.bundledVersions?.continue,
|
||||
binaryNames: ['cn'],
|
||||
label: 'Continue CLI'
|
||||
}),
|
||||
detectRuntimeBinary({
|
||||
binaryPath: '',
|
||||
bundledPath: input.bundledPaths?.deepseekHarness,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: input.bundledVersions?.deepseekHarness,
|
||||
allowAutomaticDiscovery: false,
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
opencode,
|
||||
continue: continueRuntime
|
||||
continue: continueRuntime,
|
||||
deepseekHarness
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const defaultProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const secondProfileId = '00000000-0000-4000-8000-000000000002'
|
||||
const responsesProfileId = '00000000-0000-4000-8000-000000000003'
|
||||
const imageProfileId = '00000000-0000-4000-8000-000000000004'
|
||||
const deepseekProfileId = '00000000-0000-4000-8000-000000000005'
|
||||
|
||||
function settings(
|
||||
overrides: Partial<ResolvedRuntimeSettings> = {}
|
||||
@@ -62,6 +63,16 @@ function settings(
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: 'image-key'
|
||||
},
|
||||
{
|
||||
id: deepseekProfileId,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: 'deepseek-key'
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
@@ -154,13 +165,62 @@ describe('runtime selection', () => {
|
||||
).toThrow('自动启动')
|
||||
})
|
||||
|
||||
it('selects DeepSeek Harness only with an official compatible profile', () => {
|
||||
const selected = applyRuntimeSelection(settings(), {
|
||||
provider: 'deepseek-harness',
|
||||
profileId: deepseekProfileId
|
||||
})
|
||||
expect(selected.target).toBe('deepseek-harness')
|
||||
expect(selected.settings).toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: { id: deepseekProfileId }
|
||||
})
|
||||
expect(() =>
|
||||
applyRuntimeSelection(settings(), {
|
||||
provider: 'deepseek-harness',
|
||||
profileId: secondProfileId
|
||||
})
|
||||
).toThrow('api.deepseek.com')
|
||||
})
|
||||
|
||||
it('keeps the controlled platform DeepSeek profile when selected without a profile ID', () => {
|
||||
const base = settings()
|
||||
const platformProfile = {
|
||||
...base.modelProfiles[4]!,
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek',
|
||||
modelName: 'deepseek-v4-flash'
|
||||
}
|
||||
const selected = applyRuntimeSelection(
|
||||
settings({ deepseekHarnessModelProfile: platformProfile }),
|
||||
{ provider: 'deepseek-harness' }
|
||||
)
|
||||
|
||||
expect(selected.settings).toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
modelName: 'deepseek-v4-flash'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves Agent Runtime backends from the global Runtime configuration', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
opencodeModelProfile: base.modelProfiles[1],
|
||||
continueModelProfile: base.modelProfiles[2]
|
||||
continueModelProfile: base.modelProfiles[2],
|
||||
deepseekHarnessModelProfile: base.modelProfiles[4]
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'deepseek-harness',
|
||||
profileId: deepseekProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'opencode',
|
||||
@@ -189,6 +249,23 @@ describe('runtime selection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the controlled platform DeepSeek source profile-free across configured selection repair', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
deepseekHarnessModelProfile: {
|
||||
...base.modelProfiles[4]!,
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek'
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
).toEqual({ provider: 'deepseek-harness' })
|
||||
})
|
||||
|
||||
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
|
||||
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
|
||||
expect(
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { isAgentRuntimeModelProtocol } from '../../shared/contracts'
|
||||
import {
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type {
|
||||
ResolvedModelProfile,
|
||||
ResolvedRuntimeSettings
|
||||
} from '../runtime-settings-store'
|
||||
|
||||
export type SelectedRuntimeTarget = 'model' | 'opencode' | 'continue'
|
||||
export type SelectedRuntimeTarget =
|
||||
| 'model'
|
||||
| 'opencode'
|
||||
| 'continue'
|
||||
| 'deepseek-harness'
|
||||
|
||||
function requireProfile(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
@@ -26,6 +33,9 @@ export function getConfiguredRuntimeTarget(
|
||||
if (settings.provider === 'continue') {
|
||||
return 'continue'
|
||||
}
|
||||
if (settings.provider === 'deepseek-harness') {
|
||||
return 'deepseek-harness'
|
||||
}
|
||||
if (
|
||||
settings.provider === 'opencode' ||
|
||||
settings.provider === 'auto'
|
||||
@@ -41,17 +51,24 @@ export function resolveConfiguredAgentRuntimeSelection(
|
||||
): AgentRuntimeSelection {
|
||||
if (
|
||||
selection.provider !== 'opencode' &&
|
||||
selection.provider !== 'continue'
|
||||
selection.provider !== 'continue' &&
|
||||
selection.provider !== 'deepseek-harness'
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
const profile =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelProfile
|
||||
: settings.continueModelProfile
|
||||
: selection.provider === 'continue'
|
||||
? settings.continueModelProfile
|
||||
: settings.deepseekHarnessModelProfile
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(profile ? { profileId: profile.id } : {})
|
||||
...(profile && settings.modelProfiles.some(
|
||||
(candidate) => candidate.id === profile.id
|
||||
)
|
||||
? { profileId: profile.id }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +131,27 @@ export function applyRuntimeSelection(
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
const selectedProfile =
|
||||
profile ?? settings.deepseekHarnessModelProfile
|
||||
if (
|
||||
selectedProfile &&
|
||||
!isDeepSeekHarnessModelProfile(selectedProfile)
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 独立模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
||||
)
|
||||
}
|
||||
return {
|
||||
target: 'deepseek-harness',
|
||||
settings: {
|
||||
...settings,
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: selectedProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
profile &&
|
||||
!isAgentRuntimeModelProtocol(profile.protocol)
|
||||
|
||||
@@ -32,7 +32,7 @@ export type RuntimeModelUsageEvent = {
|
||||
requestId: string
|
||||
type: 'model-usage'
|
||||
callId: string
|
||||
runtime: 'model' | 'continue' | 'opencode'
|
||||
runtime: 'model' | 'continue' | 'opencode' | 'deepseek-harness'
|
||||
provider: string
|
||||
model: string
|
||||
inputTokens: number
|
||||
|
||||
@@ -63,6 +63,13 @@ describe('bundled skills', () => {
|
||||
expect(snapshot.skills.map((skill) => skill.id)).toContain(
|
||||
'product-marketing'
|
||||
)
|
||||
expect(snapshot.skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'web-3d-game',
|
||||
name: 'Web 3D Game',
|
||||
assignments: expect.arrayContaining(['deepseek-harness'])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('injects every enabled bundled skill with its resolved directory', async () => {
|
||||
@@ -76,4 +83,19 @@ describe('bundled skills', () => {
|
||||
expect(instructions).toContain(join(builtinSkillsRoot, skill.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the 3D game Skill as a native Harness package', async () => {
|
||||
const service = await createService()
|
||||
|
||||
await expect(
|
||||
service.getRuntimeSkillContext('deepseek-harness')
|
||||
).resolves.toMatchObject({
|
||||
packages: expect.arrayContaining([
|
||||
{
|
||||
id: 'web-3d-game',
|
||||
directory: join(builtinSkillsRoot, 'web-3d-game')
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -246,7 +246,12 @@ describe('CapabilityService', () => {
|
||||
id: 'document-writing',
|
||||
source: 'builtin',
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue']
|
||||
assignments: [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -625,9 +630,31 @@ describe('CapabilityService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects MCP assignments to Agent Runtimes', async () => {
|
||||
it('allows Harness MCP assignment and rejects unsupported Agent Runtimes', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Harness MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['deepseek-harness'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({
|
||||
assignments: ['deepseek-harness']
|
||||
})
|
||||
]
|
||||
})
|
||||
await expect(
|
||||
service.getResolvedMcpServers('deepseek-harness')
|
||||
).resolves.toHaveLength(1)
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Agent MCP',
|
||||
@@ -640,7 +667,7 @@ describe('CapabilityService', () => {
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).rejects.toThrow('只能分配给直连模型')
|
||||
).rejects.toThrow('只能分配给直连模型或 DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
||||
|
||||
@@ -269,7 +269,12 @@ function emptyStoredCapabilities(
|
||||
function defaultSkillState(): z.infer<typeof skillStateSchema> {
|
||||
return {
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue']
|
||||
assignments: [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1606,10 +1611,14 @@ export class CapabilityService {
|
||||
const value = mcpServerInputSchema.parse(input)
|
||||
if (
|
||||
value.assignments.some(
|
||||
(assignment) => assignment !== 'model'
|
||||
(assignment) =>
|
||||
assignment !== 'model' &&
|
||||
assignment !== 'deepseek-harness'
|
||||
)
|
||||
) {
|
||||
throw new Error('当前版本的 MCP Server 只能分配给直连模型')
|
||||
throw new Error(
|
||||
'当前版本的 MCP Server 只能分配给直连模型或 DeepSeek Harness'
|
||||
)
|
||||
}
|
||||
const state = await this.load()
|
||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||
@@ -1809,7 +1818,7 @@ export class CapabilityService {
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
if (target !== 'model') {
|
||||
if (target !== 'model' && target !== 'deepseek-harness') {
|
||||
return []
|
||||
}
|
||||
const state = await this.load()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
parseHarnessControlMessage,
|
||||
type DeepSeekHarnessControlMessage
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { createDeepSeekHarnessHostTransport } from './agent/deepseek-harness-utility-transport'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
ControlledHarnessHostStartupError,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost,
|
||||
type ControlledHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
|
||||
const parentPort = process.parentPort
|
||||
const restoreDiagnostics = installHarnessDiagnosticGuard()
|
||||
// The Windows ACL sandbox launches its JavaScript runner through
|
||||
// `process.execPath`. Inside an Electron UtilityProcess that path is Electron,
|
||||
// so descendants must opt into Electron's supported Node execution mode.
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ELECTRON_RUN_AS_NODE = '1'
|
||||
}
|
||||
let host: ControlledHarnessHost | undefined
|
||||
let transport:
|
||||
| ReturnType<typeof createDeepSeekHarnessHostTransport>
|
||||
| undefined
|
||||
let starting = false
|
||||
let closed = false
|
||||
|
||||
function post(message: DeepSeekHarnessControlMessage): void {
|
||||
if (!closed) {
|
||||
parentPort.postMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
await host?.dispose().catch(() => undefined)
|
||||
transport?.dispose()
|
||||
restoreDiagnostics()
|
||||
}
|
||||
|
||||
function fatal(code: string): void {
|
||||
post({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'fatal',
|
||||
code
|
||||
})
|
||||
void close().finally(() => {
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
parentPort.on('message', (event) => {
|
||||
const message = parseHarnessControlMessage(event.data)
|
||||
if (!message) {
|
||||
// Once the byte transport is installed, non-control messages belong to
|
||||
// that transport's listener on the shared UtilityProcess port.
|
||||
if (transport) {
|
||||
return
|
||||
}
|
||||
fatal('INVALID_START')
|
||||
return
|
||||
}
|
||||
if (message.type !== 'start') {
|
||||
fatal('INVALID_START')
|
||||
return
|
||||
}
|
||||
if (starting || host || closed) {
|
||||
fatal('DUPLICATE_START')
|
||||
return
|
||||
}
|
||||
starting = true
|
||||
transport = createDeepSeekHarnessHostTransport(parentPort)
|
||||
void startControlledDeepSeekHarnessHost({
|
||||
...message.config,
|
||||
stream: createBoundedNdJsonStream(
|
||||
transport.stdout,
|
||||
transport.stdin,
|
||||
message.config.maxFrameBytes
|
||||
)
|
||||
})
|
||||
.then((startedHost) => {
|
||||
host = startedHost
|
||||
starting = false
|
||||
post({
|
||||
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
version: DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
type: 'ready'
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
transport?.dispose()
|
||||
fatal(
|
||||
error instanceof ControlledHarnessHostStartupError
|
||||
? error.code
|
||||
: 'HOST_START_FAILED'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
process.once('disconnect', () => {
|
||||
void close()
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void close()
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createBoundedNdJsonStream,
|
||||
installHarnessDiagnosticGuard,
|
||||
startControlledDeepSeekHarnessHost
|
||||
} from './deepseek-harness-host'
|
||||
import { vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import type {
|
||||
Agent,
|
||||
CreateAgentOptions
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './agent/goodbuddy-harness-control-plane'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
|
||||
const expectedSandbox =
|
||||
process.platform === 'win32'
|
||||
? { provider: 'windows-acl', enforcement: 'partial' as const }
|
||||
: process.platform === 'darwin'
|
||||
? { provider: 'seatbelt', enforcement: 'full' as const }
|
||||
: { provider: 'local-linux', enforcement: 'full' as const }
|
||||
|
||||
async function readAllMessages(
|
||||
readable: ReadableStream<unknown>
|
||||
): Promise<unknown[]> {
|
||||
const values: unknown[] = []
|
||||
for await (const value of readable) {
|
||||
values.push(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
describe('controlled DeepSeek Harness host', () => {
|
||||
it('rejects unsupported endpoint protocols before Cordis starts', async () => {
|
||||
await expect(
|
||||
startControlledDeepSeekHarnessHost({
|
||||
workspace: 'C:\\workspace',
|
||||
baseUrl: 'file:///private/config',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: { provider: 'test', enforcement: 'full' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
dshHome: 'C:\\controlled-dsh-home',
|
||||
skillPackages: []
|
||||
})
|
||||
).rejects.toThrow('trusted HTTPS DeepSeek endpoint')
|
||||
})
|
||||
|
||||
it('suppresses console payloads instead of contaminating stdout', () => {
|
||||
const restore = installHarnessDiagnosticGuard()
|
||||
const originalWrite = process.stderr.write
|
||||
const writes: string[] = []
|
||||
process.stderr.write = ((value: string | Uint8Array) => {
|
||||
writes.push(String(value))
|
||||
return true
|
||||
}) as typeof process.stderr.write
|
||||
try {
|
||||
console.log('prompt and secret must not reach protocol stdout')
|
||||
expect(writes.join('')).toBe(
|
||||
'DeepSeek Harness diagnostic suppressed\n'
|
||||
)
|
||||
expect(writes.join('')).not.toContain('secret')
|
||||
} finally {
|
||||
process.stderr.write = originalWrite
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies the real local sandbox before advertising capabilities', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
|
||||
)
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: root,
|
||||
dshHome: root,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('canonicalizes workspace aliases before binding the host', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
|
||||
)
|
||||
const alias = join(root, '..', basename(root))
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: alias,
|
||||
dshHome: alias,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('loads only explicitly supplied Skill packages into a session scope', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
|
||||
)
|
||||
const skillDirectory = join(root, 'web-3d-game')
|
||||
await mkdir(skillDirectory)
|
||||
await writeFile(
|
||||
join(skillDirectory, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: web-3d-game',
|
||||
'description: Build a playable browser 3D game.',
|
||||
'---',
|
||||
'',
|
||||
'# Web 3D game',
|
||||
'',
|
||||
'Create and validate a playable project.'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
const inbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const outbound = new TransformStream<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>()
|
||||
const host = await startControlledDeepSeekHarnessHost({
|
||||
workspace: root,
|
||||
dshHome: root,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: expectedSandbox,
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [
|
||||
{ id: 'web-3d-game', directory: skillDirectory }
|
||||
],
|
||||
stream: {
|
||||
readable: inbound.readable,
|
||||
writable: outbound.writable
|
||||
} as never
|
||||
})
|
||||
let createdContext: typeof host.context | undefined
|
||||
let createdAgent: Agent | undefined
|
||||
const create = vi
|
||||
.spyOn(host.context.agents, 'create')
|
||||
.mockImplementation(async (options: CreateAgentOptions) => {
|
||||
const agentContext = host.context.extend({
|
||||
isolate: ['skills', 'tools']
|
||||
})
|
||||
createdContext = agentContext
|
||||
await options.setup?.(agentContext)
|
||||
const agent = {
|
||||
options: options.agentOptions ?? {},
|
||||
session: {
|
||||
id: options.sessionId,
|
||||
header: { cwd: options.meta?.cwd ?? root },
|
||||
events: [],
|
||||
append: vi.fn()
|
||||
},
|
||||
ctx: agentContext,
|
||||
cancel: vi.fn()
|
||||
}
|
||||
createdAgent = agent as never
|
||||
return {
|
||||
agent,
|
||||
dispose: async () => {
|
||||
await agentContext.fiber.dispose()
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
const api = (
|
||||
host.controlPlane as unknown as {
|
||||
createAgentApi(): {
|
||||
newSession(params: {
|
||||
cwd: string
|
||||
mcpServers: never[]
|
||||
}): Promise<{ sessionId: string }>
|
||||
}
|
||||
}
|
||||
).createAgentApi()
|
||||
const session = await api.newSession({
|
||||
cwd: root,
|
||||
mcpServers: []
|
||||
})
|
||||
|
||||
expect(session.sessionId).toBeTruthy()
|
||||
expect(
|
||||
(
|
||||
await createdContext!.skills.list({
|
||||
cwd: root,
|
||||
scope: createdAgent
|
||||
})
|
||||
).map((skill) => skill.name)
|
||||
).toEqual(['web-3d-game'])
|
||||
expect(
|
||||
createdContext!.tools
|
||||
.schemas(createdAgent)
|
||||
.map((tool) => tool.name)
|
||||
).toContain('skill')
|
||||
const loadedSkill = await createdContext!.tools.execute({
|
||||
callId: 'skill-call',
|
||||
name: 'skill',
|
||||
arguments: { name: 'web-3d-game' },
|
||||
agent: createdAgent,
|
||||
signal: new AbortController().signal
|
||||
} as never)
|
||||
expect(loadedSkill).toMatchObject({
|
||||
isError: false,
|
||||
value: {
|
||||
name: 'web-3d-game',
|
||||
content: expect.stringContaining(
|
||||
'Create and validate a playable project.'
|
||||
)
|
||||
}
|
||||
})
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentOptions: {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
maxTokens: GOODBUDDY_HARNESS_MAX_STEP_TOKENS
|
||||
}
|
||||
})
|
||||
)
|
||||
const assembly = await createdContext!.systemPrompt.assemble({
|
||||
agent: createdAgent,
|
||||
scope: createdAgent
|
||||
})
|
||||
expect(
|
||||
assembly.sections.find(
|
||||
(section) =>
|
||||
section.name === 'goodbuddy:controlled-execution'
|
||||
)?.text
|
||||
).toContain('create or update the requested workspace files promptly')
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('frames fragmented and coalesced ACP messages individually', async () => {
|
||||
const inbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const outbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const stream = createBoundedNdJsonStream(
|
||||
outbound.writable,
|
||||
inbound.readable,
|
||||
24
|
||||
)
|
||||
const reading = readAllMessages(stream.readable)
|
||||
const writer = inbound.writable.getWriter()
|
||||
const encoder = new TextEncoder()
|
||||
await writer.write(encoder.encode('{"text":"你'))
|
||||
await writer.write(
|
||||
encoder.encode('好"}\n{"value":"1234567890"}\n')
|
||||
)
|
||||
await writer.close()
|
||||
|
||||
await expect(reading).resolves.toEqual([
|
||||
{ text: '你好' },
|
||||
{ value: '1234567890' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects oversized ACP frames at EOF in both directions', async () => {
|
||||
const inbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const outbound = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const stream = createBoundedNdJsonStream(
|
||||
outbound.writable,
|
||||
inbound.readable,
|
||||
8
|
||||
)
|
||||
const reading = readAllMessages(stream.readable)
|
||||
const inputWriter = inbound.writable.getWriter()
|
||||
await inputWriter.write(
|
||||
new TextEncoder().encode('{"value":"too large"}')
|
||||
)
|
||||
await inputWriter.close()
|
||||
await expect(reading).rejects.toThrow('input frame exceeds')
|
||||
|
||||
const outputWriter = stream.writable.getWriter()
|
||||
await expect(
|
||||
outputWriter.write({ value: 'too large' } as never)
|
||||
).rejects.toThrow('output frame exceeds')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,580 @@
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SandboxedBash from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import SandboxedPwsh from '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import * as PiAiLlm from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import LocalSandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
|
||||
import {
|
||||
GoodBuddyCredentialProvider,
|
||||
GoodBuddyHarnessControlPlane,
|
||||
createBoundedAcpStream,
|
||||
type GoodBuddyHarnessControlConfig
|
||||
} from './agent/goodbuddy-harness-control-plane'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import type { SandboxEnforcement } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024
|
||||
const MAX_DIAGNOSTIC_BYTES = 64 * 1024
|
||||
|
||||
export type ControlledHarnessHostConfig = Omit<
|
||||
GoodBuddyHarnessControlConfig,
|
||||
'stream' | 'skills'
|
||||
> & {
|
||||
workspace: string
|
||||
baseUrl: string
|
||||
api: 'openai-completions'
|
||||
maxFrameBytes?: number
|
||||
stream?: Stream
|
||||
dshHome: string
|
||||
skillPackages: readonly {
|
||||
id: string
|
||||
directory: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ControlledHarnessHost = {
|
||||
readonly context: Context
|
||||
readonly controlPlane: GoodBuddyHarnessControlPlane
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export type ControlledHarnessHostStartupCode =
|
||||
| 'HOST_PLUGIN_GRAPH_FAILED'
|
||||
| 'HOST_SANDBOX_CONFIGURATION_FAILED'
|
||||
| 'HOST_SANDBOX_EXECUTION_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_ABORTED'
|
||||
| 'HOST_SANDBOX_PROBE_EXIT_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_RUNNER_FAILED'
|
||||
| 'HOST_SANDBOX_PROBE_TIMED_OUT'
|
||||
| 'HOST_CONTROL_PLANE_FAILED'
|
||||
|
||||
export class ControlledHarnessHostStartupError extends Error {
|
||||
constructor(
|
||||
readonly code: ControlledHarnessHostStartupCode,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(code, options)
|
||||
this.name = 'ControlledHarnessHostStartupError'
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySandboxExecution(
|
||||
ctx: Context,
|
||||
expected: GoodBuddyHarnessControlConfig['sandbox'],
|
||||
workspace: string
|
||||
): Promise<void> {
|
||||
const result = await ctx.shell.run(
|
||||
ctx.shell.resolve({
|
||||
command:
|
||||
process.platform === 'win32'
|
||||
? 'Write-Output goodbuddy-sandbox-probe'
|
||||
: 'printf goodbuddy-sandbox-probe',
|
||||
workdir: workspace,
|
||||
timeoutMs: 10_000,
|
||||
stdoutMaxBytes: 1_024,
|
||||
sandboxPolicy: {
|
||||
mode: 'read-only',
|
||||
workspaceRoot: workspace
|
||||
}
|
||||
})
|
||||
)
|
||||
if (
|
||||
result.sandbox?.enforcement !== expected.enforcement
|
||||
) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox execution probe failed'
|
||||
)
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_TIMED_OUT'
|
||||
)
|
||||
}
|
||||
if (result.aborted) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_ABORTED'
|
||||
)
|
||||
}
|
||||
if (result.sandbox?.runnerFailed) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_RUNNER_FAILED'
|
||||
)
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new ControlledHarnessHostStartupError(
|
||||
'HOST_SANDBOX_PROBE_EXIT_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type PluginSpec = {
|
||||
plugin: Parameters<Context['plugin']>[0]
|
||||
config?: unknown
|
||||
}
|
||||
|
||||
function validateHostConfig(
|
||||
config: ControlledHarnessHostConfig
|
||||
): void {
|
||||
const endpoint = URL.canParse(config.baseUrl)
|
||||
? new URL(config.baseUrl)
|
||||
: undefined
|
||||
if (
|
||||
config.api !== 'openai-completions' ||
|
||||
!endpoint ||
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.hostname.toLowerCase() !== 'api.deepseek.com' ||
|
||||
endpoint.username ||
|
||||
endpoint.password
|
||||
) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires the trusted HTTPS DeepSeek endpoint'
|
||||
)
|
||||
}
|
||||
if (!config.credentialRefs.length) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires a Main-side credential reference'
|
||||
)
|
||||
}
|
||||
if (!isAbsolute(config.workspace) || !isAbsolute(config.dshHome)) {
|
||||
throw new Error(
|
||||
'Controlled Harness requires absolute workspace and home paths'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalizeHostConfig(
|
||||
config: ControlledHarnessHostConfig
|
||||
): Promise<ControlledHarnessHostConfig> {
|
||||
const [workspace, dshHome] = await Promise.all([
|
||||
realpath(config.workspace),
|
||||
realpath(config.dshHome)
|
||||
])
|
||||
const [workspaceMetadata, homeMetadata] = await Promise.all([
|
||||
stat(workspace),
|
||||
stat(dshHome)
|
||||
])
|
||||
if (!workspaceMetadata.isDirectory() || !homeMetadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'Controlled Harness workspace and home must be directories'
|
||||
)
|
||||
}
|
||||
const skillPackages = await Promise.all(
|
||||
config.skillPackages.map(async (skill) => {
|
||||
const directory = await realpath(skill.directory)
|
||||
const metadata = await stat(directory)
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new Error(
|
||||
'Controlled Harness Skill path must be a directory'
|
||||
)
|
||||
}
|
||||
return { ...skill, directory }
|
||||
})
|
||||
)
|
||||
return { ...config, workspace, dshHome, skillPackages }
|
||||
}
|
||||
|
||||
async function loadControlledSkills(
|
||||
skillPackages: ControlledHarnessHostConfig['skillPackages']
|
||||
): Promise<GoodBuddyHarnessControlConfig['skills']> {
|
||||
return Promise.all(
|
||||
skillPackages.map(async (skill) => {
|
||||
const manifest = await readFile(
|
||||
join(skill.directory, 'SKILL.md'),
|
||||
'utf8'
|
||||
)
|
||||
if (Buffer.byteLength(manifest, 'utf8') > 2 * 1024 * 1024) {
|
||||
throw new Error('Controlled Harness Skill is too large')
|
||||
}
|
||||
const match =
|
||||
/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(
|
||||
manifest
|
||||
)
|
||||
if (!match?.[1] || !match[2]?.trim()) {
|
||||
throw new Error('Controlled Harness Skill manifest is invalid')
|
||||
}
|
||||
const metadata = parseYaml(match[1]) as Record<string, unknown>
|
||||
const name =
|
||||
typeof metadata.id === 'string'
|
||||
? metadata.id
|
||||
: metadata.name
|
||||
const description = metadata.description
|
||||
if (
|
||||
name !== skill.id ||
|
||||
typeof name !== 'string' ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) ||
|
||||
typeof description !== 'string'
|
||||
) {
|
||||
throw new Error('Controlled Harness Skill metadata is invalid')
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description: description
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, 500),
|
||||
content: match[2].trim(),
|
||||
directory: skill.directory
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function sandboxProviderName(): string {
|
||||
return process.platform === 'win32'
|
||||
? 'windows-acl'
|
||||
: process.platform === 'darwin'
|
||||
? 'seatbelt'
|
||||
: 'local-linux'
|
||||
}
|
||||
|
||||
function verifySandbox(
|
||||
sandbox: {
|
||||
confine(
|
||||
argv: readonly string[],
|
||||
policy: {
|
||||
mode: 'read-only'
|
||||
workspaceRoot: string
|
||||
}
|
||||
): {
|
||||
enforcement: SandboxEnforcement
|
||||
}
|
||||
},
|
||||
config: ControlledHarnessHostConfig
|
||||
): GoodBuddyHarnessControlConfig['sandbox'] {
|
||||
const expectedEnforcement: SandboxEnforcement =
|
||||
process.platform === 'win32' ? 'partial' : 'full'
|
||||
const probe = sandbox.confine(
|
||||
process.platform === 'win32'
|
||||
? ['cmd.exe', '/d', '/s', '/c', 'exit 0']
|
||||
: ['/usr/bin/env', 'true'],
|
||||
{
|
||||
mode: 'read-only',
|
||||
workspaceRoot: config.workspace
|
||||
}
|
||||
)
|
||||
if (probe.enforcement !== expectedEnforcement) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox enforcement probe returned an unexpected result'
|
||||
)
|
||||
}
|
||||
if (config.sandbox.enforcement !== probe.enforcement) {
|
||||
throw new Error(
|
||||
'Controlled Harness sandbox capability does not match the verified provider'
|
||||
)
|
||||
}
|
||||
return {
|
||||
provider: sandboxProviderName(),
|
||||
enforcement: probe.enforcement
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots a fixed, programmatic Cordis graph. It never imports app-boot, a
|
||||
* profile loader, settings-file, local credentials, persistence, telemetry,
|
||||
* web, HMR, marketplace/plugin discovery, direct MCP clients, jobs,
|
||||
* subagents, hooks, or workflow packages. The control plane registers only
|
||||
* Main-selected Skill snapshots and Main-mediated MCP tool proxies.
|
||||
*/
|
||||
export async function startControlledDeepSeekHarnessHost(
|
||||
input: ControlledHarnessHostConfig
|
||||
): Promise<ControlledHarnessHost> {
|
||||
validateHostConfig(input)
|
||||
const config = await canonicalizeHostConfig(input)
|
||||
const skills = await loadControlledSkills(config.skillPackages)
|
||||
process.env.DSH_TELEMETRY_DISABLED = '1'
|
||||
const ctx = new Context()
|
||||
const specs: PluginSpec[] = [
|
||||
{ plugin: LlmRuntime },
|
||||
{ plugin: SessionStore },
|
||||
{ plugin: SkillRegistry },
|
||||
{
|
||||
plugin: SystemPrompt,
|
||||
config: {
|
||||
persona: '',
|
||||
includeHarnessIdentity: false,
|
||||
includeRuntimeContext: true
|
||||
}
|
||||
},
|
||||
{ plugin: ToolRuntime, config: { mode: 'native' } },
|
||||
{ plugin: AgentRegistry },
|
||||
{
|
||||
plugin: GoodBuddyCredentialProvider,
|
||||
config: new Set(config.credentialRefs)
|
||||
},
|
||||
{
|
||||
plugin: PiAiLlm,
|
||||
config: {
|
||||
providers: {
|
||||
[config.provider]: {
|
||||
apiKeyEnv: config.credentialRefs[0],
|
||||
api: config.api,
|
||||
baseURL: config.baseUrl,
|
||||
models: [{ id: config.model, input: ['text'] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
plugin: SandboxPolicy,
|
||||
config: {
|
||||
mode: 'read-only',
|
||||
workspaceRoot: config.workspace
|
||||
}
|
||||
},
|
||||
{ plugin: ApprovalService, config: { policy: 'never' } },
|
||||
{ plugin: LocalSubprocess },
|
||||
{ plugin: LocalSandbox },
|
||||
{ plugin: SandboxedFileSystem, config: { cwd: config.workspace } },
|
||||
{ plugin: ShellEnv, config: { dshHome: config.dshHome } },
|
||||
{
|
||||
plugin:
|
||||
process.platform === 'win32'
|
||||
? SandboxedPwsh
|
||||
: SandboxedBash,
|
||||
config: { timeoutMs: 60_000 }
|
||||
},
|
||||
{ plugin: ToolFs },
|
||||
{
|
||||
plugin:
|
||||
process.platform === 'win32' ? ToolPwsh : ToolBash,
|
||||
config: { enableRunInBackground: false }
|
||||
},
|
||||
{ plugin: TokenMeter, config: {} },
|
||||
{
|
||||
plugin: AgentLoop,
|
||||
config: { agents: [], maxParallelToolCalls: 10 }
|
||||
}
|
||||
]
|
||||
const fibers: Fiber[] = []
|
||||
let startupCode: ControlledHarnessHostStartupCode =
|
||||
'HOST_PLUGIN_GRAPH_FAILED'
|
||||
try {
|
||||
for (const spec of specs) {
|
||||
fibers.push(
|
||||
ctx.plugin(
|
||||
spec.plugin,
|
||||
...(spec.config === undefined ? [] : [spec.config])
|
||||
)
|
||||
)
|
||||
}
|
||||
await Promise.all(fibers)
|
||||
const credentialProvider = ctx.credentials
|
||||
if (!(credentialProvider instanceof GoodBuddyCredentialProvider)) {
|
||||
throw new Error(
|
||||
'Controlled Harness credential provider failed to start'
|
||||
)
|
||||
}
|
||||
startupCode = 'HOST_SANDBOX_CONFIGURATION_FAILED'
|
||||
const verifiedSandbox = verifySandbox(ctx.sandbox, config)
|
||||
startupCode = 'HOST_SANDBOX_EXECUTION_FAILED'
|
||||
await verifySandboxExecution(
|
||||
ctx,
|
||||
verifiedSandbox,
|
||||
config.workspace
|
||||
)
|
||||
startupCode = 'HOST_CONTROL_PLANE_FAILED'
|
||||
const rawStream =
|
||||
config.stream ??
|
||||
createBoundedNdJsonStream(
|
||||
stdoutStream(),
|
||||
stdinStream(),
|
||||
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES
|
||||
)
|
||||
const controlPlane = new GoodBuddyHarnessControlPlane(ctx, {
|
||||
...config,
|
||||
skills,
|
||||
sandbox: verifiedSandbox,
|
||||
stream: createBoundedAcpStream(
|
||||
rawStream,
|
||||
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES
|
||||
)
|
||||
})
|
||||
controlPlane.bindCredentialProvider(credentialProvider)
|
||||
controlPlane.start()
|
||||
return {
|
||||
context: ctx,
|
||||
controlPlane,
|
||||
async dispose() {
|
||||
await controlPlane.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await ctx.fiber.dispose().catch(() => undefined)
|
||||
if (error instanceof ControlledHarnessHostStartupError) {
|
||||
throw error
|
||||
}
|
||||
throw new ControlledHarnessHostStartupError(startupCode, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createBoundedNdJsonStream(
|
||||
output: WritableStream<Uint8Array>,
|
||||
input: ReadableStream<Uint8Array>,
|
||||
maxFrameBytes: number
|
||||
): Stream {
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
const encoder = new TextEncoder()
|
||||
return {
|
||||
readable: new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = input.getReader()
|
||||
let pending = ''
|
||||
const emitCompleteFrames = (): void => {
|
||||
let newline = pending.indexOf('\n')
|
||||
while (newline >= 0) {
|
||||
const line = pending.slice(0, newline).trim()
|
||||
pending = pending.slice(newline + 1)
|
||||
if (
|
||||
line &&
|
||||
Buffer.byteLength(line, 'utf8') > maxFrameBytes
|
||||
) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
if (line) {
|
||||
controller.enqueue(JSON.parse(line))
|
||||
}
|
||||
newline = pending.indexOf('\n')
|
||||
}
|
||||
if (Buffer.byteLength(pending, 'utf8') > maxFrameBytes) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
pending += decoder.decode()
|
||||
emitCompleteFrames()
|
||||
break
|
||||
}
|
||||
pending += decoder.decode(value, { stream: true })
|
||||
emitCompleteFrames()
|
||||
}
|
||||
const line = pending.trim()
|
||||
if (line) {
|
||||
if (Buffer.byteLength(line, 'utf8') > maxFrameBytes) {
|
||||
throw new Error('ACP input frame exceeds safety limit')
|
||||
}
|
||||
controller.enqueue(JSON.parse(line))
|
||||
}
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
}),
|
||||
writable: new WritableStream({
|
||||
async write(message) {
|
||||
const serialized = JSON.stringify(message)
|
||||
if (
|
||||
Buffer.byteLength(serialized, 'utf8') >
|
||||
maxFrameBytes
|
||||
) {
|
||||
throw new Error('ACP output frame exceeds safety limit')
|
||||
}
|
||||
const bytes = encoder.encode(`${serialized}\n`)
|
||||
const writer = output.getWriter()
|
||||
try {
|
||||
await writer.write(bytes)
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
const writer = output.getWriter()
|
||||
try {
|
||||
await writer.close()
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
}
|
||||
},
|
||||
async abort(reason) {
|
||||
await output.abort(reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function stdoutStream(): WritableStream<Uint8Array> {
|
||||
return new WritableStream({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) =>
|
||||
error ? reject(error) : resolve()
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stdinStream(): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
process.stdin.on('data', (chunk: Buffer) =>
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
)
|
||||
process.stdin.once('end', () => controller.close())
|
||||
process.stdin.once('error', (error) =>
|
||||
controller.error(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep diagnostics bounded and protocol-free. Call this in the utility entry
|
||||
* before Cordis plugins start; no user content or secret is forwarded.
|
||||
*/
|
||||
export function installHarnessDiagnosticGuard(): () => void {
|
||||
let bytes = 0
|
||||
const original = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug
|
||||
}
|
||||
const diagnostic = (): void => {
|
||||
const line = 'DeepSeek Harness diagnostic suppressed\n'
|
||||
const size = Buffer.byteLength(line)
|
||||
if (bytes + size <= MAX_DIAGNOSTIC_BYTES) {
|
||||
bytes += size
|
||||
process.stderr.write(line)
|
||||
}
|
||||
}
|
||||
console.log = diagnostic
|
||||
console.info = diagnostic
|
||||
console.warn = diagnostic
|
||||
console.error = diagnostic
|
||||
console.debug = diagnostic
|
||||
return () => {
|
||||
console.log = original.log
|
||||
console.info = original.info
|
||||
console.warn = original.warn
|
||||
console.error = original.error
|
||||
console.debug = original.debug
|
||||
}
|
||||
}
|
||||
+58
-2
@@ -10,8 +10,10 @@ import {
|
||||
utilityProcess
|
||||
} from 'electron'
|
||||
import { homedir } from 'node:os'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import {
|
||||
createAgentRuntime,
|
||||
@@ -75,6 +77,11 @@ import { DocumentOcrBroker } from './document-ocr-broker'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
import { ReleaseNotesService } from './release-notes-service'
|
||||
import { GoodBuddyConfigService } from './goodbuddy-config-service'
|
||||
import {
|
||||
createDeepSeekHarnessUtilityLauncher,
|
||||
type DeepSeekHarnessFork
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||
|
||||
const shortcut = 'CommandOrControl+Shift+Space'
|
||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -209,6 +216,36 @@ const launchContinueHost: ContinueHostLauncher = (
|
||||
return child
|
||||
}
|
||||
|
||||
const forkDeepSeekHarness: DeepSeekHarnessFork = (
|
||||
modulePath,
|
||||
args,
|
||||
options
|
||||
) =>
|
||||
utilityProcess.fork(modulePath, args, {
|
||||
...options,
|
||||
allowLoadingUnsignedLibraries: false,
|
||||
disclaim: false
|
||||
})
|
||||
|
||||
function terminateHarnessUtilityProcess(
|
||||
child: ReturnType<DeepSeekHarnessFork>
|
||||
): void {
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
const killer = spawn(
|
||||
'taskkill.exe',
|
||||
['/PID', String(child.pid), '/T', '/F'],
|
||||
{
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
killer.unref()
|
||||
return
|
||||
}
|
||||
child.kill()
|
||||
}
|
||||
|
||||
const launchWechatSidecar: WechatSidecarLauncher = () => {
|
||||
const utilityChild = utilityProcess.fork(
|
||||
join(mainModuleDirectory, 'wechat-sidecar.js'),
|
||||
@@ -403,6 +440,24 @@ if (hasSingleInstanceLock) {
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged
|
||||
})
|
||||
const deepSeekHarnessHome = join(
|
||||
app.getPath('userData'),
|
||||
'deepseek-harness'
|
||||
)
|
||||
await mkdir(deepSeekHarnessHome, {
|
||||
recursive: true,
|
||||
mode: 0o700
|
||||
})
|
||||
const launchDeepSeekHarness =
|
||||
createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||
dshHome: deepSeekHarnessHome,
|
||||
environment: buildControlledHarnessEnvironment(
|
||||
deepSeekHarnessHome
|
||||
),
|
||||
fork: forkDeepSeekHarness,
|
||||
terminateProcess: terminateHarnessUtilityProcess
|
||||
})
|
||||
knowledgeService = new KnowledgeService({
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||
@@ -465,8 +520,8 @@ if (hasSingleInstanceLock) {
|
||||
] =
|
||||
await Promise.all([
|
||||
capabilityService.getRuntimeSkillContext(target),
|
||||
target === 'model'
|
||||
? capabilityService.getResolvedMcpServers('model')
|
||||
target === 'model' || target === 'deepseek-harness'
|
||||
? capabilityService.getResolvedMcpServers(target)
|
||||
: Promise.resolve([]),
|
||||
target === 'model'
|
||||
? capabilityService.getComputerCapabilityStatus(
|
||||
@@ -487,6 +542,7 @@ if (hasSingleInstanceLock) {
|
||||
),
|
||||
bundledRuntimePaths,
|
||||
continueHostLauncher: launchContinueHost,
|
||||
deepseekHarnessLauncher: launchDeepSeekHarness,
|
||||
browserService:
|
||||
browserCapability?.enabled && browserCapability.supported
|
||||
? browserService
|
||||
|
||||
+74
-1
@@ -2935,6 +2935,78 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps Ask fail-closed and auto-allows DeepSeek Harness Execute tools', async () => {
|
||||
const receivedAuthorizers: unknown[] = []
|
||||
const executeDecisions: string[] = []
|
||||
const runtime = {
|
||||
runtimeId: 'deepseek-harness',
|
||||
capability: 'chat',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
getStatus: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
async *run(
|
||||
request: { requestId: string; workMode?: string },
|
||||
_signal: AbortSignal,
|
||||
authorize?: (request: {
|
||||
scopeKey: string
|
||||
title: string
|
||||
description: string
|
||||
}) => Promise<string>
|
||||
) {
|
||||
receivedAuthorizers.push(authorize)
|
||||
if (request.workMode === 'execute') {
|
||||
executeDecisions.push(
|
||||
(await authorize?.({
|
||||
scopeKey: 'deepseek-harness:write_file',
|
||||
title: '写入文件',
|
||||
description: '一次性沙箱升级'
|
||||
})) ?? 'missing'
|
||||
)
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime)
|
||||
harness.approvalBroker.request.mockResolvedValue('once')
|
||||
|
||||
for (const [index, workMode] of (
|
||||
['ask', 'execute'] as const
|
||||
).entries()) {
|
||||
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
|
||||
harness.handler?.(trustedEvent(harness.webContents), {
|
||||
requestId,
|
||||
conversationId: `conversation-${index}`,
|
||||
prompt: 'run the task',
|
||||
workMode
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'completed')
|
||||
)
|
||||
}
|
||||
|
||||
expect(receivedAuthorizers).toEqual([
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
])
|
||||
expect(executeDecisions).toEqual(['once'])
|
||||
await expect(
|
||||
(
|
||||
receivedAuthorizers[0] as (
|
||||
request: Record<string, string>
|
||||
) => Promise<string>
|
||||
)({
|
||||
scopeKey: 'deepseek-harness:write_file',
|
||||
title: '写入文件',
|
||||
description: 'must be denied'
|
||||
})
|
||||
).resolves.toBe('deny')
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['model', 'opencode'] as const)(
|
||||
'normalizes legacy interactive Plan requests to Ask for %s',
|
||||
async (runtimeId) => {
|
||||
@@ -3936,7 +4008,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
harness.getResolvedSettings.mockResolvedValue({
|
||||
toolApproval: 'always',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
continueModelProfile: { id: configuredProfileId }
|
||||
continueModelProfile: { id: configuredProfileId },
|
||||
modelProfiles: [{ id: configuredProfileId }]
|
||||
})
|
||||
vi.mocked(
|
||||
harness.assistantDatabase.listProjects
|
||||
|
||||
+31
-6
@@ -163,7 +163,11 @@ import {
|
||||
import { resolveConfiguredAgentRuntimeSelection } from './agent/runtime-selection'
|
||||
import { safeToolErrorDetail } from './agent/approval-summary'
|
||||
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
|
||||
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
|
||||
import {
|
||||
bundledContinueVersion,
|
||||
bundledDeepSeekHarnessVersion,
|
||||
type BundledRuntimePaths
|
||||
} from './agent/bundled-runtimes'
|
||||
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
||||
import {
|
||||
type MagicNotesCapabilityAccess,
|
||||
@@ -1258,6 +1262,8 @@ export function registerIpcHandlers(
|
||||
!agentRuntimeSelected
|
||||
? (await settingsStore.getPolicySettings()).toolApproval
|
||||
: undefined
|
||||
const automaticHarnessRuntime =
|
||||
requestRuntime.runtimeId === 'deepseek-harness'
|
||||
const authorize: RuntimeAuthorizer = async (approvalRequest) => {
|
||||
controller.signal.throwIfAborted()
|
||||
if (schedule.workMode !== 'execute') {
|
||||
@@ -1266,6 +1272,9 @@ export function registerIpcHandlers(
|
||||
if (origin === 'delegation') {
|
||||
return 'deny'
|
||||
}
|
||||
if (automaticHarnessRuntime) {
|
||||
return 'once'
|
||||
}
|
||||
if (origin === 'channel') {
|
||||
return channelToolPolicy === 'policy' ? 'deny' : 'once'
|
||||
}
|
||||
@@ -2456,16 +2465,26 @@ export function registerIpcHandlers(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const automaticHarnessRuntime =
|
||||
selectedRuntime.runtimeId === 'deepseek-harness'
|
||||
const executeToolPolicy =
|
||||
request.workMode === 'execute' && !agentRuntimeSelected
|
||||
? (await settingsStore.getPolicySettings()).toolApproval
|
||||
: 'policy'
|
||||
const authorize: RuntimeAuthorizer = async () => {
|
||||
controller.signal.throwIfAborted()
|
||||
return request.workMode === 'execute' &&
|
||||
if (
|
||||
request.workMode !== 'execute'
|
||||
) {
|
||||
return 'deny'
|
||||
}
|
||||
if (
|
||||
automaticHarnessRuntime ||
|
||||
executeToolPolicy !== 'policy'
|
||||
? 'once'
|
||||
: 'deny'
|
||||
) {
|
||||
return 'once'
|
||||
}
|
||||
return 'deny'
|
||||
}
|
||||
let smartRoute:
|
||||
| ReturnType<typeof routeSubagent>
|
||||
@@ -2771,7 +2790,11 @@ export function registerIpcHandlers(
|
||||
return detectAgentRuntimes({
|
||||
opencodeBinaryPath: settings.opencodeBinaryPath,
|
||||
continueBinaryPath: settings.continueBinaryPath,
|
||||
bundledPaths: bundledRuntimePaths
|
||||
bundledPaths: bundledRuntimePaths,
|
||||
bundledVersions: {
|
||||
continue: bundledContinueVersion,
|
||||
deepseekHarness: bundledDeepSeekHarnessVersion
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -2781,7 +2804,9 @@ export function registerIpcHandlers(
|
||||
async (event, input: unknown): Promise<string | undefined> => {
|
||||
assertTrustedSender(event, window)
|
||||
const kind = runtimeFileSelectionKindSchema.parse(input)
|
||||
const binary = kind.endsWith('Binary')
|
||||
const binary =
|
||||
kind === 'opencodeBinary' ||
|
||||
kind === 'continueBinary'
|
||||
const configRuntime =
|
||||
kind === 'opencodeConfig'
|
||||
? 'opencode'
|
||||
|
||||
@@ -42,6 +42,7 @@ function settings(
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
runtimeSandboxMode: 'auto',
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
@@ -103,6 +104,199 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates DeepSeek Harness to controlled platform mode and stores an official profile', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const versionFourteen = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
versionFourteen.version = 14
|
||||
delete versionFourteen.deepseekHarnessModelSource
|
||||
delete versionFourteen.deepseekHarnessBinaryPath
|
||||
await writeFile(filePath, JSON.stringify(versionFourteen), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
deepseekHarnessModelProfile: undefined
|
||||
})
|
||||
|
||||
const profileId = '00000000-0000-4000-8000-000000000044'
|
||||
await migrated.update(
|
||||
settings({
|
||||
provider: 'deepseek-harness',
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'replace', value: 'deepseek-secret' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
)
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: profileId,
|
||||
apiKey: 'deepseek-secret'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a controlled platform DeepSeek profile without exposing its credential', async () => {
|
||||
const apiKey = 'platform-deepseek-secret'
|
||||
const { store } = await createStore({
|
||||
GOODBUDDY_MODEL_API_KEY: apiKey,
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com/',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-v4-flash'
|
||||
})
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProtocol: 'anthropic-messages',
|
||||
deepseekHarnessModelProfile: {
|
||||
id: 'goodbuddy-platform-deepseek',
|
||||
name: '平台 DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/',
|
||||
modelName: 'deepseek-v4-flash',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
|
||||
const publicSettings = await store.getPublicSettings()
|
||||
expect(JSON.stringify(publicSettings)).not.toContain(apiKey)
|
||||
expect(publicSettings.modelProtocol).toBe('anthropic-messages')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a non-DeepSeek endpoint',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://deepseek.example',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'an insecure DeepSeek endpoint',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'http://api.deepseek.com',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a DeepSeek endpoint path',
|
||||
{
|
||||
GOODBUDDY_MODEL_API_KEY: 'platform-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a missing API key',
|
||||
{
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://api.deepseek.com',
|
||||
GOODBUDDY_MODEL_NAME: 'deepseek-chat'
|
||||
}
|
||||
]
|
||||
])('does not resolve platform DeepSeek from %s', async (_, environment) => {
|
||||
const { store } = await createStore(environment)
|
||||
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
deepseekHarnessModelProfile: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the legacy custom Harness Host path and ignores its environment override', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const versionFifteen = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
versionFifteen.version = 15
|
||||
versionFifteen.deepseekHarnessBinaryPath =
|
||||
'C:\\untrusted\\custom-harness.js'
|
||||
await writeFile(filePath, JSON.stringify(versionFifteen), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_DEEPSEEK_HARNESS_BINARY:
|
||||
'C:\\environment\\custom-harness.js'
|
||||
})
|
||||
const publicSettings = await migrated.getPublicSettings()
|
||||
const resolvedSettings = await migrated.getResolvedSettings()
|
||||
expect(publicSettings).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
expect(publicSettings.configured).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
expect(resolvedSettings).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
await migrated.update(settings())
|
||||
const persisted = JSON.parse(
|
||||
await readFile(filePath, 'utf8')
|
||||
) as Record<string, unknown>
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted).not.toHaveProperty(
|
||||
'deepseekHarnessBinaryPath'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incompatible DeepSeek Harness model profiles', () => {
|
||||
const profileId = '00000000-0000-4000-8000-000000000045'
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'Other compatible API',
|
||||
baseUrl: 'https://other.example/v1',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: profileId,
|
||||
name: 'DeepSeek without API key',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'clear' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: profileId,
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId }
|
||||
})
|
||||
).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('always enables bundled OpenCode when the Server address is blank', async () => {
|
||||
const { filePath, store } = await createStore({
|
||||
GOODBUDDY_OPENCODE_EMBEDDED: 'false'
|
||||
@@ -331,7 +525,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
})
|
||||
|
||||
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
|
||||
@@ -351,7 +545,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
intranetCompatibilityEnabled?: boolean
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
|
||||
})
|
||||
|
||||
@@ -940,7 +1134,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted.modelProfiles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: imageId,
|
||||
@@ -1186,7 +1380,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
unknown
|
||||
>
|
||||
expect(saved).toMatchObject({
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: 'model',
|
||||
continueBinaryPath: '',
|
||||
continueMode: 'chat',
|
||||
@@ -1465,7 +1659,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
version: number
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(persisted.version).toBe(14)
|
||||
expect(persisted.version).toBe(16)
|
||||
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defaultRuntimeSettings,
|
||||
imageGenerationQualitySchema,
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile,
|
||||
modelAuthenticationSchema,
|
||||
modelProtocolSchema,
|
||||
runtimeModelSourceSchema,
|
||||
@@ -164,7 +165,7 @@ const version13StoredSettingsSchema = version12StoredSettingsSchema
|
||||
.max(20)
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version13StoredSettingsSchema
|
||||
const version14StoredSettingsSchema = version13StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(14),
|
||||
@@ -185,7 +186,24 @@ const storedSettingsSchema = version13StoredSettingsSchema
|
||||
knowledgeRerankCredential: credentialSchema
|
||||
})
|
||||
|
||||
const version15StoredSettingsSchema = version14StoredSettingsSchema
|
||||
.omit({ version: true })
|
||||
.extend({
|
||||
version: z.literal(15),
|
||||
deepseekHarnessModelSource: runtimeModelSourceSchema,
|
||||
deepseekHarnessBinaryPath: runtimePathSchema.default('')
|
||||
})
|
||||
|
||||
const storedSettingsSchema = version15StoredSettingsSchema
|
||||
.omit({ version: true, deepseekHarnessBinaryPath: true })
|
||||
.extend({
|
||||
version: z.literal(16)
|
||||
})
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type Version15StoredSettings = z.infer<
|
||||
typeof version15StoredSettingsSchema
|
||||
>
|
||||
type Version10StoredSettings = z.infer<
|
||||
typeof version10StoredSettingsSchema
|
||||
>
|
||||
@@ -198,6 +216,9 @@ type Version12StoredSettings = z.infer<
|
||||
type Version13StoredSettings = z.infer<
|
||||
typeof version13StoredSettingsSchema
|
||||
>
|
||||
type Version14StoredSettings = z.infer<
|
||||
typeof version14StoredSettingsSchema
|
||||
>
|
||||
|
||||
const version3StoredSettingsSchema = version4StoredSettingsSchema
|
||||
.omit({ version: true, continueMode: true })
|
||||
@@ -242,6 +263,7 @@ const embeddingCredentialPayloadSchema = z.object({
|
||||
})
|
||||
|
||||
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
|
||||
const platformDeepSeekProfileId = 'goodbuddy-platform-deepseek'
|
||||
|
||||
export type CredentialCipher = SettingsCredentialCipher
|
||||
|
||||
@@ -258,6 +280,7 @@ export type ResolvedRuntimeSettings = {
|
||||
defaultModelProfileId: string
|
||||
opencodeModelProfile?: ResolvedModelProfile
|
||||
continueModelProfile?: ResolvedModelProfile
|
||||
deepseekHarnessModelProfile?: ResolvedModelProfile
|
||||
opencodeBaseUrl: string
|
||||
opencodeEmbedded: boolean
|
||||
opencodeBinaryPath: string
|
||||
@@ -297,7 +320,7 @@ export type ResolvedModelProfile = {
|
||||
}
|
||||
|
||||
const defaultSettings: StoredSettings = {
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: defaultRuntimeSettings.provider,
|
||||
modelProfiles: [
|
||||
{
|
||||
@@ -328,6 +351,7 @@ const defaultSettings: StoredSettings = {
|
||||
continueBinaryPath: defaultRuntimeSettings.continueBinaryPath,
|
||||
continueConfigPath: defaultRuntimeSettings.continueConfigPath,
|
||||
continueMode: defaultRuntimeSettings.continueMode,
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
runtimeSandboxMode: defaultRuntimeSettings.runtimeSandboxMode,
|
||||
subagentSmartRoutingEnabled:
|
||||
defaultRuntimeSettings.subagentSmartRoutingEnabled,
|
||||
@@ -411,7 +435,7 @@ function migrateVersion12(
|
||||
function migrateVersion13(
|
||||
settings: Version13StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
return migrateVersion14({
|
||||
...settings,
|
||||
version: 14,
|
||||
knowledgeRerankEnabled:
|
||||
@@ -421,6 +445,30 @@ function migrateVersion13(
|
||||
knowledgeRerankModel:
|
||||
defaultRuntimeSettings.knowledgeRerankModel,
|
||||
knowledgeRerankCredential: undefined
|
||||
})
|
||||
}
|
||||
|
||||
function migrateVersion14(
|
||||
settings: Version14StoredSettings
|
||||
): StoredSettings {
|
||||
return {
|
||||
...settings,
|
||||
version: 16,
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
}
|
||||
}
|
||||
|
||||
function migrateVersion15(
|
||||
settings: Version15StoredSettings
|
||||
): StoredSettings {
|
||||
const {
|
||||
deepseekHarnessBinaryPath: _obsolete,
|
||||
...current
|
||||
} = settings
|
||||
void _obsolete
|
||||
return {
|
||||
...current,
|
||||
version: 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +531,19 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
? { kind: 'profile', profileId: fallbackProfileId }
|
||||
: { kind: 'platform' }
|
||||
}
|
||||
const normalizeDeepSeekHarnessSource = (
|
||||
source: RuntimeSettings['deepseekHarnessModelSource']
|
||||
): NonNullable<RuntimeSettings['deepseekHarnessModelSource']> => {
|
||||
if (!source || source.kind === 'platform') {
|
||||
return { kind: 'platform' }
|
||||
}
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) => candidate.id === source.profileId
|
||||
)
|
||||
return profile && isDeepSeekHarnessModelProfile(profile)
|
||||
? source
|
||||
: { kind: 'platform' }
|
||||
}
|
||||
const opencodeBaseUrl = settings.opencodeBaseUrl.trim()
|
||||
if (opencodeBaseUrl) {
|
||||
const url = new URL(opencodeBaseUrl)
|
||||
@@ -508,6 +569,9 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
|
||||
continueModelSource: normalizeSource(
|
||||
settings.continueModelSource
|
||||
),
|
||||
deepseekHarnessModelSource: normalizeDeepSeekHarnessSource(
|
||||
settings.deepseekHarnessModelSource
|
||||
),
|
||||
opencodeBaseUrl,
|
||||
opencodeEmbedded: !opencodeBaseUrl
|
||||
}
|
||||
@@ -683,7 +747,7 @@ export class RuntimeSettingsStore {
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
assertSupportedSettingsVersion(
|
||||
parsed,
|
||||
14,
|
||||
16,
|
||||
(version) =>
|
||||
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
|
||||
)
|
||||
@@ -691,110 +755,122 @@ export class RuntimeSettingsStore {
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
} else {
|
||||
const version13 =
|
||||
version13StoredSettingsSchema.safeParse(parsed)
|
||||
if (version13.success) {
|
||||
this.settings = migrateVersion13(version13.data)
|
||||
const version15 =
|
||||
version15StoredSettingsSchema.safeParse(parsed)
|
||||
if (version15.success) {
|
||||
this.settings = migrateVersion15(version15.data)
|
||||
} else {
|
||||
const version12 =
|
||||
version12StoredSettingsSchema.safeParse(parsed)
|
||||
if (version12.success) {
|
||||
this.settings = migrateVersion12(version12.data)
|
||||
const version14 =
|
||||
version14StoredSettingsSchema.safeParse(parsed)
|
||||
if (version14.success) {
|
||||
this.settings = migrateVersion14(version14.data)
|
||||
} else {
|
||||
const version11 =
|
||||
version11StoredSettingsSchema.safeParse(parsed)
|
||||
if (version11.success) {
|
||||
this.settings = migrateVersion11(version11.data)
|
||||
const version13 =
|
||||
version13StoredSettingsSchema.safeParse(parsed)
|
||||
if (version13.success) {
|
||||
this.settings = migrateVersion13(version13.data)
|
||||
} else {
|
||||
const version10 =
|
||||
version10StoredSettingsSchema.safeParse(parsed)
|
||||
if (version10.success) {
|
||||
this.settings = migrateVersion10(version10.data)
|
||||
const version12 =
|
||||
version12StoredSettingsSchema.safeParse(parsed)
|
||||
if (version12.success) {
|
||||
this.settings = migrateVersion12(version12.data)
|
||||
} else {
|
||||
const version9 =
|
||||
version9StoredSettingsSchema.safeParse(parsed)
|
||||
if (version9.success) {
|
||||
this.settings = migrateVersion9(version9.data)
|
||||
const version11 =
|
||||
version11StoredSettingsSchema.safeParse(parsed)
|
||||
if (version11.success) {
|
||||
this.settings = migrateVersion11(version11.data)
|
||||
} else {
|
||||
const version8 =
|
||||
version8StoredSettingsSchema.safeParse(parsed)
|
||||
if (version8.success) {
|
||||
this.settings = migrateVersion8(version8.data)
|
||||
const version10 =
|
||||
version10StoredSettingsSchema.safeParse(parsed)
|
||||
if (version10.success) {
|
||||
this.settings = migrateVersion10(version10.data)
|
||||
} else {
|
||||
const version7 =
|
||||
version7StoredSettingsSchema.safeParse(parsed)
|
||||
if (version7.success) {
|
||||
this.settings = migrateVersion7(version7.data)
|
||||
const version9 =
|
||||
version9StoredSettingsSchema.safeParse(parsed)
|
||||
if (version9.success) {
|
||||
this.settings = migrateVersion9(version9.data)
|
||||
} else {
|
||||
const version6 =
|
||||
version6StoredSettingsSchema.safeParse(parsed)
|
||||
if (version6.success) {
|
||||
this.settings = migrateVersion6(version6.data)
|
||||
const version8 =
|
||||
version8StoredSettingsSchema.safeParse(parsed)
|
||||
if (version8.success) {
|
||||
this.settings = migrateVersion8(version8.data)
|
||||
} else {
|
||||
const version5 =
|
||||
version5StoredSettingsSchema.safeParse(parsed)
|
||||
if (version5.success) {
|
||||
this.settings = migrateVersion5(version5.data)
|
||||
const version7 =
|
||||
version7StoredSettingsSchema.safeParse(parsed)
|
||||
if (version7.success) {
|
||||
this.settings = migrateVersion7(version7.data)
|
||||
} else {
|
||||
const version4 =
|
||||
version4StoredSettingsSchema.safeParse(parsed)
|
||||
if (version4.success) {
|
||||
this.settings = migrateVersion4(version4.data)
|
||||
const version6 =
|
||||
version6StoredSettingsSchema.safeParse(parsed)
|
||||
if (version6.success) {
|
||||
this.settings = migrateVersion6(version6.data)
|
||||
} else {
|
||||
const version3 =
|
||||
version3StoredSettingsSchema.safeParse(parsed)
|
||||
if (version3.success) {
|
||||
this.settings = migrateVersion4({
|
||||
...version3.data,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
const version5 =
|
||||
version5StoredSettingsSchema.safeParse(parsed)
|
||||
if (version5.success) {
|
||||
this.settings = migrateVersion5(version5.data)
|
||||
} else {
|
||||
const version2 =
|
||||
version2StoredSettingsSchema.safeParse(parsed)
|
||||
if (version2.success) {
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider: version2.data.provider,
|
||||
modelBaseUrl: version2.data.modelBaseUrl,
|
||||
modelName: version2.data.modelName,
|
||||
opencodeBaseUrl: version2.data.opencodeBaseUrl,
|
||||
opencodeEmbedded: version2.data.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
version2.data.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: version2.data.workspacePath,
|
||||
credential: version2.data.credential,
|
||||
toolApproval: version2.data.toolApproval
|
||||
})
|
||||
const version4 =
|
||||
version4StoredSettingsSchema.safeParse(parsed)
|
||||
if (version4.success) {
|
||||
this.settings = migrateVersion4(version4.data)
|
||||
} else {
|
||||
const legacy =
|
||||
legacyStoredSettingsSchema.parse(parsed)
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
legacy.provider === 'bigtoken'
|
||||
? 'model'
|
||||
: legacy.provider,
|
||||
modelBaseUrl: legacy.bigtokenBaseUrl,
|
||||
modelName: legacy.bigtokenModel,
|
||||
opencodeBaseUrl: legacy.opencodeBaseUrl,
|
||||
opencodeEmbedded: legacy.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
legacy.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: legacy.workspacePath,
|
||||
credential: legacy.credential,
|
||||
toolApproval: legacy.toolApproval
|
||||
})
|
||||
const version3 =
|
||||
version3StoredSettingsSchema.safeParse(parsed)
|
||||
if (version3.success) {
|
||||
this.settings = migrateVersion4({
|
||||
...version3.data,
|
||||
version: 4,
|
||||
continueMode: 'chat'
|
||||
})
|
||||
} else {
|
||||
const version2 =
|
||||
version2StoredSettingsSchema.safeParse(parsed)
|
||||
if (version2.success) {
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider: version2.data.provider,
|
||||
modelBaseUrl: version2.data.modelBaseUrl,
|
||||
modelName: version2.data.modelName,
|
||||
opencodeBaseUrl: version2.data.opencodeBaseUrl,
|
||||
opencodeEmbedded: version2.data.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
version2.data.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: version2.data.workspacePath,
|
||||
credential: version2.data.credential,
|
||||
toolApproval: version2.data.toolApproval
|
||||
})
|
||||
} else {
|
||||
const legacy =
|
||||
legacyStoredSettingsSchema.parse(parsed)
|
||||
this.settings = migrateVersion4({
|
||||
version: 4,
|
||||
provider:
|
||||
legacy.provider === 'bigtoken'
|
||||
? 'model'
|
||||
: legacy.provider,
|
||||
modelBaseUrl: legacy.bigtokenBaseUrl,
|
||||
modelName: legacy.bigtokenModel,
|
||||
opencodeBaseUrl: legacy.opencodeBaseUrl,
|
||||
opencodeEmbedded: legacy.opencodeEmbedded,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: migrateContinueCommand(
|
||||
legacy.continueCommand
|
||||
),
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat',
|
||||
workspacePath: legacy.workspacePath,
|
||||
credential: legacy.credential,
|
||||
toolApproval: legacy.toolApproval
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -965,6 +1041,43 @@ export class RuntimeSettingsStore {
|
||||
)
|
||||
}
|
||||
|
||||
private resolvePlatformDeepSeekProfile(): ResolvedModelProfile | undefined {
|
||||
const apiKey = this.environment.GOODBUDDY_MODEL_API_KEY?.trim()
|
||||
const baseUrl = this.environment.GOODBUDDY_MODEL_BASE_URL?.trim()
|
||||
const modelName = this.environment.GOODBUDDY_MODEL_NAME?.trim()
|
||||
if (!apiKey || !baseUrl || !modelName) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const endpoint = new URL(baseUrl)
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.hostname.toLowerCase() !== 'api.deepseek.com' ||
|
||||
endpoint.port ||
|
||||
endpoint.pathname !== '/' ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
endpoint.username ||
|
||||
endpoint.password
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
id: platformDeepSeekProfileId,
|
||||
name: '平台 DeepSeek',
|
||||
baseUrl,
|
||||
modelName,
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEffectiveModelSettings(settings: StoredSettings): {
|
||||
apiKey?: string
|
||||
baseUrl: string
|
||||
@@ -1235,6 +1348,8 @@ export class RuntimeSettingsStore {
|
||||
? { kind: 'platform' }
|
||||
: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
settings.deepseekHarnessModelSource,
|
||||
secureStorageAvailable: this.cipher.isAvailable(),
|
||||
toolApproval: settings.toolApproval,
|
||||
configured: {
|
||||
@@ -1246,7 +1361,9 @@ export class RuntimeSettingsStore {
|
||||
continueConfigPath: settings.continueConfigPath,
|
||||
workspacePath: settings.workspacePath || homedir(),
|
||||
opencodeModelSource: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource
|
||||
continueModelSource: settings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
settings.deepseekHarnessModelSource
|
||||
},
|
||||
...(this.loadWarnings.length > 0
|
||||
? { warnings: [...this.loadWarnings] }
|
||||
@@ -1284,6 +1401,12 @@ export class RuntimeSettingsStore {
|
||||
settings.continueModelSource.kind === 'profile'
|
||||
? profilesById.get(settings.continueModelSource.profileId)
|
||||
: undefined
|
||||
const deepseekHarnessModelProfile =
|
||||
settings.deepseekHarnessModelSource.kind === 'profile'
|
||||
? profilesById.get(
|
||||
settings.deepseekHarnessModelSource.profileId
|
||||
)
|
||||
: this.resolvePlatformDeepSeekProfile()
|
||||
return {
|
||||
provider: settings.provider,
|
||||
modelBaseUrl: effective.baseUrl,
|
||||
@@ -1297,6 +1420,7 @@ export class RuntimeSettingsStore {
|
||||
defaultModelProfileId: settings.defaultModelProfileId,
|
||||
opencodeModelProfile,
|
||||
continueModelProfile,
|
||||
deepseekHarnessModelProfile,
|
||||
...agent,
|
||||
subagentSmartRoutingEnabled:
|
||||
settings.subagentSmartRoutingEnabled,
|
||||
@@ -1589,15 +1713,34 @@ export class RuntimeSettingsStore {
|
||||
: repairRuntimeSource(current.continueModelSource)
|
||||
validateRuntimeSource(opencodeModelSource, 'OpenCode')
|
||||
validateRuntimeSource(continueModelSource, 'Continue')
|
||||
const requestedDeepSeekHarnessSource =
|
||||
input.deepseekHarnessModelSource ??
|
||||
current.deepseekHarnessModelSource
|
||||
if (requestedDeepSeekHarnessSource.kind === 'profile') {
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === requestedDeepSeekHarnessSource.profileId
|
||||
)
|
||||
if (!profile) {
|
||||
throw new Error('DeepSeek Harness 引用的模型连接不存在')
|
||||
}
|
||||
if (!isDeepSeekHarnessModelProfile(profile)) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness 模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const next: StoredSettings = {
|
||||
...current,
|
||||
version: 14,
|
||||
version: 16,
|
||||
provider: input.provider,
|
||||
modelProfiles,
|
||||
defaultModelProfileId,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
requestedDeepSeekHarnessSource,
|
||||
opencodeBaseUrl,
|
||||
opencodeEmbedded: !opencodeBaseUrl,
|
||||
opencodeBinaryPath,
|
||||
|
||||
@@ -243,6 +243,11 @@ const api: DesktopApi = {
|
||||
continue: {
|
||||
available: false,
|
||||
detail: '未检测到 Continue'
|
||||
},
|
||||
deepseekHarness: {
|
||||
available: true,
|
||||
path: 'bundled://deepseek-harness',
|
||||
detail: 'Bundled Harness Adapter ready'
|
||||
}
|
||||
})),
|
||||
selectRuntimeFile: vi.fn(async () => undefined),
|
||||
@@ -3076,6 +3081,11 @@ describe('App', () => {
|
||||
name: /^Continue · 默认模型.*sonnet-5$/u
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('menuitemradio', {
|
||||
name: /^DeepSeek Harness · 自身配置/u
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitemradio', {
|
||||
name: /^默认模型.*sonnet-5$/u
|
||||
@@ -3190,12 +3200,16 @@ describe('App', () => {
|
||||
const continueModel = screen.getByRole('menuitemradio', {
|
||||
name: /^Continue · 默认模型.*sonnet-5$/u
|
||||
})
|
||||
const deepseekHarness = screen.getByRole('menuitemradio', {
|
||||
name: /^DeepSeek Harness · 自身配置/u
|
||||
})
|
||||
expect(directModel).toBeEnabled()
|
||||
expect(secondDirectModel).toBeEnabled()
|
||||
expect(openCodeModel).toBeEnabled()
|
||||
expect(continueModel).toBeEnabled()
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(4)
|
||||
expect(within(runtimeMenu).getAllByRole('separator')).toHaveLength(3)
|
||||
expect(deepseekHarness).toBeEnabled()
|
||||
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5)
|
||||
expect(within(runtimeMenu).getAllByRole('separator')).toHaveLength(4)
|
||||
expect(within(runtimeMenu).queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('menuitemradio', {
|
||||
|
||||
@@ -295,6 +295,7 @@ function isAgentRuntime(
|
||||
runtime: AgentRuntimeStatus | undefined
|
||||
): boolean {
|
||||
return runtime?.id === 'opencode' || runtime?.id === 'continue'
|
||||
|| runtime?.id === 'deepseek-harness'
|
||||
}
|
||||
|
||||
function supportsSubagentSmartRouting(
|
||||
@@ -899,6 +900,13 @@ function getRuntimeSelectionLabel(
|
||||
? `Continue · ${labels.modelUnavailable}`
|
||||
: 'Continue'
|
||||
}
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
return profile
|
||||
? `DeepSeek Harness · ${profile.name}`
|
||||
: requestedProfileMissing
|
||||
? `DeepSeek Harness · ${labels.modelUnavailable}`
|
||||
: 'DeepSeek Harness'
|
||||
}
|
||||
return status
|
||||
? `${labels.automatic} · ${status.label}`
|
||||
: labels.automaticSelection
|
||||
@@ -906,7 +914,7 @@ function getRuntimeSelectionLabel(
|
||||
|
||||
function getConfiguredAgentRuntimeSource(
|
||||
settings: RuntimeSettings,
|
||||
provider: 'opencode' | 'continue',
|
||||
provider: 'opencode' | 'continue' | 'deepseek-harness',
|
||||
labels: {
|
||||
modelUnavailable: string
|
||||
selectModel: string
|
||||
@@ -921,7 +929,12 @@ function getConfiguredAgentRuntimeSource(
|
||||
(candidate) => candidate.id === selection.profileId
|
||||
)
|
||||
: undefined
|
||||
const runtimeLabel = provider === 'opencode' ? 'OpenCode' : 'Continue'
|
||||
const runtimeLabel =
|
||||
provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: provider === 'continue'
|
||||
? 'Continue'
|
||||
: 'DeepSeek Harness'
|
||||
if ('profileId' in selection) {
|
||||
return {
|
||||
label: `${runtimeLabel} · ${profile?.name ?? labels.modelUnavailable}`,
|
||||
@@ -1801,6 +1814,12 @@ function App(): React.JSX.Element {
|
||||
const continueMenuSelection = runtimeSettings
|
||||
? getRuntimeSelectionForProvider('continue', runtimeSettings)
|
||||
: undefined
|
||||
const deepseekHarnessMenuSelection = runtimeSettings
|
||||
? getRuntimeSelectionForProvider(
|
||||
'deepseek-harness',
|
||||
runtimeSettings
|
||||
)
|
||||
: undefined
|
||||
const openCodeMenuSource = runtimeSettings
|
||||
? getConfiguredAgentRuntimeSource(
|
||||
runtimeSettings,
|
||||
@@ -1815,6 +1834,13 @@ function App(): React.JSX.Element {
|
||||
configuredRuntimeLabels
|
||||
)
|
||||
: undefined
|
||||
const deepseekHarnessMenuSource = runtimeSettings
|
||||
? getConfiguredAgentRuntimeSource(
|
||||
runtimeSettings,
|
||||
'deepseek-harness',
|
||||
configuredRuntimeLabels
|
||||
)
|
||||
: undefined
|
||||
useEffect(() => {
|
||||
if (!runtimeMenuOpen) {
|
||||
return
|
||||
@@ -6397,6 +6423,46 @@ function App(): React.JSX.Element {
|
||||
className="runtime-picker__divider"
|
||||
role="separator"
|
||||
/>
|
||||
<strong role="presentation">
|
||||
{t('runtime.deepseekHarnessGroup')}
|
||||
</strong>
|
||||
{deepseekHarnessMenuSelection &&
|
||||
deepseekHarnessMenuSource && (
|
||||
<button
|
||||
aria-checked={
|
||||
activeRuntimeSelectionKey ===
|
||||
agentRuntimeSelectionKey(
|
||||
deepseekHarnessMenuSelection
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
void switchRuntime(
|
||||
deepseekHarnessMenuSelection
|
||||
)
|
||||
}
|
||||
role="menuitemradio"
|
||||
tabIndex={
|
||||
activeRuntimeSelectionKey ===
|
||||
agentRuntimeSelectionKey(
|
||||
deepseekHarnessMenuSelection
|
||||
)
|
||||
? 0
|
||||
: -1
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{deepseekHarnessMenuSource.label}
|
||||
</span>
|
||||
<small>
|
||||
{deepseekHarnessMenuSource.detail}
|
||||
</small>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="runtime-picker__divider"
|
||||
role="separator"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false)
|
||||
|
||||
@@ -501,6 +501,11 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(
|
||||
within(backend).getByRole('option', { name: 'Continue' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(backend).getByRole('option', {
|
||||
name: 'DeepSeek Harness(预览 · 仅 DeepSeek)'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(backend, {
|
||||
target: {
|
||||
|
||||
@@ -196,7 +196,7 @@ function usableChannelModelProfiles(
|
||||
}
|
||||
|
||||
function configuredRuntimeSelection(
|
||||
provider: 'opencode' | 'continue'
|
||||
provider: 'opencode' | 'continue' | 'deepseek-harness'
|
||||
): AgentRuntimeSelection {
|
||||
return { provider }
|
||||
}
|
||||
@@ -231,7 +231,11 @@ function runtimeSelectionDescription(
|
||||
return t('channels.project.automaticDescription')
|
||||
}
|
||||
const runtimeLabel =
|
||||
selection.provider === 'opencode' ? 'OpenCode' : 'Continue'
|
||||
selection.provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: selection.provider === 'continue'
|
||||
? 'Continue'
|
||||
: 'DeepSeek Harness'
|
||||
return t('channels.project.runtimeDescription', {
|
||||
runtime: runtimeLabel
|
||||
})
|
||||
@@ -255,6 +259,9 @@ function ChannelProjectControls({
|
||||
const continueSelection = configuredRuntimeSelection(
|
||||
'continue'
|
||||
)
|
||||
const deepseekHarnessSelection = configuredRuntimeSelection(
|
||||
'deepseek-harness'
|
||||
)
|
||||
const directProfiles = usableChannelModelProfiles(runtimeSettings)
|
||||
const selectedDirectProfileId =
|
||||
draft.runtimeSelection.provider === 'model'
|
||||
@@ -274,7 +281,8 @@ function ChannelProjectControls({
|
||||
profileId: profile.id
|
||||
})),
|
||||
openCodeSelection,
|
||||
continueSelection
|
||||
continueSelection,
|
||||
deepseekHarnessSelection
|
||||
]
|
||||
const selectionByKey = new Map(
|
||||
selections.map((selection) => [
|
||||
@@ -377,6 +385,11 @@ function ChannelProjectControls({
|
||||
<option value={agentRuntimeSelectionKey(continueSelection)}>
|
||||
Continue
|
||||
</option>
|
||||
<option
|
||||
value={agentRuntimeSelectionKey(deepseekHarnessSelection)}
|
||||
>
|
||||
{t('channels.project.deepseekHarnessOption')}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<small>
|
||||
|
||||
@@ -213,6 +213,21 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes built-in OCR model metadata in English', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
|
||||
expect(await screen.findByText('PP-OCRv6 Tiny')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The official lightweight PaddleOCR Chinese model for local CPU recognition of scanned PDFs and images.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Chinese / English')).toBeInTheDocument()
|
||||
expect(screen.queryByText('轻量中文 OCR 模型')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes recovered document parsing settings warnings', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
getSnapshot.mockResolvedValueOnce({
|
||||
|
||||
@@ -423,6 +423,16 @@ export function DocumentParsingSettingsSection({
|
||||
const installedModel = snapshot.ocrModels.installed.find(
|
||||
(entry) => entry.id === draft.localOcrModelId
|
||||
)
|
||||
const modelDisplayName = model
|
||||
? t(`documentParsing.ocr.catalog.${model.id}.displayName`, {
|
||||
defaultValue: model.displayName
|
||||
})
|
||||
: ''
|
||||
const modelDescription = model
|
||||
? t(`documentParsing.ocr.catalog.${model.id}.description`, {
|
||||
defaultValue: model.description
|
||||
})
|
||||
: ''
|
||||
const modelOperation = snapshot.ocrModels.operations.find(
|
||||
(operation) => operation.modelId === draft.localOcrModelId
|
||||
)
|
||||
@@ -682,9 +692,13 @@ export function DocumentParsingSettingsSection({
|
||||
const installed = snapshot.ocrModels.installed.some(
|
||||
(candidate) => candidate.id === entry.id
|
||||
)
|
||||
const entryDisplayName = t(
|
||||
`documentParsing.ocr.catalog.${entry.id}.displayName`,
|
||||
{ defaultValue: entry.displayName }
|
||||
)
|
||||
return (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.displayName} ·{' '}
|
||||
{entryDisplayName} ·{' '}
|
||||
{installed
|
||||
? t('documentParsing.ocr.installedOption')
|
||||
: t('documentParsing.ocr.downloadableOption')}
|
||||
@@ -712,18 +726,25 @@ export function DocumentParsingSettingsSection({
|
||||
<div className="document-ocr-model__header">
|
||||
<div className="document-ocr-model__summary">
|
||||
<div className="document-ocr-model__name">
|
||||
<strong>{model.displayName}</strong>
|
||||
<strong>{modelDisplayName}</strong>
|
||||
{model.recommended && (
|
||||
<span className="speech-model-tag speech-model-tag--recommended">
|
||||
{t('documentParsing.ocr.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{model.description}</p>
|
||||
<p>{modelDescription}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">ModelScope</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.languages.join(' / ')}
|
||||
{model.languages
|
||||
.map((language) =>
|
||||
t(
|
||||
`documentParsing.ocr.languages.${language}`,
|
||||
{ defaultValue: language }
|
||||
)
|
||||
)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.runtime}
|
||||
@@ -753,7 +774,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.openRepository',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="secondary-button document-ocr-model__repository"
|
||||
onClick={() =>
|
||||
@@ -797,7 +818,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.cancelOperation',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
@@ -815,7 +836,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.exportModelZip',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
@@ -827,7 +848,7 @@ export function DocumentParsingSettingsSection({
|
||||
.exportOcrModelArchive(model.id),
|
||||
t(
|
||||
'documentParsing.ocr.notifications.exportedZip',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -839,7 +860,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.deleteModel',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className={
|
||||
confirmingRemove === model.id
|
||||
@@ -861,7 +882,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
@@ -882,7 +903,7 @@ export function DocumentParsingSettingsSection({
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -896,7 +917,7 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.importModelZip',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
@@ -917,7 +938,7 @@ export function DocumentParsingSettingsSection({
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.importedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.importedZip',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -938,7 +959,7 @@ export function DocumentParsingSettingsSection({
|
||||
<progress
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadProgress',
|
||||
{ name: model.displayName }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
max={100}
|
||||
{...(modelProgress === undefined
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
} from './SettingsPrimitives'
|
||||
import { PageTabs } from './WorkspacePrimitives'
|
||||
|
||||
const configurableMcpTargets: RuntimeTarget[] = ['model']
|
||||
const configurableMcpTargets: RuntimeTarget[] = [
|
||||
'model',
|
||||
'deepseek-harness'
|
||||
]
|
||||
type McpSettingsTab = 'builtin' | 'computer' | 'custom'
|
||||
|
||||
type McpEditor = {
|
||||
@@ -76,9 +79,9 @@ function editorFromServer(server: McpServerSummary): McpEditor {
|
||||
description: server.description,
|
||||
enabled: server.enabled,
|
||||
allowDynamicTools: server.allowDynamicTools,
|
||||
assignments: server.assignments.includes('model')
|
||||
? ['model']
|
||||
: [],
|
||||
assignments: server.assignments.filter((target) =>
|
||||
configurableMcpTargets.includes(target)
|
||||
),
|
||||
transport: server.transport,
|
||||
command: server.transport === 'stdio' ? server.command : '',
|
||||
args: server.transport === 'stdio' ? server.args.join('\n') : '',
|
||||
@@ -101,7 +104,8 @@ export function McpSettingsSection({
|
||||
const runtimeLabels: Record<RuntimeTarget, string> = {
|
||||
model: t('mcp.runtimeLabels.model'),
|
||||
opencode: t('mcp.runtimeLabels.opencode'),
|
||||
continue: t('mcp.runtimeLabels.continue')
|
||||
continue: t('mcp.runtimeLabels.continue'),
|
||||
'deepseek-harness': 'DeepSeek Harness'
|
||||
}
|
||||
const diagnosticStatusLabels: Record<
|
||||
CapabilityDiagnosticReport['status'],
|
||||
|
||||
@@ -81,6 +81,7 @@ const runtimeSettings: RuntimeSettings = {
|
||||
kind: 'profile',
|
||||
profileId: modelProfileId
|
||||
},
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
secureStorageAvailable: true,
|
||||
toolApproval: 'always'
|
||||
}
|
||||
@@ -118,11 +119,22 @@ const detectAgentRuntimes = vi.fn<
|
||||
available: true,
|
||||
path: 'C:\\Tools\\opencode.exe',
|
||||
version: '1.2.3',
|
||||
source: 'automatic',
|
||||
detail: '通过 PATH 检测'
|
||||
},
|
||||
continue: {
|
||||
available: false,
|
||||
detail: '未检测到 Continue'
|
||||
available: true,
|
||||
path: 'bundled://continue',
|
||||
version: '1.5.47',
|
||||
source: 'bundled',
|
||||
detail: '内置 Continue CLI 1.5.47 已就绪'
|
||||
},
|
||||
deepseekHarness: {
|
||||
available: true,
|
||||
path: 'bundled://deepseek-harness',
|
||||
version: '0.1.0-rc.6',
|
||||
source: 'bundled',
|
||||
detail: '内置 Harness Adapter 已就绪'
|
||||
}
|
||||
}))
|
||||
const selectRuntimeFile = vi.fn<
|
||||
@@ -162,10 +174,16 @@ const capabilitySnapshot = {
|
||||
source: 'builtin' as const,
|
||||
digest: 'a'.repeat(64),
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue'] as (
|
||||
assignments: [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
] as (
|
||||
| 'model'
|
||||
| 'opencode'
|
||||
| 'continue'
|
||||
| 'deepseek-harness'
|
||||
)[]
|
||||
}
|
||||
],
|
||||
@@ -612,8 +630,21 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByRole('button', { name: 'Save settings' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/OpenCode and Continue are bundled with GoodBuddy/u)
|
||||
screen.queryByText(/OpenCode and Continue are bundled with GoodBuddy/u)
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Automatically detected OpenCode 1.2.3'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('通过 PATH 检测')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
expect(
|
||||
screen.getByText('Bundled Continue 1.5.47 is ready')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('内置 Continue CLI 1.5.47 已就绪')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not translate user-defined model connection names', async () => {
|
||||
@@ -933,7 +964,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
continueConfigPath: '',
|
||||
workspacePath: 'C:\\Workspace',
|
||||
opencodeModelSource: runtimeSettings.opencodeModelSource,
|
||||
continueModelSource: runtimeSettings.continueModelSource
|
||||
continueModelSource: runtimeSettings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
runtimeSettings.deepseekHarnessModelSource
|
||||
}
|
||||
})
|
||||
render(
|
||||
@@ -1267,7 +1300,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
|
||||
|
||||
it('automatically detects runtimes and displays path, version, and detail', async () => {
|
||||
it('places Runtime detection details in the semantic overview card', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
@@ -1279,16 +1312,53 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
expect(detectAgentRuntimes).toHaveBeenCalledOnce()
|
||||
const runtimeLabel = await screen.findByText('Runtime:', {
|
||||
selector: 'dt'
|
||||
})
|
||||
const overview = runtimeLabel.closest<HTMLElement>(
|
||||
'.runtime-overview'
|
||||
)
|
||||
if (!overview) {
|
||||
throw new Error('Missing OpenCode Runtime overview')
|
||||
}
|
||||
expect(
|
||||
await screen.findByText(
|
||||
within(overview).getByText('GoodBuddy 内置 OpenCode')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(overview).getByText('模型配置:', { selector: 'dt' })
|
||||
).toBeInTheDocument()
|
||||
const status = within(overview).getByText('已就绪')
|
||||
expect(status.tagName).toBe('DD')
|
||||
expect(status).toHaveAttribute('aria-live', 'polite')
|
||||
expect(
|
||||
within(overview).getByText('C:\\Tools\\opencode.exe')
|
||||
).toHaveClass('runtime-overview__path')
|
||||
expect(within(overview).getByText('1.2.3')).toBeInTheDocument()
|
||||
expect(
|
||||
within(overview).getByText('已自动检测到 OpenCode 1.2.3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'已就绪 · C:\\Tools\\opencode.exe · 1.2.3 · 通过 PATH 检测'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
await screen.findByText(/OpenCode 和 Continue 已随 GoodBuddy 内置/)
|
||||
).not.toBeInTheDocument()
|
||||
await screen.findByText('GoodBuddy 内置 OpenCode')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
const continueOverview = screen
|
||||
.getByText('GoodBuddy 内置 Continue')
|
||||
.closest<HTMLElement>('.runtime-overview')
|
||||
if (!continueOverview) {
|
||||
throw new Error('Missing Continue Runtime overview')
|
||||
}
|
||||
expect(
|
||||
screen.getByText('尚未就绪 · 未检测到 Continue')
|
||||
within(continueOverview).getByText('已就绪')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(continueOverview).getByText('1.5.47')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(continueOverview).getByText('bundled://continue')
|
||||
).toHaveClass('runtime-overview__path')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '重新检测 Continue' })
|
||||
@@ -1302,6 +1372,177 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('configures DeepSeek Harness only with Chat Completions or platform settings', async () => {
|
||||
const harnessProfileId =
|
||||
'00000000-0000-4000-8000-000000000051'
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelProfiles: [
|
||||
runtimeSettings.modelProfiles[0]!,
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
id: harnessProfileId,
|
||||
name: 'DeepSeek Chat',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions'
|
||||
}
|
||||
],
|
||||
deepseekHarnessModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: harnessProfileId
|
||||
}
|
||||
} as unknown as RuntimeSettings)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'DeepSeek Harness(预览)'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
screen.getByText('开发者预览 · 仅支持 DeepSeek')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/当前仅支持 DeepSeek 模型/)
|
||||
).toBeInTheDocument()
|
||||
const harnessOverview = screen
|
||||
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
||||
.closest<HTMLElement>('.runtime-overview')
|
||||
if (!harnessOverview) {
|
||||
throw new Error('Missing DeepSeek Harness overview')
|
||||
}
|
||||
expect(
|
||||
within(harnessOverview).getByText('已就绪')
|
||||
).toHaveAttribute('aria-live', 'polite')
|
||||
expect(
|
||||
within(harnessOverview).getByText(
|
||||
'bundled://deepseek-harness'
|
||||
)
|
||||
).toHaveClass('runtime-overview__path')
|
||||
expect(
|
||||
within(harnessOverview).getByText('0.1.0-rc.6')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(harnessOverview).getByText(
|
||||
'内置 DeepSeek Harness 0.1.0-rc.6 已就绪'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(/自定义 Harness Host/)
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('高级设置'))
|
||||
expect(
|
||||
screen.getByText(
|
||||
/始终使用 GoodBuddy 内置并固定版本的 Host/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /选择.*Harness/
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
const source = screen.getByLabelText(
|
||||
'DeepSeek Harness GoodBuddy 模型连接'
|
||||
)
|
||||
expect(
|
||||
within(source).getByRole('option', { name: '默认模型(不兼容)' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(source).getByRole('option', { name: 'DeepSeek Chat' })
|
||||
).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: /使用平台 DeepSeek 环境配置/
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes an environment-managed DeepSeek profile to the platform source when saving', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
modelBaseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
modelProtocol: 'openai-chat-completions',
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
modelName: 'deepseek-chat',
|
||||
protocol: 'openai-chat-completions',
|
||||
credentialSource: 'environment'
|
||||
}
|
||||
],
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
configured: {
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
modelName: 'sonnet-5',
|
||||
protocol: 'openai-chat-completions',
|
||||
credentialSource: 'environment'
|
||||
}
|
||||
],
|
||||
opencodeBaseUrl: '',
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
workspacePath: 'C:\\Workspace',
|
||||
opencodeModelSource: runtimeSettings.opencodeModelSource,
|
||||
continueModelSource: runtimeSettings.continueModelSource,
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
}
|
||||
} as unknown as RuntimeSettings)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'DeepSeek Harness(预览)'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: /使用 GoodBuddy 模型连接/
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('selects, warns about, clears, and saves a custom binary', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -1313,7 +1554,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText(/OpenCode 和 Continue 已随 GoodBuddy 内置/)
|
||||
await screen.findByText('GoodBuddy 内置 OpenCode')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
fireEvent.click(screen.getByText('高级设置'))
|
||||
const input = await screen.findByLabelText('Continue 可执行文件路径')
|
||||
@@ -1362,28 +1603,22 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('GoodBuddy 内置 OpenCode')
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/OpenCode 和 Continue 已随 GoodBuddy 内置/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/配置可兼容的直连文本模型后即可使用/)
|
||||
).toBeInTheDocument()
|
||||
screen.queryByText(/配置可兼容的直连文本模型后即可使用/)
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'OpenCode' }))
|
||||
.toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.queryByText('默认 Runtime')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/GoodBuddy 内置 OpenCode/)
|
||||
).toBeInTheDocument()
|
||||
screen.getAllByText(/GoodBuddy 内置 OpenCode/).length
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByText(/模型配置:/).closest('.runtime-note')
|
||||
).toHaveTextContent(
|
||||
'跟随 GoodBuddy · 默认模型(sonnet-5)'
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/^已就绪 ·/u)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('已就绪')).toBeInTheDocument()
|
||||
expect(screen.getByText('高级设置').closest('details'))
|
||||
.not.toHaveAttribute('open')
|
||||
expect(
|
||||
@@ -1408,15 +1643,15 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
expect(
|
||||
screen.getByText(/GoodBuddy 内置 Continue/)
|
||||
).toBeInTheDocument()
|
||||
screen.getAllByText(/GoodBuddy 内置 Continue/).length
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByText(/模型配置:/).closest('.runtime-note')
|
||||
).toHaveTextContent(
|
||||
'跟随 GoodBuddy · 默认模型(sonnet-5)'
|
||||
)
|
||||
expect(screen.getByText('尚未就绪 · 未检测到 Continue'))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.getByText('已就绪')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.5.47')).toBeInTheDocument()
|
||||
expect(screen.getByText('高级设置').closest('details'))
|
||||
.not.toHaveAttribute('open')
|
||||
})
|
||||
@@ -1437,7 +1672,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText(/OpenCode 和 Continue 已随 GoodBuddy 内置/)
|
||||
await screen.findByText('GoodBuddy 内置 OpenCode')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
fireEvent.click(screen.getByText('高级设置'))
|
||||
expect(
|
||||
@@ -1497,7 +1732,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText(/OpenCode 和 Continue 已随 GoodBuddy 内置/)
|
||||
await screen.findByText('GoodBuddy 内置 OpenCode')
|
||||
fireEvent.click(screen.getByText('高级设置'))
|
||||
expect(screen.getByLabelText('OpenCode Server 地址')).toHaveValue('')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
@@ -2433,8 +2668,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(
|
||||
screen.getByText(/新导入的 Skill 默认启用/)
|
||||
).toHaveTextContent(
|
||||
'分配给直连模型、OpenCode 和 Continue'
|
||||
'分配给直连模型、OpenCode、Continue 和 DeepSeek Harness'
|
||||
)
|
||||
expect(
|
||||
screen.getByLabelText('DeepSeek Harness')
|
||||
).toBeChecked()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导入 Skill 目录' })
|
||||
)
|
||||
@@ -2633,10 +2871,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/自定义 MCP 当前仅用于直连模型/)
|
||||
screen.getByText(/自定义 MCP 可分配给直连模型或 DeepSeek Harness/)
|
||||
).toHaveTextContent('新建时默认分配给直连模型')
|
||||
expect(
|
||||
screen.getByText(/Runtime 自有 MCP 配置不在此处管理/)
|
||||
screen.getByText(/服务凭据不会进入 Harness Utility/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText('尚未配置 MCP Server')
|
||||
@@ -2663,6 +2901,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
).not.toBeChecked()
|
||||
expect(within(dialog).getByLabelText('模型')).toBeChecked()
|
||||
expect(
|
||||
within(dialog).getByLabelText('DeepSeek Harness')
|
||||
).not.toBeChecked()
|
||||
expect(
|
||||
within(dialog).queryByLabelText('OpenCode')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
@@ -25,11 +25,11 @@ import type {
|
||||
RuntimeSettingsInput,
|
||||
RuntimeModelSource
|
||||
} from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
defaultModelProfileId as builtInDefaultModelProfileId,
|
||||
defaultRuntimeSettings,
|
||||
isAgentRuntimeModelProtocol
|
||||
isAgentRuntimeModelProtocol,
|
||||
isDeepSeekHarnessModelProfile
|
||||
} from '../../shared/contracts'
|
||||
import { McpSettingsSection } from './McpSettingsSection'
|
||||
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
|
||||
@@ -59,7 +59,9 @@ import type {
|
||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||
|
||||
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
||||
type AgentRuntimeType = RuntimeConfigActionInput['runtime']
|
||||
type AgentRuntimeType =
|
||||
| RuntimeConfigActionInput['runtime']
|
||||
| 'deepseek-harness'
|
||||
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
||||
supportsImageInput: boolean
|
||||
apiKey: string
|
||||
@@ -140,7 +142,9 @@ function configuredRuntimeSettings(
|
||||
continueConfigPath: settings.continueConfigPath,
|
||||
workspacePath: settings.workspacePath,
|
||||
opencodeModelSource: settings.opencodeModelSource,
|
||||
continueModelSource: settings.continueModelSource
|
||||
continueModelSource: settings.continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
settings.deepseekHarnessModelSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +162,7 @@ function hydrateRuntimeSettings(
|
||||
defaultModelProfileId: (value: string) => void
|
||||
opencodeModelSource: (value: RuntimeModelSource) => void
|
||||
continueModelSource: (value: RuntimeModelSource) => void
|
||||
deepseekHarnessModelSource: (value: RuntimeModelSource) => void
|
||||
opencodeBaseUrl: (value: string) => void
|
||||
opencodeBinaryPath: (value: string) => void
|
||||
opencodeConfigPath: (value: string) => void
|
||||
@@ -205,6 +210,9 @@ function hydrateRuntimeSettings(
|
||||
setters.defaultModelProfileId(value.defaultModelProfileId)
|
||||
setters.opencodeModelSource(configured.opencodeModelSource)
|
||||
setters.continueModelSource(configured.continueModelSource)
|
||||
setters.deepseekHarnessModelSource(
|
||||
configured.deepseekHarnessModelSource ?? { kind: 'platform' }
|
||||
)
|
||||
setters.opencodeBaseUrl(configured.opencodeBaseUrl)
|
||||
setters.opencodeBinaryPath(configured.opencodeBinaryPath)
|
||||
setters.opencodeConfigPath(configured.opencodeConfigPath)
|
||||
@@ -241,7 +249,7 @@ function hydrateRuntimeSettings(
|
||||
}
|
||||
|
||||
type RuntimeConfigCardProps = {
|
||||
runtime: AgentRuntimeType
|
||||
runtime: RuntimeConfigActionInput['runtime']
|
||||
runtimeLabel: string
|
||||
description: string
|
||||
fileKind: Extract<
|
||||
@@ -351,6 +359,74 @@ function RuntimeConfigCard({
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeOverviewCard({
|
||||
detection,
|
||||
detectionLabel,
|
||||
detecting,
|
||||
modelConfiguration,
|
||||
recommendation,
|
||||
runtime
|
||||
}: {
|
||||
detection: AgentRuntimeDetection['opencode'] | undefined
|
||||
detectionLabel: string
|
||||
detecting: boolean
|
||||
modelConfiguration: string
|
||||
recommendation: string
|
||||
runtime: string
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const localizedDetail =
|
||||
detection?.available && detection.source
|
||||
? t(`runtime.detection.details.${detection.source}`, {
|
||||
runtime: detectionLabel,
|
||||
versionSuffix: detection.version ? ` ${detection.version}` : ''
|
||||
})
|
||||
: detection?.detail
|
||||
const status = detecting
|
||||
? t('runtime.detection.detecting')
|
||||
: detection?.available
|
||||
? t('runtime.detection.ready')
|
||||
: detection
|
||||
? t('runtime.detection.unavailable')
|
||||
: t('runtime.detection.notDetected')
|
||||
|
||||
return (
|
||||
<div className="runtime-note runtime-overview">
|
||||
<dl className="runtime-overview__details">
|
||||
<dt>{t('runtime.runtimeLabel')}</dt>
|
||||
<dd>{runtime}</dd>
|
||||
<dt>{t('runtime.modelConfigurationLabel')}</dt>
|
||||
<dd>{modelConfiguration}</dd>
|
||||
<dt>{t('runtime.detection.statusLabel')}</dt>
|
||||
<dd aria-atomic="true" aria-live="polite">
|
||||
{status}
|
||||
</dd>
|
||||
{!detecting && detection?.available && (
|
||||
<>
|
||||
<dt>{t('runtime.detection.pathLabel')}</dt>
|
||||
<dd className="runtime-overview__path">
|
||||
{detection.path}
|
||||
</dd>
|
||||
{detection.version && (
|
||||
<>
|
||||
<dt>{t('runtime.detection.versionLabel')}</dt>
|
||||
<dd>{detection.version}</dd>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!detecting && localizedDetail && (
|
||||
<>
|
||||
<dt>{t('runtime.detection.detailLabel')}</dt>
|
||||
<dd>{localizedDetail}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<p>{recommendation}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
presentation = 'modal',
|
||||
@@ -389,6 +465,8 @@ export function SettingsPanel({
|
||||
useState<RuntimeModelSource>({ kind: 'platform' })
|
||||
const [continueModelSource, setContinueModelSource] =
|
||||
useState<RuntimeModelSource>({ kind: 'platform' })
|
||||
const [deepseekHarnessModelSource, setDeepseekHarnessModelSource] =
|
||||
useState<RuntimeModelSource>({ kind: 'platform' })
|
||||
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(
|
||||
defaultRuntimeSettings.opencodeBaseUrl
|
||||
)
|
||||
@@ -495,6 +573,7 @@ export function SettingsPanel({
|
||||
defaultModelProfileId: setDefaultModelProfileId,
|
||||
opencodeModelSource: setOpencodeModelSource,
|
||||
continueModelSource: setContinueModelSource,
|
||||
deepseekHarnessModelSource: setDeepseekHarnessModelSource,
|
||||
opencodeBaseUrl: setOpencodeBaseUrl,
|
||||
opencodeBinaryPath: setOpencodeBinaryPath,
|
||||
opencodeConfigPath: setOpencodeConfigPath,
|
||||
@@ -714,6 +793,12 @@ export function SettingsPanel({
|
||||
profileInputs.find(
|
||||
(profile) => profile.id === defaultProfile.id
|
||||
) ?? profileInputs[0]!
|
||||
const normalizedDeepseekHarnessModelSource =
|
||||
deepseekHarnessModelSource.kind === 'profile' &&
|
||||
deepseekHarnessModelSource.profileId === defaultProfile.id &&
|
||||
defaultProfile.credentialSource === 'environment'
|
||||
? { kind: 'platform' as const }
|
||||
: deepseekHarnessModelSource
|
||||
const value = await window.goodbuddy.settings.updateRuntime({
|
||||
provider,
|
||||
modelBaseUrl: defaultProfileInput.baseUrl,
|
||||
@@ -758,6 +843,8 @@ export function SettingsPanel({
|
||||
defaultModelProfileId: defaultProfile.id,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
normalizedDeepseekHarnessModelSource,
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled
|
||||
})
|
||||
@@ -823,8 +910,12 @@ export function SettingsPanel({
|
||||
const runtimeSource =
|
||||
agentRuntimeType === 'opencode'
|
||||
? savedSettings.opencodeModelSource
|
||||
: savedSettings.continueModelSource
|
||||
const runtimeSelection: AgentRuntimeSelection =
|
||||
: agentRuntimeType === 'continue'
|
||||
? savedSettings.continueModelSource
|
||||
: savedSettings.deepseekHarnessModelSource ?? {
|
||||
kind: 'platform'
|
||||
}
|
||||
const runtimeSelection =
|
||||
runtimeSource.kind === 'profile'
|
||||
? {
|
||||
provider: agentRuntimeType,
|
||||
@@ -1017,6 +1108,24 @@ export function SettingsPanel({
|
||||
) {
|
||||
setContinueModelSource(runtimeFallback)
|
||||
}
|
||||
if (
|
||||
deepseekHarnessModelSource.kind === 'profile' &&
|
||||
deepseekHarnessModelSource.profileId === id
|
||||
) {
|
||||
const harnessFallback = remaining.find(
|
||||
(profile) =>
|
||||
profile.id === defaultModelProfileId &&
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
) ?? remaining.find(
|
||||
(profile) =>
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
)
|
||||
setDeepseekHarnessModelSource(
|
||||
harnessFallback
|
||||
? { kind: 'profile', profileId: harnessFallback.id }
|
||||
: { kind: 'platform' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const selectDefaultModelProfile = (
|
||||
@@ -1046,6 +1155,16 @@ export function SettingsPanel({
|
||||
) {
|
||||
setContinueModelSource(nextRuntimeSource)
|
||||
}
|
||||
if (
|
||||
deepseekHarnessModelSource.kind === 'profile' &&
|
||||
deepseekHarnessModelSource.profileId === previousDefaultProfileId &&
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
) {
|
||||
setDeepseekHarnessModelSource({
|
||||
kind: 'profile',
|
||||
profileId: profile.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const parseModelSource = (value: string): RuntimeModelSource =>
|
||||
@@ -1061,6 +1180,10 @@ export function SettingsPanel({
|
||||
profile: ModelProfileDraft
|
||||
): boolean => isAgentRuntimeModelProtocol(profile.protocol)
|
||||
|
||||
const isDeepseekHarnessCompatible = (
|
||||
profile: ModelProfileDraft
|
||||
): boolean => isDeepSeekHarnessModelProfile(profile)
|
||||
|
||||
const selectedModelProfile =
|
||||
modelProfiles.find(
|
||||
(profile) => profile.id === selectedModelProfileId
|
||||
@@ -1074,16 +1197,27 @@ export function SettingsPanel({
|
||||
modelProfiles.find((profile) =>
|
||||
isAgentRuntimeModelProtocol(profile.protocol)
|
||||
)
|
||||
const defaultDeepseekHarnessModelProfile =
|
||||
modelProfiles.find(
|
||||
(profile) =>
|
||||
profile.id === defaultModelProfileId &&
|
||||
isDeepseekHarnessCompatible(profile)
|
||||
) ??
|
||||
modelProfiles.find(isDeepseekHarnessCompatible)
|
||||
const activeRuntimeModelSource =
|
||||
agentRuntimeType === 'opencode'
|
||||
? opencodeModelSource
|
||||
: continueModelSource
|
||||
: agentRuntimeType === 'continue'
|
||||
? continueModelSource
|
||||
: deepseekHarnessModelSource
|
||||
const activeRuntimeModelProfile =
|
||||
activeRuntimeModelSource.kind === 'profile'
|
||||
? modelProfiles.find(
|
||||
(profile) =>
|
||||
profile.id === activeRuntimeModelSource.profileId &&
|
||||
isAgentRuntimeModelProtocol(profile.protocol)
|
||||
(agentRuntimeType === 'deepseek-harness'
|
||||
? isDeepseekHarnessCompatible(profile)
|
||||
: isAgentRuntimeModelProtocol(profile.protocol))
|
||||
)
|
||||
: undefined
|
||||
const savedRoleModelProfiles = (settings?.modelProfiles ?? [])
|
||||
@@ -1101,32 +1235,6 @@ export function SettingsPanel({
|
||||
? settings?.defaultModelProfileId
|
||||
: undefined
|
||||
|
||||
const detectionSummary = (
|
||||
value: AgentRuntimeDetection['opencode'] | undefined
|
||||
): React.JSX.Element => (
|
||||
<div className="credential-state" aria-live="polite">
|
||||
<TerminalSquare size={15} />
|
||||
<span>
|
||||
{value
|
||||
? value.available
|
||||
? [
|
||||
t('runtime.detection.ready'),
|
||||
value.path,
|
||||
value.version,
|
||||
value.detail
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: t('runtime.detection.notReady', {
|
||||
detail: value.detail
|
||||
})
|
||||
: detecting
|
||||
? t('runtime.detection.detecting')
|
||||
: t('runtime.detection.notDetected')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -1230,7 +1338,9 @@ export function SettingsPanel({
|
||||
runtime:
|
||||
agentRuntimeType === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Continue'
|
||||
: agentRuntimeType === 'continue'
|
||||
? 'Continue'
|
||||
: 'DeepSeek Harness'
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
@@ -1402,13 +1512,14 @@ export function SettingsPanel({
|
||||
onChange={setAgentRuntimeType}
|
||||
options={[
|
||||
{ label: 'OpenCode', value: 'opencode' },
|
||||
{ label: 'Continue', value: 'continue' }
|
||||
{ label: 'Continue', value: 'continue' },
|
||||
{
|
||||
label: t('runtime.deepseekHarness.selectorLabel'),
|
||||
value: 'deepseek-harness'
|
||||
}
|
||||
]}
|
||||
value={agentRuntimeType}
|
||||
/>
|
||||
<small>
|
||||
{t('runtime.selectorDescription')}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{agentRuntimeType === 'opencode' && (
|
||||
@@ -1420,37 +1531,39 @@ export function SettingsPanel({
|
||||
<small>{t('runtime.bundledDescription')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
<strong>{t('runtime.runtimeLabel')}</strong>
|
||||
{t('runtime.bundledRuntime', { runtime: 'OpenCode' })}
|
||||
<br />
|
||||
<strong>{t('runtime.modelConfigurationLabel')}</strong>
|
||||
{activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.ownConfiguration', {
|
||||
runtime: 'OpenCode'
|
||||
})
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
<RuntimeOverviewCard
|
||||
detection={detection?.opencode}
|
||||
detectionLabel="OpenCode"
|
||||
detecting={detecting}
|
||||
modelConfiguration={
|
||||
activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.ownConfiguration', {
|
||||
runtime: 'OpenCode'
|
||||
})
|
||||
: defaultTextModelProfile
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')}
|
||||
<br />
|
||||
{t('runtime.opencode.recommendation')}
|
||||
</div>
|
||||
: defaultTextModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')
|
||||
}
|
||||
recommendation={t('runtime.opencode.recommendation')}
|
||||
runtime={t('runtime.bundledRuntime', {
|
||||
runtime: 'OpenCode'
|
||||
})}
|
||||
/>
|
||||
<div className="runtime-note">
|
||||
{t('runtime.permissions')}
|
||||
</div>
|
||||
{detectionSummary(detection?.opencode)}
|
||||
<details className="settings-section">
|
||||
<summary>{t('runtime.advanced')}</summary>
|
||||
<p className="settings-panel__description">
|
||||
@@ -1650,37 +1763,39 @@ export function SettingsPanel({
|
||||
<small>{t('runtime.bundledDescription')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
<strong>{t('runtime.runtimeLabel')}</strong>
|
||||
{t('runtime.bundledRuntime', { runtime: 'Continue' })}
|
||||
<br />
|
||||
<strong>{t('runtime.modelConfigurationLabel')}</strong>
|
||||
{activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.ownConfiguration', {
|
||||
runtime: 'Continue'
|
||||
})
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
<RuntimeOverviewCard
|
||||
detection={detection?.continue}
|
||||
detectionLabel="Continue"
|
||||
detecting={detecting}
|
||||
modelConfiguration={
|
||||
activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.ownConfiguration', {
|
||||
runtime: 'Continue'
|
||||
})
|
||||
: defaultTextModelProfile
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')}
|
||||
<br />
|
||||
{t('runtime.continue.recommendation')}
|
||||
</div>
|
||||
: defaultTextModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
defaultTextModelProfile
|
||||
),
|
||||
model: defaultTextModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')
|
||||
}
|
||||
recommendation={t('runtime.continue.recommendation')}
|
||||
runtime={t('runtime.bundledRuntime', {
|
||||
runtime: 'Continue'
|
||||
})}
|
||||
/>
|
||||
<div className="runtime-note">
|
||||
{t('runtime.permissions')}
|
||||
</div>
|
||||
{detectionSummary(detection?.continue)}
|
||||
<details className="settings-section">
|
||||
<summary>{t('runtime.advanced')}</summary>
|
||||
<p className="settings-panel__description">
|
||||
@@ -1845,6 +1960,159 @@ export function SettingsPanel({
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{agentRuntimeType === 'deepseek-harness' && (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<TerminalSquare size={17} />
|
||||
<div>
|
||||
<strong>{t('runtime.deepseekHarness.title')}</strong>
|
||||
<small>
|
||||
{t('runtime.deepseekHarness.previewDescription')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-notice">
|
||||
{t('runtime.deepseekHarness.deepseekOnlyNotice')}
|
||||
</p>
|
||||
<RuntimeOverviewCard
|
||||
detection={detection?.deepseekHarness}
|
||||
detectionLabel="DeepSeek Harness"
|
||||
detecting={detecting}
|
||||
modelConfiguration={
|
||||
activeRuntimeModelSource.kind === 'platform'
|
||||
? t('runtime.deepseekHarness.platformSource')
|
||||
: activeRuntimeModelProfile
|
||||
? t('runtime.followGoodBuddy', {
|
||||
name: modelProfileDisplayName(
|
||||
activeRuntimeModelProfile
|
||||
),
|
||||
model: activeRuntimeModelProfile.modelName
|
||||
})
|
||||
: t('runtime.noCompatibleModel')
|
||||
}
|
||||
recommendation={t(
|
||||
'runtime.deepseekHarness.description'
|
||||
)}
|
||||
runtime={t('runtime.bundledRuntime', {
|
||||
runtime: 'DeepSeek Harness'
|
||||
})}
|
||||
/>
|
||||
<fieldset className="runtime-source-options">
|
||||
<legend>{t('runtime.sourceLegend')}</legend>
|
||||
<label>
|
||||
<input
|
||||
checked={
|
||||
deepseekHarnessModelSource.kind === 'profile'
|
||||
}
|
||||
disabled={!defaultDeepseekHarnessModelProfile}
|
||||
name="deepseek-harness-model-source"
|
||||
onChange={() => {
|
||||
if (defaultDeepseekHarnessModelProfile) {
|
||||
setDeepseekHarnessModelSource({
|
||||
kind: 'profile',
|
||||
profileId:
|
||||
defaultDeepseekHarnessModelProfile.id
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{t(
|
||||
'runtime.deepseekHarness.goodBuddySource'
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'runtime.deepseekHarness.goodBuddySourceDescription'
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
checked={
|
||||
deepseekHarnessModelSource.kind === 'platform'
|
||||
}
|
||||
name="deepseek-harness-model-source"
|
||||
onChange={() =>
|
||||
setDeepseekHarnessModelSource({
|
||||
kind: 'platform'
|
||||
})
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{t('runtime.deepseekHarness.platformSource')}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'runtime.deepseekHarness.platformSourceDescription'
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{deepseekHarnessModelSource.kind === 'profile' && (
|
||||
<label className="field">
|
||||
<span>{t('runtime.goodBuddyConnection')}</span>
|
||||
<select
|
||||
aria-label={`DeepSeek Harness ${t(
|
||||
'runtime.goodBuddyConnection'
|
||||
)}`}
|
||||
onChange={(event) =>
|
||||
setDeepseekHarnessModelSource(
|
||||
parseModelSource(event.target.value)
|
||||
)
|
||||
}
|
||||
value={deepseekHarnessModelSource.profileId}
|
||||
>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={
|
||||
!isDeepseekHarnessCompatible(profile)
|
||||
}
|
||||
key={profile.id}
|
||||
value={profile.id}
|
||||
>
|
||||
{modelProfileDisplayName(profile)}
|
||||
{isDeepseekHarnessCompatible(profile)
|
||||
? ''
|
||||
: t('runtime.incompatibleSuffix')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
{t(
|
||||
'runtime.deepseekHarness.connectionDescription'
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
)}
|
||||
<details className="settings-section">
|
||||
<summary>{t('runtime.advanced')}</summary>
|
||||
<p className="settings-panel__description">
|
||||
{t(
|
||||
'runtime.deepseekHarness.advancedDescription'
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={detecting}
|
||||
onClick={() => void detectRuntimes()}
|
||||
type="button"
|
||||
>
|
||||
{detecting
|
||||
? t('actions.detecting')
|
||||
: t('actions.redetectRuntime', {
|
||||
runtime: 'DeepSeek Harness'
|
||||
})}
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -2078,6 +2346,29 @@ export function SettingsPanel({
|
||||
) {
|
||||
setContinueModelSource(runtimeFallback)
|
||||
}
|
||||
if (
|
||||
protocol !== 'openai-chat-completions' &&
|
||||
deepseekHarnessModelSource.kind ===
|
||||
'profile' &&
|
||||
deepseekHarnessModelSource.profileId ===
|
||||
profile.id
|
||||
) {
|
||||
const harnessFallback =
|
||||
modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id !== profile.id &&
|
||||
candidate.protocol ===
|
||||
'openai-chat-completions'
|
||||
)
|
||||
setDeepseekHarnessModelSource(
|
||||
harnessFallback
|
||||
? {
|
||||
kind: 'profile',
|
||||
profileId: harnessFallback.id
|
||||
}
|
||||
: { kind: 'platform' }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
value={profile.protocol}
|
||||
@@ -2246,7 +2537,13 @@ export function SettingsPanel({
|
||||
: t('model.profile.incompatible'),
|
||||
openCodeCompatibility: isOpenCodeCompatible(profile)
|
||||
? t('model.profile.compatible')
|
||||
: t('model.profile.incompatibleImageProtocol')
|
||||
: t('model.profile.incompatibleImageProtocol'),
|
||||
deepseekHarnessCompatibility:
|
||||
isDeepseekHarnessCompatible(profile)
|
||||
? t('model.profile.compatible')
|
||||
: t(
|
||||
'model.profile.incompatibleHarnessProtocol'
|
||||
)
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,14 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
||||
</div>
|
||||
<div className="runtime-assignments">
|
||||
<small>{t('skills.assignedTo')}</small>
|
||||
{(['model', 'opencode', 'continue'] as RuntimeTarget[]).map(
|
||||
{(
|
||||
[
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
] as RuntimeTarget[]
|
||||
).map(
|
||||
(target) => (
|
||||
<label key={target}>
|
||||
<input
|
||||
|
||||
@@ -117,6 +117,8 @@ export const app = {
|
||||
switching: 'Switching…',
|
||||
picker: 'Runtime and model',
|
||||
directModels: 'Direct models',
|
||||
deepseekHarnessGroup:
|
||||
'DeepSeek Harness (Developer preview · DeepSeek only)',
|
||||
manage: 'Manage Runtime and model connections',
|
||||
errors: {
|
||||
readStatus: 'Failed to read Agent Runtime status',
|
||||
|
||||
@@ -31,6 +31,8 @@ export const integrations = {
|
||||
unavailableProfile: '{{name}} · {{modelName}} (unavailable)',
|
||||
missingProfile: 'The previous direct model no longer exists',
|
||||
noTextModels: 'No text models are available',
|
||||
deepseekHarnessOption:
|
||||
'DeepSeek Harness (Preview · DeepSeek only)',
|
||||
missingSelection:
|
||||
'The selected direct model no longer exists. Choose another model.',
|
||||
imageOnlySelection:
|
||||
@@ -142,7 +144,8 @@ export const integrations = {
|
||||
runtimeLabels: {
|
||||
model: 'Model',
|
||||
opencode: 'OpenCode',
|
||||
continue: 'Continue'
|
||||
continue: 'Continue',
|
||||
'deepseek-harness': 'DeepSeek Harness'
|
||||
},
|
||||
diagnosticStatuses: {
|
||||
available: 'Available',
|
||||
@@ -171,7 +174,7 @@ export const integrations = {
|
||||
custom: 'Custom MCP'
|
||||
},
|
||||
customNotice:
|
||||
'Custom MCP currently works only with direct models. New servers are assigned to direct models by default and loaded only in Execute mode. Runtime-owned MCP configuration is not managed here.',
|
||||
'Custom MCP can be assigned to direct models or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. GoodBuddy proxies Harness tools in the main process, so server credentials never enter the Harness Utility.',
|
||||
securityNotice:
|
||||
'Built-in tools are provided by GoodBuddy and are not MCP servers. Custom MCP servers and tools run with the current user’s permissions, so add only trusted services. Remote access tokens are encrypted in secure system storage, and tool calls still require GoodBuddy approval.',
|
||||
computer: {
|
||||
|
||||
@@ -35,8 +35,10 @@ export const settings = {
|
||||
},
|
||||
runtime: {
|
||||
label: 'Agent Runtime',
|
||||
navigationDescription: 'OpenCode, Continue, and workspace settings',
|
||||
description: 'OpenCode, Continue, and workspace settings'
|
||||
navigationDescription:
|
||||
'OpenCode, Continue, DeepSeek Harness, and workspace settings',
|
||||
description:
|
||||
'OpenCode, Continue, DeepSeek Harness, and workspace settings'
|
||||
},
|
||||
security: {
|
||||
label: 'Security and data',
|
||||
@@ -155,8 +157,19 @@ export const settings = {
|
||||
detection: {
|
||||
ready: 'Ready',
|
||||
notReady: 'Not ready · {{detail}}',
|
||||
unavailable: 'Not ready',
|
||||
detecting: 'Detecting…',
|
||||
notDetected: 'Not detected'
|
||||
notDetected: 'Not detected',
|
||||
statusLabel: 'Status:',
|
||||
pathLabel: 'Path:',
|
||||
versionLabel: 'Version:',
|
||||
detailLabel: 'Detection details:',
|
||||
details: {
|
||||
bundled: 'Bundled {{runtime}}{{versionSuffix}} is ready',
|
||||
configured: 'Custom {{runtime}}{{versionSuffix}} is ready',
|
||||
automatic:
|
||||
'Automatically detected {{runtime}}{{versionSuffix}}'
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
title: 'Default workspace',
|
||||
@@ -164,8 +177,6 @@ export const settings = {
|
||||
'Agents use this location only when the current project has no root folder',
|
||||
directoryLabel: 'Default workspace folder'
|
||||
},
|
||||
selectorDescription:
|
||||
'OpenCode and Continue are bundled with GoodBuddy. Configure a compatible direct text model to use them.',
|
||||
bundledDescription:
|
||||
'Bundled GoodBuddy Runtime that follows the text model connection by default',
|
||||
runtimeLabel: 'Runtime:',
|
||||
@@ -222,6 +233,25 @@ export const settings = {
|
||||
binaryPath: 'Continue executable path',
|
||||
missingConfigWarning:
|
||||
'Continue remains unavailable without a configuration file and will not load a remote default model anonymously.'
|
||||
},
|
||||
deepseekHarness: {
|
||||
selectorLabel: 'DeepSeek Harness (Preview)',
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: 'Developer preview · DeepSeek only',
|
||||
deepseekOnlyNotice:
|
||||
'DeepSeek Harness currently supports DeepSeek models only and is not intended for other model providers.',
|
||||
description:
|
||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
|
||||
goodBuddySource: 'Use a GoodBuddy model connection',
|
||||
goodBuddySourceDescription:
|
||||
'Only OpenAI-compatible Chat Completions connections are available. The connection must point to a DeepSeek model.',
|
||||
platformSource: 'Use platform DeepSeek environment settings',
|
||||
platformSourceDescription:
|
||||
'Reads platform-managed DeepSeek settings from the launch environment without exposing credentials to the renderer.',
|
||||
connectionDescription:
|
||||
'The internal GoodBuddy Harness Runtime currently accepts only the OpenAI-compatible Chat Completions protocol.',
|
||||
advancedDescription:
|
||||
'This Runtime always uses GoodBuddy’s bundled, version-pinned Host. It does not load external DSH plugins, marketplace packages, user profiles, or custom Hosts.'
|
||||
}
|
||||
},
|
||||
documentParsing: {
|
||||
@@ -317,6 +347,28 @@ export const settings = {
|
||||
slow: 'Slow'
|
||||
}
|
||||
},
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
英语: 'English',
|
||||
'50 种语言': '50 languages'
|
||||
},
|
||||
catalog: {
|
||||
'pp-ocrv6-tiny': {
|
||||
displayName: 'PP-OCRv6 Tiny',
|
||||
description:
|
||||
'The official lightweight PaddleOCR Chinese model for local CPU recognition of scanned PDFs and images.'
|
||||
},
|
||||
'pp-ocrv6-small': {
|
||||
displayName: 'PP-OCRv6 Small',
|
||||
description:
|
||||
'The official PaddleOCR 50-language model balancing recognition quality, speed, and local resource use.'
|
||||
},
|
||||
'pp-ocrv6-medium': {
|
||||
displayName: 'PP-OCRv6 Medium',
|
||||
description:
|
||||
'The official high-quality PaddleOCR 50-language model with slower recognition, higher memory use, and greater latency.'
|
||||
}
|
||||
},
|
||||
installed: 'Installed and verified',
|
||||
download: 'Download',
|
||||
downloadAndSelect: 'Download and enable',
|
||||
@@ -448,12 +500,14 @@ export const settings = {
|
||||
imageQualityDescription:
|
||||
'Used only for OpenAI-compatible image generation requests.',
|
||||
compatibilitySummary:
|
||||
'Direct model: {{directCapability}} · Continue: {{continueCompatibility}} · OpenCode: {{openCodeCompatibility}}',
|
||||
'Direct model: {{directCapability}} · Continue: {{continueCompatibility}} · OpenCode: {{openCodeCompatibility}} · DeepSeek Harness: {{deepseekHarnessCompatibility}}',
|
||||
textChat: 'Text chat',
|
||||
compatible: 'Compatible',
|
||||
incompatible: 'Incompatible',
|
||||
incompatibleImageProtocol:
|
||||
'Incompatible (image generation protocol is unsupported)',
|
||||
incompatibleHarnessProtocol:
|
||||
'Incompatible (Chat Completions only)',
|
||||
secureStorageWarning:
|
||||
'Secure system key storage is unavailable. Use an environment variable to avoid storing an API Key in plaintext.'
|
||||
},
|
||||
|
||||
@@ -251,7 +251,8 @@ export const settingsSections = {
|
||||
runtimeLabels: {
|
||||
model: 'Model',
|
||||
opencode: 'OpenCode',
|
||||
continue: 'Continue'
|
||||
continue: 'Continue',
|
||||
'deepseek-harness': 'DeepSeek Harness'
|
||||
},
|
||||
errors: {
|
||||
readFailed: 'Could not load Skills',
|
||||
@@ -264,7 +265,7 @@ export const settingsSections = {
|
||||
},
|
||||
listLabel: 'Skills list',
|
||||
notice:
|
||||
'Skills inject local capability instructions into selected targets without changing the Runtime’s own configuration. Newly imported Skills are enabled by default and assigned to the direct model, OpenCode, and Continue.',
|
||||
'Skills inject local capability instructions into selected targets without changing the Runtime’s own configuration. Newly imported Skills are enabled by default and assigned to the direct model, OpenCode, Continue, and DeepSeek Harness.',
|
||||
loading: 'Loading Skills…',
|
||||
source: {
|
||||
builtin: 'Built in',
|
||||
|
||||
@@ -113,6 +113,7 @@ export const app = {
|
||||
switching: '切换中…',
|
||||
picker: 'Runtime 和模型',
|
||||
directModels: '直连模型',
|
||||
deepseekHarnessGroup: 'DeepSeek Harness(开发者预览 · 仅 DeepSeek)',
|
||||
manage: '管理 Runtime 和模型连接',
|
||||
errors: {
|
||||
readStatus: 'Agent Runtime 状态读取失败',
|
||||
|
||||
@@ -26,6 +26,7 @@ export const integrations = {
|
||||
unavailableProfile: '{{name}} · {{modelName}}(不可用)',
|
||||
missingProfile: '原直连模型已不存在',
|
||||
noTextModels: '暂无可用文本模型',
|
||||
deepseekHarnessOption: 'DeepSeek Harness(预览 · 仅 DeepSeek)',
|
||||
missingSelection: '所选直连模型已不存在,请重新选择。',
|
||||
imageOnlySelection:
|
||||
'所选连接仅支持图片生成,请选择文本模型或 Agent Runtime。',
|
||||
@@ -132,7 +133,8 @@ export const integrations = {
|
||||
runtimeLabels: {
|
||||
model: '模型',
|
||||
opencode: 'OpenCode',
|
||||
continue: 'Continue'
|
||||
continue: 'Continue',
|
||||
'deepseek-harness': 'DeepSeek Harness'
|
||||
},
|
||||
diagnosticStatuses: {
|
||||
available: '可用',
|
||||
@@ -158,7 +160,7 @@ export const integrations = {
|
||||
custom: '自定义 MCP'
|
||||
},
|
||||
customNotice:
|
||||
'自定义 MCP 当前仅用于直连模型,新建时默认分配给直连模型,并仅在 Execute 模式加载。Runtime 自有 MCP 配置不在此处管理。',
|
||||
'自定义 MCP 可分配给直连模型或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Harness 工具由 GoodBuddy 主进程代理,服务凭据不会进入 Harness Utility。',
|
||||
securityNotice:
|
||||
'内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。',
|
||||
computer: {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const settings = {
|
||||
},
|
||||
runtime: {
|
||||
label: 'Agent Runtime',
|
||||
navigationDescription: 'OpenCode、Continue 与工作区',
|
||||
description: 'OpenCode、Continue 与工作区'
|
||||
navigationDescription: 'OpenCode、Continue、DeepSeek Harness 与工作区',
|
||||
description: 'OpenCode、Continue、DeepSeek Harness 与工作区'
|
||||
},
|
||||
security: {
|
||||
label: '安全与数据',
|
||||
@@ -141,16 +141,24 @@ export const settings = {
|
||||
detection: {
|
||||
ready: '已就绪',
|
||||
notReady: '尚未就绪 · {{detail}}',
|
||||
unavailable: '尚未就绪',
|
||||
detecting: '正在检测…',
|
||||
notDetected: '尚未检测'
|
||||
notDetected: '尚未检测',
|
||||
statusLabel: '状态:',
|
||||
pathLabel: '路径:',
|
||||
versionLabel: '版本:',
|
||||
detailLabel: '检测详情:',
|
||||
details: {
|
||||
bundled: '内置 {{runtime}}{{versionSuffix}} 已就绪',
|
||||
configured: '自定义 {{runtime}}{{versionSuffix}} 已就绪',
|
||||
automatic: '已自动检测到 {{runtime}}{{versionSuffix}}'
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
title: '默认工作区',
|
||||
description: '当前项目未设置根目录时,Agent 才使用此默认位置',
|
||||
directoryLabel: '默认工作区目录'
|
||||
},
|
||||
selectorDescription:
|
||||
'OpenCode 和 Continue 已随 GoodBuddy 内置;配置可兼容的直连文本模型后即可使用。',
|
||||
bundledDescription: 'GoodBuddy 内置 Runtime,默认跟随文本模型连接',
|
||||
runtimeLabel: 'Runtime:',
|
||||
modelConfigurationLabel: '模型配置:',
|
||||
@@ -203,6 +211,25 @@ export const settings = {
|
||||
binaryPath: 'Continue 可执行文件路径',
|
||||
missingConfigWarning:
|
||||
'未指定配置文件时 Continue 将保持不可用,不会匿名加载远程默认模型。'
|
||||
},
|
||||
deepseekHarness: {
|
||||
selectorLabel: 'DeepSeek Harness(预览)',
|
||||
title: 'DeepSeek Harness',
|
||||
previewDescription: '开发者预览 · 仅支持 DeepSeek',
|
||||
deepseekOnlyNotice:
|
||||
'DeepSeek Harness 当前仅支持 DeepSeek 模型,不适用于其他模型提供商。',
|
||||
description:
|
||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
|
||||
goodBuddySource: '使用 GoodBuddy 模型连接',
|
||||
goodBuddySourceDescription:
|
||||
'只能选择 OpenAI 兼容 Chat Completions 连接;该连接必须指向 DeepSeek 模型。',
|
||||
platformSource: '使用平台 DeepSeek 环境配置',
|
||||
platformSourceDescription:
|
||||
'从启动环境读取平台管理的 DeepSeek 配置,不会在渲染进程中显示凭据。',
|
||||
connectionDescription:
|
||||
'GoodBuddy 内部 Harness Runtime 目前仅接受 OpenAI 兼容 Chat Completions 协议。',
|
||||
advancedDescription:
|
||||
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载外部 DSH 插件、市场包、用户 profile 或自定义 Host。'
|
||||
}
|
||||
},
|
||||
documentParsing: {
|
||||
@@ -287,6 +314,28 @@ export const settings = {
|
||||
slow: '慢'
|
||||
}
|
||||
},
|
||||
languages: {
|
||||
中文: '中文',
|
||||
英语: '英语',
|
||||
'50 种语言': '50 种语言'
|
||||
},
|
||||
catalog: {
|
||||
'pp-ocrv6-tiny': {
|
||||
displayName: 'PP-OCRv6 Tiny',
|
||||
description:
|
||||
'PaddleOCR 官方轻量中文 OCR 模型,适合扫描 PDF 和图片的本地 CPU 识别。'
|
||||
},
|
||||
'pp-ocrv6-small': {
|
||||
displayName: 'PP-OCRv6 Small',
|
||||
description:
|
||||
'PaddleOCR 官方 50 语言 OCR 模型,在识别质量、速度和本地资源占用之间取得平衡。'
|
||||
},
|
||||
'pp-ocrv6-medium': {
|
||||
displayName: 'PP-OCRv6 Medium',
|
||||
description:
|
||||
'PaddleOCR 官方 50 语言高质量 OCR 模型,识别较慢,并需要更多内存且具有更高延迟。'
|
||||
}
|
||||
},
|
||||
installed: '已安装并校验',
|
||||
download: '下载',
|
||||
downloadAndSelect: '下载并启用',
|
||||
@@ -410,11 +459,13 @@ export const settings = {
|
||||
},
|
||||
imageQualityDescription: '仅用于 OpenAI 兼容图像生成请求。',
|
||||
compatibilitySummary:
|
||||
'直连模型:{{directCapability}} · Continue:{{continueCompatibility}} · OpenCode:{{openCodeCompatibility}}',
|
||||
'直连模型:{{directCapability}} · Continue:{{continueCompatibility}} · OpenCode:{{openCodeCompatibility}} · DeepSeek Harness:{{deepseekHarnessCompatibility}}',
|
||||
textChat: '文本对话',
|
||||
compatible: '兼容',
|
||||
incompatible: '不兼容',
|
||||
incompatibleImageProtocol: '不兼容(不支持图像生成协议)',
|
||||
incompatibleHarnessProtocol:
|
||||
'不兼容(仅支持 Chat Completions)',
|
||||
secureStorageWarning:
|
||||
'当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供 API Key。'
|
||||
},
|
||||
|
||||
@@ -233,7 +233,8 @@ export const settingsSections = {
|
||||
runtimeLabels: {
|
||||
model: '模型',
|
||||
opencode: 'OpenCode',
|
||||
continue: 'Continue'
|
||||
continue: 'Continue',
|
||||
'deepseek-harness': 'DeepSeek Harness'
|
||||
},
|
||||
errors: {
|
||||
readFailed: '读取 Skills 失败',
|
||||
@@ -246,7 +247,7 @@ export const settingsSections = {
|
||||
},
|
||||
listLabel: 'Skills 列表',
|
||||
notice:
|
||||
'Skill 以本地能力说明注入所选目标,不会写入 Runtime 自有配置。新导入的 Skill 默认启用,并分配给直连模型、OpenCode 和 Continue。',
|
||||
'Skill 以本地能力说明注入所选目标,不会写入 Runtime 自有配置。新导入的 Skill 默认启用,并分配给直连模型、OpenCode、Continue 和 DeepSeek Harness。',
|
||||
loading: '正在读取 Skills…',
|
||||
source: {
|
||||
builtin: '内置',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider
|
||||
} from './runtime-selection'
|
||||
|
||||
const harnessProfileId = '00000000-0000-4000-8000-000000000071'
|
||||
|
||||
function harnessSettings(
|
||||
source: { kind: 'platform' } | { kind: 'profile'; profileId: string }
|
||||
): RuntimeSettings {
|
||||
return {
|
||||
provider: 'deepseek-harness',
|
||||
deepseekHarnessModelSource: source
|
||||
} as RuntimeSettings
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness runtime selection', () => {
|
||||
it('uses the configured Chat Completions profile', () => {
|
||||
const selection = getRuntimeSelectionForProvider(
|
||||
'deepseek-harness',
|
||||
harnessSettings({
|
||||
kind: 'profile',
|
||||
profileId: harnessProfileId
|
||||
})
|
||||
)
|
||||
|
||||
expect(selection).toEqual({
|
||||
provider: 'deepseek-harness',
|
||||
profileId: harnessProfileId
|
||||
} satisfies Record<string, string>)
|
||||
})
|
||||
|
||||
it('uses platform settings without a profile id', () => {
|
||||
const settings = harnessSettings({ kind: 'platform' })
|
||||
|
||||
expect(getDefaultRuntimeSelection(settings)).toEqual({
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
|
||||
export function getRuntimeSelectionForProvider(
|
||||
provider: 'model' | 'opencode' | 'continue',
|
||||
provider: 'model' | 'opencode' | 'continue' | 'deepseek-harness',
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
if (provider === 'model') {
|
||||
@@ -14,7 +14,9 @@ export function getRuntimeSelectionForProvider(
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: settings.continueModelSource
|
||||
: provider === 'continue'
|
||||
? settings.continueModelSource
|
||||
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
|
||||
return {
|
||||
provider,
|
||||
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
|
||||
@@ -24,12 +26,14 @@ export function getRuntimeSelectionForProvider(
|
||||
export function getDefaultRuntimeSelection(
|
||||
settings: RuntimeSettings
|
||||
): AgentRuntimeSelection {
|
||||
const provider = settings.provider
|
||||
if (
|
||||
settings.provider === 'model' ||
|
||||
settings.provider === 'opencode' ||
|
||||
settings.provider === 'continue'
|
||||
provider === 'model' ||
|
||||
provider === 'opencode' ||
|
||||
provider === 'continue' ||
|
||||
provider === 'deepseek-harness'
|
||||
) {
|
||||
return getRuntimeSelectionForProvider(settings.provider, settings)
|
||||
return getRuntimeSelectionForProvider(provider, settings)
|
||||
}
|
||||
return settings.opencodeBaseUrl || settings.opencodeEmbedded
|
||||
? getRuntimeSelectionForProvider('opencode', settings)
|
||||
|
||||
@@ -4903,6 +4903,37 @@ button > svg {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.runtime-overview {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.runtime-overview__details {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-overview__details dt {
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.runtime-overview__details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-overview__path {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.runtime-overview > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-service-form {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -19,13 +19,14 @@ const controlCharacterFreeString = (maximumLength: number) =>
|
||||
export const runtimeTargetSchema = z.enum([
|
||||
'model',
|
||||
'opencode',
|
||||
'continue'
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
])
|
||||
export type RuntimeTarget = z.infer<typeof runtimeTargetSchema>
|
||||
|
||||
export const capabilityAssignmentsSchema = z
|
||||
.array(runtimeTargetSchema)
|
||||
.max(3)
|
||||
.max(4)
|
||||
.refine(
|
||||
(assignments) => new Set(assignments).size === assignments.length,
|
||||
'Runtime 分配不能重复'
|
||||
|
||||
+54
-3
@@ -234,7 +234,8 @@ export const runtimeProviderSchema = z.enum([
|
||||
'auto',
|
||||
'model',
|
||||
'opencode',
|
||||
'continue'
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
])
|
||||
|
||||
export const toolApprovalPolicySchema = z.enum([
|
||||
@@ -391,6 +392,26 @@ export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
|
||||
.strict()
|
||||
])
|
||||
|
||||
export function isDeepSeekHarnessModelProfile(
|
||||
profile: Pick<
|
||||
ModelConnectionSettings,
|
||||
'baseUrl' | 'protocol' | 'authentication'
|
||||
>
|
||||
): boolean {
|
||||
if (
|
||||
profile.protocol !== 'openai-chat-completions' ||
|
||||
profile.authentication !== 'api-key'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new URL(profile.baseUrl).hostname.toLowerCase() ===
|
||||
'api.deepseek.com'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const runtimeSettingsInputSchema = z
|
||||
.object({
|
||||
provider: runtimeProviderSchema,
|
||||
@@ -440,6 +461,8 @@ export const runtimeSettingsInputSchema = z
|
||||
defaultModelProfileId: modelProfileIdSchema.optional(),
|
||||
opencodeModelSource: runtimeModelSourceSchema.optional(),
|
||||
continueModelSource: runtimeModelSourceSchema.optional(),
|
||||
deepseekHarnessModelSource: runtimeModelSourceSchema
|
||||
.default({ kind: 'platform' }),
|
||||
toolApproval: toolApprovalPolicySchema
|
||||
}).strict()
|
||||
.superRefine((settings, context) => {
|
||||
@@ -505,7 +528,8 @@ export const runtimeSettingsInputSchema = z
|
||||
}
|
||||
for (const [key, source] of [
|
||||
['opencodeModelSource', settings.opencodeModelSource],
|
||||
['continueModelSource', settings.continueModelSource]
|
||||
['continueModelSource', settings.continueModelSource],
|
||||
['deepseekHarnessModelSource', settings.deepseekHarnessModelSource]
|
||||
] as const) {
|
||||
if (source?.kind === 'profile' && !ids.has(source.profileId)) {
|
||||
context.addIssue({
|
||||
@@ -551,6 +575,24 @@ export const runtimeSettingsInputSchema = z
|
||||
'Continue 独立模型连接仅支持文本对话协议,不支持图像生成协议'
|
||||
})
|
||||
}
|
||||
const deepseekHarnessSource = settings.deepseekHarnessModelSource
|
||||
const deepseekHarnessProfile =
|
||||
deepseekHarnessSource?.kind === 'profile'
|
||||
? settings.modelProfiles.find(
|
||||
(profile) => profile.id === deepseekHarnessSource.profileId
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
deepseekHarnessProfile &&
|
||||
!isDeepSeekHarnessModelProfile(deepseekHarnessProfile)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['deepseekHarnessModelSource'],
|
||||
message:
|
||||
'DeepSeek Harness 独立模型连接仅支持 api.deepseek.com 的 OpenAI Chat Completions 协议'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
settings.opencodeBaseUrl &&
|
||||
@@ -615,6 +657,7 @@ export type ConfiguredRuntimeSettings = {
|
||||
workspacePath: string
|
||||
opencodeModelSource: RuntimeModelSource
|
||||
continueModelSource: RuntimeModelSource
|
||||
deepseekHarnessModelSource?: RuntimeModelSource
|
||||
}
|
||||
|
||||
export type RuntimeSettings = {
|
||||
@@ -659,6 +702,7 @@ export type RuntimeSettings = {
|
||||
defaultModelProfileId: string
|
||||
opencodeModelSource: RuntimeModelSource
|
||||
continueModelSource: RuntimeModelSource
|
||||
deepseekHarnessModelSource?: RuntimeModelSource
|
||||
secureStorageAvailable: boolean
|
||||
toolApproval: RuntimeSettingsInput['toolApproval']
|
||||
configured?: ConfiguredRuntimeSettings
|
||||
@@ -716,7 +760,12 @@ export type WindowCaptureOption = {
|
||||
}
|
||||
|
||||
export type AgentRuntimeStatus = {
|
||||
id: 'setup' | 'model' | 'opencode' | 'continue'
|
||||
id:
|
||||
| 'setup'
|
||||
| 'model'
|
||||
| 'opencode'
|
||||
| 'continue'
|
||||
| 'deepseek-harness'
|
||||
label: string
|
||||
available: boolean
|
||||
detail: string
|
||||
@@ -729,6 +778,7 @@ export type RuntimeBinaryDetection =
|
||||
available: true
|
||||
path: string
|
||||
version?: string
|
||||
source?: 'bundled' | 'configured' | 'automatic'
|
||||
detail: string
|
||||
}
|
||||
| {
|
||||
@@ -741,6 +791,7 @@ export type RuntimeBinaryDetection =
|
||||
export type AgentRuntimeDetection = {
|
||||
opencode: RuntimeBinaryDetection
|
||||
continue: RuntimeBinaryDetection
|
||||
deepseekHarness: RuntimeBinaryDetection
|
||||
}
|
||||
|
||||
export const approvalDecisionSchema = z.enum([
|
||||
|
||||
@@ -23,6 +23,12 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
|
||||
provider: z.literal('continue'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('deepseek-harness'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
@@ -34,6 +40,7 @@ export type AgentRuntimeSelection = z.infer<
|
||||
export type RuntimeSelectionRepairSettings = {
|
||||
modelProfiles: ReadonlyArray<{
|
||||
id: string
|
||||
baseUrl?: string
|
||||
protocol?: string
|
||||
authentication?: 'api-key' | 'none'
|
||||
apiKeyConfigured?: boolean
|
||||
@@ -45,6 +52,9 @@ export type RuntimeSelectionRepairSettings = {
|
||||
continueModelSource:
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
deepseekHarnessModelSource?:
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
}
|
||||
|
||||
type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number]
|
||||
@@ -61,6 +71,25 @@ export function isChannelModelProfileUsable(
|
||||
)
|
||||
}
|
||||
|
||||
function isDeepSeekHarnessRepairProfileUsable(
|
||||
profile: ChannelModelProfile
|
||||
): boolean {
|
||||
if (
|
||||
profile.protocol !== 'openai-chat-completions' ||
|
||||
profile.authentication !== 'api-key' ||
|
||||
profile.apiKeyConfigured === false ||
|
||||
!profile.baseUrl
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new URL(profile.baseUrl).hostname.toLowerCase() ===
|
||||
'api.deepseek.com'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function repairChannelRuntimeSelection(
|
||||
selection: AgentRuntimeSelection,
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
@@ -86,6 +115,21 @@ export function repairChannelRuntimeSelection(
|
||||
) {
|
||||
return { provider: selection.provider }
|
||||
}
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
if (
|
||||
repaired.provider === 'deepseek-harness' &&
|
||||
repaired.profileId
|
||||
) {
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === repaired.profileId
|
||||
)
|
||||
if (profile && isDeepSeekHarnessRepairProfileUsable(profile)) {
|
||||
return repaired
|
||||
}
|
||||
}
|
||||
return { provider: 'deepseek-harness' }
|
||||
}
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
if (repaired.provider !== 'model') {
|
||||
return repaired
|
||||
@@ -120,7 +164,9 @@ export function repairAgentRuntimeSelection(
|
||||
const source =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: settings.continueModelSource
|
||||
: selection.provider === 'continue'
|
||||
? settings.continueModelSource
|
||||
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(source.kind === 'profile'
|
||||
|
||||
Reference in New Issue
Block a user