Merge remote-tracking branch 'origin/master' into feat/add-session-data-preview
# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/session.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/llm/token-meter/README.i18n.yaml
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
|
||||
README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c
|
||||
README.zh.md: ffffd97daa911636336ee9a515bf01bad33d31ae
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
|
||||
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -65,11 +65,11 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有该工具的结果会贡献 token。
|
||||
只有所属工具的结果会贡献 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
随该工具的结果仅追加。
|
||||
仅通过所属工具的结果追加。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/README.md
|
||||
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
|
||||
README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea
|
||||
README.md: ef82e9f4684ecf551ac7701d812088dd6b2ef6d0
|
||||
README.zh.md: 84ff244ec3d1ff5d385a3eb334e4cf9f1fe31e03
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
The capability family spans the canonical executor seam, its implementations, the shared shell environment, and the model-facing tools. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `pwsh-local/` | Local PowerShell `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (executable resolution, UTF-8-pinned spawn, Windows termination semantics) | (registers `ctx.bash`) |
|
||||
| `bash-env/` | Tool-independent managed `DSH_*` shell environment registry shared by the shell tools (built-in facts + effect-scoped contributors) | (registers `ctx.bashEnv`) |
|
||||
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
| `tool-pwsh/` | Model-facing PowerShell-dialect `pwsh` schema (behavior mirrors `tool-bash` minus the sandbox surface); background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
|
||||
能力家族横跨规范执行器 seam、其实现、共享 shell 环境与面向模型的工具。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` |
|
||||
| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) |
|
||||
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
|
||||
| `pwsh-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 PowerShell `BashExecutor` 实现(可执行文件解析、UTF-8 固定 spawn、Windows 终止语义) | (注册 `ctx.bash`) |
|
||||
| `bash-env/` | 工具无关的受管 `DSH_*` shell 环境注册表,由 shell 工具共享(内置事实 + 受 effect 作用域约束的 contributor) | (注册 `ctx.bashEnv`) |
|
||||
| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
| `tool-pwsh/` | 面向模型的 PowerShell 方言 `pwsh` schema(行为镜像 `tool-bash`,减去 sandbox 面);后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
|
||||
6
packages/bash/bash-env/README.i18n.yaml
Normal file
6
packages/bash/bash-env/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: aeb33629def3fcc10294bbca19b37d43dcc73a0c
|
||||
51
packages/bash/bash-env/README.md
Normal file
51
packages/bash/bash-env/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of trusted, per-execution `DSH_*` variables that the model-facing shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`) collect into every shell call's environment. Built-in shell facts (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`) are owned by the registry itself; other plugins register additional enumerable facts with effect-scoped disposal, and duplicate ownership or undeclared runtime keys fail loudly.
|
||||
|
||||
The package root exports the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the `BashEnvRegistry` service class and its contributor types; consumers use `ctx.bashEnv` after loading this plugin.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
Every foreground and background model shell call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executors remove all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The shell tools' descriptions teach the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` enumerates contributor-declared variables only** — registry-owned built-ins (`DSH_HOME`, `DSH_SHELL`, `DSH_SESSION_ID`) are not included, so diagnostics, prompt, or UI code must not treat `list()` as an exhaustive environment catalog.
|
||||
51
packages/bash/bash-env/README.zh.md
Normal file
51
packages/bash/bash-env/README.zh.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @deepseek-ai/dsh-bash-env
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash`、`dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`)归注册表自身所有;其他插件可以注册额外的可枚举事实,注册随插件纤维(fiber)释放,重复所有权或未声明的运行时键会响亮失败。
|
||||
|
||||
包根导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`。
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
config:
|
||||
dshHome: C:\Users\me\.dsh # default: $DSH_HOME, then ~/.dsh
|
||||
```
|
||||
|
||||
## Managed environment
|
||||
|
||||
每次前台与后台模型 shell 调用都会收到一份新收集的受信任 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 主目录绝对路径(`dshHome` 配置,然后环境变量 `$DSH_HOME`,然后 `~/.dsh`),`DSH_SHELL=1` 标识受管理的子进程。带 agent 的调用额外收到 `DSH_SESSION_ID=agent.session.header.id`;当活动的持久化 seam 定位到 JSONL 工件时,它们还会收到 `DSH_SESSION_JSONL=<绝对目标路径>`。JSONL 路径只是位置提示:首次 flush 之前它可能不存在,也不一定包含当前缓冲中的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
覆盖层根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器在合并该快照前移除所有继承的 `DSH_*`,因此嵌套 harness 与并发的父子 agent 无法泄漏过期的身份。`process.env` 永不被修改。shell 工具的描述只教授通用的 `$DSH_*` 约定,而不是点名持久化相关的变量或添加常驻的 system-prompt 段落。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the shell tools (`dsh-tool-bash`, `dsh-tool-pwsh`), which collect this registry's managed `DSH_*` snapshot into every shell-tool call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list()` 只枚举 contributor 声明的变量** — 注册表自有的内置键(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID`)不包含在内,因此诊断、prompt 或 UI 代码不得把 `list()` 当作完整的环境目录。
|
||||
48
packages/bash/bash-env/package.json
Normal file
48
packages/bash/bash-env/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-env",
|
||||
"description": "Tool-independent managed DSH_* shell environment registry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
217
packages/bash/bash-env/src/index.ts
Normal file
217
packages/bash/bash-env/src/index.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tool-independent shell environment plugin: owns the `ctx.bashEnv` registry of
|
||||
* trusted, per-execution `DSH_*` variables consumed by the model-facing shell
|
||||
* tools (`dsh-tool-bash`, `dsh-tool-pwsh`). Built-in shell facts are owned by
|
||||
* the registry itself while plugins can register additional, enumerable facts
|
||||
* with effect-scoped disposal.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-env
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'bash-env'
|
||||
export const inject: string[] = []
|
||||
|
||||
/** Plugin config (all optional — the built-in facts resolve without defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash-env plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model shell call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the shell tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: DshEnvironmentKey
|
||||
}
|
||||
|
||||
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
|
||||
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
|
||||
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
|
||||
DSH_HOME_ENV,
|
||||
DSH_SHELL_KEY,
|
||||
DSH_SESSION_ID_KEY,
|
||||
])
|
||||
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model shell call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)
|
||||
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one shell tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<DshEnvironmentKey, string> = {
|
||||
[DSH_HOME_ENV]: this.dshHome,
|
||||
[DSH_SHELL_KEY]: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as DshEnvironmentKey
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
// prompt, or UI code treats list() as an exhaustive environment catalog.
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as DshEnvironmentKey,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bash-env plugin: register the `ctx.bashEnv` service and the
|
||||
* shell-agnostic persistence contributor (`DSH_SESSION_JSONL`).
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const registry = new BashEnvRegistry(ctx, config)
|
||||
registry.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
[DSH_SESSION_JSONL_KEY]: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
|
||||
},
|
||||
})
|
||||
}
|
||||
30
packages/bash/bash-env/src/invariant.ts
Normal file
30
packages/bash/bash-env/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-bash-env`.
|
||||
* @module @deepseek-ai/dsh-bash-env/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'bash-env-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the environment registry validates ownership and collected values at each
|
||||
* registration/collection; it publishes no independent snapshot that a companion could cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Registry tests for `@deepseek-ai/dsh-bash-env`: built-in facts, contributor
|
||||
* ownership and validation, collection ordering, effect-scoped disposal, and
|
||||
* the explicit disposer contract.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -5,7 +11,8 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-bash-env'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -190,4 +197,41 @@ describe('BashEnvRegistry', () => {
|
||||
dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
|
||||
})
|
||||
|
||||
it('the plugin registers the service and the persistence contributor on load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv).toBeInstanceOf(BashEnvRegistry)
|
||||
expect(ctx.bashEnv.list()).toEqual([
|
||||
{
|
||||
contributor: 'session-persistence',
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
key: 'DSH_SESSION_JSONL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('the persistence contributor resolves DSH_SESSION_JSONL only for a jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'jsonl' as const, path: 'C:\\sessions\\s.jsonl' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p')).DSH_SESSION_JSONL).toBe('C:\\sessions\\s.jsonl')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable for a non-jsonl backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
ctx.provide('sessionPersistence', {
|
||||
locate: () => ({ kind: 'sqlite' as const, path: 'C:\\sessions\\s.db' }),
|
||||
})
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
|
||||
it('the persistence contributor omits the variable without a persistence backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
expect(ctx.bashEnv.collect(execution('sess-p'))).not.toHaveProperty('DSH_SESSION_JSONL')
|
||||
})
|
||||
})
|
||||
36
packages/bash/bash-env/tsconfig.json
Normal file
36
packages/bash/bash-env/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
|
||||
README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17
|
||||
README.zh.md: 8d6e3b8d2fdd92faeb7b1e6e373a938ae2064cd4
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **超时与取消分类**:`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`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Each command runs as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; 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.
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
/** 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: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
argv: readonly string[],
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: ['bash', '-c', spec.command],
|
||||
argv,
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
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: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const 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.
|
||||
@@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
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: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const 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
|
||||
@@ -216,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
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)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -261,8 +292,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
* 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: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
|
||||
export default LocalBashExecutor
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
|
||||
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
|
||||
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
|
||||
README.md: 2f69ea66251f00c74779f1decc69abc6003a4398
|
||||
README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -86,5 +86,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
|
||||
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
|
||||
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
|
||||
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -86,5 +86,5 @@
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
/**
|
||||
* Internal shell-quoting and sandbox-result classification helpers.
|
||||
* Internal sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word.
|
||||
* @param text - raw argv element to preserve through the outer shell parse.
|
||||
* @returns the quoted shell word.
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's runner-failure dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
|
||||
* @returns whether the failed run matches that runner-failure dialect.
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/**
|
||||
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
|
||||
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
|
||||
* mode, enforcement, and denial facts. Runner failure means the command never
|
||||
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
|
||||
* processes carry `runnerFailed`. The tool owns approval and passes a complete
|
||||
* per-call policy.
|
||||
* mode, enforcement, and denial facts. Positive runner-launch evidence means
|
||||
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
@@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial because the command did not run. Throw the
|
||||
// same fail-closed error as confine-time discovery with the first stderr line.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
@@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
@@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access processes
|
||||
* have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string): void {
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
@@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr)
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param command - shell source for the confined inner `bash -c`.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(command: string, policy: SandboxPolicy): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Deterministic real-process proofs for runner classification: the real local
|
||||
* provider and sandbox bash executor exercise direct runner-spawn failures
|
||||
* and a POSIX fake Landlock launcher that prints its notice before exec.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const FATAL_PREFIX = 'landlock-run: '
|
||||
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
|
||||
|
||||
const contexts: Context[] = []
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
|
||||
async function fakeLauncher(fatalExit?: number): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
|
||||
tempDirs.push(dir)
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
|
||||
await writeFile(launcher, `#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ro|--rw) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n' '${NOTICE}' >&2
|
||||
${fatalBranch}exec "$@"
|
||||
`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = {
|
||||
platform: 'linux',
|
||||
probeBwrap: () => false,
|
||||
probeLandlock: () => 'partial',
|
||||
landlockLauncher: await fakeLauncher(fatalExit),
|
||||
}
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: [runner],
|
||||
runnerFailureSignatures: ['configured-runner: fatal'],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe('partial Landlock runner-failure classification', () => {
|
||||
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, `${kind}-runner`)
|
||||
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
|
||||
if (kind === 'missing-interpreter') {
|
||||
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
}
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(runner)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['bare-name', 'relative'] as const)(
|
||||
'classifies a %s runner whose shebang interpreter is missing',
|
||||
async (form) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const filename = 'missing-interpreter-runner'
|
||||
const runner = form === 'bare-name' ? filename : `./${filename}`
|
||||
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = form === 'bare-name'
|
||||
? { command: 'true', env: { PATH: dir } }
|
||||
: { command: 'true', workdir: dir }
|
||||
|
||||
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
|
||||
// argv[0] in this spawn error rather than resolving it to an absolute path.
|
||||
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
|
||||
|
||||
const task = bash.start(bash.resolve(request))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, 'malformed-runner')
|
||||
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = { command: 'true' }
|
||||
|
||||
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
|
||||
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
|
||||
// provenanced ENOENT/EACCES evidence required for runner attribution.
|
||||
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
if (foreground instanceof Error) {
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((foreground as { path?: unknown }).path).toBeUndefined()
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve(request))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((background as { path?: unknown }).path).toBeUndefined()
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} else {
|
||||
expect(foreground).toMatchObject({
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
|
||||
})
|
||||
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
|
||||
|
||||
const background = bash.start(bash.resolve(request))
|
||||
await background.done
|
||||
expect(background.status).toBe('completed')
|
||||
expect(background.exitCode).toBe(127)
|
||||
expect(background.signal).toBeNull()
|
||||
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const output = background.readOutput().delta
|
||||
expect(output.startsWith('[stderr]\n')).toBe(true)
|
||||
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
|
||||
expect(output).not.toContain('spawn failed:')
|
||||
}
|
||||
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
|
||||
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
|
||||
async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
|
||||
const bash = await setup(exitCode)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
|
||||
expect((error as Error).message).not.toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('applies the same evidence rule to notice-only background exits', async () => {
|
||||
const bash = await setup()
|
||||
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
|
||||
const task = bash.start(bash.resolve({ command }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a background notice plus child Permission denied as denial', async () => {
|
||||
const bash = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('makes a background fatal line outrank denial text after the notice', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'partial',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const output = task.readOutput().delta
|
||||
expect(output).toContain(NOTICE)
|
||||
expect(output).toContain(FATAL)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
@@ -30,12 +31,19 @@ interface ConfineCall {
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-runner: '] as const
|
||||
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
|
||||
|
||||
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
|
||||
const RUNNER_FORMS = [
|
||||
['absolute', process.execPath],
|
||||
['bare', 'node'],
|
||||
['relative', './sandbox-runner'],
|
||||
] as const
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
@@ -90,15 +98,49 @@ describe('the provider hand-off', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
|
||||
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
|
||||
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
|
||||
const hook = join(dir, 'hook.sh')
|
||||
const order = join(dir, 'order.txt')
|
||||
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
|
||||
const runnerScript = [
|
||||
'const { appendFileSync } = require("node:fs");',
|
||||
'const { spawnSync } = require("node:child_process");',
|
||||
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
|
||||
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
|
||||
'process.exit(child.status ?? 125);',
|
||||
].join('')
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [process.execPath, '-e', runnerScript, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: { DSH_ORDER_FILE: order },
|
||||
}))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
@@ -120,9 +162,6 @@ describe('the provider hand-off', () => {
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
@@ -132,6 +171,120 @@ describe('fail closed', () => {
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('preserves an already-aborted foreground call as cancellation', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller cancelled before spawn')
|
||||
controller.abort(reason)
|
||||
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it.each(RUNNER_FORMS)(
|
||||
'keeps an invalid workdir ordinary with the %s provider-runner form',
|
||||
async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
|
||||
const { bash } = await setup({ mode: 'danger-full-access' })
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
|
||||
const runner = join(spillDir, 'malformed-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
|
||||
})
|
||||
|
||||
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true' }))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
|
||||
const runner = join(spillDir, 'unexecutable-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
// This pins an alternative SubprocessService's synchronous seam, not the
|
||||
// shipped local behavior.
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
|
||||
})
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'true' })))
|
||||
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(bash.resolve({ command: 'true' })))
|
||||
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
|
||||
const runner = './sandbox-runner'
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
const workdir = join(parent, 'missing')
|
||||
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
|
||||
try {
|
||||
let thrown: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true', workdir }))
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(failure)
|
||||
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
@@ -233,15 +386,134 @@ describe('classifyDenial', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
it.each(['EACCES', 'ENOENT'])(
|
||||
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
|
||||
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
|
||||
const missingWorkdir = join(spillDir, 'missing-workdir')
|
||||
for (const [, runner] of RUNNER_FORMS) {
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
|
||||
}
|
||||
const fileWorkdir = join(spillDir, 'not-a-workdir')
|
||||
writeFileSync(fileWorkdir, '')
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
|
||||
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
|
||||
const missingRunner = join(spillDir, 'definitely-missing-runner')
|
||||
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
|
||||
Object.assign(new Error('spawn failed'), { code, syscall, path })
|
||||
const spawnErrorWithoutPath = (syscall: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only syscall provenance compatible with the exact runner program', () => {
|
||||
const runner = join(spillDir, 'runner with spaces')
|
||||
const spawnError = (syscall: string, path?: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
|
||||
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const fatal = 'landlock-run: ruleset creation failed'
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['', ' ', 'landlock-run: '],
|
||||
informationalLines: [notice],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
|
||||
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
|
||||
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
|
||||
.toEqual({ detail: `${notice}: extra detail` })
|
||||
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
|
||||
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'landlock-run: usage error: missing `-- <argv>...` command',
|
||||
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
|
||||
'landlock-run: cannot open rule path: /gone: No such file or directory',
|
||||
'landlock-run: landlock ruleset error: Invalid argument',
|
||||
'landlock-run: exec failed: Permission denied',
|
||||
'landlock-run: out of memory',
|
||||
'landlock-run: future fatal diagnostic',
|
||||
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['landlock-run: '],
|
||||
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: ['env', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
@@ -253,25 +525,66 @@ describe('result facts', () => {
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps facts and releases accounting when background spawn fails', async () => {
|
||||
const { bash } = await setup()
|
||||
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
|
||||
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const emptyReader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
|
||||
}
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: emptyReader, stderr: emptyReader },
|
||||
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
done: Promise.reject(undefined),
|
||||
terminate: vi.fn(),
|
||||
waitForExit: async () => true,
|
||||
} satisfies SubprocessHandle)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
expect(task.readOutput().delta).toContain('spawn failed: undefined')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
@@ -284,7 +597,7 @@ describe('background sandbox facts', () => {
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
// the matched fatal stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
@@ -315,7 +628,7 @@ describe('background sandbox facts', () => {
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const hook = join(workdir, 'bash-env-hook.sh')
|
||||
const insideProbe = join(workdir, 'hook-ran.txt')
|
||||
const outsideProbe = join(outside, 'escaped.txt')
|
||||
await writeFile(hook, [
|
||||
'printf hook > "$DSH_BASH_ENV_INSIDE"',
|
||||
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
|
||||
'',
|
||||
].join('\n'))
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: {
|
||||
DSH_BASH_ENV_INSIDE: insideProbe,
|
||||
DSH_BASH_ENV_OUTSIDE: outsideProbe,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
|
||||
expect(existsSync(outsideProbe)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
|
||||
README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c
|
||||
README.zh.md: 4eef38e436cb0a4bcb3505aeb228d18c4a8c170b
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
6
packages/bash/pwsh-local/README.i18n.yaml
Normal file
6
packages/bash/pwsh-local/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
|
||||
README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf
|
||||
README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62
|
||||
56
packages/bash/pwsh-local/README.md
Normal file
56
packages/bash/pwsh-local/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
|
||||
|
||||
The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
|
||||
The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, the pure `resolvePwshPath`/`candidatePwshPaths` helpers, and the `ENV_OVERRIDES`/`ENCODING_PREAMBLE` constants the executor injects into every spawn.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas (through the generic task runtime), spill-file paths, and infrastructure failures.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands.
|
||||
- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
|
||||
- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
|
||||
- **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead.
|
||||
- **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.
|
||||
56
packages/bash/pwsh-local/README.zh.md
Normal file
56
packages/bash/pwsh-local/README.zh.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-pwsh-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
|
||||
|
||||
命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
|
||||
|
||||
包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`、纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数,以及执行器注入每次 spawn 的 `ENV_OVERRIDES`/`ENCODING_PREAMBLE` 常量。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
config:
|
||||
cwd: C:\path\to\workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH
|
||||
```
|
||||
|
||||
## 行为(及其由来)
|
||||
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量(经通用任务运行时)、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;具名消费方拥有请求前缀的任何变更。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
|
||||
- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。
|
||||
- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
|
||||
- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
|
||||
- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
|
||||
- **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。
|
||||
- **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。
|
||||
|
||||
清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。
|
||||
45
packages/bash/pwsh-local/package.json
Normal file
45
packages/bash/pwsh-local/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-local",
|
||||
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
288
packages/bash/pwsh-local/src/index.ts
Normal file
288
packages/bash/pwsh-local/src/index.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
* environment, and the model-facing stdout/stderr merge for background reads.
|
||||
*
|
||||
* The command string is passed as ONE argv element to `-Command`: PowerShell
|
||||
* itself parses the text, and no intermediate shell exists, so there is no
|
||||
* shell-quoting layer to escape (the `bash -c` string domain has no
|
||||
* equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
|
||||
/**
|
||||
* Model-friendly environment overrides for PowerShell: disable colors and
|
||||
* pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
|
||||
* deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* UTF-8 output pinning prepended to every command. The subprocess collector
|
||||
* decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort
|
||||
* executable fallback) writes the console/OEM code page by default, which
|
||||
* garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The
|
||||
* statements ride on line 1 after `; ` separators so PowerShell error line
|
||||
* numbers stay accurate.
|
||||
*/
|
||||
export const ENCODING_PREAMBLE =
|
||||
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
* locations and PATH entries are probed in order (PowerShell 7 install,
|
||||
* PATH entries such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH.
|
||||
*/
|
||||
pwshPath?: string
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
|
||||
|
||||
// Resolution lives in its own dependency-free module so the repository's
|
||||
// coverage-gate probe shares the exact definition the suites use.
|
||||
export { candidatePwshPaths, resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
|
||||
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
|
||||
const read = reader.readFrom(0)
|
||||
return {
|
||||
text: read.text,
|
||||
truncated: read.lossy,
|
||||
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`pwsh-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
* this executor supplies their configured budgets per spawn.
|
||||
*/
|
||||
export class PwshLocalExecutor extends BashExecutor {
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request into a fully-specified spec: fill `workdir` from
|
||||
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
|
||||
* `config.timeoutMs`, capped at `config.maxTimeoutMs`.
|
||||
*/
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
const timeoutMs = clampTimeout(
|
||||
request.timeoutMs,
|
||||
this.config.timeoutMs,
|
||||
this.config.maxTimeoutMs,
|
||||
'pwsh-local: request.timeoutMs',
|
||||
)
|
||||
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
|
||||
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
stdout: collect(stdoutMaxBytes),
|
||||
stderr: collect(this.config.maxOutputBytes),
|
||||
},
|
||||
graceMs: this.config.graceMs,
|
||||
signal,
|
||||
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
|
||||
}
|
||||
}
|
||||
|
||||
/** The collect-mode readers the executor itself requested (present by construction). */
|
||||
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
|
||||
const { stdout, stderr } = handle.collected
|
||||
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
|
||||
if (stdout === undefined || stderr === undefined) {
|
||||
throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
return {
|
||||
...outcome,
|
||||
timedOut,
|
||||
aborted,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: finalOutput(collected.stdout),
|
||||
stderr: finalOutput(collected.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
const note = spawnFailureNote ?? ''
|
||||
spawnFailureNote = undefined
|
||||
return note
|
||||
}
|
||||
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: running.done.then((outcome) => {
|
||||
// Any signal termination is killed, including a command signaling itself.
|
||||
if (proc.status === 'running') {
|
||||
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
const err = collected.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// A failed spawn never produced process output, so the note and real
|
||||
// stderr text are mutually exclusive.
|
||||
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.terminate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the declared seam for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export default PwshLocalExecutor
|
||||
30
packages/bash/pwsh-local/src/invariant.ts
Normal file
30
packages/bash/pwsh-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
|
||||
* @module @deepseek-ai/dsh-pwsh-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
60
packages/bash/pwsh-local/src/resolve.ts
Normal file
60
packages/bash/pwsh-local/src/resolve.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PowerShell executable resolution, dependency-free so non-package consumers
|
||||
* (the repository's coverage-gate probe in `vitest.config.ts`) can share the
|
||||
* ONE resolution definition with the executor and its suites — a probe that
|
||||
* resolved differently from the code under test could exempt a file whose
|
||||
* suites actually run.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local/resolve
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Well-known Windows PowerShell install locations plus PATH entries, newest
|
||||
* first. Explicitly parameterized (env) so resolution is a pure function of
|
||||
* its inputs on every platform.
|
||||
* @param env - the environment to probe; defaults to the process environment.
|
||||
* @returns candidate `pwsh` executable paths in resolution order.
|
||||
*/
|
||||
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
||||
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
|
||||
const candidates = [
|
||||
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
|
||||
]
|
||||
// Microsoft Store installs (and any user-added location) live on PATH;
|
||||
// entries may carry surrounding quotes from `setx`-style definitions.
|
||||
for (const entry of (env.PATH ?? '').split(';')) {
|
||||
const trimmed = entry.trim().replace(/^"|"$/g, '')
|
||||
if (trimmed.length === 0) continue
|
||||
candidates.push(join(trimmed, 'pwsh.exe'))
|
||||
}
|
||||
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
|
||||
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pwsh executable this executor spawns.
|
||||
* @param configured - an explicit `pwshPath` config value, trusted as-is.
|
||||
* @param env - the environment to probe on Windows; defaults to the process environment.
|
||||
* @param platform - the platform to resolve for; defaults to the process platform.
|
||||
* @returns the first existing well-known location on Windows (PowerShell 7
|
||||
* install, a PATH entry such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), else `pwsh` for PATH resolution.
|
||||
*/
|
||||
export function resolvePwshPath(
|
||||
configured?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (configured !== undefined && configured.length > 0) return configured
|
||||
if (platform === 'win32') {
|
||||
for (const candidate of candidatePwshPaths(env)) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
return 'pwsh'
|
||||
}
|
||||
454
packages/bash/pwsh-local/tests/executor.spec.ts
Normal file
454
packages/bash/pwsh-local/tests/executor.spec.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess
|
||||
* service plus a REAL pwsh executable, exercised through the executor seam
|
||||
* (`resolve` → `run`/`start`). These verify the world — actual PowerShell
|
||||
* runs, output capture, truncation and spill, deadlines, kill escalation, and
|
||||
* the background-handle contract. The suite self-skips when no usable `pwsh`
|
||||
* resolves (a CI accommodation for hosts without PowerShell); the pure unit tests
|
||||
* (config validation, executable resolution) run on every platform. PowerShell
|
||||
* writes CRLF on Windows, so exact text assertions normalize line endings.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SubprocessService from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
|
||||
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a handle's consuming readOutput until the ACCUMULATED delta contains
|
||||
* `expected`; returns the accumulation (reads never re-deliver, so the caller
|
||||
* gets everything produced up to the match).
|
||||
*/
|
||||
async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let all = ''
|
||||
while (Date.now() < deadline) {
|
||||
all += proc.readOutput().delta
|
||||
if (lf(all).includes(expected)) return lf(all)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`)
|
||||
}
|
||||
|
||||
describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => {
|
||||
it('trusts an explicit configured path verbatim', () => {
|
||||
expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe')
|
||||
expect(resolvePwshPath('pwsh')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('falls through an empty configured path to platform resolution', () => {
|
||||
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
|
||||
// fallback candidate cannot exist either.
|
||||
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh')
|
||||
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
|
||||
const candidates = candidatePwshPaths({
|
||||
ProgramFiles: 'P:\\Program Files',
|
||||
SystemRoot: 'S:\\Windows',
|
||||
PATH: ';"Q:\\quoted store";' + ';',
|
||||
})
|
||||
expect(candidates).toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('Q:\\quoted store', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
// A missing PATH contributes no entries (the empty-string fallback).
|
||||
expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' }))
|
||||
.toEqual([
|
||||
join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the first EXISTING win32 candidate, else pwsh', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
|
||||
const store = join(dir, 'store')
|
||||
mkdirSync(store, { recursive: true })
|
||||
writeFileSync(join(store, 'pwsh.exe'), '')
|
||||
// The existing PATH entry wins over the non-existent Program Files install.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
|
||||
.toBe(join(store, 'pwsh.exe'))
|
||||
// No candidate exists anywhere (SystemRoot points at a non-existent tree,
|
||||
// so even the Windows PowerShell 5.1 fallback cannot exist) → the
|
||||
// PATH-resolution fallback.
|
||||
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
|
||||
.toBe('pwsh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
this.specs.push(spec)
|
||||
return {
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: this.reader, stderr: this.reader },
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
terminate: () => {},
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
|
||||
const ctx = new Context()
|
||||
const subprocess = new CapturingSubprocessService(ctx)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.bash.run(ctx.bash.resolve({ command: 'Write-Output 你好' }))
|
||||
expect(subprocess.specs).toHaveLength(1)
|
||||
const { argv } = subprocess.specs[0]!
|
||||
expect(argv.slice(0, 5)).toEqual([expect.any(String), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
||||
expect(argv[5]).toBe(`${ENCODING_PREAMBLE}Write-Output 你好`)
|
||||
expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding')
|
||||
expect(ENCODING_PREAMBLE).toContain('$OutputEncoding')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(lf(result.stdout.text)).toBe('hi\n')
|
||||
expect(result.timeoutMs).toBe(5_000)
|
||||
})
|
||||
|
||||
it('uses config cwd, overridable per call', async () => {
|
||||
const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
|
||||
const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
|
||||
const { bash } = await setup({ cwd: first })
|
||||
const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)
|
||||
const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second }))
|
||||
expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults cwd to process.cwd()', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
|
||||
expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true)
|
||||
})
|
||||
|
||||
it('caps per-call timeouts at maxTimeoutMs', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 }))
|
||||
expect(result.timeoutMs).toBe(2_000)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric config and timeout overrides', async () => {
|
||||
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
||||
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
|
||||
})
|
||||
|
||||
it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100)
|
||||
|
||||
// Raw Console writes avoid PowerShell's own line-ending and formatting
|
||||
// layers, so the byte counts are exact on every platform.
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)',
|
||||
stdoutMaxBytes: 500,
|
||||
}))
|
||||
|
||||
expect(result.stdout.text).toBe('x'.repeat(500))
|
||||
expect(result.stdout.truncated).toBe(false)
|
||||
expect(result.stderr.truncated).toBe(true)
|
||||
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 }))
|
||||
expect(result.timedOut).toBe(true)
|
||||
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(result.timeoutMs).toBe(100)
|
||||
})
|
||||
|
||||
it('propagates abort signals', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.aborted).toBe(true)
|
||||
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
|
||||
expect(result.timedOut).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a self-killed command as neither timed out nor aborted', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.aborted).toBe(false)
|
||||
// Windows reports a forced termination without a signal; POSIX reports the
|
||||
// terminating signal PowerShell chose (SIGTERM, or SIGKILL for the hard kill).
|
||||
if (process.platform === 'win32') {
|
||||
expect(result.signal).toBeNull()
|
||||
} else {
|
||||
expect(['SIGTERM', 'SIGKILL']).toContain(result.signal)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects on spawn failure (bad workdir)', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"',
|
||||
stdin: 'piped\n',
|
||||
env: { SEAM_VAR: 'env-ok' },
|
||||
dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
|
||||
})
|
||||
// resolve() keeps the optional input/environment fields verbatim.
|
||||
expect(spec.stdin).toBe('piped\n')
|
||||
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
|
||||
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
|
||||
const result = await bash.run(spec)
|
||||
expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n')
|
||||
})
|
||||
|
||||
it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
|
||||
const { bash } = await setup()
|
||||
const spec = bash.resolve({ command: 'Write-Output ok' })
|
||||
expect('stdin' in spec).toBe(false)
|
||||
expect('env' in spec).toBe(false)
|
||||
expect('dshEnv' in spec).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => {
|
||||
it('start returns immediately with a running handle that settles as completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const before = Date.now()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
|
||||
expect(Date.now() - before).toBeLessThan(150)
|
||||
expect(proc.status).toBe('running')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({
|
||||
command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
await proc.done
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' }))
|
||||
const first = await readUntil(proc, 'first\n')
|
||||
expect(lf(first)).toBe('first\n')
|
||||
await proc.done
|
||||
// Read-after-exit returns the remaining buffered output — once.
|
||||
const second = proc.readOutput()
|
||||
expect(lf(second.delta)).toBe('second\n')
|
||||
expect(second.lossy).toBe(false)
|
||||
expect(proc.readOutput().delta).toBe('')
|
||||
})
|
||||
|
||||
it('readOutput marks stderr sections', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
|
||||
await proc.done
|
||||
expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stdoutSpillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('readOutput reports stderr spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stderrSpillPath).toBeDefined()
|
||||
expect(lf(read.delta)).toContain('[stderr]')
|
||||
})
|
||||
|
||||
it('kill() terminates the process tree: true once, false after settlement', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
expect(proc.kill()).toBe(true)
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('kill() returns false for a naturally completed process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('a spec.signal abort settles the handle as killed, not completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
|
||||
controller.abort()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.exitCode).toBeNull()
|
||||
// PowerShell picks SIGTERM for Stop-Process, SIGKILL for the hard kill.
|
||||
expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal)
|
||||
})
|
||||
|
||||
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
|
||||
// done resolves (never rejects) even though the process never ran.
|
||||
await expect(proc.done).resolves.toBeUndefined()
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.readOutput().delta).toContain('spawn failed:')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
// The child prints its own pid so the test can probe liveness through the
|
||||
// public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
// Executor reload/disposal leaves background work running — the
|
||||
// handle stays live and readable, mirroring the task runtime's
|
||||
// registrations-outlive-producer-fibers contract.
|
||||
await executorFiber.dispose()
|
||||
expect(proc.status).toBe('running')
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
|
||||
// Service disposal kills the group and AWAITS its exit (no orphans).
|
||||
await managerFiber.dispose()
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
await proc.done
|
||||
// POSIX reports the kill as a signal; Windows reports a forced
|
||||
// termination as exit 1 with no signal (indistinguishable from a crash),
|
||||
// so the status stamp follows the platform's exit facts.
|
||||
expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
|
||||
it('service disposal settles running handles and leaves settled ones untouched', async () => {
|
||||
const ctx = new Context()
|
||||
const managerFiber = await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as PwshLocalExecutor
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
|
||||
|
||||
await managerFiber.dispose()
|
||||
// A settled process was untouched; the live one was terminated and joined.
|
||||
expect(finished.status).toBe('completed')
|
||||
await running.done
|
||||
expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
|
||||
})
|
||||
})
|
||||
36
packages/bash/pwsh-local/tsconfig.json
Normal file
36
packages/bash/pwsh-local/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d
|
||||
README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57
|
||||
README.md: 35a5647365dab6daa903c14cba8b83702b50305d
|
||||
README.zh.md: eb7901f3445117988d77e6d39b73681480b7a754
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`) and the [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) registry; the plugin stays pending until every injected service exists (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). The tool contract is bash-dialect — mount a bash-parsing executor.
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
|
||||
|
||||
@@ -28,26 +28,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
|
||||
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
Every foreground and background model bash call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/README.md) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. The registry contract — contributor registration, loud duplicate/undeclared-key failure, the built-in reservations, and the contributor example — lives in that package's README. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; the local executor removes all inherited `DSH_*` before merging it, so nested harnesses and concurrent parent/child agents cannot leak stale identities, and `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
|
||||
|
||||
@@ -141,7 +122,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具契约是 bash 方言——请挂载能解析 bash 的执行器。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
|
||||
|
||||
@@ -28,26 +28,7 @@
|
||||
|
||||
### 托管 shell 环境
|
||||
|
||||
每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`(Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表契约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
@@ -141,7 +122,7 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。
|
||||
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -21,20 +21,17 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -49,15 +46,14 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
// infrastructure-failure outcome, then map it to task `failed`. Restricted
|
||||
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
|
||||
// still alias a signal-less kill; real nonzero command exits must remain
|
||||
// `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
|
||||
@@ -8,205 +8,39 @@
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
|
||||
|
||||
/** Configuration for the bash tool and its managed child environment. */
|
||||
/** Configuration for the bash tool. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the bash tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Model-visible metadata for one managed `DSH_*` environment variable. */
|
||||
export interface BashEnvVariable {
|
||||
/** Concise description of the environment fact represented by the variable. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin contribution to the managed environment of each model bash call.
|
||||
* Declared keys make ownership conflicts detectable before the first command;
|
||||
* `resolve` computes only the values available for the current execution.
|
||||
*/
|
||||
export interface BashEnvContributor {
|
||||
/** Stable contributor name used in diagnostics and duplicate detection. */
|
||||
name: string
|
||||
/** Complete set of `DSH_*` keys this contributor may return. */
|
||||
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
|
||||
/**
|
||||
* Resolve this contributor's available values for one tool execution.
|
||||
* @param execution - the bash tool execution and its optional calling agent.
|
||||
* @returns a partial map containing only keys declared in {@link variables}.
|
||||
*/
|
||||
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
|
||||
}
|
||||
|
||||
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
|
||||
export interface BashEnvVariableInfo extends BashEnvVariable {
|
||||
/** Contributor that owns the variable. */
|
||||
contributor: string
|
||||
/** Declared `DSH_*` environment variable name. */
|
||||
key: DshEnvironmentKey
|
||||
}
|
||||
|
||||
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
|
||||
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
|
||||
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
|
||||
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
|
||||
DSH_HOME_ENV,
|
||||
DSH_SHELL_KEY,
|
||||
DSH_SESSION_ID_KEY,
|
||||
])
|
||||
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
|
||||
* The namespace is rebuilt for every model bash call: ambient `DSH_*` values
|
||||
* are discarded by the executor, then the registry's current snapshot is
|
||||
* injected. Built-in shell facts remain owned by the registry itself while
|
||||
* plugins can register additional, enumerable facts with effect-scoped
|
||||
* disposal.
|
||||
*/
|
||||
export class BashEnvRegistry extends Service {
|
||||
private readonly contributors = new Map<string, BashEnvContributor>()
|
||||
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
|
||||
private readonly dshHome: string
|
||||
|
||||
/**
|
||||
* Create and install the `ctx.bashEnv` service.
|
||||
* @param ctx - Cordis context that owns the service and registrations.
|
||||
* @param config - home-directory configuration for the built-in variables.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'bashEnv')
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one environment contributor. Names and keys are unique; built-in
|
||||
* keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
* @param contributor - declared key ownership and per-execution resolver.
|
||||
* @returns the disposer that unregisters the contribution.
|
||||
*/
|
||||
register(contributor: BashEnvContributor): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
|
||||
if (contributor.name.trim().length === 0) {
|
||||
throw new Error('bash env contributor name must be non-empty')
|
||||
}
|
||||
if (this.contributors.has(contributor.name)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
|
||||
}
|
||||
|
||||
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
|
||||
for (const [key, variable] of variables) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)
|
||||
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
|
||||
}
|
||||
if (RESERVED_BASH_ENV_KEYS.has(key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
|
||||
}
|
||||
if (variable.description.trim().length === 0) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
|
||||
}
|
||||
const owner = this.keyOwners.get(key)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
|
||||
}
|
||||
}
|
||||
|
||||
this.contributors.set(contributor.name, contributor)
|
||||
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
|
||||
yield () => {
|
||||
this.contributors.delete(contributor.name)
|
||||
for (const [key] of variables) this.keyOwners.delete(key)
|
||||
}
|
||||
}.bind(this), 'bashEnv.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trusted `DSH_*` snapshot for one bash tool execution.
|
||||
* @param execution - the current tool execution.
|
||||
* @returns an immutable environment overlay containing built-ins and current contributions.
|
||||
*/
|
||||
collect(execution: ToolExecution): DshEnvironment {
|
||||
const values: Record<DshEnvironmentKey, string> = {
|
||||
[DSH_HOME_ENV]: this.dshHome,
|
||||
[DSH_SHELL_KEY]: '1',
|
||||
}
|
||||
if (execution.agent !== undefined) {
|
||||
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
|
||||
}
|
||||
|
||||
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const resolved = contributor.resolve(execution)
|
||||
for (const [rawKey, value] of Object.entries(resolved)) {
|
||||
const key = rawKey as DshEnvironmentKey
|
||||
if (!Object.hasOwn(contributor.variables, key)) {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
|
||||
}
|
||||
|
||||
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
|
||||
// prompt, or UI code treats list() as an exhaustive environment catalog.
|
||||
/**
|
||||
* Enumerate plugin-contributed variables without executing their resolvers.
|
||||
* @returns declarations sorted by environment variable name.
|
||||
*/
|
||||
list(): BashEnvVariableInfo[] {
|
||||
return [...this.contributors.values()]
|
||||
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
|
||||
contributor: contributor.name,
|
||||
description: variable.description,
|
||||
key: key as DshEnvironmentKey,
|
||||
})))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
}
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
@@ -354,24 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
} as const
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
// FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell
|
||||
// environment plugin; replacing this tool with persistent Bash must not
|
||||
// remove the managed DSH_* contributor seam.
|
||||
const bashEnv = new BashEnvRegistry(ctx, config)
|
||||
bashEnv.register({
|
||||
name: 'session-persistence',
|
||||
variables: {
|
||||
[DSH_SESSION_JSONL_KEY]: {
|
||||
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
|
||||
},
|
||||
},
|
||||
resolve(execution) {
|
||||
const agent = execution.agent
|
||||
if (agent === undefined) return {}
|
||||
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
|
||||
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
|
||||
},
|
||||
})
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
@@ -522,7 +338,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
? standingPolicy
|
||||
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
|
||||
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
const dshEnv = ctx.bashEnv.collect(exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
@@ -565,7 +381,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
...request,
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
if (result.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
@@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderProcessRead, renderResult } from '../src/render.ts'
|
||||
|
||||
@@ -35,6 +36,7 @@ async function setup() {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -50,6 +52,7 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) {
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
|
||||
}
|
||||
@@ -281,6 +285,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
expect(text(result)).toContain('[output truncated; full output: ')
|
||||
@@ -300,7 +305,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/ENOENT/)
|
||||
})
|
||||
|
||||
it('surfaces foreground aborts as isError', async () => {
|
||||
it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
@@ -312,7 +317,10 @@ describe('bash tool', () => {
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
expect(result.error).toMatchObject({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
})
|
||||
|
||||
// Type and required-key violations are rejected by the harness
|
||||
@@ -389,6 +397,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
@@ -403,6 +412,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
@@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
@@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
@@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
@@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
// not renderResult output, so a generic fenced card, no terminal output/exit.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
||||
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
|
||||
)
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
@@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
}
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
|
||||
@@ -26,21 +26,18 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
|
||||
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7
|
||||
README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7
|
||||
125
packages/bash/tool-pwsh/README.md
Normal file
125
packages/bash/tool-pwsh/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
|
||||
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export.
|
||||
|
||||
The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker.
|
||||
|
||||
## Tools
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. |
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request where the tool is visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token.
|
||||
|
||||
### Foreground result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Background result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A background start renders exactly `started background task <id>`; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The ack is a fixed short line; task output is bounded per read.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Tool errors
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the failing call adds these retained tokens; an aborted call adds no command output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
125
packages/bash/tool-pwsh/README.zh.md
Normal file
125
packages/bash/tool-pwsh/README.zh.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
|
||||
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
|
||||
|
||||
包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
|
||||
插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。
|
||||
|
||||
## 工具
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
|
||||
当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
|
||||
|
||||
## UI presentation
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
插件激活期间每次请求的固定小额输入成本。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。
|
||||
|
||||
#### Token effect
|
||||
|
||||
工具可见的每个请求上的固定 schema 成本。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。
|
||||
|
||||
### Foreground result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### Background result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
后台启动精确渲染为 `started background task <id>`;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。
|
||||
|
||||
#### Token effect
|
||||
|
||||
ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### Tool errors
|
||||
|
||||
#### What the model sees
|
||||
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
57
packages/bash/tool-pwsh/package.json
Normal file
57
packages/bash/tool-pwsh/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pwsh",
|
||||
"description": "Model-facing pwsh tool over the bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
31
packages/bash/tool-pwsh/src/background.ts
Normal file
31
packages/bash/tool-pwsh/src/background.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Generic-task adaptation for background pwsh process handles — the shell-agnostic
|
||||
* twin of `dsh-tool-bash`'s background adaptation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/background
|
||||
*/
|
||||
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail. A nonzero command exit is
|
||||
* reported, not failed, exactly like the foreground rendering.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
306
packages/bash/tool-pwsh/src/index.ts
Normal file
306
packages/bash/tool-pwsh/src/index.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
|
||||
* Windows compositions where a PowerShell executor (e.g.
|
||||
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
|
||||
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
|
||||
*
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation stays on the existing generic/terminal
|
||||
* cards; a pwsh-specific rendering twin is roadmap work.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
pwsh: 'pwsh'
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'tool-pwsh'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
|
||||
|
||||
/** Configuration for the pwsh tool. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the pwsh tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface PwshToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
interface PwshForegroundResult {
|
||||
kind: 'foreground'
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
timedOut: boolean
|
||||
aborted: boolean
|
||||
timeoutMs: number
|
||||
stdout: { text: string; truncated: boolean; spillPath?: string }
|
||||
stderr: { text: string; truncated: boolean; spillPath?: string }
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
|
||||
function validatePwshArgs(args: PwshToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
if (args.description.trim().length === 0) {
|
||||
throw new Error('invalid description: expected a non-empty string')
|
||||
}
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function pwshDescription(backgroundEnabled: boolean): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'
|
||||
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
|
||||
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
|
||||
* otherwise use the session header cwd and leave executor defaulting as the fallback.
|
||||
*/
|
||||
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
|
||||
const headerCwd = exec.agent?.session.header.cwd
|
||||
if (modelWorkdir === undefined) return headerCwd
|
||||
if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) {
|
||||
return resolvePath(headerCwd, modelWorkdir)
|
||||
}
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
truncated: stream.truncated,
|
||||
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
||||
})
|
||||
return {
|
||||
kind: 'foreground',
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
aborted: result.aborted,
|
||||
timeoutMs: result.timeoutMs,
|
||||
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical background-handle properties shared by the pwsh output union. */
|
||||
const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
kind: { type: 'string', required: true, const: 'background' },
|
||||
taskId: { type: 'string', required: true },
|
||||
} as const
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
order: 105,
|
||||
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. '
|
||||
+ 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(backgroundEnabled),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Clear, concise description of what this command does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
||||
+ '"git status" → "Show working tree status"; "Get-Process" → "List running processes".',
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
},
|
||||
output: {
|
||||
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
|
||||
// consumers of one must accept the other (see the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
/* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
timedOut: { type: 'boolean', required: true },
|
||||
aborted: { type: 'boolean', required: true },
|
||||
timeoutMs: { type: 'number', required: true },
|
||||
stdout: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderPwshResult(value),
|
||||
}],
|
||||
},
|
||||
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: ctx.bashEnv.collect(exec),
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
if (!backgroundEnabled) {
|
||||
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
|
||||
}
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
|
||||
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
|
||||
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore end */
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'pwsh',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
run: () => {
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput()),
|
||||
}
|
||||
},
|
||||
})
|
||||
return { kind: 'background' as const, taskId: id }
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
return canonicalPwshResult(result)
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
/* jscpd:ignore-start -- the background call card mirrors presentBashCall's by design (Agent Note). */
|
||||
presentCall: (args: PwshToolArgs): TerminalCallView | GenericCallView => {
|
||||
// Background acknowledgements carry no terminal exit status; the generic
|
||||
// card mirrors the bash tool's background presentation.
|
||||
if (args.run_in_background === true) {
|
||||
return {
|
||||
card: 'generic',
|
||||
title: args.command,
|
||||
kind: 'execute',
|
||||
rawInput: args.command,
|
||||
content: [{ type: 'text', text: args.description }],
|
||||
}
|
||||
}
|
||||
return {
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
description: args.description,
|
||||
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
||||
}
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
},
|
||||
}))
|
||||
}
|
||||
30
packages/bash/tool-pwsh/src/invariant.ts
Normal file
30
packages/bash/tool-pwsh/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`.
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-pwsh-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
81
packages/bash/tool-pwsh/src/render.ts
Normal file
81
packages/bash/tool-pwsh/src/render.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
|
||||
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
|
||||
* stderr section, truncation notices with spill paths, then exit-status
|
||||
* markers. Non-zero exits are reported, not errored — the model decides how to
|
||||
* react; only infrastructure failures (spawn errors, aborts) surface as
|
||||
* isError results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/render
|
||||
*/
|
||||
|
||||
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
|
||||
}
|
||||
|
||||
/** The renderable foreground result shape (the schema-derived value, no `kind`). */
|
||||
export interface RenderablePwshResult {
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
timedOut: boolean
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one finished run into the text the model sees: stdout, then a marked
|
||||
* stderr section, then exit-status markers, matching the bash tool's story —
|
||||
* a clean exit (0, no signal) produces no marker.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
let body = out
|
||||
if (err.length > 0) {
|
||||
// Single newline between sections (stdout usually ends with one already).
|
||||
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
|
||||
body += `[stderr]\n${err}`
|
||||
}
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// A command may trap the termination and exit 0 after timeout; still report interruption.
|
||||
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
||||
if (result.signal !== null) {
|
||||
markers.push(`[killed by signal: ${result.signal}]`)
|
||||
} else if (result.exitCode !== 0) {
|
||||
markers.push(`[exit code: ${result.exitCode}]`)
|
||||
}
|
||||
if (markers.length === 0) return body
|
||||
|
||||
if (!body.endsWith('\n')) body += '\n'
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
*/
|
||||
export function renderPwshProcessRead(read: BashProcessRead): string {
|
||||
const notices: string[] = []
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
|
||||
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
154
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal file
154
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
|
||||
* `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
|
||||
* process. These verify the world — actual commands run, stdout/stderr come
|
||||
* back, exit codes render, timeouts abort, background tasks settle through the
|
||||
* generic task runtime, and per-session cwd resolution works. The suite
|
||||
* self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without
|
||||
* PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage
|
||||
* gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) {
|
||||
return ctx.tools.execute({
|
||||
signal: signal ?? testToolSignal,
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agentObj ? { agent: agentObj as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-'))
|
||||
await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n')
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolPwsh)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
|
||||
|
||||
it('runs a command and returns stdout with no marker on a clean exit', async () => {
|
||||
const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 })
|
||||
expect(lf(text(result))).toBe('hi\n')
|
||||
})
|
||||
|
||||
it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: '[Console]::Error.WriteLine("boom"); exit 3',
|
||||
description: 'fail loudly',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]')
|
||||
})
|
||||
|
||||
it('resolves relative paths in the session workspace', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Get-Content greeting.txt',
|
||||
description: 'read greeting',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('hello pwsh\n')
|
||||
})
|
||||
|
||||
it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
timeoutMs: 100,
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected a timed-out foreground result')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false })
|
||||
// Windows reports the forced termination as exit 1 without a signal;
|
||||
// POSIX reports SIGTERM — the timeout marker is the stable fact.
|
||||
expect(lf(text(result))).toContain('[timed out after 100ms]')
|
||||
})
|
||||
|
||||
it('an upstream cancellation aborts the run', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
}, agent(), controller.signal)
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
|
||||
it('a background run settles through the REAL task_output tool', async () => {
|
||||
const started = await call('pwsh', {
|
||||
command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done',
|
||||
description: 'background greeting',
|
||||
run_in_background: true,
|
||||
})
|
||||
expect(started.isError).toBe(false)
|
||||
if (started.isError) throw new Error('expected background pwsh success')
|
||||
expect(started.value).toMatchObject({ kind: 'background' })
|
||||
const taskId = (started.value as { taskId: string }).taskId
|
||||
|
||||
// The output delta and the terminal status can land in separate reads
|
||||
// (Windows flushes the child pipe at exit), so collect incrementally —
|
||||
// the same two-step shape as the bash background suite.
|
||||
const deadline = Date.now() + 10_000
|
||||
let output = ''
|
||||
while (Date.now() < deadline) {
|
||||
const read = await call('task_output', { task_id: taskId })
|
||||
output += text(read)
|
||||
if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
expect(output).toContain('bg-done')
|
||||
expect(output).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal file
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* REAL-composition tier (packages/AGENTS.md): boot the examples-owned
|
||||
* tool-pwsh Loader fixture as a subprocess through the same app/boot path a
|
||||
* deployment uses, execute real foreground and background pwsh commands
|
||||
* through the tool registry, and assert the assembled model-visible surface:
|
||||
* schema, prompt section, and rendered results. Self-skips when no `pwsh`
|
||||
* executable exists (a CI accommodation for hosts without PowerShell).
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
interface PwshLoaderReport {
|
||||
schemaHasRunInBackground: boolean
|
||||
promptHasMarkerSection: boolean
|
||||
foregroundText: string
|
||||
backgroundText: string
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => {
|
||||
it('registers the pwsh surface and renders real foreground and background results', async () => {
|
||||
let report: PwshLoaderReport | undefined
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'tool-pwsh loader smoke',
|
||||
tempDirPrefix: 'tool-pwsh-loader-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
inspect: async (cwd) => {
|
||||
report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(report).toBeDefined()
|
||||
expect(report).toMatchObject({
|
||||
schemaHasRunInBackground: true,
|
||||
promptHasMarkerSection: true,
|
||||
})
|
||||
expect(report?.foregroundText).toBe('loader-ok\n')
|
||||
expect(report?.backgroundText).toContain('loader-bg-ok')
|
||||
expect(report?.backgroundText).toContain('[status: completed, exit code: 0]')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
637
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal file
637
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
|
||||
* exercised through `ctx.tools.execute()` so nothing bypasses the tool
|
||||
* registry. The fake executor makes every seam outcome scriptable — output
|
||||
* text, truncation, timeout, abort, nonzero exits, background handles — so
|
||||
* these tests verify the schema, argument validation, workdir derivation,
|
||||
* managed `DSH_*` collection, abort translation, canonical result projection,
|
||||
* rendering, background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderPwshProcessRead } from '../src/render.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
|
||||
* returns the armed foreground script, `start()` returns the armed background
|
||||
* handle.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.specs.push(spec)
|
||||
return this.handler(spec)
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
this.startCalls++
|
||||
this.specs.push(spec)
|
||||
return this.backgroundHandler(spec)
|
||||
}
|
||||
}
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A settled successful background handle; overrides script failure shapes. */
|
||||
function fakeProcess(delta = 'bg-ok\n'): BashProcess {
|
||||
let consumed = false
|
||||
return {
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => {
|
||||
if (consumed) return { delta: '', lossy: false }
|
||||
consumed = true
|
||||
return { delta, lossy: false }
|
||||
},
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
/** A running background handle whose kill() settles it as killed (like a real task_kill). */
|
||||
function killableProcess(): BashProcess {
|
||||
let resolveDone: () => void = () => {}
|
||||
const done = new Promise<void>((resolve) => { resolveDone = resolve })
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done,
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
proc.signal = 'SIGTERM'
|
||||
resolveDone()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, toolConfig)
|
||||
const bash = ctx.bash as FakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */
|
||||
async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, toolConfig)
|
||||
const bash = ctx.bash as FakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
||||
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
||||
*/
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
async function callUntilText(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
expected: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<Awaited<ReturnType<typeof call>>> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last: Awaited<ReturnType<typeof call>> | undefined
|
||||
while (Date.now() < deadline) {
|
||||
last = await call(ctx, name, args)
|
||||
if (text(last).includes(expected)) return last
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`)
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the pwsh tool with its prompt section and schema', async () => {
|
||||
const { ctx } = await setup()
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')
|
||||
expect(schema).toBeDefined()
|
||||
expect(schema?.description).toContain('PowerShell command')
|
||||
expect(schema?.parameters.properties).toMatchObject({
|
||||
command: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
workdir: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
})
|
||||
expect(schema?.parameters.required).toEqual(['command', 'description'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers')
|
||||
expect(prompt).toContain('without a signal marker')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
const fiber = await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('argument validation', () => {
|
||||
it('rejects a blank command or description and a non-positive timeoutMs', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
|
||||
.toContain('invalid timeoutMs: expected a positive number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution through the bash seam', () => {
|
||||
it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
|
||||
const { ctx, bash } = await setup({}, dshHome)
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const agent = registerFakeAgent(ctx, 'session-1')
|
||||
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'Write-Output hi',
|
||||
description: 'say hi',
|
||||
timeoutMs: 1234,
|
||||
}, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
const request = bash.requests[0]
|
||||
expect(request?.command).toBe('Write-Output hi')
|
||||
expect(request?.workdir).toBe('/sessions/s1')
|
||||
expect(request?.timeoutMs).toBe(1234)
|
||||
expect(request?.dshEnv).toEqual({
|
||||
DSH_HOME: dshHome,
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: 'session-1',
|
||||
})
|
||||
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
|
||||
})
|
||||
|
||||
it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
const agent = registerFakeAgent(ctx, 'session-cwd')
|
||||
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
|
||||
expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent)
|
||||
expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
|
||||
})
|
||||
|
||||
it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
|
||||
expect(bash.requests[0]).not.toHaveProperty('workdir')
|
||||
const dshEnv = bash.requests[0]?.dshEnv
|
||||
expect(dshEnv).toBeDefined()
|
||||
expect(dshEnv?.['DSH_SHELL']).toBe('1')
|
||||
expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
|
||||
expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
|
||||
})
|
||||
|
||||
it('forwards exec.signal into the resolved request', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await ctx.tools.execute({
|
||||
signal: controller.signal,
|
||||
callId: CallId('call-signal'),
|
||||
name: 'pwsh',
|
||||
arguments: { command: 'Write-Output ok', description: 'ok' },
|
||||
})
|
||||
expect(bash.requests[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('out\n', {
|
||||
exitCode: 2,
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
timeoutMs: 5000,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toEqual({
|
||||
kind: 'foreground',
|
||||
exitCode: 2,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 5000,
|
||||
stdout: { text: 'out\n', truncated: false },
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
})
|
||||
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
|
||||
})
|
||||
|
||||
it('renders a clean exit without a marker and an empty body as (no output)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(text(clean)).toBe('hi\n')
|
||||
|
||||
bash.handler = () => runResult('')
|
||||
const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' })
|
||||
expect(text(empty)).toBe('(no output)')
|
||||
})
|
||||
|
||||
it('renders stderr-only output without a stdout prefix', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', {
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
exitCode: 1,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
|
||||
expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline before the stderr section when stdout lacks one', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('out', {
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
exitCode: 1,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
|
||||
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]')
|
||||
})
|
||||
|
||||
it('renders the truncation notice with the spill path, then markers', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('tail', {
|
||||
stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
|
||||
expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]')
|
||||
|
||||
bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
|
||||
const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
|
||||
// A timeout kill carries both facts, mirroring the bash tool's markers.
|
||||
expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('renders the truncation notice with (unavailable) when no spill path exists', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('tail', {
|
||||
stdout: { text: 'tail', truncated: true },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
|
||||
expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
if (started.isError) throw new Error('expected background pwsh success')
|
||||
expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' })
|
||||
expect(text(started)).toBe('started background task pwsh-1')
|
||||
|
||||
const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok')
|
||||
expect(text(read)).toContain('bg-ok')
|
||||
// A later read reports the terminal outcome in the generic status line.
|
||||
const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
|
||||
expect(final.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('a running background task is killable through the REAL task_kill tool', async () => {
|
||||
const { ctx, bash } = await setupWithTasks()
|
||||
bash.backgroundHandler = () => killableProcess()
|
||||
await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' })
|
||||
expect(text(killed)).toBe('requested cancellation of task pwsh-1')
|
||||
// The cancel reached the process handle; the task settles as killed with
|
||||
// the signal detail mapped by processOutcome.
|
||||
const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true })
|
||||
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('a background task started by an agent is registered with that agent as owner', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
const agent = registerFakeAgent(ctx, 'sess-owner')
|
||||
const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
|
||||
expect(text(started)).toBe('started background task pwsh-1')
|
||||
|
||||
const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' })
|
||||
expect(anon.isError).toBe(true)
|
||||
expect(text(anon)).toMatch(/belongs to another session/)
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent)
|
||||
expect(killed.isError).toBe(false)
|
||||
await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
|
||||
})
|
||||
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const { ctx } = await setup() // no LocalTaskService / ToolTasks
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
})
|
||||
|
||||
it('a pre-aborted call is skipped before the process starts', async () => {
|
||||
const { ctx, bash } = await setupWithTasks()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('call-pre-aborted'),
|
||||
name: 'pwsh',
|
||||
arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
// With no control surface, task preflight fails before the executor can spawn.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
const bash = ctx.bash as FakeBash
|
||||
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// Declare-then-execute: the failed preflight means no process ever ran.
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
||||
const { ctx } = await setup({ enableRunInBackground: false })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
|
||||
expect(schema.description).toContain('Background execution is not available')
|
||||
expect(schema.description).not.toContain('run_in_background')
|
||||
|
||||
// Schema omission is advertising; execution must also enforce the opt-out.
|
||||
const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
||||
const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
|
||||
expect(foreground.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the built-in background default when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `?? true` fallback when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
ToolPwsh.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
expect(schema.parameters.properties).toHaveProperty('run_in_background')
|
||||
expect(schema.description).toContain('task_output')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
it('a real execute renders the console view through the tool definition presenter', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const result = await call(ctx, 'pwsh', args)
|
||||
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: '```console\nhi\n```' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
|
||||
.toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
|
||||
.toMatchObject({ cwd: 'C:\\work' })
|
||||
})
|
||||
|
||||
it('a background pending call renders the generic card like the bash tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
expect(definition?.presentCall?.({
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'long wait',
|
||||
run_in_background: true,
|
||||
})).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Start-Sleep -Seconds 60',
|
||||
kind: 'execute',
|
||||
rawInput: 'Start-Sleep -Seconds 60',
|
||||
content: [{ type: 'text', text: 'long wait' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('presentResult falls back to undefined for multi-block or non-text content', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
|
||||
const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
it('returns the delta verbatim for a lossless read', () => {
|
||||
expect(renderPwshProcessRead(base)).toBe('out\n')
|
||||
expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
|
||||
})
|
||||
|
||||
it('appends the loss notice with the available spill paths', () => {
|
||||
expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
|
||||
expect(renderPwshProcessRead({
|
||||
...base,
|
||||
lossy: true,
|
||||
stdoutSpillPath: 'C:\\spill\\out.log',
|
||||
stderrSpillPath: 'C:\\spill\\err.log',
|
||||
}))
|
||||
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
|
||||
})
|
||||
|
||||
it('reports (unavailable) when a lossy read has no safe spill path', () => {
|
||||
expect(renderPwshProcessRead({ ...base, lossy: true }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('an empty lossy delta is the notice alone', () => {
|
||||
expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
|
||||
.toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline only when the delta lacks one', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
function settled(over: Partial<BashProcess>): BashProcess {
|
||||
return {
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a signal-killed process to killed with the signal detail', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
|
||||
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
})
|
||||
|
||||
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
|
||||
.toEqual({ status: 'killed', detail: 'killed before exit' })
|
||||
})
|
||||
|
||||
it('maps a completed process to its exit code', () => {
|
||||
expect(processOutcome(settled({ exitCode: 3 })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 3' })
|
||||
})
|
||||
|
||||
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
|
||||
expect(processOutcome(settled({ exitCode: null })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 0' })
|
||||
})
|
||||
})
|
||||
45
packages/bash/tool-pwsh/tsconfig.json
Normal file
45
packages/bash/tool-pwsh/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -41,9 +41,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
|
||||
@@ -49,8 +49,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
|
||||
README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce
|
||||
README.zh.md: 62c037977115d33b834fe60b042431e44d208524
|
||||
README.zh.md: 288982b1247f93fa1e8578a9ece2fcf8ed86666d
|
||||
|
||||
@@ -10,7 +10,7 @@ locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0
|
||||
README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a
|
||||
README.md: dd780369a1d888dde2579e436afe2ce1e6dcfdd1
|
||||
README.zh.md: 5574ad6452c6a2d94fb63da7e53b98e8d074f1c9
|
||||
|
||||
@@ -8,6 +8,8 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
|
||||
|
||||
@@ -59,8 +59,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ export type {
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type {
|
||||
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
|
||||
} from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
@@ -12,7 +13,7 @@ export interface TitledSessionSummary extends SessionSummary {
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
/** One flattened session-list row with lineage depth and live pending interaction. */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
@@ -26,8 +27,8 @@ export interface SessionListEntry {
|
||||
cwd?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** User interaction currently blocking this session, derived from live mux frames. */
|
||||
pendingInteraction?: PendingInteractionStatus
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
@@ -37,10 +38,13 @@ export interface SessionListEntry {
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
|
||||
* @param pendingInteractions - current manager-owned interaction status by session.
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
|
||||
export function flattenLineage(
|
||||
summaries: readonly TitledSessionSummary[],
|
||||
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
|
||||
): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
@@ -64,7 +68,12 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
|
||||
const pendingInteraction = pendingInteractions?.get(s.sessionId)
|
||||
out.push({
|
||||
...s,
|
||||
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
|
||||
depth,
|
||||
})
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
// Type-only merge edge: the title domain's client-namespace outlet declares
|
||||
// the 'title' projection key this manager projects into list rows (and any
|
||||
// useProjection('title') consumer reads). Zero value imports by construction.
|
||||
@@ -70,23 +71,44 @@ type SessionListMutation =
|
||||
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
|
||||
| { kind: 'engaged'; sessionId: SessionId }
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
/** Stable identity of a frame retained until an uninstantiated Session can consume it. */
|
||||
function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'approval/requested': return `a:${frame.approvalId}`
|
||||
case 'question/requested': return `q:${envelope.rpcId}`
|
||||
case 'session/queue': return 'queue'
|
||||
/* v8 ignore next -- pendingBuffers contains only the three frame types above. */
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Match ui-question's binary plan-review routing at the wire boundary. */
|
||||
function questionInteractionStatus(
|
||||
questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
|
||||
): PendingInteractionStatus {
|
||||
if (questions.length !== 1) return 'question'
|
||||
const question = questions[0] as typeof questions[number]
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question'
|
||||
if (question.multiSelect === true) return 'question'
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return 'question'
|
||||
return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question'
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<SessionId, Session>()
|
||||
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
|
||||
* history (cannot be backfilled on open), the one frame class that must not take the
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
/** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history
|
||||
* cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates
|
||||
* compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
|
||||
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
|
||||
/** Outstanding answerable interactions per session, keyed by their stable request identity.
|
||||
* Manager-owned rather than read off Session instances because the sidebar must light up for
|
||||
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
|
||||
* still-pending requests — and on session-removed. */
|
||||
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
@@ -567,6 +589,26 @@ export class SessionManager {
|
||||
return this.listSnapshotCache
|
||||
}
|
||||
|
||||
/** Add or refresh one stable pending-interaction identity. */
|
||||
private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void {
|
||||
let interactions = this.pendingInteractions.get(sessionId)
|
||||
if (interactions === undefined) {
|
||||
interactions = new Map()
|
||||
this.pendingInteractions.set(sessionId, interactions)
|
||||
}
|
||||
if (interactions.get(key) === status) return
|
||||
interactions.set(key, status)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Settle one pending-interaction identity without disturbing sibling waits. */
|
||||
private resolvePending(sessionId: SessionId, key: string): void {
|
||||
const interactions = this.pendingInteractions.get(sessionId)
|
||||
if (interactions === undefined || !interactions.delete(key)) return
|
||||
if (interactions.size === 0) this.pendingInteractions.delete(sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- ConnectionController sinks (wired by boot) ----
|
||||
|
||||
/**
|
||||
@@ -592,11 +634,10 @@ export class SessionManager {
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: buffered session/queue frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
// (and enough reconnects push real approval/question frames past the
|
||||
// cap). Same re-baseline signal Session uses for its own mirror.
|
||||
// New mux-generation baseline: discard the previous queue snapshot.
|
||||
// The host omits session/queue when the live queue is empty, so retaining
|
||||
// it could replay stale work when the Session is instantiated later.
|
||||
// This is the same re-baseline signal Session uses for its own mirror.
|
||||
const buffered = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffered !== undefined) {
|
||||
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
|
||||
@@ -606,43 +647,54 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
|
||||
// every session, instantiated or not; approvalId keys make replays idempotent.
|
||||
// List-level pending-interaction status (the sidebar amber dot): tracked
|
||||
// for every session, instantiated or not; stable keys make replays idempotent.
|
||||
if (frame.type === 'approval/requested') {
|
||||
let ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
|
||||
if (!ids.has(frame.approvalId)) {
|
||||
ids.add(frame.approvalId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval')
|
||||
} else if (frame.type === 'approval/resolved') {
|
||||
const ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids !== undefined && ids.delete(frame.approvalId)) {
|
||||
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.resolvePending(frame.sessionId, `a:${frame.approvalId}`)
|
||||
} else if (frame.type === 'question/requested') {
|
||||
this.trackPending(
|
||||
frame.sessionId,
|
||||
`q:${envelope.rpcId}`,
|
||||
questionInteractionStatus(frame.questions),
|
||||
)
|
||||
} else if (frame.type === 'question/resolved') {
|
||||
this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`)
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question/queue frames never hit history: buffer for replay on
|
||||
// instantiation; everything else drops (not instantiated — history fully
|
||||
// backfills on open).
|
||||
// Answerable requests never hit history: retain each live identity until
|
||||
// instantiation, compacting replay duplicates and resolutions so list
|
||||
// status cannot outlive the PendingWait the user would need to answer.
|
||||
// Queue is a latest-value snapshot; everything else drops because open
|
||||
// backfills it from history.
|
||||
switch (frame.type) {
|
||||
case 'approval/requested':
|
||||
case 'approval/resolved':
|
||||
case 'question/requested':
|
||||
case 'question/resolved':
|
||||
case 'session/queue': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
|
||||
const prior = frame.type === 'session/queue'
|
||||
? buffer.findIndex(item => item.payload.type === 'session/queue')
|
||||
: -1
|
||||
if (prior !== -1) buffer.splice(prior, 1)
|
||||
buffer.push(envelope)
|
||||
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
|
||||
const key = frame.type === 'approval/requested'
|
||||
? `a:${frame.approvalId}`
|
||||
: frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue'
|
||||
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
|
||||
if (prior === -1) buffer.push(envelope)
|
||||
else buffer[prior] = envelope
|
||||
this.pendingBuffers.set(frame.sessionId, buffer)
|
||||
return
|
||||
}
|
||||
case 'approval/resolved':
|
||||
case 'question/resolved': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffer === undefined) return
|
||||
const key = frame.type === 'approval/resolved'
|
||||
? `a:${frame.approvalId}`
|
||||
: `q:${frame.questionRpcId}`
|
||||
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
|
||||
if (prior !== -1) buffer.splice(prior, 1)
|
||||
if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -689,7 +741,7 @@ export class SessionManager {
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved()
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
||||
// A pull already in flight was requested before this removal and can
|
||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||
@@ -735,20 +787,19 @@ export class SessionManager {
|
||||
* The moment a connection generation dies (before any next-generation frame
|
||||
* can arrive — onConnected waits for the readiness handshake while replayed
|
||||
* frames flow from stream open, so clearing there would race the replay):
|
||||
* drop generation-scoped live state. Approvals resolved while disconnected
|
||||
* send no frame, so the stale bits and the buffered answerable frames must
|
||||
* not survive into the next generation — the mux-open replay re-adds every
|
||||
* still-pending question with its live rpcId.
|
||||
*/
|
||||
* drop generation-scoped live state. Interactions resolved while disconnected
|
||||
* send no frame, so stale statuses and buffered answerable frames must not
|
||||
* survive into the next generation — mux-open replay re-adds every still-pending
|
||||
* request with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
if (this.waitingApprovals.size > 0) {
|
||||
this.waitingApprovals.clear()
|
||||
if (this.pendingInteractions.size > 0) {
|
||||
this.pendingInteractions.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
@@ -855,7 +906,15 @@ export class SessionManager {
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
})
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const pendingInteractions = new Map<SessionId, PendingInteractionStatus>()
|
||||
for (const [sessionId, interactions] of this.pendingInteractions) {
|
||||
const statuses = [...interactions.values()]
|
||||
// The composer selects the first question ahead of approval. Mirror that
|
||||
// answer order so the sidebar names the interaction the user can act on.
|
||||
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
|
||||
if (status !== undefined) pendingInteractions.set(sessionId, status)
|
||||
}
|
||||
const fresh = flattenLineage(merged, pendingInteractions)
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
@@ -863,7 +922,7 @@ export class SessionManager {
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
&& prev.pendingInteraction === entry.pendingInteraction
|
||||
&& prev.projectionValues === entry.projectionValues
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface PendingPayloads {
|
||||
/** Pending-interaction discriminant (the keys of PendingPayloads). */
|
||||
export type PendingKind = keyof PendingPayloads
|
||||
|
||||
/** Session-list summary of the user action currently blocking progress. */
|
||||
export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question'
|
||||
|
||||
/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
|
||||
export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -48,8 +49,8 @@ export interface SessionSummary {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
/** User interaction currently blocking this session (sidebar amber-dot state). */
|
||||
pendingInteraction?: PendingInteractionStatus
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
@@ -613,9 +614,11 @@ export class SessionsService implements ISessions {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.pendingInteraction === undefined
|
||||
? {}
|
||||
: { pendingInteraction: entry.pendingInteraction }),
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
@@ -643,7 +646,6 @@ export class SessionsService implements ISessions {
|
||||
parentId: address.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('instances', () => {
|
||||
const manager = new SessionManager(api)
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
|
||||
@@ -47,16 +48,26 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
}
|
||||
const pending = manager.get(S1).getSnapshot().pending
|
||||
expect(pending).toHaveLength(32)
|
||||
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: `r${i}` as never,
|
||||
payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' },
|
||||
})
|
||||
}
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
expect(manager.get(S1).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -862,48 +873,102 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q2' as never,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', detail: '# Plan',
|
||||
options: [{ label: 'Approve' }, { label: 'Refuse' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing detail', {}],
|
||||
['multi-select', { detail: '# Plan', multiSelect: true }],
|
||||
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
|
||||
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
|
||||
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q-plan' as never,
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
...over,
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
})
|
||||
|
||||
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
|
||||
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
|
||||
README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db
|
||||
README.zh.md: ebaa9e0d0a134a6fade5729215168a1a47fd375c
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包(package)不含任何 React,也不做任何渲染。
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。
|
||||
|
||||
## 契约
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -222,7 +222,6 @@ export class TestSessions implements ISessions {
|
||||
id,
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
|
||||
@@ -69,7 +69,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
||||
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
|
||||
*/
|
||||
export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] {
|
||||
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
|
||||
return [{
|
||||
entry: [...libEntry],
|
||||
outDir: 'lib',
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
|
||||
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
|
||||
README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f
|
||||
README.zh.md: e5d109af8ca94515e0b574c62c57968796af5ce8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
|
||||
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
|
||||
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **popupSelect 壳还没有已上架的业务消费者**:模型选择(host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
|
||||
- **popupSelect 壳还没有已上架的业务消费方**:模型选择(host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
|
||||
- **脱离会话后,detached result 的 notice 回退到 console**:fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器;会话拆除后,console 输出行是仅剩的呈现面。
|
||||
|
||||
@@ -69,8 +69,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 737a53da1fa4541f81d89b41945cacd2b415ef4a
|
||||
README.zh.md: 800d2b3e762d410100c8c9eb64bfbcdb22cda62a
|
||||
README.md: 6c3cbd6ddc83001561b9cac24be33ed99fb7e6df
|
||||
README.zh.md: 46cedbbd04e1fcb4071f736a80001cbadb643442
|
||||
|
||||
@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -292,7 +292,7 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: {
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('chat row diff body', () => {
|
||||
describe('FileMutationRow diff card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -306,7 +306,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => {
|
||||
describe('ReadRow keyed toolview', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -254,7 +254,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -92,10 +92,10 @@ function mount(
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('chat row terminal body', () => {
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
@@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
|
||||
README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf
|
||||
README.zh.md: 8a4c01394508d9ceb3eabb6cd38ad58abd7f0e38
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只反映持久 phase** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
|
||||
- **只反映持久 phase**——投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 通过 RPC 重新置为 armed 状态。host 活值通道待出现真实消费方后再议。
|
||||
|
||||
@@ -67,8 +67,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
|
||||
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3
|
||||
README.zh.md: a24dfa4d4eeb28fdc8df1d21daa3f6e9476d0062
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user