Merge origin/master into xjt/proofreading-active-docs-2-apply

This commit is contained in:
xjt
2026-08-05 10:57:53 +08:00
371 changed files with 7567 additions and 1104 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/README.md
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea
README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0
README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03

View File

@@ -2,13 +2,16 @@
English | [中文](README.zh.md)
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) |
| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).

View File

@@ -2,13 +2,16 @@
[English](README.md) | 中文
规范的三包能力 seam见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型工具。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
| `bash/` | 抽象 bash 执行器 seam接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` |
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现命令默认值补全、deadline、终端环境、后台读取合并 | (注册 `ctx.bash` |
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv标记拒绝强制执行事实扩展 `bash-local` 的机制) | (注册 `ctx.bash` |
| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash` |
| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor | (注册 `ctx.bashEnv` |
| `tool-bash/` | 面向模型的 `bash` schema后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools` |
| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools` |
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。

View File

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

View File

@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-bash-env
English | [中文](README.zh.md)
The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly.
The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin.
## Config
```yaml
- id: bash-env
name: '@deepseek-ai/dsh-bash-env'
config:
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
```
## Managed environment
Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-bash-env'
export const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
## Model Experience
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.

View File

@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-bash-env
[English](README.md) | 中文
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash``dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME``DSH_SHELL=1``DSH_SESSION_ID`归注册表自身所有其他插件可以注册额外的可枚举事实注册随插件纤维fiber释放重复所有权或未声明的运行时键会响亮失败。
包根导出 Cordis 插件契约(`name``inject``Config``apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`
## Config
```yaml
- id: bash-env
name: '@deepseek-ai/dsh-bash-env'
config:
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
```
## Managed environment
每次前台与后台模型 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`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME``DSH_SHELL``DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-bash-env'
export const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。
## Model Experience
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME``DSH_SHELL``DSH_SESSION_ID`不包含在内因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。

View File

@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-bash-env",
"description": "Tool-independent managed DSH_* shell environment registry",
"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"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^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-session-persistence": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,217 @@
/**
* Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of
* trusted, per-execution `DSH_*` variables consumed by the model-facing shell
* tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by
* the registry itself while plugins can register additional, enumerable facts
* with effect-scoped disposal.
*
* @module @deepseek-ai/dsh-bash-env
*/
import { Service, type Context } from 'cordis'
import z from 'schemastery'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-session-persistence'
declare module 'cordis' {
interface Context {
bashEnv: BashEnvRegistry
}
}
export const name = 'bash-env'
export const inject: string[] = []
/** Plugin config (all optional — the built-in facts resolve without defaults). */
export interface Config {
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
}
/** Runtime configuration schema for the bash-env plugin. */
export const Config: z<Config> = z.object({
dshHome: z.string(),
})
/** Model-visible metadata for one managed `DSH_*` environment variable. */
export interface BashEnvVariable {
/** Concise description of the environment fact represented by the variable. */
description: string
}
/**
* A plugin contribution to the managed environment of each model shell call.
* Declared keys make ownership conflicts detectable before the first command;
* `resolve` computes only the values available for the current execution.
*/
export interface BashEnvContributor {
/** Stable contributor name used in diagnostics and duplicate detection. */
name: string
/** Complete set of `DSH_*` keys this contributor may return. */
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
/**
* Resolve this contributor's available values for one tool execution.
* @param execution - the shell tool execution and its optional calling agent.
* @returns a partial map containing only keys declared in {@link variables}.
*/
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
}
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
export interface BashEnvVariableInfo extends BashEnvVariable {
/** Contributor that owns the variable. */
contributor: string
/** Declared `DSH_*` environment variable name. */
key: DshEnvironmentKey
}
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
DSH_HOME_ENV,
DSH_SHELL_KEY,
DSH_SESSION_ID_KEY,
])
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
/**
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
* The namespace is rebuilt for every model shell call: ambient `DSH_*` values
* are discarded by the executor, then the registry's current snapshot is
* injected. Built-in shell facts remain owned by the registry itself while
* plugins can register additional, enumerable facts with effect-scoped
* disposal.
*/
export class BashEnvRegistry extends Service {
private readonly contributors = new Map<string, BashEnvContributor>()
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
private readonly dshHome: string
/**
* Create and install the `ctx.bashEnv` service.
* @param ctx - Cordis context that owns the service and registrations.
* @param config - home-directory configuration for the built-in variables.
*/
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'bashEnv')
this.dshHome = resolveDshHome(config.dshHome)
}
/**
* Register one environment contributor. Names and keys are unique; built-in
* keys are reserved. Registration is disposed with the calling plugin fiber.
* @param contributor - declared key ownership and per-execution resolver.
* @returns the disposer that unregisters the contribution.
*/
register(contributor: BashEnvContributor): () => void {
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
if (contributor.name.trim().length === 0) {
throw new Error('bash env contributor name must be non-empty')
}
if (this.contributors.has(contributor.name)) {
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
}
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
for (const [key, variable] of variables) {
if (!key.startsWith(DSH_ENV_PREFIX)
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
}
if (RESERVED_BASH_ENV_KEYS.has(key)) {
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
}
if (variable.description.trim().length === 0) {
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
}
const owner = this.keyOwners.get(key)
if (owner !== undefined) {
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
}
}
this.contributors.set(contributor.name, contributor)
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
yield () => {
this.contributors.delete(contributor.name)
for (const [key] of variables) this.keyOwners.delete(key)
}
}.bind(this), 'bashEnv.register()')
return () => void dispose()
}
/**
* Build the trusted `DSH_*` snapshot for one shell tool execution.
* @param execution - the current tool execution.
* @returns an immutable environment overlay containing built-ins and current contributions.
*/
collect(execution: ToolExecution): DshEnvironment {
const values: Record<DshEnvironmentKey, string> = {
[DSH_HOME_ENV]: this.dshHome,
[DSH_SHELL_KEY]: '1',
}
if (execution.agent !== undefined) {
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
}
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
const resolved = contributor.resolve(execution)
for (const [rawKey, value] of Object.entries(resolved)) {
const key = rawKey as DshEnvironmentKey
if (!Object.hasOwn(contributor.variables, key)) {
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
}
if (typeof value !== 'string') {
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
}
values[key] = value
}
}
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
}
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
// prompt, or UI code treats list() as an exhaustive environment catalog.
/**
* Enumerate plugin-contributed variables without executing their resolvers.
* @returns declarations sorted by environment variable name.
*/
list(): BashEnvVariableInfo[] {
return [...this.contributors.values()]
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
contributor: contributor.name,
description: variable.description,
key: key as DshEnvironmentKey,
})))
.sort((left, right) => left.key.localeCompare(right.key))
}
}
/**
* Load the bash-env plugin: register the `ctx.bashEnv` service and the
* shell-agnostic persistence contributor (`DSH_SESSION_JSONL`).
* @param ctx - Cordis context that owns the service and registrations.
* @param config - home-directory configuration for the built-in variables.
*/
export function apply(ctx: Context, config: Config = {}): void {
const registry = new BashEnvRegistry(ctx, config)
registry.register({
name: 'session-persistence',
variables: {
[DSH_SESSION_JSONL_KEY]: {
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
},
},
resolve(execution) {
const agent = execution.agent
if (agent === undefined) return {}
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
},
})
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`.
* @module @deepseek-ai/dsh-bash-env/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env'
/** Cordis companion plugin name. */
export const name = 'bash-env-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the environment registry validates ownership and collected values at each
* registration/collection; it publishes no independent snapshot that a companion could cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,3 +1,9 @@
/**
* Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor
* ownership and validation, collection ordering, effect-scoped disposal, and
* the explicit disposer contract.
*/
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -5,7 +11,8 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
const testToolSignal = new AbortController().signal
@@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => {
dispose()
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
})
it('the plugin registers the service and the persistence contributor on load', async () => {
const ctx = new Context()
await ctx.plugin(BashEnvPlugin)
expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry)
expect(ctx.bashEnv.list()).toEqual([
{
contributor: 'session-persistence',
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
key: 'DSH_SESSION_JSONL',
},
])
})
it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => {
const ctx = new Context()
await ctx.plugin(BashEnvPlugin)
ctx.provide('sessionPersistence', {
locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }),
})
expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl')
})
it('the persistence contributor omits the variable for a non-jsonl backend', async () => {
const ctx = new Context()
await ctx.plugin(BashEnvPlugin)
ctx.provide('sessionPersistence', {
locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }),
})
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
})
it('the persistence contributor omits the variable without a persistence backend', async () => {
const ctx = new Context()
await ctx.plugin(BashEnvPlugin)
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
})
})

View File

@@ -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": "../../bash/bash"
},
{
"path": "../../util/paths"
},
{
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf
README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62

View File

@@ -0,0 +1,56 @@
# @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`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn.
## 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.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **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 (through the generic task runtime), 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.
- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

View File

@@ -0,0 +1,56 @@
# @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` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
## 配置
```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 加载与会干扰工具输出的提示符。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **面向模型的终端环境**——`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`
- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)``#requires``using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires``-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8不受影响。
清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。

View File

@@ -0,0 +1,45 @@
{
"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"
],
"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"
}
}

View File

@@ -0,0 +1,288 @@
/**
* 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 { 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'
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
/**
* 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
/**
* UTF-8 output pinning prepended to every command. The subprocess collector
* decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort
* executable fallback) writes the console/OEM code page by default, which
* garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The
* statements ride on line 1 after `; ` separators so PowerShell error line
* numbers stay accurate.
*/
export const ENCODING_PREAMBLE =
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
/** 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 and PATH entries are probed in order (PowerShell 7 install,
* PATH entries such as the Microsoft Store install, 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'>
// Resolution lives in its own dependency-free module so the repository's
// coverage-gate probe shares the exact definition the suites use.
export { candidatePwshPaths, resolvePwshPath } from './resolve.ts'
/** 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', `${ENCODING_PREAMBLE}${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. Mirrored from
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
* the declared seam for a future pwsh-confining subclass and has no consumer
* in this package yet.
* @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 {}
}
/* jscpd:ignore-end */
export default PwshLocalExecutor

View File

@@ -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 */

View File

@@ -0,0 +1,60 @@
/**
* PowerShell executable resolution, dependency-free so non-package consumers
* (the repository's coverage-gate probe in `vitest.config.ts`) can share the
* ONE resolution definition with the executor and its suites — a probe that
* resolved differently from the code under test could exempt a file whose
* suites actually run.
*
* @module @deepseek-ai/dsh-pwsh-local/resolve
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
/**
* 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'
}

View File

@@ -0,0 +1,454 @@
/**
* 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 usable `pwsh`
* resolves (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, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SubprocessService from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
// The probe follows the executor's own resolution (Program Files installs on
// Windows are found even when bare `pwsh` is not on PATH).
const hasPwsh = spawnSync(resolvePwshPath(), ['-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('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessService extends SubprocessService {
specs: SubprocessSpawnSpec[] = []
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}
override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
this.specs.push(spec)
return {
pid: -1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: this.reader, stderr: this.reader },
done: Promise.resolve({ exitCode: 0, signal: null }),
terminate: () => {},
waitForExit: async () => true,
}
}
}
it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
const ctx = new Context()
const subprocess = new CapturingSubprocessService(ctx)
await ctx.plugin(PwshLocalExecutor)
await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' }))
expect(subprocess.specs).toHaveLength(1)
const { argv } = subprocess.specs[0]!
expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
})
})
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 the
// terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill).
if (process.platform === 'win32') {
expect(result.signal).toBeNull()
} else {
expect(['SIGTERM', 'SIGKILL']).toContain(result.signal)
}
})
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()
// PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill.
expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal)
})
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')
})
})

View File

@@ -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"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d
README.zh.md: ed6cf2c9fa9d0173202b132a89f014b5a7d2e248
README.md: 35a5647365dab6daa903c14cba8b83702b50305d
README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
@@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
### Managed shell environment
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-tool-bash'
export const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
@@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Validation and policy 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>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
Validation and policy 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>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`.
#### Token effect

View File

@@ -4,9 +4,9 @@
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output``task_list``task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`;在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器
包根只公开 Cordis 插件契约(`name``inject``Config``apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
package根只公开 Cordis 插件契约(`name``inject``Config``apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
插件还会提供 `tool:bash` 提示词段落(顺序 105检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
@@ -28,26 +28,7 @@
### 托管 shell 环境
每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME``~/.dsh``DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`当活跃持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据
`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME``DSH_SHELL``DSH_SESSION_ID`tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-tool-bash'
export const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`
@@ -61,7 +42,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
## 工具仅使用具名参数构建请求
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
## 权限与升权
@@ -121,7 +102,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 后台任务上下文与结果
@@ -135,13 +116,13 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 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>``invalid escalation: sandbox_permissions requires a justification``invalid escalation: justification is only valid together with sandbox_permissions``invalid justification: expected a non-empty sentence``background execution is disabled for this bash tool``background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks``sandbox_permissions is not available in this composition (no sandboxing executor to escalate)``sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``invalid escalation: sandbox_permissions requires a justification``invalid escalation: justification is only valid together with sandbox_permissions``invalid justification: expected a non-empty sentence``background execution is disabled for this bash tool``background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks``sandbox_permissions is not available in this composition (no sandboxing executor to escalate)``sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`
#### Token 影响
@@ -149,7 +130,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作

View File

@@ -21,20 +21,17 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-bash-env": "^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-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -49,15 +46,14 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",

View File

@@ -8,205 +8,39 @@
* @module @deepseek-ai/dsh-tool-bash
*/
import { Service, type Context } from 'cordis'
import type { Context } from 'cordis'
import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, 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 type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-bash-env'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
declare module 'cordis' {
interface Context {
bashEnv: BashEnvRegistry
}
}
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
/** Configuration for the bash tool and its managed child environment. */
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
}
/** Runtime configuration schema for the bash tool plugin. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
dshHome: z.string(),
})
/** Model-visible metadata for one managed `DSH_*` environment variable. */
export interface BashEnvVariable {
/** Concise description of the environment fact represented by the variable. */
description: string
}
/**
* A plugin contribution to the managed environment of each model bash call.
* Declared keys make ownership conflicts detectable before the first command;
* `resolve` computes only the values available for the current execution.
*/
export interface BashEnvContributor {
/** Stable contributor name used in diagnostics and duplicate detection. */
name: string
/** Complete set of `DSH_*` keys this contributor may return. */
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
/**
* Resolve this contributor's available values for one tool execution.
* @param execution - the bash tool execution and its optional calling agent.
* @returns a partial map containing only keys declared in {@link variables}.
*/
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
}
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
export interface BashEnvVariableInfo extends BashEnvVariable {
/** Contributor that owns the variable. */
contributor: string
/** Declared `DSH_*` environment variable name. */
key: DshEnvironmentKey
}
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
DSH_HOME_ENV,
DSH_SHELL_KEY,
DSH_SESSION_ID_KEY,
])
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
/**
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
* The namespace is rebuilt for every model bash call: ambient `DSH_*` values
* are discarded by the executor, then the registry's current snapshot is
* injected. Built-in shell facts remain owned by the registry itself while
* plugins can register additional, enumerable facts with effect-scoped
* disposal.
*/
export class BashEnvRegistry extends Service {
private readonly contributors = new Map<string, BashEnvContributor>()
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
private readonly dshHome: string
/**
* Create and install the `ctx.bashEnv` service.
* @param ctx - Cordis context that owns the service and registrations.
* @param config - home-directory configuration for the built-in variables.
*/
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'bashEnv')
this.dshHome = resolveDshHome(config.dshHome)
}
/**
* Register one environment contributor. Names and keys are unique; built-in
* keys are reserved. Registration is disposed with the calling plugin fiber.
* @param contributor - declared key ownership and per-execution resolver.
* @returns the disposer that unregisters the contribution.
*/
register(contributor: BashEnvContributor): () => void {
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
if (contributor.name.trim().length === 0) {
throw new Error('bash env contributor name must be non-empty')
}
if (this.contributors.has(contributor.name)) {
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
}
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
for (const [key, variable] of variables) {
if (!key.startsWith(DSH_ENV_PREFIX)
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
}
if (RESERVED_BASH_ENV_KEYS.has(key)) {
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
}
if (variable.description.trim().length === 0) {
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
}
const owner = this.keyOwners.get(key)
if (owner !== undefined) {
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
}
}
this.contributors.set(contributor.name, contributor)
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
yield () => {
this.contributors.delete(contributor.name)
for (const [key] of variables) this.keyOwners.delete(key)
}
}.bind(this), 'bashEnv.register()')
return () => void dispose()
}
/**
* Build the trusted `DSH_*` snapshot for one bash tool execution.
* @param execution - the current tool execution.
* @returns an immutable environment overlay containing built-ins and current contributions.
*/
collect(execution: ToolExecution): DshEnvironment {
const values: Record<DshEnvironmentKey, string> = {
[DSH_HOME_ENV]: this.dshHome,
[DSH_SHELL_KEY]: '1',
}
if (execution.agent !== undefined) {
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
}
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
const resolved = contributor.resolve(execution)
for (const [rawKey, value] of Object.entries(resolved)) {
const key = rawKey as DshEnvironmentKey
if (!Object.hasOwn(contributor.variables, key)) {
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
}
if (typeof value !== 'string') {
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
}
values[key] = value
}
}
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
}
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
// prompt, or UI code treats list() as an exhaustive environment catalog.
/**
* Enumerate plugin-contributed variables without executing their resolvers.
* @returns declarations sorted by environment variable name.
*/
list(): BashEnvVariableInfo[] {
return [...this.contributors.values()]
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
contributor: contributor.name,
description: variable.description,
key: key as DshEnvironmentKey,
})))
.sort((left, right) => left.key.localeCompare(right.key))
}
}
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface BashToolArgs {
command: string
@@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
} as const
export function apply(ctx: Context, config: Config = {}): void {
// FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell
// environment plugin; replacing this tool with persistent Bash must not
// remove the managed DSH_* contributor seam.
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
name: 'session-persistence',
variables: {
[DSH_SESSION_JSONL_KEY]: {
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
},
},
resolve(execution) {
const agent = execution.agent
if (agent === undefined) return {}
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
},
})
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void {
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
const dshEnv = bashEnv.collect(exec)
const dshEnv = ctx.bashEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
@@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void {
...request,
signal: exec.signal,
}))
if (result.aborted) throw new Error('command aborted')
if (result.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
},
presentCall: presentBashCall,

View File

@@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
@@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
await ctx.plugin(ToolBash)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}

View File

@@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import { processOutcome } from '../src/background.ts'
import { renderProcessRead, renderResult } from '../src/render.ts'
@@ -35,6 +36,7 @@ async function setup() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
await ctx.plugin(ToolBash)
return ctx
@@ -50,6 +52,7 @@ async function setupWithTasks() {
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
await ctx.plugin(ToolBash)
return ctx
@@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
if (withApproval) await ctx.plugin(ApprovalService)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
}
@@ -281,6 +285,7 @@ describe('bash tool', () => {
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ToolBash)
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
expect(text(result)).toContain('[output truncated; full output: ')
@@ -300,7 +305,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(/ENOENT/)
})
it('surfaces foreground aborts as isError', async () => {
it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => {
const ctx = await setup()
const controller = new AbortController()
const pending = ctx.tools.execute({
@@ -312,7 +317,10 @@ describe('bash tool', () => {
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/aborted/)
expect(result.error).toMatchObject({
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
})
})
// Type and required-key violations are rejected by the harness
@@ -389,6 +397,7 @@ describe('bash tool', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(BashEnvPlugin)
const fiber = await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(1)
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
@@ -403,6 +412,7 @@ describe('bash tool', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(0)
await ctx.plugin(LocalSubprocessService)
@@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ToolBash)
const controller = new AbortController()
@@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(ToolBash)
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
@@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ToolBash, { enableRunInBackground: false })
@@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingSandboxExecutor)
await ctx.plugin(BashEnvPlugin)
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
})
@@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// not renderResult output, so a generic fenced card, no terminal output/exit.
const out = ctx.tools.get('bash')!.presentResult!(
{ command: 'x', description: 'x' },
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
)
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
})
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
@@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no
}
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome })
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}

View File

@@ -26,21 +26,18 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../bash/bash"
},
{
"path": "../../util/paths"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../bash/bash-env"
},
{
"path": "../../ui/user-approval"
},

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7
README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7

View File

@@ -0,0 +1,125 @@
# @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. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export.
The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker.
## 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. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
`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 foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## UI presentation
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. 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
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
```
#### 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 `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
#### 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.
### Background result
#### What the model sees
A background start renders exactly `started background task <id>`; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes.
#### Token effect
The ack is a fixed short line; task output is bounded per read.
#### 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>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, 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
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work.
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.

View File

@@ -0,0 +1,125 @@
# @deepseek-ai/dsh-tool-pwsh
[English](README.md) | 中文
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
包根只导出 Cordis 插件契约(`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
插件还贡献 `tool:pwsh` prompt sectionorder 105非零退出以 `[exit code: N]` marker 报告Windows 上的中断以无 signal 的 exit 1 结算。
## 工具
### `pwsh`
| Arg | Type | Notes |
|---|---|---|
| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
| `description` | string (required) | 命令的一行主动语态摘要5-10 词),仅用于 UI/日志展示——不影响执行。 |
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
| `run_in_background` | boolean | 立即返回任务 id不适用超时。 |
`command``workdir``timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`
### Managed shell environment
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
结果文本包含 stdout、可选的 `[stderr]`然后是适用的截断、超时、signal 与退出 marker。干净退出0、无 signal不产生 marker空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
`run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
## UI presentation
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。
## Model Experience
### System prompt
#### What the model sees
本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema但不会移除这个独立注册的段落。
##### Pwsh guidance
```markdown
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
```
#### Token effect
插件激活期间每次请求的固定小额输入成本。
#### KV Cache effect
注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。
### Tool schemas
#### What the model sees
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。
#### Token effect
工具可见的每个请求上的固定 schema 成本。
#### KV Cache effect
可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。
### Foreground result
#### What the model sees
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]``[timed out after <timeoutMs>ms]``[killed by signal: <signal>]``[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`
#### Token effect
调用前零结果 token。每个流的输出有界而每条已发出的行保留在历史中直到压缩。
#### KV Cache effect
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
### Background result
#### What the model sees
后台启动精确渲染为 `started background task <id>`;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。
#### Token effect
ack 是固定短行;任务输出按读取有界。
#### KV Cache effect
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
### Tool errors
#### What the model sees
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``run_in_background is disabled for this deployment (enableRunInBackground: false)``background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks``tool call aborted`
#### Token effect
只有失败的调用会新增这些保留 token被中止的调用不产生命令输出。
#### KV Cache effect
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
## Known Limitations and Deferred Work
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器bash 工具的 sandbox 面不被镜像)。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作。
- **PowerShell 方言契约** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。

View File

@@ -0,0 +1,57 @@
{
"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"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-bash-env": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^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-bash-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,31 @@
/**
* Generic-task adaptation for background pwsh process handles — the shell-agnostic
* twin of `dsh-tool-bash`'s background adaptation.
*
* @module @deepseek-ai/dsh-tool-pwsh/background
*/
import type { BashProcess } from '@deepseek-ai/dsh-bash'
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */
/**
* Map a settled background process onto the generic task-outcome vocabulary:
* `killed` stays `killed` (detail: the signal when one is known), everything
* else is `completed` with the exit code as detail. A nonzero command exit is
* reported, not failed, exactly like the foreground rendering.
* @param proc - the settled process handle.
* @returns the outcome for the `ctx.tasks` registration.
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,306 @@
/**
* 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.
*
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
* foreground and `run_in_background` execution (background handles register
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
* through the shared `bash-env` registry, and the bash marker/truncation
* rendering story. UI presentation stays on the existing generic/terminal
* cards; a pwsh-specific rendering twin is roadmap work.
*
* @module @deepseek-ai/dsh-tool-pwsh
*/
import { isAbsolute, resolve as resolvePath } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, 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-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-bash-env'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import { processOutcome } from './background.ts'
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
pwsh: 'pwsh'
}
}
export const name = 'tool-pwsh'
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
}
/** Runtime configuration schema for the pwsh tool plugin. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
})
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface PwshToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
}
/** 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 }
}
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
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)}`)
}
}
/* jscpd:ignore-end */
function pwshDescription(backgroundEnabled: boolean): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
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]`. '
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
+ background
}
/**
* 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
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
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,
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
stdout: output(result.stdout),
stderr: output(result.stderr),
}
}
/** Canonical background-handle properties shared by the pwsh output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
} as const
/* jscpd:ignore-end */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
ctx.systemPrompt.section({
name: 'tool:pwsh',
order: 105,
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. '
+ 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.',
})
ctx.tools.register(defineTool({
name: 'pwsh',
description: pwshDescription(backgroundEnabled),
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.' },
...backgroundEnabled ? {
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
} : {},
},
output: {
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
// consumers of one must accept the other (see the pwsh-tool-and-executor
// Agent Note).
/* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: BACKGROUND_OUTPUT_PROPERTIES,
},
{
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' },
},
},
},
},
],
},
/* jscpd:ignore-end */
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderPwshResult(value),
}],
},
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
async execute(args: PwshToolArgs, exec) {
validatePwshArgs(args)
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv: ctx.bashEnv.collect(exec),
}
if (args.run_in_background === true) {
// Undeclared keys are allowed, so schema omission also needs enforcement.
if (!backgroundEnabled) {
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until ctx.tasks commits detached ownership.
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
/* v8 ignore end */
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'pwsh',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.bash.start(ctx.bash.resolve(request))
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderPwshProcessRead(proc.readOutput()),
}
},
})
return { kind: 'background' as const, taskId: id }
}
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
signal: exec.signal,
}))
if (result.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
return canonicalPwshResult(result)
},
/* jscpd:ignore-end */
/* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */
presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => {
// Background acknowledgements carry no terminal exit status; the generic
// card mirrors the bash tool's background presentation.
if (args.run_in_background === true) {
return {
card: 'generic',
title: args.command,
kind: 'execute',
rawInput: args.command,
content: [{ type: 'text', text: args.description }],
}
}
return {
card: 'terminal',
title: args.command,
description: args.description,
...args.workdir !== undefined ? { cwd: args.workdir } : {},
}
},
/* jscpd:ignore-end */
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\`\`\`` }] }
},
}))
}

View File

@@ -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 */

View File

@@ -0,0 +1,81 @@
/**
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
* stderr section, truncation notices with spill paths, then exit-status
* markers. Non-zero exits are reported, not errored — the model decides how to
* react; only infrastructure failures (spawn errors, aborts) surface as
* isError results.
*
* @module @deepseek-ai/dsh-tool-pwsh/render
*/
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/** The renderable foreground result shape (the schema-derived value, no `kind`). */
export interface RenderablePwshResult {
exitCode: number | null
signal: string | null
timedOut: boolean
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers, matching the bash tool's story —
* a clean exit (0, no signal) produces no marker.
* @param result - the completed foreground run from the executor.
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderPwshResult(result: RenderablePwshResult): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// A command may trap the termination and exit 0 after timeout; still report interruption.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
/**
* Shape one background-process read into the `task_output` delta the model
* sees: the incremental delta, plus the lossy-read notice (with full-stream
* spill paths) when in-memory truncation dropped unread bytes.
* @param read - one incremental read from the process handle.
* @returns the delta text with any loss notice appended.
*/
export function renderPwshProcessRead(read: BashProcessRead): string {
const notices: string[] = []
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,154 @@
/**
* 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, background tasks settle through the
* generic task runtime, and per-session cwd resolution works. The suite
* self-skips when no usable `pwsh` resolves (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 LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
const testToolSignal = new AbortController().signal
// The probe follows the executor's own resolution (Program Files installs on
// Windows are found even when bare `pwsh` is not on PATH).
const hasPwsh = spawnSync(resolvePwshPath(), ['-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(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(BashEnvPlugin)
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 no marker on a clean exit', 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')
})
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')
})
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 } })
})
it('a background run settles through the REAL task_output tool', async () => {
const started = await call('pwsh', {
command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done',
description: 'background greeting',
run_in_background: true,
})
expect(started.isError).toBe(false)
if (started.isError) throw new Error('expected background pwsh success')
expect(started.value).toMatchObject({ kind: 'background' })
const taskId = (started.value as { taskId: string }).taskId
// The output delta and the terminal status can land in separate reads
// (Windows flushes the child pipe at exit), so collect incrementally —
// the same two-step shape as the bash background suite.
const deadline = Date.now() + 10_000
let output = ''
while (Date.now() < deadline) {
const read = await call('task_output', { task_id: taskId })
output += text(read)
if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(output).toContain('bg-done')
expect(output).toContain('[status: completed, exit code: 0]')
})
})

View File

@@ -0,0 +1,63 @@
/**
* REAL-composition tier (packages/AGENTS.md): boot the examples-owned
* tool-pwsh Loader fixture as a subprocess through the same app/boot path a
* deployment uses, execute real foreground and background pwsh commands
* through the tool registry, and assert the assembled model-visible surface:
* schema, prompt section, and rendered results. Self-skips when no `pwsh`
* executable exists (a CI accommodation for hosts without PowerShell).
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
// The probe follows the executor's own resolution (Program Files installs on
// Windows are found even when bare `pwsh` is not on PATH).
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
const driver = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
interface PwshLoaderReport {
schemaHasRunInBackground: boolean
promptHasMarkerSection: boolean
foregroundText: string
backgroundText: string
}
describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => {
it('registers the pwsh surface and renders real foreground and background results', async () => {
let report: PwshLoaderReport | undefined
const { stderr } = await runLoaderSmoke({
label: 'tool-pwsh loader smoke',
tempDirPrefix: 'tool-pwsh-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport
},
})
expect(stderr).not.toContain('UNHANDLED')
expect(report).toBeDefined()
expect(report).toMatchObject({
schemaHasRunInBackground: true,
promptHasMarkerSection: true,
})
expect(report?.foregroundText).toBe('loader-ok\n')
expect(report?.backgroundText).toContain('loader-bg-ok')
expect(report?.backgroundText).toContain('[status: completed, exit code: 0]')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,637 @@
/**
* 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, background handles — so
* these tests verify the schema, argument validation, workdir derivation,
* managed `DSH_*` collection, abort translation, canonical result projection,
* rendering, background task wiring, 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, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
import { processOutcome } from '../src/background.ts'
import { renderPwshProcessRead } from '../src/render.ts'
const testToolSignal = new AbortController().signal
/**
* A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
* returns the armed foreground script, `start()` returns the armed background
* handle.
*/
class FakeBash extends BashExecutor {
requests: BashExecRequest[] = []
specs: BashExecSpec[] = []
startCalls = 0
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n')
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(spec: BashExecSpec): BashProcess {
this.startCalls++
this.specs.push(spec)
return this.backgroundHandler(spec)
}
}
/** 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,
}
}
/** A settled successful background handle; overrides script failure shapes. */
function fakeProcess(delta = 'bg-ok\n'): BashProcess {
let consumed = false
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
readOutput: () => {
if (consumed) return { delta: '', lossy: false }
consumed = true
return { delta, lossy: false }
},
kill: () => false,
}
}
/** A running background handle whose kill() settles it as killed (like a real task_kill). */
function killableProcess(): BashProcess {
let resolveDone: () => void = () => {}
const done = new Promise<void>((resolve) => { resolveDone = resolve })
const proc: BashProcess = {
status: 'running',
exitCode: null,
signal: null,
done,
readOutput: () => ({ delta: '', lossy: false }),
kill: () => {
if (proc.status !== 'running') return false
proc.status = 'killed'
proc.signal = 'SIGTERM'
resolveDone()
return true
},
}
return proc
}
async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
await ctx.plugin(FakeBash)
await ctx.plugin(ToolPwsh, toolConfig)
const bash = ctx.bash as FakeBash
return { ctx, bash }
}
/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */
async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
await ctx.plugin(FakeBash)
await ctx.plugin(ToolPwsh, toolConfig)
const bash = ctx.bash as FakeBash
return { ctx, bash }
}
/**
* Build a fake {@link Agent} with the shared agent/session identity, give it a
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
*/
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(sessionId)
const agent = {
id,
ctx: scopeFiber.ctx,
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(agent)
return agent
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
...agent ? { agent } : {},
})
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
async function callUntilText(
ctx: Context,
name: string,
args: unknown,
expected: string,
timeoutMs = 5_000,
): Promise<Awaited<ReturnType<typeof call>>> {
const deadline = Date.now() + timeoutMs
let last: Awaited<ReturnType<typeof call>> | undefined
while (Date.now() < deadline) {
last = await call(ctx, name, args)
if (text(last).includes(expected)) return last
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`)
}
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' },
run_in_background: { type: 'boolean' },
})
expect(schema?.parameters.required).toEqual(['command', 'description'])
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers')
expect(prompt).toContain('without a signal marker')
})
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(BashEnvPlugin)
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 agent = registerFakeAgent(ctx, 'session-1')
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
const result = await call(ctx, 'pwsh', {
command: 'Write-Output hi',
description: 'say hi',
timeoutMs: 1234,
}, agent)
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')
const agent = registerFakeAgent(ctx, 'session-cwd')
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent)
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 ctx.tools.execute({
signal: controller.signal,
callId: CallId('call-signal'),
name: 'pwsh',
arguments: { command: 'Write-Output ok', description: 'ok' },
})
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 a clean exit without a marker and an empty body as (no output)', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('hi\n')
const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
expect(text(clean)).toBe('hi\n')
bash.handler = () => runResult('')
const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' })
expect(text(empty)).toBe('(no output)')
})
it('renders stderr-only output without a stdout prefix', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', {
stderr: { text: 'err\n', truncated: false },
exitCode: 1,
})
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]')
})
it('inserts the separating newline before the stderr section when stdout lacks one', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('out', {
stderr: { text: 'err\n', truncated: false },
exitCode: 1,
})
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]')
})
it('renders the truncation notice with the spill path, then markers', 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[output truncated; full output: /spill/out.log]')
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('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]')
})
it('renders the truncation notice with (unavailable) when no spill path exists', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('tail', {
stdout: { text: 'tail', truncated: true },
stderr: { text: '', truncated: false },
})
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]')
})
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 } })
})
})
describe('background execution through the task runtime', () => {
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
const { ctx } = await setupWithTasks()
const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
expect(started.isError).toBe(false)
if (started.isError) throw new Error('expected background pwsh success')
expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' })
expect(text(started)).toBe('started background task pwsh-1')
const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok')
expect(text(read)).toContain('bg-ok')
// A later read reports the terminal outcome in the generic status line.
const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
expect(final.isError).toBe(false)
})
it('a running background task is killable through the REAL task_kill tool', async () => {
const { ctx, bash } = await setupWithTasks()
bash.backgroundHandler = () => killableProcess()
await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' })
expect(text(killed)).toBe('requested cancellation of task pwsh-1')
// The cancel reached the process handle; the task settles as killed with
// the signal detail mapped by processOutcome.
const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true })
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
})
it('a background task started by an agent is registered with that agent as owner', async () => {
const { ctx } = await setupWithTasks()
const agent = registerFakeAgent(ctx, 'sess-owner')
const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
expect(text(started)).toBe('started background task pwsh-1')
const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' })
expect(anon.isError).toBe(true)
expect(text(anon)).toMatch(/belongs to another session/)
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent)
expect(killed.isError).toBe(false)
await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
})
it('fails loud when the task runtime is not loaded', async () => {
const { ctx } = await setup() // no LocalTaskService / ToolTasks
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
})
it('a pre-aborted call is skipped before the process starts', async () => {
const { ctx, bash } = await setupWithTasks()
const controller = new AbortController()
controller.abort()
const result = await ctx.tools.execute({
callId: CallId('call-pre-aborted'),
name: 'pwsh',
arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(bash.startCalls).toBe(0)
})
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
// With no control surface, task preflight fails before the executor can spawn.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(FakeBash)
await ctx.plugin(ToolPwsh)
const bash = ctx.bash as FakeBash
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no control surface is attached')
// Declare-then-execute: the failed preflight means no process ever ran.
expect(bash.startCalls).toBe(0)
})
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
const { ctx } = await setup({ enableRunInBackground: false })
const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
expect(schema.description).toContain('Background execution is not available')
expect(schema.description).not.toContain('run_in_background')
// Schema omission is advertising; execution must also enforce the opt-out.
const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
expect(forced.isError).toBe(true)
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
expect(foreground.isError).toBe(false)
})
it('applies the built-in background default when apply() receives a bare config', async () => {
// Bypasses the schemastery defaults on purpose: apply() must stand on its
// own `?? true` fallback when embedded programmatically without the schema.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(FakeBash)
ToolPwsh.apply(ctx, {})
const schema = ctx.tools.schemas()[0]!
expect(schema.parameters.properties).toHaveProperty('run_in_background')
expect(schema.description).toContain('task_output')
})
})
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)
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
expect(view).toEqual({
card: 'generic',
content: [{ type: 'text', text: '```console\nhi\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('a background pending call renders the generic card like the bash tool', async () => {
const { ctx } = await setup()
const definition = ctx.tools.get('pwsh')
expect(definition?.presentCall?.({
command: 'Start-Sleep -Seconds 60',
description: 'long wait',
run_in_background: true,
})).toEqual({
card: 'generic',
title: 'Start-Sleep -Seconds 60',
kind: 'execute',
rawInput: 'Start-Sleep -Seconds 60',
content: [{ type: 'text', text: 'long wait' }],
})
})
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()
})
})
describe('renderPwshProcessRead', () => {
const base: BashProcessRead = { delta: 'out\n', lossy: false }
it('returns the delta verbatim for a lossless read', () => {
expect(renderPwshProcessRead(base)).toBe('out\n')
expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
})
it('appends the loss notice with the available spill paths', () => {
expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
expect(renderPwshProcessRead({
...base,
lossy: true,
stdoutSpillPath: 'C:\\spill\\out.log',
stderrSpillPath: 'C:\\spill\\err.log',
}))
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
})
it('reports (unavailable) when a lossy read has no safe spill path', () => {
expect(renderPwshProcessRead({ ...base, lossy: true }))
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
})
it('an empty lossy delta is the notice alone', () => {
expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
.toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
})
it('inserts the separating newline only when the delta lacks one', () => {
expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
})
})
describe('processOutcome', () => {
function settled(over: Partial<BashProcess>): BashProcess {
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
readOutput: () => ({ delta: '', lossy: false }),
kill: () => false,
...over,
}
}
it('maps a signal-killed process to killed with the signal detail', () => {
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
})
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
.toEqual({ status: 'killed', detail: 'killed before exit' })
})
it('maps a completed process to its exit code', () => {
expect(processOutcome(settled({ exitCode: 3 })))
.toEqual({ status: 'completed', detail: 'exit code: 3' })
})
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
expect(processOutcome(settled({ exitCode: null })))
.toEqual({ status: 'completed', detail: 'exit code: 0' })
})
})

View File

@@ -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": "../../bash/bash"
},
{
"path": "../../bash/bash-env"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
}
]
}