mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at
This commit is contained in:
@@ -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 apps/cli/README.md
|
||||
README.md: dd29f7fc03a783079ea3194de99589c1f545be5b
|
||||
README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc
|
||||
README.md: 98a856261bc632c97f350db8fc7bb0b10c22235d
|
||||
README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e
|
||||
|
||||
@@ -9,18 +9,30 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. |
|
||||
| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). |
|
||||
| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `dsh web` | Alias of `--profile web`. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
|
||||
|
||||
## App arguments
|
||||
|
||||
The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
|
||||
|
||||
## Development
|
||||
|
||||
Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
|
||||
Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh <args...>` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
|
||||
|
||||
@@ -9,18 +9,30 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 |
|
||||
| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 |
|
||||
| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh web` | `--profile web` 的别名。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
|
||||
|
||||
## 应用参数
|
||||
|
||||
启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点:
|
||||
|
||||
```sh
|
||||
dsh --profile web --port 8080 # --port belongs to the web app
|
||||
dsh --profile tui --resume <id> # --resume belongs to the terminal app
|
||||
dsh --profile headless "run the tests"
|
||||
dsh --profile web --help # the web app's flags, not the launcher's
|
||||
dsh --help # the launcher's own help
|
||||
```
|
||||
|
||||
## Profile
|
||||
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行。
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。
|
||||
生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh <args...>` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。
|
||||
|
||||
@@ -8,12 +8,10 @@ The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cfg["packages/bundle/base/cordis.patch.yml<br/>cordis.yml"]
|
||||
plugin_dsh_base_timer["timer<br/>@cordisjs/plugin-timer"]
|
||||
plugin_dsh_base_timer["timer<br/>@deepseek-ai/cordis-plugin-timer"]
|
||||
cfg --> plugin_dsh_base_timer
|
||||
plugin_dsh_base_hmr["hmr<br/>@cordisjs/plugin-hmr"]
|
||||
plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/cordis-plugin-hmr"]
|
||||
cfg --> plugin_dsh_base_hmr
|
||||
plugin_dsh_base_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
|
||||
cfg --> plugin_dsh_base_repository_plugins
|
||||
plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"]
|
||||
cfg --> plugin_dsh_base_llm
|
||||
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
|
||||
@@ -46,6 +44,8 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_llm_pi_ai
|
||||
plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
|
||||
cfg --> plugin_dsh_base_session_persistence_jsonl
|
||||
plugin_dsh_base_attachment_local["attachment-local<br/>@deepseek-ai/dsh-attachment-local"]
|
||||
cfg --> plugin_dsh_base_attachment_local
|
||||
plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
|
||||
cfg --> plugin_dsh_base_session_query_sqlite
|
||||
plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
|
||||
@@ -110,6 +110,10 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_subagent_spawn
|
||||
plugin_dsh_base_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
|
||||
cfg --> plugin_dsh_base_subagent_fork
|
||||
plugin_dsh_base_subagent_codex["subagent-codex<br/>@deepseek-ai/dsh-subagent-codex"]
|
||||
cfg --> plugin_dsh_base_subagent_codex
|
||||
plugin_dsh_base_subagent_claude_code["subagent-claude-code<br/>@deepseek-ai/dsh-subagent-claude-code"]
|
||||
cfg --> plugin_dsh_base_subagent_claude_code
|
||||
plugin_dsh_base_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
|
||||
cfg --> plugin_dsh_base_tool_subagent_control
|
||||
plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
|
||||
@@ -164,9 +168,8 @@ flowchart LR
|
||||
|
||||
| Plugin id | Package / module |
|
||||
| --- | --- |
|
||||
| `timer` | `@cordisjs/plugin-timer` |
|
||||
| `hmr` | `@cordisjs/plugin-hmr` |
|
||||
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
|
||||
| `timer` | `@deepseek-ai/cordis-plugin-timer` |
|
||||
| `hmr` | `@deepseek-ai/cordis-plugin-hmr` |
|
||||
| `llm` | `@deepseek-ai/dsh-llm` |
|
||||
| `session` | `@deepseek-ai/dsh-session` |
|
||||
| `typert` | `@deepseek-ai/dsh-typert-registry` |
|
||||
@@ -183,6 +186,7 @@ flowchart LR
|
||||
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
|
||||
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
|
||||
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
|
||||
| `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
|
||||
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
|
||||
| `session-projection` | `@deepseek-ai/dsh-session-projection` |
|
||||
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
|
||||
@@ -215,6 +219,8 @@ flowchart LR
|
||||
| `subagent` | `@deepseek-ai/dsh-subagent` |
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` |
|
||||
| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` |
|
||||
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
|
||||
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
|
||||
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
|
||||
257
apps/cli/config/agent-presets/code/agent.cordis.yml
Normal file
257
apps/cli/config/agent-presets/code/agent.cordis.yml
Normal file
@@ -0,0 +1,257 @@
|
||||
# The `code` agent preset: the standard coding agent, presented as Code Mode.
|
||||
#
|
||||
# Everything in `standard` is here unchanged. What is added is the `tool-mode`
|
||||
# row: instead of one tool call per action, the model writes a TypeScript
|
||||
# program against a generated SDK and `run_code` executes it, so a sequence
|
||||
# that would be five round trips becomes one.
|
||||
#
|
||||
# The registry itself stays on the host plane — the agent loop's scheduler and
|
||||
# the API proxy's presenters are its consumers — so what this preset owns is
|
||||
# the PRESENTATION of that registry for this agent alone. Native sessions run
|
||||
# beside this one in the same process, each seeing its own catalog.
|
||||
#
|
||||
# This file is an AGENT-PLANE composition. It is mounted under one agent's
|
||||
# scope context, so every tool and prompt section it registers belongs to that
|
||||
# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`)
|
||||
# keeps everything a preset must not own: the registries themselves, the
|
||||
# sandbox and approval stack, persistence, and the model route.
|
||||
#
|
||||
# A service row here MUST sit inside a group carrying an `isolate` realm.
|
||||
# Without one it publishes into the root realm, where it is process-global
|
||||
# rather than per-session and the second session mounting this preset collides
|
||||
# with the first; `dsh-agent-presets` rejects that at mount. `true` means an
|
||||
# entry-local realm — one private instance per mounted session, which is the
|
||||
# default this deployment wants. A shared label would instead pool one instance
|
||||
# across every session naming it.
|
||||
|
||||
# ── identity ────────────────────────────────────────────────────────────────
|
||||
|
||||
# The preset's own persona, shadowing the deployment default for this agent.
|
||||
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
- id: workspace-context
|
||||
name: '@deepseek-ai/dsh-workspace-context'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
# ── shell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
- id: tool-fs-search
|
||||
name: '@deepseek-ai/dsh-tool-fs-search'
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
# its producers sit outside any realm this file could put it in — `tool-bash`
|
||||
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
|
||||
# to every sibling row, so `run_in_background` would answer "background tasks
|
||||
# unavailable" while these controls sat in the catalog. The registry is keyed by
|
||||
# owning agent anyway, so one host instance serves every session. What a preset
|
||||
# chooses is whether its agent can collect and stop background work at all.
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
# ── skills ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# The skill REGISTRY lives in the host composition and is layered per scope:
|
||||
# these rows register into THIS preset's layer of it, so they need no realm.
|
||||
# `skill-local` contributes local-root discovery for agents on this preset, and
|
||||
# `tool-skill` gives them the catalog and loader; the merged catalog also
|
||||
# carries whatever the deployment registered globally (repository plugins).
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
# ── goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing tool. The goal SERVICE, its session driver, and the
|
||||
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
|
||||
# Remote endpoints whose receiver comes from a generated descriptor, so it
|
||||
# resolves `goals` on the host and an entry-local realm here would hide it. The
|
||||
# registry is keyed by session anyway, so one host instance serves every
|
||||
# session. What a preset chooses is whether its agent can call the goal tool.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# ── plan mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Plan state is per-agent by nature, so an entry-local realm is not a
|
||||
# workaround here — it is the correct lifetime.
|
||||
- id: planning
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
planMode: true
|
||||
config:
|
||||
- id: plan-mode
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
||||
|
||||
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
||||
|
||||
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
||||
|
||||
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
||||
|
||||
# ── compaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
|
||||
# share this realm rather than sit outside it.
|
||||
#
|
||||
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
|
||||
# plane, and the rows here resolve that one instance. It takes no configuration,
|
||||
# keys every fold by Session, and owns the context-meter projection units the
|
||||
# browser reads for every session — behind a realm those units would come and go
|
||||
# with whichever presets happen to be mounted. What a preset chooses is whether
|
||||
# its agent compacts at all, which is `compact-basic` below.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
compact: true
|
||||
toolResultPrune: true
|
||||
config:
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
config:
|
||||
thresholdChars: 8192
|
||||
headChars: 4096
|
||||
tailChars: 1024
|
||||
|
||||
# ── delegation and workflows ────────────────────────────────────────────────
|
||||
|
||||
# The `subagents` registry and its spawn/fork backends live in the HOST
|
||||
# composition: the registry is a process singleton whose cross-session queries
|
||||
# the api-proxy serves to the browser, and a provider name may only be
|
||||
# registered once. This preset contributes the delegation TOOLS, which resolve
|
||||
# that host registry.
|
||||
#
|
||||
# `workflows` is different — nothing outside an agent reads it — so every row
|
||||
# that reaches it shares one entry-local realm here, and a consumer left
|
||||
# outside would resolve a host registry this preset does not populate.
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
workflows: true
|
||||
config:
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
config:
|
||||
subagentProvider: spawn
|
||||
maxRounds: 64
|
||||
|
||||
# ── remaining model-facing rows ─────────────────────────────────────────────
|
||||
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
config:
|
||||
allowParallelInProgress: true
|
||||
|
||||
# The `web` service and its search provider stay in the host composition; only
|
||||
# the model-facing tool is per-session.
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
searchTimeoutMs: 60000
|
||||
|
||||
# ── presentation ────────────────────────────────────────────────────────────
|
||||
|
||||
# Code Mode for this agent alone. The row waits for the host's `codeRuntime`
|
||||
# rather than assuming it: a deployment that composes no TypeScript runtime
|
||||
# fails this preset at mount, naming this id, instead of at the first request.
|
||||
- id: tool-mode
|
||||
name: '@deepseek-ai/dsh-agent-tool-mode'
|
||||
config:
|
||||
mode: code
|
||||
3
apps/cli/config/agent-presets/code/preset.yml
Normal file
3
apps/cli/config/agent-presets/code/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
name: 代码模式
|
||||
description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。
|
||||
order: 2
|
||||
257
apps/cli/config/agent-presets/cordis/agent.cordis.yml
Normal file
257
apps/cli/config/agent-presets/cordis/agent.cordis.yml
Normal file
@@ -0,0 +1,257 @@
|
||||
# The `cordis` agent preset: the standard coding agent, plus the ability to
|
||||
# read and write the runtime it is running in.
|
||||
#
|
||||
# It exists so a person can ask an agent to author another agent. Everything in
|
||||
# `standard` is here unchanged; what is added is the self-referential Cordis
|
||||
# toolset, a skill that teaches composition authoring, and a persona that says
|
||||
# which of the two planes an edit belongs to.
|
||||
#
|
||||
# TRUST: `cordis_mount` evaluates model-written JavaScript against the live
|
||||
# runtime, and a composition this agent writes becomes a preset other sessions
|
||||
# mount. Treat a session on this preset as shell access — the toolset's own
|
||||
# documentation makes the same statement.
|
||||
|
||||
|
||||
# The preset's own persona, shadowing the deployment default for this agent.
|
||||
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: |-
|
||||
You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. Your working directory is {{cwd}}.
|
||||
|
||||
You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session.
|
||||
|
||||
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
|
||||
|
||||
Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
|
||||
|
||||
Load the `editing-cordis-compositions` skill before writing or changing a composition.
|
||||
|
||||
- id: workspace-context
|
||||
name: '@deepseek-ai/dsh-workspace-context'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
# ── shell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
- id: tool-fs-search
|
||||
name: '@deepseek-ai/dsh-tool-fs-search'
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
# its producers sit outside any realm this file could put it in — `tool-bash`
|
||||
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
|
||||
# to every sibling row, so `run_in_background` would answer "background tasks
|
||||
# unavailable" while these controls sat in the catalog. The registry is keyed by
|
||||
# owning agent anyway, so one host instance serves every session. What a preset
|
||||
# chooses is whether its agent can collect and stop background work at all.
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
# ── goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing tool. The goal SERVICE, its session driver, and the
|
||||
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
|
||||
# Remote endpoints whose receiver comes from a generated descriptor, so it
|
||||
# resolves `goals` on the host and an entry-local realm here would hide it. The
|
||||
# registry is keyed by session anyway, so one host instance serves every
|
||||
# session. What a preset chooses is whether its agent can call the goal tool.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# ── plan mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Plan state is per-agent by nature, so an entry-local realm is not a
|
||||
# workaround here — it is the correct lifetime.
|
||||
- id: planning
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
planMode: true
|
||||
config:
|
||||
- id: plan-mode
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
||||
|
||||
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
||||
|
||||
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
||||
|
||||
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
||||
|
||||
# ── compaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
|
||||
# share this realm rather than sit outside it.
|
||||
#
|
||||
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
|
||||
# plane, and the rows here resolve that one instance. It takes no configuration,
|
||||
# keys every fold by Session, and owns the context-meter projection units the
|
||||
# browser reads for every session — behind a realm those units would come and go
|
||||
# with whichever presets happen to be mounted. What a preset chooses is whether
|
||||
# its agent compacts at all, which is `compact-basic` below.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
compact: true
|
||||
toolResultPrune: true
|
||||
config:
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
config:
|
||||
thresholdChars: 8192
|
||||
headChars: 4096
|
||||
tailChars: 1024
|
||||
|
||||
# ── delegation and workflows ────────────────────────────────────────────────
|
||||
|
||||
# The `subagents` registry and its spawn/fork backends live in the HOST
|
||||
# composition: the registry is a process singleton whose cross-session queries
|
||||
# the api-proxy serves to the browser, and a provider name may only be
|
||||
# registered once. This preset contributes the delegation TOOLS, which resolve
|
||||
# that host registry.
|
||||
#
|
||||
# `workflows` is different — nothing outside an agent reads it — so every row
|
||||
# that reaches it shares one entry-local realm here, and a consumer left
|
||||
# outside would resolve a host registry this preset does not populate.
|
||||
#
|
||||
# `tool-subagent-report` is host-plane for the same reason as the registry,
|
||||
# not because a preset may not want it: it registers a CONTINUABLE SETUP on
|
||||
# that singleton rather than a tool this agent calls, and the setup list is
|
||||
# not scope-aware — one copy per mounted preset means every child gets
|
||||
# `report` registered once per live session, which throws on the second.
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
workflows: true
|
||||
config:
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
config:
|
||||
subagentProvider: spawn
|
||||
maxRounds: 64
|
||||
|
||||
# ── remaining model-facing rows ─────────────────────────────────────────────
|
||||
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
config:
|
||||
allowParallelInProgress: true
|
||||
|
||||
# The `web` service and its search provider stay in the host composition; only
|
||||
# the model-facing tool is per-session.
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
searchTimeoutMs: 60000
|
||||
|
||||
# ── self-modification ───────────────────────────────────────────────────────
|
||||
|
||||
# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a
|
||||
# trust boundary, not a sandbox — see this file's header.
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
|
||||
# The composition-authoring skill travels with this preset rather than living
|
||||
# in the user's skill root: it documents THIS deployment's two planes, and a
|
||||
# preset is the unit that gets copied and edited. `baseUrl` is the preset's
|
||||
# own directory, so the root resolves wherever the preset is installed.
|
||||
# Both rows register into THIS preset's layer of the host skill registry, so
|
||||
# they need no realm; the agent's merged catalog also carries whatever the
|
||||
# deployment registered globally (repository plugins).
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
config:
|
||||
customSkillDirs:
|
||||
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
3
apps/cli/config/agent-presets/cordis/preset.yml
Normal file
3
apps/cli/config/agent-presets/cordis/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
name: 创造模式
|
||||
description: 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
|
||||
order: 4
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: editing-cordis-compositions
|
||||
description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing.
|
||||
---
|
||||
|
||||
# Editing Cordis compositions
|
||||
|
||||
Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.
|
||||
|
||||
## Decide the plane first
|
||||
|
||||
Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared.
|
||||
|
||||
**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.
|
||||
|
||||
**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.
|
||||
|
||||
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
|
||||
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`.
|
||||
|
||||
## Authoring a preset
|
||||
|
||||
1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
|
||||
2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands.
|
||||
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
|
||||
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
|
||||
|
||||
### Native product subagents
|
||||
|
||||
Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field.
|
||||
|
||||
Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:
|
||||
|
||||
```yaml
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product.
|
||||
|
||||
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
|
||||
|
||||
## The rule that catches people
|
||||
|
||||
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
|
||||
|
||||
Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service.
|
||||
|
||||
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm:
|
||||
|
||||
```yaml
|
||||
- id: tasks
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
tasks: true
|
||||
config:
|
||||
- id: tasks-local
|
||||
name: '@deepseek-ai/dsh-tasks-local'
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
```
|
||||
|
||||
`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate.
|
||||
|
||||
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing.
|
||||
|
||||
Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
|
||||
|
||||
To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
|
||||
|
||||
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
|
||||
|
||||
## What not to move into a preset
|
||||
|
||||
`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.
|
||||
73
apps/cli/config/agent-presets/minimal/agent.cordis.yml
Normal file
73
apps/cli/config/agent-presets/minimal/agent.cordis.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
# The `minimal` agent preset: a fixed-prompt, two-tool coding surface.
|
||||
#
|
||||
# The persona is the complete system prompt, so global identity, Web surface,
|
||||
# tool guidance, and later assembly listeners cannot add prompt text. The model
|
||||
# composes only the persistent `bash` and `str_replace_editor` tools.
|
||||
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: You are a helpful software engineer assistant.
|
||||
complete: true
|
||||
|
||||
# The PTY registry is an agent-owned service, so it lives in an entry-local
|
||||
# realm. The backend still consumes the host sandbox policy and subprocess
|
||||
# implementation, while the tool registers into this agent's scoped catalog.
|
||||
- id: persistent-shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
pty: true
|
||||
config:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
description: |-
|
||||
Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
|
||||
|
||||
# The editor requires absolute paths unconditionally.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# Model capacity comes from routed model metadata; this block states the
|
||||
# compaction policy explicitly.
|
||||
#
|
||||
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
|
||||
# plane, and the row here resolves that one instance. It takes no configuration,
|
||||
# keys every fold by Session, and owns the context-meter projection units the
|
||||
# browser reads for every session — behind a realm those units would come and go
|
||||
# with whichever presets happen to be mounted. What a preset chooses is whether
|
||||
# its agent compacts at all, which is `compact-basic` below.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
compact: true
|
||||
config:
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
thresholdRatio: 0.8
|
||||
retainTokens: 20480
|
||||
summarizationProvider: ''
|
||||
summarizationModel: ''
|
||||
maxTokens: 8192
|
||||
compactionRetries: 1
|
||||
3
apps/cli/config/agent-presets/minimal/preset.yml
Normal file
3
apps/cli/config/agent-presets/minimal/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
name: 极简模式
|
||||
description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
|
||||
order: 3
|
||||
246
apps/cli/config/agent-presets/standard/agent.cordis.yml
Normal file
246
apps/cli/config/agent-presets/standard/agent.cordis.yml
Normal file
@@ -0,0 +1,246 @@
|
||||
# The `standard` agent preset: the full coding agent, mounted once per process.
|
||||
#
|
||||
# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a
|
||||
# standing scope; every session naming it joins by scope parentage, so the
|
||||
# tools and prompt sections registered here cover each joined agent while a
|
||||
# session's own state stays keyed per Session/Agent inside the plugins. The
|
||||
# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a
|
||||
# preset must not own: the registries themselves, the sandbox and approval
|
||||
# stack, persistence, and the model route.
|
||||
#
|
||||
# A service row here MUST sit inside a group carrying an `isolate` realm.
|
||||
# Without one it publishes into the root realm, where it is process-global —
|
||||
# another preset publishing the same name collides, and a host reader would
|
||||
# resolve one preset's instance for every session; `dsh-agent-presets` rejects
|
||||
# that at mount. `true` means an entry-local realm: this standing mount's own
|
||||
# private instance, apart from every other preset's. (A shared label does NOT
|
||||
# pool instances — `provide()` throws on the second registration under the
|
||||
# same realm symbol; labels join REALMS, and are not what this file needs.)
|
||||
|
||||
# ── identity ────────────────────────────────────────────────────────────────
|
||||
|
||||
# The preset's own persona, shadowing the deployment default for this agent.
|
||||
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
config:
|
||||
text: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
- id: workspace-context
|
||||
name: '@deepseek-ai/dsh-workspace-context'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
# ── shell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Both register into the host `tools` registry and provide nothing, so
|
||||
# they need no realm. The `fs` service and its policy stay in the host.
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
- id: tool-fs-search
|
||||
name: '@deepseek-ai/dsh-tool-fs-search'
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
# ── background tasks ────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing controls. The task REGISTRY stays on the host plane:
|
||||
# its producers sit outside any realm this file could put it in — `tool-bash`
|
||||
# above resolves it with `ctx.get`, and an entry-local realm here is invisible
|
||||
# to every sibling row, so `run_in_background` would answer "background tasks
|
||||
# unavailable" while these controls sat in the catalog. The registry is keyed by
|
||||
# owning agent anyway, so one host instance serves every session. What a preset
|
||||
# chooses is whether its agent can collect and stop background work at all.
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
# ── skills ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# The skill REGISTRY lives in the host composition and is layered per scope:
|
||||
# these rows register into THIS preset's layer of it, so they need no realm.
|
||||
# `skill-local` contributes local-root discovery for agents on this preset, and
|
||||
# `tool-skill` gives them the catalog and loader; the merged catalog also
|
||||
# carries whatever the deployment registered globally (repository plugins).
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
# ── goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Only the model-facing tool. The goal SERVICE, its session driver, and the
|
||||
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
|
||||
# Remote endpoints whose receiver comes from a generated descriptor, so it
|
||||
# resolves `goals` on the host and an entry-local realm here would hide it. The
|
||||
# registry is keyed by session anyway, so one host instance serves every
|
||||
# session. What a preset chooses is whether its agent can call the goal tool.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# ── plan mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Plan state is per-agent by nature, so an entry-local realm is not a
|
||||
# workaround here — it is the correct lifetime.
|
||||
- id: planning
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
planMode: true
|
||||
config:
|
||||
- id: plan-mode
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
||||
|
||||
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
||||
|
||||
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
||||
|
||||
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
||||
|
||||
# ── compaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
|
||||
# share this realm rather than sit outside it.
|
||||
#
|
||||
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
|
||||
# plane, and the rows here resolve that one instance. It takes no configuration,
|
||||
# keys every fold by Session, and owns the context-meter projection units the
|
||||
# browser reads for every session — behind a realm those units would come and go
|
||||
# with whichever presets happen to be mounted. What a preset chooses is whether
|
||||
# its agent compacts at all, which is `compact-basic` below.
|
||||
- id: compaction
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
compact: true
|
||||
toolResultPrune: true
|
||||
config:
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
config:
|
||||
thresholdChars: 8192
|
||||
headChars: 4096
|
||||
tailChars: 1024
|
||||
|
||||
# ── delegation and workflows ────────────────────────────────────────────────
|
||||
|
||||
# The `subagents` registry and its spawn/fork backends live in the HOST
|
||||
# composition: the registry is a process singleton whose cross-session queries
|
||||
# the api-proxy serves to the browser, and a provider name may only be
|
||||
# registered once. This preset contributes the delegation TOOLS, which resolve
|
||||
# that host registry.
|
||||
#
|
||||
# `workflows` is different — nothing outside an agent reads it — so every row
|
||||
# that reaches it shares one entry-local realm here, and a consumer left
|
||||
# outside would resolve a host registry this preset does not populate.
|
||||
#
|
||||
# `tool-subagent-report` is host-plane for the same reason as the registry,
|
||||
# not because a preset may not want it: it registers a CONTINUABLE SETUP on
|
||||
# that singleton rather than a tool this agent calls, and the setup list is
|
||||
# not scope-aware — one copy per mounted preset means every child gets
|
||||
# `report` registered once per live session, which throws on the second.
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
workflows: true
|
||||
config:
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Product providers are host-plane singletons. Copy this preset, then
|
||||
# remove `disabled` from either ordinary tool row to expose that product
|
||||
# only to agents composed from the copy.
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: tool-subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
disabled: true
|
||||
config:
|
||||
provider: claude-code
|
||||
toolName: subagent_claude_code
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
config:
|
||||
subagentProvider: spawn
|
||||
maxRounds: 64
|
||||
|
||||
# ── remaining model-facing rows ─────────────────────────────────────────────
|
||||
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
config:
|
||||
allowParallelInProgress: true
|
||||
|
||||
# The `web` service and its search provider stay in the host composition; only
|
||||
# the model-facing tool is per-session.
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
searchTimeoutMs: 60000
|
||||
3
apps/cli/config/agent-presets/standard/preset.yml
Normal file
3
apps/cli/config/agent-presets/standard/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
name: 标准模式
|
||||
description: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
order: 1
|
||||
@@ -1,110 +0,0 @@
|
||||
# Opt-in Web shell for the RL core agent contract. The model receives exactly
|
||||
# the configured persona plus the native `bash` and `str_replace_editor`
|
||||
# schemas; the Web host, browser shell, persistence, and permission stack stay.
|
||||
|
||||
# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's
|
||||
# surface context removes its GUI orientation, managed shell variables, and the
|
||||
# launcher's source-checkout section through one configuration contract.
|
||||
# Workspace instructions are model-visible user context rather than a system
|
||||
# section, but RL core disables them as part of the same prompt contract.
|
||||
- id: system-prompt
|
||||
config:
|
||||
includeHarnessIdentity: false
|
||||
persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'
|
||||
|
||||
- id: web-runtime
|
||||
config:
|
||||
surfaceContext: false
|
||||
|
||||
- id: workspace-context
|
||||
disabled: true
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
mode: native
|
||||
|
||||
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
|
||||
# always-registered exit_plan_mode tool even while the session is not planning.
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
# These consumers are shared defaults on the ordinary shipped surfaces, but
|
||||
# this opt-in profile keeps exactly its two named tools.
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The matching browser controls must not offer host tools that this profile
|
||||
# omits. ui-question's host half owns the ask_user_question registration.
|
||||
- id: ui-plan
|
||||
disabled: true
|
||||
|
||||
- id: ui-question
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
# This backend consumes the existing Web sandbox and permission policy.
|
||||
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
|
||||
# Its 300s send wait matches the persistent Bash command timeout instead of
|
||||
# pty-local's 30s default. An open persistent shell fences permission-mode
|
||||
# changes until it closes.
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
# The editor consumes the Web fs-sandbox provider and therefore retains
|
||||
# the selected session permission mode.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dsh": "lib/bin.js"
|
||||
@@ -13,27 +20,62 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:*",
|
||||
"@cordisjs/plugin-include": "workspace:*",
|
||||
"@cordisjs/plugin-loader": "workspace:*",
|
||||
"@cordisjs/plugin-timer": "workspace:*",
|
||||
"@deepseek-ai/cordis-plugin-hmr": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-tool-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-base": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-persona": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-app": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"js-yaml": "^4.2.0",
|
||||
"node-addon-require-builtin": "^0.1.4"
|
||||
},
|
||||
@@ -46,6 +88,8 @@
|
||||
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: bb3e1d77f00e03f6a21f4da3c04994f208e84293
|
||||
README.zh.md: 3802f940ef6663ec215cf09293a9edda5ea4c512
|
||||
README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3
|
||||
README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
|
||||
|
||||
## Profile boot
|
||||
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch <path>` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile <name> "<task>"` command instead of reaching the row's raw required-field error.
|
||||
### App arguments
|
||||
|
||||
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
|
||||
|
||||
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
|
||||
|
||||
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
|
||||
|
||||
The shipped apps own these command lines:
|
||||
|
||||
| Profile | Arguments |
|
||||
|---|---|
|
||||
| `web` | `--host`, `--port`, repeatable `--trusted-host` |
|
||||
| `headless` | the task text, as the positional argument |
|
||||
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
|
||||
Inspect the composed tree without booting it:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
|
||||
## One-shot run
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row.
|
||||
|
||||
The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -43,40 +52,33 @@ Git-hosted plugins that ship sources build during install through their `prepare
|
||||
|
||||
## Web alias
|
||||
|
||||
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
|
||||
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
|
||||
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup.
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
|
||||
|
||||
`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback.
|
||||
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
|
||||
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
|
||||
|
||||
The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
## Source launcher
|
||||
## Source execution
|
||||
|
||||
Link the source-running launcher onto PATH:
|
||||
|
||||
```sh
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
|
||||
From the repository root, use `pnpm dsh <args...>`. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository.
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
|
||||
|
||||
## Profile 启动
|
||||
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch <path>` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
|
||||
组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
|
||||
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。
|
||||
### 应用参数
|
||||
|
||||
启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。
|
||||
|
||||
一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。
|
||||
|
||||
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
|
||||
|
||||
随附的各应用持有这些命令行:
|
||||
|
||||
| Profile | 参数 |
|
||||
|---|---|
|
||||
| `web` | `--host`、`--port`、可重复的 `--trusted-host` |
|
||||
| `headless` | 任务文本,作为位置参数 |
|
||||
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
|
||||
可在不启动的情况下检查组合出的配置树:
|
||||
|
||||
@@ -21,13 +36,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
|
||||
## 一次性运行
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。
|
||||
|
||||
启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -43,40 +52,33 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
|
||||
|
||||
## Web 别名
|
||||
|
||||
`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
|
||||
`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
dsh web --patch ./extra.cordis.yml
|
||||
dsh web --dump-config
|
||||
dsh web --help
|
||||
```
|
||||
|
||||
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
|
||||
|
||||
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
|
||||
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空——`SIGTERM` 是监督进程的普通停止请求,在所有 surface 上以 0 退出,`SIGINT` 报告 130;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
|
||||
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次 profile 启动都监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。
|
||||
|
||||
`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
|
||||
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
|
||||
|
||||
空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 约定](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
## 源码启动器
|
||||
## 源码执行
|
||||
|
||||
把源码运行启动器链接到 PATH:
|
||||
|
||||
```sh
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。
|
||||
请从仓库根目录使用 `pnpm dsh <args...>`。`package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
||||
* overlays. `run` owns one-shot task execution, defaulting to the headless
|
||||
* profile; `web` is a hardcoded alias for `--profile web` that adds the Web
|
||||
* flag family; `plugin` manages a profile's plugin dependencies by forwarding
|
||||
* to pnpm. Commander owns help, version, and parse errors.
|
||||
* Commander adapter for the `dsh` command line.
|
||||
*
|
||||
* The launcher parses only what it owns — which profile to boot, which extra
|
||||
* patch overlays to apply, and the config dumps — and hands **everything after
|
||||
* its own flags** to the booted tree verbatim, where injected app plugins parse
|
||||
* their own flag families and print their own `--help` (see
|
||||
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
|
||||
* token this parser does not recognize starts the inner arguments, so
|
||||
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
|
||||
* and `dsh --profile web -h` prints the web app's help, not this one's.
|
||||
*
|
||||
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
|
||||
* plugin dependencies by forwarding to pnpm.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/** Boot a named profile. */
|
||||
/** Boot a named profile and hand it the invocation's inner arguments. */
|
||||
interface ProfileInvocation {
|
||||
mode: 'profile'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/** Run one task through a profile mounting the headless runner. */
|
||||
interface RunInvocation {
|
||||
mode: 'run'
|
||||
profile: string
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
/** Non-blank task text joined from the variadic positional arguments. */
|
||||
task: string
|
||||
/** Everything after the launcher's own flags, verbatim, for injected app plugins. */
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Print a composed profile tree and exit without booting. */
|
||||
@@ -37,22 +36,6 @@ interface DumpConfigInvocation {
|
||||
patches: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
|
||||
* unvalidated pass-throughs to the webserver schema; absent values leave the
|
||||
* shipped web bundle values intact.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
workspaceRoot?: string
|
||||
/** Extra authorities for the /api browser-trust fence. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
|
||||
interface PluginInvocation {
|
||||
mode: 'plugin'
|
||||
@@ -62,32 +45,63 @@ interface PluginInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
||||
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
/** Launcher flags shared by the default command and the `web` alias. */
|
||||
interface BootOptions {
|
||||
patch?: string[]
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
workspaceRoot?: string
|
||||
trustedHost?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}
|
||||
|
||||
/** Raw run-subcommand options straight from Commander. */
|
||||
interface RunOptions {
|
||||
profile: string
|
||||
patch?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
|
||||
* variadic — a variadic `--patch` would swallow a following positional task.
|
||||
* variadic — a variadic `--patch` would swallow the inner arguments.
|
||||
*/
|
||||
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
|
||||
|
||||
/** The launcher's own help text; each app prints its own. */
|
||||
const HELP_EXAMPLES = `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh --profile headless "run the tests" answer one task, print the result, and exit
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
|
||||
dsh --profile web --help the web app's own flags and help
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
`
|
||||
|
||||
/**
|
||||
* Resolve a boot or dump invocation from the launcher flags and the leftover
|
||||
* inner arguments.
|
||||
* @param program - the command whose options were parsed (the root, or the `web` alias).
|
||||
* @param profile - the profile these flags boot.
|
||||
* @param options - the launcher flags commander collected.
|
||||
* @param args - the leftover arguments, in argv order.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
|
||||
return { mode: 'profile', profile, patches, args }
|
||||
}
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
// The dump is boot-free: it never runs app command-line providers, so it
|
||||
// cannot show what those flags would decide, and printing a tree that differs
|
||||
// from the same invocation's boot would mislead.
|
||||
if (args.length > 0) {
|
||||
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
return { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve argv into one invocation, or print and exit for help, version, or an
|
||||
* error.
|
||||
@@ -97,123 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
// Annotated, not inferred: the actions below call back into `program`, and an
|
||||
// inferred type would be circular through its own chain.
|
||||
const program: Command = new Command()
|
||||
program
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web boot the web profile (same as: dsh web)
|
||||
dsh run "run the tests" answer one task, print the result, and exit
|
||||
dsh run --profile custom "run the tests" run one task through a custom one-shot profile
|
||||
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh plugin --profile tui add <package> install a plugin into the tui profile
|
||||
dsh web --port 8080 the web alias with its flag family
|
||||
`)
|
||||
.addHelpText('after', HELP_EXAMPLES)
|
||||
.exitOverride()
|
||||
// The launcher's flags come first and end at the first token it does not
|
||||
// know; everything from there on belongs to the booted app, including
|
||||
// its -h. `dsh -h` with no profile still prints this help, below.
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
|
||||
.action((options: {
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}) => {
|
||||
const profile = options.profile ?? program.error('error: --profile <name> is required')
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
|
||||
return
|
||||
.action((args: string[], options: BootOptions & { profile?: string }) => {
|
||||
// With the app owning -h, the launcher's own help is what a bare
|
||||
// `dsh -h` (no profile to hand it to) must print.
|
||||
if (options.profile === undefined) {
|
||||
if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
|
||||
program.error('error: --profile <name> is required')
|
||||
}
|
||||
resolved = { mode: 'profile', profile, patches }
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
resolved = resolveBoot(program, profile, options, args)
|
||||
})
|
||||
|
||||
/** Reject parent options that crossed a subcommand boundary. */
|
||||
/** Reject parent options supplied before a subcommand. */
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{
|
||||
profile?: string
|
||||
patch?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}>()
|
||||
const parent = program.opts<BootOptions & { profile?: string }>()
|
||||
if (parent.profile !== undefined || parent.patch !== undefined
|
||||
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
|
||||
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
|
||||
}
|
||||
}
|
||||
|
||||
const run = program.command('run').description('run one task through a profile mounting the headless runner')
|
||||
run
|
||||
.option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.argument('<task...>', 'task text')
|
||||
.action((task: string[], options: RunOptions) => {
|
||||
rejectParentOptions('run')
|
||||
const profile = options.profile
|
||||
if (profile === '') program.error('error: --profile needs a name')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
const joined = task.join(' ')
|
||||
if (joined.trim() === '') program.error('error: run needs a non-blank task')
|
||||
resolved = { mode: 'run', profile, patches, task: joined }
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
|
||||
const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
|
||||
web
|
||||
.helpOption(false)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the web app (see: dsh web --help)')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
|
||||
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
.action((args: string[], options: BootOptions) => {
|
||||
rejectParentOptions('web')
|
||||
const patches = options.patch ?? []
|
||||
if (patches.includes('')) program.error('error: --patch needs a path')
|
||||
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && patches.length > 0) {
|
||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||
}
|
||||
// The dump is boot-free and does not derive flag patches; silently
|
||||
// dropping them would print a tree that differs from the same
|
||||
// invocation's boot.
|
||||
if (options.host !== undefined || options.port !== undefined || options.dev === true
|
||||
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
|
||||
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
|
||||
}
|
||||
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
||||
return
|
||||
}
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
resolved = {
|
||||
mode: 'web',
|
||||
patches,
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
resolved = resolveBoot(web, 'web', options, args)
|
||||
})
|
||||
|
||||
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
|
||||
|
||||
@@ -33,24 +33,10 @@ switch (invocation.mode) {
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
args: invocation.args,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'run': {
|
||||
const { runProfile } = await import('./profile-boot.ts')
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
patchFiles: invocation.patches,
|
||||
task: invocation.task,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation, loadLayeredEnv('dsh'))
|
||||
break
|
||||
}
|
||||
case 'plugin': {
|
||||
const { runPlugin } = await import('./plugin.ts')
|
||||
process.exit(runPlugin(invocation.profile, invocation.args))
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ConfigDumpLayer,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
|
||||
import { resolveWindowsShellLayer } from './windows-shell.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
|
||||
label: layer.packageName,
|
||||
patches: layer.patches,
|
||||
}))
|
||||
// The win32 shell platform layer rides between bundles and user layers,
|
||||
// exactly where the boot applies it.
|
||||
const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME)
|
||||
if (windowsShellLayer !== undefined) {
|
||||
layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches })
|
||||
}
|
||||
if (!defaultOnly) {
|
||||
if (existsSync(loaded.patchPath)) {
|
||||
layers.push({ label: loaded.patchPath, patches: loaded.patches })
|
||||
|
||||
@@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Process-exit controller shared by normal completion and Unix signal handlers. */
|
||||
export interface ProcessShutdown {
|
||||
/** Start or join graceful disposal before exiting with `code`. */
|
||||
/** Start or join graceful disposal before allowing natural completion with `code`. */
|
||||
shutdown(code: number): Promise<void>
|
||||
/** Start graceful disposal, or force exit when a shutdown is already running. */
|
||||
/** Start graceful disposal followed by exit, or force exit when shutdown is already running. */
|
||||
interrupt(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one process-exit controller around an application disposer.
|
||||
* @param dispose - Whole-application teardown that resolves at quiescence.
|
||||
* @param exit - Process exit boundary, replaceable by tests.
|
||||
* @param forceExit - Function that exits the process immediately, replaceable by tests.
|
||||
* @param complete - Function that records the natural completion code, replaceable by tests.
|
||||
* @param timeoutMs - Grace before forced exit, replaceable by tests.
|
||||
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
|
||||
*/
|
||||
export function createProcessShutdown(
|
||||
dispose: () => Promise<void>,
|
||||
exit: (code: number) => void = (code) => { process.exit(code) },
|
||||
forceExit: (code: number) => void = (code) => { process.exit(code) },
|
||||
complete: (code: number) => void = (code) => { process.exitCode = code },
|
||||
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
): ProcessShutdown {
|
||||
let pending: Promise<void> | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let exited = false
|
||||
let completed = false
|
||||
let forceExited = false
|
||||
|
||||
const exitOnce = (code: number): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
const clearExitTimeout = (): void => {
|
||||
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
const shutdown = (code: number): Promise<void> => {
|
||||
const forceExitOnce = (code: number): void => {
|
||||
if (forceExited) return
|
||||
forceExited = true
|
||||
clearExitTimeout()
|
||||
forceExit(code)
|
||||
}
|
||||
|
||||
const completeOnce = (code: number): void => {
|
||||
if (completed || forceExited) return
|
||||
completed = true
|
||||
clearExitTimeout()
|
||||
complete(code)
|
||||
}
|
||||
|
||||
const start = (code: number, forceAfterDispose: boolean): Promise<void> => {
|
||||
if (pending !== undefined) return pending
|
||||
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
|
||||
timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs)
|
||||
pending = Promise.resolve().then(dispose).then(
|
||||
() => { exitOnce(code) },
|
||||
() => { exitOnce(code) },
|
||||
() => {
|
||||
if (forceAfterDispose) forceExitOnce(code)
|
||||
else completeOnce(code)
|
||||
},
|
||||
() => { forceExitOnce(code) },
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
shutdown(code) {
|
||||
return start(code, false)
|
||||
},
|
||||
interrupt(code) {
|
||||
if (pending !== undefined) {
|
||||
exitOnce(code)
|
||||
forceExitOnce(code)
|
||||
return
|
||||
}
|
||||
void shutdown(code)
|
||||
void start(code, true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
|
||||
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
|
||||
* switch), mount the tree over the profile's empty root config, keep the
|
||||
* profile patch layer live, and wire fail-loud plus bounded shutdown.
|
||||
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
|
||||
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
||||
* tree over the profile's empty root config, keep the profile patch layer
|
||||
* live, and wire fail-loud plus bounded shutdown.
|
||||
*
|
||||
* App flags are not the launcher's business: the invocation's inner arguments
|
||||
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
||||
* plugin may read the same immutable snapshot.
|
||||
* @module @deepseek-ai/dsh/profile-boot
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import {
|
||||
boot,
|
||||
composeEntries,
|
||||
@@ -24,10 +29,17 @@ import {
|
||||
watchUserPatches,
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
|
||||
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
|
||||
|
||||
/** Harness-home directory holding locally authored agent presets. */
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
import { resolveWindowsShellLayer } from './windows-shell.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -47,9 +59,6 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
|
||||
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
|
||||
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
||||
|
||||
/** The one-shot runner row a `dsh run` task requires and configures. */
|
||||
const HEADLESS_ROW_ID = 'headless-runner'
|
||||
|
||||
/** The empty root entry list every profile tree patches over. */
|
||||
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
|
||||
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
|
||||
@@ -96,109 +105,114 @@ export function prepareProfile(name: string, userLayer = true): Profile {
|
||||
return profile
|
||||
}
|
||||
|
||||
/** Read-only row index of a profile composition before launcher flag patches. */
|
||||
export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
|
||||
|
||||
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
|
||||
interface ComposedProfile {
|
||||
profile: Profile
|
||||
/** Bundle layers concatenated — the part below the user layers on a live reload. */
|
||||
bundlePatches: PatchOptions[]
|
||||
/** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
|
||||
windowsShellPatches: PatchOptions[]
|
||||
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
|
||||
homePatches: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
|
||||
overlayAndFlags: PatchOptions[]
|
||||
/** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
|
||||
overlays: PatchOptions[]
|
||||
/**
|
||||
* id → row of the pre-flag composition (bundles + user layers + overlays),
|
||||
* for flag merges and row checks. Flag patches must not insert rows the
|
||||
* launcher consults here (they only override values and insert dev glue).
|
||||
* id → row of the composed tree (bundles + user layers + overlays), for the
|
||||
* launcher's own row checks.
|
||||
*/
|
||||
rows: ProfileRows
|
||||
rows: ReadonlyMap<string, EntryOptions>
|
||||
}
|
||||
|
||||
/** The full patch stack of one composed profile, in application order. */
|
||||
function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
|
||||
return [
|
||||
...composed.bundlePatches,
|
||||
...composed.windowsShellPatches,
|
||||
...composed.profile.patches,
|
||||
...composed.homePatches,
|
||||
...composed.overlays,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `name` and compose its effective patch stack: bundle layers in
|
||||
* `dsh.profile.bundles` order, the profile's user layer, the home-level user layer
|
||||
* `dsh.profile.bundles` order, the win32 shell platform layer (when the host
|
||||
* is Windows), the profile's user layer, the home-level user layer
|
||||
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
|
||||
* every profile, so it outranks the per-profile layer), `--patch` overlays,
|
||||
* then flag patches derived from the composed rows, then the telemetry
|
||||
* switch.
|
||||
* then the telemetry switch.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
|
||||
* @returns the profile, its patch layers, and the composed row index.
|
||||
*/
|
||||
function composeProfile(
|
||||
name: string,
|
||||
patchFiles: readonly string[],
|
||||
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
|
||||
): ComposedProfile {
|
||||
const profile = prepareProfile(name)
|
||||
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
|
||||
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
||||
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
||||
const rows = new Map<string, { name?: string; config?: unknown }>()
|
||||
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
|
||||
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
|
||||
const rows = new Map<string, EntryOptions>()
|
||||
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
|
||||
const composedOverlays = [...overlays]
|
||||
// Preset roots belong to every dsh composition that mounts the roster.
|
||||
if (rows.has('agent-presets')) {
|
||||
composedOverlays.push({
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
|
||||
roots: [
|
||||
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
|
||||
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
||||
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
|
||||
return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
|
||||
if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
|
||||
return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
|
||||
}
|
||||
|
||||
/** Options for {@link runProfile}. */
|
||||
export interface RunProfileOptions {
|
||||
/** This run's frozen environment snapshot, provided before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The profile name to boot. */
|
||||
profile: string
|
||||
/** `--patch` overlay paths, in argv order. */
|
||||
patchFiles: readonly string[]
|
||||
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
|
||||
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
|
||||
/** `dsh run` task text; requires the composition to mount the headless runner row. */
|
||||
task?: string
|
||||
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
||||
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
|
||||
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
|
||||
environment: EnvironmentSnapshot
|
||||
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
||||
args: readonly string[]
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
|
||||
if (!signal.aborted) throw error
|
||||
/**
|
||||
* Re-throw a watcher-setup failure unless a shutdown already owns the tree:
|
||||
* a signal aborted this invocation, or an app requested exit (`ctx.appExit`
|
||||
* from a fast one-shot) and the root's disposal rejected the in-flight setup
|
||||
* await. Either way the failure describes a tree that is exiting as asked,
|
||||
* not a broken watch.
|
||||
* @param ctx - the booted root context.
|
||||
* @param signal - this invocation's signal-shutdown fact.
|
||||
* @param error - the setup failure.
|
||||
*/
|
||||
function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
|
||||
if (signal.aborted) return
|
||||
if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one profile invocation end to end and leave process lifetime to the
|
||||
* mounted plugins (or to the one-shot runner when `task` is present).
|
||||
* @param options - profile name, overlays, flag patches, and the optional task.
|
||||
* mounted plugins (or to a one-shot runner the composition mounts).
|
||||
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
||||
* @returns the settled root context and the shutdown controller.
|
||||
*/
|
||||
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
||||
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
|
||||
if (options.task !== undefined) {
|
||||
if (!composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
|
||||
+ '(the headless profile does)',
|
||||
)
|
||||
}
|
||||
composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
|
||||
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
|
||||
// The inverse misuse: a one-shot composition booted without its task
|
||||
// would otherwise die in the runner row's schema with a raw "required"
|
||||
// error naming no fix.
|
||||
throw new Error(
|
||||
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
|
||||
+ `dsh run --profile ${options.profile} "<task>"`,
|
||||
)
|
||||
}
|
||||
|
||||
const composed = composeProfile(options.profile, options.patchFiles)
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
const signalShutdown = new AbortController()
|
||||
@@ -207,9 +221,11 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted front door can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
// settles: an inserted provider can publish before sibling rows finish mounting.
|
||||
// SIGTERM is a supervisor's ordinary stop request and exits 0 on every
|
||||
// surface — the launcher does not know whether the app considered its work
|
||||
// complete; SIGINT is a user interrupt and reports 130.
|
||||
process.on('SIGTERM', () => { interrupt(0) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
@@ -217,7 +233,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
|
||||
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
||||
// Recomposition for the live user layers: bundle layers below, overlays
|
||||
// and flag patches above, so a user edit can never displace them. BOTH
|
||||
// above, so a user edit can never displace them. Parsed app arguments are
|
||||
// not in here at all — they live in app-provided services that survive a
|
||||
// recomposition. BOTH
|
||||
// user files are re-read per generation (the HMR watcher hands us only the
|
||||
// changed file's patches, which one of the reads duplicates — fresh reads
|
||||
// keep the two watchers from stitching in each other's stale copy).
|
||||
@@ -228,37 +246,34 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// removing the override could never revert the row to the bundle default.
|
||||
const composeLive = (): PatchOptions[] => structuredClone([
|
||||
...composed.bundlePatches,
|
||||
...composed.windowsShellPatches,
|
||||
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
...composed.overlayAndFlags,
|
||||
...composed.overlays,
|
||||
])
|
||||
// One-shot runs exit through the runner; watching would only hold the
|
||||
// process open after its exit request.
|
||||
const watchProfilePatch = options.task === undefined
|
||||
// Cloned for the same insert-aliasing reason as composeLive: the boot
|
||||
// application must not mutate the objects later reloads recompose from.
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
||||
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
// Before any config-tree entry mounts, so a plugin that resolves a
|
||||
// user-facing value at construction already sees this run's layers.
|
||||
// Before any config-tree entry mounts, so plugins resolve all launch-time
|
||||
// environment values from the same immutable provenance snapshot.
|
||||
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
|
||||
if (options.task !== undefined) {
|
||||
const io: HeadlessIo = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
exit: (code) => { void shutdown.shutdown(code) },
|
||||
}
|
||||
hostCtx.provide('headlessIo', io)
|
||||
}
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
// The command line and bounded exit request are launcher facts available
|
||||
// to every app plugin that injects the argument snapshot.
|
||||
provideCmdline(hostCtx, {
|
||||
args: options.args,
|
||||
exit: code => void shutdown.shutdown(code),
|
||||
})
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup or this post-boot
|
||||
// watcher setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
&& !signalShutdown.signal.aborted
|
||||
// A surface can dispose the whole tree while boot or this post-boot watcher
|
||||
// setup is still in flight — a signal, or a fast one-shot's appExit. Loader
|
||||
// presence and fiber state own liveness; the initial check skips a tree
|
||||
// that already exited, and the catch below re-checks for an exit that
|
||||
// landed mid-setup. Watching is unconditional: a one-shot surface exits
|
||||
// through its bounded shutdown, which disposes the watchers before the
|
||||
// loop drains.
|
||||
if (!signalShutdown.signal.aborted
|
||||
&& ctx.fiber.state === FiberState.ACTIVE
|
||||
&& ctx.get('loader') !== undefined) {
|
||||
try {
|
||||
@@ -271,9 +286,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
@@ -286,7 +301,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
compose: composeLive,
|
||||
})
|
||||
} catch (error) {
|
||||
suppressSignalShutdownError(signalShutdown.signal, error)
|
||||
suppressShutdownError(ctx, signalShutdown.signal, error)
|
||||
}
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
|
||||
* plus the Web flag family (`--host/--port/--dev/--workspace-root/
|
||||
* --trusted-host`), each flag becoming a patch over the composed profile
|
||||
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
|
||||
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
|
||||
* flag patches and the LAN-trust snapshot.
|
||||
* @module @deepseek-ai/dsh/web
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
||||
* authorities an all-interfaces bind is reachable by on the LAN.
|
||||
* @returns the addresses in interface order (possibly empty).
|
||||
*/
|
||||
function lanIPv4Addresses(): string[] {
|
||||
return Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
}
|
||||
|
||||
/**
|
||||
* One LAN-trust resolution for one invocation, sampled exactly once: the
|
||||
* machine's LAN IP literals when the effective bind is all-interfaces, and
|
||||
* the `trustedHosts` value built from them plus the explicit extras. The
|
||||
* single sample is deliberate — display must advertise only addresses the
|
||||
* fence was configured with, so the web-app row receives this same snapshot.
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, so an IP-literal Host is safe on any port, and
|
||||
* the bound port may be OS-assigned, unknowable pre-boot.
|
||||
* @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
|
||||
* @param extra - `--trusted-host` values, in argv order.
|
||||
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
||||
*/
|
||||
export function resolveLanTrust(
|
||||
bindHost: string | undefined,
|
||||
extra: readonly string[],
|
||||
): { lanAddresses: string[]; trustedHosts: string[] } {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** The `dsh web` flag family, already parsed by the argument adapter. */
|
||||
export interface WebFlags {
|
||||
patches: string[]
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
workspaceRoot?: string
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the web alias's flag patches over an already-composed profile tree.
|
||||
* Patches replace a row's whole config, so each patched row's composed values
|
||||
* are re-read and merged under the overrides.
|
||||
* @param rows - the composed row index from {@link composeProfile}.
|
||||
* @param flags - the parsed flag family.
|
||||
* @returns the flag patch list, in application order.
|
||||
*/
|
||||
function deriveWebFlagPatches(
|
||||
rows: ProfileRows,
|
||||
flags: WebFlags,
|
||||
): PatchOptions[] {
|
||||
const overrides = new Map<string, Record<string, unknown>>()
|
||||
const put = (entryId: string, key: string, value: unknown): void => {
|
||||
const bag = overrides.get(entryId) ?? {}
|
||||
bag[key] = value
|
||||
overrides.set(entryId, bag)
|
||||
}
|
||||
if (flags.host !== undefined) put('webserver', 'host', flags.host)
|
||||
if (flags.port !== undefined) put('webserver', 'port', flags.port)
|
||||
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
|
||||
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
||||
if (trustedHosts.length > 0) {
|
||||
// Additive over the composed value: a cordis.patch.yml-configured fence
|
||||
// authority must survive the derived LAN literals and flag extras — a
|
||||
// silent drop of security-relevant fence configuration.
|
||||
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
|
||||
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
|
||||
}
|
||||
// mode and lanAddresses are launcher-derived on every boot (--dev also
|
||||
// inserts the client-hmr row), never pass-throughs of composed values.
|
||||
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
||||
put('web-runtime', 'lanAddresses', lanAddresses)
|
||||
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
||||
const composed = rows.get(id)
|
||||
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
|
||||
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
|
||||
})
|
||||
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
|
||||
return patches
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composed Web runtime keeps its model- and shell-visible surface
|
||||
* context. The bundle schema defaults the field to true, so only an explicit
|
||||
* false suppresses both the bundle contributions and the launcher-owned
|
||||
* source-checkout section.
|
||||
* @param rows - the composed Web profile rows before launcher flag patches.
|
||||
* @returns true unless the web-runtime row explicitly disables surface context.
|
||||
*/
|
||||
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
||||
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the browser UI from the web profile. Host/port/workspace-root flags
|
||||
* are passed through only when given (absent, the composed profile values
|
||||
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
|
||||
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||
* after Loader settlement.
|
||||
* @param flags - the parsed `dsh web` flag family.
|
||||
* @param environment - this run's frozen environment snapshot.
|
||||
*/
|
||||
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
||||
await runProfile({
|
||||
environment,
|
||||
profile: 'web',
|
||||
patchFiles: flags.patches,
|
||||
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
||||
prepare: (ctx: Context, rows: ProfileRows) => {
|
||||
if (!webSurfaceContextEnabled(rows)) return
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
52
apps/cli/src/windows-shell.ts
Normal file
52
apps/cli/src/windows-shell.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The Windows shell platform layer: on win32 hosts the shipped profile
|
||||
* compositions swap the POSIX-only bash stack for the sandbox-confined
|
||||
* PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` +
|
||||
* `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's
|
||||
* `windows.cordis.patch.yml`, injected by the launcher between the bundle
|
||||
* layers and the user layers so a user patch can still override it — the
|
||||
* only override channel is composition config, like every other roster
|
||||
* decision. POSIX hosts never receive the layer.
|
||||
* @module @deepseek-ai/dsh/windows-shell
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/** The base bundle whose package carries the Windows shell patch. */
|
||||
export const BASE_BUNDLE = '@deepseek-ai/dsh-base'
|
||||
|
||||
/** The Windows shell patch filename inside the base bundle package. */
|
||||
export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml'
|
||||
|
||||
/** One Windows shell platform layer: its patch file and parsed patches. */
|
||||
export interface WindowsShellLayer {
|
||||
/** The patch file path, used as the config-dump provenance label. */
|
||||
label: string
|
||||
/** The parsed patch entries, applied after the bundle layers. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Windows shell platform layer for a profile composition.
|
||||
* @param platform - the host platform (`process.platform` at call sites).
|
||||
* @param layers - the profile's bundle layers, in application order.
|
||||
* @param binName - the diagnostic prefix on thrown errors (`dsh`).
|
||||
* @returns the pwsh layer on win32, else `undefined`. A custom profile that
|
||||
* mounts no base bundle is skipped (it owns its shell stack); a base
|
||||
* bundle whose Windows shell patch is missing fails loud in
|
||||
* {@link loadOverlayPatches} — the shipped package always carries it, so
|
||||
* a miss is a broken installation.
|
||||
*/
|
||||
export function resolveWindowsShellLayer(
|
||||
platform: NodeJS.Platform,
|
||||
layers: readonly ProfileLayer[],
|
||||
binName: string,
|
||||
): WindowsShellLayer | undefined {
|
||||
if (platform !== 'win32') return undefined
|
||||
const base = layers.find(layer => layer.packageName === BASE_BUNDLE)
|
||||
if (base === undefined) return undefined
|
||||
const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME)
|
||||
return { label, patches: loadOverlayPatches(binName, label) }
|
||||
}
|
||||
@@ -21,22 +21,28 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
it('routes profile boots and the web alias, handing the rest to the app', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
|
||||
expect(parse(['run', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' })
|
||||
expect(parse(['run', '--', '--profile', 'is', 'task', 'text']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] })
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
|
||||
expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
|
||||
})
|
||||
|
||||
it('ends the launcher flags at the first token it does not own', () => {
|
||||
// App flags, including its -h, and positionals reach the app verbatim.
|
||||
expect(parse(['--profile', 'tui', '--resume', 'abc']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
|
||||
expect(parse(['--profile', 'web', '-h']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
|
||||
// Launcher flags placed after that boundary belong to the app too.
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
|
||||
})
|
||||
|
||||
it('routes the plugin pnpm forwarder', () => {
|
||||
@@ -44,8 +50,8 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] })
|
||||
// Unknown pnpm flags forward verbatim.
|
||||
expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
|
||||
@@ -64,18 +70,12 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
|
||||
expect(exitCode(['run'])).toBe(1)
|
||||
expect(exitCode(['run', ''])).toBe(1)
|
||||
expect(exitCode(['run', '--profile', '', 'task'])).toBe(1)
|
||||
expect(exitCode(['run', '--patch=', 'task'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
|
||||
expect(exitCode(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -87,21 +87,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||
// would print a tree that differs from the same invocation's boot.
|
||||
// A dump never runs app command-line providers, so it cannot show what
|
||||
// those flags would decide; printing a tree that differs from the same
|
||||
// invocation's boot would mislead.
|
||||
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
|
||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
it('keeps its own help for an invocation with no app to hand it to', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['-h'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
// The release version, including a prerelease such as 0.0.1-rc.1: `--version`
|
||||
// prints what this manifest carries, so no test may pin it to a literal.
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
|
||||
async function runBuiltBin(
|
||||
@@ -129,8 +131,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
@@ -145,8 +147,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
|
||||
fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
@@ -194,8 +196,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
interface StartupFixture {
|
||||
home: string
|
||||
ready: string
|
||||
echo: string
|
||||
interrupt: string
|
||||
/** An always-running row's echo, used to observe that a user patch reload landed. */
|
||||
witness: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
|
||||
* a row that reads its app-owned service through a `!!js` config expression.
|
||||
* Both plugin modules resolve
|
||||
* `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
|
||||
* fallback, exactly as an installed out-of-tree bundle does.
|
||||
*/
|
||||
function createStartupFixture(): StartupFixture {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
|
||||
const profileDir = join(home, 'profiles', 'startup')
|
||||
// Written straight into the installed location: a row module resolves its
|
||||
// own imports from where it is installed, and only inside the profile does
|
||||
// Node's parent walk reach the installation fallback these plugins need.
|
||||
const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'startup.mjs'), [
|
||||
"import { Command } from 'commander'",
|
||||
"import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
|
||||
"export const name = 'fixture-startup'",
|
||||
"export const inject = ['cmdlineArgs']",
|
||||
'export function apply(ctx) {',
|
||||
" const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
|
||||
' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))',
|
||||
' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'waiting.mjs'), [
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'startup-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
||||
' ctx.effect(() => () => { clearInterval(heartbeat) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'witness.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'reload-witness'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: startup-fixture',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
|
||||
' inject: [fixtureStartup]',
|
||||
' config:',
|
||||
// Lazy interpolation runs only after the provider's service is injected.
|
||||
" generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
|
||||
' - id: fixture-startup',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
|
||||
' - id: reload-witness',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-startup-bundle',
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-startup',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
||||
return {
|
||||
home,
|
||||
ready: join(home, 'ready'),
|
||||
echo: join(home, 'config-echo'),
|
||||
interrupt: join(home, 'interrupt'),
|
||||
witness: join(home, 'witness'),
|
||||
}
|
||||
}
|
||||
|
||||
function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -203,46 +318,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const help = await runBuiltBin(['--help'])
|
||||
expect(help.code).toBe(0)
|
||||
expect(help.stdout).toContain('dsh --profile web')
|
||||
expect(help.stdout).toContain('dsh run "run the tests"')
|
||||
expect(help.stdout).toContain('dsh plugin --profile')
|
||||
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('prints run help without initializing the selected profile', async () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
|
||||
const home = join(parent, 'not-created')
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
|
||||
expect(existsSync(home)).toBe(false)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const web = await runBuiltBin(['--profile', 'web', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(web.code).toBe(0)
|
||||
expect(web.stderr).toBe('')
|
||||
expect(web.stdout).toContain('Usage: dsh --profile web')
|
||||
expect(web.stdout).toContain('--port <port>')
|
||||
expect(web.stdout).not.toContain('dsh web: http://')
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(headlessHelp.code).toBe(0)
|
||||
expect(headlessHelp.stderr).toBe('')
|
||||
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
|
||||
|
||||
const missingTask = await runBuiltBin(['--profile', 'headless'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(missingTask.code).toBe(1)
|
||||
expect(missingTask.stderr).toContain('a task is required')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
successText: 'published headless profile reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published dsh run reached the mock')
|
||||
expect(result.stdout).toBe('published headless profile reached the mock')
|
||||
expect(result.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
@@ -258,7 +390,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
|
||||
try {
|
||||
const result = await runBuiltBin(['--version'], {}, project)
|
||||
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
|
||||
expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
@@ -318,9 +450,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// An HMR main-watcher initial scan that refreshes the include
|
||||
// mid-initial-apply deadlocks the failing apply's rollback against the
|
||||
// refresh drain: dsh exits 13 with no diagnostic instead of settling
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
@@ -337,9 +469,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
const child = startProfileLifecycle(fixture)
|
||||
const child = startProfileLifecycle(fixture, ['--unclaimed'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
requestProfileShutdown(child, fixture)
|
||||
@@ -405,6 +537,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
// The consumer started once, already carrying the flag value: the
|
||||
// launcher never saw --generation, and the app provider resolved it first.
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, [])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
// active, so each config expression reads the same invocation value (a
|
||||
// served port does not move back to its composed fallback).
|
||||
const fixture = createStartupFixture()
|
||||
const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
// Both rows: the waiting one carries the flag value, and the witness is
|
||||
// what a reload will re-mount. They start independently, so neither
|
||||
// marker implies the other.
|
||||
await waitForFile(fixture.ready)
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
// An edit to an unrelated row: the witness re-mounts, which is how this
|
||||
// test knows the whole tree was recomposed.
|
||||
rmSync(fixture.witness)
|
||||
writeFileSync(profilePatch, [
|
||||
'- id: reload-witness',
|
||||
' config:',
|
||||
' generation: reloaded',
|
||||
'',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
try {
|
||||
const result = await startStartupProfile(fixture, ['--help'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Usage: fixture')
|
||||
expect(result.stdout).toContain('--generation')
|
||||
expect(existsSync(fixture.ready)).toBe(false)
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
||||
@@ -491,18 +700,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
}, 30_000)
|
||||
|
||||
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['--profile', 'headless', '--dump-default-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
@@ -543,16 +751,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
it('shows the RL Web patch disabling runtime surface context', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['web', '--patch', coreWebOverlay, '--dump-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).toContain('surfaceContext: false')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => {
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
[](https://github.com/deepseek-ai/deepseek-harness)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
@@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => {
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
[](https://github.com/deepseek-ai/deepseek-harness)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "dsh-github-repository-plugin-e2e-fixture",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib",
|
||||
"dsh-plugin.mjs",
|
||||
"dsh-plugin-assets"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": [
|
||||
"../skills"
|
||||
],
|
||||
"mcpServers": "./.mcp.json",
|
||||
"entry": "./lib/plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "0.0.1",
|
||||
"cordis": "4.0.0-rc.7",
|
||||
"tsdown": "0.22.2",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
|
||||
// The repository root's linter cannot resolve this independently installed
|
||||
// Git-package dependency; the package's prepack tsc validates the SDK types.
|
||||
/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */
|
||||
const server = new McpServer({
|
||||
name: 'github-repository-plugin-e2e',
|
||||
version: '0.0.0',
|
||||
})
|
||||
|
||||
server.registerTool('proof', {
|
||||
description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }],
|
||||
}))
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
const PROOF_TOOL_NAME = 'mcp__github_repository__proof'
|
||||
|
||||
interface TextBlock {
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
readonly isError: boolean
|
||||
readonly content: readonly TextBlock[]
|
||||
}
|
||||
|
||||
type PostDecision =
|
||||
| { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] }
|
||||
| { readonly kind: 'block'; readonly feedback: readonly TextBlock[] }
|
||||
|
||||
type PostListener = (
|
||||
execution: ToolExecution,
|
||||
result: ToolResult,
|
||||
next: () => Promise<PostDecision>,
|
||||
) => Promise<PostDecision>
|
||||
|
||||
type DshContext = Context & {
|
||||
on(event: 'tools/post-execute', listener: PostListener): () => void
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by the repository acceptance fixture. */
|
||||
export const name = 'github-repository-typescript-proof'
|
||||
|
||||
/** DSH tool registry required by the post-execute contribution. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/**
|
||||
* Append a marker after the repository MCP proof tool succeeds.
|
||||
* @param ctx - trusted DSH Cordis context supplied to the repository package.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const dsh = ctx as DshContext
|
||||
dsh.on('tools/post-execute', async (execution, result, next): Promise<PostDecision> => {
|
||||
const decision = await next()
|
||||
if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) {
|
||||
return decision
|
||||
}
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [
|
||||
...(decision.content ?? result.content),
|
||||
{ type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' },
|
||||
],
|
||||
...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: github-source-proof
|
||||
description: Proves that dsh installed a private repository Plugin from an exact GitHub source.
|
||||
---
|
||||
|
||||
This skill exists only in the GitHub repository source fixture.
|
||||
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* Register a disposer that keeps process shutdown pending until it is forced.
|
||||
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - loader-mounted test plugin context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin')
|
||||
const releasePackageNames = new Set(globSync([
|
||||
'vendor/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
], { cwd: repoRoot }).map((filename) => {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown>
|
||||
if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`)
|
||||
return manifest.name
|
||||
}))
|
||||
const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE
|
||||
const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1'
|
||||
const enabled = required || source !== undefined
|
||||
|
||||
interface PublishedPackageRegistry {
|
||||
url: string
|
||||
requests: string[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function publishedManifest(): Record<string, unknown> {
|
||||
const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
const version = manifest.version
|
||||
if (typeof version !== 'string') throw new Error('repository Plugin package version is missing')
|
||||
Reflect.deleteProperty(manifest, 'private')
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
const dependencies = manifest[field]
|
||||
if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue
|
||||
const entries = dependencies as Record<string, unknown>
|
||||
for (const name of Object.keys(entries)) {
|
||||
if (releasePackageNames.has(name)) {
|
||||
entries[name] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> {
|
||||
const staging = join(root, 'published-repository-plugin')
|
||||
const artifacts = join(root, 'npm-registry-artifacts')
|
||||
mkdirSync(staging)
|
||||
mkdirSync(artifacts)
|
||||
cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true })
|
||||
for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) {
|
||||
cpSync(join(repositoryPluginPackage, filename), join(staging, filename))
|
||||
}
|
||||
cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE'))
|
||||
const manifest = publishedManifest()
|
||||
writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
|
||||
const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], {
|
||||
cwd: staging,
|
||||
reject: false,
|
||||
})
|
||||
if (packed.exitCode !== 0) {
|
||||
throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`)
|
||||
}
|
||||
const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz'))
|
||||
if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`)
|
||||
const tarball = readFileSync(join(artifacts, tarballs[0]!))
|
||||
const name = manifest.name as string
|
||||
const version = manifest.version as string
|
||||
const requests: string[] = []
|
||||
let registryUrl = ''
|
||||
const server = createServer((request, response) => {
|
||||
const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname)
|
||||
requests.push(`${request.method ?? 'GET'} ${path}`)
|
||||
if (path === `/${name}`) {
|
||||
const metadata = {
|
||||
name,
|
||||
'dist-tags': { latest: version },
|
||||
versions: {
|
||||
[version]: {
|
||||
...manifest,
|
||||
dist: {
|
||||
tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`,
|
||||
shasum: createHash('sha1').update(tarball).digest('hex'),
|
||||
integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify(metadata))
|
||||
return
|
||||
}
|
||||
if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(tarball.length),
|
||||
})
|
||||
response.end(tarball)
|
||||
return
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'not found' }))
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address')
|
||||
registryUrl = `http://127.0.0.1:${address.port}/`
|
||||
return {
|
||||
url: registryUrl,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => {
|
||||
it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
|
||||
expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true)
|
||||
expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch(
|
||||
/^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u,
|
||||
)
|
||||
|
||||
const apiKey = 'github-repository-plugin-e2e-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['tool_call_success', 'success'],
|
||||
apiKey,
|
||||
toolName: 'mcp__github_repository__proof',
|
||||
toolArguments: '{}',
|
||||
successText: 'trusted GitHub repository package reached dsh run',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-'))
|
||||
const registry = await startPublishedPackageRegistry(home)
|
||||
const npmrc = join(home, 'npmrc')
|
||||
writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`)
|
||||
const hostBin = join(home, 'host-bin')
|
||||
mkdirSync(hostBin)
|
||||
writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [
|
||||
'#!/bin/sh',
|
||||
'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2',
|
||||
'exit 91',
|
||||
'',
|
||||
].join('\n'), { mode: 0o700 })
|
||||
const patch = join(home, 'github-repository-plugin.cordis.patch.yml')
|
||||
writeFileSync(patch, [
|
||||
'- id: repository-plugins',
|
||||
' config:',
|
||||
' repositories:',
|
||||
` - ${JSON.stringify(source)}`,
|
||||
'- id: session-title-llm',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'run',
|
||||
'--patch',
|
||||
patch,
|
||||
'prove the private GitHub repository Plugin is active',
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
timeout: 180_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
// A warm runner cache could satisfy the exact tarball without
|
||||
// contacting this test's registry, which would stop proving the
|
||||
// unpublished package was installed through the simulated release.
|
||||
PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'),
|
||||
PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'),
|
||||
PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0)
|
||||
expect(result.stdout).toBe('trusted GitHub repository package reached dsh run')
|
||||
expect(server.requests).toHaveLength(2)
|
||||
const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}`
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin')
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz')
|
||||
const firstRequest = JSON.stringify(server.requests[0]!.body)
|
||||
const secondRequest = JSON.stringify(server.requests[1]!.body)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain(
|
||||
'Proves that dsh installed a private repository Plugin from an exact GitHub source.',
|
||||
)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof')
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
|
||||
const cacheRoot = join(home, 'cache', 'repository-plugins')
|
||||
const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory())
|
||||
expect(generations).toHaveLength(1)
|
||||
const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository')
|
||||
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'dsh-github-repository-plugin-e2e-fixture',
|
||||
private: true,
|
||||
scripts: {
|
||||
prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare',
|
||||
},
|
||||
dsh: {
|
||||
skills: ['../skills'],
|
||||
mcpServers: './.mcp.json',
|
||||
entry: './lib/plugin.mjs',
|
||||
},
|
||||
dependencies: {
|
||||
'@modelcontextprotocol/sdk': '1.29.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/dsh-repository-plugin': '0.0.1',
|
||||
cordis: '4.0.0-rc.7',
|
||||
tsdown: '0.22.2',
|
||||
typescript: '6.0.3',
|
||||
},
|
||||
})
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8'))
|
||||
.toContain('This skill exists only in the GitHub repository source fixture.')
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs')
|
||||
expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(existsSync(join(installed, 'src'))).toBe(false)
|
||||
const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs'))
|
||||
expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true)
|
||||
const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')
|
||||
expect(wrapper).toContain('dsh-repository-plugin')
|
||||
expect(wrapper).toContain('await import(manifest.entry)')
|
||||
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
|
||||
} finally {
|
||||
await server.close()
|
||||
await registry.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 190_000)
|
||||
})
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['run', 'never complete'],
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
|
||||
const fixtures: string[] = []
|
||||
|
||||
const PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
script, cwd, env_json, actions_json = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(env_json))
|
||||
actions = json.loads(actions_json)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe("sh", ["sh", script], env)
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
deadline = time.monotonic() + 15
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EIO:
|
||||
raise
|
||||
chunk = b""
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
os.write(fd, actions[action_index]["send"].encode())
|
||||
action_index += 1
|
||||
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
||||
if waited == pid:
|
||||
status = candidate
|
||||
break
|
||||
|
||||
if status is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
sys.stdout.buffer.write(output)
|
||||
if action_index != len(actions):
|
||||
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
|
||||
sys.exit(124)
|
||||
sys.exit(os.waitstatus_to_exitcode(status))
|
||||
`
|
||||
|
||||
interface Action {
|
||||
readonly waitFor: string
|
||||
readonly send: string
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly binDirectory: string
|
||||
readonly launchLog: string
|
||||
readonly pnpmLog: string
|
||||
readonly root: string
|
||||
readonly script: string
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
|
||||
})
|
||||
|
||||
function executable(path: string, content: string): void {
|
||||
writeFileSync(path, content)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
|
||||
fixtures.push(root)
|
||||
const checkoutDirectory = join(root, 'checkout')
|
||||
const scriptsDirectory = join(checkoutDirectory, 'scripts')
|
||||
const sourceBinDirectory = join(checkoutDirectory, 'bin')
|
||||
const fakeBinDirectory = join(root, 'fake-bin')
|
||||
const binDirectory = join(root, 'path-bin')
|
||||
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
const script = join(scriptsDirectory, 'install.sh')
|
||||
copyFileSync(installer, script)
|
||||
const launchLog = join(root, 'launch.log')
|
||||
const pnpmLog = join(root, 'pnpm.log')
|
||||
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
|
||||
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
|
||||
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
|
||||
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
|
||||
`)
|
||||
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
|
||||
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
|
||||
await execa('git', [
|
||||
'-c', 'user.name=dsh-test',
|
||||
'-c', 'user.email=dsh-test@example.invalid',
|
||||
'commit', '-qm', 'fixture',
|
||||
], { cwd: checkoutDirectory })
|
||||
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
|
||||
return { binDirectory, launchLog, pnpmLog, root, script }
|
||||
}
|
||||
|
||||
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
PTY_DRIVER,
|
||||
fixture.script,
|
||||
fixture.root,
|
||||
JSON.stringify({
|
||||
DSH_BIN_DIR: fixture.binDirectory,
|
||||
DSH_HOME: join(fixture.root, 'home/.dsh'),
|
||||
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
|
||||
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
|
||||
HOME: join(fixture.root, 'home'),
|
||||
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
|
||||
}),
|
||||
JSON.stringify(actions),
|
||||
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => {
|
||||
it('builds and launches the Web UI', async () => {
|
||||
const fixture = await createFixture()
|
||||
|
||||
const output = await runInstaller(fixture, [
|
||||
{ waitFor: 'Replace it?', send: '\n' },
|
||||
])
|
||||
|
||||
expect(output).toContain('launching Web UI')
|
||||
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
|
||||
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The third-party memory examples stay config-only. This suite parses every
|
||||
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
|
||||
* checked-in overlay, verifies its package pin, transport, and secret handling, then replaces
|
||||
* only the upstream endpoint with the package-owned keyless MCP fixture and
|
||||
* proves the real Cordis Loader discovers a tool through the generic bridge.
|
||||
*/
|
||||
@@ -8,8 +8,8 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -81,7 +81,7 @@ async function waitForTool(ctx: Context, name: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('third-party memory MCP example overlays', () => {
|
||||
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
|
||||
it.each(examples)('parses $file with the documented generic plugin fields', (contract) => {
|
||||
const file = resolve(exampleDir, contract.file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
|
||||
|
||||
@@ -20,35 +20,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('process shutdown', () => {
|
||||
it('exits once after graceful disposal resolves or rejects', async () => {
|
||||
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
|
||||
const resolvedExit = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
|
||||
const resolvedComplete = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
|
||||
await resolved.shutdown(0)
|
||||
expect(resolvedExit).toHaveBeenCalledOnce()
|
||||
expect(resolvedExit).toHaveBeenCalledWith(0)
|
||||
expect(resolvedComplete).toHaveBeenCalledOnce()
|
||||
expect(resolvedComplete).toHaveBeenCalledWith(0)
|
||||
expect(resolvedExit).not.toHaveBeenCalled()
|
||||
|
||||
const rejectedExit = vi.fn()
|
||||
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
|
||||
const rejectedComplete = vi.fn()
|
||||
const rejected = createProcessShutdown(
|
||||
() => Promise.reject(new Error('dispose failed')),
|
||||
rejectedExit,
|
||||
rejectedComplete,
|
||||
)
|
||||
await rejected.shutdown(1)
|
||||
expect(rejectedExit).toHaveBeenCalledOnce()
|
||||
expect(rejectedExit).toHaveBeenCalledWith(1)
|
||||
expect(rejectedComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses process.exit as the default process boundary', async () => {
|
||||
it('uses process.exitCode for default normal completion', async () => {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
|
||||
const originalExitCode = process.exitCode
|
||||
process.exitCode = undefined
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve())
|
||||
|
||||
await shutdown.shutdown(7)
|
||||
try {
|
||||
await shutdown.shutdown(7)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
expect(process.exitCode).toBe(7)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.exitCode = originalExitCode
|
||||
}
|
||||
})
|
||||
|
||||
it('forces exit when graceful disposal reaches its bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
|
||||
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a caller-supplied grace period', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
|
||||
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
shutdown.interrupt(130)
|
||||
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces exit after disposal started by a signal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
shutdown.interrupt(143)
|
||||
disposal.resolve()
|
||||
await shutdown.shutdown(0)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(143)
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains on the first signal and forces on the second signal', async () => {
|
||||
const disposal = deferred()
|
||||
const dispose = vi.fn(() => disposal.promise)
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(dispose, exit)
|
||||
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
|
||||
|
||||
shutdown.interrupt(143)
|
||||
await Promise.resolve()
|
||||
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
|
||||
it('coalesces normal shutdown calls without treating them as escalation', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
const first = shutdown.shutdown(0)
|
||||
const second = shutdown.shutdown(1)
|
||||
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
|
||||
|
||||
disposal.resolve()
|
||||
await first
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(complete).toHaveBeenCalledWith(0)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a signal force exit while natural completion drains remaining handles', async () => {
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
|
||||
|
||||
await shutdown.shutdown(0)
|
||||
shutdown.interrupt(130)
|
||||
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
|
||||
* with the exact production launch vector (`node --import tsx/esm`, the same
|
||||
* shape as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the
|
||||
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
|
||||
* with the exact production runtime vector (`node --import tsx/esm`, the
|
||||
* vector the root `dsh` script invokes after building) and assert the
|
||||
* required-config diagnostic. The Node compatibility matrix runs this
|
||||
* WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
|
||||
@@ -16,6 +17,13 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
it('builds before launching the source CLI', async () => {
|
||||
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
|
||||
readonly scripts?: Record<string, string>
|
||||
}
|
||||
expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts')
|
||||
})
|
||||
|
||||
it('boots the source entry and requires a profile', async () => {
|
||||
const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
|
||||
cwd: repoRoot,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts'
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
networkInterfaces: () => ({
|
||||
lo0: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
|
||||
],
|
||||
en0: [
|
||||
{ family: 'IPv6', internal: false, address: 'fe80::1' },
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
|
||||
],
|
||||
en1: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
|
||||
],
|
||||
utun0: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('resolveLanTrust', () => {
|
||||
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
|
||||
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
|
||||
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
|
||||
})
|
||||
|
||||
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
|
||||
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
|
||||
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSurfaceContextEnabled', () => {
|
||||
it('defaults to enabled and honors an explicit complete-prompt disable', () => {
|
||||
expect(webSurfaceContextEnabled(new Map())).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { mode: 'production' } }],
|
||||
]))).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { surfaceContext: false } }],
|
||||
]))).toBe(false)
|
||||
})
|
||||
})
|
||||
754
apps/cli/tests/web-agent-presets.e2e.ts
Normal file
754
apps/cli/tests/web-agent-presets.e2e.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
|
||||
const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
||||
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
|
||||
|
||||
/**
|
||||
* Boot the shipped Web composition, minus the rows that would bind a port,
|
||||
* touch the network, or write outside the test. Everything that decides an
|
||||
* agent's capabilities is the real thing, including both shipped presets.
|
||||
*/
|
||||
async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
|
||||
const storageRoot = join(dirname(settingsFile), 'storages')
|
||||
const patches: PatchOptions[] = [
|
||||
...loadOverlayPatches('dsh-test', BASE_PATCH),
|
||||
...loadOverlayPatches('dsh-test', WEB_PATCH),
|
||||
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
|
||||
// reads the developer's own document — and since the default preset is a
|
||||
// setting, a stored `agent-presets.default` would decide this file's
|
||||
// outcome. Point it at a temp file for the same reason the roster below
|
||||
// names only the shipped root.
|
||||
{ id: 'settings', config: { path: settingsFile, watch: false } },
|
||||
// storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
|
||||
// file writes the developer's own `~/.dsh/storages/` — and then reads it
|
||||
// back on the next run, so a stored document from any other build decides
|
||||
// this test's boot. Same reason the settings row above is pinned.
|
||||
{ id: 'storage-json', config: { root: storageRoot } },
|
||||
// Host rows with side effects outside this process: a bound port, a served
|
||||
// asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
|
||||
// stay ENABLED on purpose — the api-proxy is the host row that injects
|
||||
// `subagents`, `workspace`, and the rest of the agent plane, so disabling
|
||||
// it would hide exactly the breakage this file exists to catch: a service
|
||||
// moved into the presets that a host row still waits for. The boot audit
|
||||
// is that assertion.
|
||||
{ id: 'webserver', disabled: true },
|
||||
// The web bundle's runtime row injects `httpServer`, so it cannot
|
||||
// activate without the bound port disabled above. It owns dist serving
|
||||
// and the URL prompt line — surface glue, not anything that decides an
|
||||
// agent's capabilities, which is all this file asserts.
|
||||
{ id: 'web-runtime', disabled: true },
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
// A deployment-level skill on the host registry's GLOBAL layer — the same
|
||||
// registration shape a repository plugin's skill root uses. The layered
|
||||
// skills test below proves it reaches preset-composed agents.
|
||||
{ id: 'skill-badge', disabled: false },
|
||||
{ id: 'modules', disabled: true },
|
||||
{ id: 'connection', disabled: true },
|
||||
// The always-on reload chain waits for the browser roster and bound port
|
||||
// disabled above.
|
||||
{ id: 'client-hmr', disabled: true },
|
||||
// The shipped `-auto` chooser resolves its interaction from a running
|
||||
// host and so waits for the webserver disabled above; the browse variant
|
||||
// supplies `directoryPicker` without one.
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
|
||||
// The roster AppCLIEntry would patch in; only the shipped root, so a
|
||||
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
|
||||
// `default` here is the COMPOSITION default — the base layer the settings
|
||||
// document overrides.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
||||
},
|
||||
...extra,
|
||||
]
|
||||
// The surface is patch layers over an empty preset root, so the root sits
|
||||
// outside this workspace and bare plugin names cannot resolve by Node's
|
||||
// upward walk. The flat fallback the preset boot maintains is what makes
|
||||
// them resolvable — the same mechanism, not a test-only shim.
|
||||
const home = dirname(settingsFile)
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR, home)
|
||||
const profileDir = join(home, 'profiles', 'spec')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, patches, (bootCtx) => {
|
||||
provideCmdline(bootCtx, { args: [], exit: () => {} })
|
||||
})
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
function enablePresetTool(composition: string, id: string): string {
|
||||
const row = ` - id: ${id}\n`
|
||||
const start = composition.indexOf(row)
|
||||
if (start < 0) throw new Error(`missing preset row ${id}`)
|
||||
const end = composition.indexOf('\n - id:', start + row.length)
|
||||
const disabled = composition.indexOf(' disabled: true\n', start)
|
||||
if (disabled < 0 || (end >= 0 && disabled > end)) {
|
||||
throw new Error(`preset row ${id} is not disabled`)
|
||||
}
|
||||
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
ctx = await bootWeb(settingsFile)
|
||||
}, 120_000)
|
||||
|
||||
describe('the shipped Web composition', () => {
|
||||
it('leaves the global tool layer empty', () => {
|
||||
// Every model-facing tool belongs to a preset, `ask_user_question`
|
||||
// included: a tool in the global layer reaches EVERY agent regardless of
|
||||
// which preset composed it, so a two-tool benchmark surface would really
|
||||
// present three. A regression here means an agent-plane row came back to
|
||||
// the host composition.
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the token meter and its context-meter projections on the host plane', async () => {
|
||||
// Read before any preset in this file mounts, which is what makes this an
|
||||
// ownership assertion rather than a mount-order coincidence: a preset-side
|
||||
// meter sits behind an `isolate` realm and is invisible to `ctx.get`.
|
||||
//
|
||||
// The projection registry is process-wide rather than scope-layered, so a
|
||||
// preset-side meter would also make the browser's context meter appear for
|
||||
// a `minimal` session the moment some OTHER session mounted a preset that
|
||||
// carries one, and vanish entirely in a process that only ever ran
|
||||
// `minimal`. Host ownership is what makes the meter a per-session fact.
|
||||
expect(ctx.get('tokenMeter')).toBeDefined()
|
||||
const projections = ctx.get('sessionProjections')
|
||||
if (projections === undefined) throw new Error('the Web composition must compose a projection registry')
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-minimal-meter'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// A subset assertion: `tasks`, `goal`, and the rest register into the
|
||||
// same process-wide table, and this is about the meter's three units.
|
||||
expect(Object.keys(projections.snapshot(handle.agent.session).values))
|
||||
.toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('supplies both shipped presets, and only those, from the system root', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
|
||||
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('composes the full agent from `standard`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-standard'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The EXACT catalog, not a spot-check: an omission is this design's
|
||||
// quietest failure mode, because a row that registers into the wrong
|
||||
// layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
|
||||
// excluded for the reason the TUI composition e2e excludes them — they
|
||||
// depend on ripgrep being present on the machine.
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
|
||||
'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections).toEqual([
|
||||
{ name: 'deployment:persona', text: MINIMAL_PROMPT },
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
|
||||
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
|
||||
.toContain('Absolute path')
|
||||
const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact')
|
||||
expect(compact).toBeDefined()
|
||||
expect((compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps two differently composed sessions independent', async () => {
|
||||
const full = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-both-full'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
const minimal = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-both-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
|
||||
await minimal.dispose()
|
||||
|
||||
// Tearing the minimal session down leaves the full one whole.
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
} finally {
|
||||
await full.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('composes the cordis agent with its own toolset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-cordis'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(ctx, handle.agent)
|
||||
// The self-referential toolset is what distinguishes this preset.
|
||||
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
// And it keeps the standard agent's own tools rather than replacing them.
|
||||
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
||||
expect(tools).not.toContain('str_replace_editor')
|
||||
|
||||
// The preset's own authoring skill registers into ITS layer of the host
|
||||
// registry: the cordis agent's view carries it, the global view does not.
|
||||
const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)
|
||||
expect(scoped).toContain('editing-cordis-compositions')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('presents `code` as Code Mode without disturbing a native session beside it', async () => {
|
||||
const coded = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-code'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined),
|
||||
})
|
||||
const native = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-code-native'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// One tool reaches the MODEL: the transport. The registry's catalog for
|
||||
// this agent is unchanged — a code mode collapses the presentation, not
|
||||
// the capabilities — so the assembly is what carries the claim.
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(sdk).not.toContain('str_replace_editor')
|
||||
expect(sdk).toContain('web_search')
|
||||
|
||||
// The presentation is this agent's alone: the deployment default is
|
||||
// native, and the session composed from `standard` still sees it.
|
||||
const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent })
|
||||
expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash')
|
||||
expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code')
|
||||
expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
||||
} finally {
|
||||
await native.dispose()
|
||||
await coded.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the self-referential toolset out of every other preset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-no-cordis'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Editing the live runtime is opt-in per session, not ambient.
|
||||
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('ships the composition-authoring skill inside the preset directory', async () => {
|
||||
// The preset's skill root is derived from its own `baseUrl`, so the skill
|
||||
// travels with the directory wherever the preset is installed.
|
||||
const skill = join(
|
||||
CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
|
||||
)
|
||||
|
||||
expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
|
||||
})
|
||||
|
||||
it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => {
|
||||
const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-'))
|
||||
await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true })
|
||||
await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [
|
||||
'---',
|
||||
'name: project-proof',
|
||||
'description: Proves the preset layer discovers project skills beside global ones.',
|
||||
'---',
|
||||
'',
|
||||
'Project proof body.',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
// Unique per run: the composition persists into the ambient DSH home,
|
||||
// and a fixed id would collide with a log an earlier run left there.
|
||||
sessionId: SessionId(`preset-skills-standard-${randomUUID()}`),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The host (global) view carries the deployment-level provider alone:
|
||||
// local discovery moved behind the presets with `skill-local`.
|
||||
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
|
||||
|
||||
// The standard agent's view merges the global layer with its preset's
|
||||
// own local discovery over the session cwd.
|
||||
const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name)
|
||||
expect(scoped).toContain('dsh-badge')
|
||||
expect(scoped).toContain('project-proof')
|
||||
|
||||
// The preset's own loader tool resolves the global-layer skill.
|
||||
const loaded = await ctx.tools.execute({
|
||||
callId: CallId('preset-skills-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
signal: new AbortController().signal,
|
||||
agent: handle.agent,
|
||||
})
|
||||
expect(loaded.isError).toBe(false)
|
||||
expect(JSON.stringify(loaded.content)).toContain('powered by dsh')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a minimal agent the global layer but no loader tool', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Layer visibility is the registry's; whether an agent can USE skills
|
||||
// stays the preset's choice — minimal mounts no `tool-skill`, so its
|
||||
// tool table has no loader even though the global layer is readable.
|
||||
expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('never rewrites the preset file it composed from', async () => {
|
||||
// The Loader persists a tree whose plugin self-disposed, and tearing an
|
||||
// agent down disposes its whole subtree. Inherited, that rewrote the
|
||||
// shipped composition — truncating it to `[]` the first time a session
|
||||
// ended — so `PresetTree` refuses to write at all.
|
||||
const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
|
||||
const before = await readFile(path, 'utf8')
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-readonly'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await handle.dispose()
|
||||
// Slack, not a race the number has to win. The write is driven by the
|
||||
// Loader's fiber-unload listener, which fires as the subtree's fibers
|
||||
// settle rather than when `dispose()` resolves, and the Loader exposes no
|
||||
// flush to await. A regression writes synchronously inside that listener,
|
||||
// so any wait past settlement fails; a longer one only slows the test.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
expect(await readFile(path, 'utf8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('product subagent rows in user presets', () => {
|
||||
let productCtx: Context
|
||||
const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
||||
const userRoot = join(root, 'presets')
|
||||
const settingsFile = join(root, 'settings.yaml')
|
||||
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
for (const id of ids) {
|
||||
let composition = standard
|
||||
if (id === 'products-codex' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
||||
}
|
||||
if (id === 'products-claude' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
||||
}
|
||||
const directory = join(userRoot, id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
||||
}
|
||||
productCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await productCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('composes none, either product, or both without changing the shared host registry', async () => {
|
||||
const expected = new Map<string, string[]>([
|
||||
['products-none', []],
|
||||
['products-codex', ['subagent_codex']],
|
||||
['products-claude', ['subagent_claude_code']],
|
||||
['products-both', ['subagent_claude_code', 'subagent_codex']],
|
||||
])
|
||||
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
|
||||
'spawn', 'fork', 'codex', 'claude-code',
|
||||
]))
|
||||
|
||||
for (const [id, productTools] of expected) {
|
||||
const handle = await productCtx.agents.create({
|
||||
sessionId: SessionId(`preset-${id}`),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(productCtx, handle.agent)
|
||||
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
|
||||
.toEqual(productTools)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('applies a product-row edit only to later sessions on the preset', async () => {
|
||||
const preset = await productCtx.agentPresets.resolve('products-none')
|
||||
const original = await readFile(preset.path, 'utf8')
|
||||
const existing = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-existing'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
|
||||
|
||||
const later = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-later'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
|
||||
} finally {
|
||||
await later.dispose()
|
||||
}
|
||||
} finally {
|
||||
await existing.dispose()
|
||||
await writeFile(preset.path, original)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a switch survives the session', () => {
|
||||
it('records the choice so the log states what the agent runs', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-switch-logged'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The api-proxy's select does exactly this pair while the session is blank.
|
||||
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
|
||||
handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
// The header keeps the creation fact; the log carries what it runs.
|
||||
expect(handle.agent.session.header.agentPreset).toBe('standard')
|
||||
expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds a switched session from the log, not the creation header', () => {
|
||||
// The exact shape a resume reads back from disk: the header says standard,
|
||||
// the log records the switch the user made while the session was blank.
|
||||
const rebuilt = resolveSessionPreset({
|
||||
header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
|
||||
events: [
|
||||
{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
|
||||
{ type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
] as never,
|
||||
})
|
||||
|
||||
// Reading the header alone would compose the creation-time preset over a
|
||||
// history another one produced — the replay the blank-only lock prevents.
|
||||
expect(rebuilt).toBe('minimal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a forked session', () => {
|
||||
it('inherits the composition its seeded history was produced under', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-fork-parent'),
|
||||
meta: { agentPreset: 'minimal' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
const inherited = resolveSessionPreset(parent.agent.session)
|
||||
const child = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-fork-child'),
|
||||
meta: {
|
||||
parentSession: SessionId('preset-fork-parent'),
|
||||
seedLength: 0,
|
||||
...inherited === undefined ? {} : { agentPreset: inherited },
|
||||
},
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Composing nothing would leave the child empty: this layer moved every
|
||||
// model-facing row out of the host plane, so there is nothing to inherit
|
||||
// for free any more.
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a delegated child', () => {
|
||||
it('runs on the composition its parent runs on', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
// Exactly what an in-process subagent driver's creation window does.
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
// The shipped `standard` preset is the whole coding agent; an empty
|
||||
// child here is the defect, and equality alone would not catch it.
|
||||
expect(toolNames(ctx, child.agent)).toContain('bash')
|
||||
expect(child.agent.session.header.agentPreset).toBe('standard')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows a parent that switched preset while blank', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal')
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
// The live scope chain is the authority, not the parent's creation
|
||||
// header — which still names `standard`.
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
expect(child.agent.session.header.agentPreset).toBe('minimal')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles')
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
authorCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
// The root does not exist yet: a deployment whose user has authored
|
||||
// nothing is the normal first-run state.
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('refuses to copy over or delete a shipped preset', async () => {
|
||||
await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
|
||||
await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
|
||||
})
|
||||
|
||||
it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
|
||||
// The id becomes a directory name under the user root, so containment is
|
||||
// checked on the id rather than on the joined path afterwards.
|
||||
await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('copies a shipped preset a session then really composes from', async () => {
|
||||
await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
|
||||
|
||||
// Round-trips through the roster as a `user` row carrying the given name
|
||||
// and the source's description, over the source's own composition text.
|
||||
const preset = await authorCtx.agentPresets.resolve('my-agent')
|
||||
const source = await authorCtx.agentPresets.resolve('minimal')
|
||||
expect(preset.trust).toBe('user')
|
||||
expect(preset.name).toBe('我的模式')
|
||||
expect(preset.description).toBe(source.description)
|
||||
expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
|
||||
// Owner-only, in an owner-only directory: a composition is executable
|
||||
// configuration on a machine that may have other users.
|
||||
expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
|
||||
const handle = await authorCtx.agents.create({
|
||||
sessionId: SessionId('preset-authored'),
|
||||
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The same tools the shipped `minimal` composes, from a directory copied
|
||||
// through the service into a root outside the installed harness.
|
||||
expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes what it copied', async () => {
|
||||
await authorCtx.agentPresets.copy('minimal', 'doomed')
|
||||
|
||||
await authorCtx.agentPresets.remove('doomed')
|
||||
|
||||
expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Which preset an unnamed session gets is a user setting layered over the
|
||||
* composition's own default. The package suite proves the layering against a
|
||||
* hand-built context; this proves it through the shipped `cordis.yml` — that
|
||||
* the roster and the settings provider are actually wired to each other, and
|
||||
* that the id the setting names is the one a session composes from.
|
||||
*/
|
||||
describe('the default preset as a user setting', () => {
|
||||
it('composes an unnamed session from the stored default, not the composed one', async () => {
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
|
||||
await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' })
|
||||
try {
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-user-default'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// `mount()` with no id resolves the effective default. Two tools, not
|
||||
// `standard`'s catalog: the setting decided the composition.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
} finally {
|
||||
// The context is shared with the rest of the file. `replace({})` drops
|
||||
// the user section wholesale so the field re-inherits the composition
|
||||
// base; `update` merges, and would leave the override standing.
|
||||
await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
|
||||
}
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session keeps the preset it was created with', () => {
|
||||
it('refuses to adopt a live session under a different preset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-locked'),
|
||||
meta: { agentPreset: 'minimal' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The api-proxy guard reads exactly this: the header records what the
|
||||
// session runs, so naming anything else is a caller error rather than a
|
||||
// switch. Its history was produced under `minimal`'s two tools.
|
||||
expect(handle.agent.session.header.agentPreset).toBe('minimal')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
139
apps/cli/tests/windows-shell.spec.ts
Normal file
139
apps/cli/tests/windows-shell.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
|
||||
import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
BASE_BUNDLE,
|
||||
resolveWindowsShellLayer,
|
||||
WINDOWS_SHELL_PATCH_FILENAME,
|
||||
} from '../src/windows-shell.ts'
|
||||
|
||||
const WINDOWS_PATCH = `- id: bash-sandbox
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
`
|
||||
|
||||
/** One fake bundle layer rooted in a temp directory. */
|
||||
function fakeLayer(packageName: string, dir: string): ProfileLayer {
|
||||
return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
|
||||
}
|
||||
|
||||
/** A base bundle layer whose package carries the Windows shell patch. */
|
||||
function baseLayerWithPatch(dir: string): ProfileLayer {
|
||||
writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
|
||||
return fakeLayer(BASE_BUNDLE, dir)
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLayer', () => {
|
||||
let base: string
|
||||
afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
|
||||
const tempBase = (): string => {
|
||||
base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
|
||||
return base
|
||||
}
|
||||
|
||||
it('never applies on POSIX hosts', () => {
|
||||
expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
|
||||
expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults Windows hosts to the pwsh platform layer', () => {
|
||||
const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
|
||||
expect(layer).toBeDefined()
|
||||
expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
|
||||
expect(layer?.patches).toEqual([
|
||||
{ id: 'bash-sandbox', disabled: true },
|
||||
{ insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips custom profiles without a base bundle', () => {
|
||||
const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
|
||||
expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when the base bundle ships no Windows shell patch', () => {
|
||||
const base = tempBase()
|
||||
mkdirSync(base, { recursive: true })
|
||||
// The overlay loader owns the fail-loud contract: the caller named this
|
||||
// file, so its absence is a misconfiguration, not "no overlay".
|
||||
expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
|
||||
.toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shipped Windows composition (real bundle layers)', () => {
|
||||
let home: string
|
||||
afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
|
||||
// The app installation anchor, mirroring profile-boot.ts: the bundle layers
|
||||
// resolve from the REAL dsh-base/dsh-web-app packages through it, so this
|
||||
// suite composes the shipped patch files, not test fixtures.
|
||||
const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
|
||||
|
||||
it('composes the win32 confined roster through the real patch layers', () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
|
||||
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
const profile = loadProfile('dsh', 'web', anchor, home)
|
||||
const warnings: string[] = []
|
||||
const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
|
||||
expect(win32).toBeDefined()
|
||||
const rows = composeEntries(
|
||||
[...profile.layers.map(layer => layer.patches), win32!.patches],
|
||||
message => warnings.push(message),
|
||||
)
|
||||
const byId = new Map(rows.map(row => [row.id, row]))
|
||||
// Only the POSIX bash stack leaves the roster: the permission surface
|
||||
// (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
|
||||
// exactly as on POSIX — the confined pwsh executor is what changes.
|
||||
for (const id of ['bash-sandbox', 'tool-bash']) {
|
||||
expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
|
||||
}
|
||||
for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
|
||||
expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
|
||||
}
|
||||
for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
|
||||
expect(byId.has(id), `inserted row ${id}`).toBe(true)
|
||||
}
|
||||
// The launcher's cold-start module fallback BFS-links the apps/cli
|
||||
// dependency closure into the profile's node_modules (the pwsh-local
|
||||
// precedent), so every inserted bare plugin must resolve from there.
|
||||
const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
|
||||
for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
|
||||
expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
|
||||
}
|
||||
// The patch touches only base-owned rows plus inserts, so the full web
|
||||
// profile composes without any no-match warning.
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
|
||||
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
const profile = loadProfile('dsh', 'web', anchor, home)
|
||||
// POSIX: no platform layer, the bash stack stays enabled.
|
||||
const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
|
||||
const posixById = new Map(posixRows.map(row => [row.id, row]))
|
||||
expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
|
||||
expect(posixById.has('pwsh-local')).toBe(false)
|
||||
expect(posixById.has('pwsh-sandbox')).toBe(false)
|
||||
|
||||
// A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
|
||||
// patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
|
||||
// inserts, so the composition produces no no-match warning.
|
||||
initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
|
||||
const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
|
||||
const baseWarnings: string[] = []
|
||||
const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
|
||||
expect(win32).toBeDefined()
|
||||
composeEntries(
|
||||
[...baseOnly.layers.map(layer => layer.patches), win32!.patches],
|
||||
message => baseWarnings.push(message),
|
||||
)
|
||||
expect(baseWarnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/cmdline"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bundle/base"
|
||||
},
|
||||
@@ -47,6 +50,9 @@
|
||||
{
|
||||
"path": "../../packages/core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user