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:
@@ -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
|
||||
|
||||
@@ -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/)).
|
||||
|
||||
@@ -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/))。
|
||||
|
||||
6
packages/bash/bash-env/README.i18n.yaml
Normal file
6
packages/bash/bash-env/README.i18n.yaml
Normal 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
|
||||
51
packages/bash/bash-env/README.md
Normal file
51
packages/bash/bash-env/README.md
Normal 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.
|
||||
51
packages/bash/bash-env/README.zh.md
Normal file
51
packages/bash/bash-env/README.zh.md
Normal 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()` 当作完整的环境目录。
|
||||
50
packages/bash/bash-env/package.json
Normal file
50
packages/bash/bash-env/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
217
packages/bash/bash-env/src/index.ts
Normal file
217
packages/bash/bash-env/src/index.ts
Normal 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 } : {}
|
||||
},
|
||||
})
|
||||
}
|
||||
30
packages/bash/bash-env/src/invariant.ts
Normal file
30
packages/bash/bash-env/src/invariant.ts
Normal 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 */
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
36
packages/bash/bash-env/tsconfig.json
Normal file
36
packages/bash/bash-env/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/bash/pwsh-local/README.i18n.yaml
Normal file
6
packages/bash/pwsh-local/README.i18n.yaml
Normal 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
|
||||
56
packages/bash/pwsh-local/README.md
Normal file
56
packages/bash/pwsh-local/README.md
Normal 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.
|
||||
56
packages/bash/pwsh-local/README.zh.md
Normal file
56
packages/bash/pwsh-local/README.zh.md
Normal 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 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;具名消费方拥有请求前缀的任何变更。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。
|
||||
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
|
||||
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
|
||||
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
|
||||
- **编码 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) 持有,它拥有这些机制。
|
||||
47
packages/bash/pwsh-local/package.json
Normal file
47
packages/bash/pwsh-local/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
288
packages/bash/pwsh-local/src/index.ts
Normal file
288
packages/bash/pwsh-local/src/index.ts
Normal 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
|
||||
30
packages/bash/pwsh-local/src/invariant.ts
Normal file
30
packages/bash/pwsh-local/src/invariant.ts
Normal 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 */
|
||||
60
packages/bash/pwsh-local/src/resolve.ts
Normal file
60
packages/bash/pwsh-local/src/resolve.ts
Normal 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'
|
||||
}
|
||||
454
packages/bash/pwsh-local/tests/executor.spec.ts
Normal file
454
packages/bash/pwsh-local/tests/executor.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
36
packages/bash/pwsh-local/tsconfig.json
Normal file
36
packages/bash/pwsh-local/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 影响
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal 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
|
||||
125
packages/bash/tool-pwsh/README.md
Normal file
125
packages/bash/tool-pwsh/README.md
Normal 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.
|
||||
125
packages/bash/tool-pwsh/README.zh.md
Normal file
125
packages/bash/tool-pwsh/README.zh.md
Normal 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 section(order 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/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
59
packages/bash/tool-pwsh/package.json
Normal file
59
packages/bash/tool-pwsh/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/bash/tool-pwsh/src/background.ts
Normal file
31
packages/bash/tool-pwsh/src/background.ts
Normal 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 */
|
||||
306
packages/bash/tool-pwsh/src/index.ts
Normal file
306
packages/bash/tool-pwsh/src/index.ts
Normal 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\`\`\`` }] }
|
||||
},
|
||||
}))
|
||||
}
|
||||
30
packages/bash/tool-pwsh/src/invariant.ts
Normal file
30
packages/bash/tool-pwsh/src/invariant.ts
Normal 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 */
|
||||
81
packages/bash/tool-pwsh/src/render.ts
Normal file
81
packages/bash/tool-pwsh/src/render.ts
Normal 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 */
|
||||
154
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal file
154
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal file
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal 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)
|
||||
})
|
||||
637
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal file
637
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
45
packages/bash/tool-pwsh/tsconfig.json
Normal file
45
packages/bash/tool-pwsh/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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[]',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `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` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `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)。
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal file
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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) 中。
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user