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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user