From b995b12261d6825253eef2b18bea87c9ead9c912 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:14:51 +0800 Subject: [PATCH 01/20] test(fixtures): add private GitHub repository Plugin --- .../.dsh-plugin/package.json | 13 +++++++++++++ .../skills/github-source-proof/SKILL.md | 6 ++++++ 2 files changed, 19 insertions(+) create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json new file mode 100644 index 0000000000..e8723f671a --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json @@ -0,0 +1,13 @@ +{ + "name": "dsh-github-repository-plugin-e2e-fixture", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "dsh": { + "skills": [ + "../skills" + ] + } +} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md b/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md new file mode 100644 index 0000000000..eae668a4f9 --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md @@ -0,0 +1,6 @@ +--- +name: github-source-proof +description: Proves that dsh installed a private repository Plugin from an exact GitHub source. +--- + +This skill exists only in the GitHub repository source fixture. From da2179dd2b034b28fb0fbed52b95628dff51641a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:24:45 +0800 Subject: [PATCH 02/20] test(fixtures): prepare repository Plugin at prepack --- .../fixtures/github-repository-plugin/.dsh-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json index e8723f671a..9ce1527d03 100644 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "prepare": "dsh-plugin-prepare" + "prepack": "dsh-plugin-prepare" }, "dsh": { "skills": [ From b91b1fdefe2baa6f76530f7b90f34307efc53e1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:46:56 +0800 Subject: [PATCH 03/20] fix(repository-plugin): make GitHub source preparation self-contained --- ...-static-repository-plugin-format.i18n.yaml | 4 +- ...6-07-30-static-repository-plugin-format.md | 4 +- ...7-30-static-repository-plugin-format.zh.md | 4 +- ...it-repository-plugin-preparation.i18n.yaml | 6 ++ ...owned-git-repository-plugin-preparation.md | 42 +++++++++ ...ed-git-repository-plugin-preparation.zh.md | 42 +++++++++ .github/workflows/ci.yml | 2 + .../github-repository-plugin.built.e2e.ts | 89 +++++++++++++++++++ .../app-boot/tests/repository-cache.spec.ts | 46 ++++++---- .../repository-plugin/README.i18n.yaml | 4 +- .../repository-plugin/README.md | 9 +- .../repository-plugin/README.zh.md | 9 +- .../repository-plugin/src/format.ts | 7 +- .../repository-plugin/src/index.ts | 33 ++++--- .../repository-plugin/src/source.ts | 79 +++++++++++++++- .../tests/repository-plugin.spec.ts | 64 ++++++++++++- scripts/run-gates.spec.ts | 22 ++++- scripts/run-gates.ts | 15 ++++ vendor/README.md | 2 +- vendor/loader/src/repository.ts | 35 ++++++-- 20 files changed, 454 insertions(+), 64 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md create mode 100644 apps/cli/tests/github-repository-plugin.built.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 6319b99fb2..40a0461378 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: c9d755b925a6ea05eed71e75803397d2672df9f4 -2026-07-30-static-repository-plugin-format.zh.md: 361de64d2e98b9fb4ac42963e4ae48e77fbc7016 +2026-07-30-static-repository-plugin-format.md: 4823495dabee2101713ac72c8d0cee5bcc6b38d1 +2026-07-30-static-repository-plugin-format.zh.md: e5817ca982a71a609c86c5835b803b12f1317e1a diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index c9d755b925..4823495dab 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native- `@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. -The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. +The `.dsh-plugin` package declares exact `scripts.prepack: "dsh-plugin-prepare"` metadata without depending on a DSH npm package. During Git installation, the standalone runtime temporarily supplies that command from its own build on the isolated lifecycle `PATH`; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's exact lifecycle metadata before importing that wrapper. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. The host-owned command rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. @@ -46,4 +46,4 @@ Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `C ## Testing -Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The keyless headless example loads a checked-in prepared wrapper through its real `cordis.yml` and snapshots the repository skill's logged model catalog row. +Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The CI built-entry acceptance invokes `dsh run` with a GitHub source pinned to the pull request head, lets bundled pnpm fetch and prepare a private dependency-free fixture, then observes the copied skill in the real model request and the prepared wrapper in the immutable cache. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index 361de64d2e..e5817ca982 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -14,7 +14,7 @@ `@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式,且只允许两类贡献:skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。 -`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills`/`tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 +`.dsh-plugin` 包声明精确的 `scripts.prepack: "dsh-plugin-prepare"` 元数据,且不依赖 DSH NPM 包。在 Git 安装期间,独立运行时会从自身构建产物中临时提供该命令,并将其放入隔离的生命周期 `PATH`;`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的精确生命周期元数据。`.mjs` 扩展名避免强迫仓库作者在包元数据中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills`/`tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。宿主自有命令的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 @@ -46,4 +46,4 @@ ## 测试 -聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。Keyless headless 示例通过真实 `cordis.yml` 加载一份签入的已准备包装模块,并快照 repository skill 写入日志的模型目录行。 +聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。CI 的构建入口验收会用锁定到 PR(Pull Request)head 的 GitHub 源调用 `dsh run`,让随附 pnpm 获取并准备一个私有且不含依赖的 fixture(测试前置数据),然后在真实模型请求中观察已复制的 skill,并在不可变缓存中观察已准备的包装模块。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml new file mode 100644 index 0000000000..55d9045506 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md +2026-08-08-host-owned-git-repository-plugin-preparation.md: 45e84f9c8a89bb9d1eb7e4521634d16789dea2f0 +2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: ba88d5bb351427118d55c357209b496b39c2eb98 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md new file mode 100644 index 0000000000..45e84f9c8a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md @@ -0,0 +1,42 @@ +# Agent Note: Host-owned preparation makes GitHub repository Plugins installable + +Status: implemented + +English | [中文](2026-08-08-host-owned-git-repository-plugin-preparation.zh.md) + +## Problem + +The repository Plugin authoring contract depended on `scripts.prepare: "dsh-plugin-prepare"` and told source repositories to add `@deepseek-ai/dsh-repository-plugin` as a development dependency. That package is private and not published to npm, so an otherwise valid external GitHub repository could not obtain the helper in a clean install. + +The lifecycle choice also failed for a selectable `.dsh-plugin` inside a pnpm workspace. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` joins the containing workspace and need not execute the unlisted `.dsh-plugin` package's `prepare` script. The install could therefore succeed and publish a cache generation containing only the source package metadata; real DSH startup failed later because `dsh-plugin.mjs` did not exist. + +The checked-in headless fixture did not catch either defect because it mounted an already prepared wrapper. It proved runtime composition, not GitHub acquisition or package preparation. + +## Decision + +The fixed authoring format now requires exact `scripts.prepack: "dsh-plugin-prepare"` metadata and no DSH dependency. pnpm's Git-hosted package preparation invokes `prepack` explicitly after its dependency-install step and before packlist selects the `.dsh-plugin` subtree, so the helper can still copy sibling repository assets such as `../skills` into the package. + +`@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. The command directory exists only for the installation transaction and is removed on success or failure. The repository remains trusted package-manager input: DSH supplies one command, but other lifecycle scripts and dependencies still execute under the existing trust contract. + +The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true`, dependency-free GitHub fixture. It requires the run to reach the mock LLM, finds the repository skill description in the actual model request, and verifies the generated wrapper and copied skill under the immutable DSH cache. The test fails if CI omits the exact source instead of silently skipping. + +## Alternatives considered + +**Publish the prepare helper to npm.** Rejected because the source package would acquire a release/version dependency solely to call code already owned by the running DSH installation, and the existing helper is intentionally private. + +**Keep `prepare` and only inject the command.** Rejected because command availability does not make a nested package's `prepare` lifecycle run when the Git repository's package manager treats it as part of another workspace. + +**Prepare after RepositoryCache installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist. + +**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. + +## Consequences + +- A repository author can commit the fixed `.dsh-plugin/package.json` and source assets to GitHub without publishing either the Plugin or its preparation helper to npm. +- `prepack`, not `prepare`, is part of the pre-release authoring format. Invalid lifecycle metadata fails during source preparation or installed-package validation instead of producing an ambiguous partial format. +- Exact source strings still identify immutable cache generations; a changed ref or source configuration selects another generation. +- This repair does not expand the contribution surface: prepared repository Plugins still contribute only declared skills and common MCP definitions, while arbitrary package lifecycle code remains trusted installation code rather than a model-facing Cordis Plugin API. + +## Testing + +`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a local Git subpath through bundled pnpm with an injected command directory and proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins the exact `prepack` metadata and temporary command cleanup. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact live GitHub source, actual built `dsh run`, real headless composition, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md new file mode 100644 index 0000000000..ba88d5bb35 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 宿主自有的准备机制使 GitHub repository 插件可安装 + +状态:已实现 + +[English](2026-08-08-host-owned-git-repository-plugin-preparation.md) | 中文 + +## 问题 + +repository 插件的创作契约依赖 `scripts.prepare: "dsh-plugin-prepare"`,并要求源码仓库将 `@deepseek-ai/dsh-repository-plugin` 添加为开发依赖。该包是私有包,且未发布到 NPM,因此即使外部 GitHub 仓库符合其他要求,也无法在全新安装中取得该辅助程序。 + +这种生命周期选择也无法支持 pnpm 工作区内可选的 `.dsh-plugin`。pnpm 会先运行 Git 托管仓库首选的包管理器,再打包选定的子目录,从而准备 Git 托管包。嵌套执行的 `pnpm install` 会加入外层工作区,而不一定执行未列入其中的 `.dsh-plugin` 包的 `prepare` 脚本。因此,安装可能成功并发布一个仅包含源包元数据的缓存 generation;随后真实 DSH 启动因 `dsh-plugin.mjs` 不存在而失败。 + +签入仓库的 headless fixture(测试前置数据)没有捕获任一缺陷,因为它挂载的是已准备好的包装层。它证明的是运行时组合,而不是 GitHub 获取或包准备。 + +## 决策 + +修复后的创作格式要求元数据中精确包含 `scripts.prepack: "dsh-plugin-prepare"`,且不包含 DSH 依赖。pnpm 针对 Git 托管包的准备流程会在依赖安装步骤之后、打包清单选择 `.dsh-plugin` 子树之前显式调用 `prepack`,因此辅助程序仍可将 `../skills` 等同仓库的相邻资源复制进包内。 + +`@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。该命令目录仅存在于安装事务期间,无论成功还是失败都会被移除。仓库仍是受信任的包管理器输入:DSH 仅提供这一条命令,其他生命周期脚本和依赖仍会按既有信任契约执行。 + +Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA 派生的精确源。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true`、不含依赖的 GitHub fixture。验收要求该次运行到达 mock LLM(大语言模型),在实际模型请求中找到 repository skill 描述,并验证不可变 DSH 缓存中的生成包装层和已复制 skill。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。 + +## 考虑过的替代方案 + +**把准备辅助程序发布到 NPM。** 拒绝,因为源包会仅为了调用当前运行的 DSH 安装本就拥有的代码,而增加一个需要发布和管理版本的依赖;现有辅助程序又有意保持私有。 + +**保留 `prepare`,只注入命令。** 拒绝,因为当 Git 仓库的包管理器把嵌套包当作另一工作区的一部分时,即使命令可用,也不会使嵌套包的 `prepare` 生命周期得以运行。 + +**在 RepositoryCache 安装选定包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成打包清单前完成。 + +**在 DSH 中克隆 GitHub 仓库,并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定版本的包管理器负责的 ref 解析、子目录选择、依赖安装、打包清单行为和缓存完整性。 + +## 后果 + +- 仓库作者可以把修复后的 `.dsh-plugin/package.json` 和源资源提交到 GitHub,而无需把插件或其准备辅助程序发布到 NPM。 +- 预发布创作格式使用 `prepack` 而不是 `prepare`。无效的生命周期元数据会在源码准备或已安装包校验阶段导致失败,而不会留下状态不明的半成品格式。 +- 精确源字符串仍标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 +- 本次修复不扩大贡献范围:已准备的 repository 插件仍只贡献已声明的 skills 和通用 MCP 定义,而任意包生命周期代码仍是受信任的安装代码,不是面向模型的 Cordis 插件 API。 + +## 测试 + +`packages/ui/app-boot/tests/repository-cache.spec.ts` 会用注入的命令目录通过随附 pnpm 运行本地 Git 子路径,并证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定精确的 `prepack` 元数据和临时命令清理行为。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确的真实 GitHub 源、实际构建产物的 `dsh run`、真实 headless 组合、mock LLM 请求观测,以及对已准备缓存的检查。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46ab8e892f..5324df431e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,8 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' + DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE: >- + github:${{ github.event.pull_request.head.repo.full_name }}#${{ github.event.pull_request.head.sha }}&path:/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin # Failover halves snapshot concurrency for the shared 64-core VM. DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts new file mode 100644 index 0000000000..7262ddeb58 --- /dev/null +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') +const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE +const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1' +const enabled = required || source !== undefined + +describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => { + it('installs a private exact GitHub source and exposes its skill to the model', async () => { + expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true) + expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch( + /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u, + ) + + const apiKey = 'github-repository-plugin-e2e-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'private GitHub repository Plugin reached dsh run', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-')) + const patch = join(home, 'github-repository-plugin.cordis.patch.yml') + writeFileSync(patch, [ + '- id: repository-plugins', + ' config:', + ' repositories:', + ` - ${JSON.stringify(source)}`, + '', + ].join('\n')) + + try { + const result = await execa(process.execPath, [ + dshBin, + 'run', + '--patch', + patch, + 'prove the private GitHub repository Plugin is active', + ], { + cwd: repoRoot, + input: '', + timeout: 120_000, + killSignal: 'SIGKILL', + reject: false, + env: { + ...process.env, + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + }, + }) + if (result.timedOut) { + throw new Error(`dsh GitHub repository Plugin run did not exit within 120s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) + expect(result.stdout).toBe('private GitHub repository Plugin reached dsh run') + expect(server.requests.length).toBeGreaterThan(0) + expect(JSON.stringify(server.requests.map(request => request.body))).toContain( + 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', + ) + + const cacheRoot = join(home, 'cache', 'repository-plugins') + const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) + expect(generations).toHaveLength(1) + const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository') + const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record + expect(manifest).toMatchObject({ + name: 'dsh-github-repository-plugin-e2e-fixture', + private: true, + scripts: { prepack: 'dsh-plugin-prepare' }, + }) + expect(manifest).not.toHaveProperty('dependencies') + expect(manifest).not.toHaveProperty('devDependencies') + expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8')) + .toContain('This skill exists only in the GitHub repository source fixture.') + expect(readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')).toContain('dsh-repository-plugin') + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 130_000) +}) diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index b7d80470b8..186cbe1b43 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -36,14 +36,14 @@ describe('RepositoryCache', () => { calls.push(directory) await fakePackage(directory) } - const cache = new RepositoryCache(root, install) + const cache = new RepositoryCache(root, { install }) const specifier = 'github:owner/repository#0123456789abcdef' const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)]) expect(concurrent).toBe(first) expect(calls).toHaveLength(1) - const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') }) + const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } }) expect(await reopened.resolve(specifier)).toBe(first) expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({ packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, @@ -68,8 +68,8 @@ describe('RepositoryCache', () => { const specifier = 'github:owner/repository#race' const [first, second] = await Promise.all([ - new RepositoryCache(root, install).resolve(specifier), - new RepositoryCache(root, install).resolve(specifier), + new RepositoryCache(root, { install }).resolve(specifier), + new RepositoryCache(root, { install }).resolve(specifier), ]) expect(second).toBe(first) @@ -80,11 +80,11 @@ describe('RepositoryCache', () => { it('removes a failed staging tree and permits an exact retry', async () => { const root = await temporaryRoot('repository-retry') let attempts = 0 - const cache = new RepositoryCache(root, async (directory) => { + const cache = new RepositoryCache(root, { install: async (directory) => { attempts += 1 if (attempts === 1) throw new Error('install failed') await fakePackage(directory) - }) + } }) await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository') expect(await readdir(root)).toEqual([]) @@ -94,7 +94,7 @@ describe('RepositoryCache', () => { it('rejects empty or padded specifiers before touching the cache', async () => { const root = await temporaryRoot('repository-input') - const cache = new RepositoryCache(root, fakePackage) + const cache = new RepositoryCache(root, { install: fakePackage }) expect(() => cache.resolve('')).toThrow('non-empty unpadded string') expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string') await expect(readdir(root)).resolves.toEqual([]) @@ -107,7 +107,7 @@ describe('RepositoryCache', () => { const entry = join(root, key) await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true }) await writeFile(join(entry, '.repository-cache.json'), '{}\n') - const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') }) + const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } }) await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') }) @@ -115,6 +115,22 @@ describe('RepositoryCache', () => { it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') + const executableDirectory = join(root, 'bin') + await mkdir(executableDirectory) + await writeFile(join(executableDirectory, 'dsh-plugin-prepare'), [ + '#!/usr/bin/env node', + "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", + "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", + "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", + "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", + "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + '', + ].join('\n'), { mode: 0o700 }) + await writeFile(join(executableDirectory, 'dsh-plugin-prepare.cmd'), [ + '@echo off', + 'node "%~dp0\\dsh-plugin-prepare" %*', + '', + ].join('\r\n')) await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ @@ -125,17 +141,9 @@ describe('RepositoryCache', () => { await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { prepare: 'node prepare.mjs' }, + scripts: { prepack: 'dsh-plugin-prepare' }, dsh: { skills: ['../skills'] }, })}\n`) - await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [ - "import { cp, mkdir, writeFile } from 'node:fs/promises'", - "await mkdir('dsh-plugin-assets/skills', { recursive: true })", - "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", - "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')", - "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", - '', - ].join('\n')) await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) await execFileAsync('git', ['add', '.'], { cwd: repository }) await execFileAsync('git', [ @@ -148,7 +156,9 @@ describe('RepositoryCache', () => { vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') - const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) + const installed = await new RepositoryCache(join(root, 'cache'), { + executableDirectories: [executableDirectory], + }).resolve(specifier) await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index e0ce60a641..c75c82dc84 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f -README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b +README.md: e0b45dc5fd40b5d1005598ae100d23ca7d0b6ec9 +README.zh.md: 30f200d2b7188d88e4a5e4e566cf3394f799933a diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 33cd763d7d..e0b45dc5fd 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -14,10 +14,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: "version": "0.0.0", "private": true, "scripts": { - "prepare": "dsh-plugin-prepare" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + "prepack": "dsh-plugin-prepare" }, "dsh": { "skills": ["../skills"], @@ -26,7 +23,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: } ``` -`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. +`scripts.prepack` must be exactly `dsh-plugin-prepare`. DSH supplies that command from its own installed runtime while preparing Git source, so the repository package needs no DSH or npm dependency. `dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. ## Standalone app configuration @@ -47,7 +44,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v ## Preparation -`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. +During exact Git installation, DSH places a temporary host-owned `dsh-plugin-prepare` command on the isolated package lifecycle `PATH`; the command is not fetched from npm. The required `prepack` lifecycle runs after the Git package's dependency installation and before its selected subdirectory is packed, including when `.dsh-plugin` sits inside another package-manager workspace. The command validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained the exact `prepack` declaration. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. Failure to run or complete preparation fails installation before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source. diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index 903dfbe601..30f200d2b7 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -14,10 +14,7 @@ "version": "0.0.0", "private": true, "scripts": { - "prepare": "dsh-plugin-prepare" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + "prepack": "dsh-plugin-prepare" }, "dsh": { "skills": ["../skills"], @@ -26,7 +23,7 @@ } ``` -`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 +`scripts.prepack` 必须精确设为 `dsh-plugin-prepare`。DSH 会在准备 Git 源时由已安装的运行时提供该命令,因此仓库包无需添加 DSH 或 NPM 依赖。`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 ## 独立应用配置 @@ -47,7 +44,7 @@ ## 准备阶段 -`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 +安装精确指定的 Git 源时,DSH 会把一个临时的宿主自有 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;该命令不从 NPM 获取。必需的 `prepack` 生命周期在 Git 包完成依赖安装后、选定子目录打包前运行,即使 `.dsh-plugin` 位于另一个包管理器工作区内也不例外。该命令校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装模块前,DSH 会重新校验已安装包是否仍保留精确的 `prepack` 声明。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。准备阶段未运行或未完成时,安装会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。 diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts index 9d66321087..654e37c201 100644 --- a/packages/self-modification/repository-plugin/src/format.ts +++ b/packages/self-modification/repository-plugin/src/format.ts @@ -14,6 +14,8 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' /** Loader builtin used by every generated import-free wrapper. */ export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' +/** Exact host-owned command required by the repository package `prepack` lifecycle. */ +export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' const sourceMetadataSchema = z.object({ skills: z.array(z.string().min(1)).default([]), @@ -23,6 +25,9 @@ const sourceMetadataSchema = z.object({ }) const sourcePackageSchema = z.looseObject({ name: z.string().min(1), + scripts: z.looseObject({ + prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND), + }), dsh: sourceMetadataSchema, }) const preparedManifestSchema = z.object({ @@ -148,7 +153,7 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause }) } const parsed = sourcePackageSchema.safeParse(packageValue) - if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error) + if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error) const sourceRoot = await realpath(dirname(pluginDirectory)) const skillSources: string[] = [] diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 73eb2dc473..d3d287974f 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -20,6 +20,7 @@ import { } from './format.ts' import { parseMcpDocument, resolveMcpServers } from './mcp.ts' import { + createRepositoryPrepareCommand, loadPreparedRepository, resolveRepositoryCacheDirectory, resolveRepositorySpecifier, @@ -29,6 +30,7 @@ export { PREPARED_ASSET_DIRECTORY, PREPARED_ENTRY_FILENAME, REPOSITORY_PLUGIN_BUILTIN, + REPOSITORY_PLUGIN_PREPARE_COMMAND, prepareDshPlugin, type PreparedPluginManifest, } from './format.ts' @@ -129,17 +131,24 @@ export async function apply(ctx: Context, config: Config = {}): Promise { if (new Set(repositories).size !== repositories.length) { throw new Error('repository sources must resolve to unique exact specifiers') } - const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir)) - await ctx.effect(async function* () { - ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime - yield () => { - if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { - Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) + const prepareCommand = repositories.length === 0 ? undefined : await createRepositoryPrepareCommand() + try { + const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir), { + executableDirectories: prepareCommand === undefined ? [] : [prepareCommand.directory], + }) + await ctx.effect(async function* () { + ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime + yield () => { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { + Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) + } } - } - for (const repository of repositories) { - const plugin = await loadPreparedRepository(ctx, cache, repository) - yield plugin.dispose - } - }, 'repository-plugin runtime and sources') + for (const repository of repositories) { + const plugin = await loadPreparedRepository(ctx, cache, repository) + yield plugin.dispose + } + }, 'repository-plugin runtime and sources') + } finally { + await prepareCommand?.dispose() + } } diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts index 0befb82e42..e8a9e9b9cc 100644 --- a/packages/self-modification/repository-plugin/src/source.ts +++ b/packages/self-modification/repository-plugin/src/source.ts @@ -3,12 +3,18 @@ * @module */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import type { Context, Fiber, FiberState, Plugin } from 'cordis' import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { PREPARED_ENTRY_FILENAME } from './format.ts' +import { z } from 'zod' +import { + PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_PREPARE_COMMAND, +} from './format.ts' // Value mirror: Cordis's const enum has no runtime object to import. Keep // aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts`. @@ -17,11 +23,66 @@ const FIBER_ACTIVE = 2 as FiberState.ACTIVE /** Directory under the Harness home containing immutable repository generations. */ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' +/** Temporary host command supplied to repository package lifecycle scripts. */ +export interface RepositoryPrepareCommand { + /** Absolute directory to prepend to the isolated install's executable search path. */ + directory: string + /** Remove the temporary command directory. */ + dispose(): Promise +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function batchQuote(value: string): string { + return `"${value.replaceAll('%', '%%')}"` +} + +/** + * Materialize the DSH-owned prepare executable used only while pnpm packs Git source. + * @returns a command directory and its idempotent cleanup operation. + */ +export async function createRepositoryPrepareCommand(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-repository-plugin-bin-')) + const target = fileURLToPath(new URL('../lib/bin.js', import.meta.url)) + try { + await Promise.all([ + writeFile(join(directory, REPOSITORY_PLUGIN_PREPARE_COMMAND), [ + '#!/bin/sh', + `exec ${shellQuote(process.execPath)} ${shellQuote(target)} "$@"`, + '', + ].join('\n'), { mode: 0o700 }), + writeFile(join(directory, `${REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), [ + '@echo off', + `${batchQuote(process.execPath)} ${batchQuote(target)} %*`, + '', + ].join('\r\n'), { mode: 0o700 }), + ]) + } catch (cause) { + /* v8 ignore next -- requires a host filesystem failure after mkdtemp; cleanup semantics are the contract under test. */ + await rm(directory, { recursive: true, force: true }) + /* v8 ignore next -- preserves that unstageable host failure after best-effort cleanup. */ + throw cause + } + return { + directory, + async dispose() { + await rm(directory, { recursive: true, force: true }) + }, + } +} + // The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config // parser, with the syntax the error message promises — instead of inside the // cache's pnpm install ('misconfiguration fails loud at the earliest // resolvable point'). const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ +const installedPackageSchema = z.looseObject({ + scripts: z.looseObject({ + prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND), + }), +}) function validPluginPath(path: string): boolean { const segments = path.split('/').slice(1) @@ -57,6 +118,19 @@ export function resolveRepositoryCacheDirectory(configured: string | undefined): return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY)) } +async function assertInstalledPackageMetadata(directory: string): Promise { + let value: unknown + try { + value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown + } catch (cause) { + throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause }) + } + const result = installedPackageSchema.safeParse(value) + if (!result.success) { + throw new Error(`installed DSH plugin package must declare scripts.prepack as ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`) + } +} + /** * Load one exact repository generation's generated wrapper as a child Cordis fiber. * @param ctx - repository runtime context that owns the child. @@ -73,6 +147,7 @@ export async function loadPreparedRepository( const directory = await cache.resolve(specifier) const filename = join(directory, PREPARED_ENTRY_FILENAME) try { + await assertInstalledPackageMetadata(directory) const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin const fiber = ctx.plugin(plugin) await fiber diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 39840fd1f3..d026a4e14c 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -14,6 +14,7 @@ import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' import { parsePreparedPluginConfig } from '../src/format.ts' import { + createRepositoryPrepareCommand, loadPreparedRepository, resolveRepositoryCacheDirectory, resolveRepositorySpecifier, @@ -30,7 +31,12 @@ async function temporaryDirectory(name: string): Promise { async function writePlugin(root: string, name: string, dsh: Record): Promise { const directory = join(root, '.dsh-plugin') await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`) + await writeFile(join(directory, 'package.json'), `${JSON.stringify({ + name, + version: '0.0.0', + scripts: { prepack: RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND }, + dsh, + }, undefined, 2)}\n`) return directory } @@ -102,6 +108,16 @@ describe('dsh-plugin-prepare', () => { await writeFile(join(malformed, 'package.json'), '{') await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') + const lifecycleRoot = await temporaryDirectory('wrong-lifecycle') + const lifecycle = join(lifecycleRoot, '.dsh-plugin') + await mkdir(lifecycle) + await writeFile(join(lifecycle, 'package.json'), JSON.stringify({ + name: 'wrong-lifecycle', + scripts: { prepare: 'dsh-plugin-prepare' }, + dsh: { skills: ['../skills'] }, + })) + await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack') + const emptyRoot = await temporaryDirectory('empty-metadata') const empty = await writePlugin(emptyRoot, 'empty', {}) await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file') @@ -272,6 +288,17 @@ describe('prepared repository plugin Loader composition', () => { }) describe('configured GitHub repository sources', () => { + it('creates host-owned prepare commands and removes them idempotently', async () => { + const command = await createRepositoryPrepareCommand() + expect(await readFile(join(command.directory, RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND), 'utf8')) + .toContain(process.execPath) + expect(await readFile(join(command.directory, `${RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), 'utf8')) + .toContain(process.execPath) + await command.dispose() + await command.dispose() + await expect(stat(command.directory)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('defaults an omitted source list and rejects unknown configuration fields', () => { expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) @@ -439,12 +466,43 @@ describe('configured GitHub repository sources', () => { it('labels a missing prepared wrapper with its exact source and path', async () => { const root = await temporaryDirectory('missing-wrapper') + const directory = await writePlugin(root, 'missing-wrapper', { skills: ['../skills'] }) const ctx = new Context() const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) + await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, specifier)) .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) await ctx.fiber.dispose() }) + + it('rejects installed source with the obsolete prepare lifecycle', async () => { + const root = await temporaryDirectory('installed-lifecycle') + await writeFile(join(root, 'package.json'), JSON.stringify({ + name: 'installed-lifecycle', + scripts: { prepare: 'dsh-plugin-prepare' }, + })) + const ctx = new Context() + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) + .rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining('must declare scripts.prepack') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + + it('labels missing installed package metadata with its source', async () => { + const root = await temporaryDirectory('missing-installed-metadata') + const ctx = new Context() + const specifier = 'github:owner/repository#damaged&path:/.dsh-plugin' + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) + .rejects.toMatchObject({ + message: expect.stringContaining(JSON.stringify(specifier)) as string, + cause: expect.objectContaining({ + message: expect.stringContaining('failed to read installed DSH plugin package metadata') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) }) describe('repository plugin invariant companion', () => { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index c7b8a7d2c9..5c4ba9899a 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -218,7 +218,7 @@ describe('Node 24 lane ownership', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 10, + workers: 11, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ @@ -232,11 +232,19 @@ describe('Node 24 lane ownership', () => { 'doc-typecheck', 'node-next-types', 'built-bin-smoke', + 'github-repository-plugin-e2e', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) - for (const id of ['snapshot', 'web-snapshot', 'doc-typecheck', 'node-next-types', 'built-bin-smoke']) { + for (const id of [ + 'snapshot', + 'web-snapshot', + 'doc-typecheck', + 'node-next-types', + 'built-bin-smoke', + 'github-repository-plugin-e2e', + ]) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) @@ -249,6 +257,16 @@ describe('Node 24 lane ownership', () => { 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', ]), ) + const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e') + expect(githubRepositoryPlugin).toMatchObject({ + label: 'GitHub repository Plugin dsh run', + env: { + DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1', + }, + }) + expect(githubRepositoryPlugin?.args).toEqual( + expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']), + ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d99889d28b..3bd0e11987 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -406,6 +406,7 @@ function ciConsumerGates(): Gate[] { needs: validatedBuild, }), builtBinSmokeGate(validatedBuild), + githubRepositoryPluginE2eGate(validatedBuild), ] } @@ -636,6 +637,20 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { }) } +function githubRepositoryPluginE2eGate(needs: string[]): Gate { + return pnpmExec('github-repository-plugin-e2e', [ + 'vitest', + 'run', + '--config', + 'vitest.e2e.config.ts', + 'apps/cli/tests/github-repository-plugin.built.e2e.ts', + ], { + label: 'GitHub repository Plugin dsh run', + needs, + env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' }, + }) +} + /** * Reject a gate list whose graph cannot be executed unambiguously. * @param gates - complete aggregate to validate. diff --git a/vendor/README.md b/vendor/README.md index 02e33ea723..3cc2a313d2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. Callers may prepend host-owned executable directories to the isolated package lifecycle `PATH`; all paths are resolved before the child starts. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` run through the bundled pnpm and an injected command directory. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index 94a0c5cf16..c0cd1589aa 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' /** Exact pnpm release shipped with the Loader for repository installation. */ export const BUNDLED_PNPM_VERSION = '11.7.0' @@ -21,6 +21,14 @@ const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i /** Injectable isolated-install boundary used by {@link RepositoryCache}. */ export type RepositoryInstall = (directory: string) => Promise +/** Installation controls for {@link RepositoryCache}. */ +export interface RepositoryCacheOptions { + /** Override the isolated package installation boundary. */ + install?: RepositoryInstall + /** Command directories resolved absolutely and prepended to package lifecycle `PATH`. */ + executableDirectories?: readonly string[] +} + interface CacheMarker { specifier: string } @@ -29,12 +37,26 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) } +function installEnvironment(executableDirectories: readonly string[]): NodeJS.ProcessEnv { + const scrubbed = scrubEnvironment() + if (executableDirectories.length === 0) return scrubbed + const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] + const withoutPath = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => name.toUpperCase() !== 'PATH')) + return { + ...withoutPath, + PATH: [...executableDirectories, ...(path === undefined ? [] : [path])].join(delimiter), + } +} + function appendOutput(current: string, chunk: Uint8Array): string { const combined = current + Buffer.from(chunk).toString('utf8') return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) } -async function installWithBundledPnpm(directory: string): Promise { +async function installWithBundledPnpm( + directory: string, + executableDirectories: readonly string[], +): Promise { const require = createRequire(import.meta.url) const pnpmManifest = require.resolve('pnpm') const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') @@ -47,7 +69,7 @@ async function installWithBundledPnpm(directory: string): Promise { '--reporter=append-only', ], { cwd: directory, - env: scrubEnvironment(), + env: installEnvironment(executableDirectories), shell: false, stdio: ['ignore', 'pipe', 'pipe'], }) @@ -122,13 +144,16 @@ export class RepositoryCache { readonly directory: string private readonly tasks = new Map>() + private readonly install: RepositoryInstall /** * @param directory - caller-owned persistent cache root. - * @param install - isolated package installation boundary; defaults to the bundled pnpm. + * @param options - isolated installer override and lifecycle command directories. */ - constructor(directory: string, private readonly install: RepositoryInstall = installWithBundledPnpm) { + constructor(directory: string, options: RepositoryCacheOptions = {}) { this.directory = resolve(directory) + const executableDirectories = (options.executableDirectories ?? []).map(entry => resolve(entry)) + this.install = options.install ?? (staging => installWithBundledPnpm(staging, executableDirectories)) } /** From 778b9585d44439fa60a7e80efc90950a0c6c8f5b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:53:40 +0800 Subject: [PATCH 04/20] docs: refresh repository Plugin config source --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb4472d629..153ac72cc3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1182,7 +1182,7 @@ export interface Config { } ``` -Source: [`packages/self-modification/repository-plugin/src/index.ts:42`](../packages/self-modification/repository-plugin/src/index.ts) +Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` From a9af1a33f01310c56b60864afd51aceb95f9037a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:02:36 +0800 Subject: [PATCH 05/20] test(repository-plugin): update prepared fixture metadata --- examples/headless-agent/tests/keyless-smoke.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 8b69406c53..c737e4ea72 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -6,7 +6,11 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' +import { + PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_PREPARE_COMMAND, + prepareDshPlugin, +} from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) @@ -72,6 +76,7 @@ describe('headless-agent keyless smoke', () => { await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ name: 'headless-repository-fixture', version: '0.0.0', + scripts: { prepack: REPOSITORY_PLUGIN_PREPARE_COMMAND }, dsh: { skills: ['../skills'] }, }, undefined, 2)}\n`) await prepareDshPlugin(plugin) From a0c64f49068cfa95925f9e056f327a9d18f07c3e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:16:59 +0800 Subject: [PATCH 06/20] ci(repository-plugin): authenticate private GitHub source --- ...26-07-30-static-repository-plugin-format.i18n.yaml | 4 ++-- .../2026-07-30-static-repository-plugin-format.md | 2 +- .../2026-07-30-static-repository-plugin-format.zh.md | 2 +- ...-owned-git-repository-plugin-preparation.i18n.yaml | 4 ++-- ...08-host-owned-git-repository-plugin-preparation.md | 5 +++-- ...host-owned-git-repository-plugin-preparation.zh.md | 5 +++-- .github/workflows/ci.yml | 11 +++++++++++ .../repository-plugin/README.i18n.yaml | 4 ++-- .../self-modification/repository-plugin/README.md | 2 ++ .../self-modification/repository-plugin/README.zh.md | 2 ++ 10 files changed, 29 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 40a0461378..b555ad63c0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: 4823495dabee2101713ac72c8d0cee5bcc6b38d1 -2026-07-30-static-repository-plugin-format.zh.md: e5817ca982a71a609c86c5835b803b12f1317e1a +2026-07-30-static-repository-plugin-format.md: 5b1038f8738868a5838d4b20a6d56399c5f11ab6 +2026-07-30-static-repository-plugin-format.zh.md: c7cbc588c5ce6982c7c7003815151c9d40956972 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index 4823495dab..5b1038f873 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -46,4 +46,4 @@ Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `C ## Testing -Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The CI built-entry acceptance invokes `dsh run` with a GitHub source pinned to the pull request head, lets bundled pnpm fetch and prepare a private dependency-free fixture, then observes the copied skill in the real model request and the prepared wrapper in the immutable cache. +Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The CI built-entry acceptance invokes `dsh run` with a GitHub source pinned to the pull request head, authenticates to the private pull request repository through job-scoped Git configuration, lets bundled pnpm fetch and prepare its dependency-free fixture, then observes the copied skill in the real model request and the prepared wrapper in the immutable cache. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index e5817ca982..c7cbc588c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -46,4 +46,4 @@ ## 测试 -聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。CI 的构建入口验收会用锁定到 PR(Pull Request)head 的 GitHub 源调用 `dsh run`,让随附 pnpm 获取并准备一个私有且不含依赖的 fixture(测试前置数据),然后在真实模型请求中观察已复制的 skill,并在不可变缓存中观察已准备的包装模块。 +聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。CI 的构建入口验收会用锁定到 PR(Pull Request)head 的 GitHub 源调用 `dsh run`,通过作业作用域的 Git 配置认证私有 PR 仓库,让随附 pnpm 获取并准备其中不含依赖的 fixture(测试前置数据),然后在真实模型请求中观察已复制的 skill,并在不可变缓存中观察已准备的包装模块。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml index 55d9045506..39a7548d24 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md -2026-08-08-host-owned-git-repository-plugin-preparation.md: 45e84f9c8a89bb9d1eb7e4521634d16789dea2f0 -2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: ba88d5bb351427118d55c357209b496b39c2eb98 +2026-08-08-host-owned-git-repository-plugin-preparation.md: 47410bf3c55455a971ec347eab4e8b37ea26c6a5 +2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: 64c37543d34147c41fc7a362040c7ada8fde0652 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md index 45e84f9c8a..47410bf3c5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md @@ -18,7 +18,7 @@ The fixed authoring format now requires exact `scripts.prepack: "dsh-plugin-prep `@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. The command directory exists only for the installation transaction and is removed on success or failure. The repository remains trusted package-manager input: DSH supplies one command, but other lifecycle scripts and dependencies still execute under the existing trust contract. -The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true`, dependency-free GitHub fixture. It requires the run to reach the mock LLM, finds the repository skill description in the actual model request, and verifies the generated wrapper and copied skill under the immutable DSH cache. The test fails if CI omits the exact source instead of silently skipping. +The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Because that repository is private, the workflow writes a job-scoped Git configuration that uses the read-only job token for GitHub HTTPS and rewrites pnpm's SSH fallback to that authenticated transport. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true`, dependency-free GitHub fixture. It requires the run to reach the mock LLM, finds the repository skill description in the actual model request, and verifies the generated wrapper and copied skill under the immutable DSH cache. The test fails if CI omits the exact source instead of silently skipping. ## Alternatives considered @@ -33,10 +33,11 @@ The Node 24 consumer lane passes an exact source derived from the pull request h ## Consequences - A repository author can commit the fixed `.dsh-plugin/package.json` and source assets to GitHub without publishing either the Plugin or its preparation helper to npm. +- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. - `prepack`, not `prepare`, is part of the pre-release authoring format. Invalid lifecycle metadata fails during source preparation or installed-package validation instead of producing an ambiguous partial format. - Exact source strings still identify immutable cache generations; a changed ref or source configuration selects another generation. - This repair does not expand the contribution surface: prepared repository Plugins still contribute only declared skills and common MCP definitions, while arbitrary package lifecycle code remains trusted installation code rather than a model-facing Cordis Plugin API. ## Testing -`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a local Git subpath through bundled pnpm with an injected command directory and proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins the exact `prepack` metadata and temporary command cleanup. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact live GitHub source, actual built `dsh run`, real headless composition, mock LLM request observation, and prepared cache inspection. +`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a local Git subpath through bundled pnpm with an injected command directory and proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins the exact `prepack` metadata and temporary command cleanup. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, real headless composition, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md index ba88d5bb35..64c37543d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md @@ -18,7 +18,7 @@ repository 插件的创作契约依赖 `scripts.prepare: "dsh-plugin-prepare"` `@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。该命令目录仅存在于安装事务期间,无论成功还是失败都会被移除。仓库仍是受信任的包管理器输入:DSH 仅提供这一条命令,其他生命周期脚本和依赖仍会按既有信任契约执行。 -Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA 派生的精确源。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true`、不含依赖的 GitHub fixture。验收要求该次运行到达 mock LLM(大语言模型),在实际模型请求中找到 repository skill 描述,并验证不可变 DSH 缓存中的生成包装层和已复制 skill。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。 +Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA 派生的精确源。由于该仓库为私有仓库,工作流会写入一份作业作用域的 Git 配置,使用该作业的只读 token 对 GitHub HTTPS 连接进行认证,并将 pnpm 的 SSH 回退路径重写为这一已认证的传输方式。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true`、不含依赖的 GitHub fixture。验收要求该次运行到达 mock LLM(大语言模型),在实际模型请求中找到 repository skill 描述,并验证不可变 DSH 缓存中的生成包装层和已复制 skill。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。 ## 考虑过的替代方案 @@ -33,10 +33,11 @@ Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA ## 后果 - 仓库作者可以把修复后的 `.dsh-plugin/package.json` 和源资源提交到 GitHub,而无需把插件或其准备辅助程序发布到 NPM。 +- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 - 预发布创作格式使用 `prepack` 而不是 `prepare`。无效的生命周期元数据会在源码准备或已安装包校验阶段导致失败,而不会留下状态不明的半成品格式。 - 精确源字符串仍标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 - 本次修复不扩大贡献范围:已准备的 repository 插件仍只贡献已声明的 skills 和通用 MCP 定义,而任意包生命周期代码仍是受信任的安装代码,不是面向模型的 Cordis 插件 API。 ## 测试 -`packages/ui/app-boot/tests/repository-cache.spec.ts` 会用注入的命令目录通过随附 pnpm 运行本地 Git 子路径,并证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定精确的 `prepack` 元数据和临时命令清理行为。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确的真实 GitHub 源、实际构建产物的 `dsh run`、真实 headless 组合、mock LLM 请求观测,以及对已准备缓存的检查。 +`packages/ui/app-boot/tests/repository-cache.spec.ts` 会用注入的命令目录通过随附 pnpm 运行本地 Git 子路径,并证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定精确的 `prepack` 元数据和临时命令清理行为。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建产物的 `dsh run`、真实 headless 组合、mock LLM 请求观测,以及对已准备缓存的检查。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5324df431e..d017fa14fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,6 +242,17 @@ jobs: if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + - name: Configure private GitHub repository Plugin access + env: + DSH_GITHUB_SOURCE_TOKEN: ${{ github.token }} + run: | + source_config="$RUNNER_TEMP/dsh-github-source.gitconfig" + basic_auth=$(printf 'x-access-token:%s' "$DSH_GITHUB_SOURCE_TOKEN" | base64 | tr -d '\n') + git config --file "$source_config" url.https://github.com/.insteadOf git@github.com: + git config --file "$source_config" --add url.https://github.com/.insteadOf ssh://git@github.com/ + git config --file "$source_config" http.https://github.com/.extraheader "AUTHORIZATION: basic $basic_auth" + echo "GIT_CONFIG_GLOBAL=$source_config" >> "$GITHUB_ENV" + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index c75c82dc84..9720364496 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: e0b45dc5fd40b5d1005598ae100d23ca7d0b6ec9 -README.zh.md: 30f200d2b7188d88e4a5e4e566cf3394f799933a +README.md: 23c4cc0dceaf0692b7ef5067f9170b8c6b311cc0 +README.zh.md: db2f3e5a5c8915836d97177f797411e17e70441c diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index e0b45dc5fd..23c4cc0dce 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -40,6 +40,8 @@ The shipped `dsh-base` bundle every profile starts from contains an empty `repos Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. +Git transport uses the host's ordinary Git authentication. Public repositories need no credentials; private sources require a read-only credential or SSH agent that can read the selected repository. DSH removes credential-shaped environment variables before package lifecycles, so configure Git itself, such as through a credential helper or job-scoped Git config, instead of expecting an exported token variable to cross that boundary. Repository lifecycle code is trusted and can invoke Git, so use the narrowest repository-scoped credential available. + Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). ## Preparation diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index 30f200d2b7..db2f3e5a5c 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -40,6 +40,8 @@ 每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 +Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有源需要可读取所选仓库的只读凭据或 SSH agent。DSH 会在包生命周期运行前移除名称符合凭据模式的环境变量,因此请配置 Git 本身,例如使用 Git 凭据辅助工具或作业作用域的 Git 配置,而不要指望已导出的 token 变量跨越该边界。仓库生命周期代码受信任且可以调用 Git,因此请使用作用域最窄且仅限所选仓库的凭据。 + 长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 From 033fa4b0b12ac88e2ee89597b059891bc4e12f90 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:25:14 +0800 Subject: [PATCH 07/20] feat(repository-plugin): load trusted package code --- ...-static-repository-plugin-format.i18n.yaml | 4 +- ...6-07-30-static-repository-plugin-format.md | 16 ++-- ...7-30-static-repository-plugin-format.zh.md | 18 ++-- ...-trusted-repository-package-code.i18n.yaml | 6 ++ ...6-08-08-trusted-repository-package-code.md | 51 ++++++++++ ...8-08-trusted-repository-package-code.zh.md | 51 ++++++++++ ...it-repository-plugin-preparation.i18n.yaml | 4 +- ...owned-git-repository-plugin-preparation.md | 10 +- ...ed-git-repository-plugin-preparation.zh.md | 10 +- ...0-config-only-repository-plugins.i18n.yaml | 4 +- ...26-07-30-config-only-repository-plugins.md | 8 +- ...07-30-config-only-repository-plugins.zh.md | 8 +- .../.dsh-plugin/.mcp.json | 10 ++ .../.dsh-plugin/package.json | 20 +++- .../.dsh-plugin/src/mcp-server.ts | 19 ++++ .../.dsh-plugin/src/plugin.ts | 59 ++++++++++++ .../.dsh-plugin/tsconfig.json | 13 +++ .../github-repository-plugin.built.e2e.ts | 57 ++++++++--- .../fixtures/repository-plugin/dsh-plugin.mjs | 11 ++- knip.json | 11 +++ packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 3 +- packages/mcp/mcp-client/README.zh.md | 3 +- packages/mcp/mcp-client/src/index.ts | 10 +- packages/mcp/mcp-client/tests/apply.spec.ts | 37 +++---- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 29 ++---- .../repository-plugin/README.i18n.yaml | 4 +- .../repository-plugin/README.md | 43 +++++++-- .../repository-plugin/README.zh.md | 43 +++++++-- .../repository-plugin/package.json | 2 +- .../repository-plugin/src/format.ts | 65 +++++++++++-- .../repository-plugin/src/index.ts | 2 +- .../repository-plugin/src/source.ts | 8 +- .../tests/repository-plugin.spec.ts | 96 +++++++++++++++++-- 34 files changed, 590 insertions(+), 149 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md create mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts create mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index b555ad63c0..8ffa06ab1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: 5b1038f8738868a5838d4b20a6d56399c5f11ab6 -2026-07-30-static-repository-plugin-format.zh.md: c7cbc588c5ce6982c7c7003815151c9d40956972 +2026-07-30-static-repository-plugin-format.md: 615529c89d32ed87c73d100b632318500e1ae86c +2026-07-30-static-repository-plugin-format.zh.md: bb90994defccdaf2044b467cb3c5018c1c94641a diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index 5b1038f873..615529c89d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -6,15 +6,15 @@ English | [中文](2026-07-30-static-repository-plugin-format.zh.md) ## Problem -A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. At the same time, treating an arbitrary repository entry point as a Cordis Plugin would make every repository a new unrestricted runtime extension surface and would bypass the existing skill and MCP lifecycle owners. +A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. These portable static contributions still need to reuse the existing skill and MCP lifecycle owners when the same trusted package also carries native Cordis code. The [package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) prepares an exact package source but intentionally knows nothing about DSH formats. This layer therefore needs a package-manager-compatible authoring format, a deterministic prepared artifact, and a Cordis composition that stays transactional under Loader disposal and replacement. ## Decision -`@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. +`@deepseek-ai/dsh-repository-plugin` owns the static contribution subformat inside a `.dsh-plugin` package: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. The package may additionally declare the explicit code entry owned by the [trusted repository package decision](2026-08-08-trusted-repository-package-code.md), and at least one code or static contribution is required. -The `.dsh-plugin` package declares exact `scripts.prepack: "dsh-plugin-prepare"` metadata without depending on a DSH npm package. During Git installation, the standalone runtime temporarily supplies that command from its own build on the isolated lifecycle `PATH`; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's exact lifecycle metadata before importing that wrapper. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. The host-owned command rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). +The `.dsh-plugin` package declares a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` without depending on a DSH npm package. During Git installation, the standalone runtime temporarily supplies that command from its own build on the isolated lifecycle `PATH`; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The host-owned command rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. @@ -22,11 +22,11 @@ Each prepared skill set mounts `dsh-skill-local` with a unique `repository:` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation. -`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, runs its ordinary lifecycle including `prepare`, and atomically publishes the exact specifier. The DSH host imports only the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber, so skills and MCP retain the owners, failure contracts, and teardown defined by the format package. +`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, installs its dependencies, runs its package-authored `prepack` and the host preparation helper, and atomically publishes the exact specifier. The DSH host imports the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber; that wrapper composes static skill and MCP owners plus an explicit trusted Cordis entry when declared. ## Live update and failure @@ -24,7 +24,7 @@ An identical specifier permanently reuses its cache generation. HMR watches conf ## Trust boundary -Configuring a repository authorizes package-manager lifecycle code from that repository and its dependencies to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The fixed runtime wrapper prevents repository-authored Cordis entry points from becoming part of the supported Plugin format; it does not make package preparation untrusted-safe. +Configuring a repository authorizes package-manager lifecycle code, dependencies, the explicit `dsh.entry`, and spawned MCP servers from that repository to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The prepared wrapper validates composition boundaries and lifecycle state; it does not make repository code safe to run when the source is untrusted. ## Alternatives considered @@ -43,7 +43,7 @@ Configuring a repository authorizes package-manager lifecycle code from that rep - A repository that adds `.dsh-plugin/package.json` can reach standalone users through one personal-config edit without changing its existing skills or `.mcp.json` layout. - Long-running apps can add, replace, or remove configured generations without restart; rejected candidates retain the last good runtime and produce one generic Cordis event. - First use may require Git/network access and preparation time. Later starts reuse the exact prepared cache; old generations consume disk until a separate cache-management policy exists. -- Only skills and common MCP definitions are supported. Hooks, commands, agents, apps, arbitrary Cordis code, compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent. +- Skills and common MCP definitions retain portable static adapters, while an explicit `dsh.entry` can contribute DSH-native Cordis behavior. Format-specific compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md index 6e741b46be..8e8de1e6ed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;若加载任意仓库代码,又会绕过受限的[静态仓库插件格式](../architecture/2026-07-30-static-repository-plugin-format.md)。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。 +独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;受信任的 repository 代码仍需要由[repository 包格式](../architecture/2026-08-08-trusted-repository-package-code.md)负责一套锁定精确来源且具事务性的生命周期。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。 ## 决策 已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。 -`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包(package),运行包括 `prepare` 在内的普通生命周期,并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装模块并将其挂载为子 fiber,因此 skill(技能)与 MCP 仍沿用格式包定义的所有者、失败契约和清理行为。 +`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择已配置的 repository 子包,安装其依赖,运行包所定义的 `prepack` 与宿主准备辅助程序,并原子发布该精确说明符。DSH 宿主会导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber;该包装层组合静态 skill(技能)与 MCP 所有者,并在声明时组合显式的受信任 Cordis 入口。 ## 实时更新与失败 @@ -24,7 +24,7 @@ Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader ## 信任边界 -配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装模块会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。 +配置仓库即授权该仓库中的包管理器生命周期代码、依赖、显式 `dsh.entry` 和 spawn 的 MCP server 以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。已准备的包装层会校验组合边界和生命周期状态;当来源不受信任时,它无法让 repository 代码变得可安全运行。 ## 考虑过的替代方案 @@ -43,7 +43,7 @@ Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader - 添加 `.dsh-plugin/package.json` 的仓库只需一次个人配置编辑即可供独立用户使用,无需改变现有 skill 或 `.mcp.json` 布局。 - 长时间运行的应用无需重启即可新增、替换或移除已配置版本;被拒绝的候选配置会保留最后一个可用运行时,并产生一个通用 Cordis 事件。 - 首次使用可能需要 Git/网络访问和准备时间。后续启动会复用这份精确的已准备缓存;在另行制定缓存管理政策之前,旧版本会持续占用磁盘空间。 -- 仅支持 skill 和通用 MCP 定义。钩子、命令、agent(智能体)、应用、任意 Cordis 代码、兼容 shim、带 OAuth 的 MCP 定义和插件市场均有意不提供。 +- skill 和通用 MCP 定义保留可移植静态适配器,而显式 `dsh.entry` 可以贡献 DSH 原生 Cordis 行为。格式专用的兼容 shim、带 OAuth 的 MCP 定义和插件市场仍有意不提供。 ## 测试 diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json new file mode 100644 index 0000000000..4851f9f1f8 --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "github_repository": { + "command": "node", + "args": [ + "lib/mcp-server.mjs" + ] + } + } +} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json index 9ce1527d03..b11ea67c41 100644 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json @@ -2,12 +2,28 @@ "name": "dsh-github-repository-plugin-e2e-fixture", "version": "0.0.0", "private": true, + "type": "module", + "files": [ + "lib", + "dsh-plugin.mjs", + "dsh-plugin-assets" + ], "scripts": { - "prepack": "dsh-plugin-prepare" + "prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare" }, "dsh": { "skills": [ "../skills" - ] + ], + "mcpServers": "./.mcp.json", + "entry": "./lib/plugin.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0" + }, + "devDependencies": { + "cordis": "4.0.0-rc.7", + "tsdown": "0.22.2", + "typescript": "6.0.3" } } diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts new file mode 100644 index 0000000000..78a3e008f4 --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts @@ -0,0 +1,19 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' + +// The repository root's linter cannot resolve this independently installed +// Git-package dependency; the package's prepack tsc validates the SDK types. +/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */ +const server = new McpServer({ + name: 'github-repository-plugin-e2e', + version: '0.0.0', +}) + +server.registerTool('proof', { + description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.', + inputSchema: {}, +}, async () => ({ + content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }], +})) + +await server.connect(new StdioServerTransport()) diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts new file mode 100644 index 0000000000..5106f71b2f --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts @@ -0,0 +1,59 @@ +import type { Context } from 'cordis' + +const PROOF_TOOL_NAME = 'mcp__github_repository__proof' + +interface TextBlock { + readonly type: 'text' + readonly text: string +} + +interface ToolExecution { + readonly name: string +} + +interface ToolResult { + readonly isError: boolean + readonly content: readonly TextBlock[] +} + +type PostDecision = + | { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] } + | { readonly kind: 'block'; readonly feedback: readonly TextBlock[] } + +type PostListener = ( + execution: ToolExecution, + result: ToolResult, + next: () => Promise, +) => Promise + +type DshContext = Context & { + on(event: 'tools/post-execute', listener: PostListener): () => void +} + +/** Cordis plugin name used by the repository acceptance fixture. */ +export const name = 'github-repository-typescript-proof' + +/** DSH tool registry required by the post-execute contribution. */ +export const inject = ['tools'] + +/** + * Append a marker after the repository MCP proof tool succeeds. + * @param ctx - trusted DSH Cordis context supplied to the repository package. + */ +export function apply(ctx: Context): void { + const dsh = ctx as DshContext + dsh.on('tools/post-execute', async (execution, result, next): Promise => { + const decision = await next() + if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) { + return decision + } + return { + kind: 'accept', + content: [ + ...(decision.content ?? result.content), + { type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' }, + ], + ...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts }, + } + }) +} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json new file mode 100644 index 0000000000..632e4db48c --- /dev/null +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index 7262ddeb58..0c13a71a02 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -1,4 +1,5 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,7 +14,7 @@ const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1' const enabled = required || source !== undefined describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => { - it('installs a private exact GitHub source and exposes its skill to the model', async () => { + it('installs, builds, and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => { expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true) expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch( /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u, @@ -21,9 +22,11 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => const apiKey = 'github-repository-plugin-e2e-key' const server = await startMockLlmServer({ - sequence: ['success'], + sequence: ['tool_call_success', 'success'], apiKey, - successText: 'private GitHub repository Plugin reached dsh run', + toolName: 'mcp__github_repository__proof', + toolArguments: '{}', + successText: 'trusted GitHub repository package reached dsh run', }) const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-')) const patch = join(home, 'github-repository-plugin.cordis.patch.yml') @@ -45,7 +48,7 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => ], { cwd: repoRoot, input: '', - timeout: 120_000, + timeout: 180_000, killSignal: 'SIGKILL', reject: false, env: { @@ -57,14 +60,20 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => }, }) if (result.timedOut) { - throw new Error(`dsh GitHub repository Plugin run did not exit within 120s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) - expect(result.stdout).toBe('private GitHub repository Plugin reached dsh run') - expect(server.requests.length).toBeGreaterThan(0) - expect(JSON.stringify(server.requests.map(request => request.body))).toContain( + expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') + expect(server.requests).toHaveLength(2) + const firstRequest = JSON.stringify(server.requests[0]!.body) + const secondRequest = JSON.stringify(server.requests[1]!.body) + expect(firstRequest).toContain( 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', ) + expect(firstRequest).toContain('mcp__github_repository__proof') + expect(firstRequest).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') + expect(secondRequest).toContain('MCP_FROM_GITHUB_REPOSITORY') + expect(secondRequest).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') const cacheRoot = join(home, 'cache', 'repository-plugins') const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) @@ -74,16 +83,38 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => expect(manifest).toMatchObject({ name: 'dsh-github-repository-plugin-e2e-fixture', private: true, - scripts: { prepack: 'dsh-plugin-prepare' }, + scripts: { + prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare', + }, + dsh: { + skills: ['../skills'], + mcpServers: './.mcp.json', + entry: './lib/plugin.mjs', + }, + dependencies: { + '@modelcontextprotocol/sdk': '1.29.0', + }, + devDependencies: { + cordis: '4.0.0-rc.7', + tsdown: '0.22.2', + typescript: '6.0.3', + }, }) - expect(manifest).not.toHaveProperty('dependencies') - expect(manifest).not.toHaveProperty('devDependencies') expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8')) .toContain('This skill exists only in the GitHub repository source fixture.') - expect(readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')).toContain('dsh-repository-plugin') + expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs') + expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') + expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY') + expect(existsSync(join(installed, 'src'))).toBe(false) + const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs')) + expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true) + const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8') + expect(wrapper).toContain('dsh-repository-plugin') + expect(wrapper).toContain('await import(manifest.entry)') + expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') } finally { await server.close() rmSync(home, { recursive: true, force: true }) } - }, 130_000) + }, 190_000) }) diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs index 5515aa82a2..ce61e58973 100644 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -1,9 +1,18 @@ // Generated by dsh-plugin-prepare. Do not edit. const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} +const FIBER_ACTIVE = 2 export const name = "headless-repository-fixture" export const inject = ["loader","skills"] +async function mount(ctx, plugin, label, config) { + const fiber = ctx.plugin(plugin, config) + await fiber + if (fiber.state !== FIBER_ACTIVE) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`) + } +} export async function apply(ctx) { const runtime = ctx.loader.builtins["dsh-repository-plugin"] if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin") - await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest }) + await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest }) } diff --git a/knip.json b/knip.json index 63db33e51c..0757fe49f8 100644 --- a/knip.json +++ b/knip.json @@ -703,6 +703,17 @@ "@deepseek-ai/.+" ] }, + "apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin": { + "entry": [ + "src/*.ts" + ], + "project": [ + "src/**/*.ts" + ], + "ignoreBinaries": [ + "dsh-plugin-prepare" + ] + }, "packages/client/modules": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 9fa4504cca..6b6d4d9270 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: d7966595c68ff1ec4a288caf5d9fe4b0bf580cc5 -README.zh.md: eb9e0dbdb48423cc4bc698fda355e973e42bc7a3 +README.md: 6bcc195e36d24d7e5ae3462573b30f1b41c963a7 +README.zh.md: 8886c16c3fe66d283ae2191661118729a89f1aeb diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index d7966595c6..6bcc195e36 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -56,7 +56,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` ## Behavior -- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name. +- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection failure is logged and activates with no tools. - Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. @@ -101,7 +101,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. - **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index eb9e0dbdb4..8886c16c3f 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -56,7 +56,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc ## 行为 -- 连接时:`listTools()` → 通过 `ctx.tools.register()` 使用各自公开名称注册每个工具。 +- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接失败会记录日志,插件仍会激活但不注册工具。 - 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。 - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 @@ -101,7 +101,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc ## 已知限制与暂缓事项 -- **初始发现是异步的**:插件加载不会等待连接和 `listTools()`,因此在启动或 HMR 后立即开始的轮次可能在 MCP 工具注册前完成组装。 - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 - **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 49609bac9a..9798b4cfcb 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -116,7 +116,13 @@ export const Config = z.union([ // ---- Plugin apply ---- -export function apply(ctx: Context, config: Config): void { +/** + * Connect one MCP server and publish its initial tool generation before activation. + * @param ctx - plugin context carrying the tool registry. + * @param config - resolved transport and server namespace configuration. + * @returns startup readiness after connection and initial tool discovery settle. + */ +export function apply(ctx: Context, config: Config): Promise { // Reserve the namespace first: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { @@ -179,4 +185,6 @@ export function apply(ctx: Context, config: Config): void { for (const dispose of live().values()) dispose() try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') + + return ready.then(() => undefined) } diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 5836c74e5a..6bb97a7032 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -141,8 +141,7 @@ describe('apply (plugin lifecycle)', () => { }) it('connects, syncs tools under the namespace, and registers a notification handler', async () => { - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) expect(mockConnect).toHaveBeenCalled() expect(mockListTools).toHaveBeenCalled() @@ -152,11 +151,10 @@ describe('apply (plugin lifecycle)', () => { }) it('rejects a duplicate serverName at load and leaves the first instance intact', async () => { - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() - expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) + expect(() => { void apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) // First instance unaffected. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) @@ -165,8 +163,7 @@ describe('apply (plugin lifecycle)', () => { const first = new Context() await first.plugin(SystemPrompt) await first.plugin(ToolRegistry) - apply(first, stdioConfig) - await sleep(50) + await apply(first, stdioConfig) await first.fiber.dispose() await sleep(50) @@ -176,16 +173,17 @@ describe('apply (plugin lifecycle)', () => { const second = new Context() await second.plugin(SystemPrompt) await second.plugin(ToolRegistry) - expect(() => { apply(second, stdioConfig) }).not.toThrow() + await expect(apply(second, stdioConfig)).resolves.toBeUndefined() + await second.fiber.dispose() }) it('scopes serverName reservations per app root', async () => { const other = await mountRegistry() - apply(ctx, stdioConfig) + const first = apply(ctx, stdioConfig) // Same serverName on a DIFFERENT root is fine. - expect(() => { apply(other, stdioConfig) }).not.toThrow() - await sleep(50) + const second = apply(other, stdioConfig) + await Promise.all([first, second]) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(other.tools.get('mcp__srv__remote')).toBeDefined() @@ -194,8 +192,7 @@ describe('apply (plugin lifecycle)', () => { it('logs error and registers no tools when connect fails; dispose is a no-op', async () => { mockConnect.mockRejectedValue(new Error('connection refused')) - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() @@ -208,8 +205,7 @@ describe('apply (plugin lifecycle)', () => { }) it('re-syncs tools on ToolListChanged notification', async () => { - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() @@ -226,8 +222,7 @@ describe('apply (plugin lifecycle)', () => { }) it('keeps the previous generation when a re-sync fails', async () => { - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() mockListTools.mockRejectedValue(new Error('flaky server')) @@ -242,7 +237,7 @@ describe('apply (plugin lifecycle)', () => { // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the // registry must survive to observe the unregistration. const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig) - await sleep(50) + await fiber // Advance to a second generation first. mockListTools.mockResolvedValue({ @@ -264,8 +259,7 @@ describe('apply (plugin lifecycle)', () => { it('effect disposer handles client.close failure gracefully', async () => { mockClose.mockRejectedValue(new Error('already closed')) - apply(ctx, stdioConfig) - await sleep(50) + await apply(ctx, stdioConfig) // Should not throw when dispose is triggered. await ctx.fiber.dispose() @@ -283,8 +277,7 @@ describe('apply (plugin lifecycle)', () => { toolCallTimeoutMs: 30_000, } - apply(ctx, httpConfig) - await sleep(50) + await apply(ctx, httpConfig) expect(mockConnect).toHaveBeenCalled() expect(ctx.tools.get('mcp__web__remote')).toBeDefined() diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 3ce25e9240..6a71ab4007 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -43,21 +43,6 @@ async function mountRegistry(): Promise { return ctx } -/** Apply the MCP client plugin and wait for tools to be registered. */ -async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise { - // Annotated bindings (not withResolvers()): the tests lint layer runs - // no-invalid-void-type with default options, which rejects the explicit - // type argument in call position but accepts the inferred form. - const gate: PromiseWithResolvers = Promise.withResolvers() - const timer = setTimeout( - () => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, - timeoutMs, - ) - ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() }) - apply(ctx, config) - await gate.promise -} - function sleep(ms: number): Promise { const gate: PromiseWithResolvers = Promise.withResolvers() setTimeout(gate.resolve, ms) @@ -94,7 +79,7 @@ describe('fixture server — controlled scenarios', () => { beforeAll(async () => { ctx = await mountRegistry() - await applyAndWait(ctx, fixtureConfig) + await apply(ctx, fixtureConfig) }, 30_000) afterAll(async () => { @@ -180,9 +165,9 @@ describe('fixture server — duplicate serverName', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, } - await applyAndWait(ctx, config) + await apply(ctx, config) - expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) + expect(() => { void apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) await ctx.fiber.dispose() await sleep(200) @@ -192,7 +177,7 @@ describe('fixture server — duplicate serverName', () => { describe('fixture server — disposal', () => { it('disposes cleanly without error', async () => { const ctx = await mountRegistry() - await applyAndWait(ctx, { + await apply(ctx, { transport: 'stdio', serverName: 'fixture', command: process.execPath, @@ -229,7 +214,7 @@ describe('server-everything — official test server', () => { beforeAll(async () => { ctx = await mountRegistry() - await applyAndWait(ctx, config) + await apply(ctx, config) }, 60_000) afterAll(async () => { @@ -293,7 +278,7 @@ describe('server-filesystem — real filesystem operations', () => { cwd: '', toolCallTimeoutMs: 30_000, } - await applyAndWait(ctx, config) + await apply(ctx, config) }, 60_000) afterAll(async () => { @@ -409,7 +394,7 @@ describe('streamable-http — in-process MCP server', () => { headers: { Authorization: 'Bearer e2e-test-token' }, toolCallTimeoutMs: 15_000, } - await applyAndWait(ctx, config) + await apply(ctx, config) }, 30_000) afterAll(async () => { diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index 9720364496..7d7c04cf69 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 23c4cc0dceaf0692b7ef5067f9170b8c6b311cc0 -README.zh.md: db2f3e5a5c8915836d97177f797411e17e70441c +README.md: 52d04f16684842749e57d1b47db0a95beb22558c +README.zh.md: 921bf70b8a2e78410510d1efce79ced5f52b4641 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 23c4cc0dce..52d04f1668 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md). +Trusted repository package format for DeepSeek Harness. A `.dsh-plugin` npm package may contribute a compiled Cordis/DSH Plugin entry, skill roots, and a common `.mcp.json`; its ordinary `prepack` lifecycle owns dependency installation and source compilation before the DSH prepare helper validates the outputs and emits the Loader wrapper. Static contributions compose [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [trusted repository package code](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md) and the [static contribution subformat](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md). ## Authoring format @@ -13,17 +13,30 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: "name": "humanize-dsh-plugin", "version": "0.0.0", "private": true, + "type": "module", "scripts": { - "prepack": "dsh-plugin-prepare" + "build": "tsc", + "prepack": "npm run build && dsh-plugin-prepare" }, "dsh": { + "entry": "./lib/plugin.js", "skills": ["../skills"], "mcpServers": "../.mcp.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0" + }, + "devDependencies": { + "typescript": "6.0.3" } } ``` -`scripts.prepack` must be exactly `dsh-plugin-prepare`. DSH supplies that command from its own installed runtime while preparing Git source, so the repository package needs no DSH or npm dependency. `dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. +`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. DSH does not transpile TypeScript or infer a package entry. + +`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. + +The repository package and every dependency or lifecycle script it runs are trusted code, just like an npm package selected directly by the user. This format is not a sandbox: install only repositories whose code may access the host process, filesystem, network, and services declared through Cordis. Exact refs and the immutable cache provide identity and reproducibility, not isolation. ## Standalone app configuration @@ -46,19 +59,17 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v ## Preparation -During exact Git installation, DSH places a temporary host-owned `dsh-plugin-prepare` command on the isolated package lifecycle `PATH`; the command is not fetched from npm. The required `prepack` lifecycle runs after the Git package's dependency installation and before its selected subdirectory is packed, including when `.dsh-plugin` sits inside another package-manager workspace. The command validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained the exact `prepack` declaration. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. Failure to run or complete preparation fails installation before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). - -The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source. +During exact Git installation, DSH places a temporary host-owned `dsh-plugin-prepare` command on the isolated package lifecycle `PATH`; the command is not fetched from npm. The required `prepack` lifecycle runs after the Git package's dependency installation and before its selected subdirectory is packed, including when `.dsh-plugin` sits inside another package-manager workspace. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to build or prepare fails installation before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). ## Runtime composition -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The entry is an ordinary Cordis child Plugin: its own `inject` gates activation, startup failures reject the repository generation, and all of its effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable. ## Common MCP format The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. -Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior. +Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation; a network or child-process connection failure is logged and still activates with no tools. ## Export shape @@ -94,8 +105,22 @@ Conditional on successful connection and the remote tool list; schemas recur on Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition. +### Repository code + +#### What the model sees + +Data-dependent. The trusted Cordis entry may contribute any DSH behavior available through its declared services and events, including tools, prompt sections, policies, commands, and transformations. Every model-visible contribution remains subject to its owning DSH seam's logging and lifecycle contract. + +#### Token effect + +Defined by the services and registrations the entry contributes; the repository format itself adds no model content. + +#### KV Cache effect + +Stable registrations preserve the owning surface's normal prefix behavior. Loading, removing, or replacing the exact repository generation can change any prefixes affected by that Plugin. + ## Known Limitations and Deferred Work -- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format. +- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory. - **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here. - **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation. diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index db2f3e5a5c..921bf70b8a 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 DeepSeek Harness 的受限 repository 插件格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill(技能)根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository 插件格式 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 +这是 DeepSeek Harness 的受信任 repository 包格式。`.dsh-plugin` NPM 包可以贡献已编译的 Cordis/DSH 插件入口、skill(技能)根和通用 `.mcp.json`;其常规 `prepack` 生命周期负责安装依赖并编译源码,随后 DSH 准备辅助程序校验输出并生成 Loader 包装层。静态贡献由 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md) 组合。设计依据见[受信任 repository 包代码](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md)和[静态贡献子格式](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 ## 创作格式 @@ -13,17 +13,30 @@ "name": "humanize-dsh-plugin", "version": "0.0.0", "private": true, + "type": "module", "scripts": { - "prepack": "dsh-plugin-prepare" + "build": "tsc", + "prepack": "npm run build && dsh-plugin-prepare" }, "dsh": { + "entry": "./lib/plugin.js", "skills": ["../skills"], "mcpServers": "../.mcp.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0" + }, + "devDependencies": { + "typescript": "6.0.3" } } ``` -`scripts.prepack` 必须精确设为 `dsh-plugin-prepare`。DSH 会在准备 Git 源时由已安装的运行时提供该命令,因此仓库包无需添加 DSH 或 NPM 依赖。`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 +`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖和其他 NPM 生命周期代码。DSH 不转译 TypeScript,也不推断包入口。 + +`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export,并自行拥有常规的 `name`、`inject`、`Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 + +repository 包及其运行的每项依赖或生命周期脚本都是受信任代码,与用户直接选择的 NPM 包相同。本格式不是沙箱:只有在你信任仓库代码并愿意允许其访问宿主进程、文件系统、网络及其通过 Cordis 声明的服务时才应安装。精确 ref 和不可变缓存提供身份与可复现性,而非隔离。 ## 独立应用配置 @@ -46,19 +59,17 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 ## 准备阶段 -安装精确指定的 Git 源时,DSH 会把一个临时的宿主自有 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;该命令不从 NPM 获取。必需的 `prepack` 生命周期在 Git 包完成依赖安装后、选定子目录打包前运行,即使 `.dsh-plugin` 位于另一个包管理器工作区内也不例外。该命令校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装模块前,DSH 会重新校验已安装包是否仍保留精确的 `prepack` 声明。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。准备阶段未运行或未完成时,安装会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 - -外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。 +安装精确指定的 Git 源时,DSH 会把一个临时的宿主自有 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;该命令不从 NPM 获取。必需的 `prepack` 生命周期在 Git 包完成依赖安装后、选定子目录打包前运行,即使 `.dsh-plugin` 位于另一个包管理器工作区内也不例外。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。构建或准备失败时,安装会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 ## 运行时组合 -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。包装模块 dispose(资源释放)时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。入口是普通的 Cordis 子插件:其自有 `inject` 会门控激活,启动失败会拒绝 repository generation,Loader 移除或回滚时,其所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。 ## 通用 MCP 格式 `.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 -未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。 +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络或子进程连接失败会记录日志,且插件仍会激活但不注册工具。 ## 导出形状 @@ -94,8 +105,22 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量 稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 +### Repository 代码 + +#### 模型看到什么 + +取决于数据。受信任的 Cordis 入口可以通过其声明的服务和事件贡献任意可用的 DSH 行为,包括工具、提示词片段、策略、命令和转换。每项模型可见贡献仍受所属 DSH seam 的日志与生命周期契约约束。 + +#### Token 影响 + +由入口贡献的服务和注册决定;repository 格式本身不添加模型内容。 + +#### KV Cache 影响 + +稳定的注册会保留所属表面的正常前缀行为。加载、移除或替换精确的 repository generation,可能改变受该插件影响的任意前缀。 + ## 已知限制与暂缓事项 -- **仅支持 skill 与 MCP**:commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 +- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。 - **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 - **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/self-modification/repository-plugin/package.json b/packages/self-modification/repository-plugin/package.json index 87e88122f6..fbf18c0cb1 100644 --- a/packages/self-modification/repository-plugin/package.json +++ b/packages/self-modification/repository-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-repository-plugin", - "description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness", + "description": "Trusted repository package format and Cordis runtime for DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts index 654e37c201..63ba724108 100644 --- a/packages/self-modification/repository-plugin/src/format.ts +++ b/packages/self-modification/repository-plugin/src/format.ts @@ -1,5 +1,5 @@ /** - * Static repository-plugin preparation and prepared-manifest validation. + * Trusted repository-package preparation and prepared-manifest validation. * @module */ @@ -12,21 +12,36 @@ import { parseMcpDocument } from './mcp.ts' export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' /** Fixed directory containing copied static plugin assets. */ export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' -/** Loader builtin used by every generated import-free wrapper. */ +/** Loader builtin used by every generated repository wrapper. */ export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' -/** Exact host-owned command required by the repository package `prepack` lifecycle. */ +/** Host-owned command that repository package `prepack` lifecycles must invoke. */ export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' +/** + * Whether a package lifecycle declaration names the host preparation helper. + * @param script - package-authored lifecycle command. + * @returns true when the required helper command is present. + */ +export function hasRepositoryPrepareCommand(script: string): boolean { + return script.includes(REPOSITORY_PLUGIN_PREPARE_COMMAND) +} + +const prepackSchema = z.string().min(1).refine( + hasRepositoryPrepareCommand, + { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` }, +) + const sourceMetadataSchema = z.object({ skills: z.array(z.string().min(1)).default([]), mcpServers: z.string().min(1).optional(), -}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, { - message: 'declare at least one skill root or mcpServers file', + entry: z.string().min(1).optional(), +}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined || value.entry !== undefined, { + message: 'declare at least one skill root, mcpServers file, or compiled entry', }) const sourcePackageSchema = z.looseObject({ name: z.string().min(1), scripts: z.looseObject({ - prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND), + prepack: prepackSchema, }), dsh: sourceMetadataSchema, }) @@ -34,6 +49,7 @@ const preparedManifestSchema = z.object({ name: z.string().min(1), skills: z.array(z.string().min(1)), mcpServers: z.string().min(1).optional(), + entry: z.string().min(1).optional(), }).strict() const preparedConfigSchema = z.object({ // Wrappers pass import.meta.url, which is always file: for an installed @@ -43,11 +59,12 @@ const preparedConfigSchema = z.object({ manifest: preparedManifestSchema, }).strict() -/** Static manifest embedded in the generated wrapper. */ +/** Prepared manifest embedded in the generated wrapper. */ export interface PreparedPluginManifest { name: string skills: string[] mcpServers?: string + entry?: string } /** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */ @@ -74,6 +91,7 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig name: result.data.manifest.name, skills: result.data.manifest.skills, ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers }, + ...result.data.manifest.entry === undefined ? {} : { entry: result.data.manifest.entry }, }, } } @@ -121,28 +139,49 @@ function wrapperSource(manifest: PreparedPluginManifest): string { ...manifest.skills.length > 0 ? ['skills'] : [], ...manifest.mcpServers === undefined ? [] : ['tools'], ] + const entryHelpers = manifest.entry === undefined ? [] : [ + 'function unwrap(exports) {', + ' const value = exports?.default ?? exports', + ' return value?.__esModule ? (value.default ?? value) : value', + '}', + ] + const entryApply = manifest.entry === undefined ? [] : [ + ' const repositoryPlugin = unwrap(await import(manifest.entry))', + " await mount(ctx, repositoryPlugin, 'repository Plugin entry')", + ] return [ '// Generated by dsh-plugin-prepare. Do not edit.', `const manifest = ${JSON.stringify(manifest)}`, + 'const FIBER_ACTIVE = 2', `export const name = ${JSON.stringify(manifest.name)}`, `export const inject = ${JSON.stringify(inject)}`, + ...entryHelpers, + 'async function mount(ctx, plugin, label, config) {', + ' const fiber = ctx.plugin(plugin, config)', + ' await fiber', + ' if (fiber.state !== FIBER_ACTIVE) {', + ' const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)', + " throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`)", + ' }', + '}', 'export async function apply(ctx) {', ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, - ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', + " await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })", + ...entryApply, '}', '', ].join('\n') } /** - * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * Validate and package one `.dsh-plugin` directory into copied assets plus a generated wrapper. * Outputs are staged and committed by rename, but the final publish (remove * old outputs, rename assets, rename entry) is not one atomic step: a crash * mid-publish can leave assets without an entry or neither. Rerunning prepare * repairs the package; partial outputs are never importable as a plugin. * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. - * @returns the generated static manifest. + * @returns the generated prepared manifest. */ export async function prepareDshPlugin(directory: string = process.cwd()): Promise { const pluginDirectory = await realpath(resolve(directory)) @@ -169,11 +208,17 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file') parseMcpDocument(await readFile(mcpSource, 'utf8')) } + let entry: string | undefined + if (parsed.data.dsh.entry !== undefined) { + const entrySource = await sourcePath(pluginDirectory, pluginDirectory, parsed.data.dsh.entry, 'file') + entry = `./${relative(pluginDirectory, entrySource).split(sep).join('/')}` + } const manifest: PreparedPluginManifest = { name: parsed.data.name, skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, + ...entry === undefined ? {} : { entry }, } const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-')) try { diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index d3d287974f..403c2d1ae9 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -1,5 +1,5 @@ /** - * Restricted repository-plugin runtime for static skills and common MCP definitions. + * Trusted repository-package runtime for code, skills, and common MCP definitions. * @module @deepseek-ai/dsh-repository-plugin */ diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts index e8a9e9b9cc..51f015e68a 100644 --- a/packages/self-modification/repository-plugin/src/source.ts +++ b/packages/self-modification/repository-plugin/src/source.ts @@ -14,6 +14,7 @@ import { z } from 'zod' import { PREPARED_ENTRY_FILENAME, REPOSITORY_PLUGIN_PREPARE_COMMAND, + hasRepositoryPrepareCommand, } from './format.ts' // Value mirror: Cordis's const enum has no runtime object to import. Keep @@ -80,7 +81,10 @@ export async function createRepositoryPrepareCommand(): Promise } const result = installedPackageSchema.safeParse(value) if (!result.success) { - throw new Error(`installed DSH plugin package must declare scripts.prepack as ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`) + throw new Error(`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`) } } diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index d026a4e14c..06cacfd699 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -28,13 +28,18 @@ async function temporaryDirectory(name: string): Promise { return directory } -async function writePlugin(root: string, name: string, dsh: Record): Promise { +async function writePlugin( + root: string, + name: string, + dsh: Record, + prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, +): Promise { const directory = join(root, '.dsh-plugin') await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', - scripts: { prepack: RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND }, + scripts: { prepack }, dsh, }, undefined, 2)}\n`) return directory @@ -82,6 +87,24 @@ describe('dsh-plugin-prepare', () => { .resolves.toContain('mcp.expo.dev') }) + it('preserves a compiled package entry and accepts a build before the host prepare command', async () => { + const root = await temporaryDirectory('compiled-entry') + const directory = await writePlugin(root, 'compiled-entry-fixture', { + entry: './lib/plugin.mjs', + }, 'npm run build && dsh-plugin-prepare') + await mkdir(join(directory, 'lib')) + await writeFile(join(directory, 'lib/plugin.mjs'), 'export default { name: "compiled-entry" }\n') + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ + name: 'compiled-entry-fixture', + skills: [], + entry: './lib/plugin.mjs', + }) + const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') + expect(wrapper).toContain('await import(manifest.entry)') + expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') + }) + it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { const root = await temporaryDirectory('oauth') await writeFile(join(root, '.mcp.json'), JSON.stringify({ @@ -118,9 +141,18 @@ describe('dsh-plugin-prepare', () => { })) await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack') + const skippedPrepareRoot = await temporaryDirectory('skipped-prepare') + const skippedPrepare = await writePlugin( + skippedPrepareRoot, + 'skipped-prepare', + { skills: ['../skills'] }, + 'npm run build', + ) + await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare') + const emptyRoot = await temporaryDirectory('empty-metadata') const empty = await writePlugin(emptyRoot, 'empty', {}) - await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file') + await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root, mcpServers file, or compiled entry') const missingRoot = await temporaryDirectory('missing-asset') const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) @@ -149,16 +181,21 @@ describe('dsh-plugin-prepare', () => { await writeSkill(outside, 'outside-skill') const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] }) await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root') + + const escapedEntryRoot = await temporaryDirectory('escaped-entry') + await writeFile(join(escapedEntryRoot, 'outside.mjs'), 'export default {}\n') + const escapedEntry = await writePlugin(escapedEntryRoot, 'escaped-entry', { entry: '../outside.mjs' }) + await expect(RepositoryPlugin.prepareDshPlugin(escapedEntry)).rejects.toThrow('escapes its plugin source root') }) - it('validates prepared wrapper configs with and without MCP assets', () => { + it('validates prepared wrapper configs with optional MCP assets and code entries', () => { expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin') expect(parsePreparedPluginConfig({ baseUrl: 'file:///plugin/dsh-plugin.mjs', - manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, })).toEqual({ baseUrl: 'file:///plugin/dsh-plugin.mjs', - manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, }) }) }) @@ -195,6 +232,35 @@ describe('prepared repository plugin Loader composition', () => { await ctx.fiber.dispose() }) + it('mounts and removes the repository package code entry through the real Loader', async () => { + const root = await temporaryDirectory('code-loader') + const directory = await writePlugin(root, 'code-loader-fixture', { entry: './lib/plugin.mjs' }) + await mkdir(join(directory, 'lib')) + await writeFile(join(directory, 'lib/plugin.mjs'), [ + "export const name = 'repository-code-proof'", + 'export function apply(ctx) {', + " ctx.provide('repositoryCodeProof', { source: 'compiled-entry' })", + '}', + '', + ].join('\n')) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(RepositoryPlugin) + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + const getService = (name: string): unknown => (ctx as unknown as { get(name: string): unknown }).get(name) + expect(getService('repositoryCodeProof')).toEqual({ source: 'compiled-entry' }) + + await ctx.loader.remove(id) + expect(getService('repositoryCodeProof')).toBeUndefined() + await ctx.fiber.dispose() + }) + it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => { const root = await temporaryDirectory('mcp-loader') await writeFile(join(root, '.mcp.json'), JSON.stringify({ @@ -484,7 +550,23 @@ describe('configured GitHub repository sources', () => { await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) .rejects.toMatchObject({ cause: expect.objectContaining({ - message: expect.stringContaining('must declare scripts.prepack') as string, + message: expect.stringContaining('must declare a non-empty scripts.prepack') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + + it('rejects an installed source whose prepack omits the host prepare command', async () => { + const root = await temporaryDirectory('installed-skipped-prepare') + await writeFile(join(root, 'package.json'), JSON.stringify({ + name: 'installed-skipped-prepare', + scripts: { prepack: 'npm run build' }, + })) + const ctx = new Context() + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#unprepared&path:/.dsh-plugin')) + .rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining('must invoke dsh-plugin-prepare') as string, }) as Error, }) await ctx.fiber.dispose() From a7832cbfbfca3de1dd914d1d064ace39eb67eceb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:59:48 +0800 Subject: [PATCH 08/20] fix(repository-cache): isolate Git package dependencies --- ...it-repository-plugin-preparation.i18n.yaml | 4 +- ...owned-git-repository-plugin-preparation.md | 9 ++- ...ed-git-repository-plugin-preparation.zh.md | 9 ++- .../app-boot/tests/repository-cache.spec.ts | 29 +++++++- .../repository-plugin/README.i18n.yaml | 4 +- .../repository-plugin/README.md | 4 +- .../repository-plugin/README.zh.md | 4 +- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 70 +++++++++++++------ 9 files changed, 98 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml index d3f10b5273..189c3dc5c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md -2026-08-08-host-owned-git-repository-plugin-preparation.md: b459184f802981f91f611b0b781651cede0c9efd -2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: 5e24efa59ce44ffa8214f9c5ef9ff722ac0ca73a +2026-08-08-host-owned-git-repository-plugin-preparation.md: 3f92c0b2d782735bfb548711bb13a7a8d03a3824 +2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: ef34c99b5d9f31ea13c5fddfee77c30e640d25c9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md index b459184f80..3f92c0b2d7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md @@ -10,13 +10,15 @@ The repository Plugin authoring contract depended on `scripts.prepare: "dsh-plug The lifecycle choice also failed for a selectable `.dsh-plugin` inside a pnpm workspace. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` joins the containing workspace and need not execute the unlisted `.dsh-plugin` package's `prepare` script. The install could therefore succeed and publish a cache generation containing only the source package metadata; real DSH startup failed later because `dsh-plugin.mjs` did not exist. +The same workspace discovery could suppress package-owned dependencies after the move to `prepack`. When the source repository carried a root pnpm lockfile but did not list the selected `.dsh-plugin` as a workspace importer, pnpm reported a successful workspace install without installing dependencies declared only by that package. Its TypeScript build then failed because Cordis and the MCP SDK were absent. + The checked-in headless fixture did not catch either defect because it mounted an already prepared wrapper. It proved runtime composition, not GitHub acquisition or package preparation. ## Decision The authoring format requires a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` and needs no DSH dependency for that helper. The package may declare its own build and runtime dependencies and run compilation before the helper. pnpm's Git-hosted package preparation invokes `prepack` explicitly after its dependency-install step and before packlist selects the `.dsh-plugin` subtree, so the helper can validate built entries and still copy sibling repository assets such as `../skills` into the package. -`@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. The command directory exists only for the installation transaction and is removed on success or failure. The repository remains trusted package-manager input: DSH supplies one command, but other lifecycle scripts and dependencies still execute under the existing trust contract. +`@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. It also prepends a transaction-owned `pnpm` wrapper: the outer install still runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes that same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile. Both command directories are removed after the child settles. The repository remains trusted package-manager input: DSH supplies the two host commands, but all package lifecycle scripts and dependencies still execute under the existing trust contract. The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Because that repository is private, the workflow writes a job-scoped Git configuration that uses the read-only job token for GitHub HTTPS and rewrites pnpm's SSH fallback to that authenticated transport. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true` GitHub fixture. That fixture installs pinned npm dependencies, type-checks and bundles a TypeScript Cordis entry and MCP server in `prepack`, invokes the host helper, and proves the skill, MCP call, and code entry through real model requests and immutable-cache artifacts. The test fails if CI omits the exact source instead of silently skipping. @@ -30,14 +32,17 @@ The Node 24 consumer lane passes an exact source derived from the pull request h **Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. +**Add the in-repository CI fixture to this repository's pnpm workspace.** Rejected because that would repair only the proof fixture and leave an arbitrary selected package vulnerable to its containing repository's workspace membership and lockfile. + ## Consequences - A repository author can commit the fixed `.dsh-plugin/package.json` and source assets to GitHub without publishing either the Plugin or its preparation helper to npm. - Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. - `prepack`, not `prepare`, is part of the pre-release authoring format. It may contain package-owned build steps but must invoke the host helper; missing or empty lifecycle metadata fails installed-package validation instead of producing an ambiguous partial format. +- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It must declare its dependencies and cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs. - Exact source strings still identify immutable cache generations; a changed ref or source configuration selects another generation. - The host supplies only the preparation executable. Package dependencies, compilation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md). ## Testing -`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a local Git subpath through bundled pnpm with an injected command directory and proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and temporary command cleanup. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. +`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires a relative `file:` build dependency during `prepack`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and temporary command cleanup. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md index 5e24efa59c..ef34c99b5d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md @@ -10,13 +10,15 @@ repository 插件的创作契约依赖 `scripts.prepare: "dsh-plugin-prepare"` 这种生命周期选择也无法支持 pnpm 工作区内可选的 `.dsh-plugin`。pnpm 会先运行 Git 托管仓库首选的包管理器,再打包选定的子目录,从而准备 Git 托管包。嵌套执行的 `pnpm install` 会加入外层工作区,而不一定执行未列入其中的 `.dsh-plugin` 包的 `prepare` 脚本。因此,安装可能成功并发布一个仅包含源包元数据的缓存 generation;随后真实 DSH 启动因 `dsh-plugin.mjs` 不存在而失败。 +迁移到 `prepack` 后,同一项 workspace 发现行为还可能抑制包自有依赖。如果源仓库带有根 pnpm lockfile,却未把所选 `.dsh-plugin` 列为 workspace importer,pnpm 会报告 workspace 安装成功,但不会安装仅由该包声明的依赖。随后其 TypeScript 构建会因缺少 Cordis 和 MCP SDK 而失败。 + 签入仓库的 headless fixture(测试前置数据)没有捕获任一缺陷,因为它挂载的是已准备好的包装层。它证明的是运行时组合,而不是 GitHub 获取或包准备。 ## 决策 创作格式要求 `scripts.prepack` 非空且调用 `dsh-plugin-prepare`,使用该辅助程序无需 DSH 依赖。包可以声明自己的构建依赖与运行时依赖,并在调用辅助程序前完成编译。pnpm 针对 Git 托管包的准备流程会在依赖安装步骤之后、打包清单选择 `.dsh-plugin` 子树之前显式调用 `prepack`,因此辅助程序可以校验构建入口,并继续把 `../skills` 等同仓库的相邻资源复制进包内。 -`@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。该命令目录仅存在于安装事务期间,无论成功还是失败都会被移除。仓库仍是受信任的包管理器输入:DSH 仅提供这一条命令,其他生命周期脚本和依赖仍会按既有信任契约执行。 +`@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。它还会前置一个由安装事务持有的 `pnpm` 包装命令:外层安装仍直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会通过 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行拥有依赖解析。两个命令目录都会在子进程结算后移除。仓库仍是受信任的包管理器输入:DSH 提供这两条宿主命令,但所有包生命周期脚本和依赖仍按既有信任契约执行。 Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA 派生的精确源。由于该仓库为私有仓库,工作流会写入一份作业作用域的 Git 配置,使用该作业的只读 token 对 GitHub HTTPS 连接进行认证,并将 pnpm 的 SSH 回退路径重写为这一已认证的传输方式。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true` 的 GitHub fixture。该 fixture 安装固定版本的 NPM 依赖,在 `prepack` 中对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,调用宿主辅助程序,并通过真实模型请求和不可变缓存产物验证 skill(技能)、MCP 调用和代码入口。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。 @@ -30,14 +32,17 @@ Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA **在 DSH 中克隆 GitHub 仓库,并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定版本的包管理器负责的 ref 解析、子目录选择、依赖安装、打包清单行为和缓存完整性。 +**把仓库内的 CI fixture 加入本仓库的 pnpm workspace。** 拒绝,因为这只能修复证明用的 fixture,任意所选包仍会受其所在仓库的 workspace membership 与 lockfile 影响。 + ## 后果 - 仓库作者可以把修复后的 `.dsh-plugin/package.json` 和源资源提交到 GitHub,而无需把插件或其准备辅助程序发布到 NPM。 - 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 - 预发布创作格式使用 `prepack` 而不是 `prepare`。其中可以包含包自有构建步骤,但必须调用宿主辅助程序;生命周期元数据缺失或为空会在已安装包校验时失败,而不会留下状态不明的半成品格式。 +- pnpm 仓库中的所选包按自身 manifest 安装,而不是按外层 workspace 安装。它必须声明自己的依赖,不能依赖仅由 workspace 提升而可见的包;常规 registry 依赖与相对 `file:` 依赖仍是包自有输入。 - 精确源字符串仍标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 - 宿主只提供准备阶段可执行文件。包依赖、编译和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。 ## 测试 -`packages/ui/app-boot/tests/repository-cache.spec.ts` 会用注入的命令目录通过随附 pnpm 运行本地 Git 子路径,并证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据和临时命令清理行为。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建产物的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及对已准备缓存的检查。 +`packages/ui/app-boot/tests/repository-cache.spec.ts` 会让一个被源仓库根 pnpm lockfile 排除的包通过本地 Git 子路径运行,并要求 `prepack` 使用相对 `file:` 构建依赖;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据和临时命令清理行为。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建产物的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及对已准备缓存的检查。 diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 186cbe1b43..290b9ab2cc 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -112,7 +112,7 @@ describe('RepositoryCache', () => { await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') }) - it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => { + it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') const executableDirectory = join(root, 'bin') @@ -132,16 +132,40 @@ describe('RepositoryCache', () => { '', ].join('\r\n')) await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) + await mkdir(join(repository, 'build-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', + private: true, version: '1.0.0', + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, })}\n`) + await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n') + await writeFile(join(repository, 'pnpm-lock.yaml'), [ + "lockfileVersion: '9.0'", + 'settings:', + ' autoInstallPeers: true', + ' excludeLinksFromLockfile: false', + 'importers:', + ' .: {}', + '', + ].join('\n')) + await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({ + name: 'repository-build-helper', + version: '1.0.0', + bin: 'index.js', + })}\n`) + await writeFile(join(repository, 'build-helper', 'index.js'), [ + '#!/usr/bin/env node', + "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", + '', + ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', - scripts: { prepack: 'dsh-plugin-prepare' }, + scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, + devDependencies: { 'repository-build-helper': 'file:../build-helper' }, dsh: { skills: ['../skills'] }, })}\n`) await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) @@ -159,6 +183,7 @@ describe('RepositoryCache', () => { const installed = await new RepositoryCache(join(root, 'cache'), { executableDirectories: [executableDirectory], }).resolve(specifier) + await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index 7d7c04cf69..d41f4681a1 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 52d04f16684842749e57d1b47db0a95beb22558c -README.zh.md: 921bf70b8a2e78410510d1efce79ced5f52b4641 +README.md: 1d858c5adcc208768dcbe99f129b47abb1722431 +README.zh.md: c2d0e6d72baeb0efb25abd8d50fee2922d2e810b diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 52d04f1668..1d858c5adc 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -32,7 +32,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: } ``` -`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. DSH does not transpile TypeScript or infer a package entry. +`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry. `dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. @@ -59,7 +59,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v ## Preparation -During exact Git installation, DSH places a temporary host-owned `dsh-plugin-prepare` command on the isolated package lifecycle `PATH`; the command is not fetched from npm. The required `prepack` lifecycle runs after the Git package's dependency installation and before its selected subdirectory is packed, including when `.dsh-plugin` sits inside another package-manager workspace. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to build or prepare fails installation before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). +During exact Git installation, DSH places temporary host-owned `pnpm` and `dsh-plugin-prepare` commands on the isolated package lifecycle `PATH`; neither command is fetched from the repository. The pnpm command reinvokes DSH's pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to install, build, or prepare fails before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). ## Runtime composition diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index 921bf70b8a..c2d0e6d72b 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -32,7 +32,7 @@ } ``` -`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖和其他 NPM 生命周期代码。DSH 不转译 TypeScript,也不推断包入口。 +`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖和其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace,因此必须声明所需的每项依赖,不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript,也不推断包入口。 `dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export,并自行拥有常规的 `name`、`inject`、`Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 @@ -59,7 +59,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 ## 准备阶段 -安装精确指定的 Git 源时,DSH 会把一个临时的宿主自有 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;该命令不从 NPM 获取。必需的 `prepack` 生命周期在 Git 包完成依赖安装后、选定子目录打包前运行,即使 `.dsh-plugin` 位于另一个包管理器工作区内也不例外。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。构建或准备失败时,安装会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 +安装精确指定的 Git 源时,DSH 会把临时的宿主自有 `pnpm` 与 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;两个命令都不从 repository 获取。该 pnpm 命令会以 `--ignore-workspace` 重新调用 DSH 锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。安装、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 ## 运行时组合 diff --git a/vendor/README.md b/vendor/README.md index 3cc2a313d2..be55f8e77a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. Callers may prepend host-owned executable directories to the isolated package lifecycle `PATH`; all paths are resolved before the child starts. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` run through the bundled pnpm and an injected command directory. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. Callers may prepend host-owned executable directories to the isolated package lifecycle `PATH`; all paths are resolved before the child starts. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. Temporary command directories are removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and requires its own build dependency. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index c0cd1589aa..91ad9c372a 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' /** Exact pnpm release shipped with the Loader for repository installation. */ @@ -48,6 +49,14 @@ function installEnvironment(executableDirectories: readonly string[]): NodeJS.Pr } } +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function batchQuote(value: string): string { + return `"${value.replaceAll('%', '%%')}"` +} + function appendOutput(current: string, chunk: Uint8Array): string { const combined = current + Buffer.from(chunk).toString('utf8') return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) @@ -60,29 +69,46 @@ async function installWithBundledPnpm( const require = createRequire(import.meta.url) const pnpmManifest = require.resolve('pnpm') const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') - let output = '' - const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - const child = spawn(process.execPath, [ - pnpmBin, - 'install', - '--no-frozen-lockfile', - '--reporter=append-only', - ], { - cwd: directory, - env: installEnvironment(executableDirectories), - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], + const commandDirectory = await mkdtemp(join(tmpdir(), 'cordis-repository-pnpm-')) + try { + await Promise.all([ + writeFile(join(commandDirectory, 'pnpm'), [ + '#!/bin/sh', + `exec ${shellQuote(process.execPath)} ${shellQuote(pnpmBin)} --ignore-workspace "$@"`, + '', + ].join('\n'), { mode: 0o700 }), + writeFile(join(commandDirectory, 'pnpm.cmd'), [ + '@echo off', + `${batchQuote(process.execPath)} ${batchQuote(pnpmBin)} --ignore-workspace %*`, + '', + ].join('\r\n'), { mode: 0o700 }), + ]) + let output = '' + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const child = spawn(process.execPath, [ + pnpmBin, + 'install', + '--no-frozen-lockfile', + '--reporter=append-only', + ], { + cwd: directory, + env: installEnvironment([commandDirectory, ...executableDirectories]), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.once('error', reject) + child.once('close', (code, signal) => { resolve({ code, signal }) }) }) - child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) - child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) - child.once('error', reject) - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) - if (result.signal !== null) { - throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) - } - if (result.code !== 0) { - throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) + if (result.signal !== null) { + throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) + } + if (result.code !== 0) { + throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) + } + } finally { + await rm(commandDirectory, { recursive: true, force: true }) } } From c750d5a9087532ebb8b207dff266794ed7e7c992 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:12:38 +0800 Subject: [PATCH 09/20] test(repository-plugin): resolve published Cordis types --- .../github-repository-plugin/.dsh-plugin/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json index 632e4db48c..22d9301cfb 100644 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2024", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "noEmit": true From 67b9e9b1d6eef219864c8cfa1cfca9c56935daa5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:29:21 +0800 Subject: [PATCH 10/20] test(repository-plugin): isolate agent mock sequence --- apps/cli/tests/github-repository-plugin.built.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index 0c13a71a02..2e1ea4a08c 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -35,6 +35,8 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => ' config:', ' repositories:', ` - ${JSON.stringify(source)}`, + '- id: session-title-llm', + ' disabled: true', '', ].join('\n')) From 913ecf5f1dbd7dfabb6ec49754882e2c8e114d6e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:13:20 +0800 Subject: [PATCH 11/20] fix(mcp-client): await Cordis startup discovery --- ...-trusted-repository-package-code.i18n.yaml | 4 +-- ...6-08-08-trusted-repository-package-code.md | 6 ++-- ...8-08-trusted-repository-package-code.zh.md | 6 ++-- .../github-repository-plugin.built.e2e.ts | 11 +++--- docs/config-catalog.md | 6 +++- packages/mcp/mcp-client/README.i18n.yaml | 4 +-- packages/mcp/mcp-client/README.md | 3 +- packages/mcp/mcp-client/README.zh.md | 3 +- packages/mcp/mcp-client/src/index.ts | 31 ++++++++++------ packages/mcp/mcp-client/tests/apply.spec.ts | 36 ++++++++++++++++++- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 8 ++++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 5 +++ .../repository-plugin/README.i18n.yaml | 4 +-- .../repository-plugin/README.md | 2 +- .../repository-plugin/README.zh.md | 2 +- .../repository-plugin/src/mcp.ts | 4 +++ .../tests/mcp-format.spec.ts | 4 +++ .../tests/repository-plugin.spec.ts | 8 ++--- 18 files changed, 108 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml index eeea186c92..74c4f0eb41 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: 4968d29a6da4180a8b016eef9176acb9bc64d8b1 -2026-08-08-trusted-repository-package-code.zh.md: 2e9f59bb1f571bb1bace9623a9e6976e0238a017 +2026-08-08-trusted-repository-package-code.md: cf72853836901af9c1c9e9f0d3d23f43997a4e97 +2026-08-08-trusted-repository-package-code.zh.md: cea47d85973a81f904b1596051ac58996459c635 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md index 4968d29a6d..cf72853836 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md @@ -18,7 +18,7 @@ The package owns its npm dependencies and build toolchain. `scripts.prepack` is The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. -`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Initial connection failure keeps the existing contained failure contract: it is logged, the client activates with no tools, and disposal still closes the transport. +`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection or discovery failure rejects the candidate generation and rollback still closes the transport. ## Trust boundary @@ -41,11 +41,11 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent - A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm. - Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import. - A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration. -- The initial MCP connection can lengthen application startup, while a contained connection failure still yields a running application with no tools from that server. +- The initial MCP connection can lengthen application startup, and a repository-declared server that is unavailable prevents that candidate generation from activating. - Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening. ## Testing -Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication while preserving contained connect failure and teardown. +Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client. The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. That repository package installs pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Cache assertions require source files to be absent from the packed installation while both built modules, their installed dependency, copied assets, and generated wrapper are present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md index 2e9f59bb1f..cea47d8597 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md @@ -18,7 +18,7 @@ 生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 -`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。初始连接失败沿用既有的收束失败契约:系统会记录日志,client 激活但不注册工具,dispose 仍会关闭 transport。 +`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接或发现失败会拒绝候选 generation,回滚仍会关闭 transport。 ## 信任边界 @@ -41,11 +41,11 @@ - TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。 - 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。 - 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。 -- 初始 MCP 连接可能延长应用启动时间;连接失败被收束后,仍会得到一个正常运行、但不含该 server 工具的应用。 +- 初始 MCP 连接可能延长应用启动时间;repository 声明的 server 不可用时,该候选 generation 无法激活。 - Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。 ## 测试 -repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,同时保留收束连接失败与清理覆盖。 +repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。 Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。该 repository 包安装固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。缓存断言要求打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index 2e1ea4a08c..fb0bf2937a 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -67,15 +67,16 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') expect(server.requests).toHaveLength(2) + const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}` const firstRequest = JSON.stringify(server.requests[0]!.body) const secondRequest = JSON.stringify(server.requests[1]!.body) - expect(firstRequest).toContain( + expect(firstRequest, runtimeDiagnostic).toContain( 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', ) - expect(firstRequest).toContain('mcp__github_repository__proof') - expect(firstRequest).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') - expect(secondRequest).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(secondRequest).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') + expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof') + expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') + expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY') + expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') const cacheRoot = join(home, 'cache', 'repository-plugins') const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 153ac72cc3..9cc95bcaf5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -988,6 +988,8 @@ export interface StdioConfig { cwd: string /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number + /** Fail plugin activation when the initial connection or tool discovery fails. */ + failOnStartupError: boolean } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -1006,10 +1008,12 @@ export interface StreamableHttpConfig { headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number + /** Fail plugin activation when the initial connection or tool discovery fails. */ + failOnStartupError: boolean } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 6b6d4d9270..2d47705a0a 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: 6bcc195e36d24d7e5ae3462573b30f1b41c963a7 -README.zh.md: 8886c16c3fe66d283ae2191661118729a89f1aeb +README.md: c87255917a2def0aef938af9f2c910b65d5a96d5 +README.zh.md: 6fd6df39d7c5034795021d41de57f8500cbfd1c5 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 6bcc195e36..c87255917a 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -44,6 +44,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same | `url` | http | yes | MCP server URL | | `headers` | http | no | Extra headers (e.g. auth tokens) | | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | +| `failOnStartupError` | both | no | Reject plugin activation when the initial connection or tool discovery fails (default `false`) | ## Tool naming @@ -56,7 +57,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` ## Behavior -- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection failure is logged and activates with no tools. +- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection or discovery failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools. - Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 8886c16c3f..6fd6df39d7 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -44,6 +44,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | `url` | http | 是 | MCP 服务器 URL | | `headers` | http | 否 | 额外标头(例如认证 token) | | `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) | +| `failOnStartupError` | 两者 | 否 | 初始连接或工具发现失败时拒绝插件激活(默认 `false`) | ## 工具命名 @@ -56,7 +57,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc ## 行为 -- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接失败会记录日志,插件仍会激活但不注册工具。 +- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接或发现失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。 - 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。 - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 9798b4cfcb..348003be52 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -72,6 +72,8 @@ export interface StdioConfig { cwd: string /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number + /** Fail plugin activation when the initial connection or tool discovery fails. */ + failOnStartupError: boolean } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -90,6 +92,8 @@ export interface StreamableHttpConfig { headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number + /** Fail plugin activation when the initial connection or tool discovery fails. */ + failOnStartupError: boolean } /** Configuration for one stdio or Streamable HTTP MCP server. */ @@ -104,6 +108,7 @@ export const Config = z.union([ env: z.dict(String).default({}), cwd: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + failOnStartupError: z.boolean().default(false), }), z.object({ transport: z.const('streamable-http'), @@ -111,6 +116,7 @@ export const Config = z.union([ url: z.string().required(), headers: z.dict(String).default({}), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + failOnStartupError: z.boolean().default(false), }), ]) as unknown as z @@ -118,11 +124,13 @@ export const Config = z.union([ /** * Connect one MCP server and publish its initial tool generation before activation. + * This entry remains explicitly `async`: Cordis treats a prototype-bearing + * ordinary function as a constructor, whose returned Promise is not startup work. * @param ctx - plugin context carrying the tool registry. * @param config - resolved transport and server namespace configuration. * @returns startup readiness after connection and initial tool discovery settle. */ -export function apply(ctx: Context, config: Config): Promise { +export async function apply(ctx: Context, config: Config): Promise { // Reserve the namespace first: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { @@ -151,10 +159,10 @@ export function apply(ctx: Context, config: Config): Promise { toolCallTimeoutMs: config.toolCallTimeoutMs, } - // Connect and set up tools. Errors during connect/first sync are logged, - // not thrown (the plugin simply has no tools registered). `ready` resolves - // to an accessor for the CURRENT disposer generation, so the effect - // disposer below always unregisters the live set, not the first one. + // Connect and set up tools. `ready` always settles to an outcome so rollback + // can close a partially opened client even when strict startup later rejects. + // Its accessor returns the CURRENT disposer generation, so disposal always + // unregisters the live set, not the first one. const ready = (async () => { await client.connect(transport) @@ -174,17 +182,20 @@ export function apply(ctx: Context, config: Config): Promise { }, ) - return () => disposers + return { getDisposers: () => disposers } })().catch((error: unknown) => { ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) - return () => new Map void>() + return { getDisposers: () => new Map void>(), error } }) ctx.effect(() => async () => { - const live = await ready - for (const dispose of live().values()) dispose() + const outcome = await ready + for (const dispose of outcome.getDisposers().values()) dispose() try { await client.close() } catch { /* transport already gone */ } }, 'mcp-client.connection') - return ready.then(() => undefined) + const outcome = await ready + if ('error' in outcome && config.failOnStartupError) { + throw new Error(`mcp-client(${config.serverName}): initial connection or tool discovery failed`, { cause: outcome.error }) + } } diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 6bb97a7032..b2444341f1 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -82,6 +82,7 @@ const stdioConfig: Config = { env: {}, cwd: '', toolCallTimeoutMs: 60_000, + failOnStartupError: false, } // ---- Tests ---- @@ -150,11 +151,30 @@ describe('apply (plugin lifecycle)', () => { expect(ctx.tools.get('remote')).toBeUndefined() }) + it('keeps the Cordis plugin loading until initial discovery publishes its tools', async () => { + const connection: PromiseWithResolvers = Promise.withResolvers() + mockConnect.mockImplementation(async () => { + await connection.promise + }) + const fiber = ctx.plugin({ name: 'mcp-client-lifecycle', inject, apply }, stdioConfig) + let activated = false + const activation = Promise.resolve(fiber).then(() => { activated = true }) + + await vi.waitFor(() => { expect(mockConnect).toHaveBeenCalled() }) + expect(activated).toBe(false) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + + connection.resolve() + await activation + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + await fiber.dispose() + }) + it('rejects a duplicate serverName at load and leaves the first instance intact', async () => { await apply(ctx, stdioConfig) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() - expect(() => { void apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) + await expect(apply(ctx, stdioConfig)).rejects.toThrow(/serverName "srv" is already in use/) // First instance unaffected. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) @@ -204,6 +224,19 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('rejects activation and still closes the client when startup failure is configured as fatal', async () => { + mockConnect.mockRejectedValue(new Error('connection refused')) + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + })).rejects.toThrow('initial connection or tool discovery failed') + + expect(mockListTools).not.toHaveBeenCalled() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + await ctx.fiber.dispose() + expect(mockClose).toHaveBeenCalled() + }) + it('re-syncs tools on ToolListChanged notification', async () => { await apply(ctx, stdioConfig) @@ -275,6 +308,7 @@ describe('apply (plugin lifecycle)', () => { url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer x' }, toolCallTimeoutMs: 30_000, + failOnStartupError: false, } await apply(ctx, httpConfig) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 6a71ab4007..e1f51d20e9 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -75,6 +75,7 @@ describe('fixture server — controlled scenarios', () => { env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, + failOnStartupError: false, } beforeAll(async () => { @@ -164,10 +165,11 @@ describe('fixture server — duplicate serverName', () => { env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, + failOnStartupError: false, } await apply(ctx, config) - expect(() => { void apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) + await expect(apply(ctx, config)).rejects.toThrow(/serverName "dup" is already in use/) await ctx.fiber.dispose() await sleep(200) @@ -185,6 +187,7 @@ describe('fixture server — disposal', () => { env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, + failOnStartupError: false, }) // Tools are registered before dispose. @@ -210,6 +213,7 @@ describe('server-everything — official test server', () => { env: {}, cwd: '', toolCallTimeoutMs: 30_000, + failOnStartupError: false, } beforeAll(async () => { @@ -277,6 +281,7 @@ describe('server-filesystem — real filesystem operations', () => { env: {}, cwd: '', toolCallTimeoutMs: 30_000, + failOnStartupError: false, } await apply(ctx, config) }, 60_000) @@ -393,6 +398,7 @@ describe('streamable-http — in-process MCP server', () => { url: baseUrl, headers: { Authorization: 'Bearer e2e-test-token' }, toolCallTimeoutMs: 15_000, + failOnStartupError: false, } await apply(ctx, config) }, 30_000) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index f70f5aa6c1..b95251e7d3 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -713,6 +713,7 @@ describe('createTransport', () => { env: {}, cwd: '/tmp', toolCallTimeoutMs: 60_000, + failOnStartupError: false, } const transport = createTransport(config) expect(transport).toBeDefined() @@ -727,6 +728,7 @@ describe('createTransport', () => { url: 'http://localhost:3000/mcp', headers: {}, toolCallTimeoutMs: 60_000, + failOnStartupError: false, } const transport = createTransport(config) expect(transport).toBeDefined() @@ -741,6 +743,7 @@ describe('createTransport', () => { url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer token' }, toolCallTimeoutMs: 60_000, + failOnStartupError: false, } const transport = createTransport(config) expect(transport).toBeDefined() @@ -764,6 +767,7 @@ describe('createTransport', () => { env: { EXTRA: 'injected' }, cwd: '', toolCallTimeoutMs: 60_000, + failOnStartupError: false, } // createTransport internally calls buildChildEnv; we verify by inspecting // the constructed StdioClientTransport. Since we can't inspect private fields @@ -791,6 +795,7 @@ describe('createTransport', () => { env: { CUSTOM: 'value' }, cwd: '', toolCallTimeoutMs: 60_000, + failOnStartupError: false, } const transport = createTransport(config) expect(transport).toBeDefined() diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index d41f4681a1..5c4ad1e929 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 1d858c5adcc208768dcbe99f129b47abb1722431 -README.zh.md: c2d0e6d72baeb0efb25abd8d50fee2922d2e810b +README.md: e10907408a98cd470ae935b327ae44322f2e54a7 +README.zh.md: b8ba7dccd929574cc5aa1b7a3d68bf4f1e2f80e4 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 1d858c5adc..e10907408a 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -69,7 +69,7 @@ Loading this package registers one effect-scoped Loader builtin. Each generated The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. -Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation; a network or child-process connection failure is logged and still activates with no tools. +Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation, while a network, child-process, or discovery failure rejects the candidate repository generation instead of silently activating without its declared tools. ## Export shape diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index c2d0e6d72b..b8ba7dccd9 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -69,7 +69,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 `.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 -未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络或子进程连接失败会记录日志,且插件仍会激活但不注册工具。 +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络、子进程或发现失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。 ## 导出形状 diff --git a/packages/self-modification/repository-plugin/src/mcp.ts b/packages/self-modification/repository-plugin/src/mcp.ts index bce8179121..6c1c5ccbf3 100644 --- a/packages/self-modification/repository-plugin/src/mcp.ts +++ b/packages/self-modification/repository-plugin/src/mcp.ts @@ -49,12 +49,14 @@ export type ResolvedMcpServer = args: string[] env: Record cwd: string + failOnStartupError: true } | { transport: 'streamable-http' serverName: string url: string headers: Record + failOnStartupError: true } function assertTemplate(value: string, location: string): void { @@ -135,6 +137,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), cwd, + failOnStartupError: true, } } const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) @@ -147,6 +150,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro serverName, url, headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), + failOnStartupError: true, } }) } diff --git a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts index 5251ccdbc2..709c1a2c4b 100644 --- a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts +++ b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts @@ -22,6 +22,7 @@ describe('repository plugin common .mcp.json support', () => { serverName: 'expo', url: 'https://mcp.expo.dev/mcp', headers: {}, + failOnStartupError: true, }]) }) @@ -43,6 +44,7 @@ describe('repository plugin common .mcp.json support', () => { args: ['--endpoint', 'http://localhost:8000'], env: { DJ_API_URL: 'http://localhost:8000' }, cwd: '/plugin', + failOnStartupError: true, }]) }) @@ -74,12 +76,14 @@ describe('repository plugin common .mcp.json support', () => { args: [], env: {}, cwd: '/plugin', + failOnStartupError: true, }, { transport: 'streamable-http', serverName: 'remote', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer test-token' }, + failOnStartupError: true, }, ]) }) diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 06cacfd699..40ae773e55 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -261,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => { await ctx.fiber.dispose() }) - it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => { + it('fails an MCP repository plugin load when its declared server cannot connect', async () => { const root = await temporaryDirectory('mcp-loader') await writeFile(join(root, '.mcp.json'), JSON.stringify({ mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, @@ -275,12 +275,10 @@ describe('prepared repository plugin Loader composition', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(RepositoryPlugin) - const id = await ctx.loader.create({ + await expect(ctx.loader.create({ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - }) - await ctx.loader.await() + })).rejects.toThrow('initial connection or tool discovery failed') expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) - await ctx.loader.remove(id) await ctx.fiber.dispose() }) From e91ff6d4995d9ce93ca1446d9d501130c3fcdf9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:19:04 +0800 Subject: [PATCH 12/20] test(repository-plugin): cover MCP activation cleanup --- .../tests/repository-plugin.spec.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 40ae773e55..26b739677a 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -261,6 +261,60 @@ describe('prepared repository plugin Loader composition', () => { await ctx.fiber.dispose() }) + it('mounts and removes tools discovered from a repository MCP server', async () => { + const root = await temporaryDirectory('mcp-loader-success') + const server = join(root, 'mcp-server.mjs') + await writeFile(server, [ + "import { createInterface } from 'node:readline'", + 'const lines = createInterface({ input: process.stdin })', + 'for await (const line of lines) {', + ' const request = JSON.parse(line)', + " if (!('id' in request)) continue", + ' let result', + " if (request.method === 'initialize') {", + ' result = {', + ' protocolVersion: request.params.protocolVersion,', + ' capabilities: { tools: {} },', + " serverInfo: { name: 'repository-fixture', version: '0.0.0' },", + ' }', + " } else if (request.method === 'tools/list') {", + ' result = {', + ' tools: [{', + " name: 'proof',", + " description: 'Repository MCP proof.',", + " inputSchema: { type: 'object', properties: {} },", + ' }],', + ' }', + ' } else {', + ' result = {}', + ' }', + " process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`)", + '}', + '', + ].join('\n')) + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { online: { command: process.execPath, args: [server] } }, + })) + const directory = await writePlugin(root, 'mcp-loader-success-fixture', { mcpServers: '../.mcp.json' }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RepositoryPlugin) + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + expect(ctx.tools.get('mcp__online__proof')).toBeDefined() + + await ctx.loader.remove(id) + expect(ctx.tools.get('mcp__online__proof')).toBeUndefined() + await ctx.fiber.dispose() + }) + it('fails an MCP repository plugin load when its declared server cannot connect', async () => { const root = await temporaryDirectory('mcp-loader') await writeFile(join(root, '.mcp.json'), JSON.stringify({ From 29b3e3fa84cdd6a0c2a20ec7615af50eec265301 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:16:14 +0800 Subject: [PATCH 13/20] fix(repository-plugin): require published prepare dependency --- ...-static-repository-plugin-format.i18n.yaml | 4 +- ...6-07-30-static-repository-plugin-format.md | 2 +- ...7-30-static-repository-plugin-format.zh.md | 2 +- ...-trusted-repository-package-code.i18n.yaml | 4 +- ...6-08-08-trusted-repository-package-code.md | 4 +- ...8-08-trusted-repository-package-code.zh.md | 4 +- ...owned-git-repository-plugin-preparation.md | 48 ------- ...ed-git-repository-plugin-preparation.zh.md | 48 ------- ...t-repository-plugin-preparation.i18n.yaml} | 6 +- ...acked-git-repository-plugin-preparation.md | 48 +++++++ ...ed-git-repository-plugin-preparation.zh.md | 48 +++++++ .../.dsh-plugin/package.json | 1 + .../github-repository-plugin.built.e2e.ts | 134 +++++++++++++++++- docs/config-catalog.md | 2 +- knip.json | 3 + .../app-boot/tests/repository-cache.spec.ts | 40 +++--- .../repository-plugin/README.i18n.yaml | 4 +- .../repository-plugin/README.md | 5 +- .../repository-plugin/README.zh.md | 5 +- .../repository-plugin/package.json | 20 +++ .../repository-plugin/src/format.ts | 4 +- .../repository-plugin/src/index.ts | 32 ++--- .../repository-plugin/src/source.ts | 55 +------ .../tests/repository-plugin.spec.ts | 18 +-- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 19 +-- 26 files changed, 319 insertions(+), 243 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md rename .agents/notes/implemented/bug-fix/{2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml => 2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml} (54%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 8ffa06ab1a..33e7540bbc 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: 615529c89d32ed87c73d100b632318500e1ae86c -2026-07-30-static-repository-plugin-format.zh.md: bb90994defccdaf2044b467cb3c5018c1c94641a +2026-07-30-static-repository-plugin-format.md: afc88bb974ccc6ef7843eaf1e98f16b65bb6d4b3 +2026-07-30-static-repository-plugin-format.zh.md: a6dc6ec6ec89c04b79edea279029ae33091ca0af diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index 615529c89d..afc88bb974 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native- `@deepseek-ai/dsh-repository-plugin` owns the static contribution subformat inside a `.dsh-plugin` package: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. The package may additionally declare the explicit code entry owned by the [trusted repository package decision](2026-08-08-trusted-repository-package-code.md), and at least one code or static contribution is required. -The `.dsh-plugin` package declares a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` without depending on a DSH npm package. During Git installation, the standalone runtime temporarily supplies that command from its own build on the isolated lifecycle `PATH`; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The host-owned command rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). +The `.dsh-plugin` package declares the published `@deepseek-ai/dsh-repository-plugin` package as a development dependency and a non-empty `scripts.prepack` that invokes its `dsh-plugin-prepare` executable. During Git installation, pnpm installs that dependency from the selected package's own manifest; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The dependency and workspace-isolation rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index bb90994def..a6dc6ec6ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -14,7 +14,7 @@ `@deepseek-ai/dsh-repository-plugin` 负责 `.dsh-plugin` 包内的静态贡献子格式:skill 根和一个通用 `.mcp.json`。其包元数据使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径。每条路径都可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的插件可以拥有其包上方相邻的子树,却不能访问无关宿主路径。该包还可以声明由[受信任 repository 包决策](2026-08-08-trusted-repository-package-code.md)负责的显式代码入口,并且至少需要一种代码或静态贡献。 -`.dsh-plugin` 包声明非空 `scripts.prepack`,调用 `dsh-plugin-prepare` 且不依赖 DSH NPM 包。在 Git 安装期间,独立运行时会从自身构建产物中临时提供该命令,并将其放入隔离的生命周期 `PATH`;`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest(元数据清单)、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。宿主自有命令的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 +`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 包声明为开发依赖,并声明非空 `scripts.prepack` 来调用其 `dsh-plugin-prepare` 可执行文件。在 Git 安装期间,pnpm 会按所选包自身的 manifest(元数据清单)安装该依赖;`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。依赖与 workspace 隔离的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml index 74c4f0eb41..16b72afb4e 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: cf72853836901af9c1c9e9f0d3d23f43997a4e97 -2026-08-08-trusted-repository-package-code.zh.md: cea47d85973a81f904b1596051ac58996459c635 +2026-08-08-trusted-repository-package-code.md: 6c568964c2d4392054107acb1e73ace8f50d5f0b +2026-08-08-trusted-repository-package-code.zh.md: c2587286379398ab853b71c8b8819d274d06c65b diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md index cf72853836..6c568964c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md @@ -14,7 +14,7 @@ A repository author also needs to keep an ordinary TypeScript npm package shape. A configured repository package is trusted code. Its `.dsh-plugin/package.json` may declare `dsh.entry` as a relative path to a compiled ESM Cordis Plugin inside that package, alongside or instead of `dsh.skills` and `dsh.mcpServers`. At least one contribution is required. The entry may use namespace exports or a default export and retains ordinary Cordis semantics for `name`, `inject`, `Config`, registrations, startup failure, and effect-scoped teardown. -The package owns its npm dependencies and build toolchain. `scripts.prepack` is a non-empty package-authored command that must invoke the host-supplied `dsh-plugin-prepare`, but it may first run `tsc`, `tsdown`, or any other build. DSH neither parses the shell program nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; missing wrapper or build outputs fail before a cache generation becomes usable. +The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable. The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. @@ -48,4 +48,4 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client. -The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. That repository package installs pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Cache assertions require source files to be absent from the packed installation while both built modules, their installed dependency, copied assets, and generated wrapper are present. +The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md index cea47d8597..c258728637 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md @@ -14,7 +14,7 @@ 已配置的 repository 包是受信任代码。其 `.dsh-plugin/package.json` 可以连同 `dsh.skills` 和 `dsh.mcpServers` 声明 `dsh.entry`,也可以用它取代二者;`dsh.entry` 是指向该包内已编译 ESM Cordis 插件的相对路径。至少需要一种贡献。入口可以使用 namespace 导出或 default export,并沿用 Cordis 对 `name`、`inject`、`Config`、注册、启动失败和 effect 作用域清理的常规语义。 -包自行负责其 NPM 依赖和构建工具链。`scripts.prepack` 是由包作者编写的非空命令,必须调用宿主提供的 `dsh-plugin-prepare`,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 既不解析该 shell 程序,也不编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;包装层或构建输出缺失会在缓存 generation 可用前导致失败。 +包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序或编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。 生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 @@ -48,4 +48,4 @@ repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。 -Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。该 repository 包安装固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。缓存断言要求打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 +Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball,并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md deleted file mode 100644 index 3f92c0b2d7..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: Host-owned preparation makes GitHub repository Plugins installable - -Status: implemented - -English | [中文](2026-08-08-host-owned-git-repository-plugin-preparation.zh.md) - -## Problem - -The repository Plugin authoring contract depended on `scripts.prepare: "dsh-plugin-prepare"` and told source repositories to add `@deepseek-ai/dsh-repository-plugin` as a development dependency. That package is private and not published to npm, so an otherwise valid external GitHub repository could not obtain the helper in a clean install. - -The lifecycle choice also failed for a selectable `.dsh-plugin` inside a pnpm workspace. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` joins the containing workspace and need not execute the unlisted `.dsh-plugin` package's `prepare` script. The install could therefore succeed and publish a cache generation containing only the source package metadata; real DSH startup failed later because `dsh-plugin.mjs` did not exist. - -The same workspace discovery could suppress package-owned dependencies after the move to `prepack`. When the source repository carried a root pnpm lockfile but did not list the selected `.dsh-plugin` as a workspace importer, pnpm reported a successful workspace install without installing dependencies declared only by that package. Its TypeScript build then failed because Cordis and the MCP SDK were absent. - -The checked-in headless fixture did not catch either defect because it mounted an already prepared wrapper. It proved runtime composition, not GitHub acquisition or package preparation. - -## Decision - -The authoring format requires a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` and needs no DSH dependency for that helper. The package may declare its own build and runtime dependencies and run compilation before the helper. pnpm's Git-hosted package preparation invokes `prepack` explicitly after its dependency-install step and before packlist selects the `.dsh-plugin` subtree, so the helper can validate built entries and still copy sibling repository assets such as `../skills` into the package. - -`@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. It also prepends a transaction-owned `pnpm` wrapper: the outer install still runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes that same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile. Both command directories are removed after the child settles. The repository remains trusted package-manager input: DSH supplies the two host commands, but all package lifecycle scripts and dependencies still execute under the existing trust contract. - -The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Because that repository is private, the workflow writes a job-scoped Git configuration that uses the read-only job token for GitHub HTTPS and rewrites pnpm's SSH fallback to that authenticated transport. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true` GitHub fixture. That fixture installs pinned npm dependencies, type-checks and bundles a TypeScript Cordis entry and MCP server in `prepack`, invokes the host helper, and proves the skill, MCP call, and code entry through real model requests and immutable-cache artifacts. The test fails if CI omits the exact source instead of silently skipping. - -## Alternatives considered - -**Publish the prepare helper to npm.** Rejected because the source package would acquire a release/version dependency solely to call code already owned by the running DSH installation, and the existing helper is intentionally private. - -**Keep `prepare` and only inject the command.** Rejected because command availability does not make a nested package's `prepare` lifecycle run when the Git repository's package manager treats it as part of another workspace. - -**Prepare after RepositoryCache installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist. - -**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. - -**Add the in-repository CI fixture to this repository's pnpm workspace.** Rejected because that would repair only the proof fixture and leave an arbitrary selected package vulnerable to its containing repository's workspace membership and lockfile. - -## Consequences - -- A repository author can commit the fixed `.dsh-plugin/package.json` and source assets to GitHub without publishing either the Plugin or its preparation helper to npm. -- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. -- `prepack`, not `prepare`, is part of the pre-release authoring format. It may contain package-owned build steps but must invoke the host helper; missing or empty lifecycle metadata fails installed-package validation instead of producing an ambiguous partial format. -- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It must declare its dependencies and cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs. -- Exact source strings still identify immutable cache generations; a changed ref or source configuration selects another generation. -- The host supplies only the preparation executable. Package dependencies, compilation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md). - -## Testing - -`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires a relative `file:` build dependency during `prepack`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and temporary command cleanup. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md deleted file mode 100644 index ef34c99b5d..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.zh.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: 宿主自有的准备机制使 GitHub repository 插件可安装 - -状态:已实现 - -[English](2026-08-08-host-owned-git-repository-plugin-preparation.md) | 中文 - -## 问题 - -repository 插件的创作契约依赖 `scripts.prepare: "dsh-plugin-prepare"`,并要求源码仓库将 `@deepseek-ai/dsh-repository-plugin` 添加为开发依赖。该包是私有包,且未发布到 NPM,因此即使外部 GitHub 仓库符合其他要求,也无法在全新安装中取得该辅助程序。 - -这种生命周期选择也无法支持 pnpm 工作区内可选的 `.dsh-plugin`。pnpm 会先运行 Git 托管仓库首选的包管理器,再打包选定的子目录,从而准备 Git 托管包。嵌套执行的 `pnpm install` 会加入外层工作区,而不一定执行未列入其中的 `.dsh-plugin` 包的 `prepare` 脚本。因此,安装可能成功并发布一个仅包含源包元数据的缓存 generation;随后真实 DSH 启动因 `dsh-plugin.mjs` 不存在而失败。 - -迁移到 `prepack` 后,同一项 workspace 发现行为还可能抑制包自有依赖。如果源仓库带有根 pnpm lockfile,却未把所选 `.dsh-plugin` 列为 workspace importer,pnpm 会报告 workspace 安装成功,但不会安装仅由该包声明的依赖。随后其 TypeScript 构建会因缺少 Cordis 和 MCP SDK 而失败。 - -签入仓库的 headless fixture(测试前置数据)没有捕获任一缺陷,因为它挂载的是已准备好的包装层。它证明的是运行时组合,而不是 GitHub 获取或包准备。 - -## 决策 - -创作格式要求 `scripts.prepack` 非空且调用 `dsh-plugin-prepare`,使用该辅助程序无需 DSH 依赖。包可以声明自己的构建依赖与运行时依赖,并在调用辅助程序前完成编译。pnpm 针对 Git 托管包的准备流程会在依赖安装步骤之后、打包清单选择 `.dsh-plugin` 子树之前显式调用 `prepack`,因此辅助程序可以校验构建入口,并继续把 `../skills` 等同仓库的相邻资源复制进包内。 - -`@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。它还会前置一个由安装事务持有的 `pnpm` 包装命令:外层安装仍直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会通过 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行拥有依赖解析。两个命令目录都会在子进程结算后移除。仓库仍是受信任的包管理器输入:DSH 提供这两条宿主命令,但所有包生命周期脚本和依赖仍按既有信任契约执行。 - -Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库和 SHA 派生的精确源。由于该仓库为私有仓库,工作流会写入一份作业作用域的 Git 配置,使用该作业的只读 token 对 GitHub HTTPS 连接进行认证,并将 pnpm 的 SSH 回退路径重写为这一已认证的传输方式。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true` 的 GitHub fixture。该 fixture 安装固定版本的 NPM 依赖,在 `prepack` 中对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,调用宿主辅助程序,并通过真实模型请求和不可变缓存产物验证 skill(技能)、MCP 调用和代码入口。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。 - -## 考虑过的替代方案 - -**把准备辅助程序发布到 NPM。** 拒绝,因为源包会仅为了调用当前运行的 DSH 安装本就拥有的代码,而增加一个需要发布和管理版本的依赖;现有辅助程序又有意保持私有。 - -**保留 `prepare`,只注入命令。** 拒绝,因为当 Git 仓库的包管理器把嵌套包当作另一工作区的一部分时,即使命令可用,也不会使嵌套包的 `prepare` 生命周期得以运行。 - -**在 RepositoryCache 安装选定包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成打包清单前完成。 - -**在 DSH 中克隆 GitHub 仓库,并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定版本的包管理器负责的 ref 解析、子目录选择、依赖安装、打包清单行为和缓存完整性。 - -**把仓库内的 CI fixture 加入本仓库的 pnpm workspace。** 拒绝,因为这只能修复证明用的 fixture,任意所选包仍会受其所在仓库的 workspace membership 与 lockfile 影响。 - -## 后果 - -- 仓库作者可以把修复后的 `.dsh-plugin/package.json` 和源资源提交到 GitHub,而无需把插件或其准备辅助程序发布到 NPM。 -- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 -- 预发布创作格式使用 `prepack` 而不是 `prepare`。其中可以包含包自有构建步骤,但必须调用宿主辅助程序;生命周期元数据缺失或为空会在已安装包校验时失败,而不会留下状态不明的半成品格式。 -- pnpm 仓库中的所选包按自身 manifest 安装,而不是按外层 workspace 安装。它必须声明自己的依赖,不能依赖仅由 workspace 提升而可见的包;常规 registry 依赖与相对 `file:` 依赖仍是包自有输入。 -- 精确源字符串仍标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 -- 宿主只提供准备阶段可执行文件。包依赖、编译和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。 - -## 测试 - -`packages/ui/app-boot/tests/repository-cache.spec.ts` 会让一个被源仓库根 pnpm lockfile 排除的包通过本地 Git 子路径运行,并要求 `prepack` 使用相对 `file:` 构建依赖;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据和临时命令清理行为。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建产物的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及对已准备缓存的检查。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml similarity index 54% rename from .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml index 189c3dc5c2..e1966105a3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md -2026-08-08-host-owned-git-repository-plugin-preparation.md: 3f92c0b2d782735bfb548711bb13a7a8d03a3824 -2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: ef34c99b5d9f31ea13c5fddfee77c30e640d25c9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md +2026-08-08-npm-backed-git-repository-plugin-preparation.md: 9437043d007f5331f36f1c4cff1fe0a3e768b060 +2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: 1f06f7372dc0d948906ec4441cb161d2d2ed69e5 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md new file mode 100644 index 0000000000..9437043d00 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md @@ -0,0 +1,48 @@ +# Agent Note: npm-backed preparation makes GitHub repository Plugins self-contained + +Status: implemented + +English | [中文](2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md) + +## Problem + +The repository Plugin authoring contract requires `scripts.prepack` to invoke `dsh-plugin-prepare`. Supplying that executable from the running DSH installation made a source package appear valid even when its own manifest could not obtain the helper. It therefore did not prove the behavior users need after `@deepseek-ai/dsh-repository-plugin` is published: an ordinary Git-hosted npm package must be installable and preparable from only its declared dependencies. + +A selectable `.dsh-plugin` inside a pnpm workspace has a second isolation requirement. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` can join the containing workspace; when the root lockfile does not list `.dsh-plugin` as an importer, pnpm can report success without installing dependencies declared only by that package. Its TypeScript build or prepare command then fails, or a pre-generated artifact hides the missing dependency. + +The checked-in headless fixture mounts an already prepared wrapper. It proves runtime composition, not GitHub acquisition, npm resolution, or package-owned preparation. + +## Decision + +The `.dsh-plugin` package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency and invokes its published `dsh-plugin-prepare` executable from `scripts.prepack`. The package may declare any other build and runtime dependencies and run arbitrary compilation before the helper. The repository Plugin package marks its Cordis and DSH peers optional so a helper-only development install resolves only the helper's actual `zod` runtime dependency; an application composition still supplies the peers used by the package's Cordis entry. + +DSH does not materialize or prepend a prepare executable. `RepositoryCache` supplies only a transaction-owned `pnpm` wrapper: the outer install runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes the same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile, and normal package-manager lifecycle `PATH` construction exposes `node_modules/.bin/dsh-plugin-prepare`. The temporary pnpm wrapper disappears after the child settles. The repository remains trusted package-manager input: all dependency and lifecycle code executes under the existing trust contract. + +The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. It uses the existing private DeepSeek Harness repository rather than creating another repository per run. A job-scoped Git configuration gives the read-only job token access to that exact private source and rewrites pnpm's SSH fallback to authenticated HTTPS. + +The built-entry acceptance also creates an in-process npm registry. It stages the current built `@deepseek-ai/dsh-repository-plugin` as a publication artifact by removing `private`, replacing workspace protocols with the release version, and packing the declared files. The registry serves the resulting packument and tarball, while a job-local npm config directs only the `@deepseek-ai` scope to it. The real built `dsh run` child then fetches the exact Git source; that package resolves the helper through npm, type-checks and bundles a TypeScript Cordis entry and MCP server, prepares the adjacent skill, and loads all three contributions. A deliberately failing host `PATH` command proves the lifecycle selected the dependency-local executable. The acceptance also requires registry resolution and inspects the immutable prepared cache, so restoring a host-injected helper cannot satisfy it. + +## Alternatives considered + +**Inject `dsh-plugin-prepare` from the running DSH installation.** Rejected because it lets an incomplete repository manifest pass and tests a host-only path that npm consumers cannot reproduce. + +**Publish the source fixture itself to npm.** Rejected because the product contract is specifically that the DSH Plugin remains Git-hosted; only the reusable preparation helper is an npm dependency. + +**Create a new private GitHub repository in every CI run.** Rejected because the pull request repository at its exact head SHA is already a real authenticated private Git remote. Per-run repository mutation would add credentials, cleanup, and eventual-consistency failure modes without changing the acquisition path. + +**Prepare after `RepositoryCache` installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist. + +**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. + +## Consequences + +- A repository author can commit a `.dsh-plugin` package, TypeScript source, skills, and MCP definitions to GitHub without publishing that Plugin package to npm. The package must declare the published preparation dependency. +- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. +- `prepack`, not `prepare`, is part of the authoring format. It may contain arbitrary package-owned build steps but must invoke the dependency-provided helper; missing dependency or lifecycle metadata fails before a cache generation is usable. +- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs. +- Exact source strings identify immutable cache generations; a changed ref or source configuration selects another generation. +- Package dependencies, compilation, preparation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md). + +## Testing + +`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md new file mode 100644 index 0000000000..1f06f7372d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 基于 NPM 的准备机制使 GitHub repository 插件自包含 + +状态:已实现 + +[English](2026-08-08-npm-backed-git-repository-plugin-preparation.md) | 中文 + +## 问题 + +repository 插件创作契约要求 `scripts.prepack` 调用 `dsh-plugin-prepare`。如果由正在运行的 DSH 安装提供该可执行文件,即使源包自身的 manifest(元数据清单)无法取得辅助程序,它也会显得有效。因此,这并未证明 `@deepseek-ai/dsh-repository-plugin` 发布后用户所需的行为:普通 Git 托管 NPM 包必须只依靠自身声明的依赖即可安装和准备。 + +pnpm workspace 内可选择的 `.dsh-plugin` 还有另一项隔离要求。pnpm 会在打包所选子目录前运行仓库首选的包管理器,以准备 Git 托管包。嵌套的 `pnpm install` 可能加入外层 workspace;当根 lockfile 未把 `.dsh-plugin` 列为 importer 时,pnpm 可能报告成功,却未安装仅由该包声明的依赖。随后,其 TypeScript 构建或准备命令会失败;也可能因为存在预生成产物,依赖缺失被掩盖。 + +签入仓库的 headless fixture(测试前置数据)挂载的是已准备好的包装层。它证明运行时组合,而不证明 GitHub 获取、NPM 解析或包自有准备。 + +## 决策 + +`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,并在 `scripts.prepack` 中调用其已发布的 `dsh-plugin-prepare` 可执行文件。该包可以声明其他任意构建依赖与运行时依赖,并在辅助程序前执行任意编译。repository 插件包把 Cordis 与 DSH 对等依赖(peer dependency)标为可选,因此仅为使用辅助程序而进行的开发安装只会解析辅助程序实际依赖的 `zod` 运行时依赖;应用组合仍会提供该包 Cordis 入口所使用的对等依赖。 + +DSH 不会生成准备阶段可执行文件,也不会将其前置到 `PATH`。`RepositoryCache` 只提供一个由事务持有的 `pnpm` 包装脚本:外层安装直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会以 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行负责依赖解析,正常的包管理器生命周期 `PATH` 构造会暴露 `node_modules/.bin/dsh-plugin-prepare`。临时 pnpm 包装脚本会在子进程结算后消失。repository 仍是受信任的包管理器输入:所有依赖与生命周期代码都按既有信任契约执行。 + +Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库与 SHA 派生的精确源。它复用现有私有 DeepSeek Harness 仓库,而不会为每次运行新建仓库。作业作用域的 Git 配置允许只读作业 token 访问该精确私有源,并把 pnpm 的 SSH 回退改写为已认证 HTTPS。 + +构建入口验收还会创建一个进程内 NPM 注册表。它通过移除 `private`、将 workspace protocol 替换为发布版本并打包声明的文件,把当前已构建的 `@deepseek-ai/dsh-repository-plugin` 暂存为发布产物。注册表会提供由此生成的 `packument` 与 tarball,作业本地 NPM 配置则只把 `@deepseek-ai` scope 指向它。实际构建的 `dsh run` 子进程随后获取精确 Git 源;该包通过 NPM 解析辅助程序,对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,准备相邻的 skill(技能),并加载全部三类贡献。一个刻意设为失败的宿主 `PATH` 命令可以证明,该生命周期选中的是依赖内的可执行文件。验收还要求经过注册表解析并检查不可变的已准备缓存,因此恢复宿主注入的辅助程序也无法通过。 + +## 考虑过的替代方案 + +**从正在运行的 DSH 安装注入 `dsh-plugin-prepare`。** 拒绝,因为这会让 manifest 不完整的 repository 包通过,并测试 NPM 消费方无法复现的纯宿主路径。 + +**把源 fixture 本身发布到 NPM。** 拒绝,因为产品契约明确要求 DSH 插件仍托管在 Git;只有可复用的准备辅助程序是 NPM 依赖。 + +**在每次 CI 运行中创建新的私有 GitHub 仓库。** 拒绝,因为 PR 仓库的精确 head SHA 已是经过认证的真实私有 Git remote。每次运行的仓库变更会增加凭据、清理和最终一致性失败模式,却不改变获取路径。 + +**在 `RepositoryCache` 安装所选包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成 packlist 前完成。 + +**在 DSH 中克隆 GitHub 仓库并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定包管理器负责的 ref 解析、子目录选择、依赖安装、packlist 行为和缓存完整性。 + +## 后果 + +- 仓库作者可以把 `.dsh-plugin` 包、TypeScript 源码、skill 与 MCP 定义提交到 GitHub,而无需把该插件包发布到 NPM。该包必须声明已发布的准备依赖。 +- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 +- 创作格式使用 `prepack` 而不是 `prepare`。其中可以包含任意包自有构建步骤,但必须调用依赖提供的辅助程序;依赖或生命周期元数据缺失时,会在缓存 generation 可用前失败。 +- pnpm 仓库中的所选包按自身 manifest 安装,而不继承外层 workspace。它不能依赖仅由 workspace 提升而可见的包;普通注册表依赖和相对 `file:` 依赖仍是包自有输入。 +- 精确源字符串标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 +- 包依赖、编译、准备和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。 + +## 测试 + +`packages/ui/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及已准备缓存检查。 diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json index b11ea67c41..6871a4d053 100644 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json +++ b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json @@ -22,6 +22,7 @@ "@modelcontextprotocol/sdk": "1.29.0" }, "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "0.0.1", "cordis": "4.0.0-rc.7", "tsdown": "0.22.2", "typescript": "6.0.3" diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index fb0bf2937a..e1386e2bfe 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -1,7 +1,9 @@ -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { execa } from 'execa' @@ -9,12 +11,121 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') +const repositoryPluginPackage = join(repoRoot, 'packages/cordis/repository-plugin') +const releasePackageNames = new Set(globSync([ + 'vendor/*/package.json', + 'packages/*/*/package.json', + 'apps/*/package.json', +], { cwd: repoRoot }).map((filename) => { + const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record + if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`) + return manifest.name +})) const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1' const enabled = required || source !== undefined +interface PublishedPackageRegistry { + url: string + requests: string[] + close(): Promise +} + +function publishedManifest(): Record { + const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record + const version = manifest.version + if (typeof version !== 'string') throw new Error('repository Plugin package version is missing') + Reflect.deleteProperty(manifest, 'private') + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const dependencies = manifest[field] + if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue + const entries = dependencies as Record + for (const name of Object.keys(entries)) { + if (releasePackageNames.has(name)) { + entries[name] = version + } + } + } + return manifest +} + +async function startPublishedPackageRegistry(root: string): Promise { + const staging = join(root, 'published-repository-plugin') + const artifacts = join(root, 'npm-registry-artifacts') + mkdirSync(staging) + mkdirSync(artifacts) + cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true }) + for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) { + cpSync(join(repositoryPluginPackage, filename), join(staging, filename)) + } + cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE')) + const manifest = publishedManifest() + writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) + const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], { + cwd: staging, + reject: false, + }) + if (packed.exitCode !== 0) { + throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`) + } + const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz')) + if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`) + const tarball = readFileSync(join(artifacts, tarballs[0]!)) + const name = manifest.name as string + const version = manifest.version as string + const requests: string[] = [] + let registryUrl = '' + const server = createServer((request, response) => { + const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname) + requests.push(`${request.method ?? 'GET'} ${path}`) + if (path === `/${name}`) { + const metadata = { + name, + 'dist-tags': { latest: version }, + versions: { + [version]: { + ...manifest, + dist: { + tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`, + shasum: createHash('sha1').update(tarball).digest('hex'), + integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + }, + }, + }, + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify(metadata)) + return + } + if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) { + response.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': String(tarball.length), + }) + response.end(tarball) + return + } + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'not found' })) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address') + registryUrl = `http://127.0.0.1:${address.port}/` + return { + url: registryUrl, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }), + } +} + describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => { - it('installs, builds, and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => { + it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => { expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true) expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch( /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u, @@ -29,6 +140,17 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => successText: 'trusted GitHub repository package reached dsh run', }) const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-')) + const registry = await startPublishedPackageRegistry(home) + const npmrc = join(home, 'npmrc') + writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`) + const hostBin = join(home, 'host-bin') + mkdirSync(hostBin) + writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [ + '#!/bin/sh', + 'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2', + 'exit 91', + '', + ].join('\n'), { mode: 0o700 }) const patch = join(home, 'github-repository-plugin.cordis.patch.yml') writeFileSync(patch, [ '- id: repository-plugins', @@ -59,6 +181,8 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, + NPM_CONFIG_USERCONFIG: npmrc, + PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`, }, }) if (result.timedOut) { @@ -68,6 +192,8 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') expect(server.requests).toHaveLength(2) const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}` + expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin') + expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz') const firstRequest = JSON.stringify(server.requests[0]!.body) const secondRequest = JSON.stringify(server.requests[1]!.body) expect(firstRequest, runtimeDiagnostic).toContain( @@ -98,6 +224,7 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => '@modelcontextprotocol/sdk': '1.29.0', }, devDependencies: { + '@deepseek-ai/dsh-repository-plugin': '0.0.1', cordis: '4.0.0-rc.7', tsdown: '0.22.2', typescript: '6.0.3', @@ -117,6 +244,7 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') } finally { await server.close() + await registry.close() rmSync(home, { recursive: true, force: true }) } }, 190_000) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9cc95bcaf5..82f8534ad2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1186,7 +1186,7 @@ export interface Config { } ``` -Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) +Source: [`packages/self-modification/repository-plugin/src/index.ts:43`](../packages/self-modification/repository-plugin/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` diff --git a/knip.json b/knip.json index 0757fe49f8..9ca15de212 100644 --- a/knip.json +++ b/knip.json @@ -710,6 +710,9 @@ "project": [ "src/**/*.ts" ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-repository-plugin" + ], "ignoreBinaries": [ "dsh-plugin-prepare" ] diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 290b9ab2cc..9689790ffc 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -115,24 +115,9 @@ describe('RepositoryCache', () => { it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') - const executableDirectory = join(root, 'bin') - await mkdir(executableDirectory) - await writeFile(join(executableDirectory, 'dsh-plugin-prepare'), [ - '#!/usr/bin/env node', - "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", - "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", - "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", - "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", - "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", - '', - ].join('\n'), { mode: 0o700 }) - await writeFile(join(executableDirectory, 'dsh-plugin-prepare.cmd'), [ - '@echo off', - 'node "%~dp0\\dsh-plugin-prepare" %*', - '', - ].join('\r\n')) await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) await mkdir(join(repository, 'build-helper'), { recursive: true }) + await mkdir(join(repository, 'prepare-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', @@ -160,12 +145,29 @@ describe('RepositoryCache', () => { "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", '', ].join('\n'), { mode: 0o700 }) + await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({ + name: 'repository-prepare-helper', + version: '1.0.0', + bin: { 'dsh-plugin-prepare': 'index.js' }, + })}\n`) + await writeFile(join(repository, 'prepare-helper', 'index.js'), [ + '#!/usr/bin/env node', + "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", + "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", + "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", + "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", + "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + '', + ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ name: 'repository-plugin-fixture', version: '1.0.0', scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' }, - devDependencies: { 'repository-build-helper': 'file:../build-helper' }, + devDependencies: { + 'repository-build-helper': 'file:../build-helper', + 'repository-prepare-helper': 'file:../prepare-helper', + }, dsh: { skills: ['../skills'] }, })}\n`) await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) @@ -180,9 +182,7 @@ describe('RepositoryCache', () => { vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') - const installed = await new RepositoryCache(join(root, 'cache'), { - executableDirectories: [executableDirectory], - }).resolve(specifier) + const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index 5c4ad1e929..770cf0fab0 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: e10907408a98cd470ae935b327ae44322f2e54a7 -README.zh.md: b8ba7dccd929574cc5aa1b7a3d68bf4f1e2f80e4 +README.md: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4 +README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index e10907408a..3a6478d5cc 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -27,12 +27,13 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: "@modelcontextprotocol/sdk": "1.29.0" }, "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1", "typescript": "6.0.3" } } ``` -`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry. +`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry. `dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. @@ -59,7 +60,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v ## Preparation -During exact Git installation, DSH places temporary host-owned `pnpm` and `dsh-plugin-prepare` commands on the isolated package lifecycle `PATH`; neither command is fetched from the repository. The pnpm command reinvokes DSH's pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to install, build, or prepare fails before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md). +During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared `@deepseek-ai/dsh-repository-plugin` dependency. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). ## Runtime composition diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index b8ba7dccd9..274d11c96c 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -27,12 +27,13 @@ "@modelcontextprotocol/sdk": "1.29.0" }, "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1", "typescript": "6.0.3" } } ``` -`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖和其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace,因此必须声明所需的每项依赖,不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript,也不推断包入口。 +`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序:repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace,因此必须声明所需的每项依赖,不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript,也不推断包入口。 `dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export,并自行拥有常规的 `name`、`inject`、`Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 @@ -59,7 +60,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 ## 准备阶段 -安装精确指定的 Git 源时,DSH 会把临时的宿主自有 `pnpm` 与 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;两个命令都不从 repository 获取。该 pnpm 命令会以 `--ignore-workspace` 重新调用 DSH 锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。安装、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。 +安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从已声明的 `@deepseek-ai/dsh-repository-plugin` 依赖取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 ## 运行时组合 diff --git a/packages/self-modification/repository-plugin/package.json b/packages/self-modification/repository-plugin/package.json index fbf18c0cb1..f20ec986c2 100644 --- a/packages/self-modification/repository-plugin/package.json +++ b/packages/self-modification/repository-plugin/package.json @@ -36,6 +36,26 @@ "@deepseek-ai/dsh-skill-local": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@cordisjs/plugin-loader": { + "optional": true + }, + "@deepseek-ai/dsh-invariants": { + "optional": true + }, + "@deepseek-ai/dsh-mcp-client": { + "optional": true + }, + "@deepseek-ai/dsh-paths": { + "optional": true + }, + "@deepseek-ai/dsh-skill-local": { + "optional": true + }, + "cordis": { + "optional": true + } + }, "dependencies": { "zod": "^4.4.3" }, diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts index 63ba724108..852e57b253 100644 --- a/packages/self-modification/repository-plugin/src/format.ts +++ b/packages/self-modification/repository-plugin/src/format.ts @@ -14,11 +14,11 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' /** Loader builtin used by every generated repository wrapper. */ export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' -/** Host-owned command that repository package `prepack` lifecycles must invoke. */ +/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */ export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' /** - * Whether a package lifecycle declaration names the host preparation helper. + * Whether a package lifecycle declaration names the preparation dependency's helper. * @param script - package-authored lifecycle command. * @returns true when the required helper command is present. */ diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 403c2d1ae9..8026199c16 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -20,7 +20,6 @@ import { } from './format.ts' import { parseMcpDocument, resolveMcpServers } from './mcp.ts' import { - createRepositoryPrepareCommand, loadPreparedRepository, resolveRepositoryCacheDirectory, resolveRepositorySpecifier, @@ -131,24 +130,17 @@ export async function apply(ctx: Context, config: Config = {}): Promise { if (new Set(repositories).size !== repositories.length) { throw new Error('repository sources must resolve to unique exact specifiers') } - const prepareCommand = repositories.length === 0 ? undefined : await createRepositoryPrepareCommand() - try { - const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir), { - executableDirectories: prepareCommand === undefined ? [] : [prepareCommand.directory], - }) - await ctx.effect(async function* () { - ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime - yield () => { - if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { - Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) - } + const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir)) + await ctx.effect(async function* () { + ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime + yield () => { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { + Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) } - for (const repository of repositories) { - const plugin = await loadPreparedRepository(ctx, cache, repository) - yield plugin.dispose - } - }, 'repository-plugin runtime and sources') - } finally { - await prepareCommand?.dispose() - } + } + for (const repository of repositories) { + const plugin = await loadPreparedRepository(ctx, cache, repository) + yield plugin.dispose + } + }, 'repository-plugin runtime and sources') } diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts index 51f015e68a..a85bb0467e 100644 --- a/packages/self-modification/repository-plugin/src/source.ts +++ b/packages/self-modification/repository-plugin/src/source.ts @@ -3,10 +3,9 @@ * @module */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile } from 'node:fs/promises' import { join, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { pathToFileURL } from 'node:url' import type { Context, Fiber, FiberState, Plugin } from 'cordis' import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -24,56 +23,6 @@ const FIBER_ACTIVE = 2 as FiberState.ACTIVE /** Directory under the Harness home containing immutable repository generations. */ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' -/** Temporary host command supplied to repository package lifecycle scripts. */ -export interface RepositoryPrepareCommand { - /** Absolute directory to prepend to the isolated install's executable search path. */ - directory: string - /** Remove the temporary command directory. */ - dispose(): Promise -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function batchQuote(value: string): string { - return `"${value.replaceAll('%', '%%')}"` -} - -/** - * Materialize the DSH-owned prepare executable used only while pnpm packs Git source. - * @returns a command directory and its idempotent cleanup operation. - */ -export async function createRepositoryPrepareCommand(): Promise { - const directory = await mkdtemp(join(tmpdir(), 'dsh-repository-plugin-bin-')) - const target = fileURLToPath(new URL('../lib/bin.js', import.meta.url)) - try { - await Promise.all([ - writeFile(join(directory, REPOSITORY_PLUGIN_PREPARE_COMMAND), [ - '#!/bin/sh', - `exec ${shellQuote(process.execPath)} ${shellQuote(target)} "$@"`, - '', - ].join('\n'), { mode: 0o700 }), - writeFile(join(directory, `${REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), [ - '@echo off', - `${batchQuote(process.execPath)} ${batchQuote(target)} %*`, - '', - ].join('\r\n'), { mode: 0o700 }), - ]) - } catch (cause) { - /* v8 ignore next -- requires a host filesystem failure after mkdtemp; cleanup semantics are the contract under test. */ - await rm(directory, { recursive: true, force: true }) - /* v8 ignore next -- preserves that unstageable host failure after best-effort cleanup. */ - throw cause - } - return { - directory, - async dispose() { - await rm(directory, { recursive: true, force: true }) - }, - } -} - // The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config // parser, with the syntax the error message promises — instead of inside the // cache's pnpm install ('misconfiguration fails loud at the earliest diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 26b739677a..b5abf6fbdd 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -14,7 +14,6 @@ import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' import { parsePreparedPluginConfig } from '../src/format.ts' import { - createRepositoryPrepareCommand, loadPreparedRepository, resolveRepositoryCacheDirectory, resolveRepositorySpecifier, @@ -87,7 +86,7 @@ describe('dsh-plugin-prepare', () => { .resolves.toContain('mcp.expo.dev') }) - it('preserves a compiled package entry and accepts a build before the host prepare command', async () => { + it('preserves a compiled package entry and accepts a build before the package prepare command', async () => { const root = await temporaryDirectory('compiled-entry') const directory = await writePlugin(root, 'compiled-entry-fixture', { entry: './lib/plugin.mjs', @@ -406,17 +405,6 @@ describe('prepared repository plugin Loader composition', () => { }) describe('configured GitHub repository sources', () => { - it('creates host-owned prepare commands and removes them idempotently', async () => { - const command = await createRepositoryPrepareCommand() - expect(await readFile(join(command.directory, RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND), 'utf8')) - .toContain(process.execPath) - expect(await readFile(join(command.directory, `${RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), 'utf8')) - .toContain(process.execPath) - await command.dispose() - await command.dispose() - await expect(stat(command.directory)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - it('defaults an omitted source list and rejects unknown configuration fields', () => { expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) @@ -608,7 +596,7 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) - it('rejects an installed source whose prepack omits the host prepare command', async () => { + it('rejects an installed source whose prepack omits the package prepare command', async () => { const root = await temporaryDirectory('installed-skipped-prepare') await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'installed-skipped-prepare', diff --git a/vendor/README.md b/vendor/README.md index be55f8e77a..684c2c6363 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. Callers may prepend host-owned executable directories to the isolated package lifecycle `PATH`; all paths are resolved before the child starts. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. Temporary command directories are removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and requires its own build dependency. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index 91ad9c372a..5cb6ec387b 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -26,8 +26,6 @@ export type RepositoryInstall = (directory: string) => Promise export interface RepositoryCacheOptions { /** Override the isolated package installation boundary. */ install?: RepositoryInstall - /** Command directories resolved absolutely and prepended to package lifecycle `PATH`. */ - executableDirectories?: readonly string[] } interface CacheMarker { @@ -38,14 +36,13 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) } -function installEnvironment(executableDirectories: readonly string[]): NodeJS.ProcessEnv { +function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() - if (executableDirectories.length === 0) return scrubbed const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] const withoutPath = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => name.toUpperCase() !== 'PATH')) return { ...withoutPath, - PATH: [...executableDirectories, ...(path === undefined ? [] : [path])].join(delimiter), + PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), } } @@ -62,10 +59,7 @@ function appendOutput(current: string, chunk: Uint8Array): string { return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) } -async function installWithBundledPnpm( - directory: string, - executableDirectories: readonly string[], -): Promise { +async function installWithBundledPnpm(directory: string): Promise { const require = createRequire(import.meta.url) const pnpmManifest = require.resolve('pnpm') const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') @@ -92,7 +86,7 @@ async function installWithBundledPnpm( '--reporter=append-only', ], { cwd: directory, - env: installEnvironment([commandDirectory, ...executableDirectories]), + env: installEnvironment(commandDirectory), shell: false, stdio: ['ignore', 'pipe', 'pipe'], }) @@ -174,12 +168,11 @@ export class RepositoryCache { /** * @param directory - caller-owned persistent cache root. - * @param options - isolated installer override and lifecycle command directories. + * @param options - isolated installer override. */ constructor(directory: string, options: RepositoryCacheOptions = {}) { this.directory = resolve(directory) - const executableDirectories = (options.executableDirectories ?? []).map(entry => resolve(entry)) - this.install = options.install ?? (staging => installWithBundledPnpm(staging, executableDirectories)) + this.install = options.install ?? installWithBundledPnpm } /** From cc7bd4948d5059ac96769b6c2abbed6eade438c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:50:54 +0800 Subject: [PATCH 14/20] fix(repository-plugin): reject incomplete MCP publication --- ...-static-repository-plugin-format.i18n.yaml | 4 ++-- ...6-07-30-static-repository-plugin-format.md | 4 ++-- ...7-30-static-repository-plugin-format.zh.md | 4 ++-- ...-trusted-repository-package-code.i18n.yaml | 4 ++-- ...6-08-08-trusted-repository-package-code.md | 8 +++---- ...8-08-trusted-repository-package-code.zh.md | 10 ++++---- docs/config-catalog.md | 4 ++-- packages/mcp/mcp-client/README.i18n.yaml | 4 ++-- packages/mcp/mcp-client/README.md | 7 +++--- packages/mcp/mcp-client/README.zh.md | 7 +++--- packages/mcp/mcp-client/src/index.ts | 14 +++++++---- packages/mcp/mcp-client/src/tools.ts | 8 +++++-- packages/mcp/mcp-client/tests/apply.spec.ts | 24 ++++++++++++++++++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 1 + .../repository-plugin/README.i18n.yaml | 4 ++-- .../repository-plugin/README.md | 5 ++-- .../repository-plugin/README.zh.md | 5 ++-- .../repository-plugin/src/format.ts | 1 + .../repository-plugin/src/source.ts | 6 ++++- .../tests/repository-plugin.spec.ts | 8 ++++++- 20 files changed, 89 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 33e7540bbc..422b667ebd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: afc88bb974ccc6ef7843eaf1e98f16b65bb6d4b3 -2026-07-30-static-repository-plugin-format.zh.md: a6dc6ec6ec89c04b79edea279029ae33091ca0af +2026-07-30-static-repository-plugin-format.md: c66ee111eb0cac9e0d6c54581855ffc18efc8611 +2026-07-30-static-repository-plugin-format.zh.md: 969d7eb536158137807826eba2313670a7d37580 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index afc88bb974..c66ee111eb 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -20,7 +20,7 @@ Loading the DSH package registers that builtin as an effect. A generated wrapper Each prepared skill set mounts `dsh-skill-local` with a unique `repository:` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery. -Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Consequently an MCP connection failure keeps its established successful-plugin/no-tools behavior and is not reclassified as a repository preparation or Loader failure. +Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Repository instances enable strict startup, so an initial connection, discovery, or tool-registration failure rejects the repository Loader generation; non-strict standalone clients retain the logged successful-plugin/no-tools behavior. Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Commands, hooks, agents, rules, and other foreign manifest conventions are not inferred from static repository layout; DSH-native behavior uses the explicit trusted Cordis entry. Repository subdirectory selection and GitHub source configuration belong to the [standalone app integration](../feature/2026-07-30-config-only-repository-plugins.md), not this static adapter. @@ -34,7 +34,7 @@ Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `C **Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity. -**Treat MCP connect failures as Loader update failures.** Rejected because the existing MCP client deliberately contains connect failures and exposes no tools. Changing that semantic only for repository sources would create two failure contracts for the same server configuration. +**Make every MCP connect failure a Loader update failure.** Rejected because optional standalone MCP clients deliberately contain startup failures and expose no tools. The MCP client instead owns an explicit strict-startup option, which repository adapters enable for their declared servers. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index a6dc6ec6ec..969d7eb536 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -20,7 +20,7 @@ 每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName` 和 `includeDefaultRoots`。默认值保持原有单一本地提供方行为;repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。 -`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。因此 MCP 连接失败会继续沿用“Plugin 成功但不注册工具”的既有行为,不会被重新分类为 repository 准备或 Loader 失败。 +`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。Repository 实例会启用严格启动,因此初始连接、发现或工具注册失败会拒绝 repository Loader generation;非严格的独立 client 则保留“记录日志、Plugin 成功但不注册工具”的行为。 未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。命令、hook、agent(智能体)、规则和其他外来 manifest 约定不会从静态 repository 布局中推断出来;DSH 原生行为使用显式的受信任 Cordis 入口。Repository 子目录选择与 GitHub 源配置属于[独立应用集成](../feature/2026-07-30-config-only-repository-plugins.md),而不是本静态适配器。 @@ -34,7 +34,7 @@ **监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。 -**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。 +**把每次 MCP 连接失败都当作 Loader 更新失败。** 拒绝,因为可选的独立 MCP client 会有意收束启动失败,并且不暴露工具。MCP client 改为自行提供显式的严格启动选项,由 repository 适配器为其声明的 server 启用。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml index 16b72afb4e..af4950b20c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: 6c568964c2d4392054107acb1e73ace8f50d5f0b -2026-08-08-trusted-repository-package-code.zh.md: c2587286379398ab853b71c8b8819d274d06c65b +2026-08-08-trusted-repository-package-code.md: 387479b3b36a8bc5e145641ae40802b3090ced70 +2026-08-08-trusted-repository-package-code.zh.md: aff34d4485553f076c5dd1b71f0a3c5b5c62bc8f diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md index 6c568964c2..387479b3b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md @@ -16,9 +16,9 @@ A configured repository package is trusted code. Its `.dsh-plugin/package.json` The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable. -The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. +The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. The wrapper statically declares dependencies implied by the prepared manifest; an entry module's additional `inject` is discovered only when mounted and must already be available in the host composition. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. -`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection or discovery failure rejects the candidate generation and rollback still closes the transport. +`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection, discovery, or tool-registration failure rejects the candidate generation and rollback still closes the transport. ## Trust boundary @@ -41,11 +41,11 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent - A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm. - Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import. - A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration. -- The initial MCP connection can lengthen application startup, and a repository-declared server that is unavailable prevents that candidate generation from activating. +- Initial MCP synchronization can lengthen application startup by the MCP SDK's per-request timeout, and a repository-declared server that is unavailable or cannot publish its complete tool generation prevents that candidate generation from activating. - Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening. ## Testing -Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client. +Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained startup failure, and prove strict connection or tool-registration rejection still closes the client. The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md index c258728637..aff34d4485 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md @@ -16,13 +16,13 @@ 包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序或编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。 -生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 +生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。包装层会静态声明已准备 manifest(元数据清单)所隐含的依赖;入口模块的额外 `inject` 只有在挂载时才会被发现,并且此时必须已存在于宿主组合中。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 -`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接或发现失败会拒绝候选 generation,回滚仍会关闭 transport。 +`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接、发现或工具注册失败会拒绝候选 generation,回滚仍会关闭 transport。 ## 信任边界 -精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest(元数据清单)和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。 +精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest 和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。 模型可见行为仍由所属 DSH seam 管理。repository 入口可以注册工具、提示词段落、策略、命令、agent(智能体)或其他 effect,但任何进入模型请求的内容仍须具有对应的 DSH 日志表示和生命周期清理。repository 格式授予代码加载能力;它不会削弱这些服务契约。 @@ -41,11 +41,11 @@ - TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。 - 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。 - 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。 -- 初始 MCP 连接可能延长应用启动时间;repository 声明的 server 不可用时,该候选 generation 无法激活。 +- 初始 MCP 同步可能因 MCP SDK 的单次请求超时而延长应用启动时间;repository 声明的 server 不可用或无法发布完整工具 generation 时,该候选 generation 无法激活。 - Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。 ## 测试 -repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。 +repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留可选择启用的启动失败收束行为,并证明严格连接拒绝或工具注册拒绝仍会关闭 client。 Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball,并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f8534ad2..34ea4a7fbd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -988,7 +988,7 @@ export interface StdioConfig { cwd: string /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number - /** Fail plugin activation when the initial connection or tool discovery fails. */ + /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean } @@ -1008,7 +1008,7 @@ export interface StreamableHttpConfig { headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number - /** Fail plugin activation when the initial connection or tool discovery fails. */ + /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean } ``` diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 2d47705a0a..de8c3e8cd1 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: c87255917a2def0aef938af9f2c910b65d5a96d5 -README.zh.md: 6fd6df39d7c5034795021d41de57f8500cbfd1c5 +README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56 +README.zh.md: 49de996863ab16a46cd7ee82b13624523dbb853f diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index c87255917a..76d1271f6f 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -44,7 +44,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same | `url` | http | yes | MCP server URL | | `headers` | http | no | Extra headers (e.g. auth tokens) | | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | -| `failOnStartupError` | both | no | Reject plugin activation when the initial connection or tool discovery fails (default `false`) | +| `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) | ## Tool naming @@ -57,8 +57,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` ## Behavior -- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection or discovery failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools. -- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. +- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection, discovery, or registration failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools. +- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server. - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. @@ -103,6 +103,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. +- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles. - **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 6fd6df39d7..49de996863 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -44,7 +44,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | `url` | http | 是 | MCP 服务器 URL | | `headers` | http | 否 | 额外标头(例如认证 token) | | `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) | -| `failOnStartupError` | 两者 | 否 | 初始连接或工具发现失败时拒绝插件激活(默认 `false`) | +| `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) | ## 工具命名 @@ -57,8 +57,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc ## 行为 -- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接或发现失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。 -- 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。 +- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接、发现或注册失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。 +- 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。 - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 @@ -103,6 +103,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc ## 已知限制与暂缓事项 - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 +- **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。 - **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 348003be52..0fe7dd9de3 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -72,7 +72,7 @@ export interface StdioConfig { cwd: string /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number - /** Fail plugin activation when the initial connection or tool discovery fails. */ + /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean } @@ -92,7 +92,7 @@ export interface StreamableHttpConfig { headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number - /** Fail plugin activation when the initial connection or tool discovery fails. */ + /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean } @@ -155,6 +155,7 @@ export async function apply(ctx: Context, config: Config): Promise { ) const opts = { + registrationFailure: 'contain' as const, serverName: config.serverName, toolCallTimeoutMs: config.toolCallTimeoutMs, } @@ -166,7 +167,10 @@ export async function apply(ctx: Context, config: Config): Promise { const ready = (async () => { await client.connect(transport) - let disposers = await syncTools(client, ctx, opts, new Map()) + let disposers = await syncTools(client, ctx, { + ...opts, + registrationFailure: config.failOnStartupError ? 'throw' : 'contain', + }, new Map()) client.setNotificationHandler( ToolListChangedNotificationSchema, @@ -184,7 +188,7 @@ export async function apply(ctx: Context, config: Config): Promise { return { getDisposers: () => disposers } })().catch((error: unknown) => { - ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) + ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`) return { getDisposers: () => new Map void>(), error } }) @@ -196,6 +200,6 @@ export async function apply(ctx: Context, config: Config): Promise { const outcome = await ready if ('error' in outcome && config.failOnStartupError) { - throw new Error(`mcp-client(${config.serverName}): initial connection or tool discovery failed`, { cause: outcome.error }) + throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error }) } } diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 49e023ca4d..28c2a66319 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -23,6 +23,8 @@ import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' /** Resolved options relevant to tool bridging. */ export interface ToolBridgeOptions { + /** Whether a registry conflict is contained or rejects this synchronization. */ + registrationFailure: 'contain' | 'throw' serverName: string toolCallTimeoutMs: number } @@ -111,8 +113,9 @@ export function publicToolName(serverName: string, rawName: string): string { * 2. Swap: dispose the previous generation, register the new one. A registry * conflict here can only mean a foreign registration squats on this * server's `mcp____` namespace — the partial generation is - * rolled back (zero tools from this server), the error is logged, and an - * empty map is returned. + * rolled back (zero tools from this server) and logged. Initial strict + * synchronization may propagate the conflict so its parent transaction + * rejects; ordinary clients and later re-syncs return an empty map. * * @param client - Connected MCP Client instance used to list and call tools. * @param ctx - Cordis context providing the `tools` service for registration. @@ -164,6 +167,7 @@ export async function syncTools( // sees either the full generation or none of it — never a partial set. for (const dispose of disposers.values()) dispose() ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`) + if (opts.registrationFailure === 'throw') throw error return new Map() } return disposers diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index b2444341f1..70c88ad660 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -229,7 +229,7 @@ describe('apply (plugin lifecycle)', () => { await expect(apply(ctx, { ...stdioConfig, failOnStartupError: true, - })).rejects.toThrow('initial connection or tool discovery failed') + })).rejects.toThrow('initial connection or tool synchronization failed') expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() @@ -237,6 +237,28 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('rejects strict startup when the initial tool generation cannot be registered', async () => { + ctx.tools.register({ + name: 'mcp__srv__remote', + description: 'Foreign squatter', + parameters: { type: 'object' }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: async () => 'foreign', + }) + + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + })).rejects.toThrow('initial connection or tool synchronization failed') + + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + await ctx.fiber.dispose() + expect(mockClose).toHaveBeenCalled() + }) + it('re-syncs tools on ToolListChanged notification', async () => { await apply(ctx, stdioConfig) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index b95251e7d3..d19e30b94b 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -64,6 +64,7 @@ async function mountRegistry(): Promise { } const defaultOpts: ToolBridgeOptions = { + registrationFailure: 'contain', serverName: 'srv', toolCallTimeoutMs: 60_000, } diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index 770cf0fab0..9d7f305263 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4 -README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4 +README.md: 0df0680cef873d9f7078cb63d1c803e5c323f07c +README.zh.md: 04e5f8f81ba1b3e788775e9deaff6bb0b6dd1a48 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 3a6478d5cc..0df0680cef 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -64,13 +64,13 @@ During exact Git installation, DSH's bundled pnpm installs the selected package ## Runtime composition -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The entry is an ordinary Cordis child Plugin: its own `inject` gates activation, startup failures reject the repository generation, and all of its effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable. +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The wrapper can statically gate only the `loader`, `skills`, and `tools` services implied by the prepared manifest; the entry's own `inject` is discovered when that child is mounted. The entry must reach `ACTIVE`, so a missing entry-only service or startup failure rejects the repository generation instead of committing an inert child, and all effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable. ## Common MCP format The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. -Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation, while a network, child-process, or discovery failure rejects the candidate repository generation instead of silently activating without its declared tools. +Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool synchronization, so the first model request observes a fully registered initial tool generation, while a network, child-process, discovery, or registration failure rejects the candidate repository generation instead of silently activating without its declared tools. ## Export shape @@ -123,5 +123,6 @@ Stable registrations preserve the owning surface's normal prefix behavior. Loadi ## Known Limitations and Deferred Work - **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory. +- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects. - **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here. - **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation. diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index 274d11c96c..04e5f8f81b 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -64,13 +64,13 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 ## 运行时组合 -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。入口是普通的 Cordis 子插件:其自有 `inject` 会门控激活,启动失败会拒绝 repository generation,Loader 移除或回滚时,其所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。 +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。包装层只能静态门控已准备 manifest 所隐含的 `loader`、`skills` 与 `tools` 服务;入口自身的 `inject` 要到挂载该子级时才会发现。入口必须进入 `ACTIVE`,因此缺少入口专用服务或启动失败时,会拒绝 repository generation,而不会提交未激活的子级;Loader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。 ## 通用 MCP 格式 `.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 -未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络、子进程或发现失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。 +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation;网络、子进程、发现或注册失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。 ## 导出形状 @@ -123,5 +123,6 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量 ## 已知限制与暂缓事项 - **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。 +- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。 - **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 - **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts index 852e57b253..743a2fbf39 100644 --- a/packages/self-modification/repository-plugin/src/format.ts +++ b/packages/self-modification/repository-plugin/src/format.ts @@ -152,6 +152,7 @@ function wrapperSource(manifest: PreparedPluginManifest): string { return [ '// Generated by dsh-plugin-prepare. Do not edit.', `const manifest = ${JSON.stringify(manifest)}`, + '// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.', 'const FIBER_ACTIVE = 2', `export const name = ${JSON.stringify(manifest.name)}`, `export const inject = ${JSON.stringify(inject)}`, diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts index a85bb0467e..8460bc800b 100644 --- a/packages/self-modification/repository-plugin/src/source.ts +++ b/packages/self-modification/repository-plugin/src/source.ts @@ -80,7 +80,11 @@ async function assertInstalledPackageMetadata(directory: string): Promise } const result = installedPackageSchema.safeParse(value) if (!result.success) { - throw new Error(`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`) + throw new Error([ + `installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:`, + z.prettifyError(result.error), + 'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.', + ].join('\n')) } } diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index b5abf6fbdd..3b8d587dbc 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -330,7 +330,7 @@ describe('prepared repository plugin Loader composition', () => { await ctx.plugin(RepositoryPlugin) await expect(ctx.loader.create({ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - })).rejects.toThrow('initial connection or tool discovery failed') + })).rejects.toThrow('initial connection or tool synchronization failed') expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) await ctx.fiber.dispose() }) @@ -593,6 +593,12 @@ describe('configured GitHub repository sources', () => { message: expect.stringContaining('must declare a non-empty scripts.prepack') as string, }) as Error, }) + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) + .rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining('Clear the matching repository cache generation') as string, + }) as Error, + }) await ctx.fiber.dispose() }) From e00ec8e2aa023e58f637a3891c1a8d9f8b2db43c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:57:56 +0800 Subject: [PATCH 15/20] test(repository-plugin): refresh prepared wrapper fixture --- .../tests/fixtures/repository-plugin/dsh-plugin.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs index ce61e58973..29aa97c5ec 100644 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -1,5 +1,6 @@ // Generated by dsh-plugin-prepare. Do not edit. const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} +// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts. const FIBER_ACTIVE = 2 export const name = "headless-repository-fixture" export const inject = ["loader","skills"] From 690fc798a516fedaaee8b458619e0ed6f4bdf6d2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:14:37 +0800 Subject: [PATCH 16/20] test(repository-plugin): isolate simulated npm release --- apps/cli/tests/github-repository-plugin.built.e2e.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index e1386e2bfe..139dc8208f 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -182,6 +182,11 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, NPM_CONFIG_USERCONFIG: npmrc, + // A warm runner cache could satisfy the exact tarball without + // contacting this test's registry, which would stop proving the + // unpublished package was installed through the simulated release. + PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'), + PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'), PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`, }, }) From 8d76ddaa6c5f02ce39647f2fb5808d97228b911e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:15:36 +0800 Subject: [PATCH 17/20] fix(repository-plugin): enforce published prepare dependency --- ...0-config-only-repository-plugins.i18n.yaml | 4 +-- ...26-07-30-config-only-repository-plugins.md | 4 +-- ...07-30-config-only-repository-plugins.zh.md | 4 +-- packages/mcp/mcp-client/tests/apply.spec.ts | 2 +- .../repository-plugin/README.i18n.yaml | 4 +-- .../repository-plugin/README.md | 2 +- .../repository-plugin/README.zh.md | 2 +- .../repository-plugin/src/format.ts | 5 +++ .../repository-plugin/src/index.ts | 1 + .../repository-plugin/src/source.ts | 6 +++- .../tests/repository-plugin.spec.ts | 33 +++++++++++++++++++ 11 files changed, 55 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml index cee151c1ed..74992551f8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md -2026-07-30-config-only-repository-plugins.md: 1564a847ccef877a6cc91827e195aa04711d09ce -2026-07-30-config-only-repository-plugins.zh.md: 8e8de1e6edc356c9bb9ab55f01b6cce7608358e7 +2026-07-30-config-only-repository-plugins.md: 35327a30e03c51311f634e05ade209ab93ae0155 +2026-07-30-config-only-repository-plugins.zh.md: e2ef728cadf33f12da0e96a71fd984b694f497b0 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md index 1564a847cc..35327a30e0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md @@ -12,13 +12,13 @@ A standalone `dsh` user has no developer-owned SDK project whose `package.json`, The shipped TUI and Web/headless `cordis.yml` trees contain an empty `repository-plugins` entry. A user changes only `$DSH_HOME/config.yaml`, replacing that entry's config with a `repositories` list. Each item uses `github:owner/repository#` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation. -`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, installs its dependencies, runs its package-authored `prepack` and the host preparation helper, and atomically publishes the exact specifier. The DSH host imports the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber; that wrapper composes static skill and MCP owners plus an explicit trusted Cordis entry when declared. +`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, installs its dependencies, runs its package-authored `prepack`, and atomically publishes the exact specifier. The selected package's direct development dependency on `@deepseek-ai/dsh-repository-plugin` supplies `dsh-plugin-prepare` through package-local `node_modules/.bin`; the lifecycle invokes it after any package-owned build. The DSH host imports the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber; that wrapper composes static skill and MCP owners plus an explicit trusted Cordis entry when declared. ## Live update and failure `dsh-app-boot` mounts the root Include through one helper that retains its exact Loader `Entry`. The TUI and Web register `$DSH_HOME/config.yaml` through Cordis HMR; headless reads the same file at startup without retaining a watcher. A watcher update rebuilds the Include patch list as immutable app-owned patches followed by the newly parsed personal patches, so Web-generated port, session-root, trust, and frontend values survive every personal edit unless a later personal patch deliberately replaces that row. -Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. MCP transport connection failure remains the existing MCP client's contained successful-Plugin/no-tools result and therefore is not reclassified as a config-update failure. +Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. Repository MCP servers use strict startup, so an initial connection, discovery, or tool-registration failure rejects the candidate and becomes a config-update failure; non-strict standalone MCP clients retain their contained successful-Plugin/no-tools behavior. An identical specifier permanently reuses its cache generation. HMR watches configuration, not cached repository code; the user changes the ref, path, or source list to select another generation. diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md index 8e8de1e6ed..e2ef728cad 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md @@ -12,13 +12,13 @@ Status: implemented 已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。 -`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择已配置的 repository 子包,安装其依赖,运行包所定义的 `prepack` 与宿主准备辅助程序,并原子发布该精确说明符。DSH 宿主会导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber;该包装层组合静态 skill(技能)与 MCP 所有者,并在声明时组合显式的受信任 Cordis 入口。 +`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择已配置的 repository 子包,安装其依赖,运行包所定义的 `prepack`,并原子发布该精确说明符。所选包对 `@deepseek-ai/dsh-repository-plugin` 的直接开发依赖通过包内 `node_modules/.bin` 提供 `dsh-plugin-prepare`;该生命周期会在任何包自有构建完成后调用它。DSH 宿主会导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber;该包装层组合静态 skill(技能)与 MCP 所有者,并在声明时组合显式的受信任 Cordis 入口。 ## 实时更新与失败 `dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头界面在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。 -Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。 +Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。Repository MCP 服务器采用严格启动,因此初始连接、发现或工具注册失败会拒绝候选配置,并构成配置更新失败;非严格的独立 MCP 客户端仍保留其所收束的「插件成功加载但无工具」行为。 相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。 diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 70c88ad660..44b3e272f4 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -209,7 +209,7 @@ describe('apply (plugin lifecycle)', () => { expect(other.tools.get('mcp__srv__remote')).toBeDefined() }) - it('logs error and registers no tools when connect fails; dispose is a no-op', async () => { + it('logs error and registers no tools when connect fails; dispose closes the client', async () => { mockConnect.mockRejectedValue(new Error('connection refused')) await apply(ctx, stdioConfig) diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index 9d7f305263..e083b8abf3 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 0df0680cef873d9f7078cb63d1c803e5c323f07c -README.zh.md: 04e5f8f81ba1b3e788775e9deaff6bb0b6dd1a48 +README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7 +README.zh.md: 62f467dd9ccac904ea2a216242f5475c29734a86 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 0df0680cef..666f00e02b 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -60,7 +60,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v ## Preparation -During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared `@deepseek-ai/dsh-repository-plugin` dependency. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). +During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared direct development dependency on `@deepseek-ai/dsh-repository-plugin`. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained both the direct development dependency and a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). ## Runtime composition diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index 04e5f8f81b..62f467dd9c 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -60,7 +60,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有 ## 准备阶段 -安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从已声明的 `@deepseek-ai/dsh-repository-plugin` 依赖取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 +安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍同时保留该直接开发依赖,以及包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 ## 运行时组合 diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts index 743a2fbf39..7af948595d 100644 --- a/packages/self-modification/repository-plugin/src/format.ts +++ b/packages/self-modification/repository-plugin/src/format.ts @@ -16,6 +16,8 @@ export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' /** Dependency-provided command that repository package `prepack` lifecycles must invoke. */ export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' +/** Published package whose direct development dependency supplies the prepare command. */ +export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' /** * Whether a package lifecycle declaration names the preparation dependency's helper. @@ -40,6 +42,9 @@ const sourceMetadataSchema = z.object({ }) const sourcePackageSchema = z.looseObject({ name: z.string().min(1), + devDependencies: z.looseObject({ + [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), + }), scripts: z.looseObject({ prepack: prepackSchema, }), diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 8026199c16..1251fe45e2 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -29,6 +29,7 @@ export { PREPARED_ASSET_DIRECTORY, PREPARED_ENTRY_FILENAME, REPOSITORY_PLUGIN_BUILTIN, + REPOSITORY_PLUGIN_PACKAGE_NAME, REPOSITORY_PLUGIN_PREPARE_COMMAND, prepareDshPlugin, type PreparedPluginManifest, diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts index 8460bc800b..536025d6d7 100644 --- a/packages/self-modification/repository-plugin/src/source.ts +++ b/packages/self-modification/repository-plugin/src/source.ts @@ -12,6 +12,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { z } from 'zod' import { PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_PACKAGE_NAME, REPOSITORY_PLUGIN_PREPARE_COMMAND, hasRepositoryPrepareCommand, } from './format.ts' @@ -29,6 +30,9 @@ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' // resolvable point'). const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ const installedPackageSchema = z.looseObject({ + devDependencies: z.looseObject({ + [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), + }), scripts: z.looseObject({ prepack: z.string().min(1).refine( hasRepositoryPrepareCommand, @@ -81,7 +85,7 @@ async function assertInstalledPackageMetadata(directory: string): Promise const result = installedPackageSchema.safeParse(value) if (!result.success) { throw new Error([ - `installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:`, + `installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}, and declare ${JSON.stringify(REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies:`, z.prettifyError(result.error), 'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.', ].join('\n')) diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 3b8d587dbc..086616f2f9 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -32,12 +32,16 @@ async function writePlugin( name: string, dsh: Record, prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, + devDependencies: Record = { + [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1', + }, ): Promise { const directory = join(root, '.dsh-plugin') await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', + devDependencies, scripts: { prepack }, dsh, }, undefined, 2)}\n`) @@ -149,6 +153,17 @@ describe('dsh-plugin-prepare', () => { ) await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare') + const undeclaredPrepareRoot = await temporaryDirectory('undeclared-prepare-dependency') + const undeclaredPrepare = await writePlugin( + undeclaredPrepareRoot, + 'undeclared-prepare-dependency', + { skills: ['../skills'] }, + RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, + {}, + ) + await expect(RepositoryPlugin.prepareDshPlugin(undeclaredPrepare)) + .rejects.toThrow(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME) + const emptyRoot = await temporaryDirectory('empty-metadata') const empty = await writePlugin(emptyRoot, 'empty', {}) await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root, mcpServers file, or compiled entry') @@ -584,6 +599,7 @@ describe('configured GitHub repository sources', () => { const root = await temporaryDirectory('installed-lifecycle') await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'installed-lifecycle', + devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, scripts: { prepare: 'dsh-plugin-prepare' }, })) const ctx = new Context() @@ -606,6 +622,7 @@ describe('configured GitHub repository sources', () => { const root = await temporaryDirectory('installed-skipped-prepare') await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'installed-skipped-prepare', + devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, scripts: { prepack: 'npm run build' }, })) const ctx = new Context() @@ -618,6 +635,22 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) + it('rejects installed source without the declared prepare dependency', async () => { + const root = await temporaryDirectory('installed-missing-prepare-dependency') + await writeFile(join(root, 'package.json'), JSON.stringify({ + name: 'installed-missing-prepare-dependency', + scripts: { prepack: 'dsh-plugin-prepare' }, + })) + const ctx = new Context() + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#ambient-helper&path:/.dsh-plugin')) + .rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(`${JSON.stringify(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies`) as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + it('labels missing installed package metadata with its source', async () => { const root = await temporaryDirectory('missing-installed-metadata') const ctx = new Context() From fc20194733b1af225567e2342fff84470de077ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:22:46 +0800 Subject: [PATCH 18/20] fix(repository-plugin): follow package regrouping --- ...-08-npm-backed-git-repository-plugin-preparation.i18n.yaml | 4 ++-- ...2026-08-08-npm-backed-git-repository-plugin-preparation.md | 2 +- ...6-08-08-npm-backed-git-repository-plugin-preparation.zh.md | 2 +- apps/cli/tests/github-repository-plugin.built.e2e.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml index e1966105a3..f13bae898a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md -2026-08-08-npm-backed-git-repository-plugin-preparation.md: 9437043d007f5331f36f1c4cff1fe0a3e768b060 -2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: 1f06f7372dc0d948906ec4441cb161d2d2ed69e5 +2026-08-08-npm-backed-git-repository-plugin-preparation.md: 958b932f82f4da3cf63aa911260411855e514409 +2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: 4986c55787f291ba9e0d4594854c15eb3c892c72 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md index 9437043d00..958b932f82 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md @@ -45,4 +45,4 @@ The built-entry acceptance also creates an in-process npm registry. It stages th ## Testing -`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. +`packages/boot/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md index 1f06f7372d..4986c55787 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md @@ -45,4 +45,4 @@ Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库与 SHA ## 测试 -`packages/ui/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及已准备缓存检查。 +`packages/boot/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及已准备缓存检查。 diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index 139dc8208f..7bb4706dd7 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const repositoryPluginPackage = join(repoRoot, 'packages/cordis/repository-plugin') +const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin') const releasePackageNames = new Set(globSync([ 'vendor/*/package.json', 'packages/*/*/package.json', From a208bc1a9263291481a1a5719fb583ea02ad354c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:28:31 +0800 Subject: [PATCH 19/20] docs(config-catalog): refresh repository source --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 34ea4a7fbd..0a5d0f7519 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1186,7 +1186,7 @@ export interface Config { } ``` -Source: [`packages/self-modification/repository-plugin/src/index.ts:43`](../packages/self-modification/repository-plugin/src/index.ts) +Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` From c8e0b0f3a7bae107b332617e9b82cb3531f3fd99 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:41:30 +0800 Subject: [PATCH 20/20] test(repository-plugin): update keyless prepare fixture --- examples/headless-agent/tests/keyless-smoke.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index c737e4ea72..ba75ce9294 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -9,6 +9,7 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l import { PREPARED_ENTRY_FILENAME, REPOSITORY_PLUGIN_PREPARE_COMMAND, + REPOSITORY_PLUGIN_PACKAGE_NAME, prepareDshPlugin, } from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -77,6 +78,7 @@ describe('headless-agent keyless smoke', () => { name: 'headless-repository-fixture', version: '0.0.0', scripts: { prepack: REPOSITORY_PLUGIN_PREPARE_COMMAND }, + devDependencies: { [REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, dsh: { skills: ['../skills'] }, }, undefined, 2)}\n`) await prepareDshPlugin(plugin)