feat(bash): resolve executor config through the bash settings namespace

The capability's namespace is owned by the seam because it names the
capability, not an implementation: a host composes exactly one provider of
ctx.bash, so both executor families register the same namespace with their
own schema and composition entry without ever colliding, and a settings
document carried between platforms keeps resolving on both.

Both executors read their config through a source thunk, so a stored change
reaches the next command. The constructor checks the schema cannot express
become the section validator, refusing a bad value at the write instead of
at the next command. pwsh re-resolves its executable only when the declared
path changed, so an unrelated settings change never re-probes the filesystem.
This commit is contained in:
Yichen Jiang
2026-08-10 16:10:33 +08:00
parent 558ac47e14
commit ddc1ec2bd2
29 changed files with 396 additions and 51 deletions
+1 -1
View File
@@ -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 docs/config-catalog.md
config-catalog.md: 234e07eafdc4a01681ccff4b7ac8290905cd677b
config-catalog.md: 14610ca9c06d5c4beb6ccb74795e5859952c58a4
config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287
+2 -2
View File
@@ -315,7 +315,7 @@ export interface Config {
}
```
Source: [`packages/bash/bash-local/src/index.ts:40`](../packages/bash/bash-local/src/index.ts)
Source: [`packages/bash/bash-local/src/index.ts:41`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-bash-sandbox`
@@ -1237,7 +1237,7 @@ export interface Config {
}
```
Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts)
Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts)
## `@deepseek-ai/dsh-pwsh-sandbox`
+2 -2
View File
@@ -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 docs/module-graph.md
module-graph.md: e1c52b700a45e24c78a505bb522f0b3259415187
module-graph.zh.md: 26306588773d715a88b901edba5391abff19211d
module-graph.md: 65741283b5a48817bd01c42fe7982fff6c89511a
module-graph.zh.md: 00ea2925c6636a65e224c899ee264a4e87d4a2cd
+6 -3
View File
@@ -548,6 +548,7 @@ flowchart TD
pkg_goal --> pkg_type_meta
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_settings
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
@@ -655,10 +656,12 @@ flowchart TD
pkg_goal_session --> pkg_session
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_settings
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_settings
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
@@ -1325,7 +1328,7 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
@@ -1351,8 +1354,8 @@ flowchart TD
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
+6 -3
View File
@@ -550,6 +550,7 @@ flowchart TD
pkg_goal --> pkg_type_meta
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_settings
pkg_bash --> pkg_subprocess
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
@@ -657,10 +658,12 @@ flowchart TD
pkg_goal_session --> pkg_session
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
pkg_bash_local --> pkg_settings
pkg_bash_local --> pkg_subprocess
pkg_bash_local --> pkg_timeout
pkg_pwsh_local --> pkg_bash
pkg_pwsh_local --> pkg_invariants
pkg_pwsh_local --> pkg_settings
pkg_pwsh_local --> pkg_subprocess
pkg_pwsh_local --> pkg_timeout
pkg_fs_local --> pkg_fs
@@ -1327,7 +1330,7 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
@@ -1353,8 +1356,8 @@ flowchart TD
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
+2 -2
View File
@@ -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 docs/subsystems/bash.md
bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd
bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca
bash.md: 16899d78916c84fbb6f9501b9a278c9749fd144f
bash.zh.md: 7beae15f9ae46038ce15243ad249533115a938d4
+1 -1
View File
@@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise<BashRunResult>
abstract start(spec: BashExecSpec): BashProcess
```
Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts)
Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts)
<a id="ctxbashenv--bashenvregistry"></a>
+1 -1
View File
@@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise<BashRunResult>
abstract start(spec: BashExecSpec): BashProcess
```
Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts)
Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts)
<a id="ctxbashenv--bashenvregistry"></a>
+2 -2
View File
@@ -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-local/README.md
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
README.md: b011db6478db8fa4dbdfef4812355d0ed2c1eba8
README.zh.md: 1ddaf7c283aad40d14764388b5ec1e8ebbdf6d16
+1
View File
@@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
## Behavior
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `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-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
+1
View File
@@ -23,6 +23,7 @@
## 行为
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
+2
View File
@@ -27,6 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -37,6 +38,7 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
+40 -11
View File
@@ -11,9 +11,10 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import { BASH_SETTINGS_NAMESPACE, 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 { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
@@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void {
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceableBashConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
@@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
get config(): ResolvedConfig {
return this.source()
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
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)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
const entry = config as ResolvedConfig
assertServiceableBashConfig(entry)
this.source = () => entry
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
validate: assertServiceableBashConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Every field is read through the getter at each command, so nothing
// derived from the source needs rebuilding when the document changes.
onChange: () => {},
})
}
/**
@@ -0,0 +1,115 @@
/** The `bash` settings section layered over the executor's composition entry. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
bash: LocalBashExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor }
}
describe('bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/positive finite/)
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('refuses a grace period longer than a timer can carry', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
.rejects.toThrow(/graceMs must be no greater than/)
await bench.ctx.fiber.dispose()
})
it('serves the stored section to every later read', async () => {
const bench = await boot()
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
const spec = bench.bash.resolve({ command: 'true' })
expect(spec.stdoutMaxBytes).toBe(1_024)
expect(spec.workdir).toBe('/tmp')
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot()
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.settingsFiber.dispose()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('keeps the composition entry when no settings provider is mounted', async () => {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234)
await ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
await bench.ctx.fiber.dispose()
})
})
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}
+2 -2
View File
@@ -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/README.md
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
README.md: a3debe2e511a188f0578a4f9c65f8714ae67a159
README.zh.md: 0f338d77372b5c62e822a263235e3b6b73f9ceb9
+2
View File
@@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
+2
View File
@@ -27,6 +27,8 @@
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
`BASH_SETTINGS_NAMESPACE``bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
## 词汇
`BashExecRequest`command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
+4 -2
View File
@@ -26,14 +26,16 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+12
View File
@@ -6,9 +6,21 @@
*/
import { Context, Service } from 'cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
/**
* Settings namespace of this capability, owned here rather than by either
* executor family because it names the capability, not an implementation: a
* host composes exactly one provider of `ctx.bash` (the win32 layer swaps the
* POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
* service registration), so the providers share one namespace without ever
* registering it twice, and a settings document carried between platforms
* keeps resolving on both.
*/
export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash')
export { DSH_ENV_PREFIX } from './types.ts'
export type {
BashExecRequest,
+3
View File
@@ -20,6 +20,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}
+2 -2
View File
@@ -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/pwsh-local/README.md
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e
README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22
+2 -1
View File
@@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `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.
+2 -1
View File
@@ -28,8 +28,9 @@
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
- **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)` 的纯函数在构造时执行一次
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `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_*` 通道规则之下合并;显式调用方条目仍然优先。
+2
View File
@@ -27,6 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -37,6 +38,7 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
+58 -15
View File
@@ -15,9 +15,10 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import { BASH_SETTINGS_NAMESPACE, 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 { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolvePwshPath } from './resolve.ts'
@@ -96,6 +97,26 @@ function assertPositiveFinite(name: string, value: number): void {
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceablePwshConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
* files, and process-tree termination are the subprocess service's mechanics;
@@ -114,25 +135,47 @@ export class PwshLocalExecutor extends BashExecutor {
pwshPath: z.string(),
})
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** The pwsh executable resolved once at construction. */
readonly pwshPath: string
/** The declared executable the current {@link pwshPath} was resolved from. */
private declaredPwshPath: string | undefined
/** The pwsh executable resolved from the current config. */
private resolvedPwshPath: string
/** Validated config (schemastery applied the defaults before construction). */
get config(): ResolvedConfig {
return this.source()
}
/** The pwsh executable every command runs through. */
get pwshPath(): string {
return this.resolvedPwshPath
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
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)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
this.pwshPath = resolvePwshPath(this.config.pwshPath)
const entry = config as ResolvedConfig
assertServiceablePwshConfig(entry)
this.source = () => entry
this.declaredPwshPath = entry.pwshPath
this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
validate: assertServiceablePwshConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Probing the filesystem is the one fact derived from the source: every
// other field is read through the getter at each command.
onChange: () => {
const declared = this.source().pwshPath
if (declared === this.declaredPwshPath) return
this.declaredPwshPath = declared
this.resolvedPwshPath = resolvePwshPath(declared)
},
})
}
/**
@@ -0,0 +1,108 @@
/** The shared `bash` settings section as the pwsh executor family resolves it. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
pwsh: PwshLocalExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor }
}
describe('pwsh executor over the bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('re-resolves the executable when the stored path changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.ctx.fiber.dispose()
})
it('keeps the resolved executable when an unrelated field changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
const before = bench.pwsh.pwshPath
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.pwshPath).toBe(before)
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.settingsFiber.dispose()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
await bench.ctx.fiber.dispose()
})
})
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}
+9
View File
@@ -861,6 +861,9 @@ importers:
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess
@@ -911,6 +914,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess
@@ -966,6 +972,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess