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

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: () => {},
})
}
/**