From ddc1ec2bd22dab11f73d58c01c7bf8497dcfafdd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 16:10:33 +0800 Subject: [PATCH] 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. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 2 +- docs/subsystems/bash.zh.md | 2 +- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.md | 1 + packages/bash/bash-local/README.zh.md | 1 + packages/bash/bash-local/package.json | 2 + packages/bash/bash-local/src/index.ts | 51 ++++++-- .../bash/bash-local/tests/settings.spec.ts | 115 ++++++++++++++++++ packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash/README.i18n.yaml | 4 +- packages/bash/bash/README.md | 2 + packages/bash/bash/README.zh.md | 2 + packages/bash/bash/package.json | 6 +- packages/bash/bash/src/index.ts | 12 ++ packages/bash/bash/tsconfig.json | 3 + packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 3 +- packages/bash/pwsh-local/README.zh.md | 3 +- packages/bash/pwsh-local/package.json | 2 + packages/bash/pwsh-local/src/index.ts | 73 ++++++++--- .../bash/pwsh-local/tests/settings.spec.ts | 108 ++++++++++++++++ packages/bash/pwsh-local/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ 29 files changed, 396 insertions(+), 51 deletions(-) create mode 100644 packages/bash/bash-local/tests/settings.spec.ts create mode 100644 packages/bash/pwsh-local/tests/settings.spec.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a1aa65a008..9536e421ba 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 234e07eafd..14610ca9c0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ed5df0a29b..84e060f8a8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 diff --git a/docs/module-graph.md b/docs/module-graph.md index e1c52b700a..65741283b5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2630658877..00ea2925c6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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) | diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..cbc775c187 100644 --- a/docs/subsystems/bash.i18n.yaml +++ b/docs/subsystems/bash.i18n.yaml @@ -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 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..16899d7891 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise 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) diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..7beae15f9a 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise 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) diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index aa5f35a125..4332858874 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -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 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index cb8e7f0ae7..b011db6478 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -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). diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 20af9c1899..1ddaf7c283 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -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)。 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index c3c3a5c3e6..ebf368e1d7 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -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:^", diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 3d3ca833bc..8c9473f664 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -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: () => {}, + }) } /** diff --git a/packages/bash/bash-local/tests/settings.spec.ts b/packages/bash/bash-local/tests/settings.spec.ts new file mode 100644 index 0000000000..36fd8bdca5 --- /dev/null +++ b/packages/bash/bash-local/tests/settings.spec.ts @@ -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 = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(config: ConstructorParameters[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() + }) +}) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 53ccc94926..80015bf539 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index d5cb0cb0e5..739811a642 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -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 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 23b0acd096..a3debe2e51 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -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. diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 14ba749a00..0f338d7737 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -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` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 71e9ed8d9f..ce6ba54ef9 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -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" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 73f3d7b519..539ce12983 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -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, diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 3f611c80e0..dac354f18a 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../sandbox/sandbox" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 1d26227716..46929dd0f2 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -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 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index eb3365b009..76f3071dc1 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -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. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index d79201c756..e773e0e83e 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -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 用 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_*` 通道规则之下合并;显式调用方条目仍然优先。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index f65d524904..487932d3cf 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -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:^", diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 5983500772..3b872b0c34 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -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) + }, + }) } /** diff --git a/packages/bash/pwsh-local/tests/settings.spec.ts b/packages/bash/pwsh-local/tests/settings.spec.ts new file mode 100644 index 0000000000..7c5a9ed2ae --- /dev/null +++ b/packages/bash/pwsh-local/tests/settings.spec.ts @@ -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 = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(config: ConstructorParameters[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() + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json index 53ccc94926..80015bf539 100644 --- a/packages/bash/pwsh-local/tsconfig.json +++ b/packages/bash/pwsh-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1fa4fb925..4600aca05c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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