Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
This commit is contained in:
_Kerman
2026-08-05 10:39:18 +08:00
133 changed files with 5786 additions and 421 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,50 @@
{
"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",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-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

@@ -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,47 @@
{
"name": "@deepseek-ai/dsh-pwsh-local",
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

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: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57
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,7 +4,7 @@
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output``task_list``task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`;在 `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 的执行器
package根只公开 Cordis 插件契约(`name``inject``Config``apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
@@ -28,26 +28,7 @@
### 托管 shell 环境
每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME``~/.dsh``DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`当活跃持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<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`
@@ -141,7 +122,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
#### 模型看到的内容
验证和策略失败统一为 `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 影响

View File

@@ -29,12 +29,11 @@
"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 +48,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,59 @@
{
"name": "@deepseek-ai/dsh-tool-pwsh",
"description": "Model-facing pwsh tool over the bash executor seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-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"
}
]
}

View File

@@ -188,7 +188,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'collect(execution: ToolExecution): DshEnvironment',
jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */',
jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one shell tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */',
},
{
signature: 'list(): BashEnvVariableInfo[]',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

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/examples/agent-spine-demo/README.md
README.md: 34d68b0791746c28528124853a4d8ea82b68138d
README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182
README.md: 7ea2f4afbe5d0bea62cf0fc2c7ecdd1496d20aef
README.zh.md: cb4202ac0e72db7c85967425671144e743f31b62

View File

@@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to the shared `bash-env` managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.

View File

@@ -59,7 +59,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
组合包将每个字段转发给拥有它的子节点:`agents``maxParallelToolCalls` 交给 `agent-loop``agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agentTUI 和无头应用预创建 `main`ACP 应用则在 `session/new` 按需创建 agent`includeHarnessIdentity``persona``toolOrder` 交给 `dsh-system-prompt``tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry``skills.local``skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context``{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain``goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。
组合包将每个字段转发给拥有它的子节点:`agents``maxParallelToolCalls` 交给 `agent-loop``agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agentTUI 和无头应用预创建 `main`ACP 应用则在 `session/new` 按需创建 agent`includeHarnessIdentity``persona``toolOrder` 交给 `dsh-system-prompt``tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry``skills.local``skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context``{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain``goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给共享 `bash-env` 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。
例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。

View File

@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks-local": "^0.0.1",
"@deepseek-ai/dsh-bash-env": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
@@ -55,6 +56,7 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",

View File

@@ -29,6 +29,7 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as bashEnv from '@deepseek-ai/dsh-bash-env'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
@@ -234,7 +235,8 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(scopeInvariant)
ctx.plugin(agentLoopInvariant)
if (config.toolBash !== false) {
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
ctx.plugin(bashEnv, { dshHome })
ctx.plugin(toolBash, config.toolBash ?? {})
}
if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext)

View File

@@ -68,6 +68,9 @@
{
"path": "../../util/paths"
},
{
"path": "../../bash/bash-env"
},
{
"path": "../../bash/tool-bash"
},

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/host/directory-picker-native/README.md
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f
README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9
README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
@@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
@@ -17,3 +17,4 @@
## 已知限制与延期工作
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
- **Windows 没有机制级回退**——子进程选择器是唯一层级koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pickCOM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。

View File

@@ -19,12 +19,17 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./worker": {
"types": "./lib/types/win32-dialog-worker.d.ts",
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/worker.cjs",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
@@ -33,7 +38,8 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^"
"@deepseek-ai/dsh-native-command": "workspace:^",
"koffi": "^3.1.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
@@ -50,7 +56,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"tsx": "^4.19.2"
},
"dshClient": {
"inject": [

View File

@@ -1,9 +1,10 @@
/**
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `native` capability, opening one native OS chooser on the host
* display per pick (macOS `osascript`, Windows STA PowerShell
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
* when the operator sits at the host's screen; remote deployments compose the
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
* koffi-driven COM conversation on the child's main thread). Only viable when
* the operator sits at the host's screen; remote deployments compose the
* browse backend instead.
* @module @deepseek-ai/dsh-host-directory-picker-native
*/

View File

@@ -1,6 +1,7 @@
/** Cross-platform native single-directory chooser behind the native backend's capability. */
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import { pickWin32Directory } from './win32-dialog.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = NativeCommandRunner
@@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner
export interface DirectoryPickerInternals {
platform?: NodeJS.Platform
run?: DirectoryPickerRunner
/** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>
}
function outputPath(stdout: string): string | null {
@@ -64,20 +67,13 @@ export async function pickNativeDirectory(
}
if (platform === 'win32') {
const script = [
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Windows.Forms',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
"$dialog.Description = 'Select Workspace Directory'",
'$dialog.ShowNewFolderButton = $true',
'$result = $dialog.ShowDialog()',
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
' [Console]::WriteLine($dialog.SelectedPath)',
'}',
].join('; ')
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
// The koffi-backed IFileOpenDialog child process — the modern picker with
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
// whose availability the install guarantees, so there is no fallback
// tier: any failure surfaces as-is (the former PowerShell chain was
// removed — see the simplification Agent Note).
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
return await pickDialog(signal)
}
if (platform === 'linux') {

View File

@@ -0,0 +1,195 @@
/**
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
* driver uses to service aborts. The module loads on every platform; koffi
* itself is imported lazily inside each function, so non-Windows processes
* never load it — the same containment as the repo's other `win32.ts`
* modules.
*
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
* object's first pointer.
*/
import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts'
interface KoffiFunction { (...args: unknown[]): unknown }
interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction }
interface Koffi {
load(path: string): KoffiLibrary
proto(declaration: string): unknown
pointer(type: unknown): unknown
call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown
decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown
register(fn: (...args: unknown[]) => unknown, type: unknown): unknown
unregister(callback: unknown): void
sizeof(type: string): number
view(ref: unknown, len: number): ArrayBuffer
}
/**
* Read a NUL-terminated UTF-16 string at a native address. koffi's
* `_Out_ void **` out-params surface a raw address, and
* `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash
* on real Windows — so view the memory directly instead.
*/
function readUtf16(koffi: Koffi, address: unknown): string {
const bytes = Buffer.from(koffi.view(address, 32768))
let end = 0
while (end + 1 < bytes.length && bytes[end] !== 0) end += 2
return bytes.toString('utf16le', 0, end)
}
const COINIT_APARTMENTTHREADED = 0x2
const CLSCTX_INPROC_SERVER = 0x1
const SIGDN_FILESYSPATH = 0x80058000 | 0
/**
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
* returns NULL for an unsupported context instead of throwing, so the caller
* cascades to the best one the host accepts; DPI stays a cosmetic
* best-effort — an unsupported host still gets the modern dialog.
*/
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
const WM_CLOSE = 0x10
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
const SLOT_RELEASE = 2
const SLOT_SHOW = 3
const SLOT_SET_OPTIONS = 9
const SLOT_SET_TITLE = 17
const SLOT_GET_RESULT = 20
/** IShellItem vtable slot for `GetDisplayName`. */
const SLOT_GET_DISPLAY_NAME = 5
/**
* Encode a canonical GUID string as its 16 little-endian bytes.
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
* @returns the in-memory GUID bytes CoCreateInstance expects.
*/
function guidBytes(text: string): Buffer {
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray
const bytes = Buffer.alloc(16)
bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0)
bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4)
bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6)
Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8)
return bytes
}
const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7')
const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960')
/**
* Load koffi and expose the dialog bindings for this thread.
* @returns the bindings {@link runFolderDialog} sequences against.
*/
export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
const koffi = (await import('koffi')).default as unknown as Koffi
const ole32 = koffi.load('ole32.dll')
const user32 = koffi.load('user32.dll')
const kernel32 = koffi.load('kernel32.dll')
// Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64,
// 4 on ia32 — koffi reports the running process's width.
const pointerSize = koffi.sizeof('void *')
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32'])
const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', [])
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)')
const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)')
const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)')
const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)')
/** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */
const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => {
const vtable = koffi.decode(self, 'void *')
const fn = koffi.decode(vtable, slot * pointerSize, 'void *')
return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number
}
return {
setThreadDpiAwareness: () => {
let setContext: KoffiFunction
try {
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
} catch {
// Symbol absent (pre-1607 Windows): no per-thread DPI control exists.
// Proceed anyway — the cost is a blurry dialog above 100 % scaling on
// museum hosts, and the modern picker still beats dropping to the
// legacy 5.1 tree over a cosmetic concern.
return
}
for (const context of DPI_AWARENESS_CONTEXTS) {
if (setContext(context) !== null) return
}
// Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol
// exists); if a host ever refuses everything, the dialog still works —
// just without a DPI opt-in.
},
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
coUninitialize: () => {
coUninitialize()
},
currentThreadId: () => getCurrentThreadId() as number,
createFolderDialog: (): Win32FolderDialog => {
const out = Buffer.alloc(pointerSize)
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`)
const dialog = koffi.decode(out, 'void *')
return {
setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
resultPath: () => {
const itemOut: unknown[] = [null]
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut)
if (gotItem < 0) return { hr: gotItem }
const item = itemOut[0]
try {
const nameOut: unknown[] = [null]
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut)
if (gotName < 0) return { hr: gotName }
const path = readUtf16(koffi, nameOut[0])
coTaskMemFree(nameOut[0])
return { hr: gotName, path }
} finally {
method(item, SLOT_RELEASE, protoRelease)()
}
},
release: () => {
method(dialog, SLOT_RELEASE, protoRelease)()
},
}
},
}
}
/**
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
* lever against the worker blocked inside `Show`, after which `Show` returns
* `HRESULT_CANCELLED` and the worker unwinds normally.
* @param threadId - the dialog thread's native id (from the `showing` notice).
*/
export async function closeThreadWindows(threadId: number): Promise<void> {
const koffi = (await import('koffi')).default as unknown as Koffi
const user32 = koffi.load('user32.dll')
const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr'])
const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr'])
const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)')
const callback = koffi.register((hwnd: unknown) => {
postMessageW(hwnd, WM_CLOSE, 0, 0)
return 1
}, koffi.pointer(protoEnumProc))
try {
enumThreadWindows(threadId, callback, 0)
} finally {
koffi.unregister(callback)
}
}

View File

@@ -0,0 +1,33 @@
/**
* Real-process half of the Win32 dialog driver: spawn the dialog child
* process (source or built plane) and close a dialog thread's windows. The
* module itself loads everywhere (the import chain from native-picker.ts is
* static); what stays win32-only is koffi, imported dynamically inside the
* bindings' functions. The driver's logic is tested against fakes of this
* surface instead.
*/
import { spawn, type StdioOptions } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
/**
* Spawn the dialog child process. Built consumers launch the bundled CJS
* entry next to this module under plain node; unbuilt (source) consumers
* bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
* the child's first window, so Windows activates it without a foreground
* call.
* @param data - the child payload (dialog title).
* @returns the spawned child process.
*/
export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn> {
const env = { ...process.env, DSH_DIALOG_TITLE: data.title }
const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc']
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
if (!import.meta.url.endsWith('.ts')) {
return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true })
}
return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true })
}
export { closeThreadWindows } from './win32-dialog-bindings.ts'

View File

@@ -0,0 +1,132 @@
/**
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
* conversation over an injectable bindings seam, so every outcome path
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
* any platform. The koffi-backed bindings live in
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.
*/
/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */
export const HRESULT_CANCELLED = 0x800704c7 | 0
/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */
export const FOS_PICKFOLDERS = 0x20
/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */
export const FOS_FORCEFILESYSTEM = 0x40
/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */
export const FOS_NOCHANGEDIR = 0x8
/** One created folder dialog: the vtable calls the sequencing needs. */
export interface Win32FolderDialog {
/**
* `IFileDialog::SetOptions`.
* @param options - the `FOS_*` flag union to apply.
* @returns the call's HRESULT.
*/
setOptions(options: number): number
/**
* `IFileDialog::SetTitle`.
* @param title - the dialog title text.
* @returns the call's HRESULT.
*/
setTitle(title: string): number
/**
* `IModalWindow::Show` with no owner window; blocks the calling thread
* until the user selects or dismisses.
* @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal).
*/
show(): number
/**
* `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`,
* releasing the shell item and freeing the COM string.
* @returns the call chain's HRESULT and, on success, the selected path.
*/
resultPath(): { hr: number; path?: string }
/** Release the dialog's COM reference. */
release(): void
}
/** The thread-level native surface the dialog sequencing runs against. */
export interface Win32DialogBindings {
/**
* Opt the calling thread into the best supported DPI awareness
* (per-monitor-v2, then per-monitor, then system-aware), checking each
* call's result. Best-effort on purpose: a host accepting none of them
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
* blurry above 100 % scaling — because a cosmetic degradation must not
* cost the tier.
*/
setThreadDpiAwareness(): void
/**
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
*/
coInitializeSta(): number
/**
* `CoUninitialize` on the calling thread — COM requires one pairing call
* for every successful (including `S_FALSE`) `CoInitializeEx`, even on a
* thread that exits right after the conversation.
*/
coUninitialize(): void
/**
* `CoCreateInstance(CLSID_FileOpenDialog)`.
* @returns the created dialog surface; throws when creation fails.
*/
createFolderDialog(): Win32FolderDialog
/**
* `GetCurrentThreadId` — the native id a driver needs to close this
* thread's windows from outside.
* @returns the calling thread's native id.
*/
currentThreadId(): number
}
/**
* Throw when an HRESULT signals failure.
* @param hr - the HRESULT to check.
* @param what - the failing call's name for the error message.
* @returns the (successful) HRESULT unchanged.
*/
function check(hr: number, what: string): number {
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`)
return hr
}
/**
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
* STA init, dialog creation, `Show`, and result extraction, releasing the
* dialog on every path.
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
* @param title - the dialog title text.
* @param onShowing - called with the native thread id immediately before the
* blocking `Show`, so a driver on another thread can close the dialog.
* @returns the selected filesystem path, or null when the user cancels.
*/
export function runFolderDialog(
bindings: Win32DialogBindings,
title: string,
onShowing: (threadId: number) => void,
): string | null {
bindings.setThreadDpiAwareness()
check(bindings.coInitializeSta(), 'CoInitializeEx')
// From here the apartment is initialized (S_OK or S_FALSE) and must be
// uninitialized exactly once on every path.
try {
const dialog = bindings.createFolderDialog()
try {
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
check(dialog.setTitle(title), 'SetTitle')
onShowing(bindings.currentThreadId())
const shown = dialog.show()
if (shown === HRESULT_CANCELLED) return null
check(shown, 'Show')
const result = dialog.resultPath()
check(result.hr, 'GetResult')
return result.path as string
} finally {
dialog.release()
}
} finally {
bindings.coUninitialize()
}
}

View File

@@ -0,0 +1,52 @@
/**
* Child-process entry for the Win32 folder dialog: blocks THIS process
* inside the modal `Show` so the host event loop stays live, reporting over
* the IPC channel. Spawned as a child process (not a worker thread) so the
* dialog is the process's first window and Windows activates it without a
* manual foreground call. Protocol: `{kind:'showing',threadId}` right
* before the blocking call (the driver's abort lever needs the native
* thread id), then exactly one of `{kind:'done',path}` or
* `{kind:'error',message}`.
*/
import { loadWin32DialogBindings } from './win32-dialog-bindings.ts'
import { runFolderDialog } from './win32-dialog-logic.ts'
/** The driver-to-child payload: the dialog title (passed via env). */
export interface Win32DialogWorkerData { title: string }
/** One notice or outcome posted back to the driver. */
export type Win32DialogWorkerMessage =
| { kind: 'showing'; threadId: number }
| { kind: 'done'; path: string | null }
| { kind: 'error'; message: string }
const title = process.env.DSH_DIALOG_TITLE ?? ''
if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required')
if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel')
// node's internal `send` reads `this.connected`, so bind the receiver.
const send = process.send.bind(process)
const post = (message: Win32DialogWorkerMessage): void => {
// Flush before closing the channel; the process exits when the loop drains.
/* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */
send(message, () => { if (process.connected) process.disconnect() })
}
// A settled driver (or a dead parent) must not orphan a dialog still on screen.
/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */
process.on('disconnect', () => process.exit(0))
// No top-level await: the built worker ships as CJS, which cannot carry TLA.
void (async () => {
try {
const bindings = await loadWin32DialogBindings()
const path = runFolderDialog(bindings, title, (threadId) => {
post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage)
})
post({ kind: 'done', path } satisfies Win32DialogWorkerMessage)
} catch (error: unknown) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
post({ kind: 'error', message } satisfies Win32DialogWorkerMessage)
}
})()

View File

@@ -0,0 +1,159 @@
/**
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
* process (which blocks inside the modal `Show`), maps its message protocol
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
* thread's windows until the child reports back. The real process/window
* surface is injectable so every driver path is testable on any platform.
*/
import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts'
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
export interface Win32DialogWorkerLike {
/**
* Subscribe to a child-process event.
* @param event - `message`, `error`, or `exit`.
* @param listener - the event consumer.
*/
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown
on(event: 'error', listener: (error: Error) => void): unknown
on(event: 'exit', listener: (code: number) => void): unknown
/**
* Force-stop the child; the abort path's last resort when `WM_CLOSE`
* never lands (e.g. the dialog window was never created).
* @returns whether a kill signal was delivered.
*/
kill(): boolean
/**
* Release the event-loop reference. Called once the pick settles so a
* child stuck in the native modal call never blocks process exit.
*/
unref?(): void
}
/** Injectable process surface for deterministic driver tests. */
export interface Win32DialogInternals {
/** Replaces the real child spawn (`win32-dialog-host.ts`). */
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
closeThreadWindows?: (threadId: number) => Promise<void>
/** Abort-service cadence override so tests never wait wall-clock time. */
closeRetryMs?: number
}
/** The dialog title every host shows. */
export const DIALOG_TITLE = 'Select Workspace Directory'
/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */
const CLOSE_RETRY_MS = 150
/** Abort-service attempts before force-terminating the worker. */
const CLOSE_MAX_ATTEMPTS = 20
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
function assertNever(value: never): never {
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`)
}
/* v8 ignore stop */
/**
* Open the modern Win32 folder picker off the event loop.
* @param signal - caller lifetime; abort closes the dialog and rejects.
* @param internals - worker/window seams for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickWin32Directory(
signal: AbortSignal,
internals: Win32DialogInternals = {},
): Promise<string | null> {
if (signal.aborted) throw new Error('native directory picker aborted')
const spawnWorker = internals.spawnWorker ?? spawnDialogWorker
const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows
const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS
const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE })
let dialogThreadId: number | undefined
let closeTimer: NodeJS.Timeout | undefined
let settled = false
return await new Promise<string | null>((resolve, reject) => {
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
if (closeTimer !== undefined) clearInterval(closeTimer)
signal.removeEventListener('abort', onAbort)
worker.unref?.()
outcome()
}
const postClose = (): void => {
// Before `showing` there is no window to close; the budget below still
// runs so a child that never reports cannot dangle the pick. A
// rejected close attempt (EnumThreadWindows/PostMessageW refusing) is
// discarded: the interval retries it and kill is the backstop.
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
}
// Sole caller: the once-registered abort listener, so no re-entry guard.
const serviceAbort = (): void => {
let attempts = 0
// The `showing` notice precedes the blocking `Show`, so the very first
// WM_CLOSE can race the window's creation; re-post until the child
// reports back, then force-kill as a last resort. The budget is
// unconditional — an abort before `showing` (child hung in koffi or
// COM init) still ends in kill instead of a dangling promise.
closeTimer = setInterval(() => {
attempts += 1
if (attempts > CLOSE_MAX_ATTEMPTS) {
settle(() => {
worker.kill()
reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)'))
})
return
}
postClose()
}, closeRetryMs)
postClose()
}
const onAbort = (): void => {
serviceAbort()
}
signal.addEventListener('abort', onAbort, { once: true })
worker.on('message', (message: Win32DialogWorkerMessage) => {
switch (message.kind) {
case 'showing':
dialogThreadId = message.threadId
// An abort that raced ahead of this notice now has a window to hit.
if (signal.aborted) postClose()
return
case 'done':
settle(() => {
if (signal.aborted) reject(new Error('native directory picker aborted'))
else resolve(message.path)
})
return
case 'error':
settle(() => {
reject(new Error(`win32 folder dialog failed: ${message.message}`))
})
return
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
default:
assertNever(message)
}
})
worker.on('error', (error: Error) => {
settle(() => {
reject(error)
})
})
worker.on('exit', () => {
settle(() => {
reject(new Error('win32 folder dialog worker exited before reporting a result'))
})
})
})
}

View File

@@ -0,0 +1,34 @@
/**
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
* shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its
* real koffi requires. POSIX hosts prove the load path end to end through
* the deterministic ole32 rejection; win32 skips (a real dialog would
* open), where the win32-only smoke in win32-dialog.spec.ts covers the
* source plane instead. Skips until a build produces the artifact.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url))
describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => {
it('loads under plain node and reports the native-surface failure', async () => {
const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => {
const child = spawn(process.execPath, [builtWorker], {
env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' },
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
})
child.on('message', resolve)
child.on('error', reject)
child.on('exit', (code) => {
reject(new Error(`worker exited (${code}) before reporting`))
})
})
expect(message.kind).toBe('error')
expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i)
}, 30_000)
})

View File

@@ -1,3 +1,9 @@
/**
* Native picker tier selection and the execFile adapter: the Win32 dialog
* primary (failures surface as-is, no fallback tier), the abort rule, and
* the POSIX command tiers (osascript, Zenity → KDialog).
*/
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
@@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error {
const signal = () => new AbortController().signal
/** A Win32 dialog that always fails — the no-fallback case. */
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
describe('native directory picker', () => {
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
@@ -46,46 +55,79 @@ describe('native directory picker', () => {
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
expect.any(AbortSignal),
)
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
it('uses the Win32 dialog and never spawns a command when it answers', async () => {
const run = vi.fn<DirectoryPickerRunner>()
const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
pickWin32Dialog.mockResolvedValueOnce(null)
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull()
expect(run).not.toHaveBeenCalled()
})
it('surfaces the Win32 dialog failure with no fallback', async () => {
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
.rejects.toThrow('dialog unavailable')
expect(run).not.toHaveBeenCalled()
})
it('wires the real Win32 dialog as the default tier', async () => {
// A pre-aborted signal makes the DEFAULT dialog deterministic on every
// host: pickWin32Directory throws before spawning any worker or window.
const abort = new AbortController()
abort.abort()
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run }))
.rejects.toThrow('native directory picker aborted')
expect(run).not.toHaveBeenCalled()
})
it('does not fall back when the caller aborted the dialog', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
expect(run).not.toHaveBeenCalled()
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
callback(null, '/home/test/project\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('powershell.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
expect(command).toBe('zenity')
expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
// A non-cancellation command failure surfaces as-is with its cause and
// captured stdio attached; no tier masks or rewraps it.
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
message: 'powershell failed', cause: commandError, code: 7,
const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error)
expect(surfaced).toMatchObject({
message: 'zenity failed', code: 7,
stdout: 'partial output', stderr: 'failure details',
})
expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
})
it('uses the current process platform when no platform override is supplied', async () => {
// Deterministic on every host: the win32 tier answers from the dialog,
// the POSIX tiers from the command runner.
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
const pickWin32Dialog = async (): Promise<string | null> => 'C:\\default\\platform'
const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform'
await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected)
})
it('maps empty command output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull()
})
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {

View File

@@ -0,0 +1,354 @@
/**
* The koffi-backed bindings against a mocked `koffi` module (the same
* technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory
* COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch,
* result extraction, memory hygiene, and the WM_CLOSE poster covered on every
* host. The worker entry is exercised the same way with a mocked process
* boundary (env title + `process.send`). Real-COM behavior is pinned by the
* win32-only smoke in win32-dialog.spec.ts.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts'
const E_FAIL = 0x80004005 | 0
const WM_CLOSE = 0x10
/**
* Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer
* sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against
* this width (the win32-ia32 bug class).
*/
const FAKE_POINTER_SIZE = 4
interface ComWorld {
coInitHr: number
coCreateHr: number
showHr: number
getResultHr: number
getDisplayNameHr: number
hasThreadDpi: boolean
/** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */
supportedDpiContexts: number[]
enumThrows: boolean
path: string
titles: string[]
options: number[]
dpiContexts: unknown[]
freed: unknown[]
released: string[]
posted: { hwnd: unknown; message: number }[]
registered: number
unregistered: number
uninitialized: number
}
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
return {
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false,
path: 'C:\\选中\\directory',
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
registered: 0, unregistered: 0, uninitialized: 0,
...overrides,
}
}
/** Sentinel pointer objects standing in for native addresses. */
interface FakePtr { kind: string; [key: string]: unknown }
function installFakeKoffi(world: ComWorld): void {
const dialogPtr: FakePtr = { kind: 'dialog' }
const itemPtr: FakePtr = { kind: 'item' }
const namePtr: FakePtr = { kind: 'name', text: world.path }
const outBuffers = new Map<unknown, FakePtr>()
const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => {
if (self.kind === 'dialog') {
switch (slot) {
case 9: world.options.push(args[0] as number); return 0
case 17: world.titles.push(args[0] as string); return 0
case 3: return world.showHr
case 20: {
if (world.getResultHr < 0) return world.getResultHr
;(args[0] as unknown[])[0] = itemPtr
return 0
}
case 2: world.released.push('dialog'); return 0
default: throw new Error(`unexpected dialog slot ${slot}`)
}
}
switch (slot) {
case 5: {
if (world.getDisplayNameHr < 0) return world.getDisplayNameHr
;(args[1] as unknown[])[0] = namePtr
return 0
}
case 2: world.released.push('item'); return 0
default: throw new Error(`unexpected item slot ${slot}`)
}
}
vi.doMock('koffi', () => ({
default: {
load: (dll: string) => ({
func: (_convention: string, name: string, _result: string, _args: string[]) => {
switch (name) {
case 'CoInitializeEx': return () => world.coInitHr
case 'CoUninitialize': return () => { world.uninitialized += 1 }
case 'CoCreateInstance': return (...args: unknown[]) => {
if (world.coCreateHr < 0) return world.coCreateHr
// The out-pointer must be allocated at the fake's pointer width.
if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) {
throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`)
}
outBuffers.set(args[4], dialogPtr)
return 0
}
case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
case 'GetCurrentThreadId': return () => 31337
case 'SetThreadDpiAwarenessContext': {
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
return (context: unknown) => {
world.dpiContexts.push(context)
return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null
}
}
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
callback.fn({ kind: 'hwnd', n: 1 }, lparam)
callback.fn({ kind: 'hwnd', n: 2 }, lparam)
return 1
}
case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 }
default: throw new Error(`unexpected native import ${dll}/${name}`)
}
},
}),
proto: (declaration: string) => ({ declaration }),
pointer: (type: unknown) => type,
sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE },
view: (value: unknown, len: number): ArrayBuffer => {
const bytes = Buffer.alloc(len)
bytes.write((value as FakePtr).text as string, 'utf16le')
return bytes.buffer
},
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
unregister: () => { world.unregistered += 1 },
decode: (value: unknown, offsetOrType: unknown): unknown => {
if (offsetOrType === 'str16') return (value as FakePtr).text
if (typeof offsetOrType === 'number') {
// Vtable slot read: offsets must be multiples of the fake width.
if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`)
const owner = (value as { owner: FakePtr }).owner
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) }
}
// decode(x, 'void *'): out-buffer read or vtable read.
if (outBuffers.has(value)) return outBuffers.get(value)
return { owner: value as FakePtr }
},
call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args),
},
}))
}
async function loadBindingsModule(): Promise<typeof import('../src/win32-dialog-bindings.ts')> {
return await import('../src/win32-dialog-bindings.ts')
}
afterEach(() => {
vi.doUnmock('koffi')
vi.doUnmock('node:worker_threads')
vi.doUnmock('../src/win32-dialog-bindings.ts')
vi.resetModules()
})
describe('loadWin32DialogBindings over the fake COM world', () => {
it('drives the full selection conversation with memory hygiene', async () => {
const world = comWorld()
installFakeKoffi(world)
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
const showing = vi.fn()
expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory')
expect(world.dpiContexts).toEqual([-4])
expect(world.titles).toEqual(['选择工作区目录'])
expect(world.options).toHaveLength(1)
expect(showing).toHaveBeenCalledWith(31337)
expect(world.freed).toHaveLength(1)
expect(world.released).toEqual(['item', 'dialog'])
expect(world.uninitialized).toBe(1)
})
it('maps dismissal and the S_FALSE CoInitializeEx', async () => {
const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 })
installFakeKoffi(world)
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(world.released).toEqual(['dialog'])
expect(world.uninitialized).toBe(1)
})
it('cascades DPI contexts to the first the host accepts', async () => {
const world = comWorld({ supportedDpiContexts: [-3] })
installFakeKoffi(world)
const bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(world.dpiContexts).toEqual([-4, -3])
})
it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => {
// DPI is a cosmetic best-effort: the modern dialog still opens.
const rejecting = comWorld({ supportedDpiContexts: [] })
installFakeKoffi(rejecting)
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(rejecting.dpiContexts).toEqual([-4, -3, -2])
vi.doUnmock('koffi')
vi.resetModules()
const preThreadDpi = comWorld({ hasThreadDpi: false })
installFakeKoffi(preThreadDpi)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(preThreadDpi.dpiContexts).toEqual([])
})
it('surfaces creation and extraction failures as HRESULT errors', async () => {
const creationWorld = comWorld({ coCreateHr: E_FAIL })
installFakeKoffi(creationWorld)
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005')
vi.doUnmock('koffi')
vi.resetModules()
const resultWorld = comWorld({ getResultHr: E_FAIL })
installFakeKoffi(resultWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
expect(resultWorld.released).toEqual(['dialog'])
vi.doUnmock('koffi')
vi.resetModules()
const nameWorld = comWorld({ getDisplayNameHr: E_FAIL })
installFakeKoffi(nameWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
// The shell item is released even when its display name cannot be read.
expect(nameWorld.released).toEqual(['item', 'dialog'])
expect(nameWorld.freed).toHaveLength(0)
})
})
describe('closeThreadWindows over the fake COM world', () => {
it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => {
const world = comWorld()
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await closeThreadWindows(777)
expect(world.posted).toEqual([
{ hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE },
{ hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE },
])
expect(world.registered).toBe(1)
expect(world.unregistered).toBe(1)
})
it('unregisters the callback even when the enumeration itself throws', async () => {
const world = comWorld({ enumThrows: true })
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused')
expect(world.unregistered).toBe(1)
})
})
describe('the worker entry over a mocked process boundary', () => {
const originalSend = process.send?.bind(process)
const originalTitle = process.env.DSH_DIALOG_TITLE
const installBoundary = (): { posted: { kind: string; message?: string }[] } => {
const posted: { kind: string; message?: string }[] = []
process.env.DSH_DIALOG_TITLE = 'Pick'
// Never invoke the post callback: it runs the worker's disconnect(), and
// this process is IPC-connected under the forks pool — severing vitest's
// own channel would kill the test worker. The real close lifecycle
// belongs to built-worker.e2e.ts.
;(process as { send?: unknown }).send = (message: { kind: string }) => {
posted.push(message)
return true
}
return { posted }
}
afterEach(() => {
delete (process as { send?: unknown }).send
if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend
if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE
else process.env.DSH_DIALOG_TITLE = originalTitle
vi.doUnmock('../src/win32-dialog-bindings.ts')
vi.resetModules()
})
it('posts showing then done for a completed conversation', async () => {
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => ({
setThreadDpiAwareness: () => undefined,
coInitializeSta: () => 0,
coUninitialize: () => undefined,
currentThreadId: () => 11,
createFolderDialog: () => ({
setOptions: () => 0,
setTitle: () => 0,
show: () => 0,
resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }),
release: () => undefined,
}),
}),
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toEqual([
{ kind: 'showing', threadId: 11 },
{ kind: 'done', path: 'C:\\from-worker' },
])
})
it('posts the failure message when the native surface cannot load', async () => {
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw new Error('no ole32 here') },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toHaveLength(1)
expect(posted[0]?.kind).toBe('error')
expect(posted[0]?.message).toContain('no ole32 here')
})
it('stringifies stackless and non-Error failures', async () => {
const stackless = new Error('bare message')
delete stackless.stack
for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) {
vi.resetModules()
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw thrown },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted[0]?.message).toBe(expected)
}
})
it('refuses to run without the dialog title', async () => {
delete process.env.DSH_DIALOG_TITLE
;(process as { send?: unknown }).send = () => true
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required')
})
it('refuses to run outside a child process', async () => {
process.env.DSH_DIALOG_TITLE = 'Pick'
delete (process as { send?: unknown }).send
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process')
})
})

View File

@@ -0,0 +1,98 @@
/**
* The COM conversation's sequencing against fake bindings: outcome mapping
* (selection / cancellation / HRESULT failures at every step) and the
* release-on-every-path guarantee, all platform-independent.
*/
import { describe, expect, it, vi } from 'vitest'
import {
FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED,
runFolderDialog, type Win32DialogBindings, type Win32FolderDialog,
} from '../src/win32-dialog-logic.ts'
const E_FAIL = 0x80004005 | 0
interface FakeWorld {
bindings: Win32DialogBindings
dpi: ReturnType<typeof vi.fn>
createDialog: ReturnType<typeof vi.fn>
uninitialize: ReturnType<typeof vi.fn>
dialog: {
setOptions: ReturnType<typeof vi.fn>
setTitle: ReturnType<typeof vi.fn>
show: ReturnType<typeof vi.fn>
resultPath: ReturnType<typeof vi.fn>
release: ReturnType<typeof vi.fn>
}
}
function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorld {
const dialog = {
setOptions: vi.fn(() => 0),
setTitle: vi.fn(() => 0),
show: vi.fn(() => 0),
resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })),
release: vi.fn(),
...overrides,
}
const dpi = vi.fn()
const createDialog = vi.fn(() => dialog)
const uninitialize = vi.fn()
const bindings: Win32DialogBindings = {
setThreadDpiAwareness: dpi,
coInitializeSta: vi.fn(() => coInit),
coUninitialize: uninitialize,
createFolderDialog: createDialog,
currentThreadId: vi.fn(() => 4242),
}
return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
}
describe('runFolderDialog', () => {
it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => {
const { bindings, dpi, dialog, uninitialize } = world()
const showing = vi.fn()
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
expect(dpi).toHaveBeenCalledOnce()
expect(uninitialize).toHaveBeenCalledOnce()
expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number)
expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR)
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
expect(showing).toHaveBeenCalledWith(4242)
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
expect(dialog.release).toHaveBeenCalledOnce()
})
it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => {
const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(dialog.resultPath).not.toHaveBeenCalled()
expect(dialog.release).toHaveBeenCalledOnce()
expect(uninitialize).toHaveBeenCalledOnce()
})
it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => {
const { bindings } = world({}, 1)
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录')
})
it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => {
const { bindings, createDialog, uninitialize } = world({}, E_FAIL)
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005')
expect(createDialog).not.toHaveBeenCalled()
// A failed CoInitializeEx must NOT be paired with CoUninitialize.
expect(uninitialize).not.toHaveBeenCalled()
})
it.each([
['SetOptions', { setOptions: vi.fn(() => E_FAIL) }],
['SetTitle', { setTitle: vi.fn(() => E_FAIL) }],
['Show', { show: vi.fn(() => E_FAIL) }],
['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }],
] satisfies [string, Partial<Win32FolderDialog>][])('releases the dialog and apartment when %s fails', (what, overrides) => {
const { bindings, dialog, uninitialize } = world(overrides)
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`)
expect(dialog.release).toHaveBeenCalledOnce()
expect(uninitialize).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,163 @@
/**
* Driver tests: the child-process message protocol mapped onto the promise,
* the WM_CLOSE abort service (including the show-race retry and the kill
* last resort) against fakes, plus the real spawn plumbing — POSIX hosts
* prove the default path rejects cleanly (koffi cannot load ole32 there),
* and win32 hosts briefly open and auto-abort a real dialog.
*/
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts'
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
class FakeWorker extends EventEmitter implements Win32DialogWorkerLike {
kill = vi.fn(() => true)
post(message: Win32DialogWorkerMessage): void {
this.emit('message', message)
}
}
interface Harness {
worker: FakeWorker
internals: Win32DialogInternals
close: ReturnType<typeof vi.fn>
}
function harness(overrides: Partial<Win32DialogInternals> = {}): Harness {
const worker = new FakeWorker()
const close = vi.fn(async () => undefined)
return {
worker,
close,
internals: {
spawnWorker: () => worker,
closeThreadWindows: close,
closeRetryMs: 1,
...overrides,
},
}
}
const live = (): AbortSignal => new AbortController().signal
describe('pickWin32Directory', () => {
it('resolves the selected path and the cancellation null', async () => {
const first = harness()
const picked = pickWin32Directory(live(), first.internals)
first.worker.post({ kind: 'showing', threadId: 7 })
first.worker.post({ kind: 'done', path: 'C:\\picked' })
await expect(picked).resolves.toBe('C:\\picked')
expect(first.close).not.toHaveBeenCalled()
const second = harness()
const cancelled = pickWin32Directory(live(), second.internals)
second.worker.post({ kind: 'done', path: null })
await expect(cancelled).resolves.toBeNull()
})
it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => {
const reported = harness()
const failing = pickWin32Directory(live(), reported.internals)
reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' })
await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed')
const crashed = harness()
const crashing = pickWin32Directory(live(), crashed.internals)
crashed.worker.emit('error', new Error('worker blew up'))
await expect(crashing).rejects.toThrow('worker blew up')
const silent = harness()
const exiting = pickWin32Directory(live(), silent.internals)
silent.worker.emit('exit', 0)
await expect(exiting).rejects.toThrow('exited before reporting a result')
})
it('settles once: a late exit after the result is inert', async () => {
const { worker, internals } = harness()
const picked = pickWin32Directory(live(), internals)
worker.post({ kind: 'done', path: 'C:\\once' })
worker.emit('exit', 0)
await expect(picked).resolves.toBe('C:\\once')
})
it('throws immediately on an already-aborted signal without spawning', async () => {
const spawnWorker = vi.fn()
const controller = new AbortController()
controller.abort()
await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined }))
.rejects.toThrow('native directory picker aborted')
expect(spawnWorker).not.toHaveBeenCalled()
})
it('services an abort by closing the dialog thread windows until the worker reports', async () => {
const { worker, internals, close } = harness()
const controller = new AbortController()
// Attach the expectation BEFORE driving the race: on a fast host the
// close budget can exhaust (and reject) between waitFor ticks, and a
// rejection with no listener yet would count as unhandled.
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
worker.post({ kind: 'showing', threadId: 99 })
controller.abort()
await vi.waitFor(() => {
expect(close).toHaveBeenCalledWith(99)
})
worker.post({ kind: 'done', path: null })
await picked
})
it('starts the close service on the showing notice when the abort came first', async () => {
const closeFailures = vi.fn(async () => { throw new Error('window not there yet') })
const { worker, internals } = harness({ closeThreadWindows: closeFailures })
const controller = new AbortController()
// Attached before the race for the same unhandled-rejection reason above.
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
controller.abort()
expect(closeFailures).not.toHaveBeenCalled()
worker.post({ kind: 'showing', threadId: 12 })
await vi.waitFor(() => {
expect(closeFailures.mock.calls.length).toBeGreaterThan(1)
})
worker.post({ kind: 'done', path: null })
await picked
})
it('kills a worker that never reports showing after an abort', async () => {
// The budget runs without a thread id (nothing to WM_CLOSE yet), so a
// worker hung before `showing` cannot dangle the pick.
const { worker, internals, close } = harness()
const controller = new AbortController()
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed')
controller.abort()
await picked
expect(worker.kill).toHaveBeenCalledOnce()
expect(close).not.toHaveBeenCalled()
})
it('kills an unresponsive worker after the close budget', async () => {
const { worker, internals, close } = harness()
const controller = new AbortController()
const picked = pickWin32Directory(controller.signal, internals)
worker.post({ kind: 'showing', threadId: 5 })
controller.abort()
await expect(picked).rejects.toThrow('dialog unresponsive; worker killed')
expect(worker.kill).toHaveBeenCalledOnce()
expect(close.mock.calls.length).toBeGreaterThan(10)
})
// POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped
// worker spawns, loads koffi, fails to load ole32.dll, and reports the error.
it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => {
await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed')
}, 30_000)
// win32 hosts run the true COM smoke instead: a real dialog opens briefly
// and the abort service closes it (the same lever a disconnecting client pulls).
it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => {
const controller = new AbortController()
setTimeout(() => {
controller.abort()
}, 400)
await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted')
}, 30_000)
})

View File

@@ -1,3 +1,20 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])
// The Win32 dialog worker builds as its own CJS entry (mirroring
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
// the dialog logic while koffi stays an external native require.
export default [
...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']),
{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'] as ['cjs'],
platform: 'node' as const,
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
]

View File

@@ -34,6 +34,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
required: true,
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
{ kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' },
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
],
options: [

View File

@@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -29,6 +30,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService)

View File

@@ -45,7 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
* harness identity never leaks implicitly (a deliberately forwarded
* credential or current `DSH_*` fact goes through the spec's explicit `env`,
* which merges after this scrub). Exported as a plain function so spawners
* which merges after this scrub). Both scrubs match case-insensitively:
* Windows environment names are case-insensitive, so a parent `dsh_*` entry
* would otherwise survive and read back as `$env:DSH_*` in the child;
* deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners
* that cannot route through the service (node-pty backends, SDK-managed
* transports) share the one scrub definition.
* @returns a fresh environment object safe to hand to a child spawn.
@@ -53,7 +56,7 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
export function scrubbedParentEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
}
return env
}

View File

@@ -52,20 +52,23 @@ describe('SubprocessService seam', () => {
await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/)
})
it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => {
it('scrubbedParentEnv drops credential-shaped and DSH_ names (case-insensitively) but keeps PATH', () => {
process.env.DSH_SCRUB_PROBE = 'stale'
process.env.dsh_scrub_probe_lower = 'stale'
process.env.SCRUB_PROBE_TOKEN = 'secret'
process.env.SCRUB_PROBE_PASSWORD = 'secret'
process.env.SCRUB_PROBE_PLAIN = 'visible'
try {
const env = scrubbedParentEnv()
expect(env.DSH_SCRUB_PROBE).toBeUndefined()
expect(env.dsh_scrub_probe_lower).toBeUndefined()
expect(env.SCRUB_PROBE_TOKEN).toBeUndefined()
expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined()
expect(env.SCRUB_PROBE_PLAIN).toBe('visible')
expect(env.PATH).toBeDefined()
} finally {
delete process.env.DSH_SCRUB_PROBE
delete process.env.dsh_scrub_probe_lower
delete process.env.SCRUB_PROBE_TOKEN
delete process.env.SCRUB_PROBE_PASSWORD
delete process.env.SCRUB_PROBE_PLAIN

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/support/acp-snapshot/README.md
README.md: e7988733827ef1d4de67d6d49764a4836e33d17c
README.zh.md: e2466feb5e2025cb99f252b4206bfacb711bccda
README.md: c8fe6907848a7b661c4bfb60c871f077753fda8b
README.zh.md: 0aa28f4b2c3df1a30e5fd33a80401712f81d6614

View File

@@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove
A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes.
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent.
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).

View File

@@ -55,7 +55,7 @@ defineAcpSnapshotSuite({
每个 pin 默认拥有其生成的 `system-prompt.expected.md``tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource``toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。
每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。
每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。

View File

@@ -161,25 +161,37 @@ export interface Scenario {
* test is skipped on Windows; its fixtures stay guarded on every platform.
*/
posixOnly?: boolean
/**
* Whether the scenario boots a composition that needs a usable `pwsh`
* (the pwsh-tool-turn scenario). The run test is skipped when the suite's
* {@link SnapshotSuiteOptions.hasPwsh} probe is false; fixtures stay guarded
* on every platform.
*/
pwshOnly?: boolean
}
/**
* Whether a scenario's run test is skipped for this mode and host: record mode
* skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly}
* scenarios skip on Windows.
* skips authored (non-`recorded`) scenarios, {@link Scenario.posixOnly}
* scenarios skip on Windows, and {@link Scenario.pwshOnly} scenarios skip
* when the caller's `hasPwsh` probe is false.
*
* @param scenario The scenario whose run test is being registered.
* @param recording Whether the suite runs in record mode.
* @param platform The running Node platform, injectable for unit coverage.
* @param hasPwsh The caller's pwsh-availability probe; `pwshOnly` scenarios
* skip unless it is true.
* @returns True when the scenario's run test must not execute.
*/
export function scenarioSkipped(
scenario: Scenario,
recording: boolean,
platform: NodeJS.Platform = process.platform,
hasPwsh?: boolean,
): boolean {
if (recording && !scenario.recorded) return true
return scenario.posixOnly === true && platform === 'win32'
if (scenario.posixOnly === true && platform === 'win32') return true
return scenario.pwshOnly === true && hasPwsh !== true
}
/** One stdout expected output selected for a platform run. */
@@ -220,6 +232,11 @@ export interface SnapshotSuiteOptions {
* from `$DSH_SNAPSHOT` — env reading stays outside this library.
*/
mode: 'replay' | 'record' | 'refresh'
/**
* Whether a real `pwsh` executable is available on this host (the probe the
* caller owns; `pwshOnly` scenarios skip when this is not true).
*/
hasPwsh?: boolean
}
/** One scenario's generated claim on a shared snapshot file. */
@@ -973,8 +990,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
scenarioSuite('snapshot scenarios', () => {
for (const scenario of scenarios) {
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones
// (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows.
it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
// (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows;
// `pwshOnly` scenarios skip when the caller's `hasPwsh` probe is false.
it.skipIf(scenarioSkipped(scenario, RECORDING, process.platform, options.hasPwsh))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
const dir = join(snapshotsDir, scenario.name)
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
const overrideFile = join(dir, 'replay.override.json')

View File

@@ -459,6 +459,7 @@ describe('stdoutExpectedVariants', () => {
describe('scenarioSkipped', () => {
const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false }
const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true }
const pwsh: Scenario = { name: 'pwsh-tool', hasModelTurn: true, recorded: false, pwshOnly: true }
it('skips authored scenarios only while recording', () => {
expect(scenarioSkipped(authored, true, 'linux')).toBe(true)
@@ -471,6 +472,13 @@ describe('scenarioSkipped', () => {
expect(scenarioSkipped(posix, false, 'darwin')).toBe(false)
expect(scenarioSkipped(authored, false, 'win32')).toBe(false)
})
it('skips pwshOnly scenarios when the host lacks pwsh, and runs them otherwise', () => {
expect(scenarioSkipped(pwsh, false, 'linux', false)).toBe(true)
expect(scenarioSkipped(pwsh, false, 'win32', true)).toBe(false)
expect(scenarioSkipped(pwsh, false, 'linux', true)).toBe(false)
expect(scenarioSkipped(authored, false, 'linux', false)).toBe(false)
})
})
describe('fixtureContext', () => {