refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

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

19
packages/shell/README.md Normal file
View File

@@ -0,0 +1,19 @@
# shell/ — bash capability family
English | [中文](README.zh.md)
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All are **product** packages.
| Package | Role | ctx key |
|---|---|---|
| [`shell/`](shell/README.md) | Defines the executor contract shared by Service providers and Consumers. | `ctx.shell` |
| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.shell`) |
| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.shell`) |
| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.shell`) |
| [`shell-env/`](shell-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.shellEnv` |
| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-job integration to the model. | (registers on `ctx.tools`) |
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/shell.md](../../docs/subsystems/shell.md).

View File

@@ -0,0 +1,19 @@
# shell/ — bash 能力家族
[English](README.md) | 中文
该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
| [`shell/`](shell/README.md) | 定义 Service provider 与 Consumer 共享的执行器约定。 | `ctx.shell` |
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.shell` |
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.shell` |
| [`pwsh-local/`](pwsh-local/README.md) | 采用 Windows 特有的进程行为执行 PowerShell 命令。 | (注册 `ctx.shell` |
| [`shell-env/`](shell-env/README.md) | 提供 shell 工具共享的托管 `DSH_*` 环境。 | `ctx.shellEnv` |
| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools` |
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools` |
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACPAgent Client Protocol示例](../../examples/acp-agent/)展示一套完整接线。
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/shell.md](../../docs/subsystems/shell.md)。

View File

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

View File

@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-bash-local
English | [中文](README.zh.md)
Local Service provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — 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 package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
## Config
```yaml
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
cwd: /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
```
## Behavior
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
- **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`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.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-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `ShellProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Job ids, ownership, polling, and notices belong to the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with.
## Model Experience
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported.
- **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.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

View File

@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-bash-local
[English](README.md) | 中文
`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service provider构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess``bash -c <command>` 作为受管进程组 spawn并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose资源释放等进程组机制则由 subprocess 服务负责。
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`
## 配置
```yaml
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
cwd: /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
```
## 行为
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.md),工具层会在其中注册该句柄。
## 模型体验
通过 `dsh-tool-bash` 间接影响;该工具会渲染此执行器有界的 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
- **仅支持 POSIX**`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
- **后台 spawn 失败提示只交付一次**subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-bash-local",
"description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/bash-local"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -0,0 +1,333 @@
/**
* Local Service provider for the bash capability seam over the subprocess
* capability seam. Public commands run as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
* explicit argv. This executor owns command defaulting, deadlines and cause
* classification, the model-friendly terminal environment, and the model-facing
* stdout/stderr merge for background reads. Execution policy belongs in
* `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
* Model-friendly environment overrides: disable colors, pagers, and
* interactive terminal features that would garble tool output (the same set
* Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
* merged first into the spawn's explicit env, so a trusted caller's own entry
* still wins; the subprocess service applies its credential scrub independently.
*/
export const ENV_OVERRIDES = {
NO_COLOR: '1',
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
} as const
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
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 inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
}
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/** 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(`bash-local: ${name} must be a positive finite number`)
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceableBashConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
* mechanics; this executor supplies their configured budgets per spawn, so a
* still-running background process stays managed (killed and joined at
* composition teardown) even across an executor reload.
*/
export class LocalBashExecutor extends ShellExecutor {
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),
})
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** Validated config (schemastery applied the defaults before construction). */
get config(): ResolvedConfig {
return this.source()
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
const entry = config as ResolvedConfig
assertServiceableBashConfig(entry)
this.source = () => entry
installSettingsSection(ctx, SHELL_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
validate: assertServiceableBashConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Every field is read through the getter at each command, so nothing
// derived from the source needs rebuilding when the document changes.
onChange: () => {},
})
}
/**
* 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`. The tool layer calls
* this before {@link run}/{@link start}, so those methods receive explicit
* values and never re-default.
*/
resolve(request: ShellExecRequest): ShellExecSpec {
const timeoutMs = clampTimeout(
request.timeoutMs,
this.config.timeoutMs,
this.config.maxTimeoutMs,
'bash-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 } : {},
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
// no config default. The subprocess service owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
// Carry a sandbox policy through verbatim: this executor never
// confines, so the field is inert here (the seam contract) — a
// sandboxing subclass overrides resolve() to stamp its default instead.
sandboxPolicy: request.sandboxPolicy,
}
}
/** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
private spawnSpec(
spec: ShellExecSpec,
argv: readonly string[],
stdoutMaxBytes: number,
signal: AbortSignal | undefined,
): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv,
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,
// One explicit env map for the seam, layered so the trusted dshEnv
// snapshot beats both the caller's env and the terminal overrides; the
// subprocess service merges the whole map after its ambient scrub.
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('bash-local: subprocess implementation dropped a requested collect stream')
}
/* v8 ignore stop */
return { stdout, stderr }
}
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
return this.runArgv(spec, ['bash', '-c', spec.command])
}
/**
* Run an explicit argv with the foreground lifecycle, environment, output,
* timeout, and cancellation semantics of this executor. Subclasses use this
* after replacing the public command's shell argv at an execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the settled foreground result with collected output and cause facts.
*/
protected async runArgv(spec: ShellExecSpec, argv: readonly string[]): Promise<ShellRunResult> {
// 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, argv, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.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: ShellExecSpec): ShellProcess {
return this.startArgv(spec, ['bash', '-c', spec.command])
}
/**
* Start an explicit argv with the background lifecycle, environment, output,
* cancellation, and process-tree ownership semantics of this executor.
* Subclasses use this after replacing the public command's shell argv at an
* execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the live background handle; spawn rejection settles it as killed.
*/
protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.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: ShellProcess = {
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, false)
}, (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, true, error)
}),
readOutput: (): ShellProcessRead => {
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.
* Called after exit facts or spawn-failure output are stamped and before
* {@link ShellProcess.done} resolves. The base implementation is intentionally
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
export default LocalBashExecutor

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-local`.
* @module @deepseek-ai/dsh-bash-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local'
/** Cordis companion plugin name. */
export const name = 'bash-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,349 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
// A short kill grace via the REAL config path, so escalation tests stay fast.
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
const bash = ctx.shell as LocalBashExecutor
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: ShellProcess, expected: string, timeoutMs = 5_000): Promise<string> {
const deadline = Date.now() + timeoutMs
let all = ''
while (Date.now() < deadline) {
all += proc.readOutput().delta
if (all.includes(expected)) return all
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(all)}`)
}
describe('LocalBashExecutor.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: 'echo hi' }))
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hi\n')
expect(result.timeoutMs).toBe(5_000)
})
it('uses config cwd, overridable per call', async () => {
const { bash } = await setup({ cwd: '/tmp' })
const fromConfig = await bash.run(bash.resolve({ command: 'pwd' }))
expect(fromConfig.stdout.text.trim()).toMatch(/\/tmp$/)
const fromCall = await bash.run(bash.resolve({ command: 'pwd', workdir: '/' }))
expect(fromCall.stdout.text.trim()).toBe('/')
})
it('defaults cwd to process.cwd()', async () => {
const { bash } = await setup()
const result = await bash.run(bash.resolve({ command: 'pwd' }))
expect(result.stdout.text.trim()).toBe(process.cwd())
})
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: 'true', 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/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
expect(() => bash.resolve({ command: 'true', 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: 'true' }).stdoutMaxBytes).toBe(100)
const result = await bash.run(bash.resolve({
command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2',
stdoutMaxBytes: 500,
}))
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
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: 'sleep 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: 'sleep 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 () => {
// The command kills itself (SIGTERM) with no timeout and no upstream abort:
// the deadline signal never fires, so both classifications are false — the
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'kill -TERM $$' }))
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
it('rejects on spawn failure (bad workdir)', async () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'true', 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: 'cat; echo "[$SEAM_VAR][$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(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: 'true' })
expect('stdin' in spec).toBe(false)
expect('env' in spec).toBe(false)
expect('dshEnv' in spec).toBe(false)
})
})
describe('LocalBashExecutor.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: 'sleep 0.2; echo 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: 'cat; echo "[$BG_VAR][$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).toContain('bg-stdin')
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: 'echo first; sleep 1; echo second' }))
const first = await readUntil(proc, 'first\n')
expect(first).toBe('first\n')
await proc.done
// Read-after-exit returns the remaining buffered output — once.
const second = proc.readOutput()
expect(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: 'echo out; echo err >&2' }))
await proc.done
expect(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: 'echo err >&2' }))
await proc.done
expect(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: 'printf out; echo err >&2' }))
await proc.done
expect(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: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
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: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
await proc.done
const read = proc.readOutput()
expect(read.lossy).toBe(true)
expect(read.stderrSpillPath).toBeDefined()
expect(read.delta).toContain('[stderr]')
})
it('kill() terminates the process group: true once, false after settlement', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
expect(proc.kill()).toBe(true)
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGTERM')
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: 'true' }))
await proc.done
expect(proc.status).toBe('completed')
expect(proc.kill()).toBe(false)
})
it('kill escalation uses the configured graceMs (a TERM-trapping process dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
// The child echoes AFTER arming the trap, so waiting for the marker
// guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
// is load-flaky: a slow spawn would take the SIGTERM before the trap).
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
await readUntil(proc, 'armed')
proc.kill()
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGKILL')
})
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: 'sleep 60', signal: controller.signal }))
controller.abort()
await proc.done
expect(proc.status).toBe('killed')
expect(proc.signal).toBe('SIGTERM')
})
it('a self-signal exit settles the handle as killed, not completed', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'kill -TERM $$' }))
await proc.done
expect(proc.status).toBe('killed')
expect(proc.exitCode).toBeNull()
expect(proc.signal).toBe('SIGTERM')
})
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'true', 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('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(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.shell as LocalBashExecutor
// The child prints its own pid ($$ = the detached bash group leader) so
// the test can probe liveness through the public read API alone.
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 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 job 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
expect(proc.status).toBe('killed')
})
it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
const ctx = new Context()
const managerFiber = await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.shell as LocalBashExecutor
const finished = bash.start(bash.resolve({ command: 'echo done' }))
await finished.done
expect(finished.status).toBe('completed')
const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
await readUntil(trapping, 'armed')
await managerFiber.dispose()
// A settled process was untouched; the live one died by escalation.
expect(finished.status).toBe('completed')
await trapping.done
expect(trapping.status).toBe('killed')
expect(trapping.signal).toBe('SIGKILL')
})
})

View File

@@ -0,0 +1,115 @@
/** The `bash` settings section layered over the executor's composition entry. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SHELL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-shell'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
bash: LocalBashExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, bash: ctx.shell as LocalBashExecutor }
}
describe('bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/positive finite/)
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('refuses a grace period longer than a timer can carry', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
.rejects.toThrow(/graceMs must be no greater than/)
await bench.ctx.fiber.dispose()
})
it('serves the stored section to every later read', async () => {
const bench = await boot()
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
const spec = bench.bash.resolve({ command: 'true' })
expect(spec.stdoutMaxBytes).toBe(1_024)
expect(spec.workdir).toBe('/tmp')
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot()
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.settingsFiber.dispose()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('keeps the composition entry when no settings provider is mounted', async () => {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
expect((ctx.shell as LocalBashExecutor).config.timeoutMs).toBe(1_234)
await ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('shell')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('shell')
await bench.ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,39 @@
{
"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": "../../shell/shell"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,88 @@
# @deepseek-ai/dsh-bash-sandbox
English | [中文](README.zh.md)
Sandbox-consuming Service provider for the [`@deepseek-ai/dsh-shell`](../shell/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
| Mode | File effects |
|---|---|
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
| `danger-full-access` | No confinement; the provider is never consulted. Foreground results carry `sandbox: { mode, denied: false }`; background process handles carry no sandbox facts. |
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `ShellRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`. A present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessRuntime` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `job_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.shell.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
```yaml
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: read-only
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
```
## Model Experience
### Bash tool schema, indirectly
#### What the model sees
The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The policy owner separately contributes the current capability-neutral `sandbox:policy` context.
#### Token effect
Small fixed schema increment on requests where `bash` is visible, plus the current-policy clause owned by `dsh-sandbox-policy`.
#### KV Cache effect
A standing-policy change appends a complete owner-rendered context snapshot after retained history, preserving the existing system/history prefix byte-for-byte. Changing executor capabilities alters the `bash` schema.
### Bash tool result, indirectly
#### What the model sees
After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
#### Token effect
Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Bash tool error, indirectly
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names argv[0] remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
#### Token effect
Conditional error text is visible for that call and retained 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.
## Known Limitations and Deferred Work
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous `SubprocessRuntime` throw that names the runner path instead fails `start()` immediately.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -0,0 +1,88 @@
# @deepseek-ai/dsh-bash-sandbox
[English](README.md) | 中文
这是使用沙箱能力的 [`@deepseek-ai/dsh-shell`](../shell/) 执行器 seam 的 Service provider。加载它时应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
| 模式 | 文件影响 |
|---|---|
| `read-only`(默认) | 任何位置都不可写(在 `/dev` 中只有 `/dev/null` 节点可写,因此 `>/dev/null` 仍可正常工作) |
| `workspace-write` | 只能写入 `workspaceRoot` + `/tmp`(在 bwrap 下为临时目录,在 Landlock 下为宿主 `/tmp`,在 Seatbelt 下为 `/private/tmp` 加每用户临时目录) |
| `danger-full-access` | 不作限制;绝不咨询提供方。前台结果携带 `sandbox: { mode, denied: false }`;后台进程句柄不携带沙箱事实。 |
语义:
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `ShellRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用Node 必须报告 `ENOENT``EACCES`,并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall``'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的 `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `job_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.shell.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升权引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。
```yaml
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: read-only
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
```
## 模型体验
### 间接的 Bash 工具 schema
#### 模型看到的内容
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。策略归属方会另行贡献当前且不区分具体能力的 `sandbox:policy` 上下文。
#### Token 影响
`bash` 可见的请求上schema 固定增加少量内容,另有一条由 `dsh-sandbox-policy` 负责的当前策略子句。
#### KV Cache 影响
常驻策略变化会在保留的历史之后追加一份由归属方渲染的完整上下文快照,并使既有 system/history 前缀保持逐字节不变。更改执行器能力会改变 `bash` schema。
### 间接的 Bash 工具结果
#### 模型看到的内容
在普通有界输出之后,被拒绝的调用会精确追加 `[sandbox: file access denied under <mode> mode]`。当升权可用时,接下来精确追加 `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`。已结算的后台 runner 失败则追加 `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`
#### Token 影响
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记并保留到上下文压缩context compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### 间接的 Bash 工具错误
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。判定为 runner 失败的 spawn 错误会以原始 spawn 错误作为详细信息;如果拒绝没有通过 `ENOENT``EACCES``path``syscall` 证据指明 `argv[0]`,它仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
#### Token 影响
该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `job_output` 读取通用任务时呈现;`SubprocessRuntime` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

View File

@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-bash-sandbox",
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/bash-sandbox"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
}
}

View File

@@ -0,0 +1,116 @@
/**
* Internal sandbox-result classification helpers.
*
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import { accessSync, constants, statSync } from 'node:fs'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
/** Whether the caller-owned spawn cwd can be entered. */
function isUsableWorkdir(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that
* argv[0] or its shebang interpreter.
* The workdir is checked at classification time, not atomically with spawn;
* concurrent path replacement may change attribution but cannot permit an
* unconfined execution.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
): boolean {
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
if (typeof error !== 'object' || error === null) return false
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
if (typeof syscall !== 'string') return false
const exactSyscall = `spawn ${runnerProgram}`
if (path === undefined) return syscall === exactSyscall
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
return syscall === 'spawn' || syscall === exactSyscall
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
* Classify a failed run against the selected backend's denial dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive denial substrings from the active wrap.
* @returns whether the failed run matches that denial dialect.
*/
export function classifyDenial(result: ShellRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**
* Match a non-zero exit against case-insensitive stderr signatures.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text.
* @param signatures - substrings identifying the selected backend's dialect.
* @returns whether this is a non-zero exit whose stderr matches a signature.
*/
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
if (exitCode === null || exitCode === 0) return false
const lowered = stderr.toLowerCase()
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
}

View File

@@ -0,0 +1,182 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { Context } from '@deepseek-ai/cordis'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The runner
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
*/
export type Config = LocalConfig
/**
* Registers as `ctx.shell` in place of the local executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. Tool calls pass the calling session's resolved policy; direct
* calls fall back to deployment policy. `result.sandbox` reports the mode and
* enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) is owned by
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
/**
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
* shared latest-wrap value would classify a process against the wrong facts.
* Unconfined processes have no entry.
*/
private readonly processFacts = new Map<ShellProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
super(ctx, config)
// The default mode is the capability fact used for schema advertisement;
// actual tool executions carry their resolved per-call policy.
this.mode = ctx.sandboxPolicy.defaultMode
}
/** The configured default mode — the capability fact the tool layer reads. */
override get sandboxMode(): SandboxMode {
return this.mode
}
/**
* Stamp a complete per-call policy onto the spec. Tool calls supply the
* calling session's resolved mode and root; lower-level callers fall back to
* the deployment policy.
*/
override resolve(request: ShellExecRequest): ShellExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
const result = await super.run(spec)
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec.command, { ...policy, mode })
let result: ShellRunResult
try {
result = await this.runArgv(spec, confined.argv)
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
override start(spec: ShellExecSpec): ShellProcess {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Once startArgv returns, install facts synchronously; promise settlement
// cannot run before start() returns.
const confined = this.confine(spec.command, { ...policy, mode })
let proc: ShellProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessRuntime reports ENOENT/EACCES with the failed executable path through async
// `done` rejection; this covers alternatives that throw the same error synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
}
/**
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param command - shell source for the confined inner `bash -c`.
* @param policy - resolved confined execution policy.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
}
}
export default SandboxBashExecutor

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-sandbox`.
* @module @deepseek-ai/dsh-bash-sandbox/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox'
/** Cordis companion plugin name. */
export const name = 'bash-sandbox-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,99 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless integration of the real provider and executor through public run/start paths. With
* no rung forced, a passing bwrap probe selects the ladder's first rung. The tests check world
* effects and stamped facts, including EROFS classification through the wrap-carried dialect;
* backend-only confinement is covered by `@deepseek-ai/dsh-sandbox-local`.
*
* Skips when bwrap or unprivileged user namespaces are unavailable. HOME-based paths are
* intentional because bwrap replaces `/tmp`, which cannot prove the workspace-root boundary.
*/
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
const bwrapUsable = probe.status === 0
let ctx: Context | undefined
const tempDirs: string[] = []
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function tempDir(base: string): Promise<string> {
const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
tempDirs.push(dir)
return dir
}
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.shell as SandboxBashExecutor
}
describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.shell', () => {
it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
})
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
})
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
})
})

View File

@@ -0,0 +1,104 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
* rung forced off, so the workspace `landlock-run` launcher confines) underneath the
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
* facts; the backend-only confinement proofs live with
* `@deepseek-ai/dsh-sandbox-local`.
*
* Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from
* `native/landlock-run` before running this file.
*/
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
const landlockUsable = probe.status === 0
/** The kernel's enforcement level from the probe report — stamped facts below must match it. */
const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
let ctx: Context | undefined
const tempDirs: string[] = []
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function tempDir(base: string): Promise<string> {
const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
tempDirs.push(dir)
return dir
}
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.shell as SandboxBashExecutor
}
describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement through ctx.shell', () => {
it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
const workdir = await tempDir(tmpdir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
})
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
})
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
})
})

View File

@@ -0,0 +1,270 @@
/**
* Deterministic real-process proofs for runner classification: the real local
* provider and sandbox bash executor exercise direct runner-spawn failures
* and a POSIX fake Landlock launcher that prints its notice before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL_PREFIX = 'landlock-run: '
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatalExit?: number): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatalExit),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.shell as SandboxBashExecutor
}
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [runner],
runnerFailureSignatures: ['configured-runner: fatal'],
})
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.shell as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, `${kind}-runner`)
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
if (kind === 'missing-interpreter') {
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
}
const bash = await setupConfiguredRunner(runner)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(runner)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each(['bare-name', 'relative'] as const)(
'classifies a %s runner whose shebang interpreter is missing',
async (form) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
tempDirs.push(dir)
const filename = 'missing-interpreter-runner'
const runner = form === 'bare-name' ? filename : `./${filename}`
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = form === 'bare-name'
? { command: 'true', env: { PATH: dir } }
: { command: 'true', workdir: dir }
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
// argv[0] in this spawn error rather than resolving it to an absolute path.
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const task = bash.start(bash.resolve(request))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
},
)
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, 'malformed-runner')
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = { command: 'true' }
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
// ENOENT/EACCES with the exact failed executable path required for runner attribution.
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
if (foreground instanceof Error) {
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((foreground as { path?: unknown }).path).toBeUndefined()
let background: unknown
try {
bash.start(bash.resolve(request))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((background as { path?: unknown }).path).toBeUndefined()
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
} else {
expect(foreground).toMatchObject({
exitCode: 127,
signal: null,
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
})
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
const background = bash.start(bash.resolve(request))
await background.done
expect(background.status).toBe('completed')
expect(background.exitCode).toBe(127)
expect(background.signal).toBeNull()
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const output = background.readOutput().delta
expect(output.startsWith('[stderr]\n')).toBe(true)
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
expect(output).not.toContain('spawn failed:')
}
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
},
)
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
const bash = await setup(exitCode)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
expect((error as Error).message).not.toContain(NOTICE)
})
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
const task = bash.start(bash.resolve({ command }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
}
})
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'partial',
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(FATAL)
})
})

View File

@@ -0,0 +1,658 @@
/**
* Consumer-side `SandboxBashExecutor` tests. A fake Cordis sandbox service makes wrapping,
* policy hand-off, fail-closed propagation, classification, and fact stamping deterministic;
* real-provider integration lives in `tests/landlock.e2e.ts`. A mode-0555 directory supplies
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
*/
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
/** One recorded provider call: the argv handed over and the policy it rode with. */
interface ConfineCall {
argv: string[]
policy: SandboxPolicy
}
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
const RUNNER_FORMS = [
['absolute', process.execPath],
['bare', 'node'],
['relative', './sandbox-runner'],
] as const
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
* per test) and the executor under test on top of it.
*/
async function setup(
config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
) {
const { mode, workspaceRoot, ...execConfig } = config
const calls: ConfineCall[] = []
class FakeSandboxProvider extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
calls.push({ argv: [...argv], policy })
return behavior(argv, policy)
}
}
const ctx = new Context()
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxPolicyService, {
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.shell as SandboxBashExecutor
return { ctx, bash, calls }
}
function output(text: string): CollectedOutput {
return { text, truncated: false }
}
function runResult(exitCode: number | null, stderr: string): ShellRunResult {
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
}
function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
return { mode, workspaceRoot }
}
describe('the provider hand-off', () => {
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
const { bash, calls } = await setup()
const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
expect(result.stdout.text).toBe('a b c\'d\n')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
expect(calls).toEqual([{
argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
}])
})
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
const hook = join(dir, 'hook.sh')
const order = join(dir, 'order.txt')
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
const runnerScript = [
'const { appendFileSync } = require("node:fs");',
'const { spawnSync } = require("node:child_process");',
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
'process.exit(child.status ?? 125);',
].join('')
const { bash } = await setup({}, argv => ({
argv: [process.execPath, '-e', runnerScript, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
try {
const result = await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: { DSH_ORDER_FILE: order },
}))
expect(result.exitCode).toBe(0)
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
})
it('an explicit workspaceRoot on the policy wins', async () => {
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
await bash.run(bash.resolve({ command: 'true' }))
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
})
it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
const { bash, calls } = await setup()
await bash.run(bash.resolve({ command: 'true' }))
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(calls).toHaveLength(2)
})
})
describe('fail closed', () => {
it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
const spec = bash.resolve({ command: 'echo hi' })
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
})
it('preserves an already-aborted foreground call as cancellation', async () => {
const { bash } = await setup()
const controller = new AbortController()
const reason = new Error('caller cancelled before spawn')
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(RUNNER_FORMS)(
'keeps an invalid workdir ordinary with the %s provider-runner form',
async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
},
)
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
const { bash } = await setup({ mode: 'danger-full-access' })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
const runner = join(spillDir, 'malformed-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
})
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
let background: unknown
try {
bash.start(bash.resolve({ command: 'true' }))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
})
it('classifies a synchronous SubprocessRuntime EACCES with the exact runner path', async () => {
const runner = join(spillDir, 'unexecutable-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
// This pins an alternative SubprocessRuntime's synchronous seam, not the
// shipped local behavior.
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
})
await expect(bash.run(bash.resolve({ command: 'true' })))
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(bash.resolve({ command: 'true' })))
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
})
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
const runner = './sandbox-runner'
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const workdir = join(parent, 'missing')
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
try {
let thrown: unknown
try {
bash.start(bash.resolve({ command: 'true', workdir }))
} catch (error) {
thrown = error
}
expect(thrown).toBe(failure)
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
})
describe('danger-full-access', () => {
it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
const { bash, calls } = await setup({ mode: 'danger-full-access' })
const result = await bash.run(bash.resolve({ command: 'echo free' }))
expect(result.stdout.text).toBe('free\n')
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(calls).toHaveLength(0)
})
it('start() passes through unwrapped and stamps nothing at settle', async () => {
const { bash, calls } = await setup({ mode: 'danger-full-access' })
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(task.readOutput().delta).toContain('free-bg')
expect(calls).toHaveLength(0)
})
})
describe('per-call sandbox policy (the session and escalation carrier)', () => {
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
const { bash } = await setup()
expect(bash.sandboxMode).toBe('read-only')
expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only'))
})
it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => {
const { bash, calls } = await setup()
const explicit = executionPolicy('workspace-write', '/session/project')
expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
await bash.run(bash.resolve({ command: 'true' }))
expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
})
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
const { bash } = await setup()
const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
})
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
const { bash, calls } = await setup()
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
expect(result.stdout.text).toBe('free\n')
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(calls).toHaveLength(0)
})
it('overlapping background jobs settle with their OWN modes (an escalated task next to a default one)', async () => {
// With per-call policy, tasks under different modes are in flight at
// once — anything keyed off the configured default would misreport the
// escalated one at its settle stamp.
const { bash } = await setup()
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
const plain = bash.start(bash.resolve({ command: 'true' }))
await plain.done
await escalated.done
expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('an escalated danger-full-access background job carries no facts (nothing confined it)', async () => {
const { bash, calls } = await setup()
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(task.readOutput().delta).toContain('bg-free')
expect(calls).toHaveLength(0)
})
})
describe('classifyDenial', () => {
it('never classifies a clean exit or a signal kill as a denial', () => {
expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
})
it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
// Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
// it unsandboxed too, and the mode vocabulary governs file effects only —
// claiming a file denial here would tell the model the sandbox blocked
// something it never governed.
expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
})
it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
// The same stderr flips meaning with the backend: under Seatbelt, EPERM
// text IS how the kernel refuses a governed file write; under bwrap's
// EROFS-only dialect, `Permission denied` is ordinary DAC, not the
// sandbox — per-wrap signatures are what keep both classifications honest.
expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
})
})
describe('isRunnerSpawnFailure', () => {
it.each(['EACCES', 'ENOENT'])(
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
},
)
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
},
)
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
const missingWorkdir = join(spillDir, 'missing-workdir')
for (const [, runner] of RUNNER_FORMS) {
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
}
const fileWorkdir = join(spillDir, 'not-a-workdir')
writeFileSync(fileWorkdir, '')
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
})
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
Object.assign(new Error('spawn failed'), { code, syscall, path })
const spawnErrorWithoutPath = (syscall: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
})
it('accepts only syscall and error-path facts that identify the exact runner program', () => {
const runner = join(spillDir, 'runner with spaces')
const spawnError = (syscall: string, path?: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
})
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const fatal = 'landlock-run: ruleset creation failed'
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['', ' ', 'landlock-run: '],
informationalLines: [notice],
}]
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
})
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
.toEqual({ detail: `${notice}: extra detail` })
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
})
it.each([
'landlock-run: usage error: missing `-- <argv>...` command',
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
'landlock-run: cannot open rule path: /gone: No such file or directory',
'landlock-run: landlock ruleset error: Invalid argument',
'landlock-run: exec failed: Permission denied',
'landlock-run: out of memory',
'landlock-run: future fatal diagnostic',
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
}]
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
})
})
describe('result facts', () => {
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
const { bash } = await setup({}, argv => ({
argv: ['env', ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
const { bash } = await setup()
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
mkdirSync(lockedDir)
chmodSync(lockedDir, 0o555)
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
})
describe('background sandbox facts', () => {
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
pid: -1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
done: Promise.reject(undefined),
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.readOutput().delta).toContain('spawn failed: undefined')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
})
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
// The wrap's runner prefix on a failed run means the SANDBOX broke and
// the command never ran — the late twin of the confine-time throw, with
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
})
it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
.rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
})
it('overlapping background jobs keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
// Facts belong to each wrap and may vary between calls. The slow task settles after the
// quick task starts; a shared latest-wrap field would classify and stamp it with the wrong
// task's dialect and enforcement.
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
{ enforcement: 'full', denialSignatures: ['read-only file system'] },
]
let call = 0
const { bash } = await setup({}, (argv) => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = bash.start(bash.resolve({ command: 'true' }))
await quick.done
await slow.done
expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('a signal-killed task is never a denial (null exit code)', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
// Let the stderr land before the kill so the classifier sees the
// signature and must still refuse it on the null exit code alone.
await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
task.kill()
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('disposal kills wrapped background jobs (inherited HMR safety)', async () => {
const { ctx, bash } = await setup()
const task = bash.start(bash.resolve({ command: 'sleep 30' }))
await ctx.fiber.dispose()
expect(task.status).toBe('killed')
})
})

View File

@@ -0,0 +1,127 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless macOS integration of the real provider and executor through public run/start paths.
* Linux rungs are forced off so Seatbelt is selected. The tests check world effects and stamped
* facts, including EPERM classification through the wrap-carried dialect; backend-only
* confinement is covered by `@deepseek-ai/dsh-sandbox-local`. Skips off macOS or when
* `sandbox-exec` rejects the profile.
*/
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
const seatbeltUsable = probe.status === 0
let ctx: Context | undefined
const tempDirs: string[] = []
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function tempDir(base: string): Promise<string> {
const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
tempDirs.push(dir)
return dir
}
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.shell as SandboxBashExecutor
}
describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement through ctx.shell', () => {
it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
})
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
// HOME-based dirs on purpose: workspace-write grants /tmp and the
// per-user temp dir wholesale, so only paths outside both prove the
// workspace-root boundary.
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const hook = join(workdir, 'bash-env-hook.sh')
const insideProbe = join(workdir, 'hook-ran.txt')
const outsideProbe = join(outside, 'escaped.txt')
await writeFile(hook, [
'printf hook > "$DSH_BASH_ENV_INSIDE"',
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
'',
].join('\n'))
const bash = await sandboxedBash(workdir, 'workspace-write')
await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: {
DSH_BASH_ENV_INSIDE: insideProbe,
DSH_BASH_ENV_OUTSIDE: outsideProbe,
},
}))
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
expect(existsSync(outsideProbe)).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
})
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
})
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../native/landlock-run/packages/entry"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../shell/shell"
},
{
"path": "../../shell/bash-local"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,57 @@
# @deepseek-ai/dsh-pwsh-local
English | [中文](README.zh.md)
Local PowerShell Service provider for the `@deepseek-ai/dsh-shell` 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
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.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
- **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)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
- **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`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. 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 `ShellExecRequest.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 `ShellProcess` 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.jobs` runtime](../../jobs/jobs/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 job 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`.
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

View File

@@ -0,0 +1,57 @@
# @deepseek-ai/dsh-pwsh-local
[English](README.md) | 中文
`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service provider基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose资源释放属于 subprocess 服务。
命令字符串作为单个 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 加载与会干扰工具输出的提示符。
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.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()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose被终止并 join。一切任务相关职责job id、所有权、轮询、通知都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
## 模型体验
间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量经通用任务运行时、spill 文件路径与基础设施失败。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀的任何变更由具名消费方负责。
## 已知限制与暂缓事项
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要隔离的部署应组合启用沙箱的 bash 执行器或策略。
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束因此基于信号的状态分类POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`
- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)``#requires``using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires``-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8不受影响。
清理启发式与 spill 保留的注意事项见 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md),相关机制由其负责。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-pwsh-local",
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/pwsh-local"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -0,0 +1,363 @@
/**
* Local PowerShell Service provider for the bash capability 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
*/
/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by
design (see this package's README), so the two import the same seam surface */
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/* jscpd:ignore-end */
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 inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
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`)
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceablePwshConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* 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 ShellExecutor {
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(),
})
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** The declared executable the current {@link pwshPath} was resolved from. */
private declaredPwshPath: string | undefined
/** The pwsh executable resolved from the current config. */
private resolvedPwshPath: string
/** Validated config (schemastery applied the defaults before construction). */
get config(): ResolvedConfig {
return this.source()
}
/** The pwsh executable every command runs through. */
get pwshPath(): string {
return this.resolvedPwshPath
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
const entry = config as ResolvedConfig
assertServiceablePwshConfig(entry)
this.source = () => entry
this.declaredPwshPath = entry.pwshPath
this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
installSettingsSection(ctx, SHELL_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
validate: assertServiceablePwshConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Probing the filesystem is the one fact derived from the source: every
// other field is read through the getter at each command.
onChange: () => {
const declared = this.source().pwshPath
if (declared === this.declaredPwshPath) return
this.declaredPwshPath = declared
this.resolvedPwshPath = resolvePwshPath(declared)
},
})
}
/**
* 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: ShellExecRequest): ShellExecSpec {
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,
}
}
/**
* The pwsh invocation argv for one resolved spec — the argv-level seam a
* confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
* `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see
* `@deepseek-ai/dsh-pwsh-sandbox`).
*/
protected argv(spec: ShellExecSpec): string[] {
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
}
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
private spawnSpec(
spec: ShellExecSpec,
stdoutMaxBytes: number,
signal: AbortSignal | undefined,
argv: readonly string[],
): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: [...argv],
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: ShellExecSpec): Promise<ShellRunResult> {
return this.runArgv(spec, this.argv(spec))
}
/** Foreground run of an exact argv (the confining subclass re-wraps it). */
protected async runArgv(spec: ShellExecSpec, argv: readonly string[]): Promise<ShellRunResult> {
// 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, argv))
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: ShellExecSpec): ShellProcess {
return this.startArgv(spec, this.argv(spec))
}
/** Background start of an exact argv (the confining subclass re-wraps it). */
protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
// 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, argv))
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: ShellProcess = {
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, false)
}, (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, true, error)
}),
readOutput: (): ShellProcessRead => {
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); the
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the spawn rejected before any process existed.
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
*/
protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
/* jscpd:ignore-end */
export default PwshLocalExecutor

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
* @module @deepseek-ai/dsh-pwsh-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
/** Cordis companion plugin name. */
export const name = 'pwsh-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,60 @@
/**
* PowerShell executable resolution, dependency-free so non-package consumers
* (the repository's coverage-gate probe in `vitest.config.ts`) can share the
* ONE resolution definition with the executor and its suites — a probe that
* resolved differently from the code under test could exempt a file whose
* suites actually run.
*
* @module @deepseek-ai/dsh-pwsh-local/resolve
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
/**
* Well-known Windows PowerShell install locations plus PATH entries, newest
* first. Explicitly parameterized (env) so resolution is a pure function of
* its inputs on every platform.
* @param env - the environment to probe; defaults to the process environment.
* @returns candidate `pwsh` executable paths in resolution order.
*/
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
const candidates = [
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
]
// Microsoft Store installs (and any user-added location) live on PATH;
// entries may carry surrounding quotes from `setx`-style definitions.
for (const entry of (env.PATH ?? '').split(';')) {
const trimmed = entry.trim().replace(/^"|"$/g, '')
if (trimmed.length === 0) continue
candidates.push(join(trimmed, 'pwsh.exe'))
}
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
return candidates
}
/**
* Resolve the pwsh executable this executor spawns.
* @param configured - an explicit `pwshPath` config value, trusted as-is.
* @param env - the environment to probe on Windows; defaults to the process environment.
* @param platform - the platform to resolve for; defaults to the process platform.
* @returns the first existing well-known location on Windows (PowerShell 7
* install, a PATH entry such as the Microsoft Store install, then Windows
* PowerShell 5.1), else `pwsh` for PATH resolution.
*/
export function resolvePwshPath(
configured?: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string {
if (configured !== undefined && configured.length > 0) return configured
if (platform === 'win32') {
for (const candidate of candidatePwshPaths(env)) {
if (existsSync(candidate)) return candidate
}
}
return 'pwsh'
}

View File

@@ -0,0 +1,473 @@
/**
* 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, realpathSync, 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 '@deepseek-ai/cordis'
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SubprocessRuntime from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
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')
/** Filesystem path equality across macOS temp symlinks and Windows drive-letter casing. */
function samePath(actual: string, expected: string): boolean {
const norm = (value: string) => (
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : realpathSync.native(value)
)
return norm(actual) === norm(expected)
}
async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).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.shell 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: ShellProcess, 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',
ProgramFiles: 'P:\\no-program-files',
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('uses stable Windows roots when the environment omits both overrides', () => {
expect(candidatePwshPaths({})).toEqual([
join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
])
})
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 CapturingSubprocessRuntime extends SubprocessRuntime {
specs: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
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 CapturingSubprocessRuntime(ctx)
await ctx.plugin(PwshLocalExecutor)
await ctx.shell.run(ctx.shell.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', { timeout: 15_000 }, async () => {
const { bash } = await setup({ timeoutMs: 10_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(10_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/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
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 partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]')
await proc.done
const output = partialOutput + lf(proc.readOutput().delta)
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
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(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
const bash = ctx.shell 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 job 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(LocalSubprocessRuntime)
;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
const bash = ctx.shell as PwshLocalExecutor
const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
await finished.done
expect(finished.status).toBe('completed')
const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
await managerFiber.dispose()
// A settled process was untouched; the live one was terminated and joined.
expect(finished.status).toBe('completed')
await running.done
expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
})
})

View File

@@ -0,0 +1,108 @@
/** The shared `bash` settings section as the pwsh executor family resolves it. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { SHELL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-shell'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
pwsh: PwshLocalExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, pwsh: ctx.shell as PwshLocalExecutor }
}
describe('pwsh executor over the bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('re-resolves the executable when the stored path changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.ctx.fiber.dispose()
})
it('keeps the resolved executable when an unrelated field changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
const before = bench.pwsh.pwshPath
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.pwshPath).toBe(before)
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
await bench.ctx.settings.update(SHELL_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.settingsFiber.dispose()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('shell')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('shell')
await bench.ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,39 @@
{
"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": "../../shell/shell"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
English | [中文](README.zh.md)
Sandbox-consuming PowerShell implementation of the [`ctx.shell` executor seam](../shell/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt.
The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy).
## Behavior
- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`.
- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`.
## Model Experience
### Confinement works, denial surfaces as command failure
#### What the model sees
The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool.
#### Token effect
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
#### KV Cache effect
None directly; the denial surface belongs to the tool layer.
## Known Limitations and Deferred Work
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning.
- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL.

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
[English](README.md) | 中文
沙盒消费型的 [`ctx.shell` 执行器 seam](../shell/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。
执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。
## 行为
- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`
- 受限模式(`read-only``workspace-write`pwsh argv 由 `ctx.sandbox.confine()` 包装runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`
## 模型体验
### 隔离生效,拒绝以命令失败呈现
#### 模型看到什么
受限命令自身的 stderrWindows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
#### Token 影响
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
#### KV Cache 影响
无直接影响;拒绝呈现面属于工具层。
## 已知限制与后续工作
- **Windows 上读不受限**ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`
- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent智能体的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。
- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。

View File

@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-pwsh-sandbox",
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/pwsh-sandbox"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,120 @@
/**
* Internal sandbox-result classification helpers — deliberate call-for-call
* mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of
* the bash consumer shares the identical classification dialect).
*
* @module @deepseek-ai/dsh-pwsh-sandbox/helpers
*/
/* jscpd:ignore-start */
import { accessSync, constants, statSync } from 'node:fs'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
/** Whether the caller-owned spawn cwd can be entered. */
function isUsableWorkdir(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that
* argv[0] or its shebang interpreter.
* The workdir is checked at classification time, not atomically with spawn;
* concurrent path replacement may change attribution but cannot permit an
* unconfined execution.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
): boolean {
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
if (typeof error !== 'object' || error === null) return false
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
if (typeof syscall !== 'string') return false
const exactSyscall = `spawn ${runnerProgram}`
if (path === undefined) return syscall === exactSyscall
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
return syscall === 'spawn' || syscall === exactSyscall
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
* Classify a failed run against the selected backend's denial dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive denial substrings from the active wrap.
* @returns whether the failed run matches that denial dialect.
*/
export function classifyDenial(result: ShellRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**
* Match a non-zero exit against case-insensitive stderr signatures.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text.
* @param signatures - substrings identifying the selected backend's dialect.
* @returns whether this is a non-zero exit whose stderr matches a signature.
*/
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
if (exitCode === null || exitCode === 0) return false
const lowered = stderr.toLowerCase()
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,189 @@
/**
* Sandbox-consuming PowerShell executor — the pwsh twin of
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
* chain), inherits local process mechanics, and reports the selected mode,
* enforcement, and denial facts. Positive runner-launch evidence means the
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool layer owns the escalation approval flow
* through `ctx.approval`; this executor reports the sandbox facts the tool
* renders.
* @module @deepseek-ai/dsh-pwsh-sandbox
*/
import { Context } from '@deepseek-ai/cordis'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The
* runner choice is likewise the `ctx.sandbox` provider's config, not this
* executor's.
*/
export type Config = LocalConfig
/**
* Registers as `ctx.shell` in place of the local pwsh executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
* sandbox denial rendering and escalation surface (see the
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
* resolved policy; direct calls fall back to deployment policy.
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
* renders.
*/
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
export class SandboxPwshExecutor extends PwshLocalExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
/**
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
* shared latest-wrap value would classify a process against the wrong facts.
* Unconfined processes have no entry.
*/
private readonly processFacts = new Map<ShellProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
super(ctx, config)
// The default mode is the capability fact used for schema advertisement;
// actual tool executions carry their resolved per-call policy.
this.mode = ctx.sandboxPolicy.defaultMode
}
/** The configured default mode — the capability fact the tool layer reads. */
override get sandboxMode(): SandboxMode {
return this.mode
}
/**
* Stamp a complete per-call policy onto the spec. Tool calls supply the
* calling session's resolved mode and root; lower-level callers fall back to
* the deployment policy.
*/
override resolve(request: ShellExecRequest): ShellExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
const result = await super.run(spec)
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec, { ...policy, mode })
let result: ShellRunResult
try {
result = await this.runArgv(spec, confined.argv)
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
override start(spec: ShellExecSpec): ShellProcess {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Once startArgv returns, install facts synchronously; promise settlement
// cannot run before start() returns.
const confined = this.confine(spec, { ...policy, mode })
let proc: ShellProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
}
/**
* Stamp per-process sandbox facts before `done` settles. Full-access
* processes have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**
* Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param spec - resolved execution spec whose pwsh argv is confined.
* @param policy - resolved confined execution policy.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(spec: ShellExecSpec, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(this.argv(spec), policy)
}
}
/* jscpd:ignore-end */
export default SandboxPwshExecutor

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`.
* @module @deepseek-ai/dsh-pwsh-sandbox/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox'
/** Cordis companion plugin name. */
export const name = 'pwsh-sandbox-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 seams.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,117 @@
/**
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
* REAL pwsh spawns confined through the runner — the debug-instance
* verification of both modes on ordinary user-owned paths: read-only denies
* writes, workspace-write allows its promised roots while denying escape
* writes, and the partial-enforcement/denial facts ride the settled result.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
const isWin32 = process.platform === 'win32'
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
let scratchRoot!: string
let writableDir!: string
let outsideTempDir!: string
let secretFile!: string
let escapeFile!: string
let executor!: SandboxPwshExecutor
beforeAll(async () => {
// The workspace escape sits under the profile. A separate directory under
// the ambient temp root proves that the root itself is not granted: the
// runner creates its own private child and rewrites TMP/TEMP to it.
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
writableDir = join(scratchRoot, 'writable')
mkdirSync(writableDir)
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-'))
secretFile = join(scratchRoot, 'secret.txt')
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
escapeFile = join(scratchRoot, 'escaped.txt')
const ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxPwshExecutor, {})
executor = ctx.shell as SandboxPwshExecutor
})
afterAll(() => {
rmSync(scratchRoot, { recursive: true, force: true })
rmSync(outsideTempDir, { recursive: true, force: true })
})
it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => {
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
].join('')
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
expect(result.stdout.text).toContain('TARGET-WRITE: DENIED')
expect(result.stdout.text).toContain('TEMP-WRITE: DENIED')
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
expect(result.stdout.text).toContain('SECRET-READ: OK')
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
// A self-caught denial keeps the command exit 0: no denial fact.
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
// A raw failing write must classify as a denial of the ACL dialect.
const denied = await executor.run(executor.resolve({
command: `Set-Content -Path '${escapeFile}' -Value x`,
sandboxPolicy: policy,
}))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
}, 60_000)
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => {
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
"try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
"'TEMP-PATH: ' + $env:TEMP",
].join('')
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED')
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
expect(result.stdout.text).toContain('SECRET-READ: OK')
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
expect(existsSync(escapeFile)).toBe(false)
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
expect(privateTemp).toBeDefined()
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
expect(existsSync(privateTemp ?? '')).toBe(false)
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
}, 60_000)
})

View File

@@ -0,0 +1,326 @@
/**
* Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service
* makes wrapping, policy hand-off, fail-closed propagation, and fact stamping
* deterministic; real-provider integration lives in `tests/acl.e2e.ts`.
* Requires pwsh for the integration block (skips without it — same gate as
* pwsh-local's suites); the helpers block is pure and always runs.
*/
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { Context, Service } from '@deepseek-ai/cordis'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
// The same probe pwsh-local's suites and the vitest coverage exemption use:
// spawnSync never throws on a missing binary (it reports status null), and
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))
/** One recorded provider call: the argv handed over and the policy it rode with. */
interface ConfineCall {
argv: string[]
policy: SandboxPolicy
}
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] })
/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */
function throwingSubprocessRuntime(error: unknown): new (ctx: Context) => Service {
return class extends Service {
constructor(ctx: Context) {
super(ctx, 'subprocess')
}
spawn(): never {
throw error
}
}
}
async function setup(
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
subprocess: new (ctx: Context) => Service = LocalSubprocessRuntime,
): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
const calls: ConfineCall[] = []
class FakeSandboxProvider extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
calls.push({ argv: [...argv], policy })
return behavior(argv, policy)
}
}
const ctx = new Context()
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
await ctx.plugin(subprocess)
if (ctx.subprocess instanceof LocalSubprocessRuntime) {
ctx.subprocess.internals = { spillDir }
}
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
return { executor: ctx.shell as SandboxPwshExecutor, calls }
}
describe('helpers (pure)', () => {
const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-'))
afterAll(() => {
rmSync(workdir, { recursive: true, force: true })
})
describe('isRunnerSpawnFailure', () => {
const absolute = process.execPath
const bare = 'node'
const relative = './sandbox-runner'
it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => {
for (const runnerProgram of [absolute, bare, relative]) {
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true)
}
})
it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => {
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false)
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false)
expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false)
expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false)
// An existing FILE (not a directory) workdir is unusable without throwing.
const fileWorkdir = join(workdir, 'a-file')
writeFileSync(fileWorkdir, 'x')
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
const rules: readonly RunnerFailureRule[] = [{
allowedExitCodes: [127],
fatalSignatures: ['fake-runner: '],
informationalLines: ['fake-runner: partial enforcement'],
}]
it('matches a fatal signature on a gated exit code, skipping informational lines', () => {
expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules))
.toEqual({ detail: 'fake-runner: profile refused' })
})
it('rejects zero/null exits, gate mismatches, and empty signatures', () => {
expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined()
})
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
})
})
describe('matchesSignature', () => {
it('matches non-zero exits case-insensitively, never zero or signal exits', () => {
expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true)
expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true)
expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false)
expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false)
expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false)
})
})
})
describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
// Denial device for the POSIX classification cases: a mode-0555 directory
// INSIDE a temp scratch tree (the same device as bash-sandbox's suites) —
// unit tests never attempt writes outside the system temp directory. On
// win32 there is no POSIX mode denial; the real-sandbox denial coverage
// lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths.
const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-'))
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555)
const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')`
afterAll(() => {
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755)
rmSync(readOnlyDir, { recursive: true, force: true })
rmSync(spillDir, { recursive: true, force: true })
})
const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => {
const { executor, calls } = await setup()
const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO }))
expect(result.exitCode).toBe(0)
expect(calls).toHaveLength(1)
const call = calls[0]
expect(call?.policy).toEqual(RO)
// The confined argv is the pwsh invocation, ready for a runner prefix.
expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u)
expect(call?.argv).toContain('-NonInteractive')
expect(call?.argv.at(-1)).toContain('echo wrapped')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
}, 30_000)
it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => {
const { executor, calls } = await setup()
expect(executor.sandboxMode).toBe('workspace-write')
const result = await executor.run(executor.resolve({ command: 'echo fallback' }))
expect(result.exitCode).toBe(0)
expect(calls[0]?.policy.mode).toBe('workspace-write')
}, 30_000)
it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => {
const { executor, calls } = await setup()
const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } }))
expect(result.exitCode).toBe(0)
expect(calls).toHaveLength(0)
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
}, 30_000)
it('an aborted caller signal outranks runner-spawn attribution', async () => {
const controller = new AbortController()
controller.abort('caller-cancel')
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [],
}))
await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal })))
.rejects.toThrow('caller-cancel')
}, 30_000)
// POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the
// real-sandbox denial classification is covered by tests/acl.e2e.ts
// (the ACL runner denies scratch paths — unit tests never leave temp).
it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => {
const { executor } = await setup()
const result = await executor.run(executor.resolve({
command: deniedWriteCommand,
sandboxPolicy: RO,
}))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 30_000)
it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => {
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
}, 30_000)
it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => {
const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
const { executor: closed } = await setup(() => ({
argv: ['node', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}), throwingSubprocessRuntime(attributable))
await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
const { executor: passthroughError } = await setup(undefined, throwingSubprocessRuntime(foreign))
await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
.rejects.toThrow('sync-emfile')
}, 30_000)
it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => {
const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
const { executor: closed } = await setup(() => ({
argv: ['node', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}), throwingSubprocessRuntime(attributable))
expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
.toThrow(SandboxUnavailableError)
const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
const { executor: passthroughError } = await setup(undefined, throwingSubprocessRuntime(foreign))
expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
.toThrow('sync-emfile-start')
}, 30_000)
it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => {
const { executor } = await setup(() => ({
argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
.rejects.toThrow(SandboxUnavailableError)
}, 30_000)
it('background confined runs stamp clean facts at settlement', async () => {
const { executor } = await setup()
const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO }))
await clean.done
expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
}, 30_000)
// POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial
// coverage lives in tests/acl.e2e.ts.
it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => {
const { executor } = await setup()
const denied = executor.start(executor.resolve({
command: deniedWriteCommand,
sandboxPolicy: RO,
}))
await denied.done
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
}, 30_000)
it('background spawn rejections settle as runnerFailed facts', async () => {
const { executor } = await setup(() => ({
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
enforcement: 'full',
denialSignatures: [],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
}))
const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
await proc.done
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
// The failure note surfaces through the read path.
const read = proc.readOutput()
expect(read.delta).toContain('spawn failed')
}, 30_000)
it('danger-full-access background runs bypass confine and carry no facts', async () => {
const { executor, calls } = await setup()
const proc = executor.start(executor.resolve({
command: 'echo full-bg',
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
}))
await proc.done
expect(calls).toHaveLength(0)
expect(proc.sandbox).toBeUndefined()
}, 30_000)
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../shell/shell"
},
{
"path": "../../shell/pwsh-local"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-shell-env
English | [中文](README.zh.md)
The tool-independent shell environment plugin: owns the `ctx.shellEnv` 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 `ShellEnvRegistry` service class and its contributor types; consumers use `ctx.shellEnv` after loading this plugin.
## Config
```yaml
- id: shell-env
name: '@deepseek-ai/dsh-shell-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-home-paths`](../../util/home-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.shellEnv` 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 '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-shell-env'
export const inject = ['shellEnv']
export function apply(ctx: Context): void {
ctx.shellEnv.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 `ShellExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
## Model Experience
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.

View File

@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-shell-env
[English](README.md) | 中文
工具无关的 shell 环境插件:拥有 `ctx.shellEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash``dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME``DSH_SHELL=1``DSH_SESSION_ID`归注册表自身所有其他插件可以注册额外的可枚举事实注册随插件纤维fiber释放重复所有权或未声明的运行时键会响亮失败。
包根导出 Cordis 插件约定(`name``inject``Config``apply`)以及 `ShellEnvRegistry` 服务类及其 contributor 类型;消费方在加载本插件后使用 `ctx.shellEnv`
## Config
```yaml
- id: shell-env
name: '@deepseek-ai/dsh-shell-env'
config:
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
```
## Managed environment
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-home-paths`](../../util/home-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.shellEnv` 负责收集。其他插件可以注册一个受 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 '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-shell-env'
export const inject = ['shellEnv']
export function apply(ctx: Context): void {
ctx.shellEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `ShellExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏陈旧身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。
## Model Experience
通过 shell 工具(`dsh-tool-bash``dsh-tool-pwsh`)间接产生影响;这些工具会把该注册表的受管 `DSH_*` 快照收集进每次 shell 工具调用。
#### KV Cache effect
不会直接导致缓存失效;任何请求前缀变更均由上述消费方负责。
## Known Limitations and Deferred Work
- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME``DSH_SHELL``DSH_SESSION_ID`不包含在内因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-shell-env",
"description": "Tool-independent managed DSH_* shell environment registry",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/shell-env"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,217 @@
/**
* Tool-independent shell environment plugin: owns the `ctx.shellEnv` 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-shell-env
*/
import { Service, type Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-session-persistence'
declare module '@deepseek-ai/cordis' {
interface Context {
shellEnv: ShellEnvRegistry
}
}
export const name = 'shell-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 shell-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 ShellEnvRegistry.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.shellEnv`) 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 ShellEnvRegistry 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.shellEnv` 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, 'shellEnv')
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: ShellEnvRegistry) {
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 shell-env plugin: register the `ctx.shellEnv` 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 ShellEnvRegistry(ctx, config)
registry.register({
name: 'session-persistence',
variables: {
[DSH_SESSION_JSONL_KEY]: {
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
},
},
resolve(execution) {
const agent = execution.agent
if (agent === undefined) return {}
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
},
})
}

View File

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

View File

@@ -0,0 +1,238 @@
/**
* Registry tests for `@deepseek-ai/dsh-shell-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'
import { Context } from '@deepseek-ai/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 { ShellEnvRegistry } from '@deepseek-ai/dsh-shell-env'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
const testToolSignal = new AbortController().signal
afterEach(() => vi.unstubAllEnvs())
function execution(sessionId?: string): ToolExecution {
return {
signal: testToolSignal,
token: Symbol('bash-env-test') as ToolExecution['token'],
callId: CallId('bash-env-call'),
rootCallId: CallId('bash-env-call'),
name: 'bash',
arguments: { command: 'true' },
...(sessionId === undefined
? {}
: { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
}
}
describe('ShellEnvRegistry', () => {
it('collects unconditional shell facts and the current agent session id', () => {
const ctx = new Context()
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
expect(registry.collect(execution())).toEqual({
DSH_HOME: resolve('./test-dsh-home'),
DSH_SHELL: '1',
})
expect(registry.collect(execution('session-a'))).toEqual({
DSH_HOME: resolve('./test-dsh-home'),
DSH_SESSION_ID: 'session-a',
DSH_SHELL: '1',
})
})
it('resolves DSH_HOME from the ambient override or the user-home default', () => {
vi.stubEnv('DSH_HOME', './ambient-dsh-home')
const fromEnvironment = new ShellEnvRegistry(new Context())
expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
vi.stubEnv('DSH_HOME', undefined)
const fromDefault = new ShellEnvRegistry(new Context())
expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
})
it('collects declared contributor variables and omits unavailable values', () => {
const ctx = new Context()
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'optional-session-fact',
variables: {
DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
},
resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
})
registry.register({
name: 'always-available-fact',
variables: {
DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
},
resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
})
expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
expect(registry.list()).toEqual([
{
contributor: 'always-available-fact',
description: 'Always-available test fact.',
key: 'DSH_ALWAYS_AVAILABLE',
},
{
contributor: 'optional-session-fact',
description: 'Optional session-scoped test fact.',
key: 'DSH_SESSION_OPTIONAL',
},
])
})
it('rejects duplicate variable ownership at registration time', () => {
const ctx = new Context()
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'first',
variables: { DSH_SHARED: { description: 'First owner.' } },
resolve: () => ({ DSH_SHARED: 'first' }),
})
expect(() => registry.register({
name: 'second',
variables: { DSH_SHARED: { description: 'Second owner.' } },
resolve: () => ({ DSH_SHARED: 'second' }),
})).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
})
it('rejects duplicate contributor names and malformed declarations', () => {
const registry = new ShellEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
registry.register({
name: 'declared',
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
resolve: () => ({}),
})
expect(() => registry.register({
name: 'declared',
variables: { DSH_ANOTHER: { description: 'Another fact.' } },
resolve: () => ({}),
})).toThrow(/already registered/)
expect(() => registry.register({
name: ' ',
variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
resolve: () => ({}),
})).toThrow(/name must be non-empty/)
expect(() => registry.register({
name: 'invalid-key',
variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
resolve: () => ({}),
})).toThrow(/invalid key/)
expect(() => registry.register({
name: 'reserved-key',
variables: { DSH_HOME: { description: 'Reserved key.' } },
resolve: () => ({}),
})).toThrow(/reserved key/)
expect(() => registry.register({
name: 'blank-description',
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
resolve: () => ({}),
})).toThrow(/must describe/)
})
it('rejects undeclared variables returned by a contributor', () => {
const ctx = new Context()
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'drifted-provider',
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
resolve: () => ({ DSH_UNDECLARED: 'bad' }),
})
expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
})
it('rejects non-string values returned by a contributor', () => {
const registry = new ShellEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
registry.register({
name: 'wrong-value-type',
variables: { DSH_STRING: { description: 'String fact.' } },
resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
})
expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
})
it('removes an effect-scoped contributor when its plugin is disposed', async () => {
const ctx = new Context()
const registry = new ShellEnvRegistry(ctx, { dshHome: './test-dsh-home' })
const fiber = await ctx.plugin({
inject: ['shellEnv'],
apply(inner: Context) {
inner.shellEnv.register({
name: 'temporary',
variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
resolve: () => ({ DSH_TEMPORARY: 'present' }),
})
},
})
expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
await fiber.dispose()
expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
})
it('returns an explicit contributor disposer', () => {
const registry = new ShellEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
const dispose = registry.register({
name: 'explicit-disposal',
variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
})
expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
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.shellEnv).toBeInstanceOf(ShellEnvRegistry)
expect(ctx.shellEnv.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.shellEnv.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.shellEnv.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.shellEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../shell/shell"
},
{
"path": "../../util/home-paths"
},
{
"path": "../../core/tools"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,53 @@
# @deepseek-ai/dsh-shell
English | [中文](README.zh.md)
The **`ShellExecutor`** (`ctx.shell`) defines WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Job ids, ownership, collection, cancellation, and notices belong to the generic `ctx.jobs` runtime.
This package owns the Service Definition role of the bash capability, split so each role can evolve (and be swapped) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-shell` (this) | Service Definition: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | Service provider: local subprocesses |
| `@deepseek-ai/dsh-bash-sandbox` | Service provider: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.shell` |
The split is a standard capability seam ([capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): `dsh-bash-sandbox` is a sandboxing executor behind the same Service Definition — the Consumer detects its `sandboxMode` capability and adds escalation fields without importing the provider — and a containerized or remote executor slots in the same way.
## Service API (`ctx.shell`)
| Member | Semantics |
|---|---|
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `ShellRunResult`. |
| `start(spec)` | Background execution. Returns a task-free `ShellProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.jobs`. |
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
| `ShellProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
| `ShellProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
Implementations subclass `ShellExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
`SHELL_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.shell` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
## Vocabulary
`ShellExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `ShellExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.shellEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
## Model Experience
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept.
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).

View File

@@ -0,0 +1,53 @@
# @deepseek-ai/dsh-shell
[English](README.md) | 中文
**`ShellExecutor`**`ctx.shell`)定义 bash 后端做什么即运行前台命令与启动后台进程但不规定如何实现。job id、所有权、收集、取消与通知属于通用 `ctx.jobs` 运行时。
本包承担 bash 能力的 Service Definition 角色,各角色因此可以独立演进(和替换):
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-shell`(本包) | Service Definition抽象服务 + 词汇类型 |
| `@deepseek-ai/dsh-bash-local` | Service provider本地子进程 |
| `@deepseek-ai/dsh-bash-sandbox` | Service provider沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn并将拒绝报告为结果事实 |
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.shell`、面向模型的工具 schema |
该拆分是一个标准的能力 seam[capability-seams Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)`dsh-bash-sandbox` 是位于同一 Service Definition 之后的沙箱执行器——Consumer 检测其 `sandboxMode` 能力并添加升权字段,无需导入提供方——容器化或远程执行器也可以同样接入。
## 服务 API`ctx.shell`
| 成员 | 语义 |
|---|---|
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止导致的终止都会 resolve 为描述性 `ShellRunResult`。 |
| `start(spec)` | 后台执行。立即返回不含任务语义的 `ShellProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.jobs`。 |
| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
| `ShellProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区容量限制而丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
| `ShellProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
实现会继承 `ShellExecutor` 并实现抽象方法。dispose资源释放必须终止每个运行中的进程并等待其退出。
`SHELL_SETTINGS_NAMESPACE``bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.shell` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
## 词汇
`ShellExecRequest`command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `ShellExecSpec`command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult``start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash``renderResult``dsh-tool-pwsh``renderPwshResult` 追加的 `[exit code: N]``[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。
## 模型体验
通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **没有交互式输入词汇**`stdin` 只会在 spawn 时写入一次并关闭seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
- **前台超时始终由执行器负责**seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。

View File

@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-shell",
"description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/shell"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -0,0 +1,103 @@
/**
* Service Definition for the `ctx.shell` capability seam, covering foreground commands and background process
* handles. Job ids, ownership, polling, and notices belong to
* `@deepseek-ai/dsh-jobs`, keeping executors independent of sessions.
* @module @deepseek-ai/dsh-shell
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from './types.ts'
/**
* Settings namespace of this capability, owned here rather than by either
* executor family because it names the capability, not an implementation: a
* host composes exactly one provider of `ctx.shell` (the win32 layer swaps the
* POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
* service registration), so the providers share one namespace without ever
* registering it twice, and a settings document carried between platforms
* keeps resolving on both.
*/
export const SHELL_SETTINGS_NAMESPACE = settingsNamespace('shell')
export { DSH_ENV_PREFIX } from './types.ts'
export type {
ShellExecRequest,
ShellExecSpec,
ShellProcess,
ShellProcessRead,
ShellProcessStatus,
ShellRunResult,
ShellSandboxInfo,
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
} from './types.ts'
export { parseExitStatus } from './render.ts'
export type { ParsedExitStatus } from './render.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
shell: ShellExecutor
}
}
/**
* Abstract bash execution service. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.shell` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Implementations must honor these semantics:
* - {@link run} rejects only for infrastructure failures. Nonzero exits,
* timeout kills, and abort kills resolve with a {@link ShellRunResult}.
* - {@link start} returns immediately; no timeout applies to background
* processes. `done` settles at process close and never rejects; spawn
* failures settle as `killed` with the error on stderr.
* - {@link ShellProcess.readOutput} is incremental: consecutive reads never
* repeat output. Lossy reads report truncation and available spill files.
* - A still-running background process is stopped and awaited when its
* owning composition tears down. With the subprocess seam that
* boundary is `ctx.subprocess` disposal, so a background process survives
* an executor-only reload.
*/
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) {
super(ctx, 'shell')
}
/**
* The sandbox mode this executor applies by default, or `undefined` when it
* does not sandbox commands.
* @returns the configured default sandbox mode, when supported.
*/
get sandboxMode(): SandboxMode | undefined {
return undefined
}
/**
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
*/
abstract resolve(request: ShellExecRequest): ShellExecSpec
/**
* Run a command in the foreground; resolves when it finishes.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/**
* Start a background process and return its handle immediately.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live process handle (reads, kill, quiescence promise).
*/
abstract start(spec: ShellExecSpec): ShellProcess
}
export default ShellExecutor

View File

@@ -0,0 +1,22 @@
/** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-shell/invariant */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-shell'
/** Cordis companion plugin name. */
export const name = 'shell-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this stateless Service Definition owns request/result types, while executors and policy own observations. */
const install: InvariantInstaller = () => {}
/**
* Register the bash 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))

View File

@@ -0,0 +1,42 @@
/**
* Shared rendering helpers for the shell tools (`dsh-tool-bash`,
* `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers
* emit and the presentation layer parses back.
* @module @deepseek-ai/dsh-shell/render
*/
/**
* The exit status recovered from a rendered result, with the output body that
* status was split off from.
*/
export type ParsedExitStatus =
& { body: string }
& ({ exitCode: number } | { signal: string })
/**
* Split a rendered shell-tool result string into its output body and the
* structured exit status — the inverse of the `[exit code: N]` /
* `[killed by signal: X]` markers the shell tools' renderers append. A killed
* marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
* absent both means a clean exit 0.
*
* The consumed marker is removed from `body` because a terminal presentation
* shows the exit status as its own pill: leaving the marker in the output
* would render the exit twice. Other markers (timeout, sandbox denial) carry
* facts no pill shows, so they stay in the body.
*
* Replay only retains the rendered content text, not the original
* `ShellRunResult`, so terminal presentation must recover the exit pill here.
* Requiring a leading newline and the end of the string keeps ordinary output
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing shell-tool result.
* @returns the marker-free body plus the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): ParsedExitStatus {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
return { body: text, exitCode: 0 }
}

View File

@@ -0,0 +1,183 @@
/**
* Execution types for the bash executor seam. Background job semantics belong
* to `@deepseek-ai/dsh-jobs`; this seam exposes only process handles. The
* managed-environment and captured-output vocabulary is owned by the
* subprocess seam and re-exported here so bash consumers keep one import
* root.
* @module dsh-shell/types
*/
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
* distinguish command failures from policy denials and runner failures.
*/
export interface ShellSandboxInfo {
/** The mode the command actually ran under. */
mode: SandboxMode
/** Whether the sandbox denied a file operation. */
denied: boolean
/** How completely the selected runner enforced the requested mode. */
enforcement?: SandboxEnforcement
/** Whether the sandbox runner failed before the command could run. */
runnerFailed?: boolean
}
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link ShellExecutor.resolve} from the implementation's config.
* This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a
* fully-resolved {@link ShellExecSpec}.
*/
export interface ShellExecRequest {
command: string
/** Working directory override (default: implementation-configured). */
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
* parse complete stdout up to their own bounded limit; the model-facing bash
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
* closed/empty (the default for model-driven tool calls). Set by in-process
* plugins (e.g. the hooks bridges, which write a hook command's JSON payload
* to its stdin); the model-facing bash tool does not expose it as a parameter
* (a model that needs stdin uses shell syntax like a heredoc or a pipe).
*/
stdin?: string | undefined
/**
* Ordinary environment entries for the command, merged after the credential
* scrub. Managed facts belong in {@link dshEnv}, which merges after this
* map, so an entry here can never displace one. Set by in-process plugins
* (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the
* model-facing bash tool does not expose it as a parameter.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution (typed to managed
* keys). Executors discard ambient `DSH_*` entries before merging this
* snapshot last, so an unavailable current fact cannot inherit a stale
* value from the harness process and a caller {@link env} entry cannot
* displace a managed one.
*/
dshEnv?: DshEnvironment | undefined
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
sandboxPolicy?: SandboxExecutionPolicy | undefined
}
/**
* A resolved execution spec. {@link ShellExecutor.resolve} fills and caps the
* required fields; {@link ShellExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
*/
export interface ShellExecSpec {
command: string
workdir: string
timeoutMs: number
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background jobs and stderr keep the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
/**
* Ordinary environment entries carried through from
* {@link ShellExecRequest.env}; {@link dshEnv} still merges after them.
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
* ordinary extra environment.
*/
env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox policy; ignored by executors that do not confine. */
sandboxPolicy: SandboxExecutionPolicy | undefined
}
/** The outcome of one completed (or killed) foreground run. */
export interface ShellRunResult {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
/**
* True when the executor's own timeout was the FIRST cause to cut the command
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
* both the timeout and the caller's cancellation, so a timeout and an abort
* racing before process close report the single first-abort cause, not both
* (see the [timeout-library Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
*/
timedOut: boolean
/**
* True when the caller's `AbortSignal` was the FIRST cause to kill the command
* (and it was not the executor's own timeout). Mutually exclusive with
* {@link timedOut} — see there for the first-cause classification.
*/
aborted: boolean
/** The effective timeout applied to this run (after defaulting/capping). */
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
/** Sandbox execution facts, absent for an unsandboxed executor. */
sandbox?: ShellSandboxInfo
}
/** Lifecycle of a background process. */
export type ShellProcessStatus = 'running' | 'completed' | 'killed'
/** One incremental {@link ShellProcess.readOutput} read. */
export interface ShellProcessRead {
/** Output produced since the previous read (stderr in a marked section). */
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
lossy: boolean
/** Full stdout spill file, when stdout truncation occurred and a safe path is available. */
stdoutSpillPath?: string
/** Full stderr spill file, when stderr truncation occurred and a safe path is available. */
stderrSpillPath?: string
}
/**
* A background process handle returned by {@link ShellExecutor.start}. It is the
* only access path; buffered output remains readable after exit. Composition
* teardown (the subprocess service's disposal) kills running processes and
* awaits {@link done}; an executor-only reload leaves them running.
*/
export interface ShellProcess {
/** Process lifecycle state (settled exactly once). */
status: ShellProcessStatus
/** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
readonly done: Promise<void>
/** Sandbox facts, stamped once a confined process settles. */
sandbox?: ShellSandboxInfo
/**
* Read output produced since the previous read (consuming — consecutive
* reads never re-deliver). Reads that lost data flag `lossy` and point at
* full-stream spill files when available.
*/
readOutput(): ShellProcessRead
/**
* Kill the process group. Returns false when it had already finished
* (no-op); idempotent.
*/
kill(): boolean
}

View File

@@ -0,0 +1,36 @@
/**
* Shared exit-status parse contract: the inverse of the `[exit code: N]` /
* `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append.
* Both tools' presenter suites round-trip their own renderers through this
* parse; this spec pins the parse's own edges (marker-like output, body
* slicing) once, at the seam that owns it.
*/
import { describe, expect, it } from 'vitest'
import { parseExitStatus } from '../src/render.ts'
describe('parseExitStatus', () => {
it('recovers a clean exit 0 with the body verbatim when no marker is present', () => {
expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 })
expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 })
})
it('recovers a non-zero exit and strips only its marker from the body', () => {
expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 })
// The marker needs the leading newline and the end of the string, so a
// clean result whose output merely ENDS in marker-like text is not read
// as a failure and the text stays in the body.
expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 })
})
it('recovers a signal kill ahead of any non-zero exit marker', () => {
expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' })
// A fake signal marker with no leading newline is output, not a kill.
expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 })
})
it('keeps markers no pill shows (timeout) in the body', () => {
expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]'))
.toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 })
})
})

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult } from '@deepseek-ai/dsh-shell'
/**
* Minimal concrete executor: canned foreground results, a hand-built process
* handle. The seam is TASK-FREE (start returns a {@link ShellProcess} handle;
* task semantics live in `ctx.jobs`), so this stub is all an implementation
* owes the abstract class.
*/
class StubExecutor extends ShellExecutor {
resolve(request: ShellExecRequest): ShellExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
sandboxPolicy: request.sandboxPolicy,
}
}
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}
}
start(): ShellProcess {
const proc: ShellProcess = {
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
readOutput: (): ShellProcessRead => ({ delta: '', lossy: false }),
kill: (): boolean => {
if (proc.status !== 'running') return false
proc.status = 'killed'
return true
},
}
return proc
}
}
describe('ShellExecutor service seam', () => {
it('a concrete subclass registers as ctx.shell and serves the abstract API', async () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
const spec = ctx.shell.resolve({ command: 'echo hi' })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxPolicy: undefined })
const result = await ctx.shell.run(spec)
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('ok')
const proc = ctx.shell.start(spec)
expect(proc.status).toBe('running')
expect(proc.readOutput()).toEqual({ delta: '', lossy: false })
expect(proc.kill()).toBe(true)
expect(proc.kill()).toBe(false) // already settled → no-op
await proc.done
})
it('reports no default sandbox mode from the task-free base seam', async () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
expect(ctx.shell.sandboxMode).toBeUndefined()
})
it('loading a second implementation throws (one bash service per context — cordis standard)', async () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
class SecondExecutor extends StubExecutor {}
await expect(ctx.plugin(SecondExecutor)).rejects.toThrow(/service "shell" has been registered/)
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../settings/settings"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
English | [中文](README.zh.md)
Model-facing `bash(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `backendType` | `shell` | Registered PTY backend used for each Agent shell. |
| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. |
| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. |
| `description` | Persistent-shell description | Model-facing environment contract. |
## Model Experience
### Tool schema
#### What the model sees
The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
#### Token effect
Fixed schema cost while `bash` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### What the model sees
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
#### Token effect
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
#### KV Cache effect
Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real PTY backend.
- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
[English](README.md) | 中文
模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.terminals` shell。该包拥有工具约定和 shell 复用PTY 后端与沙箱策略由部署选择。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 |
| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 |
| `maxOutputChars` | `16000` | 命令输出最多保留的字符数;固定诊断会在此后追加。 |
| `description` | 持久 shell 描述 | 面向模型的环境约定。 |
## 模型体验
### 工具 schema
#### 模型所见
生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段persona 与环境指导由部署负责。
#### Token 影响
`bash` 可见时产生固定的 schema 成本。
#### KV Cache 影响
配置的描述与 schema 不变时前缀稳定。
### 工具结果
#### 模型所见
每个 Agent 的命令共享一个 shell因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell并报告该重置。
#### Token 影响
随数据变化。`maxOutputChars` 限制保留的命令输出;固定的截断、前缀丢失、状态、超时与重置诊断可能使结果更长。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 工具需要拥有它的 Agent 和真实 PTY 后端。
- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。

View File

@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-tool-bash-persistent",
"description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/tool-bash-persistent"
},
"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"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,445 @@
/**
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
* @module @deepseek-ai/dsh-tool-bash-persistent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
const SCROLLBACK_PAGE_LINES = 1_000
const POLL_INTERVAL_MS = 25
const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
interface ResolvedConfig {
backendType: string
timeoutMs: number
maxOutputChars: number
description: string
}
interface CommandMarkers {
start: string
end: string
}
interface RetainedOutput {
text: string
truncated: boolean
}
interface CapturedOutput {
text: string
incomplete: boolean
exitCode?: number
}
interface PersistentShells {
get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
reset(owner: Agent, reason: string): Promise<void>
}
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
if (content.length <= maxOutputChars && !incomplete) return content
return content.length <= maxOutputChars
? content + TRUNCATED_MESSAGE
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
function markers(): CommandMarkers {
const nonce = randomUUID()
return {
start: `__DSH_PERSISTENT_BASH_START_${nonce}__`,
end: `__DSH_PERSISTENT_BASH_END_${nonce}:`,
}
}
function quoteForBash(value: string): string {
return `$'${value
.replaceAll('\\', '\\\\')
.replaceAll("'", "\\'")
.replaceAll('\r', '\\r')
.replaceAll('\n', '\\n')}'`
}
function wrapCommand(command: string, marker: CommandMarkers): string {
// Keep the wrapper on one physical line. An interactive bash prints PS2 for
// embedded newlines before executing the buffer, which would leak terminal
// prompts and marker source text into the model-facing result.
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
}
function stripPrompt(text: string): string {
let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
}
function commandOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
): CapturedOutput | undefined {
const text = snapshot.text
const end = text.lastIndexOf(marker.end)
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
if (status === undefined) return undefined
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0,
exitCode: Number(status),
}
}
function promptCompleted(result: TerminalSendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
fallback: string,
fallbackTruncated = false,
): CapturedOutput {
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: false,
}
}
const fallbackStart = fallback.lastIndexOf(marker.start)
const afterStart = fallbackStart < 0
? fallback
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return {
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
}
function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
return page.lineEnd
}
function retainedScrollback(
ctx: Context,
owner: Agent,
id: TerminalSessionId,
latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput {
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
let offset = latest.lineEnd
let truncated = latest.truncated
while (true) {
if (offset >= latest.totalLines) break
const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
truncated ||= page.truncated
if (page.text.length > 0) pages.unshift(page.text)
const next = nextScrollbackOffset(page, offset)
if (next === undefined || next >= page.totalLines) break
offset = next
}
return { text: pages.join('\n'), truncated }
}
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
const withPrefix = output.incomplete && output.text.length > 0
? LOST_PREFIX_MESSAGE + rendered
: rendered
const marker = output.exitCode !== undefined && output.exitCode !== 0
? `[exit code: ${output.exitCode}]`
: undefined
return appendStatusMarker(withPrefix, marker)
}
function appendStatusMarker(content: string, marker: string | undefined): string {
if (marker === undefined) return content
return content.length === 0 ? marker : `${content}\n${marker}`
}
function renderShellExitStatus(
content: string,
exitCode: number | null,
signal: NodeJS.Signals | null,
): string {
const marker = signal !== null
? `[shell killed by signal: ${signal}]`
: exitCode !== null
? `[shell exited: code ${exitCode}]`
: '[shell exited]'
return appendStatusMarker(content, marker)
}
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
const live = new Map<Agent, TerminalSessionId>()
const creating = new Set<Promise<TerminalSessionId>>()
const ownerCleanupInstalled = new WeakSet<Agent>()
const lifecycle = new AbortController()
const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
await ctx.terminals.kill(owner, id, reason)
}
ctx.effect(() => async () => {
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
await Promise.all(closing)
live.clear()
}, 'tool-bash-persistent shell cleanup')
const reset = async (owner: Agent, reason: string): Promise<void> => {
pending.delete(owner)
const id = live.get(owner)
live.delete(owner)
if (id !== undefined) await close(owner, id, reason)
}
const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
const existing = pending.get(owner)
if (existing !== undefined) return existing
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
const creation = (async () => {
try {
const cwd = owner.session.header.cwd
const spawned = await ctx.terminals.spawn(owner, {
type: config.backendType,
...cwd === undefined ? {} : { cwd },
}, combinedSignal)
live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner)
owner.ctx.effect(() => () => {
pending.delete(owner)
live.delete(owner)
}, 'tool-bash-persistent owner cache cleanup')
}
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true,
signal: combinedSignal,
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
throw new Error('persistent bash shell did not accept initialization')
}
return spawned.sessionId
} catch (error: unknown) {
await reset(owner, 'persistent bash initialization failed')
throw error
}
})()
const tracked = creation.finally(() => {
creating.delete(tracked)
})
creating.add(tracked)
pending.set(owner, tracked)
return tracked
}
return { get, reset }
}
async function executeCommand(
ctx: Context,
shells: PersistentShells,
owner: Agent,
command: string,
config: ResolvedConfig,
upstream: AbortSignal,
): Promise<string> {
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
const id = await shells.get(owner, commandDeadline.signal)
const marker = markers()
const wrapped = wrapCommand(command, marker)
let first = true
let fallback = ''
let fallbackTruncated = false
while (true) {
let operation
let result
try {
operation = ctx.terminals.startSend(owner, id, {
text: first ? wrapped : '',
submit: first,
signal: commandDeadline.signal,
})
first = false
result = await operation.done
} catch (error: unknown) {
await shells.reset(owner, 'persistent bash send failed')
throw error
}
const incremental = operation.readOutput()
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
fallbackTruncated ||= incremental.truncated || result.truncated
const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
if (timedOut !== undefined) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
const partial = renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent bash command timed out')
return [
// TODO: Report a timeout only; this signal does not establish an OOM.
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
partial,
SHELL_RESET_MESSAGE,
].join('\n')
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent bash command aborted')
commandDeadline.signal.throwIfAborted()
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
}
if (result.sessionStatus.kind === 'exited') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
await shells.reset(owner, 'persistent bash shell exited')
return [
renderShellExitStatus(
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
result.sessionStatus.exitCode,
result.sessionStatus.signal,
),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
if (promptCompleted(result)) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
}
await pause()
}
}
/**
* Register the model-facing persistent `bash` tool.
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
* @param config - selected PTY backend and command deadline.
*/
function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
const shells = persistentShells(ctx, config)
const queues = new WeakMap<Agent, Promise<void>>()
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
const prior = queues.get(owner) ?? Promise.resolve()
const run = prior.then(operation, operation)
const tail = run.then(() => undefined, () => undefined)
queues.set(owner, tail)
try {
return await run
} finally {
if (queues.get(owner) === tail) queues.delete(owner)
}
}
ctx.tools.register(defineTool({
name: 'bash',
description: config.description,
parameters: {
command: {
type: 'string',
required: true,
description: 'The bash command to run. Relative path is preferred in the command.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
const owner = exec.agent
if (owner === undefined) throw new Error('bash requires an owning agent session')
return serialized(owner, async () => {
exec.signal.throwIfAborted()
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
})
},
presentCall: args => ({ card: 'terminal', title: args.command }),
}))
}
export const name = 'tool-bash-persistent'
export const inject = ['tools', 'terminals']
/** Configuration for the persistent Bash tool. */
export interface Config {
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
backendType?: string
/** Wall-clock limit for one command (default 300000). */
timeoutMs?: number
/** Maximum returned command-output characters before clipping (default 16000). */
maxOutputChars?: number
/** Model-facing tool description; deployments may describe their environment. */
description?: string
}
/** Runtime configuration schema for the persistent Bash tool. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
timeoutMs: z.number().default(300_000),
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
})
/** Register one owner-scoped persistent `bash` tool. */
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
backendType: config.backendType ?? 'shell',
timeoutMs: config.timeoutMs ?? 300_000,
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
}
if (resolved.backendType.trim().length === 0) {
throw new Error('tool-bash-persistent: backendType must be non-empty')
}
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer')
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer')
}
if (resolved.description.trim().length === 0) {
throw new Error('tool-bash-persistent: description must be non-empty')
}
registerPersistentBash(ctx, resolved)
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`.
* @module @deepseek-ai/dsh-tool-bash-persistent/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent'
/** Cordis companion plugin name. */
export const name = 'tool-bash-persistent-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the adapter's private owner-to-shell cache has no
* observable event or data relation. Lifecycle tests prove its cleanup without
* adding a public API solely for an invariant.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,161 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
function agent(ctx: Context, cwd: string): Agent {
const id = SessionId('persistent-bash-loader-agent')
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
suite('persistent Bash through a real cordis.yml Loader composition', () => {
it('preserves cwd and environment across calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-terminal'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-subprocess-local'",
"- name: '@deepseek-ai/dsh-terminal-bash'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 100',
' handoffGraceMs: 100',
' scrollbackLines: 20000',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-bash-persistent'",
' config:',
' timeoutMs: 5000',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context, root)
const signal = new AbortController().signal
const execute = (id: string, command: string) => context!.tools.execute({
signal,
callId: CallId(id),
name: 'bash',
arguments: { command },
agent: owner,
})
expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
expect(observed).not.toContain('DSH_PERSISTENT_BASH')
const multiline = text(await execute(
'multiline',
'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"',
))
expect(multiline).toBe("line one:it's fine")
expect(multiline).not.toContain('DSH_PERSISTENT_BASH')
const heredoc = text(await execute(
'heredoc',
"cat <<'EOF'\nalpha\nbeta\nEOF",
))
expect(heredoc).toBe('alpha\nbeta')
const large = text(await execute('large-output', 'seq 1 12050'))
expect(large.startsWith('1\n2\n3\n')).toBe(true)
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next bash call starts from the workspace')
expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
}, 20_000)
})

View File

@@ -0,0 +1,572 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackend,
TerminalBackendSession,
TerminalReadRequest,
TerminalSendOperation,
TerminalSendRequest,
TerminalSessionStatus,
TerminalSignal,
TerminalWaitReason,
} from '@deepseek-ai/dsh-terminal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
const contexts: Context[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
function agent(ctx: Context, cwd: string | undefined): Agent {
const id = SessionId(`persistent-bash-owner-${callNumber}`)
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], {
version: 0,
id,
createdAt: 0,
...cwd === undefined ? {} : { cwd },
})
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function call(
ctx: Context,
owner: Agent | undefined,
command: string,
signal = new AbortController().signal,
) {
return ctx.tools.execute({
signal,
callId: CallId(`persistent-bash-${++callNumber}`),
name: 'bash',
arguments: { command },
...owner === undefined ? {} : { agent: owner },
})
}
type StubMode =
| 'normal'
| 'prompt-only'
| 'prompt-crlf'
| 'empty-read'
| 'stalled-read'
| 'exit'
| 'signal-exit'
| 'unknown-exit'
| 'wait-for-abort'
| 'end-on-abort'
| 'idle-then-normal'
| 'large'
| 'nonzero'
| 'torn-status'
| 'finish-torn-status'
| 'end-only'
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
| 'incremental-fallback'
| 'empty-page-after-latest'
| 'paged-scrollback'
class StubPtySession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
closed: string[] = []
mode: StubMode
sends = 0
pendingText = ''
historyTruncated = false
constructor(mode: StubMode) {
this.mode = mode
}
startSend(request: TerminalSendRequest): TerminalSendOperation {
this.sends += 1
if (request.text.startsWith('stty -echo')) {
if (this.mode === 'init-exit') {
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
return this.operation(Promise.resolve(this.result('', 'session_exit')))
}
if (this.mode === 'init-timeout') {
return this.operation(Promise.resolve(this.result('', 'timeout')))
}
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
}
if (this.mode === 'send-error') throw new Error('stub send failed')
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => {
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0]
const output = this.mode === 'end-on-abort'
? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
: 'partial output'
this.scrollback += output
resolve(this.result(output, 'stdin_read'))
}, { once: true })
})
return this.operation(done)
}
if (this.mode === 'idle-then-normal') {
this.mode = 'normal'
this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
}
if (this.mode === 'prompt-after-idle') {
if (request.text.length > 0) {
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
const output = `${start ?? ''}\npartial syntax output\n`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
const output = `bash: syntax error\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
const output = `bash: syntax error${newline}${this.motd}${newline}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const sent = request.text.length > 0 ? request.text : this.pendingText
this.pendingText = ''
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
if (this.mode === 'incremental-fallback') {
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
}
if (this.mode === 'torn-status') {
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
this.scrollback += output
this.mode = 'finish-torn-status'
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
if (this.mode === 'finish-torn-status') {
const output = `7\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'end-only') {
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const commandOutput = this.mode === 'large'
? 'x'.repeat(100)
: this.mode === 'nonzero' ? '' : 'hello from stub'
const exitCode = this.mode === 'nonzero' ? 7 : 0
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
this.scrollback += output
if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
const exitedOutput = `${start ?? ''}\nhello from stub\n`
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
this.statusValue = this.mode === 'signal-exit'
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
: this.mode === 'exit'
? { kind: 'exited', exitCode: 9, signal: null }
: { kind: 'exited', exitCode: null, signal: null }
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
}
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
read(request: TerminalReadRequest) {
if (this.mode === 'empty-read') {
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'stalled-read') {
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
}
const lines = this.scrollback.split('\n')
if (this.mode === 'paged-scrollback') {
const offset = request.offset ?? 0
const end = lines.length - offset
const start = Math.max(0, end - 3)
const returnedLines = end - start
return {
text: lines.slice(start, end).join('\n'),
totalLines: lines.length,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: this.historyTruncated,
}
}
return {
text: this.scrollback,
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
lineBegin: 0,
lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
truncated: this.historyTruncated,
}
}
signal(_signal: TerminalSignal) {
return Promise.resolve({ delivered: true as const, targetPgid: 123 })
}
status() {
return this.statusValue
}
async close(reason: string) {
this.closed.push(reason)
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
private result(viewport: string, waitReason: TerminalWaitReason) {
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
}
private operation(done: Promise<ReturnType<StubPtySession['result']>>, delta = ''): TerminalSendOperation {
return {
done,
readOutput: () => ({ delta, truncated: false }),
cancel: () => false,
}
}
}
function stubBackend(initialMode: StubMode = 'normal') {
const sessions: StubPtySession[] = []
const backend: TerminalBackend = {
type: 'stub',
async spawn() {
if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
const session = new StubPtySession(initialMode)
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(
config: ToolBashPersistent.Config = { backendType: 'stub' },
initialMode: StubMode = 'normal',
) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const stub = stubBackend(initialMode)
ctx.terminals.registerBackend(stub.backend)
const fiber = await ctx.plugin(ToolBashPersistent, config)
return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
}
describe('tool-bash-persistent', () => {
it('registers a configurable schema and reuses one owner shell', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
description: 'deployment-specific persistent shell',
})
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
expect(schema?.description).toBe('deployment-specific persistent shell')
expect(schema?.parameters).toMatchObject({
required: ['command'],
properties: { command: { type: 'string' } },
})
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
.toEqual({ card: 'terminal', title: 'pwd' })
expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(1)
expect(stub.sessions[0]?.sends).toBe(3)
const ownerWithoutCwd = agent(ctx, undefined)
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
expect(ctx.tools.get('bash')).toBeUndefined()
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
})
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'idle-then-normal'
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
const missingStart = text(await call(ctx, owner, 'recover marker'))
expect(missingStart).toContain('recovered')
expect(missingStart).toContain('beginning of this command output was dropped')
expect(missingStart).toContain('<response clipped>')
session.mode = 'large'
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
session.mode = 'nonzero'
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
session.mode = 'exit'
const exited = text(await call(ctx, owner, 'exit'))
expect(exited).toContain('hello from')
expect(exited).toContain('[shell exited: code 9]')
expect(exited).not.toContain('[exit code: 9]')
expect(exited).toContain('next bash call starts from the workspace')
expect(session.closed).toContain('persistent bash shell exited')
await call(ctx, owner, 'new shell')
expect(stub.sessions).toHaveLength(2)
const replacement = stub.sessions[1]!
replacement.mode = 'signal-exit'
expect(text(await call(ctx, owner, 'kill shell')))
.toContain('[shell killed by signal: SIGTERM]')
await call(ctx, owner, 'another shell')
expect(stub.sessions).toHaveLength(3)
const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId
expect(externallyClosed).toBeDefined()
await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup')
await fiber.dispose()
expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
})
it('waits for status digits after a torn completion marker', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'torn-status'
stub.sessions[0]!.scrollback = ''
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
})
it('reports a shell exit when the backend has no code or signal', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'unknown-exit'
expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
})
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'end-only'
session.scrollback = ''
expect(text(await call(ctx, owner, 'missing start')))
.toContain('beginning of this command output was dropped')
session.mode = 'empty-read'
expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
session.mode = 'stalled-read'
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
session.mode = 'empty-page-after-latest'
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
})
it('assembles retained output across backward scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'paged-scrollback'
session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'prompt-after-idle'
session.scrollback = ''
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})
it('does not attribute old scrollback truncation to a complete current command', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.historyTruncated = true
const result = text(await call(ctx, owner, 'short command'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('<response clipped>')
expect(result).not.toContain('beginning of this command output was dropped')
})
it('closes a timed-out shell and reports bounded partial output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'wait-for-abort'
const result = await call(ctx, owner, 'hang')
expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
expect(text(result)).toContain('partial output')
expect(text(result)).toContain('next bash call starts from the workspace')
expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
})
it.each(['wait-for-abort', 'end-on-abort'] as const)(
'cancels %s work, resets the shell, and releases a queued call',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = mode
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
setTimeout(() => {
controller.abort(new Error('caller stopped'))
}, 5)
expect((await cancelled).isError).toBe(true)
expect(text(await queued)).toBe('hello from stub')
expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
expect(stub.sessions).toHaveLength(2)
},
)
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
},
)
it('clears a failed spawn without trying to close an unpublished shell', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
})
it('resets a cached shell after startSend fails', async () => {
const { ctx, owner, stub } = await setup()
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'send-error'
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const spawnStarted = Promise.withResolvers<undefined>()
const spawnAborted = Promise.withResolvers<undefined>()
ctx.terminals.registerBackend({
type: 'slow',
spawn: spec => new Promise((_resolve, reject) => {
spawnStarted.resolve(undefined)
spec.signal?.addEventListener('abort', () => {
spawnAborted.resolve(undefined)
const reason: unknown = spec.signal?.reason
reject(reason instanceof Error
? reason
: new Error('slow PTY spawn aborted', { cause: reason }))
}, { once: true })
}),
})
const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
const owner = agent(ctx, '/workspace')
const running = call(ctx, owner, 'pwd')
await spawnStarted.promise
await fiber.dispose()
await spawnAborted.promise
expect((await running).isError).toBe(true)
expect(ctx.terminals.list(owner)).toEqual([])
})
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
expect(() => {
ToolBashPersistent.apply(new Context(), { backendType: '' })
}).toThrow('backendType must be non-empty')
expect(() => {
ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
}).toThrow('timeoutMs must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/agent" },
{ "path": "../../core/tools" },
{ "path": "../../terminal/terminal" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../util/timeout" }
]
}

View File

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

View File

@@ -0,0 +1,139 @@
# @deepseek-ai/dsh-tool-bash
English | [中文](README.zh.md)
The model-facing `bash` tool registered over the `ctx.shell` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.jobs` runtime and controlled through `job_output`, `job_list`, and `job_kill` from `@deepseek-ai/dsh-tool-jobs`.
Requires a loaded executor Service provider (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-shell-env`](../shell-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 package-internal.
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
## Tools
### `bash`
| Arg | Type | Notes |
|---|---|---|
| `command` | string (required) | Run via `bash -c`. 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 filesystem identity of 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 job id immediately; no timeout applies. |
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.shell.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.shell.resolve()` before execution, so the Service Definition (`ShellExecSpec`) receives explicit `workdir`/`timeoutMs` values. 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()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
### Managed shell environment
Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-shell-env`](../shell-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 `ShellExecRequest.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`.
The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process or `{ kind: 'background', jobId }` for a published task. The Native renderer preserves the text above, including exactly `started background job <id>`; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `ShellRunResult` and carry their spill paths.
When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job 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, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a job id; the generic `job_*` tools own their own cards. These presenters are pure and replay-safe.
## The tool builds its request from named args only
`ShellExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md).
## Permissions and escalation
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
## Per-session mode switching
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. The policy owner contributes the current capability-neutral standing mode; denial results still own the operation-specific effective mode and retry guidance. See the [`dsh-shell` fold](../shell/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt
#### What the model sees
Every request in this plugin's registration scope contains the bash guidance below. The policy owner contributes current sandbox state through its cache-safe runtime context rather than changing this section. Scoped tool restrictions can hide the schemas without removing this independently registered section.
##### Bash guidance
```markdown
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
```
#### Token effect
Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
#### 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; sandbox mode switches do not.
### Tool schemas
#### What the model sees
The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
#### Token effect
Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
#### KV Cache effect
Prefix-stable while visibility, background support, and executor sandbox capabilities are unchanged. A restriction, config change, or executor change may invalidate reuse from the first changed tool definition.
### Foreground result
#### What the model sees
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
#### 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 job context and results
#### What the model sees
Start returns exactly `started background job <jobId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic job runtime. [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md) owns the visible status line, completion notice, listing, and cancellation response.
#### Token effect
The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
#### 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 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 jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `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
Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
#### 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
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `job_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -0,0 +1,139 @@
# @deepseek-ai/dsh-tool-bash
[English](README.md) | 中文
模型侧 `bash` 工具,注册在 `ctx.shell` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.jobs` 运行时,并通过 `job_output``job_list``job_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-jobs` 提供。
需要加载执行器 Service provider例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-shell-env`](../shell-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约定是 bash 方言——请挂载能解析 bash 的执行器。
包根只公开 Cordis 插件约定(`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部。
插件还会提供 `tool:bash` 提示词段落(顺序 105检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
## 工具
### `bash`
| 参数 | 类型 | 说明 |
|---|---|---|
| `command` | string必填 | 通过 `bash -c` 运行。调用之间不保留状态;请使用 `workdir`,不要使用 `cd`。 |
| `description` | string必填 | 用一行主动语态概述命令510 个词),仅用于 UI日志显示不影响执行。 |
| `timeoutMs` | number | 以毫秒为单位覆盖超时时间。执行器会应用其配置的默认值和上限。 |
| `workdir` | string | 本次调用的工作目录。默认为调用方 agent智能体会话 cwd 的文件系统标识(`session.header.cwd`),使每个会话都在自己的工作区中运行;相对 `workdir` 也以同一标识为基准解析。 |
| `run_in_background` | boolean | 立即返回 job id不应用超时。 |
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.shell.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
执行前,`command``workdir``timeoutMs` 会通过 `ctx.shell.resolve()` 依据执行器配置默认值完成解析,因此 Service Definition`ShellExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
### 托管 shell 环境
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-shell-env`](../shell-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约定——贡献方注册、重复键/未声明键的显式报错机制、内置项保留与贡献方示例——载于该包的 README。快照通过专用的 `ShellExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`
已完成前台进程的规范成功值为 `{ kind: 'foreground', ...ShellRunResult }`,已发布任务则为 `{ kind: 'background', jobId }`。Native renderer 保留上述文本,包括精确的 `started background job <id>`;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `ShellRunResult` 的采集限制,并携带其 spill 路径。
`run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose资源释放清理此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。
## UI 展示
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 job id因此使用通用执行卡片通用 `job_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
## 工具仅使用具名参数构建请求
`ShellExecRequest` 携带可选的 `stdoutMaxBytes``stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes``stdin``env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)。
## 权限与升权
除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall瀑布式事件负责参见 docs/architecture.md
需要升权的 bash 调用会在执行前解析 `ctx.approval``allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由见 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 逐会话模式切换
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责特定于该操作的有效模式与重试引导。参见 [`dsh-shell` 折叠计算](../shell/README.md)和[沙箱切换约定](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验
### 系统提示词
#### 模型看到的内容
此插件注册作用域内的每个请求都包含下方 bash 指引。策略归属方通过自身的缓存安全运行时上下文贡献当前沙箱状态,而不改变此段落。作用域工具限制可以隐藏 schema但不会移除这个独立注册的段落。
##### Bash 指引
```markdown
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
```
#### Token 影响
插件活跃期间,每个请求都会产生少量固定输入开销,不受沙箱模式或模式切换影响。
#### KV Cache 影响
只要注册作用域和提示词文本不变,前缀即可稳定复用。插件激活或 dispose 可能从此提示词段落开始使复用失效;沙箱模式切换不会。
### 工具 schema
#### 模型看到的内容
模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions``justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。
#### Token 影响
工具可见的每个请求都会产生固定 schema 开销;沙箱支持会增加升权字段及其条件说明段落。
#### KV Cache 影响
只要可见性、后台支持和执行器沙箱能力保持不变,前缀即可稳定复用。限制、配置或执行器发生变化时,可能从首个变化的工具定义开始使复用失效。
### 前台结果
#### 模型看到的内容
renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]``[sandbox: file access denied under <mode> mode]``[timed out after <timeoutMs>ms]``[killed by signal: <signal>]``[exit code: <exitCode>]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。
#### Token 影响
调用前结果 token 为零。每条流的输出有界每个已输出行则会保留在历史中直至压缩compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### 后台任务上下文与结果
#### 模型看到的内容
启动会精确返回 `started background job <jobId>`。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`、沙箱事实,以及 `exit code: <exitCode>``signal: <signal>` 等终止详情。[`dsh-tool-jobs`](../../jobs/tool-jobs/README.md) 负责模型可见的状态行、完成通知、列表和取消响应。
#### Token 影响
启动确认很短并会保留;收集到的输出依数据而定,并受执行器流缓冲区限制。消费式读取不会重复先前输出。
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### 工具错误
#### 模型看到的内容
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``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 jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs``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 影响
只有失败调用会增加这些保留 token升权被拒时命令不会运行因此不会添加命令输出。
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与延期工作
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill并且该行会从卡片正文中丢失因为解析会把它当作自己消耗的标记这是仅影响展示的已知残留问题。
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `job_kill`,或依赖持有者/服务的 dispose。

View File

@@ -0,0 +1,73 @@
{
"name": "@deepseek-ai/dsh-tool-bash",
"description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/tool-bash"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-shell-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-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,27 @@
/**
* Generic-task adaptation for background bash process handles.
*
* @module @deepseek-ai/dsh-tool-bash/background
*/
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
/**
* 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.jobs` registration.
*/
export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen ShellProcess with an explicit
// infrastructure-failure outcome, then map it to task `failed`. Restricted
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
// still alias a signal-less kill; 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}` }
}

View File

@@ -0,0 +1,394 @@
/**
* Model-facing Consumer of the `ctx.shell` capability seam. Background calls
* register process handles with `ctx.jobs`; their work uses job cancellation
* rather than the tool-call signal after an id is returned.
*
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
* sandboxing executors; see docs/architecture.md § Where new behavior goes.
* @module @deepseek-ai/dsh-tool-bash
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/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-system-prompt'
import type {} from '@deepseek-ai/dsh-jobs'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-shell-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-shell'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
}
/** Runtime configuration schema for the bash 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 BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string
justification?: string
}
function validateBashArgs(args: BashToolArgs): 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)}`)
}
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
// the shared rule both enforcing families validate identically.
validateEscalationArgs(args.sandbox_permissions, args.justification)
}
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
+ `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
if (escalationModes.length === 0) return base
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
+ 'A rejected escalation is final for that command — stop and explain, never work around '
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/**
* Present foreground calls as terminals and background starts as generic cards.
* The command remains the title on both paths; foreground cwd is passed through
* for the bridge to resolve, while background descriptions remain card content.
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView {
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 } : {},
}
}
/**
* Present completed foreground output as a terminal; background acknowledgements
* and execution errors use generic fenced output without an exit-status pill.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const raw = block.text
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// Background acknowledgements and errors have no terminal exit status.
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
// The exit marker becomes the card's exit pill, so it leaves the output body.
const { body, ...exit } = parseExitStatus(raw)
return { card: 'terminal', output: body, ...exit }
}
/**
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
* otherwise use the filesystem identity of the session cwd and leave executor
* defaulting as the fallback. A resolved sandbox-policy root wins so workdir
* and confinement use the exact same per-call identity.
*/
function resolveWorkdir(
modelWorkdir: string | undefined,
exec: { agent?: Agent },
policyWorkspaceRoot?: string,
): string | undefined {
const headerCwd = exec.agent?.session.header.cwd
const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd))
if (modelWorkdir === undefined) return sessionCwd
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
return resolvePath(sessionCwd, modelWorkdir)
}
return modelWorkdir
}
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
function canonicalBashResult(result: ShellRunResult) {
const output = (stream: ShellRunResult['stdout']) => ({
text: stream.text,
truncated: stream.truncated,
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
})
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
/** Canonical background-handle properties shared by the bash output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
jobId: { type: 'string', required: true },
} as const
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && sandboxPolicy === undefined) {
throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes, delegating the shared fail-closed sequence (strict
* widening, channel resolution, outcome mapping) to
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval
* ingredients. The shared policy resolver is required whenever the executor
* advertises confinement, so a split composition fails at tool-plugin load.
*/
const approveBashEscalation = (
mode: string,
justification: string,
exec: ToolExecution,
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
approver: ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName: 'bash',
signal: exec.signal,
},
)
}
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
ctx.systemPrompt.section({
name: 'tool:bash',
order: 105,
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
})
ctx.tools.register(defineTool({
name: 'bash',
description: bashDescription(backgroundEnabled, escalationModes),
parameters: {
command: { type: 'string', required: true, description: 'The bash 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"; "npm install" → "Install package dependencies".',
},
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 job id immediately (collect with job_output, stop with job_kill). No timeout applies.' },
} : {},
...escalationModes.length > 0 ? {
sandbox_permissions: {
type: 'string' as const,
enum: [...escalationModes],
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string' as const,
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
},
} : {},
},
output: {
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' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background job ${value.jobId}`
: renderResult(value as { kind: 'foreground' } & ShellRunResult, escalationModes),
}],
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
const standingPolicy = resolveSandboxPolicy(exec)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
const dshEnv = ctx.shellEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv,
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
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 jobs = ctx.get('jobs')
if (jobs === undefined) {
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
}
// The caller owns cancellation until ctx.jobs commits detached ownership.
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
// Task preflight finishes before the starter can spawn a process.
const id = jobs.start({
kind: 'bash',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.shell.start(ctx.shell.resolve(request))
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
return { kind: 'background' as const, jobId: id }
}
const result = await ctx.shell.run(ctx.shell.resolve({
...request,
signal: exec.signal,
}))
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,
presentResult: presentBashResult,
}))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`.
* @module @deepseek-ai/dsh-tool-bash/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash'
/** Cordis companion plugin name. */
export const name = 'tool-bash-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
* mutation/read; it publishes no independent snapshot that a companion could cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,103 @@
/**
* Model-facing result rendering for the bash tool.
*
* @module @deepseek-ai/dsh-tool-bash/render
*/
import type { ShellProcessRead, ShellRunResult, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/** 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)'}]`
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, 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.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: ShellRunResult,
escalationModes: readonly SandboxMode[] = [],
): 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[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push(escalationHintMarker('command'))
}
}
// A command may trap SIGTERM 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 `job_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. Empty-delta
* rendering (`(no new output)`) is the generic job controller's job.
* @param read - one incremental read from the process handle.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderProcessRead(
read: ShellProcessRead,
sandbox?: ShellSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): 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 (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}
/**
* The exit-status parse is the shared marker-contract half of the shell-tool
* rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses
* it (its renderer emits the same markers). Re-exported here to keep
* `../src/render.ts` a single import root for bash-tool consumers.
*/
export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-shell'

View File

@@ -0,0 +1,242 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
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 LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop integration: a scripted mock model drives the REAL bash tool
* through the agent loop, exercising the same execution paths a live model would
* (tool/call + tool/result session events, the generic `ctx.jobs` runtime,
* agent.inject completion notices).
*/
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
if (sessionRoot !== undefined) {
await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' })
}
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
const dirs: string[] = []
afterEach(() => {
vi.unstubAllEnvs()
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}
/** Find a session event by type, narrowed; throws when absent. */
function findEvent<T extends SessionEvent['type']>(
log: SessionEvent[],
type: T,
position: 'first' | 'last' = 'first',
): Extract<SessionEvent, { type: T }> {
const found = position === 'first'
? log.find(event => event.type === type)
: log.findLast(event => event.type === type)
if (!found) throw new Error(`no ${type} event in the session log`)
return found as Extract<SessionEvent, { type: T }>
}
function resultText(event: SessionEvent): string {
if (event.type !== 'tool/result') return ''
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Poll until `predicate` holds (background settlement races turn end). */
async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`condition not met within ${timeoutMs}ms`)
}
describe('bash tool through the agent loop', () => {
it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-'))
dirs.push(root)
const dshHome = join(root, 'dsh-home')
vi.stubEnv('DSH_STALE_PARENT', 'stale')
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', {
command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi',
description: 'inspect session environment',
}),
textResponse('Session environment inspected.'),
])
const ctx = await harness(adapter, root, dshHome)
const handle = await ctx.agents.create({
sessionId: SessionId('session-env-id'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
await ctx.sessions.flush(agent.session)
expect(existsSync(location!.path)).toBe(true)
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
await handle.dispose()
})
it('foreground: model calls bash, sees the result, replies', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'),
textResponse('The command printed integration-ok.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const log = events(agent)
const toolCall = findEvent(log, 'tool/call')
expect(toolCall.data.name).toBe('bash')
const toolResult = findEvent(log, 'tool/result')
expect(toolResult.data.message.content[0].isError).toBe(false)
expect(resultText(toolResult)).toBe('integration-ok\n')
// The second model call saw the tool result in its derived history.
const lastRequest = adapter.requests.at(-1)
const toolResultBlocks = (lastRequest?.messages ?? [])
.flatMap(message => message.content)
.filter(block => block.type === 'tool-result')
expect(toolResultBlocks).toHaveLength(1)
const finalMessage = findEvent(log, 'assistant/message', 'last')
expect(finalMessage.data.message.content.some(
block => block.type === 'text' && block.text.includes('integration-ok'),
)).toBe(true)
})
it('foreground: non-zero exit is reported in the result text, not as isError', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'exit 9', description: 'test command' }),
textResponse('It failed with code 9.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result')
expect(toolResult.data.message.content[0].isError).toBe(false)
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → completion wakes the idle agent → job_output collects it', async () => {
// The command blocks on a sentinel this test creates only after the agent
// has gone idle, so settlement cannot fold into the still-running turn.
// Without that fence a fast command can settle before step 2's pre-step
// claim, which folds the notice into a turn whose scripted reply is final:
// the turn then closes with an empty next-step inbox and the collection
// entries are never reached.
const dir = mkdtempSync(join(tmpdir(), 'dsh-bg-'))
dirs.push(dir)
const sentinel = join(dir, 'release')
// The job id is deterministic (a fresh LocalJobRegistry counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', {
command: `while [ ! -f ${JSON.stringify(sentinel)} ]; do sleep 0.02; done; echo bg-ok`,
description: 'test command',
run_in_background: true,
}),
textResponse('Started it in the background.'),
toolCallResponse('call-2', 'job_output', { job_id: 'bash-1' }),
textResponse('Background job finished.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result')
expect(firstResult.data.message.content[0].isError).toBe(false)
expect(resultText(firstResult)).toBe('started background job bash-1')
// The turn closed with the task still running, so the notice cannot exist yet.
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
e.type === 'user/message' && e.data.source.kind === 'plugin'
expect(events(agent).some(isNotice)).toBe(false)
// Releasing the command now settles it against a provably idle owner. No
// second user message: the wake alone opens the turn that collects it.
writeFileSync(sentinel, '')
const lastResultText = (): string => {
const found = events(agent).findLast(event => event.type === 'tool/result')
return found === undefined ? '' : resultText(found)
}
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
// Two turns: the user's, then the one the completion opened by itself.
expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2)
// The notice carries the gated command as its label, so this pins the id,
// the terminal status, and the producer identity; the verbatim notice text
// and its bounding are pinned in the tool-jobs unit tests.
const notice = events(agent).find(isNotice)!
const noticeText = notice.data.content
.filter(block => block.type === 'text').map(block => block.text).join('')
expect(noticeText).toContain('background job bash-1 (bash: ')
expect(noticeText).toContain('finished [status: completed, exit code: 0]')
expect(notice.data.source).toMatchObject({
kind: 'plugin',
plugin: 'tool-jobs',
form: 'notice',
})
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.message.content[0].isError).toBe(false)
expect(resultText(readResult)).toContain('bg-ok')
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
{
"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": "../../shell/shell"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../shell/shell-env"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}

View File

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

View File

@@ -0,0 +1,126 @@
# @deepseek-ai/dsh-tool-pwsh
English | [中文](README.zh.md)
The model-facing `pwsh` tool registered over the `ctx.shell` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.shell`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic job runtime, the managed `DSH_*` environment through the shared `shell-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker).
Requires a loaded executor implementation and the `shell-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-job 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 job id immediately; no timeout applies. |
| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.shell.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. |
| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.shell.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-shell-env`](../shell-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.shellEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `ShellExecRequest.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, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), 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', ...ShellRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', jobId }` for a published task. The renderer preserves exactly `started background job <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.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job 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 foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-shell`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe.
## Model Experience
### System prompt
#### What the model sees
Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
##### Pwsh guidance
```markdown
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>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[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 job <id>`; subsequent reads and status flow through the generic `job_output`/`job_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes.
#### Token effect
The ack is a fixed short line; job 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>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, 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
- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
- **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.
- **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. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.

View File

@@ -0,0 +1,126 @@
# @deepseek-ai/dsh-tool-pwsh
[English](README.md) | 中文
注册在 `ctx.shell` 执行器 seam 之上的面向模型的 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.shell` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `shell-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
需要已加载的执行器实现与 `shell-env` 插件;两者都存在前工具保持 pending`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
包根只导出 Cordis 插件约定(`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
插件还贡献 `tool:pwsh` 提示词段落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 | 立即返回 job id不适用超时。 |
| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.shell.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed不运行任何内容。 |
| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 |
`command``workdir``timeoutMs` 在执行前经 `ctx.shell.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`
### Managed shell environment
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-shell-env`](../shell-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.shellEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `ShellExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
结果文本包含 stdout、可选的 `[stderr]`然后是适用的截断、sandbox 拒绝组合公开升级能力时带同轮次升级提示、超时、signal 与退出 marker。干净退出0、无 signal不产生 marker空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 仅适用于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...ShellRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', jobId }`。渲染器对后台 ack 精确保留 `started background job <id>`;编程消费者使用类型化字段而不解析渲染文本。
`run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose资源释放清理本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
## UI presentation
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的前台结果同样是 `terminal` 卡:退出 marker 变成卡片的退出状态 pill`exitCode`/`signal`),去 marker 的正文成为卡片输出——与 bash 工具的 terminal 卡故事完全一致,经由 `@deepseek-ai/dsh-shell` 的共享退出状态解析。后台 ack 与执行错误保持 `generic` 卡,以 `console` 围栏包裹渲染输出。这些 presenter 是纯函数且可重放。
## 模型体验
### 系统提示词
#### 模型看到的内容
本插件注册作用域内的每个请求都包含下面的 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 影响
插件激活期间每次请求的固定小额输入成本。
#### KV Cache 影响
注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。
### 工具 schema
#### 模型看到的内容
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。
#### Token 影响
工具可见的每个请求上的固定 schema 成本。
#### KV Cache 影响
可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。
### 前台结果
#### 模型看到的内容
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]``[sandbox: file access denied under <mode> mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after <timeoutMs>ms]``[killed by signal: <signal>]``[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`
#### Token 影响
调用前零结果 token。每个流的输出有界而每条已发出的行保留在历史中直到压缩。
#### KV Cache 影响
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV Cache 条目失效。
### 后台结果
#### 模型看到的内容
后台启动精确渲染为 `started background job <id>`;随后的读取与状态通过通用 `job_output`/`job_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。
#### Token 影响
ack 是固定短行;任务输出按读取有界。
#### KV Cache 影响
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV Cache 条目失效。
### 工具错误
#### 模型看到的内容
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string``invalid description: expected a non-empty string``invalid timeoutMs: expected a positive number, got <value>``invalid escalation: sandbox_permissions requires a justification``invalid escalation: justification is only valid together with sandbox_permissions``invalid justification: expected a non-empty sentence``sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)``background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs``tool call aborted`
#### Token 影响
只有失败的调用会新增这些保留 token被中止的调用不产生命令输出。
#### KV Cache 影响
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作。
- **PowerShell 方言约定** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。

View File

@@ -0,0 +1,70 @@
{
"name": "@deepseek-ai/dsh-tool-pwsh",
"description": "Model-facing pwsh tool over the bash executor seam",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/tool-pwsh"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-shell-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-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,31 @@
/**
* Generic-task adaptation for background pwsh process handles — the shell-agnostic
* twin of `dsh-tool-bash`'s background adaptation.
*
* @module @deepseek-ai/dsh-tool-pwsh/background
*/
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
/* 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.jobs` registration.
*/
export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen ShellProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current contract aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,446 @@
/**
* Model-facing PowerShell Consumer of the `ctx.shell` capability seam. Intended for
* Windows compositions where a PowerShell executor (e.g.
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.shell`; the tool contract is
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
*
* Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
* `run_in_background` execution (background handles register with the
* generic `ctx.jobs` runtime), the managed `DSH_*` environment through the
* shared `shell-env` registry, the per-call sandbox policy resolution (the
* calling session's mode and cwd travel to the confining executor), the
* sandbox-denial rendering with the same-turn escalation surface
* (`sandbox_permissions` + `justification` resolved through
* `ctx.approval`), and the bash marker/truncation rendering story. UI
* presentation mirrors the bash tool's too: a completed foreground call is
* a terminal card with the parsed exit-status pill, using the shared
* exit-status parse from `@deepseek-ai/dsh-shell`.
*
* @module @deepseek-ai/dsh-tool-pwsh
*/
import { isAbsolute, resolve as resolvePath } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
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-system-prompt'
import type {} from '@deepseek-ai/dsh-jobs'
import type {} from '@deepseek-ai/dsh-shell-env'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
import { processOutcome } from './background.ts'
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
import type { RenderablePwshResult } from './render.ts'
declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap {
pwsh: 'pwsh'
}
}
export const name = 'tool-pwsh'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
/** 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
sandbox_permissions?: string
justification?: string
}
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
interface PwshForegroundResult {
kind: 'foreground'
exitCode: number | null
signal: NodeJS.Signals | null
timedOut: boolean
aborted: boolean
timeoutMs: number
stdout: { text: string; truncated: boolean; spillPath?: string }
stderr: { text: string; truncated: boolean; spillPath?: string }
sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean }
}
/* 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)}`)
}
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
// the shared rule both enforcing families validate identically.
validateEscalationArgs(args.sandbox_permissions, args.justification)
}
/* jscpd:ignore-end */
function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
const base = '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. '
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ '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
if (escalationModes.length === 0) return base
// The language-mode and named-pipe contracts below are Windows-restricted-token
// behavior, but the gate is 'any confining executor is mounted'
// (escalationModes non-empty). The conflation is safe today because every
// shipped composition pairing tool-pwsh with a confining executor is
// win32-only; a future POSIX pwsh-sandbox composition must gate both
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
// Agent Note).
return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while '
+ 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
+ 'In both confined modes, programs cannot open named pipes, so a command that captures another '
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
+ 'do not retry the command another way — escalate the exact command once or restructure it to '
+ 'avoid capturing output. '
+ 'Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
+ 'A rejected escalation is final for that command — stop and explain, never work around '
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/**
* 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 Service Definition types into plain JSON data. */
function canonicalPwshResult(result: ShellRunResult): PwshForegroundResult {
const output = (stream: ShellRunResult['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),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
/** Canonical background-handle properties shared by the pwsh output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
jobId: { type: 'string', required: true },
} as const
/* jscpd:ignore-end */
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && sandboxPolicy === undefined) {
throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}
/* jscpd:ignore-end */
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes, delegating the shared fail-closed sequence (strict
* widening, channel resolution, outcome mapping) to
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval
* ingredients. The shared policy resolver is required whenever the
* executor advertises confinement, so a split composition fails at
* tool-plugin load.
*/
const approvePwshEscalation = (
mode: string,
justification: string,
exec: ToolExecution,
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
approver: ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName: 'pwsh',
signal: exec.signal,
},
)
}
/* jscpd:ignore-end */
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, escalationModes),
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */
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 job id immediately (collect with job_output, stop with job_kill). No timeout applies.' },
} : {},
...escalationModes.length > 0 ? {
sandbox_permissions: {
type: 'string' as const,
enum: [...escalationModes],
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string' as const,
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
},
} : {},
},
/* jscpd:ignore-end */
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' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
},
/* jscpd:ignore-end */
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background job ${value.jobId}`
: renderPwshResult(value as RenderablePwshResult, escalationModes),
}],
},
/* 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)
// Description is display metadata; workdir defaults to the caller's session.
const standingPolicy = resolveSandboxPolicy(exec)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv: ctx.shellEnv.collect(exec),
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
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 jobs = ctx.get('jobs')
if (jobs === undefined) {
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
}
// The caller owns cancellation until ctx.jobs commits detached ownership.
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
// Task preflight finishes before the starter can spawn a process.
const id = jobs.start({
kind: 'pwsh',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.shell.start(ctx.shell.resolve(request))
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
return { kind: 'background' as const, jobId: id }
}
const result = await ctx.shell.run(ctx.shell.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 */
/* jscpd:ignore-start -- the completed-result presentation mirrors presentBashResult's by design (Agent Note). */
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
const raw = block.text
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// Background acknowledgements and errors have no terminal exit status.
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
// The exit marker becomes the card's exit pill, so it leaves the output body.
const { body, ...exit } = parseExitStatus(raw)
return { card: 'terminal', output: body, ...exit }
},
/* jscpd:ignore-end */
}))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`.
* @module @deepseek-ai/dsh-tool-pwsh/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh'
/** Cordis companion plugin name. */
export const name = 'tool-pwsh-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,113 @@
/**
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
* `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox
* denial/runner-failure markers (with the same-turn escalation hint), and
* 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 { ShellProcessRead, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (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
sandbox?: ShellSandboxInfo
}
/**
* 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.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @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,
escalationModes: readonly SandboxMode[] = [],
): 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[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push(escalationHintMarker('command'))
}
}
// 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 `job_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.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderPwshProcessRead(
read: ShellProcessRead,
sandbox?: ShellSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): 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 (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,154 @@
/**
* Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
* `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
* process. These verify the world — actual commands run, stdout/stderr come
* back, exit codes render, timeouts abort, background jobs settle through the
* generic job 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 '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import LocalSubprocessRuntime 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-shell-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(ToolRuntime)
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalSubprocessRuntime)
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 job_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 jobId = (started.value as { jobId: string }).jobId
// 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('job_output', { job_id: jobId })
output += text(read)
if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break
await new Promise(resolve => setTimeout(resolve, 50))
}
expect(output).toContain('bg-done')
expect(output).toContain('[status: completed, exit code: 0]')
})
})

View File

@@ -0,0 +1,63 @@
/**
* REAL-composition tier (packages/AGENTS.md): boot the examples-owned
* tool-pwsh Loader fixture as a subprocess through the same app/boot path a
* deployment uses, execute real foreground and background pwsh commands
* through the tool registry, and assert the assembled model-visible surface:
* schema, prompt section, and rendered results. Self-skips when no `pwsh`
* executable exists (a CI accommodation for hosts without PowerShell).
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
// The probe follows the executor's own resolution (Program Files installs on
// Windows are found even when bare `pwsh` is not on PATH).
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
const driver = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/shell/tool-pwsh/driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/shell/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)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
{
"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": "../../shell/shell"
},
{
"path": "../../shell/shell-env"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../shell/shell-env"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}