feat(pwsh): add the pwsh-local executor and the pwsh tool
Windows-native execution foundation: PwshLocalExecutor implements the bash executor seam over ctx.subprocess (pwsh -NoLogo -NoProfile -NonInteractive -Command, one argv element, no quoting layer; resolvePwshPath probes PowerShell 7 / PATH / Windows PowerShell 5.1 as a pure function), and tool-pwsh is the minimal PowerShell-dialect model-facing tool over ctx.bash (foreground only, managed DSH_* env, timeout/signal/exit markers, terminal and generic presenters). Both packages carry full suites (real pwsh, self-skipping without it) at per-file 100% coverage; vitest's Windows exclusion narrows from packages/bash/* to the bash-requiring packages so the pwsh suites run natively on Windows too. The CLI gains the workspace deps and tsconfig projects without mounting either plugin; the Windows-default roadmap is recorded as a proposed Agent Note.
This commit is contained in:
@@ -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-08-01-pwsh-tool-and-executor.md
|
||||
2026-08-01-pwsh-tool-and-executor.md: fd73e929804045d7b810a587c6f088b2f7cff9cc
|
||||
2026-08-01-pwsh-tool-and-executor.zh.md: f55be1ad0e102311d09b8b7ee1a003778e679cb2
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: PowerShell executor and pwsh tool
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry.
|
||||
|
||||
## Decision
|
||||
|
||||
Two new packages under `packages/bash/`:
|
||||
|
||||
- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH.
|
||||
- **`@deepseek-ai/dsh-tool-pwsh`** — the minimal model-facing tool over `ctx.bash`, PowerShell-dialect by contract: foreground only, no `run_in_background`, no sandbox escalation, managed `DSH_*` environment (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`), result markers `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`, and `terminal`/`generic` UI presenters.
|
||||
|
||||
Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines.
|
||||
|
||||
The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation.
|
||||
|
||||
**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest.
|
||||
|
||||
**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims.
|
||||
- `tool-pwsh` is the model-visible Windows-first profile: no background tasks or escalation to mislead a model into assuming bash-tool parity, and the prompt guidance pins the `[exit code: N]` contract.
|
||||
- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize.
|
||||
- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: PowerShell 执行器与 pwsh 工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-01-pwsh-tool-and-executor.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。
|
||||
|
||||
## 决策
|
||||
|
||||
在 `packages/bash/` 下新增两个包:
|
||||
|
||||
- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。
|
||||
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的最小面向模型工具,契约是 PowerShell 方言:仅前台,没有 `run_in_background`,没有沙箱升级,受管 `DSH_*` 环境(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`),结果标记 `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`,以及 `terminal`/`generic` UI presenter。
|
||||
|
||||
Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
|
||||
|
||||
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。
|
||||
|
||||
**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。
|
||||
|
||||
**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
|
||||
|
||||
## 后果
|
||||
|
||||
- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。
|
||||
- `tool-pwsh` 是模型可见的 Windows 优先画像:没有后台任务或升级会让模型误以为与 bash 工具对等,提示词指导钉住 `[exit code: N]` 契约。
|
||||
- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。
|
||||
- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。
|
||||
@@ -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/proposed/feature/2026-08-01-windows-pwsh-default.md
|
||||
2026-08-01-windows-pwsh-default.md: 6f3e48f33d98b2d2da7bd288d42a0d2763163ba3
|
||||
2026-08-01-windows-pwsh-default.zh.md: 270fd8d95c85400c302540376932228a6023447c
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Windows defaults to pwsh (roadmap)
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-08-01-windows-pwsh-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a minimal `pwsh` tool — but nothing yet defaults Windows hosts to them.
|
||||
|
||||
## Proposal
|
||||
|
||||
Three follow-up stages, each independently shippable:
|
||||
|
||||
1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end.
|
||||
2. **Bash-tool parity twin** — `tool-pwsh` grows the bash tool's missing surface where Windows workflows prove it: `run_in_background` through the generic task runtime, and the persistence-side `DSH_SESSION_JSONL` environment fact. Sandbox escalation stays out until a Windows-confining executor exists.
|
||||
3. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin.
|
||||
|
||||
The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then tool parity, then rendering. Nothing in this proposal changes POSIX behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
|
||||
|
||||
**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible.
|
||||
|
||||
**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there.
|
||||
- POSIX hosts are byte-for-byte unaffected (same roster, same executor).
|
||||
- The shipped-composition e2es assert the platform-gated roster on both families.
|
||||
- Stage 2 lands with task-runtime integration tests; stage 3 lands with TUI/Web rendering snapshots for pwsh output.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch.
|
||||
- **Tool-behavior drift** — a minimal `tool-pwsh` that never grows parity invites models to write bash-shaped commands; the prompt guidance and dialect contract mitigate this only if the twin keeps pace.
|
||||
- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed.
|
||||
- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 3) keeps stage 1 shippable without UI churn.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Windows 默认改用 pwsh(路线图)
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-08-01-windows-pwsh-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与最小化的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。
|
||||
|
||||
## 提案
|
||||
|
||||
三个阶段,各自可独立交付:
|
||||
|
||||
1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。
|
||||
2. **bash 工具对等孪生**——在 Windows 工作流证明需要的地方,`tool-pwsh` 补齐 bash 工具缺失的表面:经由通用任务运行时的 `run_in_background`,以及持久化侧 `DSH_SESSION_JSONL` 环境实情。在出现 Windows 约束执行器之前,沙箱升级保持缺席。
|
||||
3. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。
|
||||
|
||||
各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再工具对等,最后渲染。本提案不改变任何 POSIX 行为。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。
|
||||
|
||||
**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。
|
||||
|
||||
**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。
|
||||
- POSIX 主机逐字节不受影响(清单相同,执行器相同)。
|
||||
- 交付组合 e2e 在两个平台族上断言按平台门控的清单。
|
||||
- 阶段 2 附带任务运行时集成测试落地;阶段 3 附带 pwsh 输出的 TUI/Web 渲染快照落地。
|
||||
|
||||
## 风险
|
||||
|
||||
- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。
|
||||
- **工具行为漂移**——永远不补齐对等的 `tool-pwsh` 会诱使模型写 bash 形状的命令;只有当孪生跟上节奏时,提示词指导与方言契约才能缓解这一点。
|
||||
- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。
|
||||
- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 3)让阶段 1 无需 UI 翻动即可交付。
|
||||
@@ -77,6 +77,7 @@
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
@@ -120,6 +121,7 @@
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
|
||||
@@ -963,6 +963,36 @@ export interface Config {
|
||||
|
||||
Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-pwsh-local`
|
||||
|
||||
Requires: `subprocess`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
* locations are probed first (PowerShell 7, then Windows PowerShell 5.1),
|
||||
* falling back to a bare `pwsh` resolved through PATH.
|
||||
*/
|
||||
pwshPath?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/pwsh-local/src/index.ts:43`](../packages/bash/pwsh-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-repeat-tool-guard`
|
||||
|
||||
```ts config-catalog
|
||||
@@ -1788,6 +1818,20 @@ export interface Config {
|
||||
|
||||
Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh`
|
||||
|
||||
Requires: `tools` · `bash` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-pwsh/src/index.ts:31`](../packages/bash/tool-pwsh/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ralph`
|
||||
|
||||
Requires: `tools` · `workflows` · `subagents` · `systemPrompt`
|
||||
|
||||
@@ -40,7 +40,9 @@ flowchart TD
|
||||
pkg_bash["bash"]
|
||||
pkg_bash_local["bash-local"]
|
||||
pkg_bash_sandbox["bash-sandbox"]
|
||||
pkg_pwsh_local["pwsh-local"]
|
||||
pkg_tool_bash["tool-bash"]
|
||||
pkg_tool_pwsh["tool-pwsh"]
|
||||
end
|
||||
subgraph group_fs["packages/fs"]
|
||||
pkg_fs["fs"]
|
||||
@@ -505,6 +507,10 @@ flowchart TD
|
||||
pkg_bash_local --> pkg_invariants
|
||||
pkg_bash_local --> pkg_subprocess
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_pwsh_local --> pkg_bash
|
||||
pkg_pwsh_local --> pkg_invariants
|
||||
pkg_pwsh_local --> pkg_subprocess
|
||||
pkg_pwsh_local --> pkg_timeout
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_local --> pkg_invariants
|
||||
pkg_fs_policy --> pkg_fs
|
||||
@@ -704,6 +710,14 @@ flowchart TD
|
||||
pkg_tool_bash --> pkg_tasks
|
||||
pkg_tool_bash --> pkg_tools
|
||||
pkg_tool_bash --> pkg_user_approval
|
||||
pkg_tool_pwsh --> pkg_agent
|
||||
pkg_tool_pwsh --> pkg_bash
|
||||
pkg_tool_pwsh --> pkg_invariants
|
||||
pkg_tool_pwsh --> pkg_llm
|
||||
pkg_tool_pwsh --> pkg_paths
|
||||
pkg_tool_pwsh --> pkg_session_persistence
|
||||
pkg_tool_pwsh --> pkg_system_prompt
|
||||
pkg_tool_pwsh --> pkg_tools
|
||||
pkg_tool_fs --> pkg_fs
|
||||
pkg_tool_fs --> pkg_invariants
|
||||
pkg_tool_fs --> pkg_llm
|
||||
@@ -1134,6 +1148,7 @@ flowchart TD
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
|
||||
@@ -1175,6 +1190,7 @@ flowchart TD
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
|
||||
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
|
||||
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
|
||||
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
|
||||
@@ -205,6 +206,44 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/
|
||||
|
||||
The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh`
|
||||
|
||||
### `pwsh`
|
||||
|
||||
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The PowerShell command to execute."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "number",
|
||||
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"description"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts)
|
||||
|
||||
The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-cordis`
|
||||
|
||||
### `cordis_inspect`
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"bwrap",
|
||||
"pwsh",
|
||||
"python3",
|
||||
"sandbox-exec",
|
||||
"taskkill"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
|
||||
README.md: a97612ab4e11bf4a3fcfb77daf0624a894b02ad4
|
||||
README.zh.md: d6751dac6df789eec9727c1380f1a4c91da60728
|
||||
@@ -0,0 +1,53 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
|
||||
The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, and the pure `resolvePwshPath`/`candidatePwshPaths` helpers.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands.
|
||||
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
|
||||
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
|
||||
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
|
||||
@@ -0,0 +1,53 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
|
||||
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`,以及纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## 行为(及其由来)
|
||||
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;具名消费方拥有请求前缀的任何变更。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。
|
||||
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
|
||||
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
|
||||
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
|
||||
|
||||
清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-local",
|
||||
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
* environment, and the model-facing stdout/stderr merge for background reads.
|
||||
*
|
||||
* The command string is passed as ONE argv element to `-Command`: PowerShell
|
||||
* itself parses the text, and no intermediate shell exists, so there is no
|
||||
* shell-quoting layer to escape (the `bash -c` string domain has no
|
||||
* equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides for PowerShell: disable colors and
|
||||
* pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
|
||||
* deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
* locations are probed first (PowerShell 7, then Windows PowerShell 5.1),
|
||||
* falling back to a bare `pwsh` resolved through PATH.
|
||||
*/
|
||||
pwshPath?: string
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
|
||||
|
||||
/**
|
||||
* Well-known Windows PowerShell install locations plus PATH entries, newest
|
||||
* first. Explicitly parameterized (env) so resolution is a pure function of
|
||||
* its inputs on every platform.
|
||||
* @param env - the environment to probe; defaults to the process environment.
|
||||
* @returns candidate `pwsh` executable paths in resolution order.
|
||||
*/
|
||||
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
||||
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
|
||||
const candidates = [
|
||||
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
|
||||
]
|
||||
// Microsoft Store installs (and any user-added location) live on PATH;
|
||||
// entries may carry surrounding quotes from `setx`-style definitions.
|
||||
for (const entry of (env.PATH ?? '').split(';')) {
|
||||
const trimmed = entry.trim().replace(/^"|"$/g, '')
|
||||
if (trimmed.length === 0) continue
|
||||
candidates.push(join(trimmed, 'pwsh.exe'))
|
||||
}
|
||||
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
|
||||
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pwsh executable this executor spawns.
|
||||
* @param configured - an explicit `pwshPath` config value, trusted as-is.
|
||||
* @param env - the environment to probe on Windows; defaults to the process environment.
|
||||
* @param platform - the platform to resolve for; defaults to the process platform.
|
||||
* @returns the first existing well-known location on Windows (PowerShell 7
|
||||
* install, a PATH entry such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), else `pwsh` for PATH resolution.
|
||||
*/
|
||||
export function resolvePwshPath(
|
||||
configured?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (configured !== undefined && configured.length > 0) return configured
|
||||
if (platform === 'win32') {
|
||||
for (const candidate of candidatePwshPaths(env)) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
return 'pwsh'
|
||||
}
|
||||
|
||||
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
|
||||
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
|
||||
const read = reader.readFrom(0)
|
||||
return {
|
||||
text: read.text,
|
||||
truncated: read.lossy,
|
||||
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`pwsh-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
* this executor supplies their configured budgets per spawn.
|
||||
*/
|
||||
export class PwshLocalExecutor extends BashExecutor {
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request into a fully-specified spec: fill `workdir` from
|
||||
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
|
||||
* `config.timeoutMs`, capped at `config.maxTimeoutMs`.
|
||||
*/
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
const timeoutMs = clampTimeout(
|
||||
request.timeoutMs,
|
||||
this.config.timeoutMs,
|
||||
this.config.maxTimeoutMs,
|
||||
'pwsh-local: request.timeoutMs',
|
||||
)
|
||||
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
|
||||
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
stdout: collect(stdoutMaxBytes),
|
||||
stderr: collect(this.config.maxOutputBytes),
|
||||
},
|
||||
graceMs: this.config.graceMs,
|
||||
signal,
|
||||
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
|
||||
}
|
||||
}
|
||||
|
||||
/** The collect-mode readers the executor itself requested (present by construction). */
|
||||
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
|
||||
const { stdout, stderr } = handle.collected
|
||||
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
|
||||
if (stdout === undefined || stderr === undefined) {
|
||||
throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
return {
|
||||
...outcome,
|
||||
timedOut,
|
||||
aborted,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: finalOutput(collected.stdout),
|
||||
stderr: finalOutput(collected.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
const note = spawnFailureNote ?? ''
|
||||
spawnFailureNote = undefined
|
||||
return note
|
||||
}
|
||||
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: running.done.then((outcome) => {
|
||||
// Any signal termination is killed, including a command signaling itself.
|
||||
if (proc.status === 'running') {
|
||||
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
const err = collected.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// A failed spawn never produced process output, so the note and real
|
||||
// stderr text are mutually exclusive.
|
||||
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.terminate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
}
|
||||
|
||||
export default PwshLocalExecutor
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
|
||||
* @module @deepseek-ai/dsh-pwsh-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess
|
||||
* service plus a REAL pwsh executable, exercised through the executor seam
|
||||
* (`resolve` → `run`/`start`). These verify the world — actual PowerShell
|
||||
* runs, output capture, truncation and spill, deadlines, kill escalation, and
|
||||
* the background-handle contract. The suite self-skips when no `pwsh` is on
|
||||
* PATH (a CI accommodation for hosts without PowerShell); the pure unit tests
|
||||
* (config validation, executable resolution) run on every platform. PowerShell
|
||||
* writes CRLF on Windows, so exact text assertions normalize line endings.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { PwshLocalExecutor, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
|
||||
|
||||
const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a handle's consuming readOutput until the ACCUMULATED delta contains
|
||||
* `expected`; returns the accumulation (reads never re-deliver, so the caller
|
||||
* gets everything produced up to the match).
|
||||
*/
|
||||
async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let all = ''
|
||||
while (Date.now() < deadline) {
|
||||
all += proc.readOutput().delta
|
||||
if (lf(all).includes(expected)) return lf(all)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`)
|
||||
}
|
||||
|
||||
describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => {
|
||||
it('trusts an explicit configured path verbatim', () => {
|
||||
expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe')
|
||||
expect(resolvePwshPath('pwsh')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('falls through an empty configured path to platform resolution', () => {
|
||||
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
|
||||
// fallback candidate cannot exist either.
|
||||
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh')
|
||||
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
|
||||
const candidates = candidatePwshPaths({
|
||||
ProgramFiles: 'P:\\Program Files',
|
||||
SystemRoot: 'S:\\Windows',
|
||||
PATH: ';"Q:\\quoted store";' + ';',
|
||||
})
|
||||
expect(candidates).toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('Q:\\quoted store', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
// A missing PATH contributes no entries (the empty-string fallback).
|
||||
expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' }))
|
||||
.toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the first EXISTING win32 candidate, else pwsh', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
|
||||
const store = join(dir, 'store')
|
||||
mkdirSync(store, { recursive: true })
|
||||
writeFileSync(join(store, 'pwsh.exe'), '')
|
||||
// The existing PATH entry wins over the non-existent Program Files install.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
|
||||
.toBe(join(store, 'pwsh.exe'))
|
||||
// No candidate exists anywhere (SystemRoot points at a non-existent tree,
|
||||
// so even the Windows PowerShell 5.1 fallback cannot exist) → the
|
||||
// PATH-resolution fallback.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
|
||||
.toBe('pwsh')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(lf(result.stdout.text)).toBe('hi\n')
|
||||
expect(result.timeoutMs).toBe(5_000)
|
||||
})
|
||||
|
||||
it('uses config cwd, overridable per call', async () => {
|
||||
const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
|
||||
const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
|
||||
const { bash } = await setup({ cwd: first })
|
||||
const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)
|
||||
const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second }))
|
||||
expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults cwd to process.cwd()', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true)
|
||||
})
|
||||
|
||||
it('caps per-call timeouts at maxTimeoutMs', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 }))
|
||||
expect(result.timeoutMs).toBe(2_000)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric config and timeout overrides', async () => {
|
||||
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
|
||||
})
|
||||
|
||||
it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100)
|
||||
|
||||
// Raw Console writes avoid PowerShell's own line-ending and formatting
|
||||
// layers, so the byte counts are exact on every platform.
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)',
|
||||
stdoutMaxBytes: 500,
|
||||
}))
|
||||
|
||||
expect(result.stdout.text).toBe('x'.repeat(500))
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stderr.truncated).toBe(true)
|
||||
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 }))
|
||||
expect(result.timedOut).toBe(true)
|
||||
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.timeoutMs).toBe(100)
|
||||
})
|
||||
|
||||
it('propagates abort signals', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.aborted).toBe(true)
|
||||
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
|
||||
expect(result.timedOut).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a self-killed command as neither timed out nor aborted', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.aborted).toBe(false)
|
||||
// Windows reports a forced termination without a signal; POSIX reports SIGTERM.
|
||||
if (process.platform === 'win32') {
|
||||
expect(result.signal).toBeNull()
|
||||
} else {
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects on spawn failure (bad workdir)', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"',
|
||||
stdin: 'piped\n',
|
||||
env: { SEAM_VAR: 'env-ok' },
|
||||
dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
|
||||
})
|
||||
// resolve() keeps the optional input/environment fields verbatim.
|
||||
expect(spec.stdin).toBe('piped\n')
|
||||
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
|
||||
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
|
||||
const result = await bash.run(spec)
|
||||
expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n')
|
||||
})
|
||||
|
||||
it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'Write-Output ok' })
|
||||
expect('stdin' in spec).toBe(false)
|
||||
expect('env' in spec).toBe(false)
|
||||
expect('dshEnv' in spec).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => {
|
||||
it('start returns immediately with a running handle that settles as completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const before = Date.now()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
|
||||
expect(Date.now() - before).toBeLessThan(150)
|
||||
expect(proc.status).toBe('running')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
await proc.done
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' }))
|
||||
const first = await readUntil(proc, 'first\n')
|
||||
expect(lf(first)).toBe('first\n')
|
||||
await proc.done
|
||||
// Read-after-exit returns the remaining buffered output — once.
|
||||
const second = proc.readOutput()
|
||||
expect(lf(second.delta)).toBe('second\n')
|
||||
expect(second.lossy).toBe(false)
|
||||
expect(proc.readOutput().delta).toBe('')
|
||||
})
|
||||
|
||||
it('readOutput marks stderr sections', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stdoutSpillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('readOutput reports stderr spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stderrSpillPath).toBeDefined()
|
||||
expect(lf(read.delta)).toContain('[stderr]')
|
||||
})
|
||||
|
||||
it('kill() terminates the process tree: true once, false after settlement', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
expect(proc.kill()).toBe(true)
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('kill() returns false for a naturally completed process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('a spec.signal abort settles the handle as killed, not completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
controller.abort()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.exitCode).toBeNull()
|
||||
expect(proc.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
|
||||
// done resolves (never rejects) even though the process never ran.
|
||||
await expect(proc.done).resolves.toBeUndefined()
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.readOutput().delta).toContain('spawn failed:')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
// The child prints its own pid so the test can probe liveness through the
|
||||
// public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
// Executor reload/disposal leaves background work running — the
|
||||
// handle stays live and readable, mirroring the task runtime's
|
||||
// registrations-outlive-producer-fibers contract.
|
||||
await executorFiber.dispose()
|
||||
expect(proc.status).toBe('running')
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
|
||||
// Service disposal kills the group and AWAITS its exit (no orphans).
|
||||
await managerFiber.dispose()
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
await proc.done
|
||||
// POSIX reports the kill as a signal; Windows reports a forced
|
||||
// termination as exit 1 with no signal (indistinguishable from a crash),
|
||||
// so the status stamp follows the platform's exit facts.
|
||||
expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
|
||||
it('service disposal settles running handles and leaves settled ones untouched', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
|
||||
await managerFiber.dispose()
|
||||
// A settled process was untouched; the live one was terminated and joined.
|
||||
expect(finished.status).toBe('completed')
|
||||
await running.done
|
||||
expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: 4f1d62dbf49fef678e3285776c466286535d66da
|
||||
README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c
|
||||
@@ -0,0 +1,107 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
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. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin.
|
||||
|
||||
Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests.
|
||||
|
||||
The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. |
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every call receives a freshly 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`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request where the tool is visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token.
|
||||
|
||||
### Foreground result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Tool errors
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the failing call adds these retained tokens; an aborted call adds no command output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin.
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet.
|
||||
@@ -0,0 +1,107 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。
|
||||
|
||||
需要一个已加载的执行器实现;插件在 `ctx.bash` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
|
||||
包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。
|
||||
|
||||
该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。
|
||||
|
||||
## 工具
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
### 受管 shell 环境
|
||||
|
||||
每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。
|
||||
|
||||
规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。
|
||||
|
||||
## UI 呈现
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。
|
||||
|
||||
##### Pwsh 指导
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
插件激活期间每个请求有少量固定输入成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见时每个请求有固定的 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。
|
||||
|
||||
### 前台结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
校验与基础设施失败被规范化为 `Error: <message>`。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>` 与 `tool call aborted`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。
|
||||
- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。
|
||||
- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pwsh",
|
||||
"description": "Model-facing pwsh tool over the bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Model-facing `pwsh` tool 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.
|
||||
*
|
||||
* Minimal by design: no background tasks, no sandbox escalation — this is the
|
||||
* "works on my Windows machine" profile until the full bash-tool feature set
|
||||
* gets a PowerShell twin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
export const name = 'tool-pwsh'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the pwsh tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface PwshToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
interface PwshForegroundResult {
|
||||
kind: 'foreground'
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
timedOut: boolean
|
||||
aborted: boolean
|
||||
timeoutMs: number
|
||||
stdout: { text: string; truncated: boolean; spillPath?: string }
|
||||
stderr: { text: string; truncated: boolean; spillPath?: string }
|
||||
}
|
||||
|
||||
function validatePwshArgs(args: PwshToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
if (args.description.trim().length === 0) {
|
||||
throw new Error('invalid description: expected a non-empty string')
|
||||
}
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function pwshDescription(): string {
|
||||
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
|
||||
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
|
||||
* otherwise use the session header cwd and leave executor defaulting as the fallback.
|
||||
*/
|
||||
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
|
||||
const headerCwd = exec.agent?.session.header.cwd
|
||||
if (modelWorkdir === undefined) return headerCwd
|
||||
if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) {
|
||||
return resolvePath(headerCwd, modelWorkdir)
|
||||
}
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing text of one foreground pwsh result: stdout, a marked
|
||||
* stderr section, then the applicable timeout, signal, and exit markers —
|
||||
* each separated by a newline only when the accumulated text lacks one, so a
|
||||
* trailing newline in stdout never produces a blank line.
|
||||
*
|
||||
* @param value - the canonical foreground result (the schema-derived value shape).
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
function renderPwshOutput(value: RenderablePwshOutput): string {
|
||||
let rendered = value.stdout.text
|
||||
const marker = (line: string): void => {
|
||||
rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line
|
||||
}
|
||||
if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`)
|
||||
if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`)
|
||||
if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`)
|
||||
if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`)
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the executor DTO from readonly seam interfaces into plain JSON data.
|
||||
* @param result - the executor's run outcome.
|
||||
* @returns the canonical foreground result the tool returns and renders.
|
||||
*/
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
truncated: stream.truncated,
|
||||
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
||||
})
|
||||
return {
|
||||
kind: 'foreground',
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
aborted: result.aborted,
|
||||
timeoutMs: result.timeoutMs,
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */
|
||||
interface RenderablePwshOutput {
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
timedOut: boolean
|
||||
timeoutMs: number
|
||||
stdout: { text: string }
|
||||
stderr: { text: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell
|
||||
* marker, and the session identity when an agent is present.
|
||||
*/
|
||||
function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment {
|
||||
const values: Record<string, string> = {
|
||||
[DSH_HOME_ENV]: dshHome,
|
||||
[`${DSH_ENV_PREFIX}SHELL`]: '1',
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const dshHome = resolveDshHome(config.dshHome)
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Clear, concise description of what this command does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
||||
+ '"git status" → "Show working tree status"; "Get-Process" → "List running processes".',
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
timedOut: { type: 'boolean', required: true },
|
||||
aborted: { type: 'boolean', required: true },
|
||||
timeoutMs: { type: 'number', required: true },
|
||||
stdout: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderPwshOutput(value),
|
||||
}],
|
||||
},
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: collectDshEnv(exec, dshHome),
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
return canonicalPwshResult(result)
|
||||
},
|
||||
presentCall: (args: PwshToolArgs): TerminalCallView => ({
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
description: args.description,
|
||||
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
||||
}),
|
||||
presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`.
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-pwsh-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
|
||||
* `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
|
||||
* process. These verify the world — actual commands run, stdout/stderr come
|
||||
* back, exit codes render, timeouts abort, and per-session cwd resolution
|
||||
* works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation
|
||||
* for hosts without PowerShell); the fake-executor suite (tools.spec.ts)
|
||||
* carries the coverage gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) {
|
||||
return ctx.tools.execute({
|
||||
signal: signal ?? testToolSignal,
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agentObj ? { agent: agentObj as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-'))
|
||||
await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n')
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolPwsh)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
|
||||
|
||||
it('runs a command and returns stdout with the exit marker', async () => {
|
||||
const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 })
|
||||
expect(lf(text(result))).toBe('hi\n[exit code: 0]')
|
||||
})
|
||||
|
||||
it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: '[Console]::Error.WriteLine("boom"); exit 3',
|
||||
description: 'fail loudly',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]')
|
||||
})
|
||||
|
||||
it('resolves relative paths in the session workspace', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Get-Content greeting.txt',
|
||||
description: 'read greeting',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]')
|
||||
})
|
||||
|
||||
it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
timeoutMs: 100,
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected a timed-out foreground result')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false })
|
||||
// Windows reports the forced termination as exit 1 without a signal;
|
||||
// POSIX reports SIGTERM — the timeout marker is the stable fact.
|
||||
expect(lf(text(result))).toContain('[timed out after 100ms]')
|
||||
})
|
||||
|
||||
it('an upstream cancellation aborts the run', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
}, agent(), controller.signal)
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
|
||||
* exercised through `ctx.tools.execute()` so nothing bypasses the tool
|
||||
* registry. The fake executor makes every seam outcome scriptable — output
|
||||
* text, truncation, timeout, abort, nonzero exits — so these tests verify the
|
||||
* schema, argument validation, workdir derivation, managed `DSH_*` collection,
|
||||
* abort translation, canonical result projection, rendering, and the UI
|
||||
* presenters. Real-pwsh behavior is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
|
||||
* returns the armed script, `start()` throws — the pwsh tool must NEVER create
|
||||
* a background task.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.specs.push(spec)
|
||||
return this.handler(spec)
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
throw new Error('the pwsh tool must never start a background task')
|
||||
}
|
||||
}
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config: Partial<ToolPwsh.Config> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd and id. */
|
||||
const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } })
|
||||
|
||||
let callCounter = 0
|
||||
function call(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: { agent?: object; signal?: AbortSignal } = {},
|
||||
) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...options.agent ? { agent: options.agent as never } : {},
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
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')
|
||||
expect(schema).toBeDefined()
|
||||
expect(schema?.description).toContain('PowerShell command')
|
||||
expect(schema?.parameters.properties).toMatchObject({
|
||||
command: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
workdir: { type: 'string' },
|
||||
})
|
||||
expect(schema?.parameters.required).toEqual(['command', 'description'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
const fiber = await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('argument validation', () => {
|
||||
it('rejects a blank command or description and a non-positive timeoutMs', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
|
||||
.toContain('invalid timeoutMs: expected a positive number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution through the bash seam', () => {
|
||||
it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
|
||||
const { ctx, bash } = await setup({ dshHome })
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'Write-Output hi',
|
||||
description: 'say hi',
|
||||
timeoutMs: 1234,
|
||||
}, { agent: agent('/sessions/s1') })
|
||||
expect(result.isError).toBe(false)
|
||||
const request = bash.requests[0]
|
||||
expect(request?.command).toBe('Write-Output hi')
|
||||
expect(request?.workdir).toBe('/sessions/s1')
|
||||
expect(request?.timeoutMs).toBe(1234)
|
||||
expect(request?.dshEnv).toEqual({
|
||||
DSH_HOME: dshHome,
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: 'session-1',
|
||||
})
|
||||
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
|
||||
})
|
||||
|
||||
it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') })
|
||||
expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') })
|
||||
expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
|
||||
})
|
||||
|
||||
it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
|
||||
expect(bash.requests[0]).not.toHaveProperty('workdir')
|
||||
const dshEnv = bash.requests[0]?.dshEnv
|
||||
expect(dshEnv).toBeDefined()
|
||||
expect(dshEnv?.['DSH_SHELL']).toBe('1')
|
||||
expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
|
||||
expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
|
||||
})
|
||||
|
||||
it('forwards exec.signal into the resolved request', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal })
|
||||
expect(bash.requests[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('out\n', {
|
||||
exitCode: 2,
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
timeoutMs: 5000,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toEqual({
|
||||
kind: 'foreground',
|
||||
exitCode: 2,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 5000,
|
||||
stdout: { text: 'out\n', truncated: false },
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
})
|
||||
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
|
||||
})
|
||||
|
||||
it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('tail', {
|
||||
stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
|
||||
expect(text(result)).toBe('tail\n[exit code: 0]')
|
||||
|
||||
bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
|
||||
const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
|
||||
// A timeout kill carries both facts, mirroring the bash tool's markers.
|
||||
expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
|
||||
it('never starts a background task', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
await call(ctx, 'pwsh', { command: 'missing', description: 'missing' })
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
it('a real execute renders the console view through the tool definition presenter', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const result = await call(ctx, 'pwsh', args, { agent: agent('/w') })
|
||||
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
|
||||
.toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
|
||||
.toMatchObject({ cwd: 'C:\\work' })
|
||||
})
|
||||
|
||||
it('presentResult falls back to undefined for multi-block or non-text content', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
|
||||
const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+71
@@ -309,6 +309,9 @@ importers:
|
||||
'@deepseek-ai/dsh-pty-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/pty/pty-local
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/bash/pwsh-local
|
||||
'@deepseek-ai/dsh-repeat-tool-guard':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/guard/repeat-tool-guard
|
||||
@@ -426,6 +429,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-goal':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/goal/tool-goal
|
||||
'@deepseek-ai/dsh-tool-pwsh':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/bash/tool-pwsh
|
||||
'@deepseek-ai/dsh-tool-ralph':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/workflow/tool-ralph
|
||||
@@ -941,6 +947,31 @@ importers:
|
||||
specifier: 0.0.0-test.0
|
||||
version: 0.0.0-test.0
|
||||
|
||||
packages/bash/pwsh-local:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../bash
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess-local
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/bash/tool-bash:
|
||||
dependencies:
|
||||
schemastery:
|
||||
@@ -1011,6 +1042,46 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/bash/tool-pwsh:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../bash
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-paths':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/paths
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../pwsh-local
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess-local
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/client/connection:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-commands':
|
||||
|
||||
@@ -17,6 +17,7 @@ import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -31,6 +32,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -167,6 +169,23 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pwsh',
|
||||
dir: 'tool-pwsh',
|
||||
source: 'packages/bash/tool-pwsh/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The pwsh tool consumes the bash executor seam; the schema harvest
|
||||
// mounts the pwsh-local implementation so the inject resolves without
|
||||
// executing anything (registration never spawns a process).
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
},
|
||||
note:
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\\...` paths and `$env:NAME` variables.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
|
||||
@@ -43,6 +43,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
|
||||
"@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
|
||||
"@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
|
||||
"@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"],
|
||||
"@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"],
|
||||
"@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
|
||||
"@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
|
||||
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
|
||||
|
||||
@@ -136,6 +136,8 @@
|
||||
{ "path": "./packages/llm/llm-deepseek" },
|
||||
{ "path": "./packages/llm/llm-pi-ai" },
|
||||
{ "path": "./packages/bash/bash-local" },
|
||||
{ "path": "./packages/bash/pwsh-local" },
|
||||
{ "path": "./packages/bash/tool-pwsh" },
|
||||
{ "path": "./packages/sandbox/sandbox" },
|
||||
{ "path": "./packages/sandbox/sandbox-local" },
|
||||
{ "path": "./packages/sandbox/sandbox-policy" },
|
||||
|
||||
+7
-1
@@ -11,7 +11,13 @@ const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ proj
|
||||
|
||||
const windowsUnsupportedPackages = process.platform === 'win32'
|
||||
? [
|
||||
'packages/bash/*',
|
||||
// Bash-requiring suites (a real POSIX shell is unavailable on Windows).
|
||||
// The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay
|
||||
// INCLUDED: PowerShell ships with Windows, so they run natively here.
|
||||
'packages/bash/bash-local',
|
||||
'packages/bash/bash-sandbox',
|
||||
'packages/bash/tool-bash',
|
||||
'packages/bash/tool-bash-persistent',
|
||||
'packages/hooks/*',
|
||||
'packages/subprocess/*',
|
||||
'packages/pty/pty-local',
|
||||
|
||||
Reference in New Issue
Block a user