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:
3281 files changed
+21730
-21592
No files matched your search
@@ -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
|
||||
@@ -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).
|
||||
@@ -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) 明确暂缓。
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user