From fa7051a9d1b6a35fc037b464d71742ed0e391329 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:15:07 +0800 Subject: [PATCH] feat: add static repository plugin format --- ...-static-repository-plugin-format.i18n.yaml | 6 + ...6-07-30-static-repository-plugin-format.md | 49 ++++ ...7-30-static-repository-plugin-format.zh.md | 49 ++++ docs/config-catalog.md | 5 + docs/module-graph.md | 5 + .../tests/fixtures/cli.cordis.yml | 10 + .../skills/0/repository-fixture/SKILL.md | 6 + .../fixtures/repository-plugin/dsh-plugin.mjs | 9 + .../headless-agent/tests/keyless-smoke.e2e.ts | 11 + examples/package.json | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/cordis/README.i18n.yaml | 4 +- packages/cordis/README.md | 5 +- packages/cordis/README.zh.md | 5 +- .../cordis/repository-plugin/README.i18n.yaml | 6 + packages/cordis/repository-plugin/README.md | 85 ++++++ .../cordis/repository-plugin/README.zh.md | 85 ++++++ .../cordis/repository-plugin/package.json | 53 ++++ packages/cordis/repository-plugin/src/bin.ts | 12 + .../cordis/repository-plugin/src/format.ts | 168 ++++++++++++ .../cordis/repository-plugin/src/index.ts | 97 +++++++ .../cordis/repository-plugin/src/invariant.ts | 30 +++ packages/cordis/repository-plugin/src/mcp.ts | 145 +++++++++++ .../tests/mcp-format.spec.ts | 109 ++++++++ .../tests/repository-plugin.spec.ts | 243 ++++++++++++++++++ .../cordis/repository-plugin/tsconfig.json | 30 +++ .../cordis/repository-plugin/tsdown.config.ts | 17 ++ packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 4 +- packages/skill/skill-local/README.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 37 ++- pnpm-lock.yaml | 34 +++ tsconfig.host.json | 1 + 35 files changed, 1310 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs create mode 100644 packages/cordis/repository-plugin/README.i18n.yaml create mode 100644 packages/cordis/repository-plugin/README.md create mode 100644 packages/cordis/repository-plugin/README.zh.md create mode 100644 packages/cordis/repository-plugin/package.json create mode 100644 packages/cordis/repository-plugin/src/bin.ts create mode 100644 packages/cordis/repository-plugin/src/format.ts create mode 100644 packages/cordis/repository-plugin/src/index.ts create mode 100644 packages/cordis/repository-plugin/src/invariant.ts create mode 100644 packages/cordis/repository-plugin/src/mcp.ts create mode 100644 packages/cordis/repository-plugin/tests/mcp-format.spec.ts create mode 100644 packages/cordis/repository-plugin/tests/repository-plugin.spec.ts create mode 100644 packages/cordis/repository-plugin/tsconfig.json create mode 100644 packages/cordis/repository-plugin/tsdown.config.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 new file mode 100644 index 0000000000..0190e19f8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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/architecture/2026-07-30-static-repository-plugin-format.md +2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a +2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb 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 new file mode 100644 index 0000000000..c4739a6843 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -0,0 +1,49 @@ +# Agent Note: Static repository Plugin format + +Status: implemented + +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. + +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. + +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, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. + +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. + +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. + +Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Hooks, commands, agents, apps, arbitrary Cordis code, marketplaces, and discovery are also unsupported. Repository subdirectory selection and GitHub configuration belong to the later app/cache integration, not this format package. + +## Alternatives considered + +**Load a repository's own Cordis entry point.** Rejected because it makes the advertised static format an unrestricted code-loading API, requires repository authors to depend on Harness internals, and duplicates the ordinary SDK/plugin-dependency path. + +**Teach generated wrappers to implement skills and MCP directly.** Rejected because copied runtime code would drift from `dsh-skill-local` and `dsh-mcp-client`, especially their provider invalidation, tool synchronization, failure, and teardown contracts. + +**Import Harness packages from each generated wrapper.** Rejected because repository packages should not resolve or version the application's internal dependency graph. A Loader builtin supplies one app-owned implementation and keeps generated wrappers import-free. + +**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. + +## Consequences + +- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project. +- Prepared output is deterministic static glue, while the configured repository and its dependency lifecycle remain trusted executable package-manager input rather than a sandbox. +- Multiple repository Plugins coexist through provider names and ordinary MCP server-name uniqueness; duplicate names fail through their existing registries and participate in Loader rollback. +- Cached source edits do not appear live. Another exact source/ref/path/config selection is required. +- Adding another contribution kind requires an explicit format and DSH-owned runtime consumer; it cannot arrive as repository JavaScript by accident. + +## 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. 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 new file mode 100644 index 0000000000..0b0a2af132 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -0,0 +1,49 @@ +# Agent Note:静态 repository Plugin 格式 + +状态:已实现 + +[English](2026-07-30-static-repository-plugin-format.md) | 中文 + +## 问题 + +一个已经包含可复用 skills 或 MCP server 声明的仓库,应当能被独立 Harness 应用使用,而不必先变成 Harness SDK 项目,也不应被迫改写现有布局。常见仓库只需新增一个 `.dsh-plugin` 目录,同时仍可把原有 skills 与 `.mcp.json` 放在仓库其他位置。与此同时,如果把任意仓库入口都当作 Cordis Plugin,就会让每个仓库成为新的无限制运行时扩展表面,并绕过现有的 skill 与 MCP 生命周期所有者。 + +[Package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) 会准备一个精确 package source,但有意不了解任何 DSH 格式。因此本层需要一种兼容 package manager 的创作格式、确定性的已准备产物,以及在 Loader dispose 和替换期间仍保持事务性的 Cordis 组合。 + +## 决策 + +`@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、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 + +加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 + +每份已准备 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 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。Hooks、commands、agents、apps、任意 Cordis 代码、marketplace 和发现同样不受支持。Repository 子目录选择与 GitHub 配置属于后续 app/cache 集成,而不是本格式 package。 + +## 考虑过的替代方案 + +**加载仓库自己的 Cordis 入口。** 拒绝,因为这会把宣传为静态的格式变成无限制代码加载 API,要求仓库作者依赖 Harness 内部实现,并重复普通 SDK/Plugin dependency 路径。 + +**让生成包装模块直接实现 skills 和 MCP。** 拒绝,因为复制的运行时代码会与 `dsh-skill-local` 和 `dsh-mcp-client` 漂移,尤其是提供方失效、工具同步、失败和 teardown 契约。 + +**让每个生成包装模块 import Harness package。** 拒绝,因为 repository package 不应解析或锁定应用的内部依赖图。Loader builtin 提供一份由 app 所有的实现,并让生成包装模块保持无 import。 + +**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。 + +**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。 + +## 后果 + +- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 +- 已准备输出是确定性的静态胶水;已配置仓库及其依赖生命周期仍是受信任的可执行 package-manager 输入,而非 sandbox。 +- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 +- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 +- 新增贡献类型必须提供显式格式和 DSH 自有运行时消费方;它不能意外以 repository JavaScript 形式进入。 + +## 测试 + +聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。Keyless headless 示例通过真实 `cordis.yml` 加载一份签入的已准备包装模块,并快照 repository skill 写入日志的模型目录行。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d339d216e1..457f1be7b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1312,6 +1312,10 @@ Requires: `skills` ```ts config-catalog /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -2318,6 +2322,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) +- `@deepseek-ai/dsh-repository-plugin` — requires `loader` ([`packages/cordis/repository-plugin/src/index.ts`](../packages/cordis/repository-plugin/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f0f58a18c1..9c3afcec85 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -94,6 +94,7 @@ flowchart TD pkg_plan_mode["plan-mode"] end subgraph group_cordis["packages/cordis"] + pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_hooks["packages/hooks"] @@ -904,6 +905,9 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools + pkg_repository_plugin --> pkg_invariants + pkg_repository_plugin --> pkg_mcp_client + pkg_repository_plugin --> pkg_skill_local pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol pkg_hooks_claude --> pkg_invariants @@ -1208,6 +1212,7 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 91941c108a..5e1a296de5 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,6 +1,12 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: repository-plugin-runtime + name: '@deepseek-ai/dsh-repository-plugin' + +- id: repository-plugin-fixture + name: './repository-plugin/dsh-plugin.mjs' + - id: base name: '@cordisjs/plugin-include' config: @@ -16,4 +22,8 @@ model: cli-mock persistenceRoot: './.sessions' workspaceContext: false + dshHome: './.dsh-home' + skills: + local: + agentsHome: './.agents-home' persona: 'Keyless headless-agent smoke.' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md new file mode 100644 index 0000000000..e24104e79f --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md @@ -0,0 +1,6 @@ +--- +name: repository-fixture +description: Repository fixture skill. +--- + +Static instructions from a prepared repository plugin. diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs new file mode 100644 index 0000000000..31225c0afc --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -0,0 +1,9 @@ +// Generated by dsh-plugin-prepare. Do not edit. +const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] } +export const name = 'headless-repository-fixture' +export const inject = ['loader'] +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 }) +} diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4cd06aed78..d5e1cce826 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -36,6 +36,17 @@ describe('headless-agent keyless smoke', () => { const result = lines.at(-1) expect(stderr).toBe('') expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) + const catalogMessage = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill') + const catalog = catalogMessage?.type === 'user/message' + ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + : '' + expect(catalog.split('\n').find(line => line.includes('repository-fixture'))).toMatchInlineSnapshot( + ` + "- \`repository-fixture\`: Repository fixture skill." + `, + ) const toolResult = events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') expect(result).toMatchObject({ diff --git a/examples/package.json b/examples/package.json index b7e7b5d736..97e7918361 100644 --- a/examples/package.json +++ b/examples/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", + "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 369277ba3f..5491ce5432 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b -README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462 +README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863 +README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01 diff --git a/packages/README.md b/packages/README.md index 0c729f7811..c8984bfa65 100644 --- a/packages/README.md +++ b/packages/README.md @@ -33,7 +33,7 @@ Packages live at `packages///`; groups are containers, while names r | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | -| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | +| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface | | [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 660a24eeea..2a59a63d22 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -33,7 +33,7 @@ | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | | [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | -| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | +| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | | [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 | diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index aaa96435d3..29f9e303de 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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/cordis/README.md -README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 -README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4 +README.md: 485a6ce7858a77507c07b76138127faa411b354b +README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index a47b9ba207..485a6ce785 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -1,9 +1,10 @@ -# packages/cordis — the self-referential runtime toolset +# packages/cordis — Cordis runtime integration English | [中文](README.zh.md) -Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +Plugins that integrate Harness-owned formats with the Cordis runtime: the self-referential model toolset and the restricted repository Plugin runtime. | Package | Role | ctx key | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 3ee1ddb1db..38bfcd9fcb 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -1,9 +1,10 @@ -# packages/cordis:自指运行时工具集 +# packages/cordis:Cordis 运行时集成 [English](README.md) | 中文 -这些面向模型的工具作用于 agent(智能体)自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose(资源释放)。设计说明见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。 | 包(package) | 角色 | ctx 键 | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin | diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml new file mode 100644 index 0000000000..806ee1c3af --- /dev/null +++ b/packages/cordis/repository-plugin/README.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 packages/cordis/repository-plugin/README.md +README.md: dab6287304f083e5c0ae128d5a3cb861332c076a +README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md new file mode 100644 index 0000000000..dab6287304 --- /dev/null +++ b/packages/cordis/repository-plugin/README.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +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). + +## Authoring format + +Place an ordinary package in the repository's `.dsh-plugin` directory: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`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. + +## 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. + +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. + +## 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. 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. + +## 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. + +## Export shape + +Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion. + +## Model Experience + +### Repository skills + +#### What the model sees + +Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). + +#### Token effect + +Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history. + +#### KV Cache effect + +A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes. + +### Repository MCP tools + +#### What the model sees + +Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering. + +#### Token effect + +Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction. + +#### KV Cache effect + +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. + +## 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 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/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md new file mode 100644 index 0000000000..790e601ad0 --- /dev/null +++ b/packages/cordis/repository-plugin/README.zh.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +[English](README.md) | 中文 + +这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.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 Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 + +## 创作格式 + +在仓库的 `.dsh-plugin` 目录中放置一个普通 package: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 + +## 准备阶段 + +`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 + +外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。 + +## 运行时组合 + +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 + +## 通用 MCP 格式 + +`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。 + +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。 + +## 导出形状 + +Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 + +## 模型体验 + +### Repository skills + +#### 模型看到什么 + +通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 + +#### Token 影响 + +有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。 + +#### KV Cache 影响 + +稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。 + +### Repository MCP 工具 + +#### 模型看到什么 + +通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。 + +#### Token 影响 + +取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。 + +#### KV Cache 影响 + +稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 + +## 已知限制与延后工作 + +- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 +- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 +- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json new file mode 100644 index 0000000000..f6500eb007 --- /dev/null +++ b/packages/cordis/repository-plugin/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-repository-plugin", + "description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-plugin-prepare": "./lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-mcp-client": "^0.0.1", + "@deepseek-ai/dsh-skill-local": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/cordis/repository-plugin/src/bin.ts b/packages/cordis/repository-plugin/src/bin.ts new file mode 100644 index 0000000000..a1787ff090 --- /dev/null +++ b/packages/cordis/repository-plugin/src/bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** Command-line entry that prepares the current `.dsh-plugin` package. @module */ + +import { prepareDshPlugin } from './format.ts' + +try { + await prepareDshPlugin() +} catch (error) { + process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts new file mode 100644 index 0000000000..0c00142383 --- /dev/null +++ b/packages/cordis/repository-plugin/src/format.ts @@ -0,0 +1,168 @@ +/** + * Static repository-plugin preparation and prepared-manifest validation. + * @module + */ + +import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { z } from 'zod' +import { parseMcpDocument } from './mcp.ts' + +/** Fixed module filename loaded from an installed prepared plugin package. */ +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. */ +export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' + +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', +}) +const sourcePackageSchema = z.looseObject({ + name: z.string().min(1), + dsh: sourceMetadataSchema, +}) +const preparedManifestSchema = z.object({ + name: z.string().min(1), + skills: z.array(z.string().min(1)), + mcpServers: z.string().min(1).optional(), +}).strict() +const preparedConfigSchema = z.object({ + baseUrl: z.url(), + manifest: preparedManifestSchema, +}).strict() + +/** Static manifest embedded in the generated wrapper. */ +export interface PreparedPluginManifest { + name: string + skills: string[] + mcpServers?: string +} + +/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */ +export interface PreparedPluginConfig { + baseUrl: string + manifest: PreparedPluginManifest +} + +function formatZodError(label: string, error: z.ZodError): Error { + return new Error(`${label}:\n${z.prettifyError(error)}`) +} + +/** + * Validate the config passed by an installed prepared wrapper. + * @param value - wrapper-provided value crossing the file/module boundary. + * @returns a detached typed config. + */ +export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig { + const result = preparedConfigSchema.safeParse(value) + if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error) + return { + baseUrl: result.data.baseUrl, + manifest: { + name: result.data.manifest.name, + skills: result.data.manifest.skills, + ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers }, + }, + } +} + +function isOutside(root: string, candidate: string): boolean { + const path = relative(root, candidate) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) +} + +async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise { + if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`) + let path: string + try { + path = await realpath(resolve(pluginDirectory, configured)) + } catch (cause) { + throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause }) + } + if (isOutside(sourceRoot, path)) { + throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`) + } + const info = await stat(path) + if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) { + throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`) + } + return path +} + +function wrapperSource(manifest: PreparedPluginManifest): string { + return [ + '// Generated by dsh-plugin-prepare. Do not edit.', + `const manifest = ${JSON.stringify(manifest)}`, + `export const name = ${JSON.stringify(manifest.name)}`, + "export const inject = ['loader']", + '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 })', + '}', + '', + ].join('\n') +} + +/** + * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. + * @returns the generated static manifest. + */ +export async function prepareDshPlugin(directory: string = process.cwd()): Promise { + const pluginDirectory = await realpath(resolve(directory)) + let packageValue: unknown + try { + packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown + } catch (cause) { + 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) + + const sourceRoot = await realpath(dirname(pluginDirectory)) + const skillSources: string[] = [] + for (const configured of parsed.data.dsh.skills) { + const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory') + if (!isOutside(source, pluginDirectory)) { + throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`) + } + skillSources.push(source) + } + let mcpSource: string | undefined + if (parsed.data.dsh.mcpServers !== undefined) { + mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file') + parseMcpDocument(await readFile(mcpSource, 'utf8')) + } + + const manifest: PreparedPluginManifest = { + name: parsed.data.name, + skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), + ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, + } + const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-')) + try { + const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY) + await mkdir(join(stagedAssets, 'skills'), { recursive: true }) + await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), { + recursive: true, + force: false, + errorOnExist: true, + }))) + if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json')) + await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest)) + + await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true }) + await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true }) + await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY)) + await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME)) + } finally { + await rm(staging, { recursive: true, force: true }) + } + return manifest +} diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts new file mode 100644 index 0000000000..50a76fc952 --- /dev/null +++ b/packages/cordis/repository-plugin/src/index.ts @@ -0,0 +1,97 @@ +/** + * Restricted repository-plugin runtime for static skills and common MCP definitions. + * @module @deepseek-ai/dsh-repository-plugin + */ + +import { readFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type {} from '@cordisjs/plugin-loader' +import * as SkillLocal from '@deepseek-ai/dsh-skill-local' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' +import { + REPOSITORY_PLUGIN_BUILTIN, + parsePreparedPluginConfig, + type PreparedPluginConfig, +} from './format.ts' +import { parseMcpDocument, resolveMcpServers } from './mcp.ts' + +export { + PREPARED_ASSET_DIRECTORY, + PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_BUILTIN, + prepareDshPlugin, + type PreparedPluginManifest, +} from './format.ts' + +/** Cordis plugin name used by Loader diagnostics. */ +export const name = 'repository-plugin' +/** Loader service required to register the fixed prepared-wrapper builtin. */ +export const inject = ['loader'] + +function preparedPath(baseUrl: string, configured: string): string { + if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) + const directory = dirname(fileURLToPath(baseUrl)) + const path = resolve(directory, configured) + const rel = relative(directory, path) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) + } + return path +} + +async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise { + const config = parsePreparedPluginConfig(value) + const directory = dirname(fileURLToPath(config.baseUrl)) + const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path)) + const mcpConfigs = config.manifest.mcpServers === undefined + ? [] + : resolveMcpServers( + parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), + process.env, + directory, + ).map(input => McpClient.Config(input as never)) + + await ctx.effect(async function* () { + if (skillDirectories.length > 0) { + const skills = ctx.plugin(SkillLocal, { + providerName: `repository:${config.manifest.name}`, + includeDefaultRoots: false, + customSkillDirs: skillDirectories, + watch: false, + }) + await skills + yield skills.dispose + } + for (const mcpConfig of mcpConfigs) { + const mcp = ctx.plugin(McpClient, mcpConfig) + await mcp + yield mcp.dispose + } + }, `repository-plugin(${config.manifest.name})`) +} + +const preparedRuntime = { + name: 'repository-plugin-runtime', + apply: applyPrepared, +} + +/** + * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. + * @param ctx - plugin context carrying the Loader service. + */ +export function apply(ctx: Context): void { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { + throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) + } + ctx.effect(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) + } + } + }, 'repository-plugin Loader builtin') +} diff --git a/packages/cordis/repository-plugin/src/invariant.ts b/packages/cordis/repository-plugin/src/invariant.ts new file mode 100644 index 0000000000..410e8bf69e --- /dev/null +++ b/packages/cordis/repository-plugin/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`. + * @module @deepseek-ai/dsh-repository-plugin/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' + +/** Cordis companion plugin name. */ +export const name = 'repository-plugin-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns no service state; Loader fibers and the existing skill + * and MCP owners expose the authoritative lifecycle relationships for its composed children. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts new file mode 100644 index 0000000000..893d96f086 --- /dev/null +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -0,0 +1,145 @@ +/** + * Parser for the common `.mcp.json` file consumed by prepared repository plugins. + * @module + */ + +import { z } from 'zod' + +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g + +const stringMap = z.record(z.string(), z.string()) +const stdioServerSchema = z.object({ + type: z.literal('stdio').optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMap.optional(), +}).strict() +const httpServerSchema = z.object({ + type: z.literal('http'), + url: z.string().min(1), + headers: stringMap.optional(), +}).strict() +const documentSchema = z.object({ + mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])), +}).strict() + +/** One supported server entry from the common `.mcp.json` format. */ +export type McpServerDefinition = z.infer | z.infer + +/** Parsed common MCP document before process-environment expansion. */ +export interface McpDocument { + mcpServers: Record +} + +/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */ +export type ResolvedMcpServer = + | { + transport: 'stdio' + serverName: string + command: string + args: string[] + env: Record + cwd: string + } + | { + transport: 'streamable-http' + serverName: string + url: string + headers: Record + } + +function assertTemplate(value: string, location: string): void { + for (const match of value.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[1] as string + if (!ENVIRONMENT_NAME_PATTERN.test(name)) { + throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`) + } + } + if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) { + throw new Error(`${location} contains an unterminated environment placeholder`) + } +} + +function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void { + if ('command' in definition) { + visit(definition.command, `mcpServers.${serverName}.command`) + definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) }) + Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) }) + return + } + visit(definition.url, `mcpServers.${serverName}.url`) + Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) }) +} + +/** + * Parse and validate one common `.mcp.json` document without resolving environment values. + * @param content - UTF-8 JSON document. + * @returns the supported stdio and Streamable HTTP server definitions. + */ +export function parseMcpDocument(content: string): McpDocument { + let value: unknown + try { + value = JSON.parse(content) as unknown + } catch (cause) { + throw new Error('invalid .mcp.json: expected JSON', { cause }) + } + const result = documentSchema.safeParse(value) + if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) + for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { + if (!SERVER_NAME_PATTERN.test(serverName)) { + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`) + } + visitStrings(serverName, definition, assertTemplate) + } + return result.data +} + +function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string { + return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => { + const replacement = environment[name] + if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`) + return replacement + }) +} + +function expandMap(values: Record | undefined, environment: NodeJS.ProcessEnv, location: string): Record { + return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [ + name, + expand(value, environment, `${location}.${name}`), + ])) +} + +/** + * Resolve supported MCP definitions to inputs for the existing MCP client. + * @param document - validated common MCP document. + * @param environment - process environment used for exact `${NAME}` expansion. + * @param cwd - prepared plugin directory used for stdio child processes. + * @returns one existing-client config input per declared server. + */ +export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] { + return Object.entries(document.mcpServers).map(([serverName, definition]) => { + if ('command' in definition) { + return { + transport: 'stdio', + serverName, + command: expand(definition.command, environment, `mcpServers.${serverName}.command`), + args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), + env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), + cwd, + } + } + const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) + const protocol = new URL(url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`mcpServers.${serverName}.url must use http or https`) + } + return { + transport: 'streamable-http', + serverName, + url, + headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), + } + }) +} diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts new file mode 100644 index 0000000000..094cb93020 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' + +describe('repository plugin common .mcp.json support', () => { + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + + expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{ + transport: 'streamable-http', + serverName: 'expo', + url: 'https://mcp.expo.dev/mcp', + headers: {}, + }]) + }) + + it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + datajunction: { + command: 'dj-mcp', + args: ['--endpoint', '${DJ_API_URL}'], + env: { DJ_API_URL: '${DJ_API_URL}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{ + transport: 'stdio', + serverName: 'datajunction', + command: 'dj-mcp', + args: ['--endpoint', 'http://localhost:8000'], + env: { DJ_API_URL: 'http://localhost:8000' }, + cwd: '/plugin', + }]) + }) + + it('fails loud when a declared environment value is absent', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } }, + })) + + expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL') + }) + + it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + local: { type: 'stdio', command: 'local-mcp' }, + remote: { + type: 'http', + url: 'http://${MCP_HOST}/mcp', + headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([ + { + transport: 'stdio', + serverName: 'local', + command: 'local-mcp', + args: [], + env: {}, + cwd: '/plugin', + }, + { + transport: 'streamable-http', + serverName: 'remote', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer test-token' }, + }, + ]) + }) + + it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => { + expect(() => parseMcpDocument('{')).toThrow('expected JSON') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { 'bad name': { command: 'server' } }, + }))).toThrow('server name') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${BAD-NAME}' } }, + }))).toThrow('unsupported environment placeholder') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${UNFINISHED' } }, + }))).toThrow('unterminated environment placeholder') + const ftp = parseMcpDocument(JSON.stringify({ + mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } }, + })) + expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https') + }) + + it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => { + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + }))).toThrow('invalid .mcp.json') + }) +}) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts new file mode 100644 index 0000000000..f8e5807501 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -0,0 +1,243 @@ +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' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SkillService from '@deepseek-ai/dsh-skill' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' +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' + +const roots: string[] = [] + +async function temporaryDirectory(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) + roots.push(directory) + return directory +} + +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`) + return directory +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('dsh-plugin-prepare', () => { + it('copies declared static assets and emits the fixed import-free wrapper', async () => { + const root = await temporaryDirectory('prepare') + await writeSkill(join(root, 'skills'), 'repository-fixture') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + const directory = await writePlugin(root, 'fixture-plugin', { + skills: ['../skills'], + mcpServers: '../.mcp.json', + }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ + name: 'fixture-plugin', + skills: ['dsh-plugin-assets/skills/0'], + mcpServers: 'dsh-plugin-assets/.mcp.json', + }) + const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') + expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) + expect(wrapper).not.toMatch(/\b(?:import|from)\s/) + await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) + .resolves.toContain('Static instructions.') + await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) + .resolves.toContain('mcp.expo.dev') + }) + + it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { + const root = await temporaryDirectory('oauth') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + })) + const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') + await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { + const malformedRoot = await temporaryDirectory('malformed-package') + const malformed = join(malformedRoot, '.dsh-plugin') + await mkdir(malformed) + await writeFile(join(malformed, 'package.json'), '{') + await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') + + 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') + + const missingRoot = await temporaryDirectory('missing-asset') + const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) + await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') + + const absoluteRoot = await temporaryDirectory('absolute-asset') + const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) + await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') + + const wrongTypeRoot = await temporaryDirectory('wrong-type') + await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') + const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') + + const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') + await mkdir(join(wrongMcpRoot, 'not-a-file')) + const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') + + const containingRoot = await temporaryDirectory('containing-root') + const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) + await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') + + const escapedRoot = await temporaryDirectory('escaped-root') + const outside = await temporaryDirectory('outside-root') + 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') + }) + + it('validates prepared wrapper configs with and without MCP assets', () => { + 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' }, + })).toEqual({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + }) + }) +}) + +describe('prepared repository plugin Loader composition', () => { + it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { + const root = await temporaryDirectory('loader') + await writeSkill(join(root, 'skills'), 'loaded-from-repository') + const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ + name: 'loaded-from-repository', + provider: 'repository:loader-fixture', + content: 'Static instructions.', + }) + + await ctx.loader.remove(id) + await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).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({ + mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, + })) + const directory = await writePlugin(root, 'mcp-loader-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.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) + await ctx.loader.remove(id) + await ctx.fiber.dispose() + }) + + it('rejects hostile prepared paths before mounting children', async () => { + const root = await temporaryDirectory('prepared-paths') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath] of [ + ['absolute.mjs', resolve(root)], + ['escaped.mjs', '../outside'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') + } + await ctx.fiber.dispose() + }) + + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered') + + const replacement = { name: 'replacement', apply() {} } + ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) + await ctx.fiber.dispose() + }) +}) + +describe('repository plugin invariant companion', () => { + it('registers its explained empty invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json new file mode 100644 index 0000000000..f7918dcdd9 --- /dev/null +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../skill/skill-local" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/repository-plugin/tsdown.config.ts b/packages/cordis/repository-plugin/tsdown.config.ts new file mode 100644 index 0000000000..ac8e9a5fe0 --- /dev/null +++ b/packages/cordis/repository-plugin/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +/** Build the runtime, invariant, and prepare executable as self-contained entries. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index d1fa4602be..1902122c68 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill/skill-local/README.md -README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 -README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 +README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9 +README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 2077cf852f..836a2a631e 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -14,6 +14,8 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| +| `providerName` | `local` | Unique name used to register this provider on `ctx.skills`. | +| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | @@ -36,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index ba1c71f1bc..2e2cc45ad8 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -14,6 +14,8 @@ | 字段 | 默认值 | 含义 | |---|---|---| +| `providerName` | `local` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 | +| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 | | `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 | @@ -36,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名的隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index a19fa1dde5..aa07443a56 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -47,6 +47,10 @@ export const inject = ['skills'] /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -70,6 +74,8 @@ export interface Config { } export const Config: Schema = z.object({ + providerName: z.string().min(1).default('local'), + includeDefaultRoots: z.boolean().default(true), dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), @@ -138,7 +144,8 @@ export function apply(ctx: Context, config: Config = {}): void { /** Provider that maps local project/user skill roots into `ctx.skills`. */ export class LocalSkillProvider implements SkillProvider { - readonly name = 'local' + readonly name: string + private readonly includeDefaultRoots: boolean private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] @@ -151,6 +158,8 @@ export class LocalSkillProvider implements SkillProvider { control: SkillProviderControl, config: Config = {}, ) { + this.name = config.providerName ?? 'local' + this.includeDefaultRoots = config.includeDefaultRoots ?? true this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) @@ -177,7 +186,7 @@ export class LocalSkillProvider implements SkillProvider { } const candidates: SkillCandidate[] = [] for (const root of roots) { - for (const skill of await discoverRoot(root, this.ctx)) { + for (const skill of await discoverRoot(root, this.ctx, this.name)) { candidates.push(skill) } } @@ -227,21 +236,23 @@ export class LocalSkillProvider implements SkillProvider { private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] - if (cwd !== undefined) { + if (this.includeDefaultRoots && cwd !== undefined) { const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx)) roots.push( { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot }, { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } - roots.push( - ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), - { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, - { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, - ...this.bundledSkillDir === undefined - ? [] - : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], - ) + roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK }))) + if (this.includeDefaultRoots) { + roots.push( + { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, + { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ) + } + if (this.bundledSkillDir !== undefined) { + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + } return roots } } @@ -693,7 +704,7 @@ function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -async function discoverRoot(root: SkillRoot, ctx: Context): Promise { +async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -711,7 +722,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise