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