feat: expand multi-runtime workflows for 0.10.0

GoodBuddy previously exposed Runtime capabilities, MCP assignments, plugin controls, context compaction, usage reporting, and task-completion behavior through incomplete or inconsistent paths. This release unifies managed OpenCode, Continue, and DeepSeek Harness controls; adds DSH plugin and image workflows; strengthens MCP and Runtime lifecycle bounds; fixes Windows notification activation; validates cross-architecture packages; and presents the approved bilingual four-section release notes.

The DSH plugin marketplace remains a default-off preview whose trusted third-party code runs with current-user permissions. Ask remains read-only, Execute keeps approval controls, and context compaction may use the selected model without deleting GoodBuddy chat history.

Release note: GoodBuddy 0.10.0 重点完善多 Runtime 工作流,统一 OpenCode、Continue 与 DeepSeek Harness 的能力、MCP、插件和上下文管理,并提升长对话、多会话与任务通知的连贯性。
This commit is contained in:
mesalogo
2026-08-17 12:57:12 +08:00
parent f5ee9b2198
commit cbbe30896e
50 changed files with 4077 additions and 605 deletions
+7 -1
View File
@@ -138,7 +138,7 @@ npm run dist:linux:arm64
- OpenCode 平台二进制来自 `.runtime-resources/<arch>` - OpenCode 平台二进制来自 `.runtime-resources/<arch>`
- Continue Runtime 来自锁定版本的 `@continuedev/cli` - Continue Runtime 来自锁定版本的 `@continuedev/cli`
- DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。 - DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。
- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与当前平台的 Skia 原生包必须从 `app.asar` 解包;发布校验会检查版本、目标架构和 MIT 许可证。 - DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与目标平台、目标架构的 Skia 原生包必须从 `app.asar` 解包;当打包 Runner 的架构与目标架构不同时,发布脚本会根据 lockfile 的精确版本、下载地址和 integrity 临时暂存目标原生包,完成后清理。发布校验会检查版本、目标架构和 MIT 许可证。
- 打包钩子位于 `build/runtime-hooks.cjs` - 打包钩子位于 `build/runtime-hooks.cjs`
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。 跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
@@ -172,6 +172,12 @@ manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建
更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工 更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工
编辑的 Release notes 和未知附件。 编辑的 Release notes 和未知附件。
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
请留意 / Before You Start”四段组织,应用首次启动弹窗与 GitHub Release
正文共用该来源。旧版两段式记录会兼容读取,无需改写。提交发布候选前运行
`npm run release:notes:verify` 校验版本、双语条目数量并生成 Markdown。
发布标签必须与 `package.json` 版本完全一致。实际推送标签和触发发布前仍 发布标签必须与 `package.json` 版本完全一致。实际推送标签和触发发布前仍
需人工确认,例如当前版本应使用: 需人工确认,例如当前版本应使用:
+74 -23
View File
@@ -527,14 +527,39 @@ function targetHarnessPaths(options) {
function targetRuntimePackageNames(options) { function targetRuntimePackageNames(options) {
const target = targetHarnessPaths(options) const target = targetHarnessPaths(options)
return [target.koffiPackage] return [target.koffiPackage, target.canvasPackage]
} }
function lockedTargetRuntimePackage(packageName) { function lockedTargetRuntimePackage(
const expectedVersion = packageName,
packageJson.optionalDependencies?.[packageName] packageMetadata = packageJson,
lockMetadata = packageLock
) {
let expectedVersion =
packageMetadata.optionalDependencies?.[packageName]
if (
typeof expectedVersion !== 'string' &&
packageName.startsWith('@napi-rs/canvas-')
) {
const canvasPackageName = '@napi-rs/canvas'
const canvasVersion =
packageMetadata.dependencies?.[canvasPackageName]
const canvasLockEntry =
lockMetadata.packages?.[`node_modules/${canvasPackageName}`]
if (
typeof canvasVersion !== 'string' ||
canvasLockEntry?.version !== canvasVersion ||
canvasLockEntry.optionalDependencies?.[packageName] !==
canvasVersion
) {
throw new Error(
`目标 Runtime 依赖未完整锁定:${packageName}`
)
}
expectedVersion = canvasVersion
}
const lockEntry = const lockEntry =
packageLock.packages?.[`node_modules/${packageName}`] lockMetadata.packages?.[`node_modules/${packageName}`]
if ( if (
typeof expectedVersion !== 'string' || typeof expectedVersion !== 'string' ||
lockEntry?.version !== expectedVersion || lockEntry?.version !== expectedVersion ||
@@ -548,7 +573,6 @@ function lockedTargetRuntimePackage(packageName) {
return { return {
name: packageName, name: packageName,
version: expectedVersion, version: expectedVersion,
resolved: lockEntry.resolved,
integrity: lockEntry.integrity integrity: lockEntry.integrity
} }
} }
@@ -596,9 +620,13 @@ function verifyArchiveIntegrity(filePath, expectedIntegrity) {
} }
} }
function installedPackageMatches(packageName, expectedVersion) { function installedPackageMatches(
packageName,
expectedVersion,
runtimeRoot = root
) {
const manifestPath = join( const manifestPath = join(
root, runtimeRoot,
'node_modules', 'node_modules',
...packageName.split('/'), ...packageName.split('/'),
'package.json' 'package.json'
@@ -618,14 +646,29 @@ function installedPackageMatches(packageName, expectedVersion) {
return true return true
} }
async function stageTargetRuntimeDependencies(options) { async function stageTargetRuntimeDependencies(
options,
dependencies = {}
) {
const runtimeRoot = dependencies.root ?? root
const runtimePackageJson =
dependencies.packageJson ?? packageJson
const runtimePackageLock =
dependencies.packageLock ?? packageLock
const missing = targetRuntimePackageNames(options) const missing = targetRuntimePackageNames(options)
.map(lockedTargetRuntimePackage) .map((packageName) =>
lockedTargetRuntimePackage(
packageName,
runtimePackageJson,
runtimePackageLock
)
)
.filter( .filter(
(dependency) => (dependency) =>
!installedPackageMatches( !installedPackageMatches(
dependency.name, dependency.name,
dependency.version dependency.version,
runtimeRoot
) )
) )
if (missing.length === 0) { if (missing.length === 0) {
@@ -644,14 +687,27 @@ async function stageTargetRuntimeDependencies(options) {
} }
try { try {
const npm = npmInvocation() const npm = dependencies.npmInvocation?.() ?? npmInvocation()
const captureCommand =
dependencies.runCapture ?? runCapture
const extractArchive =
dependencies.extractArchive ??
((archivePath, destination) =>
run('tar', [
'-xzf',
archivePath,
'-C',
destination,
'--strip-components',
'1'
]))
for (const [index, dependency] of missing.entries()) { for (const [index, dependency] of missing.entries()) {
const archiveDirectory = join( const archiveDirectory = join(
stagingRoot, stagingRoot,
`package-${index}` `package-${index}`
) )
mkdirSync(archiveDirectory, { recursive: true }) mkdirSync(archiveDirectory, { recursive: true })
const output = await runCapture(npm.command, [ const output = await captureCommand(npm.command, [
...npm.prefixArgs, ...npm.prefixArgs,
'pack', 'pack',
`${dependency.name}@${dependency.version}`, `${dependency.name}@${dependency.version}`,
@@ -671,7 +727,7 @@ async function stageTargetRuntimeDependencies(options) {
verifyArchiveIntegrity(archivePath, dependency.integrity) verifyArchiveIntegrity(archivePath, dependency.integrity)
const destination = join( const destination = join(
root, runtimeRoot,
'node_modules', 'node_modules',
...dependency.name.split('/') ...dependency.name.split('/')
) )
@@ -682,18 +738,12 @@ async function stageTargetRuntimeDependencies(options) {
} }
mkdirSync(destination, { recursive: true }) mkdirSync(destination, { recursive: true })
stagedDirectories.push(destination) stagedDirectories.push(destination)
await run('tar', [ await extractArchive(archivePath, destination)
'-xzf',
archivePath,
'-C',
destination,
'--strip-components',
'1'
])
if ( if (
!installedPackageMatches( !installedPackageMatches(
dependency.name, dependency.name,
dependency.version dependency.version,
runtimeRoot
) )
) { ) {
throw new Error( throw new Error(
@@ -1619,6 +1669,7 @@ module.exports = {
parseArguments, parseArguments,
parsePackedPackageMetadata, parsePackedPackageMetadata,
platformDefinitions, platformDefinitions,
lockedTargetRuntimePackage,
replaceOutput, replaceOutput,
stageTargetRuntimeDependencies, stageTargetRuntimeDependencies,
targetRuntimePackageNames, targetRuntimePackageNames,
+58 -19
View File
@@ -32,13 +32,21 @@ function validateItems(value, label) {
fail(`${label} contains a non-string item`) fail(`${label} contains a non-string item`)
} }
const normalized = item.trim() const normalized = item.trim()
if (!normalized || normalized.length > 240) { if (!normalized || normalized.length > 500) {
fail(`${label} contains an empty or oversized item`) fail(`${label} contains an empty or oversized item`)
} }
return normalized return normalized
}) })
} }
const releaseNoteSections = [
'highlights',
'features',
'fixes',
'notices'
]
const legacyReleaseNoteSections = ['features', 'fixes']
function validateRelease(value, index) { function validateRelease(value, index) {
const label = `releases[${index}]` const label = `releases[${index}]`
if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) { if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) {
@@ -63,28 +71,50 @@ function validateRelease(value, index) {
const notes = Object.fromEntries( const notes = Object.fromEntries(
['zh-CN', 'en-US'].map((locale) => { ['zh-CN', 'en-US'].map((locale) => {
const localized = value.notes[locale] const localized = value.notes[locale]
if (!hasExactKeys(localized, ['features', 'fixes'])) { const isCurrentFormat = hasExactKeys(
localized,
releaseNoteSections
)
const isLegacyFormat = hasExactKeys(
localized,
legacyReleaseNoteSections
)
if (!isCurrentFormat && !isLegacyFormat) {
fail(`${label}.notes.${locale} has invalid fields`) fail(`${label}.notes.${locale} has invalid fields`)
} }
const features = validateItems( const normalized = Object.fromEntries(
localized.features, releaseNoteSections.map((section) => [
`${label}.notes.${locale}.features` section,
section in localized
? validateItems(
localized[section],
`${label}.notes.${locale}.${section}`
)
: []
])
) )
const fixes = validateItems( if (normalized.highlights.length > 3) {
localized.fixes, fail(
`${label}.notes.${locale}.fixes` `${label}.notes.${locale}.highlights must contain no more than 3 items`
) )
if (features.length + fixes.length === 0) { }
if (
releaseNoteSections.every(
(section) => normalized[section].length === 0
)
) {
fail(`${label}.notes.${locale} must not be empty`) fail(`${label}.notes.${locale} must not be empty`)
} }
return [locale, { features, fixes }] return [locale, normalized]
}) })
) )
if ( for (const section of releaseNoteSections) {
notes['zh-CN'].features.length !== notes['en-US'].features.length || if (
notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length notes['zh-CN'][section].length !==
) { notes['en-US'][section].length
fail(`${label} localized section counts do not match`) ) {
fail(`${label} localized ${section} counts do not match`)
}
} }
return { return {
version: value.version, version: value.version,
@@ -126,14 +156,18 @@ const localizedDefinitions = [
{ {
locale: 'zh-CN', locale: 'zh-CN',
title: `GoodBuddy ${release.version} 更新内容`, title: `GoodBuddy ${release.version} 更新内容`,
highlights: '本次亮点',
features: '功能更新', features: '功能更新',
fixes: '问题修复' fixes: '问题修复',
notices: '使用前请留意'
}, },
{ {
locale: 'en-US', locale: 'en-US',
title: `What's New in GoodBuddy ${release.version}`, title: `What's New in GoodBuddy ${release.version}`,
highlights: 'Highlights',
features: 'Features', features: 'Features',
fixes: 'Bug Fixes' fixes: 'Bug Fixes',
notices: 'Before You Start'
} }
] ]
@@ -151,8 +185,13 @@ const markdown = localizedDefinitions
...(index === 0 ? [] : ['---', '']), ...(index === 0 ? [] : ['---', '']),
`# ${definition.title}`, `# ${definition.title}`,
'', '',
...markdownSection(
definition.highlights,
notes.highlights
),
...markdownSection(definition.features, notes.features), ...markdownSection(definition.features, notes.features),
...markdownSection(definition.fixes, notes.fixes) ...markdownSection(definition.fixes, notes.fixes),
...markdownSection(definition.notices, notes.notices)
] ]
}) })
.join('\n') .join('\n')
+5 -1
View File
@@ -212,7 +212,9 @@ GoodBuddy 控制面自身不导出 `apply(ctx, config)`,也不提供默认 std
- Main 只传递受管 Store 中已启用插件的稳定 ID、规范化入口文件和 JSON 配置。 - Main 只传递受管 Store 中已启用插件的稳定 ID、规范化入口文件和 JSON 配置。
- Launcher 与 Host 对消息结构和绝对入口路径执行严格校验;Host 解析真实路径并要求入口是普通文件。 - Launcher 与 Host 对消息结构和绝对入口路径执行严格校验;Host 解析真实路径并要求入口是普通文件。
- Host 动态加载 Cordis 插件并等待激活,每个插件有独立的 5 秒激活超时,完整插件序列最多占用 90 秒。 - Host 动态加载 Cordis 插件并等待激活,每个插件有独立的 5 秒激活超时,完整插件序列最多占用 90 秒。
- Main 的 Host 启动预算使用 10 秒基础预算,加上每个已启用插件 5 秒激活与最多 1 秒失败清理、且整个插件序列最多占用 91 秒,再预留 2 秒保存失败插件状态;显式测试超时仍作为调用方指定的硬上限。
- 插件按清单依次加载;导入、导出形态或激活失败只记录该插件,不阻止其他插件和 Host 启动。失败 Fiber 的清理同样有界。 - 插件按清单依次加载;导入、导出形态或激活失败只记录该插件,不阻止其他插件和 Host 启动。失败 Fiber 的清理同样有界。
- 有限但超过预算的同步导入或同步 `apply` 在返回后按超时失败并继续加载后续插件;JavaScript 不能在同一事件循环内抢占永不返回的同步第三方代码,此时由 Main 的独立启动截止时间终止整个 Utility。
- 失败 ID 在 ready 握手中返回 Main;Main 原子写入停用状态和启动错误。 - 失败 ID 在 ready 握手中返回 Main;Main 原子写入停用状态和启动错误。
- 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。 - 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。
@@ -335,6 +337,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。 - Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
- Main 在宽限期内等待正常退出。 - Main 在宽限期内等待正常退出。
- 超时后终止 utilityProcess,并在平台允许时清理完整进程树。 - 超时后终止 utilityProcess,并在平台允许时清理完整进程树。
- 应用退出时中止正在运行的 npm 插件安装并终止其完整进程树,不能让 lifecycle script 在 GoodBuddy 退出后继续运行。
- 应用退出不得因 Harness 清理无限阻塞。 - 应用退出不得因 Harness 清理无限阻塞。
## 10. 权限与主机执行 ## 10. 权限与主机执行
@@ -460,7 +463,8 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
| 工具输出摘要 | 4,000 字符 | | 工具输出摘要 | 4,000 字符 |
| 待处理事件数 | 1,000 | | 待处理事件数 | 1,000 |
| stderr 累计 | 64 KiB | | stderr 累计 | 64 KiB |
| 初始化 | 10 秒 | | Host 启动 | 10 秒基础预算 + 每插件 5 秒激活与最多 1 秒失败清理,插件序列最多 91 秒;Main 另预留 2 秒持久化失败状态 |
| ACP 初始化与内部握手 | 每阶段 10 秒 |
| 单次 Prompt | 10 分钟 | | 单次 Prompt | 10 分钟 |
| 有序关闭宽限期 | 2 秒 | | 有序关闭宽限期 | 2 秒 |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.3", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.3", "version": "0.10.0",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1", "@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.3", "version": "0.10.0",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
+62
View File
@@ -1,6 +1,68 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"releases": [ "releases": [
{
"version": "0.10.0",
"releasedAt": "2026-08-17",
"notes": {
"zh-CN": {
"highlights": [
"0.10.0 重点完善多 Runtime 工作流。OpenCode、Continue 和 DeepSeek Harness 的能力、MCP、插件与上下文管理现在可以在 GoodBuddy 内统一查看和配置,长对话、多会话和任务通知也更加连贯。"
],
"features": [
"**Runtime 能力概览。** 切换 Runtime 或排查不可用工具,设置页会展示内置 OpenCode、Continue 和 DeepSeek Harness 实际提供的 Agents、Tools、Commands、Rules、Prompts、Skills、MCP 等能力,并提供 Runtime 默认项与上下文压缩配置。",
"**DSH 插件管理。** DeepSeek Harness 的工具能力可以通过默认关闭的插件市场扩展,支持搜索、安装、更新和启停 npm 插件,并填写插件所需的 JSON 配置。",
"**按 Runtime 分配 MCP。** 知识库、魔法笔记等内置 MCP 可以分别分配给直连模型、OpenCode 和 Continue;自定义 MCP 也能分配给 OpenCode、Continue 和 DeepSeek Harness,同一套工具服务不必重复配置。",
"**上下文压缩。** 内置 OpenCode 默认自动整理上下文,也可手动触发;Continue 可手动生成并复用 GoodBuddy 摘要;直连模型是否自动压缩由用户决定。界面会分别显示本次调用用量和压缩后的对话估算。",
"**DSH 图片输入。** 支持图片的模型连接可以接收经过校验的 JPEG/PNG 截图与图片;文本模型会在调用前明确提示不支持图片,避免提交后才发现无法处理。",
"**多会话并行处理。** 多个会话可以在后台运行任务,另一个会话仍可继续聊天。侧栏会标记活动和未读状态,聊天内容与页面位置也会按会话保留。",
"**Runtime 用量统计。** 运行记录会按 Runtime 和模型归类每次调用,连续工具调用与上下文摘要不再合并计数,并会显示归一化的缓存命中率。"
],
"fixes": [
"**Windows 通知激活。** 任务完成后,点击系统通知会回到 GoodBuddy,不再打开 Electron 默认页;开发版、解包版和安装版也使用相互隔离的通知身份。",
"**直连模型连续对话。** 较长的连续对话和消息重编辑不再因本地消息 ID 被发送给模型服务而失败,工具调用与上下文摘要也不会覆盖前序用量记录。",
"**DSH 插件与 MCP 稳定性。** 多个 DSH 插件共同运行、MCP 返回大量分页工具,启动、取消和退出依然可靠;单个插件失败会被隔离,循环游标和未释放会话也会得到处理。",
"**设置界面一致性。** DSH 搜索文字不再被图标遮挡,正常状态不会重复显示说明,导航高亮与页面边距也保持一致。",
"**跨架构发布包。** Windows、macOS 和 Linux 的 x64、arm64 安装包会校验 Canvas、Koffi 等原生依赖,避免构建成功后加载错误架构的二进制文件。",
"**魔法笔记编辑体验。** 更高且可纵向拖动的编辑区域为长笔记留出空间,数字字号选项、工具栏和分栏间距也更加清楚。"
],
"notices": [
"**DSH 插件市场。** 该功能仍处于预览阶段并默认关闭。第三方插件的安装脚本、初始化代码和 Execute 工具会以当前用户权限运行,请只安装可信的包。关闭市场不会自动停用或卸载已有插件。",
"**工作模式权限。** Ask 模式仍保持只读;Execute 模式下,经过批准的工具会以当前用户权限操作文件、运行命令或访问外部服务。",
"**上下文压缩默认行为。** 内置 OpenCode 默认启用原生自动压缩;直连模型的自动压缩默认关闭;Continue 当前不会自动生成摘要,需要手动执行压缩。压缩可能调用所选模型并产生额外 Token 用量,GoodBuddy 中的原始聊天记录不会删除。",
"**Runtime 兼容性。** 外部 OpenCode Server 当前只提供连接状态,Continue 暂不支持静态发现原生 ToolsDeepSeek Harness 暂不支持内置 MCP,但可在 Execute 模式使用已分配的自定义 MCP。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.10.0 strengthens multi-Runtime workflows. OpenCode, Continue, and DeepSeek Harness capabilities, MCP, plugins, and context controls can now be viewed and configured in one place, with smoother long conversations, parallel work, and task notifications."
],
"features": [
"**Runtime capability overview.** Switching Runtimes or diagnosing unavailable tools now reveals the Agents, Tools, Commands, Rules, Prompts, Skills, MCP, and other capabilities actually provided by managed OpenCode, Continue, and DeepSeek Harness, along with Runtime defaults and context controls.",
"**DSH plugin management.** DeepSeek Harness can be extended through the default-off plugin marketplace, with npm plugin search, installation, updates, enablement, and JSON configuration.",
"**MCP assignment by Runtime.** Built-in MCP servers such as Knowledge and Magic Notes can be assigned individually to direct models, OpenCode, and Continue. Custom MCP can also be shared with OpenCode, Continue, and DeepSeek Harness without duplicating service configuration.",
"**Context compaction.** Managed OpenCode compacts context automatically by default and also provides a manual action. Continue can manually create and reuse GoodBuddy summaries, while users decide whether to enable automatic compaction for direct models. Latest-call usage and compressed-conversation estimates are shown separately.",
"**DSH image input.** Image-capable model connections accept validated JPEG/PNG screenshots and images. GoodBuddy rejects image input with a clear message before invoking a text-only model.",
"**Parallel conversations.** Multiple conversations can keep running tasks in the background while you continue chatting in another. The sidebar marks active and unread conversations, while chat content and page position remain preserved per conversation.",
"**Runtime usage reporting.** Run History groups every call by Runtime and model. Consecutive tool calls and context summaries remain separate usage records, with normalized prompt-cache hit rates for easier comparison."
],
"fixes": [
"**Windows notification activation.** Clicking a task-completion notification now opens GoodBuddy instead of Electrons default page. Development, unpacked, and installed builds also use isolated notification identities.",
"**Direct-model follow-ups.** Long conversations and edited messages no longer fail because local message IDs reached model providers. Tool and context-summary calls also preserve earlier usage records.",
"**DSH plugin and MCP reliability.** Startup, cancellation, and shutdown remain reliable with several DSH plugins or large paginated MCP tool sets. Plugin failures are isolated, cursor loops are bounded, and sessions are released.",
"**Settings consistency.** DSH plugin search text no longer overlaps its icon, healthy status descriptions are no longer duplicated, and navigation highlights and page gutters remain aligned.",
"**Cross-architecture packages.** Windows, macOS, and Linux packages validate Canvas, Koffi, and related native dependencies for x64 and arm64, preventing successful builds from loading binaries for the wrong architecture.",
"**Magic Notes editing.** The editor is taller and vertically resizable, with numeric font-size choices and clearer toolbar and pane spacing."
],
"notices": [
"**DSH plugin marketplace.** This preview feature is disabled by default. Third-party install scripts, initialization code, and Execute tools run with current-user permissions, so install only trusted packages. Disabling the marketplace does not disable or remove installed plugins.",
"**Work mode permissions.** Ask remains read-only. In Execute, approved tools can modify files, run commands, or access external services with current-user permissions.",
"**Context compaction defaults.** Managed OpenCode enables native automatic compaction by default. Automatic compaction for direct models is disabled by default, while Continue requires manual compaction to create a summary. Compaction may call the selected model and incur additional token usage; original chat history in GoodBuddy is not deleted.",
"**Runtime compatibility.** External OpenCode Servers currently expose connection status only, and Continue cannot statically discover native Tools. DeepSeek Harness does not yet support built-in MCP, but assigned custom MCP is available in Execute mode."
]
}
}
},
{ {
"version": "0.9.3", "version": "0.9.3",
"releasedAt": "2026-08-15", "releasedAt": "2026-08-15",
+149 -1
View File
@@ -27,7 +27,10 @@ vi.mock('./runtime-discovery', () => ({
detectRuntimeBinary: mocks.detectRuntimeBinary detectRuntimeBinary: mocks.detectRuntimeBinary
})) }))
import { ContinueAgentRuntime } from './continue-runtime' import {
buildContinuePrompt,
ContinueAgentRuntime
} from './continue-runtime'
function createRuntime(): ContinueAgentRuntime { function createRuntime(): ContinueAgentRuntime {
return new ContinueAgentRuntime({ return new ContinueAgentRuntime({
@@ -618,6 +621,151 @@ describe('ContinueAgentRuntime', () => {
expect(prompt).not.toContain('old secret turn') expect(prompt).not.toContain('old secret turn')
}) })
it('keeps a persisted summary when its covered prefix rolls out of the bounded history window', () => {
const history = Array.from({ length: 500 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `recent message ${index}`
}))
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: history.map(() => randomUUID()),
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted question' },
{ role: 'assistant', content: 'evicted answer' }
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId: randomUUID(),
summary: 'persisted evicted facts'
}
})
expect(prompt).toContain('persisted evicted facts')
expect(prompt).toContain('recent message 499')
expect(prompt).not.toContain('evicted question')
})
it('keeps a persisted summary when filtered messages shorten the bounded history window', () => {
const history = Array.from({ length: 499 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `filtered recent message ${index}`
}))
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'filtered-evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: history.map(() => randomUUID()),
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted question' },
{ role: 'assistant', content: 'evicted answer' }
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId: randomUUID(),
summary: 'persisted facts after filtering'
}
})
expect(prompt).toContain('persisted facts after filtering')
expect(prompt).toContain('filtered recent message 498')
expect(prompt).not.toContain('evicted question')
})
it('keeps a persisted summary when only its covered start rolls out of the history window', () => {
const coveredThroughMessageId = randomUUID()
const history = [
{
role: 'assistant' as const,
content: 'covered answer still at window start'
},
...Array.from({ length: 499 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `later message ${index}`
}))
]
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'partially-evicted-summary-conversation',
prompt: 'continue',
history,
historyMessageIds: [
coveredThroughMessageId,
...history.slice(1).map(() => randomUUID())
],
contextCompressionState: {
coveredHistoryDigest: createHash('sha256')
.update(
JSON.stringify([
{ role: 'user', content: 'evicted covered question' },
history[0]
])
)
.digest('hex'),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId,
summary: 'persisted partially evicted facts'
}
})
expect(prompt).toContain('persisted partially evicted facts')
expect(prompt).toContain('later message 498')
expect(prompt).not.toContain('covered answer still at window start')
})
it('rejects a persisted summary that contradicts the current bounded history window', () => {
const coveredThroughMessageId = randomUUID()
const history = Array.from({ length: 500 }, (_, index) => ({
role:
index % 2 === 0
? ('user' as const)
: ('assistant' as const),
content: `conflicting message ${index}`
}))
const historyMessageIds = history.map(() => randomUUID())
historyMessageIds[10] = coveredThroughMessageId
const prompt = buildContinuePrompt({
requestId: randomUUID(),
conversationId: 'conflicting-summary-conversation',
prompt: 'continue',
history,
historyMessageIds,
contextCompressionState: {
coveredHistoryDigest: '0'.repeat(64),
coveredMessageCount: 2,
coveredFromMessageId: randomUUID(),
coveredThroughMessageId,
summary: 'contradictory summary must not appear'
}
})
expect(prompt).toContain('conflicting message 499')
expect(prompt).not.toContain('contradictory summary must not appear')
})
it('falls back to bounded raw history when a persisted summary is stale', async () => { it('falls back to bounded raw history when a persisted summary is stale', async () => {
const runtime = createRuntime() const runtime = createRuntime()
for await (const _event of runtime.run( for await (const _event of runtime.run(
+57 -24
View File
@@ -110,41 +110,74 @@ function flattenContinueSegment(value: string): string {
.trim() .trim()
} }
function hasValidCompressionPrefix( function getCurrentCompressionPrefixLength(
request: AgentExecutionRequest request: AgentExecutionRequest
): boolean { ): number | undefined {
const state = request.contextCompressionState const state = request.contextCompressionState
const history = request.history const history = request.history
if ( if (
!state || !state ||
!history || !history ||
state.coveredMessageCount <= 0 || state.coveredMessageCount <= 0
state.coveredMessageCount > history.length
) { ) {
return false return undefined
}
const coveredHistory = history.slice(0, state.coveredMessageCount)
if (
createHash('sha256')
.update(JSON.stringify(coveredHistory))
.digest('hex') !== state.coveredHistoryDigest
) {
return false
} }
const ids = request.historyMessageIds const ids = request.historyMessageIds
if ( if (
(state.coveredFromMessageId || state.coveredThroughMessageId) && (state.coveredFromMessageId || state.coveredThroughMessageId) &&
(!ids || ids.length !== history.length) (!ids || ids.length !== history.length)
) { ) {
return false return undefined
} }
return (
(!state.coveredFromMessageId || if (state.coveredMessageCount <= history.length) {
ids?.[0] === state.coveredFromMessageId) && const coveredHistory = history.slice(
(!state.coveredThroughMessageId || 0,
ids?.[state.coveredMessageCount - 1] === state.coveredMessageCount
state.coveredThroughMessageId) )
const digestMatches =
createHash('sha256')
.update(JSON.stringify(coveredHistory))
.digest('hex') === state.coveredHistoryDigest
const boundariesMatch =
(!state.coveredFromMessageId ||
ids?.[0] === state.coveredFromMessageId) &&
(!state.coveredThroughMessageId ||
ids?.[state.coveredMessageCount - 1] ===
state.coveredThroughMessageId)
if (digestMatches && boundariesMatch) {
return state.coveredMessageCount
}
}
if (
!ids ||
!state.coveredFromMessageId ||
!state.coveredThroughMessageId
) {
return undefined
}
const coveredFromIndex = ids.indexOf(
state.coveredFromMessageId
) )
const coveredThroughIndex = ids.indexOf(
state.coveredThroughMessageId
)
if (
coveredFromIndex === -1 &&
coveredThroughIndex >= 0 &&
coveredThroughIndex < state.coveredMessageCount - 1
) {
return coveredThroughIndex + 1
}
if (
coveredFromIndex === -1 &&
coveredThroughIndex === -1
) {
return 0
}
return undefined
} }
export function buildContinuePrompt( export function buildContinuePrompt(
@@ -176,7 +209,9 @@ export function buildContinuePrompt(
'Answer the CURRENT USER REQUEST now.' 'Answer the CURRENT USER REQUEST now.'
].join(' | ') ].join(' | ')
if (hasValidCompressionPrefix(request)) { const compressionPrefixLength =
getCurrentCompressionPrefixLength(request)
if (compressionPrefixLength !== undefined) {
const state = request.contextCompressionState! const state = request.contextCompressionState!
const summaryEnvelope = { const summaryEnvelope = {
role: 'user' as const, role: 'user' as const,
@@ -207,9 +242,7 @@ export function buildContinuePrompt(
} }
if (compose(summaryPair).length <= MAX_CONTINUE_PROMPT_CHARACTERS) { if (compose(summaryPair).length <= MAX_CONTINUE_PROMPT_CHARACTERS) {
const retained = [...summaryPair] const retained = [...summaryPair]
const recent = request.history!.slice( const recent = request.history!.slice(compressionPrefixLength)
state.coveredMessageCount
)
for (const message of recent.slice(-18).reverse()) { for (const message of recent.slice(-18).reverse()) {
const candidate = [ const candidate = [
...summaryPair, ...summaryPair,
+170 -163
View File
@@ -1005,174 +1005,181 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
mkdir(dshHome), mkdir(dshHome),
mkdir(installation) mkdir(installation)
]) ])
const entry = { let installer: DshNpmExtensionInstaller | undefined
id: 'dsh-plugin-greet-live',
package: {
name: 'dsh-plugin-greet',
version: '0.1.0'
},
displayName: 'dsh-plugin-greet',
description: 'Reviewed minimal live DSH plugin fixture.'
}
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
: resolve(
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
const nodeExecutablePath =
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
? resolve(process.env.GOODBUDDY_DSH_NODE_EXECUTABLE)
: undefined
const installer = new DshNpmExtensionInstaller({
dshHome,
npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {})
})
const installed = await installer.install({
entry,
destinationDirectory: installation
})
const observedRequests: GenerateOptions[] = []
const inProcess = createInProcessLaunch(
dshHome,
undefined,
(options) => observedRequests.push(options)
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: liveBaseUrl,
model: liveModel,
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: liveApiKey
},
extensionPackages: [
{
id: entry.id,
entrypoint: join(
installation,
...installed.entrypoint.split('/')
),
configuration: {}
}
],
initializationTimeoutMs: 20_000,
promptTimeoutMs: 120_000,
shutdownTimeoutMs: 5_000
})
try { try {
const askEvents = await collect( const entry = {
runtime.run( id: 'dsh-plugin-greet-live',
{ package: {
requestId: 'request-live-plugin-ask', name: 'dsh-plugin-greet',
conversationId: 'live-plugin-ask', version: '0.2.0'
prompt: },
'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.', displayName: 'dsh-plugin-greet',
workMode: 'ask' description: 'Reviewed minimal live DSH plugin fixture.'
}, }
new AbortController().signal const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
) ? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
) : resolve(
const askRequests = observedRequests.filter((options) => 'node_modules',
latestUserText(options).includes( 'npm',
'DSH_ASK_PLUGIN_PROBE' 'bin',
) 'npm-cli.js'
)
expect(askRequests.length).toBeGreaterThan(0)
expect(
askRequests.flatMap(
(options) =>
options.tools?.map((tool) => tool.name) ?? []
)
).toContain('greet')
expect(askEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'failed',
output: expect.stringContaining(
'Ask 模式不允许执行非只读工具'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
askEvents.some(
(event) =>
event.type === 'tool' &&
event.state === 'completed'
)
).toBe(false)
expect(
askEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
) )
.join('') const nodeExecutablePath =
).toContain('DSH_ASK_PLUGIN_BLOCKED') process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
? resolve(
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
)
: undefined
installer = new DshNpmExtensionInstaller({
dshHome,
npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {})
})
const installed = await installer.install({
entry,
destinationDirectory: installation
})
const observedRequests: GenerateOptions[] = []
const inProcess = createInProcessLaunch(
dshHome,
undefined,
(options) => observedRequests.push(options)
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: liveBaseUrl,
model: liveModel,
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: liveApiKey
},
extensionPackages: [
{
id: entry.id,
entrypoint: join(
installation,
...installed.entrypoint.split('/')
),
configuration: {}
}
],
initializationTimeoutMs: 20_000,
promptTimeoutMs: 120_000,
shutdownTimeoutMs: 5_000
})
const executeEvents = await collect( try {
runtime.run( const askEvents = await collect(
{ runtime.run(
requestId: 'request-live-plugin-execute', {
conversationId: 'live-plugin-execute', requestId: 'request-live-plugin-ask',
prompt: conversationId: 'live-plugin-ask',
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.', prompt:
workMode: 'execute' 'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.',
}, workMode: 'ask'
new AbortController().signal },
) new AbortController().signal
)
const executeRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_EXECUTE_PLUGIN_PROBE'
)
)
expect(executeRequests.length).toBeGreaterThan(0)
expect(
executeRequests.some((options) =>
options.tools?.some((tool) => tool.name === 'greet')
)
).toBe(true)
expect(executeEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'completed',
output: expect.stringContaining(
'Hello, GoodBuddyLive!'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
executeEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
) )
.join('') )
).toContain('DSH_EXECUTE_PLUGIN_OK') const askRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_ASK_PLUGIN_PROBE'
)
)
expect(askRequests.length).toBeGreaterThan(0)
expect(
askRequests.flatMap(
(options) =>
options.tools?.map((tool) => tool.name) ?? []
)
).toContain('greet')
expect(askEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'failed',
output: expect.stringContaining(
'Ask 模式不允许执行非只读工具'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
askEvents.some(
(event) =>
event.type === 'tool' &&
event.state === 'completed'
)
).toBe(false)
expect(
askEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain('DSH_ASK_PLUGIN_BLOCKED')
const executeEvents = await collect(
runtime.run(
{
requestId: 'request-live-plugin-execute',
conversationId: 'live-plugin-execute',
prompt:
'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.',
workMode: 'execute'
},
new AbortController().signal
)
)
const executeRequests = observedRequests.filter((options) =>
latestUserText(options).includes(
'DSH_EXECUTE_PLUGIN_PROBE'
)
)
expect(executeRequests.length).toBeGreaterThan(0)
expect(
executeRequests.some((options) =>
options.tools?.some((tool) => tool.name === 'greet')
)
).toBe(true)
expect(executeEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
name: 'greet',
state: 'pending'
}),
expect.objectContaining({
type: 'tool',
state: 'completed',
output: expect.stringContaining(
'Hello, GoodBuddyLive!'
)
}),
expect.objectContaining({ type: 'done' })
])
)
expect(
executeEvents
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain('DSH_EXECUTE_PLUGIN_OK')
} finally {
await Promise.allSettled([
runtime.dispose(),
...inProcess.hosts.map((host) => host.dispose())
])
}
} finally { } finally {
await runtime.dispose() await installer?.dispose().catch(() => undefined)
await Promise.allSettled(
inProcess.hosts.map((host) => host.dispose())
)
await rm(root, { recursive: true, force: true }) await rm(root, { recursive: true, force: true })
} }
}, },
@@ -14,6 +14,42 @@ export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
'GOODBUDDY_HARNESS_MODEL_API_KEY' 'GOODBUDDY_HARNESS_MODEL_API_KEY'
export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES = export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES =
8 * 1024 * 1024 8 * 1024 * 1024
export const DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS =
5_000
export const DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS =
1_000
export const DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS =
90_000
const DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS = 10_000
const DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS = 2_000
export function deepSeekHarnessStartupBudget(
extensionCount: number
): {
hostTimeoutMs: number
mainTimeoutMs: number
} {
const boundedExtensionCount = Math.max(
0,
Math.floor(extensionCount)
)
const extensionSequenceMs = Math.min(
DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS +
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
boundedExtensionCount *
(DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS +
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS)
)
const hostTimeoutMs =
DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS +
extensionSequenceMs
return {
hostTimeoutMs,
mainTimeoutMs:
hostTimeoutMs +
DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS
}
}
const skillPackageSchema = z const skillPackageSchema = z
.object({ .object({
@@ -15,6 +15,13 @@ function extension(
} }
} }
function blockEventLoop(durationMs: number): void {
const deadline = Date.now() + durationMs
while (Date.now() <= deadline) {
// Deliberately model finite synchronous CommonJS/plugin startup work.
}
}
describe('DeepSeek Harness extension loader', () => { describe('DeepSeek Harness extension loader', () => {
it('loads named Cordis plugin exports and keeps working extensions active', async () => { it('loads named Cordis plugin exports and keeps working extensions active', async () => {
const ctx = new Context() const ctx = new Context()
@@ -157,4 +164,91 @@ describe('DeepSeek Harness extension loader', () => {
expect(apply).not.toHaveBeenCalled() expect(apply).not.toHaveBeenCalled()
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('rejects a synchronous import that returns after its budget', async () => {
const ctx = new Context()
const apply = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('slow-import')],
{
activationTimeoutMs: 10,
importModule: async () => {
blockEventLoop(25)
return { apply }
}
}
)
expect(result).toEqual({
loadedIds: [],
failedIds: ['slow-import'],
failures: [
{
id: 'slow-import',
message:
'DeepSeek Harness extension activation timed out'
}
]
})
expect(apply).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects over-budget synchronous apply and loads the next extension', async () => {
const ctx = new Context()
const laterApply = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('slow-apply'), extension('later')],
{
activationTimeoutMs: 10,
totalActivationTimeoutMs: 100,
importModule: async (url) =>
url.includes('slow-apply')
? {
apply() {
blockEventLoop(25)
}
}
: { apply: laterApply }
}
)
expect(result.loadedIds).toEqual(['later'])
expect(result.failedIds).toEqual(['slow-apply'])
expect(result.failures[0]?.message).toBe(
'DeepSeek Harness extension activation timed out'
)
expect(laterApply).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('times out asynchronous activation and disposes its effects', async () => {
const ctx = new Context()
const cleanup = vi.fn()
const result = await loadControlledHarnessExtensions(
ctx,
[extension('async-slow')],
{
activationTimeoutMs: 10,
importModule: async () => ({
apply(pluginContext: Context) {
pluginContext.effect(() => cleanup)
return new Promise<void>((resolve) =>
setTimeout(resolve, 30)
)
}
})
}
)
expect(result.failedIds).toEqual(['async-slow'])
expect(result.failures[0]?.message).toContain('timed out')
expect(cleanup).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
}) })
@@ -1,10 +1,14 @@
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis' import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url' import { fileURLToPath, pathToFileURL } from 'node:url'
import {
DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS,
DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS
} from './deepseek-harness-control-protocol'
const DEFAULT_ACTIVATION_TIMEOUT_MS = 5_000 const ACTIVATION_TIMEOUT_MESSAGE =
const DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS = 90_000 'DeepSeek Harness extension activation timed out'
const DISPOSAL_TIMEOUT_MS = 1_000
export type ControlledHarnessExtensionPackage = { export type ControlledHarnessExtensionPackage = {
id: string id: string
@@ -117,16 +121,18 @@ export async function loadControlledHarnessExtensions(
const failedIds: string[] = [] const failedIds: string[] = []
const failures: Array<{ id: string; message: string }> = [] const failures: Array<{ id: string; message: string }> = []
const activationTimeoutMs = const activationTimeoutMs =
options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS options.activationTimeoutMs ??
DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS
const deadline = const deadline =
Date.now() + Date.now() +
(options.totalActivationTimeoutMs ?? (options.totalActivationTimeoutMs ??
DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS) DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS)
const importModule = options.importModule ?? defaultImportModule const importModule = options.importModule ?? defaultImportModule
for (const extension of extensions) { for (const extension of extensions) {
let fiber: (Fiber & PromiseLike<Fiber>) | undefined let fiber: (Fiber & PromiseLike<Fiber>) | undefined
let acceptActivation = true let acceptActivation = true
let activationDeadline: number | undefined
try { try {
const remainingMs = deadline - Date.now() const remainingMs = deadline - Date.now()
if (remainingMs <= 0) { if (remainingMs <= 0) {
@@ -134,32 +140,54 @@ export async function loadControlledHarnessExtensions(
'DeepSeek Harness extension startup deadline exceeded' 'DeepSeek Harness extension startup deadline exceeded'
) )
} }
const extensionBudgetMs = Math.max(
1,
Math.min(activationTimeoutMs, remainingMs)
)
activationDeadline = Date.now() + extensionBudgetMs
const rejectLateSynchronousWork = (): void => {
if (Date.now() > activationDeadline!) {
acceptActivation = false
throw new Error(ACTIVATION_TIMEOUT_MESSAGE)
}
}
const activation = (async () => {
const module = await importModule(
pathToFileURL(extension.entrypoint).href
)
rejectLateSynchronousWork()
if (!acceptActivation) {
throw new Error(ACTIVATION_TIMEOUT_MESSAGE)
}
const plugin = resolvePlugin(module)
fiber = ctx.plugin(plugin, extension.configuration)
// A timer cannot run while CommonJS evaluation or a plugin's
// synchronous apply body owns this event loop. Re-check elapsed
// wall time immediately after those calls return so finite
// over-budget work is never reported as successfully activated.
rejectLateSynchronousWork()
await Promise.resolve(fiber)
rejectLateSynchronousWork()
})()
await withTimeout( await withTimeout(
(async () => { activation,
const module = await importModule( Math.max(1, activationDeadline - Date.now()),
pathToFileURL(extension.entrypoint).href ACTIVATION_TIMEOUT_MESSAGE,
)
if (!acceptActivation) {
throw new Error(
'DeepSeek Harness extension activation timed out'
)
}
const plugin = resolvePlugin(module)
fiber = ctx.plugin(plugin, extension.configuration)
await Promise.resolve(fiber)
})(),
Math.max(1, Math.min(activationTimeoutMs, remainingMs)),
'DeepSeek Harness extension activation timed out',
() => { () => {
acceptActivation = false acceptActivation = false
} }
) )
loadedIds.push(extension.id) loadedIds.push(extension.id)
} catch (error) { } catch (error) {
const failure =
activationDeadline !== undefined &&
Date.now() > activationDeadline
? new Error(ACTIVATION_TIMEOUT_MESSAGE)
: error
if (fiber) { if (fiber) {
await withTimeout( await withTimeout(
fiber.dispose(), fiber.dispose(),
DISPOSAL_TIMEOUT_MS, DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS,
'DeepSeek Harness extension disposal timed out' 'DeepSeek Harness extension disposal timed out'
).catch(() => undefined) ).catch(() => undefined)
} }
@@ -167,8 +195,8 @@ export async function loadControlledHarnessExtensions(
failures.push({ failures.push({
id: extension.id, id: extension.id,
message: message:
error instanceof Error && error.message.trim() failure instanceof Error && failure.message.trim()
? error.message.slice(0, 1_000) ? failure.message.slice(0, 1_000)
: 'DeepSeek Harness extension failed to start' : 'DeepSeek Harness extension failed to start'
}) })
} }
+131 -3
View File
@@ -6,6 +6,7 @@ import {
type ModelToolDefinition, type ModelToolDefinition,
type ModelToolProviderLike type ModelToolProviderLike
} from './model-tool-provider' } from './model-tool-provider'
import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol'
import type { import type {
ResolvedMcpServer ResolvedMcpServer
} from '../capabilities/capability-service' } from '../capabilities/capability-service'
@@ -47,6 +48,19 @@ function setup(
maxRequestOutputCharacters?: number maxRequestOutputCharacters?: number
supportsImageInput?: boolean supportsImageInput?: boolean
advertisedImageInput?: boolean advertisedImageInput?: boolean
initializationTimeoutMs?: number
useDefaultInitializationTimeout?: boolean
launchDelayMs?: number
extensionPackages?: Array<{
id: string
entrypoint: string
configuration: Record<string, unknown>
}>
launch?: (
options: Parameters<
ConstructorParameters<typeof DeepSeekHarnessRuntime>[0]['launch']
>[0]
) => Promise<DeepSeekHarnessChild>
} = {} } = {}
) { ) {
const exit = deferred<{ const exit = deferred<{
@@ -215,7 +229,17 @@ function setup(
ClientSideConnection, ClientSideConnection,
ndJsonStream: vi.fn(() => ({ stream: true })) ndJsonStream: vi.fn(() => ({ stream: true }))
} as unknown as DeepSeekHarnessAcpSdk } as unknown as DeepSeekHarnessAcpSdk
const launch = vi.fn(async () => child) const launch = vi.fn(
options.launch ??
(async () => {
if (options.launchDelayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.launchDelayMs)
)
}
return child
})
)
const runtime = new DeepSeekHarnessRuntime({ const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace', defaultWorkspace: 'C:\\workspace',
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
@@ -223,7 +247,12 @@ function setup(
supportsImageInput: options.supportsImageInput, supportsImageInput: options.supportsImageInput,
launch, launch,
loadAcpSdk: async () => sdk, loadAcpSdk: async () => sdk,
initializationTimeoutMs: 100, ...(options.useDefaultInitializationTimeout
? {}
: {
initializationTimeoutMs:
options.initializationTimeoutMs ?? 100
}),
promptTimeoutMs: options.promptTimeoutMs ?? 100, promptTimeoutMs: options.promptTimeoutMs ?? 100,
shutdownTimeoutMs: 10, shutdownTimeoutMs: 10,
maxStderrBytes: 16, maxStderrBytes: 16,
@@ -231,7 +260,8 @@ function setup(
maxRequestOutputCharacters: maxRequestOutputCharacters:
options.maxRequestOutputCharacters, options.maxRequestOutputCharacters,
toolProvider: options.toolProvider, toolProvider: options.toolProvider,
skillPackages: options.skillPackages skillPackages: options.skillPackages,
extensionPackages: options.extensionPackages
}) })
const emit = async ( const emit = async (
sessionId: string, sessionId: string,
@@ -409,6 +439,104 @@ function toolProvider(
} }
describe('DeepSeekHarnessRuntime', () => { describe('DeepSeekHarnessRuntime', () => {
it('includes bounded failed-extension cleanup in the startup budget', () => {
expect(deepSeekHarnessStartupBudget(11)).toEqual({
hostTimeoutMs: 76_000,
mainTimeoutMs: 78_000
})
expect(deepSeekHarnessStartupBudget(64)).toEqual({
hostTimeoutMs: 101_000,
mainTimeoutMs: 103_000
})
})
it('expands the default launcher deadline for enabled extensions', async () => {
vi.useFakeTimers()
try {
const harness = setup({
useDefaultInitializationTimeout: true,
launchDelayMs: 10_001,
extensionPackages: [
{
id: 'slow-one',
entrypoint: 'C:\\extensions\\slow-one.js',
configuration: {}
},
{
id: 'slow-two',
entrypoint: 'C:\\extensions\\slow-two.js',
configuration: {}
}
]
})
const status = harness.runtime.getStatus()
await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({
available: true
})
expect(harness.child.terminate).not.toHaveBeenCalled()
const disposal = harness.runtime.dispose()
await vi.advanceTimersByTimeAsync(10)
await disposal
} finally {
vi.useRealTimers()
}
})
it('keeps the default no-extension launcher deadline bounded', async () => {
vi.useFakeTimers()
try {
let launchSignal: AbortSignal | undefined
const harness = setup({
useDefaultInitializationTimeout: true,
launch: (options) => {
launchSignal = options.signal
return new Promise<DeepSeekHarnessChild>(
() => undefined
)
}
})
const status = harness.runtime.getStatus()
await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({
available: false,
detail: 'DeepSeek Harness 启动超时'
})
expect(launchSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts a pending launch and terminates a child returned after disposal', async () => {
const launch = deferred<DeepSeekHarnessChild>()
let launchSignal: AbortSignal | undefined
const harness = setup({
launch: (options) => {
launchSignal = options.signal
return launch.promise
}
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await harness.runtime.dispose()
expect(launchSignal?.aborted).toBe(true)
launch.resolve(harness.child)
await expect(status).resolves.toMatchObject({
available: false,
detail: 'DeepSeek Harness Runtime 已关闭'
})
expect(harness.child.terminate).toHaveBeenCalledOnce()
})
it('surfaces bounded internal Harness details from ACP errors', () => { it('surfaces bounded internal Harness details from ACP errors', () => {
expect( expect(
harnessPromptError( harnessPromptError(
+32 -1
View File
@@ -34,6 +34,7 @@ import {
GOODBUDDY_TOOLS_CALL, GOODBUDDY_TOOLS_CALL,
GOODBUDDY_TOOLS_LIST GOODBUDDY_TOOLS_LIST
} from './deepseek-harness-protocol' } from './deepseek-harness-protocol'
import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol'
const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk' const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk'
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000 const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000
@@ -185,6 +186,11 @@ export type DeepSeekHarnessRuntimeOptions = {
launch: ( launch: (
options: DeepSeekHarnessLaunchOptions options: DeepSeekHarnessLaunchOptions
) => Promise<DeepSeekHarnessChild> ) => Promise<DeepSeekHarnessChild>
/**
* Explicit hard timeout for each initialization operation, including the
* complete launcher call. When omitted, launcher startup is expanded from
* the enabled extension count while later ACP operations retain 10 seconds.
*/
initializationTimeoutMs?: number initializationTimeoutMs?: number
promptTimeoutMs?: number promptTimeoutMs?: number
shutdownTimeoutMs?: number shutdownTimeoutMs?: number
@@ -450,6 +456,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
readonly supportsScopedDataTools = false readonly supportsScopedDataTools = false
private state?: HarnessState private state?: HarnessState
private initialization?: Promise<HarnessState> private initialization?: Promise<HarnessState>
private launchController?: AbortController
private disposed = false private disposed = false
private fatalError?: Error private fatalError?: Error
private stderrBytes = 0 private stderrBytes = 0
@@ -470,6 +477,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
) )
} }
private get launchTimeoutMs(): number {
return (
this.options.initializationTimeoutMs ??
deepSeekHarnessStartupBudget(
this.options.extensionPackages?.length ?? 0
).mainTimeoutMs
)
}
private get promptTimeoutMs(): number { private get promptTimeoutMs(): number {
return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS
} }
@@ -806,6 +822,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness Runtime 已关闭') throw new Error('DeepSeek Harness Runtime 已关闭')
} }
const launchController = new AbortController() const launchController = new AbortController()
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined let child: DeepSeekHarnessChild | undefined
try { try {
child = await withTimeout( child = await withTimeout(
@@ -822,9 +839,12 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
skillPackages: this.options.skillPackages ?? [], skillPackages: this.options.skillPackages ?? [],
extensionPackages: this.options.extensionPackages ?? [] extensionPackages: this.options.extensionPackages ?? []
}), }),
this.initializationTimeoutMs, this.launchTimeoutMs,
'启动' '启动'
) )
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
const sdk = await (this.options.loadAcpSdk ?? defaultLoadAcpSdk)() const sdk = await (this.options.loadAcpSdk ?? defaultLoadAcpSdk)()
let agent: AcpAgent | undefined let agent: AcpAgent | undefined
const connection = new sdk.ClientSideConnection( const connection = new sdk.ClientSideConnection(
@@ -1076,6 +1096,9 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
...stateWithoutCapabilities, ...stateWithoutCapabilities,
capabilities capabilities
} }
if (this.disposed) {
throw new Error('DeepSeek Harness Runtime 已关闭')
}
this.state = state this.state = state
return state return state
} catch (error) { } catch (error) {
@@ -1084,6 +1107,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
await this.terminate(child) await this.terminate(child)
} }
throw error throw error
} finally {
if (this.launchController === launchController) {
this.launchController = undefined
}
} }
} }
@@ -1611,6 +1638,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
return return
} }
this.disposed = true this.disposed = true
this.launchController?.abort(
new Error('DeepSeek Harness Runtime 已关闭')
)
this.launchController = undefined
const state = this.state const state = this.state
this.state = undefined this.state = undefined
this.initialization = undefined this.initialization = undefined
@@ -15,6 +15,7 @@ import {
DEEPSEEK_HARNESS_CREDENTIAL_REF, DEEPSEEK_HARNESS_CREDENTIAL_REF,
DEEPSEEK_HARNESS_HOST_VERSION, DEEPSEEK_HARNESS_HOST_VERSION,
DEEPSEEK_HARNESS_MAX_FRAME_BYTES, DEEPSEEK_HARNESS_MAX_FRAME_BYTES,
deepSeekHarnessStartupBudget,
parseHarnessControlMessage, parseHarnessControlMessage,
type DeepSeekHarnessControlMessage as HarnessControlMessage type DeepSeekHarnessControlMessage as HarnessControlMessage
} from './deepseek-harness-control-protocol' } from './deepseek-harness-control-protocol'
@@ -53,6 +54,11 @@ export type DeepSeekHarnessUtilityLauncherOptions = {
onExtensionStartupFailures?: ( onExtensionStartupFailures?: (
extensionIds: readonly string[] extensionIds: readonly string[]
) => Promise<void> ) => Promise<void>
/**
* Explicit hard Host-handshake deadline. Callers that also set the Runtime
* initialization timeout must leave enough additional time for startup
* failure persistence.
*/
startupTimeoutMs?: number startupTimeoutMs?: number
} }
@@ -174,10 +180,9 @@ export function createDeepSeekHarnessUtilityLauncher(
} }
const startupTimeoutMs = const startupTimeoutMs =
launcherOptions.startupTimeoutMs ?? launcherOptions.startupTimeoutMs ??
Math.min( deepSeekHarnessStartupBudget(
120_000, canonicalExtensionPackages.length
10_000 + canonicalExtensionPackages.length * 5_000 ).hostTimeoutMs
)
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let onAbort: (() => void) | undefined let onAbort: (() => void) | undefined
try { try {
@@ -19,12 +19,22 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
const userDataPath = await mkdtemp( const userDataPath = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-marketplace-live-') join(tmpdir(), 'goodbuddy-dsh-marketplace-live-')
) )
let installer: DshNpmExtensionInstaller | undefined
let host:
| Awaited<
ReturnType<typeof startControlledDeepSeekHarnessHost>
>
| undefined
try { try {
const market = new DshNpmMarketplaceCatalog() const market = new DshNpmMarketplaceCatalog()
const greet = (await market.list()).find( const greet = (await market.list()).find(
(entry) => entry.package.name === 'dsh-plugin-greet' (entry) => entry.package.name === 'dsh-plugin-greet'
) )
expect(greet).toBeDefined() expect(greet).toBeDefined()
expect(greet?.package).toEqual({
name: 'dsh-plugin-greet',
version: '0.2.0'
})
const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI
? resolve(process.env.GOODBUDDY_DSH_NPM_CLI) ? resolve(process.env.GOODBUDDY_DSH_NPM_CLI)
: resolve( : resolve(
@@ -39,16 +49,17 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
process.env.GOODBUDDY_DSH_NODE_EXECUTABLE process.env.GOODBUDDY_DSH_NODE_EXECUTABLE
) )
: undefined : undefined
const installer = new DshNpmExtensionInstaller({ const activeInstaller = new DshNpmExtensionInstaller({
dshHome: userDataPath, dshHome: userDataPath,
npmCliPath, npmCliPath,
...(nodeExecutablePath ? { nodeExecutablePath } : {}) ...(nodeExecutablePath ? { nodeExecutablePath } : {})
}) })
installer = activeInstaller
const store = new RuntimeExtensionStore(userDataPath, { const store = new RuntimeExtensionStore(userDataPath, {
catalog: { catalog: {
list: async () => [greet!] list: async () => [greet!]
}, },
install: (input) => installer.install(input) install: (input) => activeInstaller.install(input)
}) })
await store.apply({ await store.apply({
type: 'set-marketplace-enabled', type: 'set-marketplace-enabled',
@@ -77,7 +88,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
Record<string, unknown>, Record<string, unknown>,
Record<string, unknown> Record<string, unknown>
>() >()
const host = await startControlledDeepSeekHarnessHost({ host = await startControlledDeepSeekHarnessHost({
workspace: userDataPath, workspace: userDataPath,
dshHome: userDataPath, dshHome: userDataPath,
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
@@ -103,10 +114,16 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
} as never) } as never)
).resolves.toMatchObject({ ).resolves.toMatchObject({
isError: false, isError: false,
value: 'Hello, GoodBuddy!' value: {
message: 'Hello, GoodBuddy!',
name: 'GoodBuddy',
language: 'en',
style: 'friendly'
}
}) })
await host.dispose()
} finally { } finally {
await host?.dispose().catch(() => undefined)
await installer?.dispose().catch(() => undefined)
await rm(userDataPath, { await rm(userDataPath, {
recursive: true, recursive: true,
force: true, force: true,
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
DshNpmExtensionInstaller, DshNpmExtensionInstaller,
DshNpmMarketplaceCatalog, DshNpmMarketplaceCatalog,
runPackageManager,
type PackageManagerRunner type PackageManagerRunner
} from './dsh-extension-marketplace' } from './dsh-extension-marketplace'
@@ -139,7 +140,7 @@ describe('DSH npm marketplace', () => {
expect(fetcher).toHaveBeenCalledOnce() expect(fetcher).toHaveBeenCalledOnce()
}) })
it('uses bundled npm to install the exact package and verifies its entrypoint', async () => { it('installs the exact package when an older manifest lacks DSH bundle metadata', async () => {
const destinationDirectory = await mkdtemp( const destinationDirectory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-npm-installer-') join(tmpdir(), 'goodbuddy-dsh-npm-installer-')
) )
@@ -161,6 +162,11 @@ describe('DSH npm marketplace', () => {
const fetcher = vi.fn<typeof fetch>(async () => const fetcher = vi.fn<typeof fetch>(async () =>
response({ response({
versions: { versions: {
'0.0.1': {
name: packageName,
version: '0.0.1',
dist: { integrity }
},
[version]: manifest [version]: manifest
} }
}) })
@@ -294,4 +300,95 @@ describe('DSH npm marketplace', () => {
}) })
).rejects.toThrow() ).rejects.toThrow()
}) })
it('aborts an active package-manager process and settles the run', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-npm-abort-')
)
temporaryDirectories.push(directory)
const controller = new AbortController()
const operation = runPackageManager(
process.execPath,
['-e', 'setInterval(() => {}, 1_000)'],
{
cwd: directory,
env: process.env,
timeoutMs: 60_000,
signal: controller.signal
}
)
await new Promise((resolve) => setTimeout(resolve, 50))
controller.abort(new Error('installer cancellation fixture'))
await expect(operation).rejects.toThrow(
'installer cancellation fixture'
)
})
it('disposes active installs, propagates cancellation, and rejects new work', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-dsh-installer-dispose-')
)
temporaryDirectories.push(directory)
const integrity = `sha512-${Buffer.from('verified').toString(
'base64'
)}`
const packageName = 'dsh-plugin-cancellable'
const version = '1.0.0'
const runner: PackageManagerRunner = vi.fn(
(_command, _args, options) =>
new Promise<{
exitCode: number
stdout: string
stderr: string
}>((_resolve, reject) => {
const rejectCancellation = (): void => {
reject(options.signal?.reason)
}
options.signal?.addEventListener(
'abort',
rejectCancellation,
{ once: true }
)
})
)
const installer = new DshNpmExtensionInstaller({
dshHome: directory,
fetcher: vi.fn<typeof fetch>(async () =>
response({
versions: {
[version]: {
name: packageName,
version,
main: 'index.js',
dist: { integrity },
dsh: { bundle: { patch: './cordis.patch.yml' } }
}
}
})
),
runner
})
const input = {
entry: {
id: 'cancellable',
package: { name: packageName, version },
displayName: packageName,
description: 'Cancellation fixture.'
},
destinationDirectory: directory
}
const installation = installer.install(input)
await vi.waitFor(() => expect(runner).toHaveBeenCalledOnce())
await installer.dispose()
await expect(installation).rejects.toThrow(
'应用退出,DSH 插件安装已取消'
)
await expect(installer.install(input)).rejects.toThrow(
'DSH 插件安装器正在关闭'
)
})
}) })
+197 -36
View File
@@ -33,6 +33,7 @@ const MAXIMUM_CATALOG_ENTRIES = 1_000
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000 const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000 const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000
const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024 const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024
const MAXIMUM_PACKUMENT_VERSIONS = 20_000
const npmSearchPackageSchema = z const npmSearchPackageSchema = z
.object({ .object({
@@ -93,11 +94,59 @@ const npmVersionManifestSchema = npmInstalledManifestSchema.extend({
dist: npmDistributionSchema dist: npmDistributionSchema
}) })
const npmPackumentSchema = z function isPlainObject(
.object({ value: unknown
versions: z.record(z.string(), npmVersionManifestSchema) ): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
const npmPackumentVersionsSchema = z
.custom<Record<string, unknown>>(isPlainObject, {
message: 'npm packument versions must be a plain object'
}) })
.passthrough() .superRefine((versions, context) => {
const keys = Object.keys(versions)
if (keys.length > MAXIMUM_PACKUMENT_VERSIONS) {
context.addIssue({
code: 'too_big',
origin: 'object',
maximum: MAXIMUM_PACKUMENT_VERSIONS,
inclusive: true,
path: [],
message: 'npm packument contains too many versions'
})
}
if (
keys.some(
(key) =>
key === '__proto__' ||
key === 'prototype' ||
key === 'constructor'
)
) {
context.addIssue({
code: 'custom',
path: [],
message: 'npm packument contains an unsafe version key'
})
}
})
const npmPackumentSchema = z
.custom<Record<string, unknown>>(isPlainObject, {
message: 'npm packument must be a plain object'
})
.pipe(
z
.object({
versions: npmPackumentVersionsSchema
})
.passthrough()
)
type NpmVersionManifest = z.infer<typeof npmVersionManifestSchema> type NpmVersionManifest = z.infer<typeof npmVersionManifestSchema>
@@ -114,6 +163,7 @@ export type PackageManagerRunner = (
cwd: string cwd: string
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
timeoutMs: number timeoutMs: number
signal?: AbortSignal
} }
) => Promise<PackageManagerRunResult> ) => Promise<PackageManagerRunResult>
@@ -124,11 +174,13 @@ function waitForProcessClose(
return Promise.resolve() return Promise.resolve()
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const timer = setTimeout(resolve, 5_000) const finish = (): void => {
child.once('close', () => {
clearTimeout(timer) clearTimeout(timer)
child.removeListener('close', finish)
resolve() resolve()
}) }
const timer = setTimeout(finish, 5_000)
child.once('close', finish)
}) })
} }
@@ -147,11 +199,13 @@ async function terminatePackageManager(
} }
) )
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 5_000)
const finish = (): void => { const finish = (): void => {
clearTimeout(timer) clearTimeout(timer)
killer.removeListener('close', finish)
killer.removeListener('error', finish)
resolve() resolve()
} }
const timer = setTimeout(finish, 5_000)
killer.once('close', finish) killer.once('close', finish)
killer.once('error', finish) killer.once('error', finish)
}) })
@@ -183,6 +237,14 @@ export const runPackageManager: PackageManagerRunner = (
options options
) => ) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(
options.signal.reason instanceof Error
? options.signal.reason
: new Error('DSH 插件安装已取消')
)
return
}
const child = spawn(command, [...args], { const child = spawn(command, [...args], {
cwd: options.cwd, cwd: options.cwd,
env: options.env, env: options.env,
@@ -194,42 +256,79 @@ export const runPackageManager: PackageManagerRunner = (
let stdout = '' let stdout = ''
let stderr = '' let stderr = ''
let settled = false let settled = false
const timer = setTimeout(() => { let terminating = false
if (settled) { const onStdout = (chunk: unknown): void => {
return
}
settled = true
void terminatePackageManager(child).then(
() => reject(new Error('DSH 插件安装超时')),
() => reject(new Error('DSH 插件安装超时'))
)
}, options.timeoutMs)
child.stdout?.on('data', (chunk) => {
stdout = boundedAppend(stdout, chunk) stdout = boundedAppend(stdout, chunk)
}) }
child.stderr?.on('data', (chunk) => { const onStderr = (chunk: unknown): void => {
stderr = boundedAppend(stderr, chunk) stderr = boundedAppend(stderr, chunk)
}) }
child.once('error', (error) => { const cleanup = (): void => {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onAbort)
child.stdout?.removeListener('data', onStdout)
child.stderr?.removeListener('data', onStderr)
child.removeListener('error', onError)
child.removeListener('close', onClose)
}
const settleRejected = (error: Error): void => {
if (settled) { if (settled) {
return return
} }
settled = true settled = true
clearTimeout(timer) cleanup()
reject(error) reject(error)
}) }
child.once('close', (code) => { const terminateAndReject = (error: Error): void => {
if (settled) { if (settled || terminating) {
return
}
terminating = true
void terminatePackageManager(child).then(
() => settleRejected(error),
() => settleRejected(error)
)
}
const onAbort = (): void => {
terminateAndReject(
options.signal?.reason instanceof Error
? options.signal.reason
: new Error('DSH 插件安装已取消')
)
}
const onError = (error: Error): void => {
if (terminating) {
return
}
settleRejected(error)
}
const onClose = (code: number | null): void => {
if (settled || terminating) {
return return
} }
settled = true settled = true
clearTimeout(timer) cleanup()
resolve({ resolve({
exitCode: code ?? 1, exitCode: code ?? 1,
stdout, stdout,
stderr stderr
}) })
}
const timer = setTimeout(
() =>
terminateAndReject(new Error('DSH 插件安装超时')),
options.timeoutMs
)
child.stdout?.on('data', onStdout)
child.stderr?.on('data', onStderr)
child.once('error', onError)
child.once('close', onClose)
options.signal?.addEventListener('abort', onAbort, {
once: true
}) })
if (options.signal?.aborted) {
onAbort()
}
}) })
function publicHttpUrl(value: string | undefined): string | undefined { function publicHttpUrl(value: string | undefined): string | undefined {
@@ -292,14 +391,18 @@ function catalogEntry(
async function fetchJson( async function fetchJson(
fetcher: typeof fetch, fetcher: typeof fetch,
url: URL, url: URL,
timeoutMs: number timeoutMs: number,
signal?: AbortSignal
): Promise<unknown> { ): Promise<unknown> {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const response = await fetcher(url, { const response = await fetcher(url, {
headers: { headers: {
accept: 'application/json', accept: 'application/json',
'user-agent': 'GoodBuddy-DSH-Marketplace/1' 'user-agent': 'GoodBuddy-DSH-Marketplace/1'
}, },
signal: AbortSignal.timeout(timeoutMs) signal: signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`DSH 插件市场请求失败(HTTP ${response.status}`) throw new Error(`DSH 插件市场请求失败(HTTP ${response.status}`)
@@ -520,6 +623,15 @@ async function prepareNodeCommand(
} }
export class DshNpmExtensionInstaller { export class DshNpmExtensionInstaller {
private disposed = false
private readonly activeInstalls = new Map<
Promise<{
entrypoint: string
integrity?: string
}>,
AbortController
>()
constructor( constructor(
private readonly options: { private readonly options: {
dshHome: string dshHome: string
@@ -534,7 +646,7 @@ export class DshNpmExtensionInstaller {
} }
) {} ) {}
async install( install(
input: Parameters< input: Parameters<
RuntimeExtensionStoreDependencies['install'] RuntimeExtensionStoreDependencies['install']
>[0] >[0]
@@ -542,7 +654,45 @@ export class DshNpmExtensionInstaller {
entrypoint: string entrypoint: string
integrity?: string integrity?: string
}> { }> {
const manifest = await this.resolveManifest(input.entry) if (this.disposed) {
return Promise.reject(new Error('DSH 插件安装器正在关闭'))
}
const controller = new AbortController()
const operation = this.performInstall(input, controller.signal)
this.activeInstalls.set(operation, controller)
void operation.then(
() => {
this.activeInstalls.delete(operation)
},
() => {
this.activeInstalls.delete(operation)
}
)
return operation
}
async dispose(): Promise<void> {
this.disposed = true
const active = [...this.activeInstalls.entries()]
for (const [, controller] of active) {
controller.abort(new Error('应用退出,DSH 插件安装已取消'))
}
await Promise.allSettled(
active.map(([operation]) => operation)
)
}
private async performInstall(
input: Parameters<
RuntimeExtensionStoreDependencies['install']
>[0],
signal: AbortSignal
): Promise<{
entrypoint: string
integrity?: string
}> {
const manifest = await this.resolveManifest(input.entry, signal)
signal.throwIfAborted()
await writeFile( await writeFile(
join(input.destinationDirectory, 'package.json'), join(input.destinationDirectory, 'package.json'),
`${JSON.stringify( `${JSON.stringify(
@@ -608,6 +758,7 @@ export class DshNpmExtensionInstaller {
], ],
{ {
cwd: input.destinationDirectory, cwd: input.destinationDirectory,
signal,
env: { env: {
...packageManagerEnvironment, ...packageManagerEnvironment,
npm_config_audit: 'false', npm_config_audit: 'false',
@@ -625,6 +776,7 @@ export class DshNpmExtensionInstaller {
} catch (error) { } catch (error) {
throw packageManagerError(error) throw packageManagerError(error)
} }
signal.throwIfAborted()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
const detail = const detail =
result.stderr.trim() || result.stderr.trim() ||
@@ -686,7 +838,8 @@ export class DshNpmExtensionInstaller {
} }
private async resolveManifest( private async resolveManifest(
entry: RuntimeExtensionCatalogEntry entry: RuntimeExtensionCatalogEntry,
signal: AbortSignal
): Promise<NpmVersionManifest> { ): Promise<NpmVersionManifest> {
const registryUrl = ( const registryUrl = (
this.options.registryUrl ?? NPM_REGISTRY_URL this.options.registryUrl ?? NPM_REGISTRY_URL
@@ -699,13 +852,21 @@ export class DshNpmExtensionInstaller {
this.options.fetcher ?? fetch, this.options.fetcher ?? fetch,
url, url,
this.options.requestTimeoutMs ?? this.options.requestTimeoutMs ??
DEFAULT_REQUEST_TIMEOUT_MS DEFAULT_REQUEST_TIMEOUT_MS,
signal
) )
) )
const manifest = packument.versions[entry.package.version] if (
if (!manifest) { !Object.prototype.hasOwnProperty.call(
packument.versions,
entry.package.version
)
) {
throw new Error('DSH 插件精确版本未发布') throw new Error('DSH 插件精确版本未发布')
} }
const manifest = npmVersionManifestSchema.parse(
packument.versions[entry.package.version]
)
if ( if (
manifest.name !== entry.package.name || manifest.name !== entry.package.name ||
manifest.version !== entry.package.version manifest.version !== entry.package.version
+461 -2
View File
@@ -7,7 +7,14 @@ import { z } from 'zod'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import {
LATEST_PROTOCOL_VERSION,
ListToolsRequestSchema,
isInitializeRequest,
type Tool
} from '@modelcontextprotocol/sdk/types.js'
import type { KnowledgeService } from '../knowledge/knowledge-service' import type { KnowledgeService } from '../knowledge/knowledge-service'
import { AssistantDatabase } from '../assistant/assistant-database' import { AssistantDatabase } from '../assistant/assistant-database'
import { import {
@@ -67,6 +74,115 @@ function createService() {
return { service, searchHybridMany } return { service, searchHybridMany }
} }
function customMcpServer(
url: string,
id = '00000000-0000-4000-8000-000000000092'
) {
return {
id,
name: 'Paged MCP',
description: '',
enabled: true,
allowDynamicTools: true,
assignments: ['opencode' as const],
secretConfigured: false,
transport: 'http' as const,
url
}
}
async function startToolUpstream(
listTools: (
cursor: string | undefined
) =>
| { tools: Tool[]; nextCursor?: string }
| Promise<{ tools: Tool[]; nextCursor?: string }>
): Promise<{
url: string
notifyToolsChanged: () => Promise<void>
}> {
const sessions = new Map<
string,
{
protocol: McpProtocolServer
transport: StreamableHTTPServerTransport
}
>()
const server = createServer(async (request, response) => {
let body: unknown
if (request.method === 'POST') {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(Buffer.from(chunk))
}
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
const sessionId = request.headers['mcp-session-id']
let session =
typeof sessionId === 'string'
? sessions.get(sessionId)
: undefined
if (!session) {
if (
request.method !== 'POST' ||
!isInitializeRequest(body)
) {
response.writeHead(404)
response.end()
return
}
const protocol = new McpProtocolServer(
{ name: 'tool-upstream', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true } } }
)
protocol.setRequestHandler(
ListToolsRequestSchema,
async (requestValue) =>
listTools(requestValue.params?.cursor)
)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (idValue) => {
sessions.set(idValue, { protocol, transport })
},
onsessionclosed: (idValue) => {
sessions.delete(idValue)
}
})
session = { protocol, transport }
await protocol.connect(transport)
}
await session.transport.handleRequest(request, response, body)
})
httpServers.push(server)
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('tool upstream did not bind')
}
return {
url: `http://127.0.0.1:${address.port}/mcp`,
notifyToolsChanged: async () => {
await Promise.all(
[...sessions.values()].map(({ protocol }) =>
protocol.sendToolListChanged()
)
)
}
}
}
function testTool(name: string): Tool {
return {
name,
description: name,
inputSchema: { type: 'object' }
}
}
const gateways: KnowledgeMcpGateway[] = [] const gateways: KnowledgeMcpGateway[] = []
const databases: AssistantDatabase[] = [] const databases: AssistantDatabase[] = []
const temporaryDirectories: string[] = [] const temporaryDirectories: string[] = []
@@ -437,7 +553,7 @@ describe('KnowledgeMcpGateway', () => {
).toThrow('笔记不存在') ).toThrow('笔记不存在')
}) })
it('binds a POST-only authenticated endpoint and rejects oversized bodies', async () => { it('binds an authenticated MCP endpoint and rejects oversized bodies', async () => {
const { service } = createService() const { service } = createService()
const gateway = new KnowledgeMcpGateway(service, { const gateway = new KnowledgeMcpGateway(service, {
maximumBodyBytes: 32 maximumBodyBytes: 32
@@ -452,7 +568,7 @@ describe('KnowledgeMcpGateway', () => {
)! )!
const getResponse = await fetch(endpoint) const getResponse = await fetch(endpoint)
expect(getResponse.status).toBe(405) expect(getResponse.status).toBe(401)
expect(getResponse.headers.get('access-control-allow-origin')).toBeNull() expect(getResponse.headers.get('access-control-allow-origin')).toBeNull()
const unauthorized = await fetch(endpoint, { const unauthorized = await fetch(endpoint, {
@@ -587,4 +703,347 @@ describe('KnowledgeMcpGateway', () => {
await client.close() await client.close()
} }
}) })
it('loads every tools/list page before exposing custom MCP tools', async () => {
const listTools = vi.fn((cursor: string | undefined) =>
cursor !== undefined
? { tools: [testTool('second')] }
: {
tools: [testTool('first')],
nextCursor: 'page-2'
}
)
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const token = gateway.grantCustomMcp(
'paged-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const tools = await gateway.prepareCustomMcpTools(token)
expect(tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_first$/u),
expect.stringMatching(/_second$/u)
])
expect(listTools).toHaveBeenNthCalledWith(1, undefined)
expect(listTools).toHaveBeenNthCalledWith(2, 'page-2')
})
it('continues tools/list pagination with an empty cursor', async () => {
const listTools = vi.fn((cursor: string | undefined) =>
cursor === undefined
? {
tools: [testTool('first')],
nextCursor: ''
}
: { tools: [testTool('second')] }
)
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const token = gateway.grantCustomMcp(
'empty-cursor-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const tools = await gateway.prepareCustomMcpTools(token)
expect(tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_first$/u),
expect.stringMatching(/_second$/u)
])
expect(listTools).toHaveBeenNthCalledWith(2, '')
})
it('explicitly requests task execution for required tools from earlier pages', async () => {
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const server = customMcpServer('http://127.0.0.1:1/mcp')
const token = gateway.grantCustomMcp(
'required-task-tool',
[server],
new AbortController().signal
)!
const callToolStream = vi.fn(
async function* () {
yield {
type: 'result' as const,
result: {
content: [{ type: 'text' as const, text: 'done' }],
structuredContent: { value: 'done' }
}
}
}
)
const outputValidator = vi.fn(() => ({
valid: true as const,
data: { value: 'done' },
errorMessage: undefined
}))
const callRequiredTool = (
gateway as unknown as {
callCustomMcpTool(
capabilityToken: string,
binding: unknown,
input: Record<string, unknown>,
signal: AbortSignal
): Promise<unknown>
}
).callCustomMcpTool.bind(gateway)
await expect(
callRequiredTool(
token,
{
client: {
experimental: {
tasks: {
callToolStream,
cancelTask: vi.fn()
}
}
},
server,
originalName: 'required-first-page',
taskSupport: 'required',
outputValidator,
exposedTool: testTool('required-first-page')
},
{},
new AbortController().signal
)
).resolves.toMatchObject({
content: [{ type: 'text', text: 'done' }]
})
expect(callToolStream).toHaveBeenCalledWith(
{
name: 'required-first-page',
arguments: {}
},
undefined,
expect.objectContaining({ task: {} })
)
expect(outputValidator).toHaveBeenCalledWith({ value: 'done' })
})
it('rejects cyclic cursors and custom MCP tool counts over 100', async () => {
const cyclicUpstream = await startToolUpstream(
(cursor: string | undefined) => ({
tools: [testTool(cursor ? 'second' : 'first')],
nextCursor: 'cycle'
})
)
const excessiveUpstream = await startToolUpstream(() => ({
tools: Array.from({ length: 101 }, (_, index) =>
testTool(`tool-${index}`)
)
}))
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const cycleToken = gateway.grantCustomMcp(
'cursor-cycle',
[customMcpServer(cyclicUpstream.url)],
new AbortController().signal
)!
const excessiveToken = gateway.grantCustomMcp(
'excessive-tools',
[
customMcpServer(
excessiveUpstream.url,
'00000000-0000-4000-8000-000000000093'
)
],
new AbortController().signal
)!
await expect(
gateway.prepareCustomMcpTools(cycleToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('分页游标发生循环')
})
})
await expect(
gateway.prepareCustomMcpTools(excessiveToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('工具数量超过安全限制')
})
})
})
it('releases rejected initialize attempts before enforcing the session limit', async () => {
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const endpoint = gateway.getEndpoint()!
const token = gateway.grant(
'initialize-retry',
[firstLibraryId],
new AbortController().signal
)!
const initialize = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: {
name: 'initialize-retry-fixture',
version: '1.0.0'
}
}
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const rejected = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
'content-type': 'application/json'
},
body: JSON.stringify(initialize)
})
expect(rejected.status).toBe(406)
}
const accepted = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
authorization: `Bearer ${token}`,
'content-type': 'application/json'
},
body: JSON.stringify(initialize)
})
expect(accepted.status).toBe(200)
expect(accepted.headers.get('mcp-session-id')).toEqual(
expect.any(String)
)
})
it('publishes upstream tool changes downstream after a successful refresh', async () => {
let tools = [testTool('before')]
const listTools = vi.fn(async () => ({ tools }))
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const token = gateway.grantCustomMcp(
'dynamic-tools',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const listChanged = vi.fn()
const client = new Client(
{ name: 'dynamic-client', version: '1.0.0' },
{
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: listChanged
}
}
}
)
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
const initial = await client.listTools()
expect(initial.tools[0]?.name).toMatch(/_before$/u)
await new Promise((resolve) => setTimeout(resolve, 25))
tools = [testTool('after')]
await upstream.notifyToolsChanged()
await vi.waitFor(() => {
expect(listChanged).toHaveBeenCalledWith(null, null)
})
const updated = await client.listTools()
expect(updated.tools.map((tool) => tool.name)).toEqual([
expect.stringMatching(/_after$/u)
])
} finally {
await client.close()
}
})
it('does not publish a downstream change when dynamic refresh fails', async () => {
let failRefresh = false
const listTools = vi.fn(async () => {
if (failRefresh) {
throw new Error('refresh failed')
}
return { tools: [testTool('stable')] }
})
const upstream = await startToolUpstream(listTools)
const { service } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
await gateway.start()
const token = gateway.grantCustomMcp(
'failed-refresh',
[customMcpServer(upstream.url)],
new AbortController().signal
)!
const listChanged = vi.fn()
const client = new Client(
{ name: 'failed-refresh-client', version: '1.0.0' },
{
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: listChanged
}
}
}
)
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
await client.listTools()
await new Promise((resolve) => setTimeout(resolve, 25))
failRefresh = true
await upstream.notifyToolsChanged()
await vi.waitFor(() => {
expect(listTools).toHaveBeenCalledTimes(2)
})
await new Promise((resolve) => setTimeout(resolve, 25))
expect(listChanged).not.toHaveBeenCalled()
} finally {
await client.close()
}
})
}) })
+376 -106
View File
@@ -8,10 +8,13 @@ import {
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js' import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import type { JsonSchemaValidator } from '@modelcontextprotocol/sdk/validation'
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
import { import {
CallToolRequestSchema, CallToolRequestSchema,
CallToolResultSchema, CallToolResultSchema,
ListToolsRequestSchema, ListToolsRequestSchema,
isInitializeRequest,
type CallToolResult, type CallToolResult,
type Tool type Tool
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
@@ -57,6 +60,7 @@ import type {
import { import {
createMcpToolName, createMcpToolName,
isValidMcpToolName, isValidMcpToolName,
listAllMcpTools,
normalizeMcpToolSchema normalizeMcpToolSchema
} from './mcp-tool-utils' } from './mcp-tool-utils'
@@ -65,11 +69,13 @@ const MAX_RESULT_BYTES = 128 * 1024
const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024 const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024
const MAX_CUSTOM_MCP_SERVERS = 16 const MAX_CUSTOM_MCP_SERVERS = 16
const MAX_CUSTOM_MCP_TOOLS = 100 const MAX_CUSTOM_MCP_TOOLS = 100
const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
const CUSTOM_MCP_TIMEOUT_MS = 30_000 const CUSTOM_MCP_TIMEOUT_MS = 30_000
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000 const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000 const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000 const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
const MAX_CAPABILITY_TTL_MS = 15 * 60_000 const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export { export {
knowledgeToolNames, knowledgeToolNames,
@@ -179,6 +185,7 @@ type CustomMcpBinding = {
originalName: string originalName: string
exposedTool: Tool exposedTool: Tool
taskSupport?: 'forbidden' | 'optional' | 'required' taskSupport?: 'forbidden' | 'optional' | 'required'
outputValidator?: JsonSchemaValidator<Record<string, unknown>>
} }
type CustomMcpConnection = { type CustomMcpConnection = {
@@ -187,6 +194,19 @@ type CustomMcpConnection = {
bindings: CustomMcpBinding[] bindings: CustomMcpBinding[]
dynamicToolsSupported: boolean dynamicToolsSupported: boolean
dynamicToolsChanged: boolean dynamicToolsChanged: boolean
dynamicToolsChangeVersion: number
dynamicToolsRefresh?: Promise<void>
}
type DownstreamMcpSession = {
id?: string
registryKey: string
token: string
mcp: McpProtocolServer
transport: StreamableHTTPServerTransport
initialized: boolean
listedTools: boolean
closing?: Promise<void>
} }
export type KnowledgeMcpGatewayOptions = { export type KnowledgeMcpGatewayOptions = {
@@ -308,6 +328,11 @@ async function readBoundedJson(
export class KnowledgeMcpGateway { export class KnowledgeMcpGateway {
private readonly capabilities = new Map<string, Capability>() private readonly capabilities = new Map<string, Capability>()
private readonly downstreamMcpSessions = new Map<
string,
DownstreamMcpSession
>()
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
private readonly customMcpCleanups = new Set<Promise<void>>() private readonly customMcpCleanups = new Set<Promise<void>>()
private readonly now: () => number private readonly now: () => number
private readonly capabilityTtlMs: number private readonly capabilityTtlMs: number
@@ -490,6 +515,11 @@ export class KnowledgeMcpGateway {
capability.brokerController.abort( capability.brokerController.abort(
new Error('MCP capability was revoked') new Error('MCP capability was revoked')
) )
for (const session of this.downstreamMcpSessions.values()) {
if (session.token === token) {
void this.closeDownstreamMcpSession(session)
}
}
if (capability.configAccess !== 'none') { if (capability.configAccess !== 'none') {
this.configService?.revokeRequest(capability.requestId) this.configService?.revokeRequest(capability.requestId)
} }
@@ -500,6 +530,24 @@ export class KnowledgeMcpGateway {
}) })
} }
private closeDownstreamMcpSession(
session: DownstreamMcpSession
): Promise<void> {
if (session.closing) {
return session.closing
}
this.downstreamMcpSessions.delete(session.registryKey)
const cleanup = session.mcp
.close()
.catch(() => undefined)
.finally(() => {
this.downstreamMcpCleanups.delete(cleanup)
})
session.closing = cleanup
this.downstreamMcpCleanups.add(cleanup)
return cleanup
}
drainReferences( drainReferences(
token: string | undefined token: string | undefined
): KnowledgeSearchReference[] { ): KnowledgeSearchReference[] {
@@ -673,6 +721,11 @@ export class KnowledgeMcpGateway {
server, server,
originalName: tool.name, originalName: tool.name,
taskSupport: tool.execution?.taskSupport, taskSupport: tool.execution?.taskSupport,
outputValidator: tool.outputSchema
? customMcpJsonSchemaValidator.getValidator<
Record<string, unknown>
>(normalizeMcpToolSchema(tool.outputSchema))
: undefined,
exposedTool: { exposedTool: {
name: createMcpToolName(server.id, tool.name), name: createMcpToolName(server.id, tool.name),
title: `${server.name} / ${tool.name}`.slice(0, 200), title: `${server.name} / ${tool.name}`.slice(0, 200),
@@ -690,11 +743,116 @@ export class KnowledgeMcpGateway {
}) })
} }
private async listAllCustomMcpTools(
client: Client,
server: ResolvedMcpServer,
signal: AbortSignal
): Promise<Awaited<ReturnType<Client['listTools']>>['tools']> {
return listAllMcpTools(client, server.name, signal, {
maximumTools: MAX_CUSTOM_MCP_TOOLS,
pageTimeoutMs: CUSTOM_MCP_TIMEOUT_MS,
totalTimeoutMs: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
})
}
private async publishCustomMcpToolListChanged(
capability: Capability
): Promise<void> {
const token = [...this.capabilities.entries()].find(
([, value]) => value === capability
)?.[0]
if (!token) {
return
}
const sessions = [...this.downstreamMcpSessions.values()].filter(
(session) =>
session.token === token &&
session.initialized &&
session.listedTools
)
await Promise.allSettled(
sessions.map((session) => session.mcp.sendToolListChanged())
)
}
private scheduleDynamicToolsRefresh(
capability: Capability,
connection: CustomMcpConnection
): void {
void this.refreshDynamicTools(capability, connection).catch(
() => undefined
)
}
private refreshDynamicTools(
capability: Capability,
connection: CustomMcpConnection,
signal?: AbortSignal
): Promise<void> {
if (connection.dynamicToolsRefresh) {
return connection.dynamicToolsRefresh
}
const effectiveSignal = signal
? AbortSignal.any([
signal,
capability.signal,
capability.brokerController.signal
])
: AbortSignal.any([
capability.signal,
capability.brokerController.signal
])
const changeVersion = connection.dynamicToolsChangeVersion
let refreshSucceeded = false
const refresh = (async () => {
try {
const tools = await this.listAllCustomMcpTools(
connection.client,
connection.server,
effectiveSignal
)
const bindings = this.createCustomMcpBindings(
connection.client,
connection.server,
tools
)
connection.bindings = bindings
connection.dynamicToolsChanged =
connection.dynamicToolsChangeVersion !== changeVersion
refreshSucceeded = true
await this.publishCustomMcpToolListChanged(capability)
} catch (error) {
connection.dynamicToolsChanged = true
if (effectiveSignal.aborted) {
throw effectiveSignal.reason
}
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
}
})()
connection.dynamicToolsRefresh = refresh
void refresh.finally(() => {
connection.dynamicToolsRefresh = undefined
if (
refreshSucceeded &&
connection.dynamicToolsChanged &&
!capability.signal.aborted &&
!capability.brokerController.signal.aborted
) {
this.scheduleDynamicToolsRefresh(capability, connection)
}
}).catch(() => undefined)
return refresh
}
private async connectCustomMcpServer( private async connectCustomMcpServer(
capability: Capability, capability: Capability,
server: ResolvedMcpServer server: ResolvedMcpServer
): Promise<CustomMcpConnection> { ): Promise<CustomMcpConnection> {
let connection: CustomMcpConnection | undefined let connection: CustomMcpConnection | undefined
let dynamicToolsChangeVersion = 0
const client = new Client( const client = new Client(
{ {
name: 'goodbuddy-main-mcp-broker', name: 'goodbuddy-main-mcp-broker',
@@ -707,8 +865,17 @@ export class KnowledgeMcpGateway {
autoRefresh: false, autoRefresh: false,
debounceMs: 0, debounceMs: 0,
onChanged: (error) => { onChanged: (error) => {
if (!error && connection) { if (!error) {
connection.dynamicToolsChanged = true dynamicToolsChangeVersion += 1
if (connection) {
connection.dynamicToolsChangeVersion =
dynamicToolsChangeVersion
connection.dynamicToolsChanged = true
this.scheduleDynamicToolsRefresh(
capability,
connection
)
}
} }
} }
} }
@@ -725,22 +892,29 @@ export class KnowledgeMcpGateway {
timeout: CUSTOM_MCP_TIMEOUT_MS, timeout: CUSTOM_MCP_TIMEOUT_MS,
signal signal
}) })
const result = await client.listTools(undefined, { const listedAtChangeVersion = dynamicToolsChangeVersion
timeout: CUSTOM_MCP_TIMEOUT_MS, const tools = await this.listAllCustomMcpTools(
client,
server,
signal signal
}) )
connection = { connection = {
client, client,
server, server,
bindings: this.createCustomMcpBindings( bindings: this.createCustomMcpBindings(
client, client,
server, server,
result.tools tools
), ),
dynamicToolsSupported: dynamicToolsSupported:
server.allowDynamicTools && server.allowDynamicTools &&
client.getServerCapabilities()?.tools?.listChanged === true, client.getServerCapabilities()?.tools?.listChanged === true,
dynamicToolsChanged: false dynamicToolsChanged:
dynamicToolsChangeVersion !== listedAtChangeVersion,
dynamicToolsChangeVersion
}
if (connection.dynamicToolsChanged) {
this.scheduleDynamicToolsRefresh(capability, connection)
} }
return connection return connection
} catch (error) { } catch (error) {
@@ -791,41 +965,19 @@ export class KnowledgeMcpGateway {
throw error throw error
} }
if (refreshDynamic) { if (refreshDynamic) {
const effectiveSignal = signal
? AbortSignal.any([
signal,
capability.signal,
capability.brokerController.signal
])
: AbortSignal.any([
capability.signal,
capability.brokerController.signal
])
for (const connection of connections) { for (const connection of connections) {
if ( if (
!connection.dynamicToolsSupported || !connection.dynamicToolsSupported ||
!connection.dynamicToolsChanged (!connection.dynamicToolsChanged &&
!connection.dynamicToolsRefresh)
) { ) {
continue continue
} }
connection.dynamicToolsChanged = false await this.refreshDynamicTools(
try { capability,
const result = await connection.client.listTools(undefined, { connection,
timeout: CUSTOM_MCP_TIMEOUT_MS, signal
signal: effectiveSignal )
})
connection.bindings = this.createCustomMcpBindings(
connection.client,
connection.server,
result.tools
)
} catch (error) {
connection.dynamicToolsChanged = true
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
}
} }
} }
const bindings = new Map<string, CustomMcpBinding>() const bindings = new Map<string, CustomMcpBinding>()
@@ -877,7 +1029,8 @@ export class KnowledgeMcpGateway {
} }
try { try {
if (binding.taskSupport !== 'required') { if (binding.taskSupport !== 'required') {
return ensureBoundedCustomMcpResult( return this.validateCustomMcpResult(
binding,
await binding.client.callTool(params, undefined, options) await binding.client.callTool(params, undefined, options)
) )
} }
@@ -886,7 +1039,10 @@ export class KnowledgeMcpGateway {
for await (const message of binding.client.experimental.tasks.callToolStream( for await (const message of binding.client.experimental.tasks.callToolStream(
params, params,
undefined, undefined,
options {
...options,
task: {}
}
)) { )) {
if ( if (
(message.type === 'taskCreated' || (message.type === 'taskCreated' ||
@@ -895,7 +1051,10 @@ export class KnowledgeMcpGateway {
) { ) {
taskId = message.task.taskId taskId = message.task.taskId
} else if (message.type === 'result') { } else if (message.type === 'result') {
return ensureBoundedCustomMcpResult(message.result) return this.validateCustomMcpResult(
binding,
message.result
)
} else if (message.type === 'error') { } else if (message.type === 'error') {
throw message.error throw message.error
} }
@@ -923,6 +1082,33 @@ export class KnowledgeMcpGateway {
} }
} }
private validateCustomMcpResult(
binding: CustomMcpBinding,
result: unknown
): CallToolResult {
const bounded = ensureBoundedCustomMcpResult(result)
if (!binding.outputValidator) {
return bounded
}
if (!bounded.structuredContent) {
if (!bounded.isError) {
throw new Error(
`MCP 工具「${binding.originalName}」未返回结构化结果`
)
}
return bounded
}
const validation = binding.outputValidator(
bounded.structuredContent
)
if (!validation.valid) {
throw new Error(
`MCP 工具「${binding.originalName}」返回结果不符合声明结构:${validation.errorMessage.slice(0, 500)}`
)
}
return bounded
}
private async closeCustomMcpConnections( private async closeCustomMcpConnections(
capability: Capability capability: Capability
): Promise<void> { ): Promise<void> {
@@ -1218,55 +1404,10 @@ export class KnowledgeMcpGateway {
} }
} }
private async handleRequest( private createDownstreamMcpSession(
request: IncomingMessage, token: string,
response: ServerResponse availableScopedTools: ReadonlySet<ScopedDataToolName>
): Promise<void> { ): DownstreamMcpSession {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
return
}
if (request.method !== 'POST') {
response.setHeader('allow', 'POST')
sendJson(response, 405, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null
})
return
}
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
!authorization.startsWith('Bearer ')
) {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
const token = authorization.slice('Bearer '.length)
try {
this.getCapability(token)
} catch {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
let body: unknown
try {
body = await readBoundedJson(request, this.maximumBodyBytes)
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, {
error:
error instanceof RangeError
? 'Request body too large'
: 'Invalid JSON'
})
return
}
const availableScopedTools = new Set(
this.getAvailableToolNames(token)
)
const mcp = new McpProtocolServer( const mcp = new McpProtocolServer(
{ {
name: 'goodbuddy-request-scoped-capabilities', name: 'goodbuddy-request-scoped-capabilities',
@@ -1274,10 +1415,38 @@ export class KnowledgeMcpGateway {
}, },
{ {
capabilities: { capabilities: {
tools: {} tools: {
listChanged: true
}
} }
} }
) )
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomBytes(32).toString('base64url'),
onsessioninitialized: (sessionId) => {
this.downstreamMcpSessions.delete(session.registryKey)
session.id = sessionId
session.registryKey = sessionId
this.downstreamMcpSessions.set(sessionId, session)
},
onsessionclosed: (sessionId) => {
this.downstreamMcpSessions.delete(sessionId)
}
})
const session: DownstreamMcpSession = {
registryKey: randomBytes(32).toString('base64url'),
token,
mcp,
transport,
initialized: false,
listedTools: false
}
transport.onclose = () => {
this.downstreamMcpSessions.delete(session.registryKey)
}
mcp.oninitialized = () => {
session.initialized = true
}
mcp.setRequestHandler( mcp.setRequestHandler(
ListToolsRequestSchema, ListToolsRequestSchema,
async (_request, extra) => { async (_request, extra) => {
@@ -1287,9 +1456,7 @@ export class KnowledgeMcpGateway {
) )
const scopedTools = [...availableScopedTools].flatMap( const scopedTools = [...availableScopedTools].flatMap(
(name): Tool[] => { (name): Tool[] => {
const definition = scopedDataToolByName.get( const definition = scopedDataToolByName.get(name)
name as ScopedDataToolName
)
if (!definition) { if (!definition) {
return [] return []
} }
@@ -1315,6 +1482,7 @@ export class KnowledgeMcpGateway {
] ]
} }
) )
session.listedTools = true
return { return {
tools: [ tools: [
...scopedTools, ...scopedTools,
@@ -1330,9 +1498,7 @@ export class KnowledgeMcpGateway {
async (call, extra) => { async (call, extra) => {
const name = call.params.name const name = call.params.name
const input = call.params.arguments ?? {} const input = call.params.arguments ?? {}
if ( if (availableScopedTools.has(name as ScopedDataToolName)) {
availableScopedTools.has(name as ScopedDataToolName)
) {
const definition = scopedDataToolByName.get( const definition = scopedDataToolByName.get(
name as ScopedDataToolName name as ScopedDataToolName
) )
@@ -1369,20 +1535,123 @@ export class KnowledgeMcpGateway {
) )
} }
) )
const transport = new StreamableHTTPServerTransport({ return session
sessionIdGenerator: undefined }
})
const close = (): void => { private async handleRequest(
void Promise.allSettled([transport.close(), mcp.close()]) request: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
return
} }
response.once('close', close) if (
request.method !== 'POST' &&
request.method !== 'GET' &&
request.method !== 'DELETE'
) {
response.setHeader('allow', 'POST, GET, DELETE')
sendJson(response, 405, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed' },
id: null
})
return
}
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
!authorization.startsWith('Bearer ')
) {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
const token = authorization.slice('Bearer '.length)
try { try {
await mcp.connect(transport) this.getCapability(token)
await transport.handleRequest(request, response, body) } catch {
sendJson(response, 401, { error: 'Unauthorized' })
return
}
let body: unknown
if (request.method === 'POST') {
try {
body = await readBoundedJson(request, this.maximumBodyBytes)
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, {
error:
error instanceof RangeError
? 'Request body too large'
: 'Invalid JSON'
})
return
}
}
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
typeof sessionId === 'string'
? this.downstreamMcpSessions.get(sessionId)
: undefined
if (session && session.token !== token) {
session = undefined
}
if (!session) {
if (
request.method !== 'POST' ||
!isInitializeRequest(body) ||
typeof sessionId === 'string'
) {
sendJson(response, typeof sessionId === 'string' ? 404 : 400, {
jsonrpc: '2.0',
error: {
code:
typeof sessionId === 'string' ? -32001 : -32000,
message:
typeof sessionId === 'string'
? 'Session not found'
: 'Bad Request: No valid session ID provided'
},
id: null
})
return
}
const sessionCount = [
...this.downstreamMcpSessions.values()
].filter((candidate) => candidate.token === token).length
if (
sessionCount >=
MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY
) {
sendJson(response, 429, {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many MCP sessions'
},
id: null
})
return
}
const availableScopedTools = new Set(
this.getAvailableToolNames(token)
)
session = this.createDownstreamMcpSession(
token,
availableScopedTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
await session.transport.handleRequest(request, response, body)
} finally { } finally {
if (response.writableFinished) { if (createdSession && session.id === undefined) {
response.off('close', close) await this.closeDownstreamMcpSession(session)
close()
} }
} }
} }
@@ -1391,6 +1660,7 @@ export class KnowledgeMcpGateway {
for (const token of [...this.capabilities.keys()]) { for (const token of [...this.capabilities.keys()]) {
this.revoke(token) this.revoke(token)
} }
await Promise.allSettled([...this.downstreamMcpCleanups])
await Promise.allSettled([...this.customMcpCleanups]) await Promise.allSettled([...this.customMcpCleanups])
const server = this.server const server = this.server
this.server = undefined this.server = undefined
+71
View File
@@ -1,6 +1,77 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024 const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024
const DEFAULT_MAXIMUM_MCP_TOOL_PAGES = 100
type ListedMcpTool =
Awaited<ReturnType<Client['listTools']>>['tools'][number]
export async function listAllMcpTools(
client: Pick<Client, 'listTools'>,
serverName: string,
signal: AbortSignal,
options: {
maximumTools: number
pageTimeoutMs: number
totalTimeoutMs: number
maximumPages?: number
}
): Promise<ListedMcpTool[]> {
const tools: ListedMcpTool[] = []
const toolNames = new Set<string>()
const cursors = new Set<string>()
const deadline = Date.now() + options.totalTimeoutMs
const maximumPages =
options.maximumPages ?? DEFAULT_MAXIMUM_MCP_TOOL_PAGES
let cursor: string | undefined
for (let page = 0; page < maximumPages; page += 1) {
signal.throwIfAborted()
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
throw new Error(
`MCP Server「${serverName}」的工具分页超过总超时`
)
}
const result = await client.listTools(
cursor !== undefined ? { cursor } : undefined,
{
timeout: Math.max(
1,
Math.min(options.pageTimeoutMs, remainingMs)
),
signal
}
)
for (const tool of result.tools) {
if (toolNames.has(tool.name)) {
throw new Error(
`MCP Server「${serverName}」返回了重复工具「${tool.name}`
)
}
toolNames.add(tool.name)
tools.push(tool)
if (tools.length > options.maximumTools) {
throw new Error(
`MCP Server「${serverName}」提供的工具数量超过安全限制`
)
}
}
if (result.nextCursor === undefined) {
return tools
}
if (cursors.has(result.nextCursor)) {
throw new Error(
`MCP Server「${serverName}」的工具分页游标发生循环`
)
}
cursors.add(result.nextCursor)
cursor = result.nextCursor
}
throw new Error(
`MCP Server「${serverName}」的工具分页超过安全限制`
)
}
export function isValidMcpToolName(value: unknown): value is string { export function isValidMcpToolName(value: unknown): value is string {
return ( return (
+128
View File
@@ -490,6 +490,134 @@ describe('ModelToolProvider', () => {
await overflowingProvider.dispose() await overflowingProvider.dispose()
}) })
it('loads every paginated MCP tool before exposing the catalog', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockImplementation(
async (params?: { cursor?: string }) =>
params?.cursor === ''
? {
tools: [
{
name: 'second',
inputSchema: {
type: 'object',
properties: {}
}
}
]
}
: {
tools: [
{
name: 'first',
inputSchema: {
type: 'object',
properties: {}
}
}
],
nextCursor: ''
}
)
const provider = new ModelToolProvider(workspace, [
createMcpServer()
])
const tools = await provider.listTools(
toolContext,
new AbortController().signal
)
expect(tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ displayName: 'Search MCP / first' }),
expect.objectContaining({ displayName: 'Search MCP / second' })
])
)
expect(mocks.client.listTools).toHaveBeenNthCalledWith(
2,
{ cursor: '' },
expect.objectContaining({ timeout: expect.any(Number) })
)
await provider.dispose()
})
it('refreshes a dynamic MCP change announced during initial listing', async () => {
const workspace = await createWorkspace()
let resolveInitialList:
| ((value: {
tools: Array<{
name: string
inputSchema: {
type: 'object'
properties: Record<string, never>
}
}>
}) => void)
| undefined
mocks.client.getServerCapabilities.mockReturnValue({
tools: { listChanged: true }
})
mocks.client.listTools
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInitialList = resolve
})
)
.mockResolvedValueOnce({
tools: [
{
name: 'current',
inputSchema: {
type: 'object',
properties: {}
}
}
]
})
const provider = new ModelToolProvider(workspace, [
createMcpServer(true)
])
const listing = provider.listTools(
toolContext,
new AbortController().signal
)
await vi.waitFor(() => {
expect(mocks.client.listTools).toHaveBeenCalledOnce()
})
const options = mocks.Client.mock.calls[0]?.[1] as
| {
listChanged?: {
tools?: {
onChanged?: (error?: Error) => void
}
}
}
| undefined
options?.listChanged?.tools?.onChanged?.()
resolveInitialList?.({
tools: [
{
name: 'stale',
inputSchema: {
type: 'object',
properties: {}
}
}
]
})
await expect(listing).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ displayName: 'Search MCP / current' })
])
)
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
await provider.dispose()
})
it('rejects workspace traversal before accessing the filesystem', async () => { it('rejects workspace traversal before accessing the filesystem', async () => {
const workspace = await createWorkspace() const workspace = await createWorkspace()
const provider = new ModelToolProvider(workspace) const provider = new ModelToolProvider(workspace)
+34 -13
View File
@@ -41,6 +41,7 @@ import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { import {
createMcpToolName, createMcpToolName,
isValidMcpToolName, isValidMcpToolName,
listAllMcpTools,
normalizeMcpToolSchema normalizeMcpToolSchema
} from './mcp-tool-utils' } from './mcp-tool-utils'
@@ -736,6 +737,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
clientScope: Set<Client> = this.customMcpClients clientScope: Set<Client> = this.customMcpClients
): Promise<ConnectedMcp> { ): Promise<ConnectedMcp> {
let connection: ConnectedMcp | undefined let connection: ConnectedMcp | undefined
let dynamicToolsChangeVersion = 0
const client = new Client( const client = new Client(
{ {
name: 'goodbuddy-direct-model', name: 'goodbuddy-direct-model',
@@ -748,8 +750,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
autoRefresh: false, autoRefresh: false,
debounceMs: 0, debounceMs: 0,
onChanged: (error) => { onChanged: (error) => {
if (!error && connection) { if (!error) {
connection.dynamicToolsChanged = true dynamicToolsChangeVersion += 1
if (connection) {
connection.dynamicToolsChanged = true
}
} }
} }
} }
@@ -764,18 +769,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
timeout: MCP_TIMEOUT_MS, timeout: MCP_TIMEOUT_MS,
signal signal
}) })
const result = await client.listTools(undefined, { const listedAtChangeVersion = dynamicToolsChangeVersion
timeout: MCP_TIMEOUT_MS, const tools = await listAllMcpTools(
signal client,
}) server.name,
signal,
{
maximumTools:
MAX_MODEL_TOOLS - this.getReservedToolCount(),
pageTimeoutMs: MCP_TIMEOUT_MS,
totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS
}
)
connection = { connection = {
client, client,
server, server,
tools: this.createMcpBindings(client, server, result.tools), tools: this.createMcpBindings(client, server, tools),
dynamicToolsSupported: dynamicToolsSupported:
server.allowDynamicTools && server.allowDynamicTools &&
client.getServerCapabilities()?.tools?.listChanged === true, client.getServerCapabilities()?.tools?.listChanged === true,
dynamicToolsChanged: false dynamicToolsChanged:
dynamicToolsChangeVersion !== listedAtChangeVersion
} }
return connection return connection
} catch (error) { } catch (error) {
@@ -863,14 +877,21 @@ export class ModelToolProvider implements ModelToolProviderLike {
} }
connection.dynamicToolsChanged = false connection.dynamicToolsChanged = false
try { try {
const result = await connection.client.listTools(undefined, { const tools = await listAllMcpTools(
timeout: MCP_TIMEOUT_MS, connection.client,
signal connection.server.name,
}) signal,
{
maximumTools:
MAX_MODEL_TOOLS - this.getReservedToolCount(),
pageTimeoutMs: MCP_TIMEOUT_MS,
totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS
}
)
connection.tools = this.createMcpBindings( connection.tools = this.createMcpBindings(
connection.client, connection.client,
connection.server, connection.server,
result.tools tools
) )
} catch (error) { } catch (error) {
connection.dynamicToolsChanged = true connection.dynamicToolsChanged = true
+129 -9
View File
@@ -2984,7 +2984,7 @@ describe('OpenCodeRuntime native customization', () => {
await runtime.dispose() await runtime.dispose()
}) })
it('compacts an existing managed session through the v2 API', async () => { it('compacts a managed session through the supported native API', async () => {
const setup = runClient([ const setup = runClient([
{ {
id: 'idle', id: 'idle',
@@ -2993,19 +2993,74 @@ describe('OpenCodeRuntime native customization', () => {
} }
]) ])
const context = vi.fn().mockResolvedValue({ const context = vi.fn().mockResolvedValue({
data: { data: [] } data: {
data: [
{
type: 'assistant',
model: {
providerID: 'anthropic',
id: 'claude-sonnet'
}
}
]
}
}) })
const compact = vi.fn().mockResolvedValue({ const summarize = vi.fn().mockResolvedValue({
data: undefined, data: true,
error: undefined error: undefined
}) })
Object.assign(setup.client, { Object.assign(setup.client, {
v2: { v2: {
session: { context, compact } session: { context }
} },
session: { ...setup.client.session, summarize }
}) })
const runtime = embeddedRuntime(setup.client) const runtime = embeddedRuntime(setup.client)
await collectRun(runtime) await collectRun(runtime)
vi.mocked(setup.event.subscribe).mockResolvedValueOnce({
stream: (async function* () {
yield {
type: 'message.updated',
properties: {
sessionID: 'session-1',
info: {
id: 'compaction-message',
sessionID: 'session-1',
role: 'assistant',
time: {
created: 1,
completed: 2
},
parentID: 'compaction-parent',
modelID: 'claude-sonnet',
providerID: 'anthropic',
mode: 'compaction',
agent: 'build',
path: {
cwd: process.cwd(),
root: process.cwd()
},
cost: 0,
tokens: {
input: 100,
output: 20,
reasoning: 0,
cache: {
read: 30,
write: 4
},
total: 124
},
finish: 'stop'
}
}
}
yield {
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
} as never)
const signal = new AbortController().signal const signal = new AbortController().signal
await expect( await expect(
@@ -3025,19 +3080,84 @@ describe('OpenCodeRuntime native customization', () => {
strategy: 'native', strategy: 'native',
compacted: true, compacted: true,
detail: 'OpenCode 已完成原生上下文压缩' detail: 'OpenCode 已完成原生上下文压缩'
} },
usageEvents: [
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'model-usage',
callId: 'compaction-message',
runtime: 'opencode',
provider: 'anthropic',
model: 'claude-sonnet',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 4,
reportedTotalTokens: 124
}
]
}) })
expect(context).toHaveBeenCalledWith( expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' }, { sessionID: 'session-1' },
{ signal } { signal }
) )
expect(compact).toHaveBeenCalledWith( expect(summarize).toHaveBeenCalledWith(
{ sessionID: 'session-1' }, {
sessionID: 'session-1',
directory: process.cwd(),
providerID: 'anthropic',
modelID: 'claude-sonnet',
auto: false
},
{ signal } { signal }
) )
await runtime.dispose() await runtime.dispose()
}) })
it('reports when a managed session has no model to compact with', async () => {
const setup = runClient([
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const context = vi.fn().mockResolvedValue({
data: { data: [] }
})
const summarize = vi.fn()
Object.assign(setup.client, {
v2: {
session: { context }
},
session: { ...setup.client.session, summarize }
})
const runtime = embeddedRuntime(setup.client)
await collectRun(runtime)
await expect(
runtime.compactConversation(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
runtimeSelection: { provider: 'opencode' },
history: [],
historyMessageIds: []
},
new AbortController().signal
)
).resolves.toEqual({
result: {
provider: 'opencode',
strategy: 'native',
compacted: false,
detail: '当前 OpenCode 会话尚无可用于压缩的模型记录'
}
})
expect(summarize).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports when no managed OpenCode session can be compacted', async () => { it('reports when no managed OpenCode session can be compacted', async () => {
const runtime = new OpenCodeRuntime(options()) const runtime = new OpenCodeRuntime(options())
+113 -19
View File
@@ -77,6 +77,7 @@ const MAX_NATIVE_MCP_SERVERS =
runtimeNativeInventoryLimits.mcpServers runtimeNativeInventoryLimits.mcpServers
const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills
const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources
const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000
const EMBEDDED_SERVER_USERNAME = 'goodbuddy' const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const TEMPORARY_MCP_PREFIXES = [ const TEMPORARY_MCP_PREFIXES = [
@@ -2407,27 +2408,120 @@ export class OpenCodeRuntime implements AgentRuntime {
if (context.error || !context.data) { if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
} }
signal.throwIfAborted() const latestAssistant = [...context.data.data]
const compact = await client.v2.session.compact( .reverse()
{ sessionID: sessionId }, .find((message) => message.type === 'assistant')
{ signal } const configuredModel = this.options.modelProfile
) ? {
if (compact.error) { providerID: resolveOpenCodeProvider(
throw new Error( this.options.modelProfile
opencodeErrorMessage( ).id,
compact.error, modelID: this.options.modelProfile.modelName
'OpenCode 原生 Compact 失败' }
) : latestAssistant?.type === 'assistant'
) ? {
} providerID: latestAssistant.model.providerID,
return { modelID: latestAssistant.model.id
result: { }
provider: 'opencode', : undefined
strategy: 'native', if (!configuredModel) {
compacted: true, return {
detail: 'OpenCode 已完成原生上下文压缩' result: {
provider: 'opencode',
strategy: 'native',
compacted: false,
detail: '当前 OpenCode 会话尚无可用于压缩的模型记录'
}
} }
} }
signal.throwIfAborted()
const subscriptionController = new AbortController()
const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{
signal: AbortSignal.any([
signal,
subscriptionController.signal
])
}
)
const usageEvents: RuntimeModelUsageEvent[] = []
const reportedMessageIds = new Set<string>()
const usageCapture = (async () => {
for await (const event of subscription.stream) {
if (
event.type === 'message.updated' &&
event.properties.sessionID === sessionId &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
!reportedMessageIds.has(event.properties.info.id)
) {
const usage = createUsageEvent(
request.requestId,
event.properties.info
)
if (usage) {
reportedMessageIds.add(event.properties.info.id)
usageEvents.push(usage)
}
}
if (
event.type === 'session.idle' &&
event.properties.sessionID === sessionId
) {
return
}
}
})()
try {
const compact = await client.session.summarize(
{
sessionID: sessionId,
directory: this.options.defaultWorkspace,
providerID: configuredModel.providerID,
modelID: configuredModel.modelID,
auto: false
},
{ signal }
)
if (compact.error || compact.data !== true) {
throw new Error(
opencodeErrorMessage(
compact.error,
'OpenCode 原生 Compact 失败'
)
)
}
let graceTimer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
usageCapture,
new Promise<void>((resolveGrace) => {
graceTimer = setTimeout(
resolveGrace,
COMPACTION_USAGE_EVENT_GRACE_MS
)
graceTimer.unref?.()
})
])
} finally {
if (graceTimer) {
clearTimeout(graceTimer)
}
}
return {
result: {
provider: 'opencode',
strategy: 'native',
compacted: true,
detail: 'OpenCode 已完成原生上下文压缩'
},
...(usageEvents.length > 0 ? { usageEvents } : {})
}
} finally {
subscriptionController.abort()
await usageCapture.catch(() => undefined)
}
} finally { } finally {
releaseConversation?.() releaseConversation?.()
releaseEmbedded() releaseEmbedded()
+97
View File
@@ -922,6 +922,103 @@ describe.runIf(enabled)('runtime end-to-end', () => {
180_000 180_000
) )
it(
'compacts and continues a real bundled OpenCode session',
async () => {
const runtime = new OpenCodeRuntime({
embedded: true,
binaryPath: '',
bundledBinaryPath: join(
portableRoot,
'resources',
'runtimes',
'opencode',
'opencode.exe'
),
configPath: '',
defaultWorkspace: workspace,
modelProfile: {
id: crypto.randomUUID(),
name: 'E2E model',
baseUrl,
modelName,
apiKey,
protocol,
authentication: 'api-key'
}
})
const conversationId = crypto.randomUUID()
const signal = new AbortController().signal
try {
await expect(
collectText(
runtime.run(
{
requestId: crypto.randomUUID(),
conversationId,
workMode: 'ask',
prompt:
'Remember that the verification codename is NATIVE-COMPACT-739. Reply with exactly OPENCODE_COMPACT_READY.'
},
signal
)
)
).resolves.toContain('OPENCODE_COMPACT_READY')
await expect(
runtime.compactConversation(
{
requestId: crypto.randomUUID(),
conversationId,
runtimeSelection: { provider: 'opencode' },
history: [
{
role: 'user',
content:
'The verification codename is NATIVE-COMPACT-739.'
},
{
role: 'assistant',
content: 'OPENCODE_COMPACT_READY'
}
],
historyMessageIds: [
crypto.randomUUID(),
crypto.randomUUID()
]
},
signal
)
).resolves.toMatchObject({
result: {
provider: 'opencode',
strategy: 'native',
compacted: true
}
})
await expect(
collectText(
runtime.run(
{
requestId: crypto.randomUUID(),
conversationId,
workMode: 'ask',
prompt:
'Return exactly the verification codename from before and nothing else.'
},
signal
)
)
).resolves.toContain('NATIVE-COMPACT-739')
} finally {
await runtime.dispose()
}
},
180_000
)
it( it(
'completes an Execute file task through bundled Continue', 'completes an Execute file task through bundled Continue',
async () => { async () => {
+227 -8
View File
@@ -1,23 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events'
import { showDesktopNotificationWhenUnfocused } from './desktop-notification' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
registerDesktopNotificationActivation,
showDesktopNotificationWhenUnfocused
} from './desktop-notification'
const notificationMocks = vi.hoisted(() => ({ const notificationMocks = vi.hoisted(() => ({
activationHandler: undefined as (() => void) | undefined,
handleActivation: vi.fn((callback: () => void) => {
notificationMocks.activationHandler = callback
}),
close: vi.fn(),
isSupported: vi.fn(() => true), isSupported: vi.fn(() => true),
show: vi.fn() show: vi.fn(),
instances: [] as Array<{
emit: (event: string, ...args: unknown[]) => boolean
listenerCount: (event: string) => number
}>
})) }))
vi.mock('electron', () => ({ vi.mock('electron', async () => {
Notification: class { const { EventEmitter } = await import('node:events')
static isSupported = notificationMocks.isSupported
show = notificationMocks.show return {
Notification: class extends EventEmitter {
static handleActivation = notificationMocks.handleActivation
static isSupported = notificationMocks.isSupported
close = notificationMocks.close
show = notificationMocks.show
constructor() {
super()
notificationMocks.instances.push(this)
}
}
} }
})) })
describe('showDesktopNotificationWhenUnfocused', () => { describe('showDesktopNotificationWhenUnfocused', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
notificationMocks.activationHandler = undefined
notificationMocks.isSupported.mockReturnValue(true) notificationMocks.isSupported.mockReturnValue(true)
notificationMocks.instances.length = 0
})
afterEach(() => {
for (const notification of notificationMocks.instances) {
notification.emit('close', {})
}
}) })
it('suppresses desktop notifications while GoodBuddy is focused', () => { it('suppresses desktop notifications while GoodBuddy is focused', () => {
@@ -45,4 +77,191 @@ describe('showDesktopNotificationWhenUnfocused', () => {
expect(shown).toBe(true) expect(shown).toBe(true)
expect(notificationMocks.show).toHaveBeenCalledOnce() expect(notificationMocks.show).toHaveBeenCalledOnce()
}) })
it('registers Windows notification activation to restore GoodBuddy', () => {
const window = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => true),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
registerDesktopNotificationActivation(window as never, 'win32')
notificationMocks.activationHandler?.()
expect(notificationMocks.handleActivation).toHaveBeenCalledOnce()
expect(window.restore).toHaveBeenCalledOnce()
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
})
it('waits for the renderer before showing a cold-start activation', () => {
const webContents = new EventEmitter() as EventEmitter & {
getURL: ReturnType<typeof vi.fn>
isLoadingMainFrame: ReturnType<typeof vi.fn>
}
webContents.getURL = vi.fn(() => '')
webContents.isLoadingMainFrame = vi.fn(() => true)
const window = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn(),
webContents
}
registerDesktopNotificationActivation(window as never, 'win32')
notificationMocks.activationHandler?.()
expect(window.show).not.toHaveBeenCalled()
webContents.getURL.mockReturnValue(
'file:///D:/goodbuddy/out/renderer/index.html'
)
webContents.isLoadingMainFrame.mockReturnValue(false)
webContents.emit('did-finish-load')
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
})
it('does not register native activation outside Windows', () => {
registerDesktopNotificationActivation(
{
isDestroyed: vi.fn(() => false)
} as never,
'linux'
)
expect(notificationMocks.handleActivation).not.toHaveBeenCalled()
})
it('restores, shows, and focuses GoodBuddy when a notification is clicked', () => {
const window = {
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(() => true),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
notificationMocks.instances[0]!.emit('click', {})
expect(window.restore).toHaveBeenCalledOnce()
expect(window.show).toHaveBeenCalledOnce()
expect(window.focus).toHaveBeenCalledOnce()
expect(
notificationMocks.instances[0]!.listenerCount('click')
).toBe(0)
})
it('does not operate on a window destroyed before the click', () => {
const window = {
isDestroyed: vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValue(true),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
notificationMocks.instances[0]!.emit('click', {})
expect(window.isMinimized).not.toHaveBeenCalled()
expect(window.restore).not.toHaveBeenCalled()
expect(window.show).not.toHaveBeenCalled()
expect(window.focus).not.toHaveBeenCalled()
})
it('does not let window activation errors escape the native callback', () => {
const window = {
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(() => {
throw new Error('window closed')
}),
focus: vi.fn()
}
showDesktopNotificationWhenUnfocused(window as never, {
title: '任务已完成'
})
expect(() =>
notificationMocks.instances[0]!.emit('click', {})
).not.toThrow()
expect(
notificationMocks.instances[0]!.listenerCount('click')
).toBe(0)
})
it.each(['close', 'failed'])(
'releases retained notifications after %s',
(event) => {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: '任务已完成' }
)
const notification = notificationMocks.instances[0]!
notification.emit(event, {})
expect(notification.listenerCount('click')).toBe(0)
}
)
it('releases an unhandled notification after a bounded retention period', () => {
vi.useFakeTimers()
try {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: '任务已完成' }
)
const notification = notificationMocks.instances[0]!
expect(notification.listenerCount('click')).toBe(1)
vi.advanceTimersByTime(15 * 60_000)
expect(notificationMocks.close).toHaveBeenCalledOnce()
expect(notification.listenerCount('click')).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('bounds retained notification instances during long-running sessions', () => {
for (let index = 0; index < 65; index += 1) {
showDesktopNotificationWhenUnfocused(
{
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => false)
} as never,
{ title: `任务已完成 ${index}` }
)
}
expect(notificationMocks.close).toHaveBeenCalledOnce()
expect(notificationMocks.instances[0]!.listenerCount('click')).toBe(0)
expect(notificationMocks.instances[64]!.listenerCount('click')).toBe(1)
})
}) })
+92 -1
View File
@@ -3,6 +3,50 @@ import {
type BrowserWindow, type BrowserWindow,
type NotificationConstructorOptions type NotificationConstructorOptions
} from 'electron' } from 'electron'
import { showWindow } from './window'
const MAX_RETAINED_NOTIFICATIONS = 64
const NOTIFICATION_RETENTION_MS = 15 * 60_000
const activeNotifications = new Map<Notification, () => void>()
const pendingWindowActivations = new WeakSet<BrowserWindow>()
function activateWindow(window: BrowserWindow): void {
try {
if (window.isDestroyed()) {
return
}
const webContents = window.webContents
if (
webContents &&
(!webContents.getURL() ||
webContents.isLoadingMainFrame())
) {
if (!pendingWindowActivations.has(window)) {
pendingWindowActivations.add(window)
webContents.once('did-finish-load', () => {
pendingWindowActivations.delete(window)
activateWindow(window)
})
}
return
}
showWindow(window)
} catch {
// The window can be destroyed between checks while a native callback runs.
}
}
export function registerDesktopNotificationActivation(
window: BrowserWindow,
platform: NodeJS.Platform = process.platform
): void {
if (platform !== 'win32') {
return
}
Notification.handleActivation(() => {
activateWindow(window)
})
}
export function showDesktopNotificationWhenUnfocused( export function showDesktopNotificationWhenUnfocused(
window: BrowserWindow, window: BrowserWindow,
@@ -15,6 +59,53 @@ export function showDesktopNotificationWhenUnfocused(
) { ) {
return false return false
} }
new Notification(options).show()
const notification = new Notification(options)
let retentionTimer: ReturnType<typeof setTimeout> | undefined
const release = (): void => {
if (retentionTimer) {
clearTimeout(retentionTimer)
retentionTimer = undefined
}
activeNotifications.delete(notification)
notification.removeListener('click', handleClick)
notification.removeListener('close', release)
notification.removeListener('failed', release)
}
const dismiss = (): void => {
try {
notification.close()
} catch {
// The native notification may already have been dismissed.
} finally {
release()
}
}
const handleClick = (): void => {
try {
activateWindow(window)
} finally {
release()
}
}
if (activeNotifications.size >= MAX_RETAINED_NOTIFICATIONS) {
activeNotifications.values().next().value?.()
}
activeNotifications.set(notification, dismiss)
retentionTimer = setTimeout(dismiss, NOTIFICATION_RETENTION_MS)
retentionTimer.unref?.()
notification.once('click', handleClick)
notification.once('close', release)
notification.once('failed', release)
try {
notification.show()
} catch (error) {
release()
throw error
}
return true return true
} }
+70 -4
View File
@@ -6,6 +6,7 @@ import {
Menu, Menu,
safeStorage, safeStorage,
session, session,
shell,
Tray, Tray,
utilityProcess utilityProcess
} from 'electron' } from 'electron'
@@ -88,6 +89,12 @@ import {
DshNpmExtensionInstaller, DshNpmExtensionInstaller,
DshNpmMarketplaceCatalog DshNpmMarketplaceCatalog
} from './agent/dsh-extension-marketplace' } from './agent/dsh-extension-marketplace'
import { registerDesktopNotificationActivation } from './desktop-notification'
import {
isInstalledWindowsBuild,
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId
} from './windows-notification-identity'
const shortcut = 'CommandOrControl+Shift+Space' const shortcut = 'CommandOrControl+Shift+Space'
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url)) const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
@@ -99,8 +106,18 @@ const portableUserDataPath = resolvePortableUserDataPath({
if (portableUserDataPath) { if (portableUserDataPath) {
app.setPath('userData', portableUserDataPath) app.setPath('userData', portableUserDataPath)
} }
const installedWindowsBuild = isInstalledWindowsBuild({
packaged: app.isPackaged,
platform: process.platform,
executablePath: process.execPath
})
if (process.platform === 'win32') { if (process.platform === 'win32') {
app.setAppUserModelId('live.digiman.goodbuddy') app.setAppUserModelId(
resolveWindowsAppUserModelId({
installed: installedWindowsBuild,
executablePath: process.execPath
})
)
} }
const hasSingleInstanceLock = app.requestSingleInstanceLock() const hasSingleInstanceLock = app.requestSingleInstanceLock()
@@ -122,6 +139,7 @@ let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined let documentOcrModelManager: DocumentOcrModelManager | undefined
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
function createEmbeddingProvider( function createEmbeddingProvider(
settings: ResolvedRuntimeSettings settings: ResolvedRuntimeSettings
@@ -367,6 +385,7 @@ if (hasSingleInstanceLock) {
) )
mainWindow = createMainWindow(() => isQuitting) mainWindow = createMainWindow(() => isQuitting)
registerDesktopNotificationActivation(mainWindow)
tray = buildTray() tray = buildTray()
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir() const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
const secureCipher = { const secureCipher = {
@@ -452,7 +471,7 @@ if (hasSingleInstanceLock) {
app.getPath('userData'), app.getPath('userData'),
'deepseek-harness' 'deepseek-harness'
) )
const dshExtensionInstaller = new DshNpmExtensionInstaller({ const startupDshExtensionInstaller = new DshNpmExtensionInstaller({
dshHome: deepSeekHarnessHome, dshHome: deepSeekHarnessHome,
npmCliPath: app.isPackaged npmCliPath: app.isPackaged
? join( ? join(
@@ -470,11 +489,13 @@ if (hasSingleInstanceLock) {
'npm-cli.js' 'npm-cli.js'
) )
}) })
dshExtensionInstaller = startupDshExtensionInstaller
const runtimeExtensionStore = new RuntimeExtensionStore( const runtimeExtensionStore = new RuntimeExtensionStore(
app.getPath('userData'), app.getPath('userData'),
{ {
catalog: new DshNpmMarketplaceCatalog(), catalog: new DshNpmMarketplaceCatalog(),
install: (input) => dshExtensionInstaller.install(input) install: (input) =>
startupDshExtensionInstaller.install(input)
} }
) )
const launchDeepSeekHarness = const launchDeepSeekHarness =
@@ -713,6 +734,48 @@ if (hasSingleInstanceLock) {
runtimeExtensionStore runtimeExtensionStore
) )
loadMainWindow(mainWindow) loadMainWindow(mainWindow)
setImmediate(() => {
void repairStaleWindowsNotificationShortcuts({
platform: process.platform,
installed: installedWindowsBuild,
executablePath: process.execPath,
programsDirectory: join(
app.getPath('appData'),
'Microsoft',
'Windows',
'Start Menu',
'Programs'
),
shortcutAccess: {
readShortcutLink: (shortcutPath) =>
shell.readShortcutLink(shortcutPath),
writeShortcutLink: (
shortcutPath,
operation,
options
) =>
shell.writeShortcutLink(
shortcutPath,
operation,
options
)
}
}).then(
({ failed }) => {
if (failed > 0) {
console.warn(
`Failed to repair ${failed} stale notification shortcut(s)`
)
}
},
(error: unknown) => {
console.warn(
'Failed to inspect stale notification shortcuts',
error
)
}
)
})
app.on('activate', () => { app.on('activate', () => {
if (mainWindow) { if (mainWindow) {
@@ -744,7 +807,10 @@ app.on('before-quit', (event) => {
void (async () => { void (async () => {
try { try {
const cleanup = settleCleanupPhases([ const cleanup = settleCleanupPhases([
[() => removeIpcHandlers?.()], [
() => dshExtensionInstaller?.dispose(),
() => removeIpcHandlers?.()
],
[() => stopRuntimeReconfiguration?.()], [() => stopRuntimeReconfiguration?.()],
[ [
() => runtime?.dispose(), () => runtime?.dispose(),
+46 -2
View File
@@ -2151,6 +2151,9 @@ describe('registerIpcHandlers Runtime customization', () => {
state: 'complete' as const state: 'complete' as const
} }
] ]
let persistedRuntimeSelection:
| { provider: 'opencode' }
| undefined = { provider: 'opencode' }
const assistantDatabase = { const assistantDatabase = {
claimDueSchedules: vi.fn(() => []), claimDueSchedules: vi.fn(() => []),
getProject: vi.fn(() => ({ getProject: vi.fn(() => ({
@@ -2160,7 +2163,7 @@ describe('registerIpcHandlers Runtime customization', () => {
getConversation: vi.fn(() => ({ getConversation: vi.fn(() => ({
id: conversationId, id: conversationId,
projectId, projectId,
runtimeSelection: { provider: 'opencode' as const }, runtimeSelection: persistedRuntimeSelection,
title: 'Runtime conversation', title: 'Runtime conversation',
updatedAt: Date.now(), updatedAt: Date.now(),
messages messages
@@ -2290,13 +2293,54 @@ describe('registerIpcHandlers Runtime customization', () => {
)?.(event, { )?.(event, {
...compactInput, ...compactInput,
requestId: '00000000-0000-4000-8000-000000000606', requestId: '00000000-0000-4000-8000-000000000606',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000607',
projectId: '00000000-0000-4000-8000-000000000608'
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
persistedRuntimeSelection = undefined
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000609'
})
).resolves.toMatchObject({
provider: 'opencode',
compacted: true
})
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000610',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000611',
history: [ history: [
compactInput.history[0], compactInput.history[0],
{ role: 'assistant', content: 'stale content' } { role: 'assistant', content: 'stale content' }
] ]
}) })
).rejects.toThrow('对话历史已更改') ).rejects.toThrow('对话历史已更改')
expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce() expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2)
await dispose() await dispose()
}) })
}) })
+6 -3
View File
@@ -127,6 +127,7 @@ import {
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
getDefaultRuntimeSelection,
type AgentRuntimeSelection type AgentRuntimeSelection
} from '../shared/runtime-selection-contracts' } from '../shared/runtime-selection-contracts'
import { import {
@@ -2968,10 +2969,13 @@ export function registerIpcHandlers(
const conversation = assistantDatabase.getConversation( const conversation = assistantDatabase.getConversation(
request.conversationId request.conversationId
) )
const settings = await settingsStore.getResolvedSettings()
const persistedRuntimeSelection =
conversation.runtimeSelection ??
getDefaultRuntimeSelection(settings)
if ( if (
conversation.projectId !== request.projectId || conversation.projectId !== request.projectId ||
!conversation.runtimeSelection || agentRuntimeSelectionKey(persistedRuntimeSelection) !==
agentRuntimeSelectionKey(conversation.runtimeSelection) !==
agentRuntimeSelectionKey(request.runtimeSelection) agentRuntimeSelectionKey(request.runtimeSelection)
) { ) {
throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试') throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试')
@@ -2998,7 +3002,6 @@ export function registerIpcHandlers(
contextCompressionState: contextCompressionState:
conversation.contextCompressionState conversation.contextCompressionState
} }
const settings = await settingsStore.getResolvedSettings()
const selected = applyRuntimeSelection( const selected = applyRuntimeSelection(
settings, settings,
request.runtimeSelection request.runtimeSelection
+45 -6
View File
@@ -17,12 +17,51 @@ describe('packaged release notes', () => {
expect.objectContaining({ version: '0.8.19' }) expect.objectContaining({ version: '0.8.19' })
) )
for (const release of parsed.releases) { for (const release of parsed.releases) {
expect(release.notes['zh-CN'].features).toHaveLength( for (const section of [
release.notes['en-US'].features.length 'highlights',
) 'features',
expect(release.notes['zh-CN'].fixes).toHaveLength( 'fixes',
release.notes['en-US'].fixes.length 'notices'
) ] as const) {
expect(release.notes['zh-CN'][section]).toHaveLength(
release.notes['en-US'][section].length
)
}
} }
const currentRelease = parsed.releases.find(
(release) => release.version === '0.10.0'
)
expect(currentRelease).toBeDefined()
expect(currentRelease?.releasedAt).toBe('2026-08-17')
expect(currentRelease?.notes['zh-CN'].highlights).toHaveLength(1)
expect(currentRelease?.notes['zh-CN'].features).toHaveLength(7)
expect(currentRelease?.notes['zh-CN'].fixes).toHaveLength(6)
expect(currentRelease?.notes['zh-CN'].notices).toHaveLength(4)
expect(
currentRelease?.notes['zh-CN'].features.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(
currentRelease?.notes['zh-CN'].fixes.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(
currentRelease?.notes['zh-CN'].notices.every((item) =>
item.startsWith('**')
)
).toBe(true)
expect(JSON.stringify(currentRelease)).not.toContain('官网')
expect(JSON.stringify(currentRelease)).not.toContain(
'Website and product preview'
)
const legacyRelease = parsed.releases.find(
(release) => release.version === '0.9.3'
)
expect(legacyRelease?.notes['zh-CN'].highlights).toEqual([])
expect(legacyRelease?.notes['zh-CN'].notices).toEqual([])
}) })
}) })
@@ -0,0 +1,194 @@
import {
mkdtemp,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ShortcutDetails } from 'electron'
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest'
import {
isInstalledWindowsBuild,
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId,
WINDOWS_APP_USER_MODEL_ID
} from './windows-notification-identity'
const temporaryDirectories: string[] = []
async function createTemporaryDirectory(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-notification-identity-')
)
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('Windows notification identity', () => {
it('reserves the production AUMID for an installed build', () => {
expect(
resolveWindowsAppUserModelId({
installed: true,
executablePath: 'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe'
})
).toBe(WINDOWS_APP_USER_MODEL_ID)
})
it('uses stable path-scoped identities for standalone builds', () => {
const first = resolveWindowsAppUserModelId({
installed: false,
executablePath: 'D:\\repo\\GoodBuddy.exe'
})
expect(first).not.toBe(WINDOWS_APP_USER_MODEL_ID)
expect(
resolveWindowsAppUserModelId({
installed: false,
executablePath: 'd:\\REPO\\goodbuddy.exe'
})
).toBe(first)
expect(
resolveWindowsAppUserModelId({
installed: false,
executablePath: 'D:\\other\\GoodBuddy.exe'
})
).not.toBe(first)
})
it('recognizes only packaged Windows layouts with an uninstaller', async () => {
const directory = await createTemporaryDirectory()
const executablePath = join(directory, 'GoodBuddy.exe')
await writeFile(join(directory, 'Uninstall GoodBuddy.exe'), '')
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'win32',
executablePath
})
).toBe(true)
expect(
isInstalledWindowsBuild({
packaged: false,
platform: 'win32',
executablePath
})
).toBe(false)
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'linux',
executablePath
})
).toBe(false)
expect(
isInstalledWindowsBuild({
packaged: true,
platform: 'win32',
executablePath: '\0'
})
).toBe(false)
})
it('moves stale shortcuts off the production identity without changing their targets', async () => {
const programsDirectory = await createTemporaryDirectory()
const stalePath = join(programsDirectory, 'Electron.lnk')
const currentPath = join(programsDirectory, 'GoodBuddy.lnk')
await Promise.all([
writeFile(stalePath, ''),
writeFile(currentPath, '')
])
const staleDetails: ShortcutDetails = {
target: 'D:\\repo\\node_modules\\electron\\electron.exe',
appUserModelId: WINDOWS_APP_USER_MODEL_ID,
toastActivatorClsid:
'{6D4C974B-001A-47E5-AE4B-F8F12FFDA281}'
}
const details = new Map<string, ShortcutDetails>([
[stalePath, staleDetails],
[
currentPath,
{
target:
'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe',
appUserModelId: WINDOWS_APP_USER_MODEL_ID
}
]
])
const writeShortcutLink = vi.fn(() => true)
await expect(
repairStaleWindowsNotificationShortcuts({
platform: 'win32',
installed: true,
executablePath:
'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe',
programsDirectory,
shortcutAccess: {
readShortcutLink: (shortcutPath) =>
details.get(shortcutPath)!,
writeShortcutLink
}
})
).resolves.toEqual({
scanned: 2,
repaired: 1,
failed: 0
})
expect(writeShortcutLink).toHaveBeenCalledOnce()
expect(writeShortcutLink).toHaveBeenCalledWith(
stalePath,
'update',
expect.objectContaining({
target: staleDetails.target,
toastActivatorClsid: staleDetails.toastActivatorClsid,
appUserModelId: expect.not.stringMatching(
/^live\.digiman\.goodbuddy$/u
)
})
)
})
it('does not touch shortcuts from development or portable builds', async () => {
const programsDirectory = await createTemporaryDirectory()
await writeFile(join(programsDirectory, 'Electron.lnk'), '')
const readShortcutLink = vi.fn()
const writeShortcutLink = vi.fn()
await expect(
repairStaleWindowsNotificationShortcuts({
platform: 'win32',
installed: false,
executablePath: 'D:\\repo\\GoodBuddy.exe',
programsDirectory,
shortcutAccess: {
readShortcutLink,
writeShortcutLink
}
})
).resolves.toEqual({
scanned: 0,
repaired: 0,
failed: 0
})
expect(readShortcutLink).not.toHaveBeenCalled()
expect(writeShortcutLink).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
import { createHash } from 'node:crypto'
import { statSync } from 'node:fs'
import { dirname, join, resolve, win32 } from 'node:path'
import type { ShortcutDetails } from 'electron'
export const WINDOWS_APP_USER_MODEL_ID = 'live.digiman.goodbuddy'
const windowsUninstallerName = 'Uninstall GoodBuddy.exe'
const standaloneIdentityPrefix = `${WINDOWS_APP_USER_MODEL_ID}.standalone`
const knownShortcutNames = ['Electron.lnk', 'GoodBuddy.lnk'] as const
interface ShortcutAccess {
readShortcutLink(shortcutPath: string): ShortcutDetails
writeShortcutLink(
shortcutPath: string,
operation: 'update',
options: ShortcutDetails
): boolean
}
export interface WindowsNotificationShortcutRepairResult {
scanned: number
repaired: number
failed: number
}
function normalizeWindowsExecutablePath(executablePath: string): string {
return win32.resolve(executablePath).toLocaleLowerCase('en-US')
}
function resolveStandaloneWindowsAppUserModelId(
executablePath: string
): string {
const executableHash = createHash('sha256')
.update(normalizeWindowsExecutablePath(executablePath))
.digest('hex')
.slice(0, 24)
return `${standaloneIdentityPrefix}.${executableHash}`
}
export function isInstalledWindowsBuild(input: {
packaged: boolean
platform: NodeJS.Platform
executablePath: string
}): boolean {
if (!input.packaged || input.platform !== 'win32') {
return false
}
try {
const uninstallerPath = join(
dirname(resolve(input.executablePath)),
windowsUninstallerName
)
return statSync(uninstallerPath, {
throwIfNoEntry: false
})?.isFile() === true
} catch {
return false
}
}
export function resolveWindowsAppUserModelId(input: {
installed: boolean
executablePath: string
}): string {
return input.installed
? WINDOWS_APP_USER_MODEL_ID
: resolveStandaloneWindowsAppUserModelId(input.executablePath)
}
export async function repairStaleWindowsNotificationShortcuts(input: {
platform: NodeJS.Platform
installed: boolean
executablePath: string
programsDirectory: string
shortcutAccess: ShortcutAccess
}): Promise<WindowsNotificationShortcutRepairResult> {
if (input.platform !== 'win32' || !input.installed) {
return { scanned: 0, repaired: 0, failed: 0 }
}
const shortcutPaths = knownShortcutNames.map((shortcutName) =>
join(input.programsDirectory, shortcutName)
)
const currentExecutablePath = normalizeWindowsExecutablePath(
input.executablePath
)
let scanned = 0
let repaired = 0
let failed = 0
for (const shortcutPath of shortcutPaths) {
let details: ShortcutDetails
try {
details = input.shortcutAccess.readShortcutLink(shortcutPath)
} catch {
continue
}
scanned += 1
if (
details.appUserModelId !== WINDOWS_APP_USER_MODEL_ID ||
normalizeWindowsExecutablePath(details.target) ===
currentExecutablePath
) {
continue
}
try {
const updated = input.shortcutAccess.writeShortcutLink(
shortcutPath,
'update',
{
...details,
appUserModelId:
resolveStandaloneWindowsAppUserModelId(details.target)
}
)
if (updated) {
repaired += 1
} else {
failed += 1
}
} catch {
failed += 1
}
}
return {
scanned,
repaired,
failed
}
}
+14 -6
View File
@@ -948,10 +948,14 @@ describe('App', () => {
expect(loading).toHaveAttribute('aria-busy', 'true') expect(loading).toHaveAttribute('aria-busy', 'true')
await act(async () => lazyRouteMocks.releaseKnowledgeRoute()) await act(async () => lazyRouteMocks.releaseKnowledgeRoute())
expect( expect(
await screen.findByRole('heading', { await screen.findByRole(
level: 1, 'heading',
name: '知识库' {
}) level: 1,
name: '知识库'
},
{ timeout: 3000 }
)
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.queryByRole('status', { name: '正在加载页面…' }) screen.queryByRole('status', { name: '正在加载页面…' })
@@ -1208,12 +1212,16 @@ describe('App', () => {
releasedAt: '2026-08-11', releasedAt: '2026-08-11',
notes: { notes: {
'zh-CN': { 'zh-CN': {
highlights: ['多 Runtime 工作流更加连贯'],
features: ['新增版本更新说明'], features: ['新增版本更新说明'],
fixes: ['修复重复显示'] fixes: ['修复重复显示'],
notices: ['Ask 模式保持只读']
}, },
'en-US': { 'en-US': {
highlights: ['Multi-Runtime workflows are more cohesive'],
features: ['Added release notes'], features: ['Added release notes'],
fixes: ['Fixed repeated display'] fixes: ['Fixed repeated display'],
notices: ['Ask remains read-only']
} }
} }
} }
+19 -1
View File
@@ -15,7 +15,10 @@ import {
vi vi
} from 'vitest' } from 'vitest'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
import { MarkdownRenderer } from './MarkdownRenderer' import {
InlineMarkdown,
MarkdownRenderer
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({ const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(), initialize: vi.fn(),
@@ -41,6 +44,21 @@ describe('MarkdownRenderer', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('renders bounded inline emphasis without links or raw HTML', () => {
const { container } = render(
<p>
<InlineMarkdown>
{'**明确标题。** 查看[外部页面](https://example.com)。<script>bad()</script>'}
</InlineMarkdown>
</p>
)
expect(screen.getByText('明确标题。').tagName).toBe('STRONG')
expect(container).toHaveTextContent('外部页面')
expect(container.querySelector('a')).not.toBeInTheDocument()
expect(container.querySelector('script')).not.toBeInTheDocument()
})
it('renders CommonMark and GitHub Flavored Markdown', () => { it('renders CommonMark and GitHub Flavored Markdown', () => {
render( render(
<MarkdownRenderer>{`# 标题 <MarkdownRenderer>{`# 标题
+19
View File
@@ -92,6 +92,25 @@ type MarkdownRendererProps = {
children: string children: string
} }
const inlineMarkdownComponents: Components = {
p: ({ children }) => <>{children}</>
}
export const InlineMarkdown = memo(function InlineMarkdown({
children
}: MarkdownRendererProps): React.JSX.Element {
return (
<ReactMarkdown
allowedElements={['p', 'strong']}
components={inlineMarkdownComponents}
skipHtml
unwrapDisallowed
>
{children}
</ReactMarkdown>
)
})
const wholeMarkdownFence = const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu /^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
+43 -8
View File
@@ -19,12 +19,18 @@ const snapshot: ReleaseNotesSnapshot = {
releasedAt: '2026-08-11', releasedAt: '2026-08-11',
notes: { notes: {
'zh-CN': { 'zh-CN': {
features: ['新增双语界面'], highlights: ['多 Runtime 工作流更加连贯。'],
fixes: ['修复开关尺寸'] features: ['**Runtime 能力概览。** 查看实际可用能力。'],
fixes: ['**设置界面一致性。** 修复开关尺寸。'],
notices: ['**工作模式权限。** Ask 模式保持只读。']
}, },
'en-US': { 'en-US': {
features: ['Added a bilingual interface'], highlights: ['Multi-Runtime workflows are more cohesive.'],
fixes: ['Fixed switch dimensions'] features: [
'**Runtime capability overview.** View actual capabilities.'
],
fixes: ['**Settings consistency.** Fixed switch dimensions.'],
notices: ['**Work mode permissions.** Ask remains read-only.']
} }
} }
} }
@@ -71,8 +77,26 @@ describe('ReleaseNotesDialog', () => {
name: 'GoodBuddy 0.8.18 更新内容' name: 'GoodBuddy 0.8.18 更新内容'
}) })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText('新增双语界面')).toBeInTheDocument() expect(
expect(screen.getByText('修复开关尺寸')).toBeInTheDocument() screen.getByRole('heading', { name: '本次亮点' })
).toBeInTheDocument()
expect(
screen.getByText('多 Runtime 工作流更加连贯。')
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '功能更新' })
).toBeInTheDocument()
expect(screen.getByText('Runtime 能力概览。').tagName).toBe(
'STRONG'
)
expect(screen.getByText('查看实际可用能力。')).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '问题修复' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '使用前请留意' })
).toBeInTheDocument()
expect(screen.getByText('工作模式权限。').tagName).toBe('STRONG')
expect(screen.queryByRole('link')).not.toBeInTheDocument() expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect( expect(
container.querySelector<HTMLElement>('.app-shell')?.inert container.querySelector<HTMLElement>('.app-shell')?.inert
@@ -115,9 +139,20 @@ describe('ReleaseNotesDialog', () => {
}) })
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.getByText('Added a bilingual interface') screen.getByRole('heading', { name: 'Highlights' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText('Fixed switch dimensions')).toBeInTheDocument() expect(
screen.getByText('Multi-Runtime workflows are more cohesive.')
).toBeInTheDocument()
expect(
screen.getByText('Runtime capability overview.').tagName
).toBe('STRONG')
expect(
screen.getByRole('heading', { name: 'Before You Start' })
).toBeInTheDocument()
expect(screen.getByText('Work mode permissions.').tagName).toBe(
'STRONG'
)
expect( expect(
screen.getByRole('button', { name: 'Get Started' }) screen.getByRole('button', { name: 'Get Started' })
).toBeInTheDocument() ).toBeInTheDocument()
+75 -28
View File
@@ -1,4 +1,10 @@
import { Sparkles, Wrench, X } from 'lucide-react' import {
Lightbulb,
Sparkles,
TriangleAlert,
Wrench,
X
} from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -8,6 +14,7 @@ import type {
} from '../../shared/release-notes-contracts' } from '../../shared/release-notes-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus' import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { UiLocale } from './i18n' import type { UiLocale } from './i18n'
import { InlineMarkdown } from './MarkdownRenderer'
type ReleaseNotesDialogProps = { type ReleaseNotesDialogProps = {
locale: UiLocale locale: UiLocale
@@ -16,6 +23,47 @@ type ReleaseNotesDialogProps = {
onClose: () => void onClose: () => void
} }
function ReleaseNotesSection({
heading,
headingLevel,
icon,
items,
variant
}: {
heading: string
headingLevel: 'h3' | 'h4'
icon: React.ReactNode
items: string[]
variant?: 'notices'
}): React.JSX.Element | null {
if (items.length === 0) {
return null
}
const SectionHeading = headingLevel
return (
<div
className={[
'release-notes-dialog__section',
variant && `release-notes-dialog__section--${variant}`
]
.filter(Boolean)
.join(' ')}
>
<SectionHeading>
{icon}
{heading}
</SectionHeading>
<ul>
{items.map((item) => (
<li key={item}>
<InlineMarkdown>{item}</InlineMarkdown>
</li>
))}
</ul>
</div>
)
}
function ReleaseSection({ function ReleaseSection({
locale, locale,
release, release,
@@ -28,7 +76,7 @@ function ReleaseSection({
const { t } = useTranslation('app') const { t } = useTranslation('app')
const notes = release.notes[locale] const notes = release.notes[locale]
const releaseHeadingId = useId() const releaseHeadingId = useId()
const SectionHeading = showVersion ? 'h4' : 'h3' const headingLevel = showVersion ? 'h4' : 'h3'
return ( return (
<section <section
aria-labelledby={showVersion ? releaseHeadingId : undefined} aria-labelledby={showVersion ? releaseHeadingId : undefined}
@@ -42,32 +90,31 @@ function ReleaseSection({
GoodBuddy {release.version} GoodBuddy {release.version}
</h3> </h3>
)} )}
{notes.features.length > 0 && ( <ReleaseNotesSection
<div className="release-notes-dialog__section"> heading={t('releaseNotes.highlights')}
<SectionHeading> headingLevel={headingLevel}
<Sparkles aria-hidden="true" size={16} /> icon={<Lightbulb aria-hidden="true" size={16} />}
{t('releaseNotes.features')} items={notes.highlights}
</SectionHeading> />
<ul> <ReleaseNotesSection
{notes.features.map((feature) => ( heading={t('releaseNotes.features')}
<li key={feature}>{feature}</li> headingLevel={headingLevel}
))} icon={<Sparkles aria-hidden="true" size={16} />}
</ul> items={notes.features}
</div> />
)} <ReleaseNotesSection
{notes.fixes.length > 0 && ( heading={t('releaseNotes.fixes')}
<div className="release-notes-dialog__section"> headingLevel={headingLevel}
<SectionHeading> icon={<Wrench aria-hidden="true" size={16} />}
<Wrench aria-hidden="true" size={16} /> items={notes.fixes}
{t('releaseNotes.fixes')} />
</SectionHeading> <ReleaseNotesSection
<ul> heading={t('releaseNotes.notices')}
{notes.fixes.map((fix) => ( headingLevel={headingLevel}
<li key={fix}>{fix}</li> icon={<TriangleAlert aria-hidden="true" size={16} />}
))} items={notes.notices}
</ul> variant="notices"
</div> />
)}
</section> </section>
) )
} }
@@ -659,7 +659,7 @@ export const RuntimeCustomizationSection = forwardRef<
</p> </p>
) : null} ) : null}
{snapshot ? ( {snapshot && snapshot.inventoryStatus !== 'available' ? (
<NativeInventoryStatus snapshot={snapshot} /> <NativeInventoryStatus snapshot={snapshot} />
) : null} ) : null}
+31 -7
View File
@@ -2072,14 +2072,9 @@ describe('SettingsPanel runtime files', () => {
const agent = await screen.findByLabelText('默认 Agent') const agent = await screen.findByLabelText('默认 Agent')
expect(agent).toHaveValue('planner') expect(agent).toHaveValue('planner')
const nativeStatus = screen.getByRole('status')
expect(nativeStatus).toHaveTextContent('OpenCode 原生能力已就绪')
expect( expect(
Boolean( screen.queryByText('OpenCode 原生能力已就绪')
nativeStatus.compareDocumentPosition(agent) & ).not.toBeInTheDocument()
Node.DOCUMENT_POSITION_FOLLOWING
)
).toBe(true)
expect(screen.getByText('能力与默认配置')).toBeInTheDocument() expect(screen.getByText('能力与默认配置')).toBeInTheDocument()
expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument() expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument()
expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument() expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument()
@@ -2162,6 +2157,35 @@ describe('SettingsPanel runtime files', () => {
) )
}) })
it.each([
['opencode', 'OpenCode 原生能力已就绪'],
[
'continue',
'内置 Continue CLI 已就绪;Rules 与 Prompts 来自原始静态配置;MCP Prompt 仅在 MCPService 运行并连接后可发现,非运行快照不会启动服务器。Continue MCPService 不提供 Resources。'
],
[
'deepseek-harness',
'显示 DeepSeek Harness Host 与插件原生能力;GoodBuddy 分配的 Skill 和 MCP 不在此清单中。'
]
] as const)(
'hides the redundant ready detail for %s',
async (provider, detail) => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider
})
getRuntimeNativeSnapshot.mockResolvedValueOnce({
...fallbackSnapshot,
detail
})
render(<RuntimeCustomizationSection provider={provider} />)
await screen.findByRole('tablist', { name: '能力清单' })
expect(screen.queryByText(detail)).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('distinguishes external OpenCode connectivity from readable native inventory', async () => { it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({ const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider: 'opencode' provider: 'opencode'
@@ -188,6 +188,12 @@ describe('WorkspacePrimitives', () => {
) )
}) })
it('keeps DSH marketplace search text clear of its icon', () => {
expect(stylesheet).toMatch(
/\.field \.runtime-extension-marketplace__search-input > input\s*\{[^}]*padding-left:\s*34px;/u
)
})
it('separates model service fields from credential status', () => { it('separates model service fields from credential status', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.model-service-form\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);/u /\.model-service-form\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);/u
+3 -2
View File
@@ -15,10 +15,11 @@ export const app = {
releaseNotes: { releaseNotes: {
eyebrow: 'VERSION UPDATE', eyebrow: 'VERSION UPDATE',
title: "What's New in GoodBuddy {{version}}", title: "What's New in GoodBuddy {{version}}",
description: description: 'Review the key changes and usage notes in this release.',
'This release includes the following features and bug fixes.', highlights: 'Highlights',
features: 'Features', features: 'Features',
fixes: 'Bug Fixes', fixes: 'Bug Fixes',
notices: 'Before You Start',
close: 'Close release notes', close: 'Close release notes',
start: 'Get Started', start: 'Get Started',
closing: 'Closing…', closing: 'Closing…',
+3 -1
View File
@@ -12,9 +12,11 @@ export const app = {
releaseNotes: { releaseNotes: {
eyebrow: '版本更新', eyebrow: '版本更新',
title: 'GoodBuddy {{version}} 更新内容', title: 'GoodBuddy {{version}} 更新内容',
description: '本次版本带来了以下功能更新与问题修复。', description: '查看本次版本的主要更新与使用提示。',
highlights: '本次亮点',
features: '功能更新', features: '功能更新',
fixes: '问题修复', fixes: '问题修复',
notices: '使用前请留意',
close: '关闭版本更新说明', close: '关闭版本更新说明',
start: '开始使用', start: '开始使用',
closing: '正在关闭…', closing: '正在关闭…',
+4 -41
View File
@@ -1,41 +1,4 @@
import type { RuntimeSettings } from '../../shared/contracts' export {
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
export function getRuntimeSelectionForProvider( } from '../../shared/runtime-selection-contracts'
provider: 'model' | 'opencode' | 'continue' | 'deepseek-harness',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
const provider = settings.provider
if (
provider === 'model' ||
provider === 'opencode' ||
provider === 'continue' ||
provider === 'deepseek-harness'
) {
return getRuntimeSelectionForProvider(provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
+11 -6
View File
@@ -4373,6 +4373,16 @@ button > svg {
color: var(--accent); color: var(--accent);
} }
.release-notes-dialog__section li strong {
color: var(--text-primary);
font-weight: 600;
}
.release-notes-dialog__section--notices :is(h3, h4) svg,
.release-notes-dialog__section--notices li::marker {
color: var(--warning);
}
.release-notes-dialog__footer { .release-notes-dialog__footer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5496,11 +5506,6 @@ button > svg {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.runtime-native-inventory__status--available {
border-color: color-mix(in srgb, var(--success) 35%, transparent);
background: var(--success-subtle);
}
.runtime-native-inventory__status--unavailable { .runtime-native-inventory__status--unavailable {
border-color: var(--danger-border); border-color: var(--danger-border);
background: var(--danger-subtle); background: var(--danger-subtle);
@@ -5678,7 +5683,7 @@ button > svg {
transform: translateY(-50%); transform: translateY(-50%);
} }
.runtime-extension-marketplace__search-input > input { .field .runtime-extension-marketplace__search-input > input {
padding-left: 34px; padding-left: 34px;
} }
+51 -21
View File
@@ -7,16 +7,40 @@ export const releaseVersionSchema = z
'Release version must be a stable semantic version' 'Release version must be a stable semantic version'
) )
const localizedReleaseNotesSchema = z const releaseNoteItemSchema = z.string().trim().min(1).max(500)
.object({
features: z.array(z.string().trim().min(1).max(240)).max(20), const localizedReleaseNotesSchema = z.preprocess(
fixes: z.array(z.string().trim().min(1).max(240)).max(20) (value) => {
}) if (value === null || typeof value !== 'object' || Array.isArray(value)) {
.strict() return value
.refine( }
(notes) => notes.features.length > 0 || notes.fixes.length > 0, const notes = value as Record<string, unknown>
'Release notes must contain at least one item' if ('highlights' in notes || 'notices' in notes) {
) return value
}
return {
highlights: [],
...notes,
notices: []
}
},
z
.object({
highlights: z.array(releaseNoteItemSchema).max(3),
features: z.array(releaseNoteItemSchema).max(20),
fixes: z.array(releaseNoteItemSchema).max(20),
notices: z.array(releaseNoteItemSchema).max(20)
})
.strict()
.refine(
(notes) =>
notes.highlights.length > 0 ||
notes.features.length > 0 ||
notes.fixes.length > 0 ||
notes.notices.length > 0,
'Release notes must contain at least one item'
)
)
export const releaseNoteSchema = z export const releaseNoteSchema = z
.object({ .object({
@@ -48,17 +72,23 @@ export const releaseNotesFileSchema = z
}) })
} }
versions.add(release.version) versions.add(release.version)
if ( for (const section of [
release.notes['zh-CN'].features.length !== 'highlights',
release.notes['en-US'].features.length || 'features',
release.notes['zh-CN'].fixes.length !== 'fixes',
release.notes['en-US'].fixes.length 'notices'
) { ] as const) {
context.addIssue({ if (
code: 'custom', release.notes['zh-CN'][section].length !==
message: 'Localized release-note sections must have matching counts', release.notes['en-US'][section].length
path: ['releases', index, 'notes'] ) {
}) context.addIssue({
code: 'custom',
message:
'Localized release-note sections must have matching counts',
path: ['releases', index, 'notes', section]
})
}
} }
} }
}) })
+61
View File
@@ -58,8 +58,69 @@ export type RuntimeSelectionRepairSettings = {
| { kind: 'profile'; profileId: string } | { kind: 'profile'; profileId: string }
} }
type RuntimeModelSource =
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
export type RuntimeSelectionDefaultSettings = {
provider: AgentRuntimeSelection['provider']
defaultModelProfileId: string
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeModelSource?: RuntimeModelSource
continueModelSource?: RuntimeModelSource
deepseekHarnessModelSource?: RuntimeModelSource
opencodeModelProfile?: { id: string }
continueModelProfile?: { id: string }
deepseekHarnessModelProfile?: { id: string }
}
type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number] type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number]
export function getRuntimeSelectionForProvider(
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource
const resolvedProfile =
provider === 'opencode'
? settings.opencodeModelProfile
: provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
return {
provider,
...(source?.kind === 'profile'
? { profileId: source.profileId }
: !source && resolvedProfile
? { profileId: resolvedProfile.id }
: {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
const provider = settings.provider
if (provider !== 'auto') {
return getRuntimeSelectionForProvider(provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
export function isChannelModelProfileUsable( export function isChannelModelProfileUsable(
profile: ChannelModelProfile profile: ChannelModelProfile
): boolean { ): boolean {
+259 -2
View File
@@ -58,6 +58,28 @@ interface ReleaseBuilderModule {
integrity: string integrity: string
filename: string filename: string
} }
lockedTargetRuntimePackage: (
packageName: string,
packageJson?: {
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
},
packageLock?: {
packages?: Record<
string,
{
version?: string
resolved?: string
integrity?: string
optionalDependencies?: Record<string, string>
}
>
}
) => {
name: string
version: string
integrity: string
}
replaceOutput: ( replaceOutput: (
stagingDirectory: string, stagingDirectory: string,
destination: string, destination: string,
@@ -66,6 +88,39 @@ interface ReleaseBuilderModule {
targetRuntimePackageNames: ( targetRuntimePackageNames: (
options: ReleaseOptions options: ReleaseOptions
) => string[] ) => string[]
stageTargetRuntimeDependencies: (
options: ReleaseOptions,
dependencies?: {
root: string
packageJson: {
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
packageLock: {
packages?: Record<
string,
{
version?: string
resolved?: string
integrity?: string
optionalDependencies?: Record<string, string>
}
>
}
npmInvocation: () => {
command: string
prefixArgs: string[]
}
runCapture: (
command: string,
arguments_: string[]
) => Promise<string>
extractArchive: (
archivePath: string,
destination: string
) => Promise<void>
}
) => Promise<() => void>
verifyHarnessPackage: ( verifyHarnessPackage: (
resources: string, resources: string,
options: ReleaseOptions, options: ReleaseOptions,
@@ -354,14 +409,216 @@ describe('release build arguments', () => {
...windowsOptions, ...windowsOptions,
arch: 'arm64' arch: 'arm64'
}) })
).toEqual(['@koromix/koffi-win32-arm64']) ).toEqual([
'@koromix/koffi-win32-arm64',
'@napi-rs/canvas-win32-arm64-msvc'
])
expect( expect(
releaseBuilder.targetRuntimePackageNames({ releaseBuilder.targetRuntimePackageNames({
...windowsOptions, ...windowsOptions,
platform: 'linux', platform: 'linux',
formats: ['AppImage', 'deb'] formats: ['AppImage', 'deb']
}) })
).toEqual(['@koromix/koffi-linux-x64']) ).toEqual([
'@koromix/koffi-linux-x64',
'@napi-rs/canvas-linux-x64-gnu'
])
expect(
releaseBuilder.targetRuntimePackageNames({
...windowsOptions,
platform: 'macos',
arch: 'arm64',
formats: ['dmg', 'zip']
})
).toEqual([
'@koromix/koffi-darwin-arm64',
'@napi-rs/canvas-darwin-arm64'
])
})
it('stages a missing target Canvas package from locked metadata', async () => {
const runtimeRoot = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-canvas-stage-')
)
const canvasPackage =
'@napi-rs/canvas-win32-arm64-msvc'
const koffiPackage = '@koromix/koffi-win32-arm64'
const archiveContents = Buffer.from('locked Canvas archive')
const integrity = `sha512-${createHash('sha512')
.update(archiveContents)
.digest('base64')}`
const packageJson = {
dependencies: {
'@napi-rs/canvas': '1.0.3'
},
optionalDependencies: {
[koffiPackage]: '3.1.4'
}
}
const packageLock = {
packages: {
'node_modules/@napi-rs/canvas': {
version: '1.0.3',
optionalDependencies: {
[canvasPackage]: '1.0.3'
}
},
[`node_modules/${canvasPackage}`]: {
version: '1.0.3',
resolved: 'https://registry.npmjs.org/locked-canvas.tgz',
integrity
},
[`node_modules/${koffiPackage}`]: {
version: '3.1.4',
resolved: 'https://registry.npmjs.org/locked-koffi.tgz',
integrity: 'sha512-locked'
}
}
}
const koffiDirectory = join(
runtimeRoot,
'node_modules',
...koffiPackage.split('/')
)
const canvasDirectory = join(
runtimeRoot,
'node_modules',
...canvasPackage.split('/')
)
const invocations: string[][] = []
let cleanup: () => void = () => undefined
try {
mkdirSync(koffiDirectory, { recursive: true })
writeFileSync(
join(koffiDirectory, 'package.json'),
JSON.stringify({
name: koffiPackage,
version: '3.1.4'
})
)
cleanup =
await releaseBuilder.stageTargetRuntimeDependencies(
{
...windowsOptions,
arch: 'arm64'
},
{
root: runtimeRoot,
packageJson,
packageLock,
npmInvocation: () => ({
command: 'npm-test',
prefixArgs: []
}),
runCapture: async (_command, arguments_) => {
invocations.push(arguments_)
const destination = arguments_[
arguments_.indexOf('--pack-destination') + 1
]
if (!destination) {
throw new Error('pack destination is missing')
}
writeFileSync(
join(destination, 'locked-canvas.tgz'),
archiveContents
)
return JSON.stringify([
{
name: canvasPackage,
version: '1.0.3',
integrity,
filename: 'locked-canvas.tgz'
}
])
},
extractArchive: async (_archive, destination) => {
writeFileSync(
join(destination, 'package.json'),
JSON.stringify({
name: canvasPackage,
version: '1.0.3'
})
)
}
}
)
expect(invocations).toHaveLength(1)
expect(invocations[0]).toEqual(
expect.arrayContaining([
'pack',
`${canvasPackage}@1.0.3`,
'--ignore-scripts',
'--json'
])
)
expect(invocations[0]).not.toContain(
`${canvasPackage}@latest`
)
expect(
JSON.parse(
readFileSync(
join(canvasDirectory, 'package.json'),
'utf8'
)
)
).toMatchObject({
name: canvasPackage,
version: '1.0.3'
})
cleanup()
cleanup = () => undefined
expect(existsSync(canvasDirectory)).toBe(false)
expect(existsSync(koffiDirectory)).toBe(true)
} finally {
cleanup()
rmSync(runtimeRoot, { recursive: true, force: true })
}
})
it.each([
[
'the target package version differs',
{
version: '1.0.4',
resolved: 'https://registry.npmjs.org/canvas.tgz',
integrity: 'sha512-locked'
}
],
[
'target package integrity is missing',
{
version: '1.0.3',
resolved: 'https://registry.npmjs.org/canvas.tgz'
}
]
])('fails closed when %s in the lockfile', (_case, targetLock) => {
const canvasPackage =
'@napi-rs/canvas-win32-arm64-msvc'
expect(() =>
releaseBuilder.lockedTargetRuntimePackage(
canvasPackage,
{
dependencies: {
'@napi-rs/canvas': '1.0.3'
}
},
{
packages: {
'node_modules/@napi-rs/canvas': {
version: '1.0.3',
optionalDependencies: {
[canvasPackage]: '1.0.3'
}
},
[`node_modules/${canvasPackage}`]: targetLock
}
}
)
).toThrow('目标 Runtime 依赖未完整锁定')
}) })
it('validates packed target dependency metadata and archive integrity', () => { it('validates packed target dependency metadata and archive integrity', () => {