Merge remote-tracking branch 'github/master' into feat/web-queue-steer-all

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
_Kerman
2026-08-10 10:21:56 +08:00
3852 changed files with 105025 additions and 31302 deletions

View File

@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
Naming notes:
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.

View File

@@ -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: dec4d71ca2d323fe05f918dd3bf4709cfa01878e
README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009
README.md: eff1d9522e3ca6e8a7efaa20463d73036101f8f5
README.zh.md: cc2d37d3999e2e59095a8000feb3f963d0b4e4a1

View File

@@ -2,52 +2,52 @@
English | [中文](README.zh.md)
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
## Hierarchy
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`e2b/`](e2b/README.md) | E2B providers | POC |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable surface |
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: 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)) 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 |
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
@@ -61,6 +61,6 @@ New packages join existing groups; new groups update their README and this table
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
**Extension plugins depend on Service Definitions, never concrete providers.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities separate Service Definition / Service provider / Consumer roles when they evolve independently; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).

View File

@@ -2,54 +2,54 @@
[English](README.md) | 中文
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类函数插件;所有贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类函数插件贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
## 层级结构
`packages/<group>/<pkg>/`组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的ctx 键映射。**
按组置`packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责ctx 键映射。**
| 组 | 职责 | 发布预期 |
|---|---|---|
| [`core/`](core/README.md) | 产品 API 主干会话、提示词、工具、agent智能体服务与具体循环 | 产品:稳定表面 |
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
| [`sandbox/`](sandbox/README.md) | 进程限制 seambwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
| [`fs/`](fs/README.md) | 文件系统能力系列seam、本地实现面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |
| [`skill/`](skill/README.md) | Skill技能能力系列提供方注册表、本地提供方和面向模型的目录加载器 | 产品:稳定表面 |
| [`compact/`](compact/README.md) | 压缩compaction能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 |
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
| [`core/`](core/README.md) | 产品 API 主干会话、提示词、工具、agent智能体服务与具体循环 | 产品:稳定接口 |
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定接口 |
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定接口 |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:Service Definition + 本地进程树提供方 | 产品:稳定接口 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定接口 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现面向模型的工具 | 产品:稳定接口 |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定接口 |
| [`sandbox/`](sandbox/README.md) | 进程限制 seambwrap/Landlock/Seatbelt 后端 | 产品:稳定接口 |
| [`fs/`](fs/README.md) | 文件系统能力系列seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定接口 |
| [`lsp/`](lsp/README.md) | LSP 能力系列seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定接口 |
| [`skill/`](skill/README.md) | skill技能能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定接口 |
| [`compact/`](compact/README.md) | 压缩compaction能力系列Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定接口 |
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定接口 |
| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定接口 |
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定接口 |
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件提供方 | 产品:稳定接口 |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
| [`storage/`](storage/README.md) | 会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
| [`scaffold/`](scaffold/README.md) | 创建启动驱动项目的工具helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
| [`examples/`](examples/README.md) | 演示组合包agent-spine + CLI/ACP/JSON-RPC bin由叶节点加载 | 支持:示例基础设施 |
| [`support/`](support/README.md) | 支持基础设施testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
@@ -61,6 +61,6 @@
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)`pnpm run gen-module-graph`CI 中有新鲜度门禁)。
**扩展插件依赖接口,绝不依赖具体循环** `dsh-agent-loop` 可替换UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
**扩展插件依赖 Service Definition,绝不依赖具体提供方** `dsh-agent-loop` 可替换UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力在 Service DefinitionService providerConsumer 角色需要独立演进时将其分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。

View File

@@ -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/acp/README.md
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5
README.zh.md: fb9f419e23aef5b3bdf0bdd486b331eb2f3f236a

View File

@@ -2,10 +2,10 @@
English | [中文](README.zh.md)
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface.
| Package | Role |
|---|---|
| [`acp/`](acp/README.md) | Automation-only ACP server. |
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
The server contract is documented in [`acp/README.md`](acp/README.md).

View File

@@ -2,10 +2,10 @@
[English](README.md) | 中文
ACPAgent Client Protocol组将 harness 中的 agent智能体公开给程序化客户端。它是互操作传输层而非展示或人机交互层。
ACPAgent Client Protocol通过该协议将 harness 中的 agent智能体公开给程序化客户端。它是互操作传输层不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口
| 包 | 职责 |
|---|---|
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器
服务器约定见 [`acp/README.md`](acp/README.md)

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17

View File

@@ -17,14 +17,14 @@
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。
## 协议
## 协议约
| 方法 | 行为 |
|---|---|
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent接受空的 `additionalDirectories``mcpServers`,拒绝非空值。 |
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
| `session/cancel` | 仅取消指定的 agent并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许拒绝选项。客户端可以自动回答。 |
@@ -37,7 +37,7 @@
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此仅 ACP 的插件重载不会遗留 agent。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入 idle 前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入空闲状态前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
## 运行

View File

@@ -66,7 +66,7 @@ function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Plugin config: the provider/model target used for each ACP-created agent. */
/** Plugin config: the provider/model selection used for each ACP-created agent. */
export interface AcpConfig {
/** Provider route for created agents. */
provider?: string
@@ -100,7 +100,7 @@ interface SessionRecord {
/**
* Mount the automation-only ACP server.
* @param ctx - Cordis context carrying the agent factory and session events.
* @param config - Initial provider/model target and optional test transport.
* @param config - Initial provider/model selection and optional test transport.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture the

View File

@@ -24,7 +24,7 @@
"path": "../../core/agent"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../support/invariants"

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/README.md
README.md: 3f99d6d45ddc64ac068457b0d03e533d21451a5f
README.zh.md: 003383c5f6238b37893927813959d52d96350c3e
# pnpm run verify-translation-pairing --write packages/api/README.md
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
README.zh.md: f4e882570d2dc929cca466e0bdb05bb2943f2c93

17
packages/api/README.md Normal file
View File

@@ -0,0 +1,17 @@
# api/ — Remote API layers
English | [中文](README.zh.md)
The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments.
| Package | Role | ctx key |
|---|---|---|
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` |
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
## Known Limitations and Deferred Work
- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts.
- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy.

17
packages/api/README.zh.md Normal file
View File

@@ -0,0 +1,17 @@
# api/Remote API 层
[English](README.md) | 中文
面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。
| 包 | 职责 | ctx key |
|---|---|---|
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
运行时依赖方向为 `remotes → gateway → connection → webserver`BFF 消费共享的 `TypeRTClientRemote` 约定Gateway 把传输交给 ConnectionConnection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
## 已知限制与延期工作
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection``api/webserver` 下,而无需改变服务约定。
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver使已迁移与旧方法共用同一套 Agent/Session 身份策略。

View File

@@ -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/api/gateway/README.md
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-api-gateway
English | [中文](README.zh.md)
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
## Client service: `ClientRemote` (ctx key: `remote`)
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
## Model Experience
None, as the package dispatches application calls and registers no prompt, tool, or session event.
#### KV Cache effect
No direct effect; invoked business Services own any model-visible result.
## Known Limitations and Deferred Work
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-api-gateway
[English](README.md) | 中文
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway``@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes将传输、请求关联、信任和响应封装交给 Connection。
## Host 服务:`TypertGatewayService`ctx key`typertGateway`
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote``@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver业务包注册稳定声明与默认策略Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridgehandler 将已认领 endpoint 分发给 Gateway未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数Connection 将它提供给 GatewayGateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
## Client 服务:`ClientRemote`ctx key`remote`
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service并在最后一个方法撤回后卸载。重复端点、命名空间冲突以及缺少生成的严格编解码器的描述符都会在方法可调用前报错。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
## 模型体验
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
#### KV Cache 影响
无直接影响;被调用的业务服务负责产生任何模型可见结果。
## 已知限制与延期工作
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON不校验生成的业务类型也绝不会推断可选字段。
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。

View File

@@ -0,0 +1,66 @@
{
"name": "@deepseek-ai/dsh-api-gateway",
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,498 @@
/**
* Client projection of generated TypeRT Remote descriptors. Contributions
* install traced `remote.<namespace>` services; no JavaScript Proxy
* participates in method lookup, invocation, or type exposure.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
TypeRTClientRemote,
TypeRTCodec,
TypeRTDisposer,
TypeRTRemoteContribution,
} from '@deepseek-ai/dsh-type-meta'
interface MountToken {
active: boolean
readonly abort: AbortController
}
interface ScopedProjection {
readonly context: string
readonly wire: string
readonly codec: TypeRTCodec
readonly parameterIndex?: number
}
interface DirectMethod {
readonly descriptor: InvocationDescriptor
readonly token: MountToken
}
interface ScopedMethod extends DirectMethod {
readonly projection: ScopedProjection
}
interface RemoteMethodRecord {
direct?: DirectMethod
scoped?: ScopedMethod
}
interface BoundContextIdentity {
readonly value: unknown
}
interface RemoteNamespaceHandle {
readonly service: RemoteNamespaceService
readonly dispose: TypeRTDisposer
}
/** Typed Remote service augmented by generated direct namespaces. */
export type ClientRemote = TypeRTClientRemote
declare module 'cordis' {
interface Context {
/** Generated Remote namespaces selected by the Client assembly. */
remote: ClientRemote
}
}
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
export const inject = ['typert', 'connection']
/**
* Install the typed Client Remote service.
* @param ctx - Client Cordis root.
*/
export function apply(ctx: Context): void {
new ClientRemoteService(ctx)
}
class ClientRemoteService extends Service implements TypeRTClientRemote {
private readonly ownerCtx: Context
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
private mutations = Promise.resolve()
constructor(ctx: Context) {
super(ctx, 'remote')
this.ownerCtx = ctx
}
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
const callerCtx = this.ctx
const owned = callerCtx.effect(async () => {
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
return () => this.enqueue(dispose)
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
await owned
return async () => { await owned() }
}
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
const result = this.mutations.then(operation, operation)
this.mutations = result.then(() => undefined, () => undefined)
return result
}
private async mountContribution(
callerCtx: Context,
contribution: TypeRTRemoteContribution,
): Promise<TypeRTDisposer> {
this.validateContribution(contribution)
const disposeRemote = callerCtx.typert.remotes.register(contribution)
const installed: TypeRTDisposer[] = []
try {
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
} catch (error) {
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
throw error
}
return async () => {
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
}
}
private validateContribution(contribution: TypeRTRemoteContribution): void {
const direct = new Map<string, Set<string>>()
const scoped = new Map<string, Set<string>>()
const add = (
table: Map<string, Set<string>>,
descriptor: InvocationDescriptor,
kind: 'direct' | 'scoped',
): void => {
const methods = table.get(descriptor.namespace) ?? new Set<string>()
if (methods.has(descriptor.method)) {
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
}
methods.add(descriptor.method)
table.set(descriptor.namespace, methods)
const namespace = this.namespaces.get(descriptor.namespace)?.service
if (namespace?.has(kind, descriptor.method) === true) {
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
}
}
for (const descriptor of contribution.descriptors) {
requireStrictDescriptor(descriptor)
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
}
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
for (const namespace of namespaces) {
const service = this.namespaces.get(namespace)?.service
if (service === undefined) {
if (namespace in this) {
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
}
const serviceKey = remoteServiceKey(namespace)
const property = this.ownerCtx.reflect.props[serviceKey]
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
}
}
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
else service.assertMethodAvailable(method)
}
}
}
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
const token: MountToken = { active: true, abort: new AbortController() }
const installed: TypeRTDisposer[] = []
try {
if (descriptor.invocation.kind === 'direct') {
installed.push(await this.installDirect(descriptor, token))
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
} catch (error) {
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
throw error
}
return async () => {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!token.active) return
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
}
}
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installDirect(descriptor, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
return async () => {
namespace.service.remove('direct', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private async installScoped(
descriptor: InvocationDescriptor,
projection: ScopedProjection,
token: MountToken,
): Promise<TypeRTDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installScoped(descriptor, projection, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
return async () => {
namespace.service.remove('scoped', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
let namespace = this.namespaces.get(name)
if (namespace !== undefined) return namespace
let service: RemoteNamespaceService | undefined
const fiber = this.ownerCtx.plugin({
name: remoteServiceKey(name),
apply: (ctx: Context) => {
service = new RemoteNamespaceService(
ctx,
name,
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
)
},
})
try {
await fiber
} catch (error) {
await fiber.dispose()
throw error
}
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
namespace = { service, dispose: fiber.dispose }
this.namespaces.set(name, namespace)
return namespace
}
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
this.namespaces.delete(name)
await namespace.dispose()
}
private invokeMethod(
direct: DirectMethod | undefined,
scoped: ScopedMethod | undefined,
callerCtx: Context,
values: readonly unknown[],
): Promise<unknown> {
if (scoped !== undefined) {
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
const identity = binder?.identity(callerCtx)
if (identity !== undefined) {
return this.invoke(
scoped.descriptor,
scoped.projection,
scoped.token,
callerCtx,
values,
{ value: identity },
)
}
}
if (direct !== undefined) {
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
}
if (scoped !== undefined) {
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
}
throw new Error('client api: Remote method is no longer mounted')
}
private async invoke(
descriptor: InvocationDescriptor,
projection: ScopedProjection | undefined,
token: MountToken,
callerCtx: Context,
values: readonly unknown[],
boundIdentity?: BoundContextIdentity,
): Promise<unknown> {
const endpoint = endpointOf(descriptor)
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
if (values.length !== expected && !hasCallerSignal) {
const contract = descriptor.cancellation === undefined
? `${String(expected)} argument(s)`
: `${String(expected)} business argument(s) plus an optional AbortSignal`
throw new Error(
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
)
}
const args = Object.create(null) as Record<string, unknown>
if (projection !== undefined) {
const binder = boundIdentity === undefined
? this.ownerCtx.typert.contexts.getClient(projection.context)
: undefined
if (boundIdentity === undefined && binder === undefined) {
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
}
const identity = boundIdentity === undefined
? binder?.identity(callerCtx)
: boundIdentity.value
if (identity === undefined) {
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
}
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
}
let valueIndex = 0
descriptor.parameters.forEach((parameter, parameterIndex) => {
if (parameterIndex === projection?.parameterIndex) return
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
valueIndex += 1
})
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
if (!result.ok) throw remoteFailure(endpoint, result.error)
return parse(descriptor.result, result.value, endpoint, 'result')
}
}
type InvokeRemote = (
direct: DirectMethod | undefined,
scoped: ScopedMethod | undefined,
callerCtx: Context,
args: readonly unknown[],
) => Promise<unknown>
class RemoteNamespaceService extends Service {
private readonly methods = new Map<string, RemoteMethodRecord>()
private readonly namespace: string
static assertMethodAvailable(namespace: string, method: string): void {
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
}
}
constructor(
ctx: Context,
name: string,
private readonly invokeRemote: InvokeRemote,
) {
super(ctx, remoteServiceKey(name))
this.namespace = name
}
assertMethodAvailable(method: string): void {
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
if (method in this && !this.methods.has(method)) {
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`)
}
}
get empty(): boolean {
return this.methods.size === 0
}
has(kind: 'direct' | 'scoped', method: string): boolean {
return this.methods.get(method)?.[kind] !== undefined
}
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
this.install(descriptor.method, 'direct', { descriptor, token })
}
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
}
private install(method: string, kind: 'direct', value: DirectMethod): void
private install(method: string, kind: 'scoped', value: ScopedMethod): void
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
this.assertMethodAvailable(method)
let record = this.methods.get(method)
const fresh = record === undefined
record ??= {}
if (fresh) {
Object.defineProperty(this, method, {
configurable: true,
enumerable: true,
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
const callerCtx = this.ctx
const current = this.methods.get(method)
const direct = current?.direct
const scoped = current?.scoped
return (...args: unknown[]) => {
return this.invokeRemote(direct, scoped, callerCtx, args)
}
},
})
this.methods.set(method, record)
}
if (kind === 'direct') record.direct = value
else record.scoped = value as ScopedMethod
}
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
const record = this.methods.get(method)
const current = record?.[kind]
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
if (record === undefined || current?.token !== token) return
if (kind === 'direct') delete record.direct
else delete record.scoped
if (record.direct !== undefined || record.scoped !== undefined) return
this.methods.delete(method)
Reflect.deleteProperty(this, method)
}
}
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
function remoteServiceKey(namespace: string): string {
return `remote.${namespace}`
}
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
return `${descriptor.namespace}/${descriptor.method}`
}
function mountActive(token: MountToken): boolean {
return token.active
}
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
if (descriptor.invocation.kind === 'context') {
return {
context: descriptor.invocation.context,
wire: descriptor.invocation.wire,
codec: descriptor.invocation.codec,
}
}
if (descriptor.scope === undefined) return undefined
const lookupParameters = descriptor.parameters
.map((parameter, index) => ({ parameter, index }))
.filter(candidate => candidate.parameter.source === 'lookup')
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
if (selected === undefined
|| selected.parameter.wire !== descriptor.scope.wire
|| selected.parameter.lookup !== descriptor.scope.context) {
throw new Error(
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
)
}
return {
context: descriptor.scope.context,
wire: descriptor.scope.wire,
codec: selected.parameter.codec,
parameterIndex: selected.index,
}
}
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
const endpoint = endpointOf(descriptor)
requireStrictCodec(descriptor.result, endpoint, 'result')
for (const parameter of descriptor.parameters) {
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
}
if (descriptor.invocation.kind === 'context') {
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
}
}
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
if (codec.mode !== 'strict') {
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
}
}
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
if (codec.mode !== 'strict') {
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
}
try {
return codec.schema.parse(value)
} catch (cause) {
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
}
}
function remoteFailure(endpoint: string, error: RpcError): Error {
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
}

View File

@@ -0,0 +1,638 @@
/**
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
* Transport, request correlation, and response envelopes belong to Connection.
* @module @deepseek-ai/dsh-api-gateway
*/
import { Context, Service, symbols } from 'cordis'
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
import {
remoteMethods,
TypeRTLookupFailure,
type InvocationDescriptor,
type InvocationParameterDescriptor,
type TypeRTCodec,
type TypeRTGatewayBinding,
} from '@deepseek-ai/dsh-type-meta'
import type {
InvokeRemoteRequest,
TypertGateway,
TypertGatewayErrorCode,
} from './types.ts'
export type {
InvokeRemoteRequest,
TypertGateway,
TypertGatewayErrorCode,
} from './types.ts'
interface GatewayErrorOptions {
readonly cause?: unknown
readonly field?: string
}
interface ResolvedBinding {
readonly binding: TypeRTGatewayBinding
readonly original: object
}
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
const NEVER_ABORTED_SIGNAL = new AbortController().signal
/** Dispatch failure produced outside the invoked business method. */
export class TypertGatewayError extends Error {
/** Machine-readable failure category. */
readonly code: TypertGatewayErrorCode
/** Canonical `<namespace>/<method>` endpoint. */
readonly endpoint: string
/** Affected wire field when the failure is field-specific. */
readonly field: string | undefined
/**
* Construct a Gateway failure without embedding boundary values in its message.
* @param code - stable failure category.
* @param endpoint - canonical Remote endpoint.
* @param message - correction-oriented diagnostic without sensitive values.
* @param options - optional field and contained cause.
*/
constructor(
code: TypertGatewayErrorCode,
endpoint: string,
message: string,
options: GatewayErrorOptions = {},
) {
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
this.name = 'TypertGatewayError'
this.code = code
this.endpoint = endpoint
this.field = options.field
}
}
/**
* Resolve strict generated definitions or conservative SRC markers against
* current Cordis Services and TypeRT providers.
* @typert service typertGateway
*/
export class TypertGatewayService extends Service implements TypertGateway {
static inject = ['typert']
private srcClaims: ReadonlySet<string> | undefined
/**
* Register the Gateway against the active TypeRT registry.
* @param ctx - owning Host Context with TypeRT registry access.
*/
constructor(ctx: Context) {
super(ctx, 'typertGateway')
ctx.on('internal/service', () => {
this.srcClaims = undefined
})
ctx.inject(['connection'], (connectionCtx) => {
connectionCtx.connection.rpc.intercept(
'/api',
endpoint => this.claimsEndpoint(endpoint),
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
{ authority: 'trusted-host' },
)
})
}
private claimsEndpoint(endpoint: string): boolean {
const segments = endpoint.split('/')
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
this.srcClaims ??= this.collectSrcClaims()
return this.srcClaims.has(endpoint)
}
private collectSrcClaims(): ReadonlySet<string> {
const claims = new Set<string>()
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
if (definition.type !== 'service') continue
const receiver = this.ctx.get(serviceKey) as unknown
if (!isObject(receiver)) continue
const original = originalOf(receiver)
const binding = Reflect.get(original, 'typertGateway') as unknown
if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
const namespace = Reflect.get(binding, 'namespace') as string
for (const candidate of remoteMethods(original)) {
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
}
}
return claims
}
/**
* Invoke one live Remote method through strict generated reflection or SRC markers.
* @param request - decoded endpoint and exact named wire arguments.
* @returns the validated business result.
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
*/
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
const endpoint = endpointOf(request.namespace, request.method)
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
assertExactArguments(request.args, descriptor, endpoint)
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
const receiver = receiverContext.get(descriptor.service) as unknown
if (!isObject(receiver)) {
throw new TypertGatewayError(
'service-unavailable',
endpoint,
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
)
}
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
const args = await Promise.all(descriptor.parameters.map(parameter =>
this.resolveParameter(parameter, request.args, endpoint)))
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
const implementation = descriptor.implementation ?? descriptor.method
const method = Reflect.get(receiver, implementation) as unknown
if (typeof method !== 'function') {
throw new TypertGatewayError(
'method-unavailable',
endpoint,
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
)
}
const result = await Reflect.apply(method, receiver, args) as unknown
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
}
private async dispatchRpc(
endpoint: string,
payload: unknown,
signal: AbortSignal,
): Promise<ConnectionRpcResult> {
return this.invokeRpc(endpoint, payload, signal)
}
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
try {
const segments = endpoint.split('/')
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
}
const [namespace, method] = segments as [string, string]
if (!isObject(payload)
|| !isPlainObject(payload)
|| Reflect.ownKeys(payload).length !== 1
|| !Object.hasOwn(payload, 'args')
|| !isObject(payload.args)
|| !isPlainObject(payload.args)) {
throw new Error('Remote payload must contain exactly one plain-object args field')
}
const value = await this.invoke({
namespace,
method,
args: payload.args,
signal,
})
return { ok: true, value }
} catch (error) {
return rpcFailure(error)
}
}
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
const strict = this.ctx.typert.local.get(endpoint)
if (strict !== undefined) return strict
if (this.ctx.typert.local.hasSeen(endpoint)) {
throw new TypertGatewayError(
'definition-unavailable',
endpoint,
'its strict definition was withdrawn and SRC fallback is forbidden',
)
}
return this.resolveSrcDescriptor(namespace, method, endpoint)
}
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
const candidates: InvocationDescriptor[] = []
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
if (definition.type !== 'service') continue
const receiver = this.ctx.get(serviceKey) as unknown
if (!isObject(receiver)) continue
const original = originalOf(receiver)
const value = Reflect.get(original, 'typertGateway') as unknown
if (value === undefined) continue
const binding = readBinding(value, original, serviceKey, endpoint)
if (binding.namespace !== namespace) continue
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
if (marker === undefined) continue
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
}
if (candidates.length === 0) {
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
}
if (candidates.length > 1) {
throw new TypertGatewayError(
'ambiguous-endpoint',
endpoint,
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
)
}
return candidates[0] as InvocationDescriptor
}
private srcDescriptor(
binding: TypeRTGatewayBinding,
marker: ReturnType<typeof remoteMethods>[number],
method: string,
endpoint: string,
): InvocationDescriptor {
const names = methodParameterNames(binding.service, marker.method, endpoint)
const signalIndex = names.indexOf('signal')
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
'SRC cancellation parameter signal must be the final parameter',
{ field: 'signal' },
)
}
const cancellation = signalIndex >= 0
? { parameter: 'signal' as const }
: undefined
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
const parameters: InvocationParameterDescriptor[] = []
const wires = new Set<string>()
for (const name of businessNames) {
const matches = this.ctx.typert.lookups.definitions()
.filter(definition => definition.parameter === name)
if (matches.length > 1) {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
{ field: name },
)
}
const match = matches[0]
const parameter: InvocationParameterDescriptor = match === undefined
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
: {
name,
wire: match.wire,
source: 'lookup',
lookup: match.key,
codec: { mode: 'src-json' },
}
if (wires.has(parameter.wire)) {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
{ field: parameter.wire },
)
}
wires.add(parameter.wire)
parameters.push(parameter)
}
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
if (marker.invocation.kind === 'context') {
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
if (provider === undefined) {
throw new TypertGatewayError(
'context-unavailable',
endpoint,
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
)
}
if (wires.has(provider.wire)) {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
{ field: provider.wire },
)
}
receiver = {
kind: 'context',
context: marker.invocation.context,
wire: provider.wire,
codec: { mode: 'src-json' },
}
}
return {
id: `src:${binding.serviceKey}#${endpoint}`,
service: binding.serviceKey,
namespace: binding.namespace,
method,
...(marker.method === method ? {} : { implementation: marker.method }),
invocation: receiver,
parameters,
...(cancellation === undefined ? {} : { cancellation }),
result: { mode: 'src-json' },
}
}
private async resolveReceiverContext(
descriptor: InvocationDescriptor,
args: Readonly<Record<string, unknown>>,
endpoint: string,
): Promise<Context> {
if (descriptor.invocation.kind === 'direct') return this.ctx
const invocation = descriptor.invocation
const provider = this.ctx.typert.contexts.getHost(invocation.context)
if (provider === undefined) {
throw new TypertGatewayError(
'context-unavailable',
endpoint,
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
)
}
if (provider.wire !== invocation.wire
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
throw new TypertGatewayError(
'provider-mismatch',
endpoint,
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
{ field: invocation.wire },
)
}
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
let context: Context | undefined
try {
context = await provider.resolve(identity)
} catch (cause) {
if (cause instanceof TypeRTLookupFailure) throw cause
throw new TypertGatewayError(
'context-failed',
endpoint,
`Context provider ${JSON.stringify(invocation.context)} failed`,
{ cause, field: invocation.wire },
)
}
if (context === undefined) {
throw new TypertGatewayError(
'context-not-found',
endpoint,
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
{ field: invocation.wire },
)
}
return context
}
private async resolveParameter(
parameter: InvocationParameterDescriptor,
args: Readonly<Record<string, unknown>>,
endpoint: string,
): Promise<unknown> {
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
if (parameter.source === 'json') return value
const key = parameter.lookup
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
if (key === undefined) {
throw new TypertGatewayError(
'lookup-unavailable',
endpoint,
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
{ field: parameter.wire },
)
}
const provider = this.ctx.typert.lookups.get(key)
if (provider === undefined) {
throw new TypertGatewayError(
'lookup-unavailable',
endpoint,
`lookup provider ${JSON.stringify(key)} is unavailable`,
{ field: parameter.wire },
)
}
if (provider.wire !== parameter.wire
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
throw new TypertGatewayError(
'provider-mismatch',
endpoint,
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
{ field: parameter.wire },
)
}
let resolved: unknown
try {
resolved = await provider.resolve(value)
} catch (cause) {
if (cause instanceof TypeRTLookupFailure) throw cause
throw new TypertGatewayError(
'lookup-failed',
endpoint,
`lookup provider ${JSON.stringify(key)} failed`,
{ cause, field: parameter.wire },
)
}
if (resolved === undefined) {
throw new TypertGatewayError(
'lookup-not-found',
endpoint,
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
{ field: parameter.wire },
)
}
return resolved
}
}
function rpcFailure(error: unknown): ConnectionRpcResult {
if (error instanceof TypeRTLookupFailure) {
return { ok: false, error: error.failure as ConnectionRpcError }
}
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
},
}
}
function endpointOf(namespace: string, method: string): string {
return `${namespace}/${method}`
}
function validateBinding(
receiver: object,
serviceKey: string,
namespace: string,
endpoint: string,
): ResolvedBinding {
const original = originalOf(receiver)
const value = Reflect.get(original, 'typertGateway') as unknown
if (value === undefined) {
throw new TypertGatewayError(
'binding-invalid',
endpoint,
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
)
}
return {
binding: readBinding(value, original, serviceKey, endpoint, namespace),
original,
}
}
function readBinding(
value: unknown,
original: object,
serviceKey: string,
endpoint: string,
namespace?: string,
): TypeRTGatewayBinding {
if (!isObject(value)
|| Reflect.get(value, 'service') !== original
|| Reflect.get(value, 'serviceKey') !== serviceKey
|| typeof Reflect.get(value, 'namespace') !== 'string'
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
throw new TypertGatewayError(
'binding-invalid',
endpoint,
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
)
}
return value as unknown as TypeRTGatewayBinding
}
function originalOf(receiver: object): object {
const original = Reflect.get(receiver, symbols.original) as unknown
return isObject(original) ? original : receiver
}
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
let prototype: object | null = Object.getPrototypeOf(service) as object | null
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
while (prototype !== null) {
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
if (descriptor !== undefined) {
if ('value' in descriptor && typeof descriptor.value === 'function') {
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
}
break
}
prototype = Object.getPrototypeOf(prototype) as object | null
}
if (implementation === undefined) {
throw new TypertGatewayError(
'method-unavailable',
endpoint,
`Remote marker has no prototype method ${JSON.stringify(method)}`,
)
}
const source = Function.prototype.toString.call(implementation)
const open = source.indexOf('(')
const close = source.indexOf(')', open + 1)
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
const body = source.slice(open + 1, close).trim()
if (body.length === 0) return []
const parts = body.split(',').map(part => part.trim())
const names = new Set<string>()
for (const part of parts) {
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
names.add(part)
}
return [...names]
}
function invalidSignature(endpoint: string, method: string): never {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
)
}
function assertExactArguments(
args: Readonly<Record<string, unknown>>,
descriptor: InvocationDescriptor,
endpoint: string,
): void {
if (!isPlainObject(args)) {
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
}
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
const actual = Reflect.ownKeys(args)
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
if (extra.length === 0 && missing.length === 0) return
const clauses: string[] = []
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
}
function decode(
codec: TypeRTCodec,
value: unknown,
code: 'input-invalid' | 'result-invalid',
endpoint: string,
field: string,
): unknown {
try {
if (codec.mode === 'strict') value = codec.schema.parse(value)
assertJsonValue(value, new Set())
return value
} catch (cause) {
throw new TypertGatewayError(
code,
endpoint,
code === 'input-invalid'
? `wire field ${JSON.stringify(field)} failed boundary validation`
: 'business result failed boundary validation',
{ cause, field },
)
}
}
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
if (typeof value === 'number') {
if (Number.isFinite(value)) return
throw new TypeError('non-finite number is not JSON-safe')
}
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
ancestors.add(value)
try {
if (Array.isArray(value)) {
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
throw new TypeError('sparse or decorated array is not JSON-safe')
}
for (let index = 0; index < value.length; index += 1) {
if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
assertJsonValue(value[index], ancestors)
}
return
}
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
throw new TypeError('non-data property is not JSON-safe')
}
assertJsonValue(descriptor.value, ancestors)
}
} finally {
ancestors.delete(value)
}
}
function isPlainObject(value: object): value is Record<string, unknown> {
if (Array.isArray(value)) return false
const prototype = Object.getPrototypeOf(value) as object | null
return prototype === null || prototype === Object.prototype
}
function isObject(value: unknown): value is object {
return (typeof value === 'object' && value !== null) || typeof value === 'function'
}
export default TypertGatewayService

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`.
* @module @deepseek-ai/dsh-api-gateway/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
/** Cordis companion plugin name. */
export const name = 'api-gateway-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
* state, while Client methods and descriptors mutate in one owned effect.
*/
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 */

View File

@@ -0,0 +1,54 @@
/**
* Carrier-independent TypeRT Gateway request, service, and error contracts.
* @module @deepseek-ai/dsh-api-gateway/types
*/
/** One Remote method request after a carrier has decoded its envelope. */
export interface InvokeRemoteRequest {
/** Remote namespace selected by the generated descriptor. */
readonly namespace: string
/** Exported Service method name. */
readonly method: string
/** Named wire values; fields must exactly match the descriptor. */
readonly args: Readonly<Record<string, unknown>>
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
readonly signal?: AbortSignal
}
/** Stable infrastructure and boundary failures emitted before or after business execution. */
export type TypertGatewayErrorCode =
| 'ambiguous-endpoint'
| 'arguments-invalid'
| 'binding-invalid'
| 'context-failed'
| 'context-not-found'
| 'context-unavailable'
| 'definition-unavailable'
| 'input-invalid'
| 'invocation-unavailable'
| 'lookup-failed'
| 'lookup-not-found'
| 'lookup-unavailable'
| 'method-unavailable'
| 'provider-mismatch'
| 'result-invalid'
| 'service-unavailable'
| 'signature-invalid'
/** Host dispatcher consumed by Connection adapters. */
export interface TypertGateway {
/**
* Invoke one live Remote method without assuming a carrier or response envelope.
* @param request - decoded endpoint and named wire arguments.
* @returns the validated business result.
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
*/
invoke(request: InvokeRemoteRequest): Promise<unknown>
}
declare module 'cordis' {
interface Context {
/** Host dispatcher for TypeRT Remote calls. */
typertGateway: TypertGateway
}
}

View File

@@ -0,0 +1,573 @@
import { Context, Service } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
TypeRTClientRemote,
TypeRTContext,
TypeRTRemoteScopeApi,
TypeRTRemoteNamespace,
} from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { apply, inject } from '../src/client/index.ts'
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTContextMap {
fixture: TypeRTContext<string>
}
interface TypeRTRemoteMap {
'goals/create': (
agentId: string,
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
}
interface TypeRTRemoteScopeMap {
'fixture:goals/create': (
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
}
interface TypeRTRemoteNamespaceMap {
goals: TypeRTRemoteNamespace<'goals'>
}
}
type FixtureContext = Omit<Context, 'remote'> & {
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
}
const idSchema = z.string().min(1)
const requestSchema = z.object({ objective: z.string().min(1) })
const createResultSchema = z.object({ ref: z.string().min(1) })
const renameResultSchema = z.object({ renamed: z.boolean() })
function directDescriptor(): InvocationDescriptor {
return {
id: '@fixture/goals#goals/create',
service: 'goals',
namespace: 'goals',
method: 'create',
invocation: { kind: 'direct' },
scope: { context: 'fixture', wire: 'agentId' },
parameters: [{
name: 'agent',
wire: 'agentId',
source: 'lookup',
lookup: 'fixture',
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
}, {
name: 'request',
wire: 'request',
source: 'json',
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
}],
cancellation: { parameter: 'signal' },
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
}
}
function contextDescriptor(): InvocationDescriptor {
return {
id: '@fixture/goals#goals/rename',
service: 'goals',
namespace: 'goals',
method: 'rename',
invocation: {
kind: 'context',
context: 'fixture',
wire: 'agentId',
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
},
parameters: [{
name: 'request',
wire: 'request',
source: 'json',
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
}],
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
}
}
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
await ctx.plugin({ inject, apply })
return ctx
}
describe('Client TypeRT API', () => {
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const businessGoals = { owner: 'host business service' }
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
const retained = ctx.remote.goals.create
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal),
)
const callerAbort = new AbortController()
await expect(ctx.remote.goals.create(
'agent-1',
{ objective: 'cancel me' },
callerAbort.signal,
)).resolves.toEqual({ ref: 'goal-1' })
const combinedSignal = call.mock.calls.at(-1)?.[3]
expect(combinedSignal).toBeInstanceOf(AbortSignal)
expect(combinedSignal).not.toBe(callerAbort.signal)
const cancellation = new Error('caller cancelled')
callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('goals')).toBe(businessGoals)
expect(ctx.typert.remotes.list()).toEqual([])
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
disposeBusinessGoals()
})
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/create',
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
.rejects.toThrow('expected 2 business argument(s)')
await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
})
it('uses the caller Context identity for scoped namespace methods', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { renamed: true } })
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const assembly = ctx.plugin(Object.assign(
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
{ inject: ['remote'] },
))
await assembly
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenCalledWith(
'/api',
'goals/rename',
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
expect.any(AbortSignal),
)
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
.rejects.toThrow('requires a "fixture" Context')
await assembly.dispose()
expect(ctx.get('remote.goals')).toBeUndefined()
})
it('rejects weak descriptors and namespace collisions before registration', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const weak: InvocationDescriptor = {
...directDescriptor(),
result: { mode: 'src-json' },
}
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
.rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/conflict',
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
})).rejects.toThrow('conflicts with the Remote service')
expect(ctx.typert.remotes.list()).toEqual([])
})
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { renamed: true } })
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const direct = directDescriptor()
const context = contextDescriptor()
await expect(ctx.remote.$mount({
package: '@fixture/direct-duplicates',
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
})).rejects.toThrow('repeats direct method')
await expect(ctx.remote.$mount({
package: '@fixture/scoped-duplicates',
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
})).rejects.toThrow('repeats scoped method')
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
await expect(ctx.remote.$mount({
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
})).rejects.toThrow('direct method goals/create is already mounted')
await disposeDirect()
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
await expect(ctx.remote.$mount({
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
})).rejects.toThrow('scoped method goals/rename is already mounted')
await expect(ctx.remote.$mount({
package: '@fixture/service-method-conflict',
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
})).rejects.toThrow('conflicts with its namespace service')
const scopedService = ctx.get('remote.goals') as unknown as object
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
await expect(ctx.remote.$mount({
package: '@fixture/service-own-property-conflict',
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
})).rejects.toThrow('conflicts with its namespace service')
Reflect.deleteProperty(scopedService, 'custom')
await disposeScoped()
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
await expect(ctx.remote.$mount({
package: '@fixture/context-property-conflict',
descriptors: [{ ...context, namespace: 'typert' }],
})).rejects.toThrow('conflicts with an existing Remote namespace')
await disposeRemoteTypert()
const disposeMultipleScoped = await ctx.remote.$mount({
package: '@fixture/multiple-scoped',
descriptors: [directDescriptor(), contextDescriptor()],
})
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenLastCalledWith(
'/api',
'goals/rename',
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
expect.any(AbortSignal),
)
await disposeMultipleScoped()
})
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = {
...first,
id: '@fixture/goals#goals/archive',
method: 'archive',
}
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'archive') throw new Error('fixture later-descriptor failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
.rejects.toThrow('fixture later-descriptor failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
await retry()
})
it('rolls back a direct projection when its scoped projection fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const disposeContext = await ctx.remote.$mount({
package: '@fixture/context-anchor',
descriptors: [contextDescriptor()],
})
const namespace = ctx.get('remote.goals') as unknown as {
installScoped: (...args: unknown[]) => void
readonly create?: unknown
}
const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
throw new Error('fixture scoped projection failure')
})
try {
await expect(ctx.remote.$mount({
package: '@fixture/direct-projection-failure',
descriptors: [directDescriptor()],
})).rejects.toThrow('fixture scoped projection failure')
} finally {
installScoped.mockRestore()
}
expect(namespace.create).toBeUndefined()
await disposeContext()
})
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor()
const context = contextDescriptor()
await expect(ctx.remote.$mount({
package: '@fixture/weak-parameter',
descriptors: [{
...direct,
parameters: direct.parameters.map((parameter, index) => index === 0
? { ...parameter, codec: { mode: 'src-json' } }
: parameter),
}],
})).rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/weak-context',
descriptors: [{
...context,
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
} as InvocationDescriptor],
})).rejects.toThrow('has no strict codec')
await expect(ctx.remote.$mount({
package: '@fixture/malformed-scope',
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
})).rejects.toThrow('scope must select its only lookup parameter')
await expect(ctx.remote.$mount({
package: '@fixture/ambiguous-scope',
descriptors: [{
...direct,
parameters: [...direct.parameters, {
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
}],
}],
})).rejects.toThrow('scope must select its only lookup parameter')
})
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const descriptor = directDescriptor()
const dispose = await ctx.remote.$mount({
package: '@fixture/goals',
descriptors: [descriptor, contextDescriptor()],
})
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).remote.goals
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
.rejects.toThrow('got 4')
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
.rejects.toThrow('expected 2 business argument(s)')
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
ctx.set('connection', undefined)
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
await dispose()
})
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
resolveCall = resolve
})
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
const ctx = await bench(call)
const { scope: _scope, ...first } = directDescriptor()
const second: InvocationDescriptor = {
...first,
id: '@fixture/goals#goals/archive',
method: 'archive',
}
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
await dispose()
resolveCall({ ok: true, value: { ref: 'goal-1' } })
await expect(invocation).rejects.toThrow('withdrawn during invocation')
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
})
it('fails a method obtained from a withdrawn namespace getter', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
const namespace = ctx.get('remote.goals') as unknown as object
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
await dispose()
expect(getWithdrawn).toBeTypeOf('function')
const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise<unknown>
expect(() => withdrawn('agent-1', { objective: 'ship' }))
.toThrow('Remote method is no longer mounted')
})
it('preserves a __proto__ wire parameter as an own named argument', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const { scope: _scope, ...base } = directDescriptor()
const descriptor: InvocationDescriptor = {
...base,
id: '@fixture/goals#goals/prototype',
method: 'prototype',
parameters: [{
name: 'value',
wire: '__proto__',
source: 'json',
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
}],
}
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
expect(Object.getPrototypeOf(payload.args)).toBeNull()
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
expect(payload.args.__proto__).toBe('wire-value')
await dispose()
})
it('rolls back Remote registration when namespace Service startup fails', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
.rejects.toThrow('fixture namespace startup failure')
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
} finally {
spy.mockRestore()
}
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
expect(ctx.remote.goals.create).toBeTypeOf('function')
await retry()
})
it('withdraws a fresh direct namespace when its first method fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'create') throw new Error('fixture direct method installation failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({
package: '@fixture/direct-method-failure',
descriptors: [directDescriptor()],
})).rejects.toThrow('fixture direct method installation failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({
package: '@fixture/direct-method-retry',
descriptors: [directDescriptor()],
})
expect(ctx.remote.goals.create).toBeTypeOf('function')
await retry()
})
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'rename') throw new Error('fixture scoped installation failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
.rejects.toThrow('fixture scoped installation failure')
} finally {
spy.mockRestore()
}
expect(ctx.get('remote.goals')).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
await retry()
})
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
expect(ctx.get('remote.goals')).toBeDefined()
await dispose()
expect(ctx.get('remote.goals')).toBeUndefined()
const replacement = { owner: 'replacement' }
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
expect(ctx.get('remote.goals')).toBe(replacement)
await disposeReplacement()
})
it('throws RPC failures with the structured error as its cause', async () => {
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
let failure: unknown
try {
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(Error)
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
expect(failure.message).toContain('internal: host failed')
expect(failure.cause).toBe(rpcError)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/connection"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -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/api/remotes/README.md
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-api-remotes
English | [中文](README.zh.md)
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
## Build boundary
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
## Model Experience
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
#### KV Cache effect
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
## Known Limitations and Deferred Work
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-api-remotes
[English](README.md) | 中文
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence并为 TypeRT `agent``session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
## 构建边界
仓库中的普通包只属于一个 TypeScript faceHost 包登记在根 `tsconfig.host.json`Client 包登记在根 `tsconfig.client.json``api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json``tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle不得因一个包同时存在 `src/index.ts``src/client/index.ts` 就复制本包的拆分。
## 模型体验
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
#### KV Cache 影响
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
## 已知限制与暂缓事项
- 能力集合由构建时显式导入的值固定确定Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。

View File

@@ -0,0 +1,64 @@
{
"name": "@deepseek-ai/dsh-api-remotes",
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,194 @@
/** Host BFF policy for resolving Remote Agent and Session identities. */
import type { Context } from 'cordis'
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import type {} from '@deepseek-ai/dsh-typert-registry'
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
export type ApiRemoteLookupError =
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
/** Result of resolving one session identity to its live Agent. */
export type ApiRemoteAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiRemoteLookupError }
/** Resume configuration supplied by the owning Host composition. */
export interface ApiRemoteAgentOptions {
/** Read the per-Agent defaults when a cold identity must resume. */
readonly agentOptions?: () => AgentOptions
/** Host-specific Agent-scope composition completed before publication. */
readonly setup?: AgentSetup
}
/** Cold identity absent from the durable session store. */
export class ApiRemoteSessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing. */
export class ApiRemoteSubagentSessionOwnership extends Error {
/**
* Construct the ownership fence.
* @param sessionId - identity reserved to subagent routing.
*/
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/**
* Test whether generic Host routing must leave an identity to subagent routing.
* @param ctx - Host Context carrying the live Agent registry.
* @param session - attached or live Session metadata.
* @param agent - live Agent when one is registered.
* @returns whether generic Remote and legacy API calls must reject the identity.
*/
export function hasApiRemoteSubagentOwner(
ctx: Context,
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/**
* Build the stable caller-facing ownership rejection.
* @param sessionId - identity reserved to subagent routing.
* @returns the existing `agent-busy` RPC shape.
*/
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/**
* Inspect one cold served session without repairing, resuming, or publishing it.
* @param ctx - Host Context carrying the optional persistence provider.
* @param sessionId - durable identity to inspect.
* @returns detached metadata and events for a servable session.
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
*/
export async function inspectApiRemoteSession(
ctx: Context,
sessionId: SessionId,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
if (meta === undefined || meta.cwd === undefined) {
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
}
const inspected = await persistence.inspect(sessionId)
if (inspected.meta.cwd === undefined) {
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: inspected.meta, events: [...inspected.events] }
}
/**
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
* Live Agents are reused, ordinary cold sessions resume once per identity, and
* subagent-owned identities retain the legacy `agent-busy` fence.
* @param ctx - owning Host Context.
* @param options - defaults and Agent-scope setup used only for cold resume.
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
*/
export function createApiRemoteAgentResolver(
ctx: Context,
options: ApiRemoteAgentOptions,
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
const resumes = new Map<SessionId, Promise<Agent>>()
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
const live = ctx.agents.get(sessionId)
if (live === undefined) return undefined
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
return { agent: live }
}
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
const inspected = await inspectApiRemoteSession(ctx, sessionId)
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
throw new ApiRemoteSubagentSessionOwnership(sessionId)
}
const publishedSession = ctx.sessions.get(sessionId)
const publishedAgent = ctx.agents.get(sessionId)
if (publishedSession !== undefined
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
throw new ApiRemoteSubagentSessionOwnership(sessionId)
}
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
...options.setup === undefined ? {} : { setup: options.setup },
})
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof ApiRemoteSessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
if (error instanceof ApiRemoteSubagentSessionOwnership) {
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
}
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
return { error: apiRemoteSubagentOwnershipError(sessionId) }
}
return {
error: {
code: 'internal',
message: `resume failed for session "${sessionId}": ${String(error)}`,
details: {},
},
}
}
}
ctx.inject(['typert'], (typeCtx) => {
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
const found = await agentFor(sessionId)
if ('error' in found) throw new TypeRTLookupFailure(found.error)
return found.agent
}
typeCtx.typert.lookups.configure('agent', resolveAgent)
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
})
return agentFor
}

View File

@@ -0,0 +1,27 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from 'cordis'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-goal/remote'
declare module 'cordis' {
interface Context {
/** Generated Remote namespaces selected by this Client assembly. */
remote: TypeRTClientRemote
}
}
/** Required service: the typed Client Remote contribution mount. */
export const inject = ['remote']
/**
* Mount the Host capabilities explicitly selected for this Client assembly.
* @param ctx - Client Cordis root carrying the typed API service.
* @returns disposer after every selected Remote namespace is ready.
*/
export async function apply(ctx: Context): Promise<() => Promise<void>> {
return await ctx.remote.$mount(goalsRemote)
}

View File

@@ -0,0 +1,18 @@
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
export {
ApiRemoteSessionNotFound,
ApiRemoteSubagentSessionOwnership,
apiRemoteSubagentOwnershipError,
createApiRemoteAgentResolver,
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from './agent-lookup.ts'
export type {
ApiRemoteAgentOptions,
ApiRemoteAgentResult,
ApiRemoteLookupError,
} from './agent-lookup.ts'
/** Host plugin body; the selected contributions mount only in Client environments. */
export function apply(): void {}

View File

@@ -0,0 +1,24 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
/** Cordis companion plugin name. */
export const name = 'api-remotes-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
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 */

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
const sid = (value: string): SessionId => value as SessionId
function header(id: SessionId): SessionHeader {
return { version: 0, id, createdAt: 1, cwd: '/proj' }
}
async function createContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
return ctx
}
function provideSession(
ctx: Context,
meta: SessionHeader,
inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>,
): void {
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
}
function stubAgent(ctx: Context, session: Session): Agent {
return { id: session.id, session, status: 'idle', ctx } as Agent
}
describe('API Remote Agent resolver races', () => {
it('maps an inspected session without a cwd to session-not-found', async () => {
const ctx = await createContext()
const sessionId = sid('missing-after-inspect')
const meta = header(sessionId)
provideSession(ctx, meta, () => Promise.resolve({
meta: { ...meta, cwd: undefined } as unknown as SessionHeader,
events: [],
}))
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } })
await ctx.fiber.dispose()
})
it('resumes through a concurrently attached ordinary Session without optional defaults', async () => {
const ctx = await createContext()
const sessionId = sid('ordinary-attach-race')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() }
})
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ agent: { id: sessionId } })
expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId })
await ctx.fiber.dispose()
})
it('rejects a subagent Session published after durable inspection', async () => {
const ctx = await createContext()
const sessionId = sid('owned-attach-race')
const meta = header(sessionId)
provideSession(ctx, meta, () => {
ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
return Promise.resolve({ meta, events: [] })
})
const resume = vi.spyOn(ctx.agents, 'resume')
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
expect(resume).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('reclassifies failed resumes after a live or attached subagent wins publication', async () => {
for (const winner of ['agent', 'session'] as const) {
const ctx = await createContext()
const sessionId = sid(`owned-${winner}-resume-race`)
const meta = header(sessionId)
provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] }))
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session))
throw new Error('session id already published')
})
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
await ctx.fiber.dispose()
}
})
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-cold-resume')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const agentCtx = ctx.extend()
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
})
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
await ctx.fiber.dispose()
})
it('applies the subagent ownership fence to the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-owned-subagent')
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
ctx.agents.register(stubAgent(ctx.extend(), session))
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
const resolution = provider.resolve(sessionId)
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,224 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* Built-artifact smoke for the first generated Remote: plain Node boots the
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
*/
const packageDir = fileURLToPath(new URL('..', import.meta.url))
const root = resolve(packageDir, '../../..')
const artifact = (path: string): string => join(root, path)
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
const requiredArtifacts = [
'packages/client/connection/lib/client.js',
'packages/client/connection/lib/index.js',
'packages/api/remotes/lib/client.js',
'packages/core/agent/lib/index.js',
'packages/core/session/lib/index.js',
'packages/goal/goal/lib/index.js',
'packages/goal/goal/lib/typert.host.js',
'packages/api/gateway/lib/client.js',
'packages/api/gateway/lib/index.js',
'packages/typert/registry/lib/client.js',
'packages/typert/registry/lib/index.js',
].every(path => existsSync(artifact(path)))
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
const urls = Object.fromEntries(Object.entries({
agent: 'packages/core/agent/lib/index.js',
apiGatewayClient: 'packages/api/gateway/lib/client.js',
apiGatewayHost: 'packages/api/gateway/lib/index.js',
connectionClient: 'packages/client/connection/lib/client.js',
connectionHost: 'packages/client/connection/lib/index.js',
goal: 'packages/goal/goal/lib/index.js',
goalTypert: 'packages/goal/goal/lib/typert.host.js',
registryClient: 'packages/typert/registry/lib/client.js',
registryHost: 'packages/typert/registry/lib/index.js',
remotesClient: 'packages/api/remotes/lib/client.js',
session: 'packages/core/session/lib/index.js',
}).map(([key, path]) => [key, artifactUrl(path)]))
const script = `
import { createServer } from 'node:http'
import * as cordis from 'cordis'
const urls = ${JSON.stringify(urls)}
const { Context } = cordis
const { default: AgentRegistry } = await import(urls.agent)
const connectionHost = await import(urls.connectionHost)
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
const { default: GoalService } = await import(urls.goal)
const { TYPERT } = await import(urls.goalTypert)
const { default: TypertRegistry } = await import(urls.registryHost)
const { Session, SessionId } = await import(urls.session)
const routes = []
const host = new Context()
host.provide('httpServer', {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex() { return () => {} },
port: 0,
})
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
await host.plugin(TypertRegistry)
await host.plugin(AgentRegistry)
await host.plugin(TypertGatewayService)
await host.plugin(GoalService)
host.typert.register(TYPERT)
const makeAgent = rawId => {
const session = new Session(SessionId(rawId))
return {
id: session.id,
options: {},
session,
ctx: host.extend(),
status: 'idle',
acceptsNextStep: false,
send() {},
updateInbox() { return 'not-found' },
followup() {},
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
reserveTurnAdmission() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
}
const rootAgent = makeAgent('built-root-agent')
const scopedAgent = makeAgent('built-scoped-agent')
host.agents.register(rootAgent)
host.agents.register(scopedAgent)
if (routes.length !== 1 || routes[0].path !== '/api') {
throw new Error('Connection did not register exactly one /api route')
}
const server = createServer((request, response) => { void routes[0].handler(request, response) })
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
const origin = 'http://127.0.0.1:' + String(address.port)
const handoffs = new Map()
globalThis.window = {
__ModuleLoader__: {
load(handoff) { handoffs.set(handoff.id, handoff) },
},
}
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
await import(urls.registryClient)
await import(urls.connectionClient)
await import(urls.apiGatewayClient)
await import(urls.remotesClient)
const instantiate = id => {
const handoff = handoffs.get(id)
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
return handoff.factory(specifier => {
if (specifier === 'cordis') return cordis
throw new Error('unexpected Client external ' + specifier)
})
}
const client = new Context()
for (const id of [
'@deepseek-ai/dsh-typert-registry',
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-api-gateway',
'@deepseek-ai/dsh-api-remotes',
]) {
const plugin = instantiate(id)
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
}
client.typert.contexts.registerClient('agent', {
identity: candidate => candidate.builtAgentId,
})
let invalidRejected = false
try {
await client.remote.goals.create(rootAgent.id, { objective: 1 })
} catch {
invalidRejected = true
}
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.remote.goals.edit(
rootAgent.id,
rootResult.ref,
{ objective: 'edited root goal' },
)
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
const result = {
invalidRejected,
rootResult,
rootEdit,
scopedResult,
rootGoal: host.goals.get(rootAgent)?.objective,
scopedGoal: host.goals.get(scopedAgent)?.objective,
rootEvents: rootAgent.session.events.length,
scopedEvents: scopedAgent.session.events.length,
}
await client.fiber.dispose()
await new Promise((resolveClose, rejectClose) => server.close(error => {
if (error === undefined) resolveClose()
else rejectClose(error)
}))
await host.fiber.dispose()
console.log(JSON.stringify(result))
`
const result = await runPlainNode(script)
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
invalidRejected: boolean
rootResult: { ref: { id: string; revision: number } }
rootEdit: { objective: string; revision: number }
scopedResult: { ref: { id: string; revision: number } }
rootGoal: string
scopedGoal: string
rootEvents: number
scopedEvents: number
}
expect(output).toMatchObject({
invalidRejected: true,
rootResult: { ref: { revision: 1 } },
rootEdit: { objective: 'edited root goal', revision: 2 },
scopedResult: { ref: { revision: 1 } },
rootGoal: 'edited root goal',
scopedGoal: 'scoped goal',
rootEvents: 2,
scopedEvents: 1,
})
expect(output.rootResult.ref.id).toMatch(/^goal-/)
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
}, 60_000)
})
/** Execute one ESM script without tsx or a TypeScript loader. */
function runPlainNode(script: string): Promise<{
readonly exitCode: number | null
readonly stdout: string
readonly stderr: string
}> {
return new Promise((resolveRun) => {
execFile(process.execPath, ['--input-type=module', '-e', script], {
cwd: packageDir,
encoding: 'utf8',
timeout: 55_000,
}, (error, stdout, stderr) => {
resolveRun({
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
stdout,
stderr,
})
})
})
}

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../goal/goal"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/registry"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.host.json"
},
{
"path": "./tsconfig.client.json"
}
]
}

View File

@@ -0,0 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(
'@deepseek-ai/dsh-api-remotes',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)

View File

@@ -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/bash/README.md
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
README.md: edffdd67a982a885b7e3e537be77c8971f5b697b
README.zh.md: 8cb56f38df4999cb2055b9e2808ce092bc7a8282

View File

@@ -6,7 +6,7 @@ The capability family spans the canonical executor seam, its implementations, th
| Package | Role | ctx key |
|---|---|---|
| [`bash/`](bash/README.md) | Defines the executor contract shared by implementations and consumers. | `ctx.bash` |
| [`bash/`](bash/README.md) | Defines the executor contract shared by Service providers and Consumers. | `ctx.bash` |
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.bash`) |
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.bash`) |
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.bash`) |
@@ -15,3 +15,5 @@ The capability family spans the canonical executor seam, its implementations, th
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/bash.md](../../docs/subsystems/bash.md).

View File

@@ -6,7 +6,7 @@
| 包 | 职责 | ctx key |
|---|---|---|
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器约。 | `ctx.bash` |
| [`bash/`](bash/README.md) | 定义 Service provider 与 Consumer 共享的执行器约。 | `ctx.bash` |
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash` |
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash` |
| [`pwsh-local/`](pwsh-local/README.md) | 以 Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash` |
@@ -15,3 +15,5 @@
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools` |
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACPAgent Client Protocol示例](../../examples/acp-agent/)展示一套完整接线。
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/bash.md](../../docs/subsystems/bash.md)。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc

View File

@@ -4,7 +4,7 @@
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash``dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME``DSH_SHELL=1``DSH_SESSION_ID`归注册表自身所有其他插件可以注册额外的可枚举事实注册随插件纤维fiber释放重复所有权或未声明的运行时键会响亮失败。
包根导出 Cordis 插件约(`name``inject``Config``apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费在加载本插件后使用 `ctx.bashEnv`
包根导出 Cordis 插件约`name``inject``Config``apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费在加载本插件后使用 `ctx.bashEnv`
## Config

View File

@@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution {
signal: testToolSignal,
token: Symbol('bash-env-test') as ToolExecution['token'],
callId: CallId('bash-env-call'),
rootCallId: CallId('bash-env-call'),
name: 'bash',
arguments: { command: 'true' },
...(sessionId === undefined

View File

@@ -27,7 +27,7 @@
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
"path": "../../session/session-persistence"
},
{
"path": "../../support/invariants"

View File

@@ -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/bash/bash-local/README.md
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
Local Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose资源释放等进程组机制则由 subprocess 服务负责。
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 Service provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose资源释放等进程组机制则由 subprocess 服务负责。
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`

View File

@@ -1,6 +1,6 @@
/**
* Local implementation of the bash executor seam over the subprocess
* seam. Public commands run as `bash -c` in a managed process group spawned
* Local Service provider for the bash capability seam over the subprocess
* capability seam. Public commands run as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
* explicit argv. This executor owns command defaulting, deadlines and cause
* classification, the model-friendly terminal environment, and the model-facing

View File

@@ -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/bash/bash-sandbox/README.md
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
README.md: 44321d7ef26e9e4399438f61b3fdfbfa2e4d8c11
README.zh.md: f3e110c049871a5a6121716307aaa3d1d1f3eedb

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
Sandbox-consuming Service provider for the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`. A present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -70,7 +70,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names argv[0] remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
#### Token effect
@@ -84,5 +84,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a synchronous `SubprocessService` throw that names the runner path instead fails `start()` immediately.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam Service provider。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
@@ -17,8 +17,8 @@
语义:
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT``EACCES`且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息`ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升引导。
- **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用Node 必须报告 `ENOENT``EACCES`并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall``'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessService` 同步抛出同样能指明 runner `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
@@ -70,7 +70,7 @@
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT``EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT``EACCES` `path``syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
#### Token 影响
@@ -84,5 +84,5 @@
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT``EACCES` 时,则会使 `start()` 立即失败。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;`SubprocessService` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

View File

@@ -41,6 +41,6 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"cordis": "^4.0.0-rc.7",
"node-addon-landlock-run": "0.0.0-test.0"
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
}
}

View File

@@ -23,7 +23,7 @@ function isUsableWorkdir(path: string): boolean {
}
/**
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that

View File

@@ -44,7 +44,7 @@ export type Config = LocalConfig
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// No own Config: the sandbox default (mode + workspaceRoot) is owned by
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
// verbatim (the config catalog walks the inherited static).
@@ -124,7 +124,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
// LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async
// `done` rejection; this covers alternatives that throw that shape synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))

View File

@@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { launcherPath } from 'node-addon-landlock-run'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
* rung forced off, so the workspace `landlock-run` launcher confines) underneath the
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
* facts; the backend-only confinement proofs live with
* `@deepseek-ai/dsh-sandbox-local`.
*
* Self-skips when the running kernel does not enforce Landlock; the
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
* Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from
* `native/landlock-run` before running this file.
*/
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })

View File

@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
@@ -151,7 +151,7 @@ describe('partial Landlock runner-failure classification', () => {
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
// provenanced ENOENT/EACCES evidence required for runner attribution.
// ENOENT/EACCES with the exact failed executable path required for runner attribution.
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)

View File

@@ -240,7 +240,7 @@ describe('fail closed', () => {
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
})
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
it('classifies a synchronous SubprocessService EACCES with the exact runner path', async () => {
const runner = join(spillDir, 'unexecutable-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
@@ -439,7 +439,7 @@ describe('isRunnerSpawnFailure', () => {
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
})
it('accepts only syscall provenance compatible with the exact runner program', () => {
it('accepts only syscall and error-path facts that identify the exact runner program', () => {
const runner = join(spillDir, 'runner with spaces')
const spawnError = (syscall: string, path?: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../native/landlock-run/packages/entry"
},
{
"path": "../../util/brand"
},

View File

@@ -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/bash/bash/README.md
README.md: 88f519a21a0889d6b7649502c51077940c23709f
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a

View File

@@ -2,18 +2,18 @@
English | [中文](README.zh.md)
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
The **`BashExecutor`** (`ctx.bash`) defines WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
This package owns the Service Definition role of the bash capability, split so each role can evolve (and be swapped) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
| `@deepseek-ai/dsh-bash` (this) | Service Definition: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | Service provider: local subprocesses |
| `@deepseek-ai/dsh-bash-sandbox` | Service provider: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface; the consumer detects its `sandboxMode` capability and adds escalation fields without importing the implementation. A containerized or remote executor slots in the same way.
The split is a standard capability seam ([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): `dsh-bash-sandbox` is a sandboxing executor behind the same Service Definition — the Consumer detects its `sandboxMode` capability and adds escalation fields without importing the provider — and a containerized or remote executor slots in the same way.
## Service API (`ctx.bash`)
@@ -31,11 +31,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
## Model Experience

View File

@@ -2,18 +2,18 @@
[English](README.md) | 中文
**bash 执行器 seam**:抽象 `BashExecutor` 服务`ctx.bash`)定义 bash 后端做什么即运行前台命令与启动后台进程但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
**`BashExecutor`**`ctx.bash`)定义 bash 后端做什么即运行前台命令与启动后台进程但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
本包 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
本包承担 bash 能力的 Service Definition 角色,各角色因此可以独立演进(和替换):
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 |
| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 |
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn并将拒绝报告为结果事实 |
| `@deepseek-ai/dsh-bash`(本包) | Service Definition:抽象服务 + 词汇类型 |
| `@deepseek-ai/dsh-bash-local` | Service provider:本地子进程 |
| `@deepseek-ai/dsh-bash-sandbox` | Service provider:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn并将拒绝报告为结果事实 |
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
该拆分与 LLM大语言模型 seam`LlmService``LlmAdapter`)及 agent智能体工具调研结果一致pi 将执行隐藏在 `BashOperations` 接口之后(本地 shellSSHVM 后端Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
该拆分是一个标准的能力 seam[capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。
## 服务 API`ctx.bash`
@@ -31,11 +31,11 @@
`BashExecRequest`command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约的另一半:`dsh-tool-bash``renderResult``dsh-tool-pwsh``renderPwshResult` 追加的 `[exit code: N]``[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill它放在 seam 上,两个工具便永远不会在 marker 约上漂移。
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约的另一半:`dsh-tool-bash``renderResult``dsh-tool-pwsh``renderPwshResult` 追加的 `[exit code: N]``[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill它放在 Service Definition 中,两个工具便永远不会在 marker 约上漂移。
## 模型体验

View File

@@ -1,5 +1,5 @@
/**
* The `ctx.bash` executor seam for foreground commands and background process
* Service Definition for the `ctx.bash` capability seam, covering foreground commands and background process
* handles. Task ids, ownership, polling, and notices belong to
* `@deepseek-ai/dsh-tasks`, keeping executors independent of sessions.
* @module @deepseek-ai/dsh-bash

View File

@@ -10,7 +10,7 @@ export const name = 'bash-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this stateless seam owns request/result types, while executors and policy own observations. */
/** No runtime invariant: this stateless Service Definition owns request/result types, while executors and policy own observations. */
const install: InvariantInstaller = () => {}
/**

View File

@@ -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/bash/pwsh-local/README.md
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
Local PowerShell Service provider for the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
@@ -23,7 +23,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
```
## Behavior (and where it came from)
## Behavior
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell Service provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
@@ -23,7 +23,7 @@
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
```
## 行为(及其由来)
## 行为
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
@@ -31,7 +31,7 @@
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join。一切任务形状的职责id、所有权、轮询、通知都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。

View File

@@ -1,5 +1,5 @@
/**
* Local PowerShell implementation of the bash executor seam. Each command runs
* Local PowerShell Service provider for the bash capability seam. Each command runs
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
* process spawned through `ctx.subprocess`; the executor owns command
* defaulting, deadlines and cause classification, the model-friendly terminal
@@ -279,7 +279,7 @@ export class PwshLocalExecutor extends BashExecutor {
* Settlement hook for subclasses that attach execution facts to a process.
* The base implementation is intentionally empty. Mirrored from
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
* the declared seam for a future pwsh-confining subclass and has no consumer
* the protected extension point for a future pwsh-confining subclass and has no consumer
* in this package yet.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.

View File

@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessService extends SubprocessService {
specs: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}

View File

@@ -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/bash/tool-bash/README.md
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
README.md: 9e2cd0c2ed999a11dcbffcd99a1d0cb672367905
README.zh.md: 8a1e7ba0af36ffd891b38491075ee75283fdbed5

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
Requires a loaded executor Service provider (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal.
@@ -24,7 +24,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the Service Definition (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
### Managed shell environment
@@ -42,7 +42,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions and escalation

View File

@@ -4,9 +4,9 @@
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output``task_list``task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约是 bash 方言——请挂载能解析 bash 的执行器。
需要加载执行器 Service provider(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约是 bash 方言——请挂载能解析 bash 的执行器。
package根只公开 Cordis 插件约(`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部。
package根只公开 Cordis 插件约`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部。
插件还会提供 `tool:bash` 提示词段落(顺序 105检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
@@ -24,11 +24,11 @@
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
执行前,`command``workdir``timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
执行前,`command``workdir``timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此 Service Definition`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
### 托管 shell 环境
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`
@@ -42,7 +42,7 @@
## 工具仅使用具名参数构建请求
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
`BashExecRequest` 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
## 权限与升权
@@ -52,7 +52,7 @@
## 逐会话模式切换
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验

View File

@@ -1,5 +1,5 @@
/**
* Model-facing `bash` tool over the `ctx.bash` executor seam. Background calls
* Model-facing Consumer of the `ctx.bash` capability seam. Background calls
* register process handles with `ctx.tasks`; their work uses task cancellation
* rather than the tool-call signal after an id is returned.
*
@@ -155,7 +155,7 @@ function resolveWorkdir(
return modelWorkdir
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
function canonicalBashResult(result: BashRunResult) {
const output = (stream: BashRunResult['stdout']) => ({
text: stream.text,
@@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config = {}): void {
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval ingredients
* The shared policy resolver is required whenever the executor advertises
* confinement, so a split composition fails at tool-plugin load.
* `sandbox_permissions` still reaches execute) and the approval
* ingredients. The shared policy resolver is required whenever the executor
* advertises confinement, so a split composition fails at tool-plugin load.
*/
const approveBashEscalation = (
mode: string,

View File

@@ -19,7 +19,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
/**
* Full-loop integration: a scripted mock model drives the REAL bash tool
* through the agent loop, exercising the same seams a live model would
* through the agent loop, exercising the same execution paths a live model would
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
* agent.inject completion notices).
*/

View File

@@ -39,7 +39,7 @@
"path": "../../bash/bash-env"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"

View File

@@ -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/bash/tool-pwsh/README.md
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
README.md: 7d8ee5fb69b71d8e8707d3e4ed07ebdda99f799f
README.zh.md: 40984bbc36be4b5809e6ee4db21e52d842f50cdb

View File

@@ -119,6 +119,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only.
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
包根只导出 Cordis 插件约(`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
包根只导出 Cordis 插件约`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
插件还贡献 `tool:pwsh` prompt sectionorder 105非零退出以 `[exit code: N]` marker 报告Windows 上的中断以无 signal 的 exit 1 结算。
@@ -119,6 +119,6 @@ ack 是固定短行;任务输出按读取有界。
## Known Limitations and Deferred Work
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器bash 工具的 sandbox 面不被镜像)。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作
- **PowerShell 方言约** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端仅限 Linux/macOS。
- **PowerShell 方言约** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。

View File

@@ -20,7 +20,7 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// sandbox.runnerFailed to task `failed`. The current contract aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
if (proc.status === 'killed') {

View File

@@ -1,5 +1,5 @@
/**
* Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
* Model-facing PowerShell Consumer of the `ctx.bash` capability seam. Intended for
* Windows compositions where a PowerShell executor (e.g.
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
@@ -112,7 +112,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
const output = (stream: BashRunResult['stdout']) => ({
text: stream.text,

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/README.md
README.md: e8b0790f911d1acc1216d412191ca967e0e1ac27
README.zh.md: c452f9a7ef69e7f5af8900153271d500f19f9e47
# pnpm run verify-translation-pairing --write packages/boot/README.md
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56

11
packages/boot/README.md Normal file
View File

@@ -0,0 +1,11 @@
# boot/ — shared app-bin boot glue
English | [中文](README.zh.md)
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
| Package | Role | ctx key |
|---|---|---|
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).

View File

@@ -0,0 +1,11 @@
# boot/:共享的 app bin 启动粘合层
[English](README.md) | 中文
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。

View File

@@ -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/boot/app-boot/README.md
README.md: 1bbd376121ae79bf37376b51f5ac3eb405af6dfd
README.zh.md: 15263d8de9b69fc976ce328a6056e1b35e9beda5

View File

@@ -2,12 +2,13 @@
English | [中文](README.zh.md)
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so loader-failure behavior has one owner instead of drifting between published artifacts.
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so loader-failure behavior has one owner instead of drifting between published artifacts.
| Export | Role |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `loadLayeredEnv(binName, cwd?, warn?)` | Build the product CLI's frozen inherited > project `.env` > user `.env` snapshot, reject bootstrap-only file variables, and materialize accepted file values without replacing inherited ones |
| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller |
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
@@ -18,7 +19,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
@@ -32,11 +33,11 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
## Profiles
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path).
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). `loadProfile` normalizes an exact installation-owned bundle tuple to its shipped template while preserving every other manifest field; any extra, missing, or reordered entry makes the list user-owned and leaves it unchanged.
User-level machine-local preferences also live in the Harness home:
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
@@ -53,5 +54,5 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Environment discovery is launch-scoped**`loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps.

View File

@@ -2,44 +2,45 @@
[English](README.md) | 中文
供 app bin[`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合并以自身诊断前缀参数化。这样Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
供 app bin[`dsh`](../../../apps/cli/README.md)[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些辅助函数之上构建的精简自执行组合并以自身诊断前缀参数化。这样Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
| 导出 | 职责 |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr |
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 |
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 |
| `loadLayeredEnv(binName, cwd?, warn?)` | 构建产品 CLI命令行界面冻结的「继承环境 > 项目 `.env` > 用户 `.env`」快照,拒绝文件中的 bootstrap-only 变量,并在不替换继承值的前提下物化其余文件值 |
| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader 拒绝转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 |
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的清理函数只会延迟致命退出,而不会取消它 |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin并保留用户 patch 层 HMR热模块替换使用的确切根配置项 |
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader在配置树条目挂载前执行可选的宿主准备操作`prepare` 可以使用 Loader也可以提供由启动器拥有的上下文插槽再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose资源释放部分构造的上下文并以带标签的错误 reject |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析形状失败则抛出 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML`!!js` 表达式原样保留;每段来自同一文件且经相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析形状失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber把原始错误堆栈写入启动 rejection并列出每个等待中配置项尚未解析的服务。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber把原始错误堆栈写入启动 rejection并列出每个等待中配置项尚未解析的服务。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection插件游离的异步工作在挂载期间或挂载完成后失败持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh``boot()``prepare` 回调中捕获根上下文而不是取其返回值使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection后续 rejection(包括拆卸自身)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection插件游离的异步工作在挂载期间或挂载完成后失败持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh``boot()``prepare` 回调中捕获根上下文而不是取其返回值使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。
配置中的裸插件 specifier`@deepseek-ai/dsh-*`、npm 包package)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest元数据清单声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
配置中的裸插件 specifier`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest元数据清单声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper构建后的消费方仍使用普通 Node 包解析。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper构建后的消费方仍使用普通 Node 包解析。
## Profile
## Profiles
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES``web``headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES``web``headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会明确报错(即 `dsh plugin` 路径)。`loadProfile` 会将与安装自有组合包元组完全一致的列表规范化为随发行版交付的模板,同时保留 manifest 中其他所有字段;一旦条目有任何额外、缺失或重排,该列表就归用户所有并保持不变。
用户级的机器本地偏好同样位于 Harness home 中:
- **`.env`**[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥
- **`.env`**产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值
- **`cordis.patch.yml`**home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch组合包层在下、overlay标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时最后一个可用树会继续运行HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher并等待进行中的刷新结束。
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch组合包层在下、overlay标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时最后一个可用树会继续运行HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher并等待进行中的刷新结束。
## 模型体验
@@ -47,11 +48,11 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`
#### KV Cache 影响
`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方持有
`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方负责
## 已知限制与延期工作
## 已知限制与暂缓事项
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper没有该 helper 的进程内调用方必须使用可解析的相对file specifier或提供自己的模块解析钩子。
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对file specifier或提供自己的模块解析钩子。
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml``cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境加载局限于 cwd 且为可选操作**helper 只加载一个 `.env` 文件,并在失败时发出警告;它不搜索父目录、合并 profile 或验证必需变量
- **环境发现以启动为界**`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper
- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。

View File

@@ -31,6 +31,7 @@
"@cordisjs/plugin-hmr": "^1.0.15",
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -46,6 +47,7 @@
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -1,5 +1,5 @@
/**
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* Shared boot glue for the app bins (`dsh`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
@@ -8,12 +8,14 @@
import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { parseEnv } from 'node:util'
import { basename, dirname, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from 'cordis'
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type {} from '@cordisjs/plugin-hmr'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -86,6 +88,113 @@ export function loadEnv(
}
}
/** Exact names no discovered file may set. */
const BOOTSTRAP_NAMES = new Set([
// Process launch and module resolution.
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
// Interpreter startup hooks.
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
'PYTHONHOME',
// Version-control command hooks and config redirects.
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
'GIT_ASKPASS', 'SSH_ASKPASS',
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
'EDITOR', 'VISUAL', 'PAGER',
// Network reach and trust.
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
'NODE_TLS_REJECT_UNAUTHORIZED',
])
/** Name prefixes no discovered file may set. */
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
/**
* Whether a variable may come only from the inherited process environment
* because it changes process, runtime, VCS, or network bootstrap.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
function isBootstrapOnly(name: string): boolean {
const upper = name.toUpperCase()
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
}
/**
* Parse one directory's `.env` without applying it, rejecting bootstrap-only
* names before any value is materialized.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the directory whose `.env` to read.
* @param warn - sink for the one-line unreadable-file diagnostic.
* @returns the parsed entries, or `undefined` when the file is absent or unreadable.
* @throws when the file declares a name {@link isBootstrapOnly} rejects.
*/
function readEnvLayer(
binName: string, dir: string, warn: (line: string) => void,
): { path: string; values: Record<string, string> } | undefined {
const path = resolve(dir, '.env')
let content: string
try {
content = readFileSync(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
return undefined
}
// Parse once so validation and materialization use exactly the same entries.
const values = parseEnv(content) as Record<string, string>
for (const name of Object.keys(values)) {
if (!isBootstrapOnly(name)) continue
throw new Error(
`${binName}: ${path} sets "${name}", which only the launching environment may set`
+ ' (it decides how this process starts, where its code and instructions load from, or how it'
+ ` reaches the network); export ${name} instead of putting it in a .env file`,
)
}
return { path, values }
}
/**
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
* `.env` snapshot. The Harness home resolves before either file; both files
* are checked before either is applied, and accepted values are materialized
* without replacing inherited ones. The snapshot preserves which layer supplied each value.
* @param binName - the diagnostic prefix on the diagnostics.
* @param cwd - the invoking directory whose `.env` is the project layer.
* @param warn - sink for the one-line misconfiguration diagnostics.
* @returns this run's frozen environment snapshot.
* @throws when either file declares a bootstrap-only variable.
*/
export function loadLayeredEnv(
binName: string, cwd: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): EnvironmentSnapshot {
const home = resolveDshHome()
const inherited = { ...process.env } as Record<string, string>
// Parse both layers first: a rejection must not leave one file applied.
const project = readEnvLayer(binName, cwd, warn)
const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn)
// Apply the checked values without replacing a higher-ranked name.
for (const layer of [project, user]) {
if (layer === undefined) continue
for (const [name, value] of Object.entries(layer.values)) {
if (process.env[name] === undefined) process.env[name] = value
}
}
return createEnvironmentSnapshot([
{ source: 'process', values: inherited },
...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }],
...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }],
])
}
const bootstrapIncludes = new WeakMap<Context, Entry>()
// The include's YAML dialect (`!!js` scalars become expression nodes the
@@ -226,9 +335,9 @@ function parsePatchList(
return parsed as PatchOptions[]
}
/** One overlay patch list with the label provenance comments print for it. */
/** One overlay patch list with the source label printed in dump comments. */
export interface ConfigDumpLayer {
/** Source name shown in provenance comments (a file basename or path). */
/** Source name shown in dump comments (a file basename or path). */
label: string
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
patches: PatchOptions[]
@@ -244,10 +353,10 @@ export interface ConfigDumpLayer {
* sees) compose identically then render the result as YAML in the same
* dialect (`!!js` expressions print verbatim, unevaluated).
*
* Every run of rows with the same provenance is preceded by a `# ==` comment
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
* naming the file that contributed the rows and any layers that patched them,
* so the output stays a loadable YAML document while showing which section
* comes from which file. Provenance is derived from single-call prefix
* comes from which file. The file and patch labels are derived from single-call prefix
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
* only rewrites rows in place or appends, so a top-level index identifies one
* row across snapshots, and a layer whose addition changes the row (config
@@ -263,7 +372,7 @@ export interface ConfigDumpLayer {
* @param layers - overlay layers in application order (later wins).
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
* @returns the composed entry list rendered as a YAML document with
* provenance comment separators.
* source comment separators.
*/
export function renderConfigDump(
binName: string,
@@ -330,7 +439,7 @@ export function renderConfigDump(
return groupedDump(composed, provenance)
}
/** Render the composed rows grouped under one provenance comment per contiguous run. */
/** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
function groupedDump(
composed: readonly unknown[],
provenance: readonly { origin: string; patchedBy: string[] }[],
@@ -346,7 +455,7 @@ function groupedDump(
}
for (let index = 0; index < composed.length; index += 1) {
const record = provenance[index]
/* v8 ignore next -- provenance is index-aligned with composed by construction */
/* v8 ignore next -- this array is index-aligned with composed by construction */
if (record === undefined) continue
const label = record.patchedBy.length === 0
? record.origin
@@ -532,7 +641,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
/**
* Value mirrors used because Cordis's const enum has no runtime object to import.
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
* Keep aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts` and
* `packages/client/web/src/loader-status.ts`.
*/
const FIBER_PENDING = 0 as FiberState.PENDING

View File

@@ -114,6 +114,11 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome())
/** The shipped profile templates auto-initialized on first use, by name. */
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}
/** Installation-owned bundle tuples normalized to the shipped template. */
const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'],
}
@@ -203,10 +208,10 @@ function ensureSymlink(link: string, target: string): void {
* directory after the profile's own `node_modules`, so every in-box plugin
* resolves without pnpm ever managing it the exact "bundles come from the
* installation" contract. The closure (not just direct dependencies) is
* required for out-of-tree plugins: their peer dependencies name seam
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
* through its implementation packages. Symlinked packages resolve their own
* dependencies from their real directories (Node's default
* required for out-of-tree plugins: their peer dependencies name Service
* Definition packages (`dsh-compact`, `dsh-invariants`, ...) that the app
* reaches only through its Service provider packages. Symlinked packages
* resolve their own dependencies from their real directories (Node's default
* symlink-following), so each package needs only its one flat link.
* Idempotent: correct links are kept and moved installations are
* re-pointed; a stale link to a vanished package stays until its name is
@@ -226,7 +231,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
// map itself (first resolution wins, matching Node's own nearest-wins).
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
// Peer dependencies participate: seam packages (dsh-subprocess,
// Peer dependencies participate: Service Definition packages (dsh-subprocess,
// dsh-compact, ...) are peers of their implementations, never plain
// dependencies, yet out-of-tree plugins import them directly.
/* v8 ignore next -- a real app manifest always declares dependencies */
@@ -279,6 +284,32 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
}
/** Return whether two bundle lists have the same values in the same order. */
function sameBundles(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
/**
* Normalize an exact installation-owned bundle tuple to its shipped template
* while preserving every other manifest field. Any other list is user-owned.
*/
function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
const current = PROFILE_TEMPLATES[name]
const bundles = manifest.dsh?.profile?.bundles
if (installationOwned === undefined || current === undefined || bundles === undefined
|| !sameBundles(bundles, installationOwned)) return manifest
const normalized: ProfileManifest = {
...manifest,
dsh: {
...manifest.dsh,
profile: { ...manifest.dsh?.profile, bundles: [...current] },
},
}
writeProfileManifest(dir, normalized)
return normalized
}
/**
* Resolve a package's root directory from one anchor without depending on the
* package exporting `./package.json` (`require.resolve` would need that):
@@ -350,7 +381,7 @@ export function loadProfile(
}
initProfile(dir, template)
}
const manifest = readProfileManifest(binName, dir)
const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
// A hand-written profile manifest may omit the dsh section entirely.
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers = bundles.map((packageName): ProfileLayer => {

View File

@@ -7,7 +7,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -86,6 +86,190 @@ describe('loadEnv', () => {
})
})
describe('loadLayeredEnv', () => {
const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
function clear(): void {
for (const name of NAMES) Reflect.deleteProperty(process.env, name)
}
it('layers user under project under the inherited environment', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), [
`${NAMES[0]}=user`,
`${NAMES[1]}=user-only`,
'APP_BOOT_LAYERED_INHERITED=user-loses',
'',
].join('\n'))
writeFileSync(join(project, '.env'), [
`${NAMES[0]}=project`,
`${NAMES[2]}=project-only`,
'APP_BOOT_LAYERED_INHERITED=project-loses',
'',
].join('\n'))
clear()
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
const warn = vi.fn()
try {
loadLayeredEnv(NAME, project, warn)
expect(process.env[NAMES[0]]).toBe('project')
expect(process.env[NAMES[1]]).toBe('user-only')
expect(process.env[NAMES[2]]).toBe('project-only')
expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(warn).not.toHaveBeenCalled()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it.each([
['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
['the executable search path', 'PATH=/tmp/evil\n'],
['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
const home = tmp()
const project = tmp()
writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
expect(process.env[NAMES[1]]).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('reports each file value with its absolute path', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') })
expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('resolves the harness home from the inherited environment, never from a file', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
loadLayeredEnv(NAME, project, vi.fn())
expect(process.env[NAMES[1]]).toBe('real-home')
expect(process.env[NAMES[2]]).toBe('set-by-project')
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('warns and continues when a layer exists but cannot be read', () => {
const home = tmp()
const project = tmp()
// A directory named `.env` is a present-but-unreadable layer.
mkdirSync(join(home, '.env'))
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
clear()
vi.stubEnv('DSH_HOME', home)
const warn = vi.fn()
try {
const snapshot = loadLayeredEnv(NAME, project, warn)
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
expect(snapshot.get(NAMES[1])).toBeUndefined()
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
expect(process.env[NAMES[2]]).toBe('project-only')
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('reports to stderr when the caller supplies no reporter', () => {
const home = tmp()
const project = tmp()
mkdirSync(join(home, '.env'))
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
clear()
vi.stubEnv('DSH_HOME', home)
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const snapshot = loadLayeredEnv(NAME, project)
expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`))
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
expect(process.env[NAMES[2]]).toBe('project-only')
} finally {
write.mockRestore()
clear()
vi.unstubAllEnvs()
}
})
it('passes over an absent layer without reporting it', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
clear()
vi.stubEnv('DSH_HOME', home)
const warn = vi.fn()
try {
const snapshot = loadLayeredEnv(NAME, project, warn)
expect(warn).not.toHaveBeenCalled()
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('carries only the inherited environment when neither file exists', () => {
const home = tmp()
const project = tmp()
clear()
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
try {
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' })
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('reads a harness home that is also the invocation directory exactly once', () => {
const both = tmp()
writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`)
clear()
vi.stubEnv('DSH_HOME', both)
try {
const snapshot = loadLayeredEnv(NAME, both, vi.fn())
expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') })
} finally {
clear()
vi.unstubAllEnvs()
}
})
})
describe('installFailLoud', () => {
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
const handlers: Array<(err: unknown) => void> = []

View File

@@ -1,7 +1,7 @@
/**
* `renderConfigDump` behavior: the offline composition must equal what
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
* expressions verbatim, separate provenance runs with comment lines while
* expressions verbatim, separate source-file runs with comment lines while
* staying one loadable YAML document, and report skipped patches through
* `warn` instead of failing mirroring the Loader's boot-time warning for a
* shared overlay whose row exists only on another surface.
@@ -35,7 +35,7 @@ function writeBase(dir: string): string {
}
describe('renderConfigDump', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its source and patches', () => {
const dir = tmp()
const base = writeBase(dir)
const surface = join(dir, 'surface.yml')
@@ -49,17 +49,17 @@ describe('renderConfigDump', () => {
' name: ./noop.mjs',
'',
].join('\n'))
const personal = join(dir, 'personal.yml')
writeFileSync(personal, [
const user = join(dir, 'user.yml')
writeFileSync(user, [
'- id: surface-extra',
' config:',
' value: personal',
' value: user',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
{ label: 'user.yml', patches: loadOverlayPatches(NAME, user) },
], () => {})
// Comments do not break loadability: the dump parses as one document
// equal to what boot() would mount.
@@ -74,19 +74,19 @@ describe('renderConfigDump', () => {
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
},
{ id: 'untouched', name: './noop.mjs' },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } },
])
// Unevaluated: the expression text round-trips as a !!js scalar.
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
// Provenance separators: origin file, plus every layer that changed the
// Source separators: origin file, plus every layer that changed the
// row; an inserted row carries the inserting layer as its origin.
expect(dump).toContain('# == base.yml, patched by surface.yml')
expect(dump).toContain('# == base.yml\n- id: untouched')
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra')
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
})
it('groups contiguous same-provenance rows under one separator', () => {
it('groups contiguous rows with the same origin and patches under one separator', () => {
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
@@ -130,7 +130,7 @@ describe('renderConfigDump', () => {
config?: { config?: { v?: number } }[]
}[]
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
// The skipped layer did not change the row, so it is not in provenance.
// The skipped layer did not change the row, so the comment does not list it.
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
expect(dump).not.toContain('b.yml\n- id: g')
})

View File

@@ -1,4 +1,5 @@
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -6,14 +7,19 @@ import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
async function bootHmr(dir: string): Promise<Context> {
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dir).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await ctx.plugin(Hmr, {
root,
ignored: [],
debounce: 0,
...usePolling === undefined ? {} : { usePolling },
})
return ctx
}
@@ -26,6 +32,57 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
}
describe('HMR exact config paths', () => {
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
const alias = `${target}-alias`
const aliasFilename = join(alias, 'module.ts')
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
writeFileSync(aliasFilename, 'export const generation = 0\n')
// This acceptance owns alias-to-cache identity. Other cases below exercise
// native events; polling keeps Windows fs.watch queue pressure out of it.
const ctx = await bootHmr(alias, ['.'], true)
const filename = join(await realpath(target), 'module.ts')
const expected = pathToFileURL(filename).href
const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
const observed: string[] = []
ctx.on('hmr/change', (url) => { observed.push(url) })
try {
const deadline = Date.now() + 20_000
for (let generation = 1; !observed.includes(expected); generation += 1) {
if (Date.now() >= deadline) {
throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
}
// The watch base, not the writer spelling, is the alias under test.
// Grow the file on every write: polling must not depend on timestamp
// precision when several generations land inside one filesystem tick.
writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
// Leave Chokidar's atomic-write window idle so one coalesced change can publish.
await new Promise(resolve => setTimeout(resolve, 250))
}
expect(cacheHas).toHaveBeenCalledWith(expected)
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
rmSync(target, { recursive: true, force: true })
}
})
it('collapses filesystem aliases before registering an exact watch', async () => {
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
const alias = `${target}-alias`
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const ctx = await bootHmr(alias)
try {
await ctx.hmr.registerConfig('plugins.yml', () => {})
await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
.rejects.toThrow('config path already registered')
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
rmSync(target, { recursive: true, force: true })
}
})
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
@@ -133,6 +190,9 @@ describe('HMR exact config paths', () => {
expect(observed.error).toBeInstanceOf(Error)
expect(observed.error.message).toBe('42')
// Let Chokidar's atomic-write window close before requiring a distinct
// second notification from the same path.
await new Promise(resolve => setTimeout(resolve, 250))
writeFileSync(filename, 'invalid again')
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
} finally {

View File

@@ -161,6 +161,33 @@ describe('loadProfile', () => {
.toEqual([...PROFILE_TEMPLATES.web ?? []])
})
it('normalizes only the exact installation-owned headless bundle tuple', () => {
const anchor = stageInstallation({
'@deepseek-ai/dsh-base': { patch: '[]\n' },
'@deepseek-ai/dsh-web-app': { patch: '[]\n' },
'@deepseek-ai/dsh-headless': { patch: '[]\n' },
'custom-bundle': { patch: '[]\n' },
})
const home = tmp()
const stock = resolveProfileDir('headless', home)
initProfile(stock, [
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
])
loadProfile('t', 'headless', anchor, home)
expect(readProfileManifest('t', stock).dsh?.profile?.bundles)
.toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'])
const customHome = tmp()
const custom = resolveProfileDir('headless', customHome)
initProfile(custom, [
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
])
loadProfile('t', 'headless', anchor, customHome)
expect(readProfileManifest('t', custom).dsh?.profile?.bundles).toEqual([
'@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
])
})
it('fails loud when a listed bundle declares no dsh.bundle', () => {
const anchor = stageInstallation({ 'not-a-bundle': {} })
const home = tmp()

View File

@@ -36,14 +36,14 @@ describe('RepositoryCache', () => {
calls.push(directory)
await fakePackage(directory)
}
const cache = new RepositoryCache(root, install)
const cache = new RepositoryCache(root, { install })
const specifier = 'github:owner/repository#0123456789abcdef'
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
expect(concurrent).toBe(first)
expect(calls).toHaveLength(1)
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } })
expect(await reopened.resolve(specifier)).toBe(first)
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
@@ -68,8 +68,8 @@ describe('RepositoryCache', () => {
const specifier = 'github:owner/repository#race'
const [first, second] = await Promise.all([
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
])
expect(second).toBe(first)
@@ -80,11 +80,11 @@ describe('RepositoryCache', () => {
it('removes a failed staging tree and permits an exact retry', async () => {
const root = await temporaryRoot('repository-retry')
let attempts = 0
const cache = new RepositoryCache(root, async (directory) => {
const cache = new RepositoryCache(root, { install: async (directory) => {
attempts += 1
if (attempts === 1) throw new Error('install failed')
await fakePackage(directory)
})
} })
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
expect(await readdir(root)).toEqual([])
@@ -94,7 +94,7 @@ describe('RepositoryCache', () => {
it('rejects empty or padded specifiers before touching the cache', async () => {
const root = await temporaryRoot('repository-input')
const cache = new RepositoryCache(root, fakePackage)
const cache = new RepositoryCache(root, { install: fakePackage })
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
await expect(readdir(root)).resolves.toEqual([])
@@ -107,35 +107,69 @@ describe('RepositoryCache', () => {
const entry = join(root, key)
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } })
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
})
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'build-helper'), { recursive: true })
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
private: true,
version: '1.0.0',
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
})}\n`)
await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n')
await writeFile(join(repository, 'pnpm-lock.yaml'), [
"lockfileVersion: '9.0'",
'settings:',
' autoInstallPeers: true',
' excludeLinksFromLockfile: false',
'importers:',
' .: {}',
'',
].join('\n'))
await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({
name: 'repository-build-helper',
version: '1.0.0',
bin: 'index.js',
})}\n`)
await writeFile(join(repository, 'build-helper', 'index.js'), [
'#!/usr/bin/env node',
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepare: 'node prepare.mjs' },
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
devDependencies: {
'repository-build-helper': 'file:../build-helper',
'repository-prepare-helper': 'file:../prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'))
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
await execFileAsync('git', ['add', '.'], { cwd: repository })
await execFileAsync('git', [
@@ -149,6 +183,7 @@ describe('RepositoryCache', () => {
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
}

Some files were not shown because too many files have changed in this diff Show More