feat(cli): even out the shipped tool rosters across both surfaces

The two shipped surfaces offered different tools for no recorded reason:
session checkpoints, tool-result pruning, the goal tools, and Ralph were in
`tui.cordis.yml`; `tool-todo` and web search were in `web.cordis.yml`. Neither
offered session search, a string-replacement editor, or a repeat-tool guard,
though none of the three is surface-specific.

Move the rows that are not surface-specific into `base.cordis.yml` and add
those three. Web search moves there too — the TUI decision the change that
made it a Web default deferred. Both surfaces now assemble the same 27 tools.

This adds only. No row is removed from either surface and no existing row's
configuration is edited: executors, sandbox composition, access defaults,
`tools.mode`, and the workflow tool are exactly what they were. Two rows stay
surface-specific: `tmux-context` (no terminal multiplexer in a browser) and
`session-reference` (its index has one writer owner).

Ship `dsh-mcp-client` as a dependency without a row: the plugin mounts one
server per instance and `command` is required, so a default would name a
third-party server and spawn it outside `ctx.bash` on every launch. The CLI
README carries the YAML for mounting one from a personal config.
This commit is contained in:
NI0317
2026-07-31 13:42:51 +08:00
parent aba776b179
commit d558cb4fd4
20 changed files with 655 additions and 50 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
2026-07-31-even-out-shipped-tool-rosters.md: 28b44e68228043bcccd2d5f8bef96a86ffcae9d4
2026-07-31-even-out-shipped-tool-rosters.zh.md: 8f26f7e0f86837a235bf96fa25d74f053c48e54b

View File

@@ -0,0 +1,69 @@
# Agent Note: Even out the shipped tool rosters
Status: implemented
English | [中文](2026-07-31-even-out-shipped-tool-rosters.zh.md)
## Problem
The two shipped `dsh` surfaces offered different tools for no recorded reason. Session checkpoints, tool-result pruning, the goal tools, and Ralph were in `tui.cordis.yml`; `tool-todo` and, later, web search were in `web.cordis.yml`. Neither surface offered session search, a string-replacement editor, or a repeat-tool guard, though all three exist as packages and none is surface-specific.
The result was a user-visible difference nobody had decided: the same model, asked the same thing, could set a goal on the terminal but not in the browser, and could search the web in the browser but not on the terminal.
## Decision
The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too, which is the TUI decision that the change making it a Web default explicitly deferred. Both surfaces now assemble the same twenty-seven tools.
Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
**This change adds only.** No row is removed from either surface and no existing row's configuration is edited: the executors, the sandbox composition, the access defaults, `tools.mode`, and the workflow tool are exactly what they were. A reader comparing the two catalogs before and after should find additions and nothing else.
### What stays unmounted, and why
Three capabilities stay out on the evidence their own packages record, and are listed here so "we forgot" and "we decided against" stay distinguishable.
**`dsh-tool-cordis`** lets the model write JavaScript and mount it as a temporary plugin. Its README states the limit: "The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node" ([Known limitations](../../../../packages/cordis/tool-cordis/README.md)). The `node:vm` realm lives inside the harness process while `dsh-sandbox-local` confines only the argv it spawns, so on the Web surface both the sandbox and the approval seam are bypassed rather than enforced.
**`dsh-web-fetch-local`** stays unmounted and `dsh-tool-web` keeps `fetch: false`. SSRF protection is deferred in the implementation ([`policy.ts`](../../../../packages/web/web-fetch-local/src/policy.ts) validates protocol, credentials, and length only) and the package says so: "this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets" ([README](../../../../packages/web/web-fetch-local/README.md)). The model chooses the target, which includes the harness's own gateway on loopback, private ranges, and cloud metadata endpoints.
Withholding it narrows the surface without removing the reach: `bash` is mounted, so `curl` gets the same page, as a live run confirmed. What the absence buys is the removal of an argument-shaped request primitive that needs no shell — and with it the accidental path where a summarization request quietly reaches loopback. A deployment that must contain outbound traffic needs a network-level control.
**The LSP trio** stays out for an operational reason rather than a security one: `command` resolves from `PATH` at plugin load, so a missing language server fails the whole boot rather than one tool. It becomes mountable once absence degrades to a skipped registration.
### MCP is a dependency, not a row
`@deepseek-ai/dsh-mcp-client` becomes a runtime dependency of the CLI without a row in any shipped config. The plugin mounts exactly one server per instance and `command` is required, so a default would have to name a third-party server and spawn it as a child process on every launch — outside `ctx.bash`, and therefore outside the sandbox policy the Web surface composes.
The layer that would make MCP a default is the one this repository does not have yet: a bridge that reads a user's server list and mounts one client per entry, the same shape [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude/README.md) already has for a Claude Code `hooks.json`. Shipping the dependency means an installed `dsh` can mount servers from `$DSH_HOME/config.yaml` today; the CLI README carries the YAML.
## Testing
[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) boots the shipped tree through the real Loader in a pseudo-terminal and reads the tool names out of the `request/header` the session log persisted, so the assertion is the catalog the model was actually sent. Its `--config` overlay, [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml), is test isolation only: a network-free adapter and workspace-local session artifacts.
That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic.
The same smoke pins the TUI's unchanged execution posture from the same artifact: `tool-bash` emits its `sandbox_permissions` escalation pair only when the mounted executor has wider modes to escalate to, so asserting its **absence** fails if a later change quietly sandboxes this surface.
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)).
`glob` and `grep` are asserted as an all-or-nothing pair rather than fixed members: `dsh-tool-fs-search` probes `command -v rg` at load and registers neither tool without ripgrep, which is a host dependency.
Beyond the committed tests, both surfaces were driven against a real key from the built `apps/cli/lib/bin.js` under plain Node. Every mounted tool executed successfully, including `ralph` and `web_search`; the model never reached `cordis_*` or `mcp_*`, fell back to `grep` when asked for LSP navigation, and used a background `bash` task when asked for a persistent terminal.
## Alternatives considered
**Duplicate the shared rows into both overlays instead of promoting them.** Rejected on the one-home rule: three of the new rows would exist twice with no reason for the copies to diverge, and the next roster change would have to remember both.
**Sandbox the TUI in the same change.** The TUI mounts unrestricted executors, which is a real gap, and an earlier revision of this change closed it by moving the confined stack into the base. Rejected as a separate decision that does not belong in a roster change: it alters what an existing surface does rather than what it offers, and it needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting.
**Enable Code Mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one.
**Mount an MCP server by default.** Rejected because a shipped default would have to name one, and any choice spawns a third-party child process on every user's machine outside the sandbox. The dependency ships instead.
## Consequences
The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. Twenty-seven names, asserted exactly and identically on both sides, so a later change that adds a tool to one surface only — or drops one from either — fails a check instead of shipping quietly.
`apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can.
Nothing about execution changed. The TUI still runs the model's commands through unrestricted executors with no approval seam, and the Web surface still defaults to `danger-full-access`. Both are pinned by assertions in this change, which makes them visible rather than fixed — the sandbox decision is still open.

View File

@@ -0,0 +1,69 @@
# Agent Note: 拉平交付的工具清单
Status: implemented
[English](2026-07-31-even-out-shipped-tool-rosters.md) | 中文
## 问题
两个交付的 `dsh` surface 提供着不同的工具,而没有任何记录说明为什么。会话检查点、工具结果裁剪、goal 工具和 Ralph 在 `tui.cordis.yml`;`tool-todo` 以及后来的 web 搜索在 `web.cordis.yml`。两个 surface 都没有会话搜索、字符串替换编辑器和重复工具守卫,尽管这三者都已成包存在,且没有一个是 surface 专属的。
结果是一处没人做过决定的用户可见差异:同一个模型、同一个请求,在终端上能定目标而在浏览器里不能,在浏览器里能搜网页而在终端上不能。
## 决策
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query``tool-str-replace-editor``repeat-tool-guard`。Web 搜索也一并移入,这正是把它做成 Web 默认的那次改动明确推迟的 TUI 决定。两个 surface 现在组装出同样的二十七个工具。
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
**本次改动只做加法。** 两个 surface 都没有任何一行被移除,也没有任何既有行的配置被编辑:执行器、沙箱组合、访问默认值、`tools.mode` 以及 workflow 工具,全都保持原样。对比改动前后的两份目录,读者应当只看到新增,别无其他。
### 什么保持不挂,以及为什么
有三项能力基于其自身包所记录的证据保持在外,列在这里是为了让「我们忘了」和「我们决定不要」保持可区分。
**`dsh-tool-cordis`** 让模型写一段 JavaScript 并挂成临时插件。它的 README 写明了这个界限:「The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node」([Known limitations](../../../../packages/cordis/tool-cordis/README.md))。`node:vm` 的 realm 就在 harness 进程内,而 `dsh-sandbox-local` 只约束它 spawn 出去的 argv,因此在 Web surface 上,沙箱与批准接缝是被绕过而非被执行。
**`dsh-web-fetch-local`** 保持不挂,`dsh-tool-web` 保持 `fetch: false`。SSRF 防护在实现中是 deferred 状态([`policy.ts`](../../../../packages/web/web-fetch-local/src/policy.ts) 只校验协议、凭据与长度),包里也直说了:「this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets」([README](../../../../packages/web/web-fetch-local/README.md))。目标由模型选择,其中包括 harness 自己跑在环回地址上的网关、内网段和云元数据端点。
弃用它收窄的是接触面而非可达性:`bash` 是挂着的,`curl` 照样能拿到同一个页面——一次真实运行确认了这点。这个缺席买到的是去掉一个无需 shell、以参数成形的请求原语,以及随之而来的那条意外路径:一次「帮我总结这个页面」悄悄打到环回地址。真要收住出站流量的部署需要的是网络层管控。
**LSP 三件套**留在外面是运维原因而非安全原因:`command` 在插件加载时从 `PATH` 解析,因此缺少语言服务器会让整次启动失败,而不只是失去一个工具。等到「缺失」退化为「跳过注册」之后,它就可以挂了。
### MCP 是依赖,不是配置行
`@deepseek-ai/dsh-mcp-client` 成为本 CLI 的运行时依赖,但在任何交付配置里都没有对应的行。该插件每个实例只挂载一台服务器,且 `command` 是必填,因此一个默认值必须点名一台第三方服务器,并在每次启动时把它作为子进程 spawn——不经 `ctx.bash`,因而也在 Web surface 所组合的沙箱策略之外。
真正能让 MCP 成为默认的那一层,恰恰是本仓库尚未拥有的:一个读取用户服务器清单、按条目逐台挂载客户端的桥接,形态与 [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude/README.md) 读取 Claude Code 的 `hooks.json` 完全相同。交付这个依赖意味着已安装的 `dsh` 今天就能从 `$DSH_HOME/config.yaml` 挂载服务器;CLI README 里给了那段 YAML。
## 测试
[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) 在伪终端中通过真实 Loader 启动交付树,并从会话日志持久化的 `request/header` 中读出工具名,因此断言的正是模型实际收到的目录。它传入的 `--config` overlay [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml) 只做测试隔离:一个无网络适配器,以及落在工作区内的会话产物。
该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。
同一份冒烟还从同一份产物上钉住 TUI 未改变的执行姿态:`tool-bash` 只在挂载的执行器确实有更宽模式可升级时才发出 `sandbox_permissions` 升级参数对,因此断言它的**缺席**会在日后有人悄悄给这个 surface 加上沙箱时失败。
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。
`glob``grep` 被作为全有或全无的一对断言,而不是固定成员:`dsh-tool-fs-search` 在加载时探测 `command -v rg`,没有 ripgrep 就两个工具都不注册,这是宿主依赖。
除入库测试外,两个 surface 都以 plain Node 从构建产物 `apps/cli/lib/bin.js` 出发、用真实密钥驱动过。每一个已挂载的工具都执行成功,包括 `ralph``web_search`;模型从未触达 `cordis_*``mcp_*`,被要求做 LSP 跳转时退化到 `grep`,被要求开持久终端时用了后台 `bash` 任务。
## 曾考虑的替代方案
**把共享的行复制进两份 overlay,而不是提升到 base。** 基于「一处归属」原则否决:新增行里有三行会存在两份,而这些副本没有任何理由发生分歧,下一次改工具清单还得记着改两处。
**在同一次改动里给 TUI 加沙箱。** TUI 挂的是不受限执行器,这确实是个真实缺口,本次改动的早先一版曾把受限栈移入 base 来堵上它。作为一个不属于工具清单改动的独立决定被否决:它改变的是一个既有 surface 的行为而非它提供的东西,而且需要它自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升级请求在那里是 fail-closed 而不是弹出提示。
**开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。
**默认挂一台 MCP 服务器。** 否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。
## 后果
同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。二十七个名字,两侧精确且一致地断言,因此日后只给一个 surface 加工具、或从任一侧丢掉工具,都会让检查失败而不是悄悄发出去。
`apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。
执行相关的一切都没有变。TUI 仍以不受限执行器运行模型的命令且没有批准接缝,Web surface 仍默认 `danger-full-access`。两者都由本次改动中的断言钉住,这让它们变得可见而非被修复——沙箱那个决定仍然悬着。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: cf038ad19c631721c7b3182ffe83e75e3837d9ba
README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02
README.md: 3b1f7b9e938c7886e208498416fa3193ef05fd54
README.zh.md: 374c22b2c2f62742897495f572c1c0ed8278e4cf

View File

@@ -23,12 +23,29 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
The Web/headless composition also registers only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The TUI composition does not mount Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
Every surface presents tools in `both` mode: the model receives every native schema **and** the Code Mode `run_code` transport, so a task that would be a long tool-call chain can be one program instead. Code Mode's trust posture is bash-equivalent by design — model code reaches Node APIs, but its tool calls pass the same `tools/pre-execute` gate as bash, and the worker adds containment bash has no equivalent for (a separate isolate, an empty environment, a heap cap, and hard termination). `DSH_TOOLS_MODE` overrides the presentation for a whole Web/headless process — `native` drops `run_code`, `code` sends only it — and any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam, process-wide because Loader composition is static, removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout:
```yaml
- insert:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
```
The model then sees `mcp__github__*`. See the [mcp-client README](../../packages/mcp/mcp-client/README.md) for the Streamable HTTP transport and the full field table.
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:

View File

@@ -23,12 +23,29 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
Web无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。TUI 组合默认不挂载 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的因此该设置作用于整个进程待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量其配置树固定了自身模式
每个界面都以 `both` 模式呈现工具:模型同时收到全部原生 schema **和** Code Mode 的 `run_code` 传输通道,于是原本要一长串工具调用的任务可以写成一段程序。Code Mode 的信任立场按设计与 bash 同级——模型代码可以触达 Node API,但它的工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,而该 worker 还提供了 bash 没有的收容(独立 isolate、空环境、堆上限、硬终止)。`DSH_TOOLS_MODE` 为整个 Web无头进程覆盖呈现模式——`native` 去掉 `run_code`,`code` 则只发它——任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs``DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector`DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0``false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml``--config` 覆盖层挂载你自己的服务器:
```yaml
- insert:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
```
模型随后会看到 `mcp__github__*`。Streamable HTTP 传输与完整字段表见 [mcp-client README](../../packages/mcp/mcp-client/README.md)。
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout因此代码更改会在下次启动时生效无需构建

View File

@@ -96,6 +96,28 @@ flowchart LR
cfg --> plugin_tui_spill_local
plugin_tui_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_tui_spill_policy
plugin_tui_session_checkpoint_policy["session-checkpoint-policy<br/>@deepseek-ai/dsh-session-checkpoint-policy"]
cfg --> plugin_tui_session_checkpoint_policy
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
cfg --> plugin_tui_tool_result_prune
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_tui_tool_todo
plugin_tui_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
cfg --> plugin_tui_tool_goal
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
cfg --> plugin_tui_tool_session_query
plugin_tui_tool_str_replace_editor["tool-str-replace-editor<br/>@deepseek-ai/dsh-tool-str-replace-editor"]
cfg --> plugin_tui_tool_str_replace_editor
plugin_tui_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
cfg --> plugin_tui_repeat_tool_guard
plugin_tui_web["web<br/>@deepseek-ai/dsh-web"]
cfg --> plugin_tui_web
plugin_tui_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
cfg --> plugin_tui_web_search_deepseek
plugin_tui_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
cfg --> plugin_tui_tool_web
plugin_tui_tools["tools<br/>@deepseek-ai/dsh-tools"]
cfg --> plugin_tui_tools
plugin_tui_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
@@ -154,6 +176,17 @@ flowchart LR
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` |
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `tools` | `@deepseek-ai/dsh-tools` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |

View File

@@ -246,6 +246,74 @@
config:
maxInlineBytes: 50000
# Durability checkpoints before each model request and top-level dispatch.
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are above.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# Fresh-agent Ralph iteration over a build-time-fixed script.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
config:
maxSearchResults: 100
searchTimeoutMs: 30000
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# Consecutive-repeat reminders on the tool chain.
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8]
argumentsPreviewChars: 500
# Every surface enables the stable web_search model surface. DeepSeek search
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
# chat, at each search; its Messages endpoint is separate from the
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
# disabled and no fetch provider is mounted: that provider defers SSRF
# protection and the model would choose the request target.
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
# ── rows every surface mounts, whose values each overlay states ──────────────
# The tool registry. Presentation mode is a surface choice, so each overlay

View File

@@ -71,29 +71,17 @@
- id: agent-loop-invariant
name: '@deepseek-ai/dsh-agent-loop/invariant'
- id: session-checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# The derived query index behind `/resume`. The launcher provides a unique
# process-local path because this SQLite backend has one writer owner; the
# project-local fallback applies when no launcher sets the typed slot.
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
# Compacts oversized tool results before the broader conversation compactor
# runs, preserving the model-visible result within the configured budget.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
# Persisted same-session goals reach the model and the slash menu here; the
# domain, driver, and `/goal` command are in the base.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
# changing the workflow tool or same-session goal behavior.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# Terminal-multiplexer context, mounted only where a terminal exists.
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 900000
# The keyboard-backed provider behind ask_user_question and the plan-mode
# review, and the front door it renders inside.

View File

@@ -80,28 +80,6 @@
- id: fs-local
disabled: true
# The Web/headless product enables only the stable web_search model surface.
# DeepSeek search resolves the same DEEPSEEK_API_KEY credential the Models page
# manages for chat, at each search; its Messages endpoint is separate from the
# chat-completions endpoint. Fetch remains disabled and no default fetch
# provider is mounted.
- insert:
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
@@ -136,9 +114,6 @@
writeEveryEvents: 200
writeIntervalMs: 5000
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Resolve bind host, SSH launch, and display once at boot, then mount the
# matching dual-face directory picker. Mount -native or -browse directly in
# an overlay to pin the interaction.

View File

@@ -68,9 +68,11 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -100,6 +102,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "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:^",
@@ -108,7 +111,9 @@
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "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-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",

View File

@@ -0,0 +1,51 @@
import type { Context } from 'cordis'
import type {
GenerateOptions,
LlmModelInfo,
LlmResolvedModelInfo,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */
export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.'
// Provider id and model the keyless tail routes `main` to; that overlay is the
// only caller, so the pair lives here as plain constants.
const COMPOSITION_PROVIDER = 'composition-keyless'
const COMPOSITION_MODEL = 'composition-keyless-model'
/**
* Network-free adapter for the shipped-composition smoke. It answers every
* request — tool-ful agent turns and the tool-less auxiliary calls alike — with
* one fixed text and never calls a tool, because the assertion under test is the
* assembled tool catalog the loop logs, not any tool's behavior.
*/
class CompositionEchoAdapter extends LlmAdapter {
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }])
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } })
}
override async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char }
yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'composition-echo-llm'
export const inject = ['llm']
/**
* Register the network-free adapter the shipped-composition smoke routes through.
* @param ctx - the loader-mounted plugin context.
*/
export function apply(ctx: Context): void {
ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter())
}

View File

@@ -0,0 +1,52 @@
# Keyless tail for the shipped-composition smoke, applied as `--config` so the
# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file.
#
# Everything below is test isolation, never composition under test: the model is
# replaced so no request leaves the process, the settle marker gates the smoke's
# first prompt, and the session artifacts move into the smoke's temporary
# workspace so the log inspection can read them.
# A patch's `name` is an assertion rather than a replacement, so the base
# adapter row is disabled and the scripted one inserted. Relative specifiers
# resolve against the INCLUDED file's directory (apps/cli/config), not this
# file's, because the include moves baseUrl there.
- id: llm-deepseek
disabled: true
- insert:
- id: composition-echo-llm
name: '../tests/fixtures/composition-echo-llm.ts'
- id: composition-settled
name: '../tests/fixtures/composition-settled.ts'
- id: agent-loop
config:
agents:
- id: main
provider: composition-keyless
model: composition-keyless-model
cwd: !!js process.cwd()
- id: session-persistence-jsonl
config:
root: './.sessions'
compression: none
- id: session-query-sqlite
config:
path: './.sessions/session-query.db'
# The title call is a second, tool-less request that would race the log
# inspection for no coverage: the catalog under test rides the agent turn.
- id: session-title-llm
disabled: true
- id: tui
config:
sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'
welcome: 'composition smoke ready.'
showReasoning: true
# HMR watches the repository; a PTY subprocess test must not start a watcher.
- id: hmr
disabled: true

View File

@@ -0,0 +1,24 @@
import type { Context } from 'cordis'
/**
* Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as
* its own fiber starts, so a prompt typed at the banner can reach the loop while
* later rows — tool plugins, persistence — are still activating, and would
* assemble a partial catalog. Waiting for this line makes the turn observe the
* settled tree.
*/
export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED'
export const name = 'composition-settled'
/**
* Announce settled Loader activation on the terminal byte stream, after every
* entry in the booted tree has started. The write is detached: awaiting the
* Loader from inside an entry would wait on this entry's own activation.
* @param ctx - the loader-mounted plugin context.
*/
export function apply(ctx: Context): void {
void ctx.loader.await().then(() => {
process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`)
})
}

View File

@@ -0,0 +1,126 @@
import { readdir, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts'
import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts'
import { runTuiPtySmoke } from './pty-harness.ts'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// An overlay over the shipped tree, so the catalog under test is the one
// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model
// and redirects session artifacts.
const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url))
/**
* The catalog the shipped `dsh` TUI puts in front of the model, as the loop
* logged it, minus the ripgrep-dependent pair below.
* The absences are the composition's security decisions, not incidental gaps:
* `workflow` and the `cordis_*` toolset both execute model-written JavaScript
* that no sandbox row confines, `web_fetch` chooses its own request target, and
* `mcp_*` servers spawn outside `ctx.bash`. `ralph` proves the narrower
* alternative survives: it runs a script this repository fixed at build time,
* so the engine stays mounted without a model-code tool on top of it. The composition Agent Note owns
* the rationale and its sources.
*/
const EXPECTED_TUI_TOOLS = [
'ask_user_question',
'bash',
'create_goal',
'edit',
'exit_plan_mode',
'get_goal',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'skill',
'str_replace_editor',
'subagent',
'subagent_fork',
'task_kill',
'task_list',
'task_output',
'todo_write',
'update_goal',
'web_search',
'workflow',
'write',
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
/** The assembled request header the smoke asserts on. */
interface LoggedHeader {
/** Assembled tool names, sorted. */
names: string[]
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
bashArguments: Record<string, unknown>
}
/**
* Read the request header the loop assembled for its first request from the
* session log the smoke's workspace persisted — the model-visible composition
* itself, not a registry projection taken beside it.
* @param cwd - the smoke's temporary workspace.
* @returns the assembled catalog, system prompt, and `bash` argument shape.
*/
async function loggedHeader(cwd: string): Promise<LoggedHeader> {
const sessionsDir = join(cwd, '.sessions')
const entries = await readdir(sessionsDir, { recursive: true })
// A single keyless run writes one session log.
const logRelPath = entries.find(name => name.endsWith('.jsonl'))
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
for (const line of lines) {
const event = JSON.parse(line) as SessionEvent
if (event.type !== 'request/header') continue
const tools = event.data.header.tools ?? []
const bash = tools.find(schema => schema.name === 'bash')
return {
names: tools.map(schema => schema.name).sort(),
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {},
}
}
throw new Error(`session log ${logRelPath} has no request/header event`)
}
describe('shipped dsh composition (real Loader tree in a PTY)', () => {
it('assembles exactly the shipped TUI catalog', async () => {
let observed: LoggedHeader | undefined
const output = await runTuiPtySmoke({
label: 'dsh shipped composition',
tempDirPrefix: 'dsh-shipped-tui-',
binScript: dshBinScript,
tsconfigPath,
configPath: keylessTail,
env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' },
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
actions: [
{ waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' },
{ waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' },
],
inspect: async (cwd) => { observed = await loggedHeader(cwd) },
})
expect(output).toContain(COMPOSITION_REPLY_TEXT)
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name)))
// The TUI mounts the unrestricted local executors, so `tool-bash` emits no
// escalation pair. Pinning its absence keeps a later sandbox change from
// arriving here unannounced.
expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,86 @@
// Boots the shipped Web composition over the built dist this lane already uses
// and asserts what that composition produces: the model-visible tool catalog
// and the sandbox/approval knobs it ships with. No browser and no model call —
// these are composition facts, and the browser scenarios in this lane cover the
// surface itself.
import { tmpdir } from 'node:os'
import { afterEach, expect, it } from 'vitest'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
* The catalog the shipped Web composition puts in front of the model, minus the
* ripgrep-dependent pair below. The absences are
* deliberate, not incidental gaps: `workflow` and the
* `cordis_*` toolset both execute model-written JavaScript that no sandbox row
* confines, `web_fetch` chooses its own request target, and `mcp_*` servers
* spawn outside `ctx.bash`. `ralph` and `web_search` prove the narrower
* alternatives survive: Ralph runs a harness-fixed script, and `web_search`
* reaches one pinned provider endpoint. The composition Agent Note owns the
* rationale and its sources.
*/
const EXPECTED_TOOLS = [
'ask_user_question',
'bash',
'create_goal',
'edit',
'exit_plan_mode',
'get_goal',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'skill',
'str_replace_editor',
'subagent',
'subagent_fork',
'task_kill',
'task_list',
'task_output',
'todo_write',
'update_goal',
'web_search',
'workflow',
'write',
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
let scaffold: WebScaffold | undefined
afterEach(async () => {
await scaffold?.close()
scaffold = undefined
})
it('assembles the shipped Web catalog and keeps its access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
// keeps a future boundary test from being run inside /tmp — where an
// "escape" write succeeds by design and reads as a sandbox failure.
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
)
// The Web surface keeps its shipped access default; the base's confined one
// reaches the TUI. Pinning both keeps a base change from moving Web silently.
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access')
expect(scaffold.ctx.approval.config.policy).toBe('never')
}, 120_000)

View File

@@ -47,7 +47,8 @@
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts"
"tests/access-confirmation.e2e.ts",
"tests/shipped-composition.e2e.ts"
],
"references": [
{

View File

@@ -622,7 +622,9 @@
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts",
"tests/**/*.snapshot.ts",
"tests/fixtures/tui-scripted-llm.ts"
"tests/fixtures/tui-scripted-llm.ts",
"tests/fixtures/composition-echo-llm.ts",
"tests/fixtures/composition-settled.ts"
],
"project": [
"src/**/*.ts",

15
pnpm-lock.yaml generated
View File

@@ -279,6 +279,9 @@ importers:
'@deepseek-ai/dsh-llm-retry':
specifier: workspace:^
version: link:../../packages/llm/llm-retry
'@deepseek-ai/dsh-mcp-client':
specifier: workspace:^
version: link:../../packages/mcp/mcp-client
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../packages/util/paths
@@ -288,6 +291,9 @@ importers:
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../packages/plan/plan-mode
'@deepseek-ai/dsh-repeat-tool-guard':
specifier: workspace:^
version: link:../../packages/guard/repeat-tool-guard
'@deepseek-ai/dsh-sandbox-local':
specifier: workspace:^
version: link:../../packages/sandbox/sandbox-local
@@ -375,6 +381,9 @@ importers:
'@deepseek-ai/dsh-timeout-policy':
specifier: workspace:^
version: link:../../packages/timeout/timeout-policy
'@deepseek-ai/dsh-tmux-context':
specifier: workspace:^
version: link:../../packages/context/tmux-context
'@deepseek-ai/dsh-token-meter':
specifier: workspace:^
version: link:../../packages/llm/token-meter
@@ -399,9 +408,15 @@ importers:
'@deepseek-ai/dsh-tool-ralph':
specifier: workspace:^
version: link:../../packages/workflow/tool-ralph
'@deepseek-ai/dsh-tool-session-query':
specifier: workspace:^
version: link:../../packages/session-query/tool-session-query
'@deepseek-ai/dsh-tool-skill':
specifier: workspace:^
version: link:../../packages/skill/tool-skill
'@deepseek-ai/dsh-tool-str-replace-editor':
specifier: workspace:^
version: link:../../packages/fs/tool-str-replace-editor
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../../packages/subagent/tool-subagent

View File

@@ -35,6 +35,7 @@
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",
"apps/web/tests/access-confirmation.e2e.ts",
"apps/web/tests/shipped-composition.e2e.ts",
"apps/cli/tests/**/*.ts",
"examples/*/src/**/*.ts",
"examples/*/start.ts",