feat: add DeepSeek Harness runtime
This commit is contained in:
@@ -179,3 +179,13 @@ git push github "$tag"
|
||||
6. OpenCode 与 Continue 的权限边界、取消和超时。
|
||||
7. 智能心跳的创建、暂停、恢复和历史记录。
|
||||
8. 应用退出后无残留 Runtime 子进程。
|
||||
|
||||
DeepSeek Harness 的 Electron Utility Host 可单独执行无模型、无凭据冒烟测试:
|
||||
|
||||
```bash
|
||||
npm run smoke:deepseek-harness
|
||||
```
|
||||
|
||||
该命令先生成 production bundle,再从 CommonJS Electron 主入口启动实际
|
||||
`utilityProcess`,等待固定 Host 完成沙箱探测与内部 ready 握手。它不会发起
|
||||
模型请求,也不会读取或传递 API Key。
|
||||
|
||||
@@ -24,6 +24,11 @@ const {
|
||||
sep
|
||||
} = require('node:path')
|
||||
const { finished } = require('node:stream/promises')
|
||||
const {
|
||||
extractFile,
|
||||
listPackage,
|
||||
statFile
|
||||
} = require('@electron/asar')
|
||||
const { Zip, ZipDeflate } = require('fflate')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
|
||||
@@ -35,6 +40,23 @@ const productName = packageJson.build?.productName ?? packageJson.name
|
||||
const releaseRoot = join(root, 'dist', 'release')
|
||||
const manifestName = 'release-manifest.json'
|
||||
const portableMarkerName = '.goodbuddy-portable.json'
|
||||
const harnessHostEntry =
|
||||
'out/main/deepseek-harness-host-bootstrap.js'
|
||||
const harnessBundleManifest = 'out/main/package.json'
|
||||
const harnessPackageVersions = {
|
||||
'@deepseek-ai/dsh-agent': '0.1.0-rc.6',
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': '0.1.0-rc.6',
|
||||
'@deepseek-ai/node-addon-landlock-run': '0.1.1',
|
||||
'node-pty': '1.1.0'
|
||||
}
|
||||
const koffiVersion = '3.1.4'
|
||||
const harnessLicenseFiles = [
|
||||
'agent-client-protocol-Apache-2.0.txt',
|
||||
'deepseek-cordis-MIT.txt',
|
||||
'deepseek-harness-MIT.txt',
|
||||
'koffi-MIT.txt',
|
||||
'node-pty-MIT.txt'
|
||||
]
|
||||
const portableRequiredFiles = [
|
||||
`${productName}.exe`,
|
||||
'resources/app.asar',
|
||||
@@ -359,6 +381,326 @@ function assertFile(filePath, description) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAsarEntry(filePath) {
|
||||
return filePath.split('/').join(sep)
|
||||
}
|
||||
|
||||
function asarEntryMetadata(
|
||||
asarPath,
|
||||
entryNames,
|
||||
filePath,
|
||||
description,
|
||||
statAsarFile = statFile
|
||||
) {
|
||||
const entry = normalizeAsarEntry(filePath)
|
||||
if (!entryNames.has(`${sep}${entry}`)) {
|
||||
throw new Error(`${description}缺失:${filePath}`)
|
||||
}
|
||||
return statAsarFile(asarPath, entry)
|
||||
}
|
||||
|
||||
function assertAsarEntry(entryNames, filePath, description) {
|
||||
const entry = normalizeAsarEntry(filePath)
|
||||
if (!entryNames.has(`${sep}${entry}`)) {
|
||||
throw new Error(`${description}缺失:${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertBinaryArchitecture(filePath, expected, description) {
|
||||
assertFile(filePath, description)
|
||||
const actual = binaryArchitecture(filePath)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${description}架构错误:期望 ${expected},实际 ${actual ?? '未知'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function targetHarnessPaths(options) {
|
||||
const platformName = {
|
||||
windows: 'win32',
|
||||
macos: 'darwin',
|
||||
linux: 'linux'
|
||||
}[options.platform]
|
||||
const koffiPackage = `@koromix/koffi-${platformName}-${options.arch}`
|
||||
const koffiBinary = {
|
||||
windows: `win32_${options.arch}/koffi.node`,
|
||||
macos: `darwin_${options.arch}/koffi.node`,
|
||||
linux: `linux_${options.arch}/koffi.node`
|
||||
}[options.platform]
|
||||
return {
|
||||
koffiPackage,
|
||||
koffiBinary,
|
||||
nodePtyBinary:
|
||||
options.platform === 'linux'
|
||||
? 'build/Release/pty.node'
|
||||
: `prebuilds/${platformName}-${options.arch}/pty.node`,
|
||||
nodePtyDirectory: `${platformName}-${options.arch}`,
|
||||
landlockPackage:
|
||||
options.platform === 'linux'
|
||||
? `@deepseek-ai/node-addon-landlock-run-linux-${options.arch}`
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function verifyHarnessPackage(
|
||||
resources,
|
||||
options,
|
||||
dependencies = {}
|
||||
) {
|
||||
const asarPath = join(resources, 'app.asar')
|
||||
const unpackedRoot = join(resources, 'app.asar.unpacked')
|
||||
const listAsarEntries = dependencies.listPackage ?? listPackage
|
||||
const statAsarFile = dependencies.statFile ?? statFile
|
||||
const extractAsarFile = dependencies.extractFile ?? extractFile
|
||||
const entries = new Set(listAsarEntries(asarPath))
|
||||
const target = targetHarnessPaths(options)
|
||||
|
||||
assertAsarEntry(entries, harnessHostEntry, 'DeepSeek Harness Host')
|
||||
const readJson = (filePath, description) => {
|
||||
const metadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
filePath,
|
||||
description,
|
||||
statAsarFile
|
||||
)
|
||||
if ('files' in metadata || 'link' in metadata) {
|
||||
throw new Error(`${description}类型错误:${filePath}`)
|
||||
}
|
||||
return JSON.parse(
|
||||
extractAsarFile(asarPath, normalizeAsarEntry(filePath))
|
||||
)
|
||||
}
|
||||
const bundleManifest = readJson(
|
||||
harnessBundleManifest,
|
||||
'DeepSeek Harness bundle 元数据'
|
||||
)
|
||||
if (
|
||||
bundleManifest.name !== '@deepseek-ai/dsh-llm' ||
|
||||
bundleManifest.version !==
|
||||
harnessPackageVersions['@deepseek-ai/dsh-agent']
|
||||
) {
|
||||
throw new Error('DeepSeek Harness bundle 元数据错误')
|
||||
}
|
||||
assertFile(
|
||||
join(unpackedRoot, ...harnessBundleManifest.split('/')),
|
||||
'DeepSeek Harness 可执行 bundle 元数据'
|
||||
)
|
||||
assertFile(
|
||||
join(unpackedRoot, ...harnessHostEntry.split('/')),
|
||||
'DeepSeek Harness 可执行 Host'
|
||||
)
|
||||
const harnessLlmChunk = [...entries]
|
||||
.map((entry) => entry.slice(1).split(sep).join('/'))
|
||||
.find((entry) =>
|
||||
/^out\/main\/chunks\/deepseek-harness-llm-[^/]+\.js$/u.test(
|
||||
entry
|
||||
)
|
||||
)
|
||||
if (!harnessLlmChunk) {
|
||||
throw new Error('DeepSeek Harness LLM chunk缺失')
|
||||
}
|
||||
const harnessLlmSource = extractAsarFile(
|
||||
asarPath,
|
||||
normalizeAsarEntry(harnessLlmChunk)
|
||||
).toString('utf8')
|
||||
const requiredChunkNames = new Set([
|
||||
...[
|
||||
...harnessLlmSource.matchAll(
|
||||
/import\(["']\.\/([^/"']+\.js)["']\)/gu
|
||||
)
|
||||
].map((match) => match[1]),
|
||||
...[...entries]
|
||||
.map((entry) => entry.slice(1).split(sep).join('/'))
|
||||
.filter((entry) =>
|
||||
/^out\/main\/chunks\/[^/]+\.js$/u.test(entry)
|
||||
)
|
||||
.map((entry) => entry.slice('out/main/chunks/'.length))
|
||||
])
|
||||
if (requiredChunkNames.size === 0) {
|
||||
throw new Error('DeepSeek Harness LLM lazy chunk closure缺失')
|
||||
}
|
||||
for (const chunkName of requiredChunkNames) {
|
||||
const chunkPath = `out/main/chunks/${chunkName}`
|
||||
const metadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
chunkPath,
|
||||
'DeepSeek Harness module chunk',
|
||||
statAsarFile
|
||||
)
|
||||
if (!('unpacked' in metadata) || !metadata.unpacked) {
|
||||
throw new Error(
|
||||
`DeepSeek Harness module chunk未从 ASAR 解包:${chunkPath}`
|
||||
)
|
||||
}
|
||||
assertFile(
|
||||
join(unpackedRoot, ...chunkPath.split('/')),
|
||||
'DeepSeek Harness 可执行 module chunk'
|
||||
)
|
||||
}
|
||||
for (const [packageName, expectedVersion] of Object.entries(
|
||||
harnessPackageVersions
|
||||
)) {
|
||||
const manifest = readJson(
|
||||
`node_modules/${packageName}/package.json`,
|
||||
`${packageName} 元数据`
|
||||
)
|
||||
if (manifest.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${packageName} 版本错误:期望 ${expectedVersion},实际 ${String(manifest.version)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const targetKoffiManifest = readJson(
|
||||
`node_modules/${target.koffiPackage}/package.json`,
|
||||
`${target.koffiPackage} 元数据`
|
||||
)
|
||||
if (targetKoffiManifest.version !== koffiVersion) {
|
||||
throw new Error(
|
||||
`${target.koffiPackage} 版本错误:期望 ${koffiVersion},实际 ${String(targetKoffiManifest.version)}`
|
||||
)
|
||||
}
|
||||
|
||||
const ptyBinary = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
...target.nodePtyBinary.split('/')
|
||||
)
|
||||
const koffiBinary = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
...target.koffiPackage.split('/'),
|
||||
...target.koffiBinary.split('/')
|
||||
)
|
||||
assertBinaryArchitecture(
|
||||
ptyBinary,
|
||||
options.arch,
|
||||
'DeepSeek Harness node-pty'
|
||||
)
|
||||
const nodePtyMetadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
`node_modules/node-pty/${target.nodePtyBinary}`,
|
||||
'DeepSeek Harness node-pty 元数据',
|
||||
statAsarFile
|
||||
)
|
||||
const koffiMetadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
`node_modules/${target.koffiPackage}/${target.koffiBinary}`,
|
||||
'DeepSeek Harness Koffi 元数据',
|
||||
statAsarFile
|
||||
)
|
||||
for (const [metadata, description] of [
|
||||
[nodePtyMetadata, 'DeepSeek Harness node-pty'],
|
||||
[koffiMetadata, 'DeepSeek Harness Koffi']
|
||||
]) {
|
||||
if (!('unpacked' in metadata) || !metadata.unpacked) {
|
||||
throw new Error(`${description}未从 ASAR 解包`)
|
||||
}
|
||||
}
|
||||
assertBinaryArchitecture(
|
||||
koffiBinary,
|
||||
options.arch,
|
||||
'DeepSeek Harness Koffi'
|
||||
)
|
||||
|
||||
if (options.platform === 'darwin') {
|
||||
const helper = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
target.nodePtyDirectory,
|
||||
'spawn-helper'
|
||||
)
|
||||
assertFile(helper, 'DeepSeek Harness node-pty spawn-helper')
|
||||
if ((statSync(helper).mode & 0o111) === 0) {
|
||||
throw new Error(
|
||||
`DeepSeek Harness node-pty spawn-helper 不可执行:${helper}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (target.landlockPackage) {
|
||||
const targetLandlockManifest = readJson(
|
||||
`node_modules/${target.landlockPackage}/package.json`,
|
||||
`${target.landlockPackage} 元数据`
|
||||
)
|
||||
if (
|
||||
targetLandlockManifest.version !==
|
||||
harnessPackageVersions[
|
||||
'@deepseek-ai/node-addon-landlock-run'
|
||||
]
|
||||
) {
|
||||
throw new Error(
|
||||
`${target.landlockPackage} 版本错误:期望 ${harnessPackageVersions['@deepseek-ai/node-addon-landlock-run']},实际 ${String(targetLandlockManifest.version)}`
|
||||
)
|
||||
}
|
||||
const launcher = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
...target.landlockPackage.split('/'),
|
||||
'bin',
|
||||
'landlock-run'
|
||||
)
|
||||
assertBinaryArchitecture(
|
||||
launcher,
|
||||
options.arch,
|
||||
'DeepSeek Harness Landlock launcher'
|
||||
)
|
||||
const launcherMetadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
`node_modules/${target.landlockPackage}/bin/landlock-run`,
|
||||
'DeepSeek Harness Landlock launcher 元数据',
|
||||
statAsarFile
|
||||
)
|
||||
if (
|
||||
!('unpacked' in launcherMetadata) ||
|
||||
!launcherMetadata.unpacked
|
||||
) {
|
||||
throw new Error(
|
||||
'DeepSeek Harness Landlock launcher 未从 ASAR 解包'
|
||||
)
|
||||
}
|
||||
if ((statSync(launcher).mode & 0o111) === 0) {
|
||||
throw new Error(
|
||||
`DeepSeek Harness Landlock launcher 不可执行:${launcher}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.platform === 'windows') {
|
||||
assertAsarEntry(
|
||||
entries,
|
||||
'node_modules/@deepseek-ai/dsh-sandbox-windows-acl/lib/runner.js',
|
||||
'DeepSeek Harness Windows ACL runner'
|
||||
)
|
||||
assertFile(
|
||||
join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
'@deepseek-ai',
|
||||
'dsh-sandbox-windows-acl',
|
||||
'lib',
|
||||
'runner.js'
|
||||
),
|
||||
'DeepSeek Harness 可执行 Windows ACL runner'
|
||||
)
|
||||
}
|
||||
|
||||
for (const license of harnessLicenseFiles) {
|
||||
assertFile(
|
||||
join(resources, 'licenses', license),
|
||||
'DeepSeek Harness 许可证'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyUnpackedOutput(directory, options) {
|
||||
const definition = platformDefinitions[options.platform]
|
||||
const unpackedDirectory = findUnpackedDirectory(
|
||||
@@ -387,6 +729,7 @@ function verifyUnpackedOutput(directory, options) {
|
||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||
'Continue Runtime'
|
||||
)
|
||||
verifyHarnessPackage(resources, options)
|
||||
for (const [filePath, label] of [
|
||||
[applicationExecutable, '应用主程序'],
|
||||
[runtimeExecutable, 'OpenCode Runtime']
|
||||
@@ -992,6 +1335,8 @@ module.exports = {
|
||||
parseArguments,
|
||||
platformDefinitions,
|
||||
replaceOutput,
|
||||
verifyHarnessPackage,
|
||||
verifyUnpackedOutput,
|
||||
verifyArtifacts,
|
||||
verifyArtifactSignature,
|
||||
verifyPortableZip,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
mkdirSync,
|
||||
mkdtempSync
|
||||
} = require('node:fs')
|
||||
const {
|
||||
rm,
|
||||
writeFile
|
||||
} = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const {
|
||||
isAbsolute,
|
||||
join,
|
||||
resolve
|
||||
} = require('node:path')
|
||||
const { app, utilityProcess } = require('electron/main')
|
||||
|
||||
const protocol = 'goodbuddy.deepseek-harness.control'
|
||||
const version = 1
|
||||
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
|
||||
const configuredHostPath =
|
||||
process.env.GOODBUDDY_HARNESS_SMOKE_HOST
|
||||
const hostPath = configuredHostPath
|
||||
? isAbsolute(configuredHostPath)
|
||||
? configuredHostPath
|
||||
: resolve(configuredHostPath)
|
||||
: resolve('out/main/deepseek-harness-host-bootstrap.js')
|
||||
const workspace = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-harness-electron-smoke-')
|
||||
)
|
||||
const dshHome = join(workspace, 'dsh-home')
|
||||
mkdirSync(dshHome)
|
||||
const configuredResultPath =
|
||||
process.env.GOODBUDDY_HARNESS_SMOKE_RESULT
|
||||
const resultPath =
|
||||
configuredResultPath && isAbsolute(configuredResultPath)
|
||||
? configuredResultPath
|
||||
: join(
|
||||
tmpdir(),
|
||||
`goodbuddy-harness-utility-smoke-${process.pid}.json`
|
||||
)
|
||||
|
||||
let child
|
||||
let timeout
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
let transportProbed = false
|
||||
|
||||
void writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({ status: 'checkpoint', stage: 'script-start' }),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
async function checkpoint(stage, detail = '') {
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({ status: 'checkpoint', stage, detail }),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
function finish(status, detail = '') {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
void writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({
|
||||
status,
|
||||
detail: detail.slice(0, 4_096)
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
child?.kill()
|
||||
void rm(workspace, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!configuredResultPath) {
|
||||
console.log(
|
||||
`GoodBuddy packaged Harness smoke: ${status}`
|
||||
)
|
||||
}
|
||||
app.exit(status === 'ready' ? 0 : 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await checkpoint('module-loaded')
|
||||
await app.whenReady()
|
||||
await checkpoint('app-ready')
|
||||
child = utilityProcess.fork(hostPath, [], {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '',
|
||||
Path: process.env.Path ?? '',
|
||||
PATHEXT: process.env.PATHEXT ?? '',
|
||||
SystemRoot: process.env.SystemRoot ?? '',
|
||||
COMSPEC: process.env.COMSPEC ?? '',
|
||||
TEMP: process.env.TEMP ?? '',
|
||||
TMP: process.env.TMP ?? '',
|
||||
USERPROFILE: process.env.USERPROFILE ?? '',
|
||||
APPDATA: process.env.APPDATA ?? '',
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA ?? '',
|
||||
DSH_HOME: dshHome,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
OTEL_SDK_DISABLED: 'true'
|
||||
},
|
||||
serviceName: 'GoodBuddy DeepSeek Harness Smoke',
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
allowLoadingUnsignedLibraries: false,
|
||||
disclaim: false
|
||||
})
|
||||
await checkpoint('utility-forked', String(child.pid ?? ''))
|
||||
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = (stderr + String(chunk)).slice(-4_096)
|
||||
})
|
||||
child.on('message', (message) => {
|
||||
if (
|
||||
message?.protocol === protocol &&
|
||||
message.version === version &&
|
||||
message.type === 'ready'
|
||||
) {
|
||||
child.postMessage({
|
||||
protocol: byteProtocol,
|
||||
version,
|
||||
type: 'data',
|
||||
stream: 'stdin',
|
||||
seq: 0,
|
||||
bytes: Buffer.from('{}\n')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
message?.protocol === byteProtocol &&
|
||||
message.version === version &&
|
||||
message.type === 'ack' &&
|
||||
message.stream === 'stdin' &&
|
||||
message.seq === 0
|
||||
) {
|
||||
transportProbed = true
|
||||
finish('ready')
|
||||
return
|
||||
}
|
||||
if (
|
||||
message?.protocol === protocol &&
|
||||
message.version === version &&
|
||||
message.type === 'fatal'
|
||||
) {
|
||||
finish('fatal', String(message.code))
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
finish(
|
||||
'exit',
|
||||
`${code}:${stderr.replaceAll(/\s+/gu, ' ').trim()}`
|
||||
)
|
||||
})
|
||||
child.postMessage({
|
||||
protocol,
|
||||
version,
|
||||
type: 'start',
|
||||
config: {
|
||||
workspace,
|
||||
dshHome,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-chat',
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
sandbox: {
|
||||
provider:
|
||||
process.platform === 'win32'
|
||||
? 'windows-acl'
|
||||
: process.platform === 'darwin'
|
||||
? 'seatbelt'
|
||||
: 'local-linux',
|
||||
enforcement:
|
||||
process.platform === 'win32' ? 'partial' : 'full'
|
||||
},
|
||||
credentialRefs: ['GOODBUDDY_DEEPSEEK_API_KEY'],
|
||||
skillPackages: [],
|
||||
maxFrameBytes: 1024 * 1024
|
||||
}
|
||||
})
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(
|
||||
'timeout',
|
||||
`${transportProbed ? 'transport-probed ' : ''}${stderr.replaceAll(/\s+/gu, ' ').trim()}`
|
||||
)
|
||||
}, 20_000)
|
||||
}
|
||||
|
||||
void run().catch((error) => {
|
||||
finish(
|
||||
'bootstrap-error',
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
'use strict'
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
const {
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const electronPath = process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON
|
||||
? resolve(process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON)
|
||||
: require('electron')
|
||||
const configuredAppPath =
|
||||
process.env.GOODBUDDY_HARNESS_SMOKE_APP
|
||||
const appPath = configuredAppPath
|
||||
? resolve(configuredAppPath)
|
||||
: resolve('build/smoke-app')
|
||||
const temporaryAppPath =
|
||||
configuredAppPath ||
|
||||
process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON
|
||||
? undefined
|
||||
: join(
|
||||
tmpdir(),
|
||||
`goodbuddy-harness-smoke-app-${process.pid}`
|
||||
)
|
||||
const resultPath = join(
|
||||
tmpdir(),
|
||||
`goodbuddy-harness-utility-smoke-result-${process.pid}.json`
|
||||
)
|
||||
const profilePath = join(
|
||||
tmpdir(),
|
||||
`goodbuddy-harness-utility-smoke-profile-${process.pid}`
|
||||
)
|
||||
const environment = {
|
||||
...process.env,
|
||||
GOODBUDDY_HARNESS_SMOKE_RESULT: resultPath
|
||||
}
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
|
||||
function runElectron(applicationPath) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(
|
||||
electronPath,
|
||||
[
|
||||
applicationPath,
|
||||
'--no-sandbox',
|
||||
`--user-data-dir=${profilePath}`,
|
||||
'--no-first-run'
|
||||
],
|
||||
{
|
||||
cwd: resolve('.'),
|
||||
env: environment,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
let output = ''
|
||||
const capture = (chunk) => {
|
||||
output = (output + String(chunk)).slice(-8_192)
|
||||
}
|
||||
child.stdout.on('data', capture)
|
||||
child.stderr.on('data', capture)
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
rejectRun(
|
||||
new Error(
|
||||
`DeepSeek Harness Electron smoke timed out: ${output.trim()}`
|
||||
)
|
||||
)
|
||||
}, 30_000)
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
rejectRun(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
resolveRun({ code, signal, output })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await rm(resultPath, { force: true })
|
||||
await writeFile(
|
||||
resolve('out/main/package.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: '@deepseek-ai/dsh-llm',
|
||||
version: '0.1.0-rc.6',
|
||||
private: true,
|
||||
type: 'module'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
if (temporaryAppPath) {
|
||||
await rm(temporaryAppPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
const { cp, copyFile, mkdir } = require('node:fs/promises')
|
||||
await mkdir(temporaryAppPath, { recursive: true })
|
||||
await cp(resolve('build/smoke-app'), temporaryAppPath, {
|
||||
recursive: true
|
||||
})
|
||||
await copyFile(
|
||||
resolve('build/deepseek-harness-utility-smoke.cjs'),
|
||||
join(temporaryAppPath, 'deepseek-harness-utility-smoke.cjs')
|
||||
)
|
||||
}
|
||||
const execution = await runElectron(
|
||||
temporaryAppPath ?? appPath
|
||||
)
|
||||
let result
|
||||
try {
|
||||
result = JSON.parse(await readFile(resultPath, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`DeepSeek Harness Electron smoke produced no valid result (code ${execution.code}, signal ${execution.signal ?? 'none'}): ${execution.output.trim()}`,
|
||||
{ cause: error }
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(resultPath, { force: true }),
|
||||
rm(profilePath, { recursive: true, force: true }),
|
||||
temporaryAppPath
|
||||
? rm(temporaryAppPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
: Promise.resolve()
|
||||
])
|
||||
}
|
||||
if (execution.code !== 0 || result.status !== 'ready') {
|
||||
throw new Error(
|
||||
`DeepSeek Harness Electron smoke failed (code ${execution.code}, status ${String(result.status)}): ${String(result.detail ?? execution.output).trim()}`
|
||||
)
|
||||
}
|
||||
console.log('DeepSeek Harness Electron utility smoke: ready')
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
'use strict'
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
const {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} = require('node:fs/promises')
|
||||
const { statSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const unpackedPath = process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
: resolve('dist/harness-package-probe/win-unpacked')
|
||||
const executable = join(
|
||||
unpackedPath,
|
||||
process.platform === 'win32' ? 'GoodBuddy.exe' : 'goodbuddy'
|
||||
)
|
||||
const host = join(
|
||||
unpackedPath,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
|
||||
for (const [path, description] of [
|
||||
[executable, 'packaged Electron executable'],
|
||||
[host, 'packaged DeepSeek Harness host']
|
||||
]) {
|
||||
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
|
||||
throw new Error(`${description} is missing: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, env) {
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: resolve('.'),
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let output = ''
|
||||
const capture = (chunk) => {
|
||||
output = (output + String(chunk)).slice(-8_192)
|
||||
}
|
||||
child.stdout.on('data', capture)
|
||||
child.stderr.on('data', capture)
|
||||
child.once('error', rejectExit)
|
||||
child.once('exit', (exitCode, signal) => {
|
||||
resolveExit({ exitCode, signal, output })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-packaged-harness-smoke-')
|
||||
)
|
||||
try {
|
||||
const project = join(root, 'app')
|
||||
const profile = join(root, 'profile')
|
||||
const resultPath = join(root, 'result.json')
|
||||
await mkdir(project, { recursive: true })
|
||||
|
||||
await copyFile(
|
||||
resolve('build/deepseek-harness-utility-smoke.cjs'),
|
||||
join(project, 'deepseek-harness-utility-smoke.cjs')
|
||||
)
|
||||
await writeFile(
|
||||
join(project, 'package.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: 'goodbuddy-packaged-harness-smoke',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
main: 'deepseek-harness-utility-smoke.cjs'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
join(project, 'electron-builder.yml'),
|
||||
[
|
||||
'appId: live.digiman.goodbuddy.harness-smoke',
|
||||
'productName: GoodBuddyHarnessSmoke',
|
||||
'electronVersion: "43.2.0"',
|
||||
'asar: true',
|
||||
'npmRebuild: false',
|
||||
'files:',
|
||||
' - package.json',
|
||||
' - deepseek-harness-utility-smoke.cjs',
|
||||
'win:',
|
||||
' target:',
|
||||
' - dir'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const packageArguments = [
|
||||
resolve('node_modules/electron-builder/cli.js'),
|
||||
'--projectDir',
|
||||
project,
|
||||
'--win',
|
||||
'dir',
|
||||
'--x64',
|
||||
'--publish',
|
||||
'never',
|
||||
`--config.directories.output=${join(root, 'dist')}`
|
||||
]
|
||||
if (process.env.GOODBUDDY_ELECTRON_DIST) {
|
||||
packageArguments.push(
|
||||
`--config.electronDist=${resolve(process.env.GOODBUDDY_ELECTRON_DIST)}`
|
||||
)
|
||||
}
|
||||
const packaged = await run(
|
||||
process.execPath,
|
||||
packageArguments,
|
||||
process.env
|
||||
)
|
||||
if (packaged.exitCode !== 0 || packaged.signal) {
|
||||
throw new Error(
|
||||
`Unable to package Harness smoke app: ${packaged.output.trim()}`
|
||||
)
|
||||
}
|
||||
|
||||
const smokeEnvironment = {
|
||||
...process.env,
|
||||
GOODBUDDY_HARNESS_SMOKE_HOST: host,
|
||||
GOODBUDDY_HARNESS_SMOKE_RESULT: resultPath
|
||||
}
|
||||
delete smokeEnvironment.ELECTRON_RUN_AS_NODE
|
||||
const executed = await run(
|
||||
join(root, 'dist', 'win-unpacked', 'GoodBuddyHarnessSmoke.exe'),
|
||||
[`--user-data-dir=${profile}`, '--no-first-run'],
|
||||
smokeEnvironment
|
||||
)
|
||||
const result = JSON.parse(await readFile(resultPath, 'utf8'))
|
||||
if (
|
||||
executed.exitCode !== 0 ||
|
||||
executed.signal ||
|
||||
result.status !== 'ready'
|
||||
) {
|
||||
throw new Error(
|
||||
`Packaged DeepSeek Harness smoke failed (${executed.exitCode}, ${executed.signal ?? 'no signal'}): ${JSON.stringify(result)} ${executed.output.trim()}`
|
||||
)
|
||||
}
|
||||
console.log('Packaged DeepSeek Harness utility smoke: ready')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -139,6 +139,24 @@ module.exports = async function prepareBundledRuntimes(context) {
|
||||
architecture === 'x64' ? `${architecture}-baseline` : architecture
|
||||
const packageName = `opencode-${packagePlatform}-${suffix}`
|
||||
const projectDir = context.packager.projectDir
|
||||
const projectPackage = JSON.parse(
|
||||
await readFile(join(projectDir, 'package.json'), 'utf8')
|
||||
)
|
||||
await writeFile(
|
||||
join(projectDir, 'out', 'main', 'package.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: '@deepseek-ai/dsh-llm',
|
||||
version:
|
||||
projectPackage.dependencies['@deepseek-ai/dsh-llm'],
|
||||
private: true,
|
||||
type: 'module'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const integrity = await lockedIntegrity(projectDir, packageName)
|
||||
const targetDirectory = join(
|
||||
projectDir,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "goodbuddy-harness-utility-smoke",
|
||||
"private": true,
|
||||
"main": "deepseek-harness-utility-smoke.cjs"
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
# GoodBuddy 自维护 DeepSeek Harness Runtime 设计
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档状态 | 实现与发布验收基线 |
|
||||
| 设计目标 | 将 DeepSeek Harness 作为 GoodBuddy 的第三个 Agent Runtime |
|
||||
| Runtime 标识 | `deepseek-harness` |
|
||||
| 首版依赖基线 | 实际使用的 `@deepseek-ai/dsh-*` 底层库,精确锁定 `0.1.0-rc.6` |
|
||||
| 上游状态 | Developer Preview,允许出现破坏性变更 |
|
||||
| 上游许可证 | MIT |
|
||||
| GoodBuddy 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 本文性质 | 设计与发布验收约定 |
|
||||
|
||||
本文定义 DeepSeek Harness 在 GoodBuddy 中的架构边界、协议、安全策略、界面、打包和验收要求。实现必须继续遵守 GoodBuddy 已有的 Main 进程安全边界、Ask/Execute 语义、授权、取消、超时、有界输出和资源回收约定。
|
||||
|
||||
## 2. 摘要
|
||||
|
||||
DeepSeek Harness 的底层库使用 Cordis 组合服务。GoodBuddy 不采用官方产品 profile、插件安装或市场机制,也不让用户配置覆盖安全服务,而是增加一个实验性的第三 Runtime,并完全自行维护 Host、控制协议、生命周期和兼容层。上游 DSH 包只是精确锁定并逐次审查的实现依赖,不构成 GoodBuddy 对 DSH 插件 ABI、插件目录或产品路线的承诺。
|
||||
|
||||
GoodBuddy 并不迫切于把该能力做成 DSH 插件或进入插件市场。当前优先级是向用户提供稳定、可靠、可审计且可完整回收的 Runtime;只有未来真实用户需求和成熟度证明插件化确有价值时,才重新评估该形态。
|
||||
|
||||
整体分成两个互相约束的部分:
|
||||
|
||||
1. **GoodBuddy Main Control Plane**
|
||||
- 运行在 Electron Main 进程。
|
||||
- 持有加密设置、模型连接选择、Ask 拒绝与 Execute 自动授权策略、Runtime 生命周期和审计归属。
|
||||
- 通过 Electron `utilityProcess` 启动受控 Harness 子进程。
|
||||
- 对环境、输入、输出、超时、取消和进程树执行强制限制。
|
||||
|
||||
2. **GoodBuddy Harness Control Plane**
|
||||
- 运行在 Harness 子进程内,是 Host 私有的内部控制组件,不导出 Cordis 插件入口。
|
||||
- 使用 ACP 兼容的 JSON-RPC stdio 作为基础控制面。
|
||||
- 增加 GoodBuddy 所需的能力握手、每轮权限准备、会话释放、工具事件、推理、用量和安全凭据请求扩展。
|
||||
- 与 GoodBuddy Host 一起维护、构建和发布,不设计为独立 npm 包、`dsh.bundle` 或市场插件。
|
||||
|
||||
DeepSeek Harness 不替换 OpenCode、Continue 或直连模型 Runtime。用户可以按全局、项目、会话或消息通道继续选择现有 Runtime。
|
||||
|
||||
## 3. 背景与上游能力
|
||||
|
||||
### 3.1 已确认的官方能力
|
||||
|
||||
- `@deepseek-ai/dsh` 是官方 profile 启动器。
|
||||
- Harness 插件是导出 `apply(ctx, config)` 的 Cordis 模块。
|
||||
- npm 包可通过 `dsh.bundle` 声明配置补丁,再通过 `dsh plugin --profile <name> add <package>` 安装。
|
||||
- ACP 支持:
|
||||
- 初始化。
|
||||
- 创建多个会话。
|
||||
- 发送 Prompt。
|
||||
- 按会话取消。
|
||||
- 一次性权限选择。
|
||||
- 已提交的助手文本。
|
||||
- 官方本地沙箱支持:
|
||||
- Linux:Bubblewrap,或 Landlock 降级。
|
||||
- macOS:Seatbelt。
|
||||
- Windows:ACL 受限令牌,官方明确标记为部分强制执行。
|
||||
|
||||
### 3.2 官方通道的缺口
|
||||
|
||||
官方 ACP 插件有意只输出已提交文本,不输出推理、工具进度、计划、标题和用量。它也没有标准的会话关闭方法。SDK JSON-RPC 的展示事件更完整,但缺少 GoodBuddy 需要的单轮取消和权限回传。
|
||||
|
||||
因此,首版不单独选用其中一个官方通道作为完整实现。GoodBuddy Harness Control Plane 以 ACP 语义为基础,补充有命名空间的扩展方法和事件。
|
||||
|
||||
### 3.3 自维护边界
|
||||
|
||||
GoodBuddy 不急于把该 Runtime 包装成标准 DSH 插件,也不以进入官方或第三方插件市场为近期目标。所有入口都随 GoodBuddy 发布,只有 GoodBuddy Main 可以启动并使用内部 Host。是否采用上游新版本或未来重新评估插件形态,只由真实用户价值、安全审查和六平台稳定性决定,不跟随市场机制或上游发布节奏。
|
||||
|
||||
## 4. 目标与非目标
|
||||
|
||||
### 4.1 首版目标
|
||||
|
||||
- 增加 `deepseek-harness` Runtime,并在设置、聊天和消息通道中可选择。
|
||||
- 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。
|
||||
- Ask 模式在 Runtime 边界强制只读,并禁止任何权限升级。
|
||||
- Execute 模式下的工具权限请求由 Main 自动给予单次授权,不弹出交互审批;默认文件模式仍为 `workspace-write`,越界仅允许在真实沙箱拒绝后对完全相同操作单次重试。
|
||||
- 支持多会话、同会话串行、跨会话并行。
|
||||
- 支持按请求取消、超时、会话释放和应用退出时完整回收。
|
||||
- 输出文本、推理、工具参数、工具结果、stderr 和协议队列全部有界。
|
||||
- 使用真实 DeepSeek 模型验证调用,而不在日志、测试产物或提交中暴露凭据。
|
||||
- 保留 Windows、macOS、Linux 的 x64 和 arm64 发布能力。
|
||||
|
||||
### 4.2 首版非目标
|
||||
|
||||
- 不替换 OpenCode、Continue 或直连模型 Runtime。
|
||||
- 不开放用户 Cordis profile、cordis.patch.yml 或 $DSH_HOME 全局补丁覆盖。
|
||||
- 不提供外部 Host、自定义 Harness Control Plane、DSH 插件安装或市场入口。
|
||||
- 不加载 Harness Web UI、HMR、遥测、自动更新或目录选择器。
|
||||
- 不支持 `danger-full-access` 作为会话默认值或持久设置。
|
||||
- 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。
|
||||
- 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。
|
||||
- 不在首版支持图像输入、会话恢复、Harness Subagent、后台 Job、Hook、Web Search 或 Workflow。
|
||||
- 不发布独立 npm 包,也不创建上游 PR。
|
||||
|
||||
## 5. 核心设计决策
|
||||
|
||||
### 5.1 第三个独立 Runtime
|
||||
|
||||
`deepseek-harness` 是明确的 Runtime 类型,不伪装成 `model`、`opencode` 或 `continue`。共享契约、设置迁移、Runtime 选择、检测、聊天标签、消息通道和模型用量都使用同一个稳定标识。
|
||||
|
||||
### 5.2 受控组合,不启动用户 profile
|
||||
|
||||
GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不调用 `dsh web`,也不启动用户已有 profile。运行时禁止以下来源参与组合:
|
||||
|
||||
- 当前工作目录的 `.env`。
|
||||
- 用户 Harness Home 的 `.env`。
|
||||
- `$DSH_HOME/cordis.patch.yml`。
|
||||
- 用户 profile 的 `cordis.patch.yml`。
|
||||
- 任意 `--patch`。
|
||||
- HMR 和动态插件安装。
|
||||
|
||||
模型名称、服务地址、工作区和非秘密策略通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。
|
||||
|
||||
### 5.3 双层内部控制面
|
||||
|
||||
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
|
||||
|
||||
- Harness Control Plane 最接近 Session、Agent、Tool、Usage 和权限 seam,适合做内部协议转换。
|
||||
- Main 控制面是可信安全边界,适合持有模式授权策略、加密设置、进程控制和 IPC。
|
||||
|
||||
任何一侧缺失能力握手时,Runtime 必须报告不可用,不能降级为不受控执行。
|
||||
|
||||
### 5.4 GoodBuddy 继续拥有持久会话
|
||||
|
||||
首版不启用 Harness JSONL 会话持久化和 SQLite 会话索引。原因如下:
|
||||
|
||||
- GoodBuddy 已经持久化对话、消息、活动、工具事件和用量。
|
||||
- 再写一份 Harness 日志会扩大敏感数据副本和清理范围。
|
||||
- GoodBuddy 在 Runtime 重启后可以用现有的有界历史创建新 Harness Session。
|
||||
|
||||
Harness Session 只在当前 Runtime 进程生命周期内存在。释放 GoodBuddy 会话时必须同步释放对应 Harness Agent。
|
||||
|
||||
## 6. 总体架构
|
||||
|
||||
```text
|
||||
Renderer
|
||||
│ 显式、经 schema 验证的 preload API
|
||||
▼
|
||||
Electron Main
|
||||
├─ RuntimeSettingsStore
|
||||
├─ AgentRuntimeController
|
||||
├─ RuntimeAuthorizer(Ask 拒绝 / Execute 自动单次授权)
|
||||
└─ DeepSeekHarnessRuntime / Main Control Plane
|
||||
│ ACP + goodbuddy/* 扩展,stdin/stdout
|
||||
▼
|
||||
Electron utilityProcess
|
||||
└─ GoodBuddy Harness Host
|
||||
├─ 固定 Cordis 组合
|
||||
├─ GoodBuddy Harness Control Plane(内部组件)
|
||||
├─ DSH Agent 与 LLM seam
|
||||
├─ DSH Sandbox Policy
|
||||
├─ 沙箱 Shell / Filesystem
|
||||
└─ 最小工具集
|
||||
│ HTTPS
|
||||
▼
|
||||
用户选择的 DeepSeek 兼容模型连接
|
||||
```
|
||||
|
||||
### 6.1 信任边界
|
||||
|
||||
| 区域 | 信任级别 | 允许持有的内容 |
|
||||
| --- | --- | --- |
|
||||
| Renderer | 不可信展示层 | 脱敏设置、状态、用户可见事件 |
|
||||
| Preload | 窄桥 | 明确方法和共享 schema |
|
||||
| Electron Main | 可信控制面 | 加密设置、模式授权策略、Runtime 生命周期 |
|
||||
| Harness utilityProcess | 不可信执行面 | 当前请求、临时凭据、受控工具和工作区权限 |
|
||||
| Harness 工具子进程 | 最低信任 | 单次命令所需的最小环境和沙箱能力 |
|
||||
|
||||
Harness 子进程崩溃、输出异常、拒绝协议、加载错误或沙箱不可用时,Main 必须失败关闭。
|
||||
|
||||
## 7. GoodBuddy Harness Control Plane
|
||||
|
||||
### 7.1 内部组件职责
|
||||
|
||||
控制面负责:
|
||||
|
||||
- 启动 ACP 兼容的 JSON-RPC stdio 服务。
|
||||
- 创建、查找和释放 Harness Agent。
|
||||
- 在 Prompt 前应用 GoodBuddy 指定的 Ask/Execute 权限。
|
||||
- 将 DSH Session 事件转换为有界的 GoodBuddy 事件。
|
||||
- 将权限请求转发到 Main,并只接受一次性结果。
|
||||
- 将 LLM 用量转换为稳定的模型用量事件。
|
||||
- 在 dispose 时先取消 Agent,再等待子 Agent 和工具清理。
|
||||
- 保证 stdout 只包含协议帧,诊断只写 stderr。
|
||||
|
||||
控制面不负责:
|
||||
|
||||
- 保存 GoodBuddy 设置。
|
||||
- 持久保存 API Key。
|
||||
- 决定 Main 的模式授权结果。
|
||||
- 直接访问 Renderer 或 Electron API。
|
||||
- 接受用户提供的插件、Host 或 profile 覆盖。
|
||||
- 自行上传遥测。
|
||||
|
||||
### 7.2 非插件约束
|
||||
|
||||
控制面不导出 `apply(ctx, config)`,不提供默认 stdin/stdout 入口,不包含 `dsh.bundle`、`cordis.patch.yml` 或可安装 manifest,也不接受 Host 之外创建的 transport。它可以保留清晰的内部模块边界以便测试和维护,但该边界不是公开扩展点。
|
||||
|
||||
若未来确有来自 GoodBuddy 真实用户、经过研究验证的扩展需求,应先重新完成产品需求、威胁模型和兼容策略评审;不得因为上游已经提供插件或市场机制而默认开放。
|
||||
|
||||
## 8. 协议设计
|
||||
|
||||
### 8.1 传输
|
||||
|
||||
- stdin/stdout 使用换行分隔 JSON-RPC。
|
||||
- stdout 不得出现日志、Banner、进度条或调试输出。
|
||||
- stderr 只允许有界诊断,不得包含 Prompt、工具完整输出或凭据。
|
||||
- 每一帧、每一字段和每个请求累计输出都必须在解析前或接收时限流。
|
||||
|
||||
### 8.2 标准 ACP 方法
|
||||
|
||||
首版保留 ACP 的初始化、`session/new`、`session/prompt` 和 `session/cancel` 语义。标准 ACP 客户端可以使用只读默认行为,但只有完成 GoodBuddy 能力握手的客户端才能启用 Execute。
|
||||
|
||||
### 8.3 GoodBuddy 扩展
|
||||
|
||||
扩展统一使用 `goodbuddy/` 命名空间:
|
||||
|
||||
| 方法或事件 | 方向 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `goodbuddy/handshake` | Main → Control Plane | 交换控制协议、Harness、ACP 版本和能力 |
|
||||
| `goodbuddy/session/prepare` | Main → Control Plane | 在下一次 Prompt 前设置工作模式和请求标识 |
|
||||
| `goodbuddy/session/release` | Main → Control Plane | 取消并释放指定 Session |
|
||||
| `goodbuddy/session/event` | Control Plane → Main | 文本、推理、工具、状态和用量事件 |
|
||||
| `goodbuddy/credential/resolve` | Control Plane → Main | 按已登记引用请求当前 Runtime 的临时凭据 |
|
||||
| `goodbuddy/tools/list` | Control Plane → Main | 取得用户分配给 Harness 的有界 MCP 工具 schema |
|
||||
| `goodbuddy/tools/call` | Control Plane → Main | 通过当前 Execute 请求、schema 校验和自动单次授权调用 MCP |
|
||||
| `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 |
|
||||
|
||||
扩展版本独立于 ACP 版本。握手响应至少包含:
|
||||
|
||||
```ts
|
||||
type GoodBuddyHarnessCapabilities = {
|
||||
controlProtocolVersion: 1
|
||||
harnessVersion: string
|
||||
acpProtocolVersion: number
|
||||
supports: {
|
||||
cancellation: true
|
||||
sessionRelease: true
|
||||
oneShotApproval: true
|
||||
reasoningEvents: boolean
|
||||
toolEvents: boolean
|
||||
usageEvents: boolean
|
||||
}
|
||||
sandbox: {
|
||||
provider: string
|
||||
enforcement: 'full' | 'partial'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
版本不兼容、必需能力缺失或 `sandbox.enforcement` 不满足设置要求时,Main 不得开始模型请求。
|
||||
|
||||
### 8.4 每轮权限准备
|
||||
|
||||
GoodBuddy 的工作模式属于每个请求,不属于 Runtime 进程全局状态。同一对话可以在 Ask 和 Execute 之间切换。因此:
|
||||
|
||||
1. `session/new` 后默认是 `read-only + never`。
|
||||
2. 每个 Prompt 前,Main 发送一次 `goodbuddy/session/prepare`。
|
||||
3. Harness Control Plane 将准备状态绑定到 `sessionId + requestId`。
|
||||
4. `session/prompt` 只能消费匹配且尚未使用的准备状态。
|
||||
5. 缺少准备状态、重复使用、请求标识不匹配时,Control Plane 使用只读且禁止授权的安全默认值,或直接拒绝请求。
|
||||
6. 同一 Session 只允许一个 Prompt 在途。
|
||||
|
||||
### 8.5 事件模型
|
||||
|
||||
Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
|
||||
|
||||
- `status`:简短运行状态。
|
||||
- `text`:已提交的助手文本分片。
|
||||
- `reasoning`:可选的有界推理摘要分片。
|
||||
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。
|
||||
- `model-usage`:模型、提供方、输入、输出和缓存 Token。
|
||||
- `done`:停止原因和 Session ID。
|
||||
|
||||
禁止发送原始 Cordis Context、完整环境、内部对象、堆栈中的凭据或无界 Session 日志。
|
||||
|
||||
## 9. Runtime 生命周期
|
||||
|
||||
### 9.1 进程模型
|
||||
|
||||
- 每个活动的 DeepSeek Harness Runtime 实例拥有一个 `utilityProcess`。
|
||||
- 一个进程可以承载多个 Harness Session。
|
||||
- 同一 GoodBuddy 对话的 Prompt 串行执行。
|
||||
- 不同对话可以并行,但受全局并发上限控制。
|
||||
- Runtime 设置变化时创建新实例,旧实例等待在途请求结束或在宽限期后被取消。
|
||||
|
||||
### 9.2 会话映射
|
||||
|
||||
Main 保存内存映射:
|
||||
|
||||
```text
|
||||
GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
```
|
||||
|
||||
- 首次请求创建 Session。
|
||||
- 已有 Session 只发送当前 Prompt。
|
||||
- 进程重启或映射失效时,创建新 Session,并只在这一次加入 GoodBuddy 提供的有界历史。
|
||||
- 历史以明确的“不可信会话数据”结构传入,不能拼接成系统指令。
|
||||
- 用户分配的 Skill 只通过 Main 校验的包路径进入 Host,并在 Agent scope 注册;不得把 Skill 内容伪装成用户 Prompt。
|
||||
|
||||
### 9.3 取消与超时
|
||||
|
||||
- 用户取消时立即发送 `session/cancel`。
|
||||
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
|
||||
- 初始化、握手、Session 创建、Prompt、权限回传和关闭分别使用独立超时。
|
||||
- Prompt 超时与用户取消使用不同错误类型,不能被宽泛 catch 抹平。
|
||||
- 取消后仍可接收并丢弃该请求的最终协议结算帧,但不得写入下一请求。
|
||||
|
||||
### 9.4 释放与退出
|
||||
|
||||
- 删除或释放对话时调用 `goodbuddy/session/release`。
|
||||
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
|
||||
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
|
||||
- Main 在宽限期内等待正常退出。
|
||||
- 超时后终止 utilityProcess,并在平台允许时清理完整进程树。
|
||||
- 应用退出不得因 Harness 清理无限阻塞。
|
||||
|
||||
## 10. 权限与沙箱
|
||||
|
||||
### 10.1 模式映射
|
||||
|
||||
| GoodBuddy 模式 | DSH 文件模式 | DSH 权限策略 | 行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| Ask | `read-only` | `never` | 允许受控读取,不允许写入,不允许升级 |
|
||||
| Execute | `workspace-write` | `ask` | 允许工作区与受控临时目录写入;权限请求由 Main 自动单次授权,不弹出交互审批 |
|
||||
|
||||
`danger-full-access` 只能作为某个已被沙箱拒绝的完全相同操作的一次性、更宽重试。Main 仅对该次重试自动返回 `allow-once`;它不能保存为默认值、复用于后续操作,或通过“始终允许”返回。
|
||||
|
||||
### 10.2 Ask 模式
|
||||
|
||||
- Main 即使收到权限请求也固定拒绝。
|
||||
- Harness Control Plane 禁止 `sandbox_permissions` 升级。
|
||||
- 文件写入和 Shell 写入都由 DSH 共享 Sandbox Policy 强制拒绝。
|
||||
- 只读不等于无限输出,读取仍受路径、字节和工具结果上限控制。
|
||||
- 首版不向 Ask 暴露 GoodBuddy 的可变数据工具。
|
||||
|
||||
### 10.3 Execute 模式
|
||||
|
||||
- 工作区根来自 Session 创建时的规范化绝对路径。
|
||||
- 工具不能自行更换工作区根。
|
||||
- 工作区内操作按 DSH `workspace-write` 执行。
|
||||
- 只有真实沙箱拒绝后的同一操作,才可请求一次升级。
|
||||
- Main 不调用 `ToolApprovalBroker`,而是对当前 Execute 请求自动返回 `allow-once`;界面不进入等待审批状态,也不弹出审批对话框。
|
||||
- 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。
|
||||
- Harness Control Plane 不接受 `allow_always`,也不把未知结果解释为允许。
|
||||
|
||||
### 10.4 沙箱可用性
|
||||
|
||||
- `strict`:要求完整强制执行。仅有 `partial` 或无 Runner 时 Runtime 不可用。
|
||||
- `auto`:允许官方报告的 `full` 或 `partial`,但必须在状态卡显示实际强制程度。
|
||||
- `off`:不允许 Harness 退化到无限制工具执行。首版将 Execute 标记为不可用,Ask 仍只能在可强制只读时运行。
|
||||
|
||||
Windows ACL 和旧 Linux Landlock 可能只报告 `partial`。界面和诊断必须如实显示,不能写成“完全隔离”。
|
||||
|
||||
### 10.5 环境与凭据
|
||||
|
||||
- 使用环境变量白名单构造 utilityProcess 环境。
|
||||
- 不继承 `NODE_OPTIONS`、调试端口、任意 npm 配置、用户 `DSH_*` 覆盖或白名单之外的凭据。
|
||||
- `DSH_TELEMETRY_DISABLED=1` 必须固定设置。
|
||||
- Harness Home 指向 GoodBuddy 管理的隔离目录。
|
||||
- 不调用官方 `loadEnv` 或 `loadLayeredEnv`。
|
||||
- API Key 由 Main 从加密设置中解析。
|
||||
- Harness Control Plane 只能用已握手登记的引用通过 `goodbuddy/credential/resolve` 请求当前 Runtime 的凭据。
|
||||
- 凭据只在模型请求所需的子进程内存中短暂存在,不写磁盘、不进入工具环境、不打印。
|
||||
|
||||
## 11. 受控 Harness 组合
|
||||
|
||||
首版只加载完成文本对话、受控代码操作和用户明确分配能力所需的固定服务:
|
||||
|
||||
- Agent、Session、LLM 和 Tool Registry 基础服务。
|
||||
- GoodBuddy Harness Control Plane。
|
||||
- DeepSeek 兼容 LLM 适配器。
|
||||
- Sandbox Policy 与平台 Sandbox Provider。
|
||||
- 平台对应的受沙箱 Shell。
|
||||
- 受沙箱 Filesystem。
|
||||
- 一次性权限请求服务。
|
||||
- Token Meter 和必要的上下文压缩。
|
||||
- 有界的读取、写入、编辑和 Shell 工具。
|
||||
- Agent scope 的 Skill Registry 与 `skill` 工具。Skill 目录由 Main 选择并在 Launcher 和 Host 两次规范化、校验。
|
||||
- Main 代理的 MCP schema 工具。Utility 不持有 MCP URL 凭据或 Transport。
|
||||
|
||||
首版明确不加载:
|
||||
|
||||
- Web UI、HMR、Host API 和目录选择器。
|
||||
- Harness 遥测。
|
||||
- Settings File 和 Local Credentials。
|
||||
- 用户 profile 与全局补丁。
|
||||
- Web Search、Fetch、Utility 直连 MCP、Hooks。
|
||||
- Subagent、Workflow、Ralph、后台 Job。
|
||||
- JSONL Session Persistence 和 SQLite Session Query。
|
||||
- 自动技能发现和市场技能加载。
|
||||
|
||||
如果某个首版工具依赖被排除服务,启动审计必须失败,而不是自动加载更大的默认 bundle。
|
||||
|
||||
## 12. 模型配置
|
||||
|
||||
### 12.1 配置来源
|
||||
|
||||
DeepSeek Harness 首版只使用 GoodBuddy 模型连接:
|
||||
|
||||
- 协议必须是 `openai-chat-completions`。
|
||||
- 认证必须是 API Key。
|
||||
- 服务地址必须是 `https://api.deepseek.com`,且不得包含用户信息。
|
||||
- 模型名称和服务地址由 Main 传入受控 Host。
|
||||
- API Key 继续保存在 GoodBuddy 加密设置中。
|
||||
|
||||
不允许选择 Harness 自有的用户配置文件或自定义 Host。Runtime 始终使用随当前 GoodBuddy 版本发布的内置 Host,并通过完整内部能力握手。
|
||||
|
||||
### 12.2 设置变化
|
||||
|
||||
模型、凭据、沙箱、Skill 或 MCP 分配变化时,GoodBuddy 创建新 Runtime 实例。Harness Host 路径始终由当前 GoodBuddy 构建提供,不能由设置或环境变量替换。旧实例按现有 Runtime Controller 语义退役,不在一个活动进程内热替换安全配置。
|
||||
|
||||
### 12.3 输入限制
|
||||
|
||||
- 首版只支持文本。
|
||||
- 图片输入应在发起网络调用前返回明确错误。
|
||||
- GoodBuddy 历史、Prompt、系统指令分别保持不同信任层。
|
||||
- 任何用户文本都不能进入 Cordis 配置表达式或模块名。
|
||||
|
||||
## 13. 输出和资源边界
|
||||
|
||||
建议首版默认限制:
|
||||
|
||||
| 项目 | 默认上限 |
|
||||
| --- | --- |
|
||||
| 单个 JSON-RPC 帧 | 1 MiB |
|
||||
| 单个文本或推理事件 | 64 KiB |
|
||||
| 单次请求累计协议输出 | 4 MiB |
|
||||
| 工具输入摘要 | 4,000 字符 |
|
||||
| 工具输出摘要 | 4,000 字符 |
|
||||
| 待处理事件数 | 1,000 |
|
||||
| stderr 累计 | 64 KiB |
|
||||
| 初始化 | 10 秒 |
|
||||
| 单次 Prompt | 10 分钟 |
|
||||
| 有序关闭宽限期 | 2 秒 |
|
||||
|
||||
超过限制时应取消当前请求。协议帧、队列或 stderr 持续异常时,应终止 Runtime 进程,避免继续信任已失控的通道。
|
||||
|
||||
## 14. Runtime 检测与状态
|
||||
|
||||
### 14.1 检测
|
||||
|
||||
检测只验证:
|
||||
|
||||
- 内置 Host 路径是规范化文件。
|
||||
- 版本可读取且在支持范围内。
|
||||
- 内部控制面能力握手成功。
|
||||
- 必需 Sandbox Provider 可用并报告强制程度。
|
||||
|
||||
检测不得调用付费模型,也不得读取或输出 API Key。真实模型测试是单独的显式操作。
|
||||
|
||||
### 14.2 设置界面
|
||||
|
||||
Agent Runtime 使用共享 `SegmentedControl` 展示 OpenCode、Continue 和 DeepSeek Harness。DeepSeek Harness 必须标记为“开发者预览”,并说明上游 RC 可能发生破坏性变更。
|
||||
|
||||
Runtime 的概览、模型配置和检测信息放在同一张详情卡中。当前单独显示的一行“已就绪”应移入卡片,与路径、版本号归为同一组:
|
||||
|
||||
```text
|
||||
Runtime: GoodBuddy 内置 DeepSeek Harness
|
||||
模型配置: 跟随 GoodBuddy · dsv4flash(deepseek-v4-flash)
|
||||
状态: 已就绪
|
||||
路径: <受控 Host 路径>
|
||||
版本: 0.1.0-rc.6
|
||||
安全强制: 完整 / 部分
|
||||
|
||||
Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
|
||||
```
|
||||
|
||||
界面要求:
|
||||
|
||||
- 不再在卡片外重复一行检测结果。
|
||||
- 使用语义化键值结构,路径允许换行,不截断关键信息。
|
||||
- 状态不能只依靠绿色表达,必须同时有文字。
|
||||
- 检测中、不可用和部分强制分别显示明确文案。
|
||||
- 高级设置默认收起。
|
||||
|
||||
聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。
|
||||
|
||||
## 15. IPC 与共享契约
|
||||
|
||||
共享 schema 需要覆盖:
|
||||
|
||||
- `deepseek-harness` provider 和 Runtime ID。
|
||||
- Runtime 选择中的 `deepseekHarness` 分支。
|
||||
- 检测结果中的路径、版本、详情和沙箱强制程度。
|
||||
- GoodBuddy 模型连接选择。
|
||||
- DeepSeek Harness 模型用量归属。
|
||||
- Skill 与 MCP 对 `deepseek-harness` 的显式分配。
|
||||
|
||||
Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或内部 Cordis 配置都不能进入共享契约。
|
||||
|
||||
已有设置迁移必须:
|
||||
|
||||
- 对没有新字段的用户使用安全默认值。
|
||||
- 保留 OpenCode、Continue 和模型连接选择。
|
||||
- 修复失效的 DeepSeek Harness 模型引用时给出可报告的迁移警告。
|
||||
- 不把旧 Runtime 自动迁移为 DeepSeek Harness。
|
||||
|
||||
## 16. 打包与供应链
|
||||
|
||||
### 16.1 版本策略
|
||||
|
||||
- 官方 RC 包全部精确锁定,不使用 `^` 或 `~`。
|
||||
- 同一 Harness 核心包族必须保持同一 RC 版本。
|
||||
- 升级前检查 release diff、协议 diff、沙箱 diff和依赖闭包。
|
||||
- 内部握手同时检查锁定的 Harness 基线和 GoodBuddy 控制协议版本。
|
||||
|
||||
### 16.2 原生依赖
|
||||
|
||||
受控组合可能需要:
|
||||
|
||||
- `node-pty`,用于受管理的工具子进程。
|
||||
- `koffi`,用于 Windows ACL 或相关本地能力。
|
||||
- `@deepseek-ai/node-addon-landlock-run` 的平台包。
|
||||
|
||||
不得广泛批准所有安装脚本。只允许生产组合实际需要、来源已审查、版本已锁定的脚本。六个平台的构建必须验证:
|
||||
|
||||
- 对应架构的原生文件存在。
|
||||
- Electron Utility Process 可加载原生模块。
|
||||
- Runner 或 spawn helper 的权限正确。
|
||||
- 包中没有混入其他平台不需要的可执行内容,除非上游包无法拆分且已记录。
|
||||
|
||||
### 16.3 生产闭包
|
||||
|
||||
发布包只包含受控 Host 需要的插件和许可证。应尽量避免把 Harness Web profile、HMR 和其他未加载产品面带入生产闭包。若 npm 依赖结构无法拆分,必须:
|
||||
|
||||
- 确认这些模块不会被加载。
|
||||
- 评估它们带来的 audit 和体积风险。
|
||||
- 在后续上游版本允许时改为最小包族。
|
||||
|
||||
### 16.4 漏洞门禁
|
||||
|
||||
当前安装后的 `npm audit` 报告不能直接用 `npm audit fix --force` 处理。每项漏洞需要区分:
|
||||
|
||||
- GoodBuddy 既有依赖。
|
||||
- Harness 新增生产依赖。
|
||||
- 仅开发或打包依赖。
|
||||
- 未加载但被带入的 Web 依赖。
|
||||
|
||||
进入 Harness 执行路径且有可利用条件的高危问题必须在发布前修复、替换或移出生产闭包。
|
||||
|
||||
### 16.5 发布验证
|
||||
|
||||
`build/build-release.cjs` 需要验证:
|
||||
|
||||
- Harness Host 和受控配置存在。
|
||||
- GoodBuddy Host、内部控制协议与 Harness 依赖版本清单存在。
|
||||
- 平台原生 Sandbox/PTY 依赖架构正确。
|
||||
- Harness、ACP SDK 和其他新增第三方许可证已打包。
|
||||
- `app.asar` 外需要执行或动态加载的资源位于预期目录。
|
||||
|
||||
## 17. 测试策略
|
||||
|
||||
### 17.1 单元测试
|
||||
|
||||
- Runtime 选择、设置迁移和失效引用修复。
|
||||
- 二进制检测、版本解析和路径规范化。
|
||||
- ACP 握手、事件转换和请求关联。
|
||||
- 每个会话单请求、跨会话并行。
|
||||
- Ask 固定拒绝升级。
|
||||
- Execute 权限请求由 Main 自动返回单次授权,Ask 与 delegation 固定拒绝。
|
||||
- 未分配 Skill/MCP 不可见;分配后的 Skill catalog 可调用 `skill` 加载。
|
||||
- Ask 不注册 MCP 工具;Execute 每轮刷新有界 schema,并在调用前再次校验活动请求、模式、参数和自动单次授权。
|
||||
- MCP URL、启动命令和凭据不进入 Utility 启动配置或协议结果。
|
||||
- 未知授权结果失败关闭。
|
||||
- 超时、取消、迟到帧和进程意外退出。
|
||||
- 协议帧、事件队列、工具摘要和 stderr 上限。
|
||||
- release 和 dispose 的幂等性。
|
||||
- 状态卡中的状态、路径、版本和强制程度。
|
||||
|
||||
### 17.2 本地集成测试
|
||||
|
||||
使用无网络的假控制面/模型验证:
|
||||
|
||||
- utilityProcess 管道。
|
||||
- 多 Session。
|
||||
- Session 释放。
|
||||
- Runtime 替换。
|
||||
- 进程树回收。
|
||||
- 受控配置不会读取工作区 `.env` 和用户 DSH 配置。
|
||||
|
||||
### 17.3 真实模型测试
|
||||
|
||||
真实测试已经获得用户授权,但必须由显式环境门禁启用。至少验证:
|
||||
|
||||
1. 文本问答成功,并记录正确 Runtime 和模型用量。
|
||||
2. Ask 可以读取工作区,但写入被拒绝,且不会弹出权限对话框。
|
||||
3. Execute 可以在工作区创建测试文件。
|
||||
4. Execute 越界操作先被拒绝,再对完全相同的重试自动给予单次授权,全程不弹出审批。
|
||||
5. 不匹配的重试、Ask 和 delegation 不能换路径或重复绕过。
|
||||
6. 取消长请求后不再产生文本,并可继续使用其他 Session。
|
||||
7. 两个 Session 可并行,事件不会串线。
|
||||
8. 释放会话和关闭应用后没有残留 Harness 或工具进程。
|
||||
9. 从全新用户设置流程启用一个 3D 游戏 Skill 和实际本地或开放 MCP,工具事件能够证明二者确实被调用。
|
||||
10. Harness 生成的 3D 游戏项目可以安装、启动和实际游玩,包含 3D 渲染、玩家控制、目标和反馈,浏览器无关键错误。
|
||||
|
||||
测试不得打印、快照或提交 API Key。测试创建的文件只能位于专用临时工作区,并在确认可再现后清理。
|
||||
|
||||
### 17.4 项目验证
|
||||
|
||||
源码完成后必须运行:
|
||||
|
||||
```text
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
涉及发布资源后,还要按可用原生平台运行聚焦的 `release:package` 验证。无法在当前主机执行的目标必须由六平台 CI 验证。
|
||||
|
||||
## 18. 验收标准
|
||||
|
||||
功能只有同时满足以下条件才算完成:
|
||||
|
||||
- `deepseek-harness` 可被保存、选择、检测和显示。
|
||||
- Runtime 详情卡内显示状态、路径、版本和沙箱强制程度。
|
||||
- Skills 与 MCP 设置页可把能力分配给 DeepSeek Harness,布局、键盘语义、文案和保存回显通过真机检查。
|
||||
- Ask 写入测试在 Runtime 边界失败。
|
||||
- Execute 工作区内写入成功。
|
||||
- 越界写入只有同一操作获得自动单次授权后才能执行一次,且不弹出审批。
|
||||
- 取消、超时、切换 Runtime 和退出应用均能回收进程。
|
||||
- 多会话不串流、不串权限请求、不串用量。
|
||||
- 用户 DSH 配置、`.env`、遥测和 Web UI 未被加载。
|
||||
- API Key 不进入 Renderer、配置文件、日志、错误文本或测试产物。
|
||||
- 全量测试、类型检查、Lint 和生产构建通过。
|
||||
- 真实 DeepSeek 请求成功。
|
||||
- 真实请求调用已分配 Skill 和 MCP,并生成、启动和实际游玩一个可用的 3D 游戏项目。
|
||||
- 新增第三方许可证和发布校验完整。
|
||||
|
||||
## 19. 已知限制
|
||||
|
||||
- DeepSeek Harness 底层库当前是 RC,但 GoodBuddy 不自动跟随升级;每次升级都可能要求同步修改内部控制面。
|
||||
- Windows ACL 和部分 Linux Landlock 环境只能提供部分强制执行。
|
||||
- 首版不恢复 Harness 原生 Session,Runtime 重启后由 GoodBuddy 历史重建。
|
||||
- 首版不支持图片、知识库、浏览器工具和 Harness Subagent;MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。
|
||||
- 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
|
||||
- 不支持 DSH 插件、市场包、用户 profile 或自定义 Host。
|
||||
|
||||
## 20. 自维护与升级策略
|
||||
|
||||
GoodBuddy 对该 Runtime 采用内部维护策略:
|
||||
|
||||
1. 当前通过验证的 Host、控制协议和依赖锁定随 GoodBuddy 一起版本化。
|
||||
2. 不自动跟随 DSH RC、插件 ABI、profile 格式或市场元数据变化。
|
||||
3. 升级前审查实际用户收益、上游 diff、沙箱与工具语义、协议行为、依赖闭包和许可证。
|
||||
4. 六个平台的单元、假模型、UtilityProcess、沙箱和真实模型门禁全部通过后才能更新基线。
|
||||
5. 若上游方向不再满足 GoodBuddy 用户需求或安全边界,允许维护兼容补丁、替换单个底层包,或逐步移除 DSH 依赖;`goodbuddy/*` 内部协议保持由 GoodBuddy 控制。
|
||||
6. 不以进入官方插件目录、适配市场机制或服务非 GoodBuddy 客户端作为目标。
|
||||
|
||||
## 21. 备选方案记录
|
||||
|
||||
### 21.1 每次调用 `dsh --profile headless`
|
||||
|
||||
未采用。它适合一次性任务,但不能满足流式事件、多会话、细粒度取消、权限回传和低延迟复用。
|
||||
|
||||
### 21.2 只使用官方 ACP 插件
|
||||
|
||||
未采用。取消和一次性权限选择符合需求,但缺少工具、推理、用量和会话释放事件。
|
||||
|
||||
### 21.3 只使用官方 SDK JSON-RPC
|
||||
|
||||
未采用。事件更完整,但单轮取消和权限回传能力不足。
|
||||
|
||||
### 21.4 把全部安全逻辑放进 Harness 子进程
|
||||
|
||||
未采用。Harness 子进程属于不可信执行面,不能拥有最终模式授权策略、加密设置和进程回收权限。
|
||||
|
||||
### 21.5 把全部控制适配放在 Main
|
||||
|
||||
未采用。Main 无法可靠观察 Cordis 内部 Session、Tool、Usage 和权限 seam,只能得到不完整的外部进程行为。
|
||||
|
||||
当前选择的双层内部控制面放弃标准 DSH 插件形态,只复用锁定的底层库,并维持 GoodBuddy 的可信 Main 控制权。
|
||||
+71
-1
@@ -4,14 +4,84 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@agentclientprotocol/sdk',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/dsh-agent',
|
||||
'@deepseek-ai/dsh-agent-loop',
|
||||
'@deepseek-ai/dsh-bash-sandbox',
|
||||
'@deepseek-ai/dsh-credentials',
|
||||
'@deepseek-ai/dsh-fs-sandbox',
|
||||
'@deepseek-ai/dsh-llm',
|
||||
'@deepseek-ai/dsh-llm-pi-ai',
|
||||
'@deepseek-ai/dsh-pwsh-sandbox',
|
||||
'@deepseek-ai/dsh-sandbox',
|
||||
'@deepseek-ai/dsh-sandbox-local',
|
||||
'@deepseek-ai/dsh-sandbox-policy',
|
||||
'@deepseek-ai/dsh-session',
|
||||
'@deepseek-ai/dsh-shell-env',
|
||||
'@deepseek-ai/dsh-skill',
|
||||
'@deepseek-ai/dsh-subprocess-local',
|
||||
'@deepseek-ai/dsh-system-prompt',
|
||||
'@deepseek-ai/dsh-token-meter',
|
||||
'@deepseek-ai/dsh-tool-bash',
|
||||
'@deepseek-ai/dsh-tool-fs',
|
||||
'@deepseek-ai/dsh-tool-pwsh',
|
||||
'@deepseek-ai/dsh-tool-skill',
|
||||
'@deepseek-ai/dsh-tools',
|
||||
'@deepseek-ai/dsh-user-approval',
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
})
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
),
|
||||
'deepseek-harness-host-bootstrap': resolve(
|
||||
'src/main/deepseek-harness-host-bootstrap.ts'
|
||||
)
|
||||
},
|
||||
external: [
|
||||
'node-pty',
|
||||
'koffi',
|
||||
/^@koromix\/koffi-/u,
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl/runner',
|
||||
/^@deepseek-ai\/node-addon-landlock-run-/u
|
||||
],
|
||||
output: {
|
||||
entryFileNames(chunk) {
|
||||
return chunk.name === 'deepseek-harness-host-bootstrap'
|
||||
? 'deepseek-harness-host-bootstrap.js'
|
||||
: '[name].js'
|
||||
},
|
||||
chunkFileNames(chunk) {
|
||||
const moduleIds = chunk.moduleIds.join('\n')
|
||||
return moduleIds.includes('deepseek-harness') ||
|
||||
moduleIds.includes('deepseek-harness-utility')
|
||||
? 'chunks/deepseek-harness-[name]-[hash].js'
|
||||
: 'chunks/[name]-[hash].js'
|
||||
},
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes('@deepseek-ai/dsh-llm') ||
|
||||
id.includes('@deepseek-ai/dsh-credentials') ||
|
||||
id.includes('@deepseek-ai/dsh-settings') ||
|
||||
id.includes('@deepseek-ai/dsh-timeout') ||
|
||||
id.includes('@deepseek-ai/dsh-token-meter') ||
|
||||
id.includes('@deepseek-ai/dsh-llm-pi-ai') ||
|
||||
id.includes('@mariozechner/pi-ai')
|
||||
) {
|
||||
return 'deepseek-harness-llm'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2053
-6
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,8 @@
|
||||
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
|
||||
"build": "npm run typecheck && npm run build:bundle",
|
||||
"build:bundle": "electron-vite build",
|
||||
"smoke:deepseek-harness": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs",
|
||||
"smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs",
|
||||
"release:notes:verify": "node build/release-notes.cjs",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
||||
@@ -49,6 +51,21 @@
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"beforePack": "build/runtime-hooks.cjs",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"out/main/package.json",
|
||||
"out/main/deepseek-harness-*",
|
||||
"out/main/chunks/**/*",
|
||||
"node_modules/node-pty/lib/**/*",
|
||||
"node_modules/node-pty/package.json",
|
||||
"node_modules/node-pty/prebuilds/**/*",
|
||||
"node_modules/node-pty/build/Release/**/*",
|
||||
"node_modules/koffi/**/*",
|
||||
"node_modules/@koromix/koffi-*/**/*",
|
||||
"node_modules/@deepseek-ai/dsh-sandbox-windows-acl/**/*",
|
||||
"node_modules/@deepseek-ai/node-addon-landlock-run/**/*",
|
||||
"node_modules/@deepseek-ai/node-addon-landlock-run-*/**/*"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"compression": "maximum",
|
||||
"files": [
|
||||
"out/**/*",
|
||||
@@ -90,6 +107,26 @@
|
||||
"from": "node_modules/opencode-ai/LICENSE",
|
||||
"to": "licenses/opencode-ai-LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@deepseek-ai/dsh-agent/LICENSE",
|
||||
"to": "licenses/deepseek-harness-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@deepseek-ai/cordis/LICENSE",
|
||||
"to": "licenses/deepseek-cordis-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@agentclientprotocol/sdk/LICENSE",
|
||||
"to": "licenses/agent-client-protocol-Apache-2.0.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/node-pty/LICENSE",
|
||||
"to": "licenses/node-pty-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/koffi/LICENSE.txt",
|
||||
"to": "licenses/koffi-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@continuedev/cli",
|
||||
"to": "runtimes/continue",
|
||||
@@ -178,7 +215,32 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-shell-env": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-skill": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@opencode-ai/sdk": "^1.18.9",
|
||||
"@wecom/aibot-node-sdk": "^1.0.6",
|
||||
@@ -238,5 +300,15 @@
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@deepseek-ai/node-addon-landlock-run-linux-arm64": "0.1.1",
|
||||
"@deepseek-ai/node-addon-landlock-run-linux-x64": "0.1.1",
|
||||
"@koromix/koffi-darwin-arm64": "3.1.4",
|
||||
"@koromix/koffi-darwin-x64": "3.1.4",
|
||||
"@koromix/koffi-linux-arm64": "3.1.4",
|
||||
"@koromix/koffi-linux-x64": "3.1.4",
|
||||
"@koromix/koffi-win32-arm64": "3.1.4",
|
||||
"@koromix/koffi-win32-x64": "3.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
id: web-3d-game
|
||||
name: Web 3D Game
|
||||
version: 1.0.0
|
||||
description: |
|
||||
设计、实现并验证无需外部网络或安装依赖即可运行的浏览器 3D 游戏。
|
||||
当用户要求制作可玩的 WebGL 游戏、3D 关卡、交互式 3D Demo 或需要实际启动和游玩验收时使用。
|
||||
tags:
|
||||
- WebGL
|
||||
- 3D 游戏
|
||||
- 浏览器
|
||||
- 交互原型
|
||||
---
|
||||
|
||||
# 浏览器 3D 游戏
|
||||
|
||||
目标不是静态页面或“看起来像 3D”的插画,而是一个能启动、能操作、有明确目标和反馈、经过实际游玩验证的 3D 游戏。
|
||||
|
||||
## 开始前
|
||||
|
||||
1. 先检查工作区,不覆盖用户已有文件。若目录非空,创建独立子目录。
|
||||
2. 若会话提供游戏设计、关卡或资产类 MCP 工具,必须先调用适用工具,并将其结果转化为实现约束。不得声称调用了未实际调用的工具。
|
||||
3. 明确一个短小但完整的玩法循环:移动或跳跃、收集或躲避、达成目标、胜利或失败、重新开始。
|
||||
4. 默认制作单人、离线、键盘可玩的游戏。除非用户明确要求,不加入账号、遥测、广告、远程资源或联网功能。
|
||||
|
||||
## 技术边界
|
||||
|
||||
- 默认使用原生 HTML、CSS、JavaScript 和 WebGL2。可以在项目内实现小型向量/矩阵辅助函数,但不得引用 CDN、远程字体、远程贴图或运行时网络请求。
|
||||
- 不要求 `npm install`。若需要本地服务器,使用 Node.js 标准库编写 `server.mjs`,仅绑定 `127.0.0.1`,并限制在游戏目录内提供静态文件。
|
||||
- WebGL2 不可用时显示可读错误,不得用空白画布静默失败。
|
||||
- 使用透视投影、深度测试、可辨识的相机运动和至少一种明暗或雾效,确保场景是真实 3D 渲染,而不是 Canvas 2D 伪装。
|
||||
- 游戏循环使用 `requestAnimationFrame`,限制异常大的 delta time;窗口尺寸和 device pixel ratio 变化时正确调整画布。
|
||||
- 不读取工作区外文件,不执行下载脚本,不把密钥、环境变量或本机路径写入游戏。
|
||||
|
||||
## 最小项目结构
|
||||
|
||||
创建并说明下列文件。可按实际需要拆分更多本地模块,但所有引用必须留在项目目录:
|
||||
|
||||
- `index.html`:画布、HUD、开始/暂停/结束界面和键盘说明。
|
||||
- `styles.css`:响应式布局、清晰焦点、可读对比度和状态反馈。
|
||||
- `game.js`:渲染、输入、物理/碰撞、规则、音画反馈和测试接口。
|
||||
- `server.mjs`:无依赖本地静态服务器,或在 README 中说明为何可直接打开。
|
||||
- `README.md`:启动命令、URL、控制方式、目标、文件结构和已执行的验收。
|
||||
|
||||
## 可玩性要求
|
||||
|
||||
游戏至少包含:
|
||||
|
||||
- WASD 与方向键的等价移动;需要跳跃时支持 Space。
|
||||
- 明确的玩家实体、地面/平台、边界和相机跟随。
|
||||
- 至少一个有空间位置的目标集合,以及一个会改变游戏状态的障碍、计时或敌对机制。
|
||||
- HUD 显示目标进度和当前状态。
|
||||
- 收集、受击、解锁、胜利和失败中的适用反馈,可使用几何动画、颜色、屏幕提示和 Web Audio 合成音效。
|
||||
- 开始、暂停/继续、胜利或失败后的重新开始路径。
|
||||
- 页面失焦时清理按键状态,防止输入卡住。
|
||||
- 不依赖刷新页面即可重开一局。
|
||||
|
||||
首次进入页面不得因为浏览器音频策略而报错。仅在用户首次交互后创建或恢复 AudioContext。
|
||||
|
||||
## 可测试接口
|
||||
|
||||
在不改变正常玩法的前提下公开一个只读为主的测试表面:
|
||||
|
||||
```js
|
||||
window.__GOODBUDDY_GAME__ = {
|
||||
version: 1,
|
||||
getState() {
|
||||
return {
|
||||
status: 'ready',
|
||||
score: 0,
|
||||
target: 5,
|
||||
player: { x: 0, y: 0, z: 0 }
|
||||
}
|
||||
},
|
||||
setInput(action, active) {},
|
||||
reset() {}
|
||||
}
|
||||
```
|
||||
|
||||
- `status` 至少区分 `ready`、`playing`、`won` 和适用的 `lost`/`paused`。
|
||||
- `getState()` 只返回有界、可序列化的游戏状态,不返回 WebGL 对象或隐私数据。
|
||||
- `setInput()` 接受与真实按键相同的动作语义,用于自动化游玩,不得直接加分或跳过规则。
|
||||
- `reset()` 与界面中的重开按钮走同一条状态重置路径。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 建立静态项目和启动方式,先确认页面可访问。
|
||||
2. 完成着色器编译、网格、透视相机、深度测试和 resize。
|
||||
3. 完成玩家控制、碰撞和相机跟随。
|
||||
4. 加入目标、障碍和完整状态机。
|
||||
5. 加入 HUD、开始/结束界面、视觉和音频反馈。
|
||||
6. 加入测试接口和 README。
|
||||
7. 启动本地服务器并实际游玩,不只检查源代码。
|
||||
|
||||
## 验收门禁
|
||||
|
||||
完成前必须验证:
|
||||
|
||||
1. 启动命令能从全新终端成功运行,且只监听 loopback。
|
||||
2. 页面加载后没有 uncaught exception、着色器错误、404 或外部网络请求。
|
||||
3. 真实键盘可以开始、移动、完成核心目标并触发胜利或失败。
|
||||
4. 碰撞不会让玩家稳定穿过地面、边界或关键障碍。
|
||||
5. HUD 进度与 `window.__GOODBUDDY_GAME__.getState()` 一致。
|
||||
6. 使用 `setInput()` 也能通过同一玩法规则推进游戏,`reset()` 能恢复初始状态。
|
||||
7. 至少测试一次 resize 和页面失焦后的输入恢复。
|
||||
8. README 记录实际执行过的命令与结果,不把计划写成已验证事实。
|
||||
|
||||
若受当前环境限制无法启动浏览器或完成某项验证,明确列出未验证项和阻塞原因;不得将“文件已生成”表述为“游戏已可玩”。
|
||||
@@ -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'
|
||||
|
||||
@@ -49,6 +49,25 @@ interface ReleaseBuilderModule {
|
||||
destination: string,
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
verifyHarnessPackage: (
|
||||
resources: string,
|
||||
options: ReleaseOptions,
|
||||
dependencies?: {
|
||||
listPackage: (asarPath: string) => string[]
|
||||
statFile: (
|
||||
asarPath: string,
|
||||
filePath: string
|
||||
) => {
|
||||
files?: Record<string, unknown>
|
||||
link?: string
|
||||
size?: number
|
||||
}
|
||||
extractFile: (
|
||||
asarPath: string,
|
||||
filePath: string
|
||||
) => Buffer
|
||||
}
|
||||
) => void
|
||||
verifyArtifacts: (
|
||||
directory: string,
|
||||
options: ReleaseOptions
|
||||
@@ -237,6 +256,45 @@ describe('release build arguments', () => {
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('pins and unpacks every target-specific Harness native package', () => {
|
||||
const packageJson = require('../package.json') as {
|
||||
build: {
|
||||
asarUnpack: string[]
|
||||
npmRebuild: boolean
|
||||
}
|
||||
optionalDependencies: Record<string, string>
|
||||
}
|
||||
expect(packageJson.build.npmRebuild).toBe(false)
|
||||
expect(packageJson.build.asarUnpack).toEqual(
|
||||
expect.arrayContaining([
|
||||
'out/main/package.json',
|
||||
'out/main/deepseek-harness-*',
|
||||
'out/main/chunks/**/*',
|
||||
'node_modules/node-pty/lib/**/*',
|
||||
'node_modules/node-pty/package.json',
|
||||
'node_modules/node-pty/prebuilds/**/*',
|
||||
'node_modules/node-pty/build/Release/**/*',
|
||||
'node_modules/koffi/**/*',
|
||||
'node_modules/@koromix/koffi-*/**/*',
|
||||
'node_modules/@deepseek-ai/dsh-sandbox-windows-acl/**/*',
|
||||
'node_modules/@deepseek-ai/node-addon-landlock-run/**/*',
|
||||
'node_modules/@deepseek-ai/node-addon-landlock-run-*/**/*'
|
||||
])
|
||||
)
|
||||
expect(packageJson.optionalDependencies).toEqual({
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-arm64':
|
||||
'0.1.1',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64':
|
||||
'0.1.1',
|
||||
'@koromix/koffi-darwin-arm64': '3.1.4',
|
||||
'@koromix/koffi-darwin-x64': '3.1.4',
|
||||
'@koromix/koffi-linux-arm64': '3.1.4',
|
||||
'@koromix/koffi-linux-x64': '3.1.4',
|
||||
'@koromix/koffi-win32-arm64': '3.1.4',
|
||||
'@koromix/koffi-win32-x64': '3.1.4'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('release binary architecture detection', () => {
|
||||
@@ -255,6 +313,32 @@ describe('release binary architecture detection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('release Harness package verification', () => {
|
||||
it('fails closed when the packaged Harness Host is missing', () => {
|
||||
const directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-harness-closure-')
|
||||
)
|
||||
const resources = join(directory, 'resources')
|
||||
try {
|
||||
mkdirSync(resources, { recursive: true })
|
||||
writeFileSync(join(resources, 'app.asar'), 'asar')
|
||||
expect(() =>
|
||||
releaseBuilder.verifyHarnessPackage(
|
||||
resources,
|
||||
windowsOptions,
|
||||
{
|
||||
listPackage: () => [],
|
||||
statFile: () => ({ size: 1 }),
|
||||
extractFile: () => Buffer.from('{}')
|
||||
}
|
||||
)
|
||||
).toThrow('DeepSeek Harness Host缺失')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('release output safety', () => {
|
||||
it('preserves an existing portable ZIP when exclusive creation fails', async () => {
|
||||
const directory = mkdtempSync(
|
||||
|
||||
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
import process from 'node:process'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'goodbuddy-web-3d-game-fixture',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const blueprints = {
|
||||
'neon-ruins': {
|
||||
title: 'Prism Relay',
|
||||
palette: {
|
||||
sky: '#08111f',
|
||||
floor: '#17243d',
|
||||
player: '#68f6ff',
|
||||
collectible: '#ffd166',
|
||||
hazard: '#ff4d6d',
|
||||
exit: '#7bf1a8'
|
||||
},
|
||||
setting:
|
||||
'A compact neon ruin suspended above a dark energy field.'
|
||||
},
|
||||
'sky-temple': {
|
||||
title: 'Aether Beacon',
|
||||
palette: {
|
||||
sky: '#87ceeb',
|
||||
floor: '#d9c7a3',
|
||||
player: '#235789',
|
||||
collectible: '#f6ae2d',
|
||||
hazard: '#d1495b',
|
||||
exit: '#2a9d8f'
|
||||
},
|
||||
setting:
|
||||
'A bright floating temple built from stone platforms and wind gates.'
|
||||
},
|
||||
'crystal-cavern': {
|
||||
title: 'Crystal Circuit',
|
||||
palette: {
|
||||
sky: '#09051a',
|
||||
floor: '#241b4b',
|
||||
player: '#8be9fd',
|
||||
collectible: '#f1fa8c',
|
||||
hazard: '#ff79c6',
|
||||
exit: '#50fa7b'
|
||||
},
|
||||
setting:
|
||||
'A luminous cavern whose crystal relays awaken an ancient portal.'
|
||||
}
|
||||
}
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'create_game_blueprint',
|
||||
title: 'Create a deterministic 3D game blueprint',
|
||||
description:
|
||||
'Returns a bounded offline WebGL game design with controls, level geometry, rules, and play-test acceptance criteria.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: {
|
||||
type: 'string',
|
||||
enum: ['neon-ruins', 'sky-temple', 'crystal-cavern']
|
||||
},
|
||||
seed: { type: 'string' },
|
||||
targetCount: { type: 'number' }
|
||||
},
|
||||
required: ['theme', 'seed', 'targetCount'],
|
||||
additionalProperties: false
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name !== 'create_game_blueprint') {
|
||||
throw new Error('Unknown tool')
|
||||
}
|
||||
const args = request.params.arguments
|
||||
const theme = args?.theme
|
||||
const seed = args?.seed
|
||||
const targetCount = args?.targetCount
|
||||
if (
|
||||
(theme !== 'neon-ruins' &&
|
||||
theme !== 'sky-temple' &&
|
||||
theme !== 'crystal-cavern') ||
|
||||
typeof seed !== 'string' ||
|
||||
seed.trim().length === 0 ||
|
||||
seed.length > 64 ||
|
||||
typeof targetCount !== 'number' ||
|
||||
!Number.isInteger(targetCount) ||
|
||||
targetCount < 3 ||
|
||||
targetCount > 8
|
||||
) {
|
||||
throw new Error('Invalid blueprint arguments')
|
||||
}
|
||||
const selected = blueprints[theme]
|
||||
const blueprint = {
|
||||
schemaVersion: 1,
|
||||
seed,
|
||||
title: selected.title,
|
||||
setting: selected.setting,
|
||||
renderer: {
|
||||
api: 'WebGL2',
|
||||
projection: 'perspective',
|
||||
requiredFeatures: [
|
||||
'depth-test',
|
||||
'directional-light',
|
||||
'distance-fog',
|
||||
'resize-aware-canvas'
|
||||
],
|
||||
networkAssets: false
|
||||
},
|
||||
player: {
|
||||
spawn: [0, 0.6, 6],
|
||||
moveSpeed: 5.5,
|
||||
jumpVelocity: 7.5,
|
||||
controls: {
|
||||
move: ['WASD', 'Arrow keys'],
|
||||
jump: ['Space'],
|
||||
pause: ['Escape'],
|
||||
restart: ['R']
|
||||
}
|
||||
},
|
||||
objective: {
|
||||
type: 'collect-and-exit',
|
||||
collectible: 'energy prism',
|
||||
targetCount,
|
||||
exitUnlocksAt: targetCount,
|
||||
victoryText: 'Relay synchronized'
|
||||
},
|
||||
level: {
|
||||
bounds: { x: [-12, 12], z: [-10, 10], fallY: -5 },
|
||||
platforms: [
|
||||
{ center: [0, 0, 0], size: [18, 1, 14] },
|
||||
{ center: [-8, 1.5, -5], size: [5, 1, 4] },
|
||||
{ center: [8, 2.5, -4], size: [5, 1, 5] }
|
||||
],
|
||||
hazards: [
|
||||
{
|
||||
kind: 'moving-energy-bar',
|
||||
axis: 'x',
|
||||
range: [-6, 6],
|
||||
speed: 2.4,
|
||||
penalty: 'reset-player-and-increment-hits'
|
||||
}
|
||||
],
|
||||
exit: { center: [0, 1, -8], lockedColor: '#56606f' }
|
||||
},
|
||||
palette: selected.palette,
|
||||
feedback: [
|
||||
'collectible-pulse-and-chime',
|
||||
'hazard-flash-and-low-tone',
|
||||
'exit-unlock-color-change',
|
||||
'victory-overlay-with-restart'
|
||||
],
|
||||
acceptance: {
|
||||
minimumFramesObserved: 30,
|
||||
requiredStates: ['ready', 'playing', 'won'],
|
||||
testSurface: 'window.__GOODBUDDY_GAME__',
|
||||
checks: [
|
||||
'keyboard and test inputs share the same action state',
|
||||
'collecting every prism unlocks the exit',
|
||||
'entering the unlocked exit wins',
|
||||
'restart restores score, player, hazards, and exit',
|
||||
'no external network requests or console errors'
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(blueprint)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await server.connect(
|
||||
new StdioServerTransport(process.stdin, process.stdout, {
|
||||
maxBufferSize: 1024 * 1024
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user