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()