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:54:38 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 files changed
+21730 -21592

No files matched your search

+6
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
+53
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).
+53
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) 明确暂缓。
+48
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:^"
}
}
+103
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
+22
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))
+42
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 }
}
+183
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
}
+36
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 })
})
})
@@ -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/)
})
})
+30
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"
}
]
}