diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 6cbc24d8aa..d5b4798e33 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 417c6d6bc91eb3afaa38976e0013e4fbe72854ca -2026-08-02-pwsh-tool-bash-parity.zh.md: 926433526b3820f7b4770acb3ee172448970b601 +2026-08-02-pwsh-tool-bash-parity.md: 286acbce3289331a107924120da91464b2303bab +2026-08-02-pwsh-tool-bash-parity.zh.md: 4de34c1416821436c156b1b19b7ae775779e8915 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 417c6d6bc9..286acbce32 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -29,6 +29,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). +- The seam now carries a `dialect` (`ShellDialect`: `bash` | `powershell` — concrete shells, not families; a POSIX-ish sibling would be its own value), and both shell tools reject a mismatched executor at load, so pairing tool-pwsh with bash-local (or the reverse) fails loud instead of surfacing commands handed to the wrong parser as ordinary nonzero exits. - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. - The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. - The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 926433526b..4de34c1416 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -29,6 +29,7 @@ Status: implemented - bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 +- seam 现在携带 `dialect`(`ShellDialect`:`bash` | `powershell`——具体 shell 而非家族;类 POSIX 的同胞将是自己的值),两个 shell 工具在加载时拒绝不匹配的执行器:把 tool-pwsh 与 bash-local 误配(或反之)会响亮失败,而不是让交错 parser 的命令表现为普通非零退出。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 - pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 - 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb6993962c..111741a1a7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -293,7 +293,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` diff --git a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml index 91fdeabf53..9daab43aff 100644 --- a/examples/acp-agent/tests/pwsh.cordis.snapshot.yml +++ b/examples/acp-agent/tests/pwsh.cordis.snapshot.yml @@ -27,7 +27,8 @@ workspaceContext: false skills: enabled: false -# task_output/task_kill stay mounted so background pwsh runs are readable and killable. + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/examples/acp-agent/tests/pwsh.cordis.yml b/examples/acp-agent/tests/pwsh.cordis.yml index cb8305c7d9..7021ae2116 100644 --- a/examples/acp-agent/tests/pwsh.cordis.yml +++ b/examples/acp-agent/tests/pwsh.cordis.yml @@ -26,7 +26,8 @@ workspaceContext: false skills: enabled: false -# task_output/task_kill stay mounted so background pwsh runs are readable and killable. + # task_output/task_kill stay mounted (the bundle's toolTasks default) so + # background pwsh runs are readable and killable. goals: false # The pwsh tool replaces the bundle's bash tool in this composition. toolBash: false diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..7bc57fad0b 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -80,6 +80,8 @@ function assertPositiveFinite(name: string, value: number): void { export class LocalBashExecutor extends BashExecutor { static inject = ['subprocess'] + readonly dialect = 'bash' as const + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index 4a2c37ad91..de2e7510e0 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash/README.md -README.md: d7bf746969f52000fe298b65b995b7c631d8001c -README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c +README.md: b4dbfb6cd9af92d65e30be8d0237b17611b6cbe8 +README.zh.md: 8cf73948970bf0ba465d04b5bedc7e4146fdd217 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index d7bf746969..b4dbfb6cd9 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime. +The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Every implementation declares its `dialect` (`ShellDialect`: the concrete shell that parses the command string, `bash` or `powershell`), and the model-facing shell tools reject a mismatched executor at load. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime. This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently: diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index a7c0cac0bc..8cf7394897 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 +**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。每个实现声明自己的 `dialect`(`ShellDialect`:解析命令字符串的具体 shell,`bash` 或 `powershell`),模型侧 shell 工具在加载时拒绝不匹配的执行器。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 4f8ae112a9..756f866e73 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -29,6 +29,14 @@ declare module 'cordis' { } } +/** + * The shell language a command string is written in. Values name concrete + * shells, not families — the executor hands the string verbatim to that + * shell's parser (`bash -c`, `pwsh -Command`), so a POSIX-ish sibling such + * as zsh or fish would be its own dialect, never `bash`. + */ +export type ShellDialect = 'bash' | 'powershell' + /** * Abstract bash execution service. Subclass, implement the abstract methods, * and load the subclass as a plugin — it registers as `ctx.bash` (one @@ -53,6 +61,14 @@ export abstract class BashExecutor extends Service { super(ctx, 'bash') } + /** + * The shell dialect this executor's `run`/`start` parse commands with. + * Model-facing shell tools reject a mismatched executor at load + * (misconfiguration fails loud): a PowerShell command handed to `bash -c` + * would otherwise surface as an ordinary nonzero exit. + */ + abstract readonly dialect: ShellDialect + /** * The sandbox mode this executor applies by default, or `undefined` when it * does not sandbox commands. diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index cacfe85eca..33eb9a61b0 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -10,6 +10,8 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR * owes the abstract class. */ class StubExecutor extends BashExecutor { + readonly dialect = 'bash' as const + resolve(request: BashExecRequest): BashExecSpec { return { command: request.command, diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 316d2c8651..8fc7f43054 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -104,6 +104,8 @@ function assertPositiveFinite(name: string, value: number): void { export class PwshLocalExecutor extends BashExecutor { static inject = ['subprocess'] + readonly dialect = 'powershell' as const + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index 19370b6c64..6eaff6daa5 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md -README.md: c168b3bfe49faec0be8bd7664411f538a8142edf -README.zh.md: 3b94f8bc2e4aca8ade15c4e2e1e35d1674c66fdb +README.md: be89acf06dbc6b4c420dd7fd34eb6ccb842c1fbd +README.zh.md: 7886addd4c941d81a90546518a5967f64edcdf61 diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index c168b3bfe4..be89acf06d 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`), and rejects an executor whose `dialect` is not `bash` at load — a bash command handed to another shell's parser would surface as an ordinary nonzero exit. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. @@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. - -`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; `dsh-bash-env`'s session-persistence contributor owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. +Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 3b94f8bc2e..7886addd4c 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -4,7 +4,7 @@ 模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。 -需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 +需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`),并在加载时拒绝 `dialect` 不为 `bash` 的执行器——bash 命令被交给其他 shell 解析只会表现为普通的非零退出。 包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。 @@ -28,26 +28,7 @@ ### 托管 shell 环境 -每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。 - -`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;`dsh-bash-env` 的会话持久化贡献方持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。 - -```ts -import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-tool-bash' - -export const inject = ['bashEnv'] - -export function apply(ctx: Context): void { - ctx.bashEnv.register({ - name: 'deployment-region', - variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, - resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, - }) -} -``` - -overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 +每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 91a88c9cca..41a2491375 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -188,6 +188,11 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { + // Model commands are written in bash; a mismatched executor would hand + // them to another shell's parser and surface as ordinary nonzero exits. + if (ctx.bash.dialect !== 'bash') { + throw new Error(`tool-bash: the mounted executor speaks '${ctx.bash.dialect}', not bash — mount a bash executor (e.g. dsh-bash-local) or the matching shell tool`) + } const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4f913d78ee..9cb2330928 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -101,6 +101,8 @@ async function callUntilText( } class RecordingSandboxExecutor extends BashExecutor { + readonly dialect = 'bash' as const + readonly modes: Array = [] override get sandboxMode() { @@ -154,6 +156,8 @@ class RecordingSandboxExecutor extends BashExecutor { /** Test executor that records whether the background start boundary was crossed. */ class CountingStartExecutor extends BashExecutor { + readonly dialect: ShellDialect = 'bash' + starts = 0 resolve(request: BashExecRequest): BashExecSpec { @@ -361,6 +365,19 @@ describe('bash tool', () => { expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable') }) + it('rejects an executor speaking another shell dialect at load', async () => { + class PowershellDialectExecutor extends CountingStartExecutor { + override readonly dialect: ShellDialect = 'powershell' + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(PowershellDialectExecutor) + await expect(ctx.plugin(ToolBash)).rejects.toThrow("the mounted executor speaks 'powershell', not bash") + }) + it('registers the bash schema with run_in_background exposed by default', async () => { const ctx = await setup() const schemas = ctx.tools.schemas() @@ -1067,6 +1084,8 @@ describe('the model-facing bash tool builds its request from named args only (no * hands back an already-settled fake handle so the task registration completes. */ class RecordingBashExecutor extends BashExecutor { + readonly dialect = 'bash' as const + readonly requests: BashExecRequest[] = [] resolve(request: BashExecRequest): BashExecSpec { this.requests.push(request) diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 030d24c7c2..642f2c2089 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md -README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 -README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 +README.md: 7bc1c0998a67ee772ec54eb46bebed52e5164588 +README.zh.md: 4f74b7cb4b737fd4c4f788586568676423f59cd4 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index dfe26a6368..7bc1c0998a 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). -Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`), and rejects an executor whose `dialect` is not `powershell` at load — a PowerShell command handed to `bash -c` would surface as an ordinary nonzero exit. The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 2344f8477e..4f74b7cb4b 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -4,7 +4,7 @@ 注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 -需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`),并在加载时拒绝 `dialect` 不为 `powershell` 的执行器——PowerShell 命令被交给 `bash -c` 只会表现为普通的非零退出。 包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9423fe36e6..969a9a777f 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -138,6 +138,11 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-end */ export function apply(ctx: Context, config: Config = {}): void { + // Model commands are written in PowerShell; a mismatched executor would + // hand them to bash and surface as ordinary nonzero exits. + if (ctx.bash.dialect !== 'powershell') { + throw new Error(`tool-pwsh: the mounted executor speaks '${ctx.bash.dialect}', not powershell — mount dsh-pwsh-local or the matching shell tool`) + } const backgroundEnabled = config.enableRunInBackground ?? true ctx.systemPrompt.section({ diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 218099326f..620be2f354 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -23,7 +23,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult, ShellDialect } from '@deepseek-ai/dsh-bash' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' @@ -38,6 +38,8 @@ const testToolSignal = new AbortController().signal * handle. */ class FakeBash extends BashExecutor { + readonly dialect: ShellDialect = 'powershell' + requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 @@ -199,6 +201,19 @@ async function callUntilText( } describe('registration', () => { + it('rejects an executor speaking another shell dialect at load', async () => { + class BashDialectExecutor extends FakeBash { + override readonly dialect = 'bash' as const + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(BashDialectExecutor) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow("the mounted executor speaks 'bash', not powershell") + }) + it('registers the pwsh tool with its prompt section and schema', async () => { const { ctx } = await setup() const schema = ctx.tools.schemas().find(s => s.name === 'pwsh') diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 1d94399184..54954f8e4d 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -48,6 +48,8 @@ function runResult(stdout: string, overrides: Partial = {}): Bash /** A scriptable fake `ctx.bash` recording the command it was asked to run. */ class FakeBash extends BashExecutor { + readonly dialect = 'bash' as const + commands: string[] = [] result: BashRunResult = runResult(`${tmuxLine()}\n`) runError?: Error diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index f9e97dd45b..db59c9265b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -162,7 +162,7 @@ export interface Scenario { */ posixOnly?: boolean /** - * Whether the scenario boots a composition that needs a real `pwsh` on PATH + * Whether the scenario boots a composition that needs a usable `pwsh` * (the pwsh-tool-turn scenario). The run test is skipped when the suite's * {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded * on every platform. diff --git a/t1120.json b/t1120.json new file mode 100644 index 0000000000..3e064c460c --- /dev/null +++ b/t1120.json @@ -0,0 +1 @@ +[{"comments":{"nodes":[{"body":"🟡 **warning**: Duplicated contract with a stale example import: the §Managed shell environment section carries the full bashEnv registry contract (both prose paragraphs, the contributor code example, and the overlay paragraph) near-verbatim duplicated in this PR's new owning home, packages/bash/bash-env/README.md. The tool-bash copy's example still reads `import type {} from '@deepseek-ai/dsh-tool-bash'` to get `ctx.bashEnv` typed — but this PR moved the `declare module 'cordis'` merge to dsh-bash-env (tool-bash only type-imports it, so the example compiles transitively but names the wrong owner). tool-pwsh's README shows the intended post-extraction shape: a one-paragraph summary linking to ../bash-env/. Trim tool-bash's section to the bash-specific facts plus the link, keep the contract and example in bash-env only (one home per fact; symmetry between the two shell tools), update both language sides and re-record the pairing. Non-blocking, but worth fixing before merge since the stale example is a direct consequence of this PR's extraction.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8o","isResolved":false,"line":30,"path":"packages/bash/tool-bash/README.md"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Nit: `Scenario.pwshOnly`'s JSDoc says the composition \"needs a real `pwsh` on PATH\", but the probe is caller-owned and the shipped caller (examples/acp-agent/tests/acp.snapshot.ts:441) deliberately follows resolvePwshPath() — Program Files installs are found even when bare `pwsh` is not on PATH. This is the same stale \"on PATH\" phrasing already corrected in the executor.spec.ts and integration.spec.ts headers this round. One-word fix: \"a usable `pwsh`\".\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H8w","isResolved":false,"line":164,"path":"packages/support/acp-snapshot/src/suite.ts"},{"comments":{"nodes":[{"body":"🔵 **suggestion**: Optional nit: the `# task_output/task_kill stay mounted so background pwsh runs are readable and killable.` contract comment sits at column 0 mid-way through the acp-agent config map (between `skills` and `goals` keys), in both pwsh.cordis.yml and pwsh.cordis.snapshot.yml. Valid YAML and a genuinely useful pin (the mounting decision is the bundle's toolTasks default, invisible in this file), but indent it into the block or move it above the composition's plugin list where the decision reads naturally.\n\n\u003csub\u003e🤖 v5\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H82","isResolved":false,"line":30,"path":"examples/acp-agent/tests/pwsh.cordis.yml"},{"comments":{"nodes":[{"body":"🟡 **warning**: 拒绝 shell 工具与执行器方言不匹配: 当组合误将 `tool-pwsh` 与 `dsh-bash-local` 搭配时,当前注入会正常完成,PowerShell 命令却被交给 `bash -c`,通常只返回普通的非零退出标记;反向将 `tool-bash` 搭配 `pwsh-local` 也一样。新增第二种方言后,`ctx.bash` 需要暴露可校验的方言能力,并让两个工具在加载时拒绝不匹配的执行器,否则明显的部署错误会静默表现为命令失败。\n\n\u003csub\u003e🤖 v6\u003c/sub\u003e"}]},"id":"PRRT_kwDOS3Pfcs6V_H88","isResolved":false,"line":141,"path":"packages/bash/tool-pwsh/src/index.ts"}] diff --git a/zh-tail.txt b/zh-tail.txt new file mode 100644 index 0000000000..3429afb031 --- /dev/null +++ b/zh-tail.txt @@ -0,0 +1,3 @@ +overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。 + +结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。