mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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/bash-local/README.md
|
||||
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
|
||||
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
|
||||
README.md: b011db6478db8fa4dbdfef4812355d0ed2c1eba8
|
||||
README.zh.md: 1ddaf7c283aad40d14764388b5ec1e8ebbdf6d16
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user