feat(pwsh): add the pwsh-local executor and the pwsh tool
Windows-native execution foundation: PwshLocalExecutor implements the bash executor seam over ctx.subprocess (pwsh -NoLogo -NoProfile -NonInteractive -Command, one argv element, no quoting layer; resolvePwshPath probes PowerShell 7 / PATH / Windows PowerShell 5.1 as a pure function), and tool-pwsh is the minimal PowerShell-dialect model-facing tool over ctx.bash (foreground only, managed DSH_* env, timeout/signal/exit markers, terminal and generic presenters). Both packages carry full suites (real pwsh, self-skipping without it) at per-file 100% coverage; vitest's Windows exclusion narrows from packages/bash/* to the bash-requiring packages so the pwsh suites run natively on Windows too. The CLI gains the workspace deps and tsconfig projects without mounting either plugin; the Windows-default roadmap is recorded as a proposed Agent Note.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Local PowerShell implementation of the bash executor seam. Each command runs
|
||||
* as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
|
||||
* process spawned through `ctx.subprocess`; the executor owns command
|
||||
* defaulting, deadlines and cause classification, the model-friendly terminal
|
||||
* environment, and the model-facing stdout/stderr merge for background reads.
|
||||
*
|
||||
* The command string is passed as ONE argv element to `-Command`: PowerShell
|
||||
* itself parses the text, and no intermediate shell exists, so there is no
|
||||
* shell-quoting layer to escape (the `bash -c` string domain has no
|
||||
* equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { 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 { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides for PowerShell: disable colors and
|
||||
* pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
|
||||
* deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
* locations are probed first (PowerShell 7, then Windows PowerShell 5.1),
|
||||
* falling back to a bare `pwsh` resolved through PATH.
|
||||
*/
|
||||
pwshPath?: string
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
|
||||
|
||||
/**
|
||||
* Well-known Windows PowerShell install locations plus PATH entries, newest
|
||||
* first. Explicitly parameterized (env) so resolution is a pure function of
|
||||
* its inputs on every platform.
|
||||
* @param env - the environment to probe; defaults to the process environment.
|
||||
* @returns candidate `pwsh` executable paths in resolution order.
|
||||
*/
|
||||
export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
||||
const systemRoot = env.SystemRoot ?? 'C:\\Windows'
|
||||
const candidates = [
|
||||
join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
|
||||
]
|
||||
// Microsoft Store installs (and any user-added location) live on PATH;
|
||||
// entries may carry surrounding quotes from `setx`-style definitions.
|
||||
for (const entry of (env.PATH ?? '').split(';')) {
|
||||
const trimmed = entry.trim().replace(/^"|"$/g, '')
|
||||
if (trimmed.length === 0) continue
|
||||
candidates.push(join(trimmed, 'pwsh.exe'))
|
||||
}
|
||||
// Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
|
||||
candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pwsh executable this executor spawns.
|
||||
* @param configured - an explicit `pwshPath` config value, trusted as-is.
|
||||
* @param env - the environment to probe on Windows; defaults to the process environment.
|
||||
* @param platform - the platform to resolve for; defaults to the process platform.
|
||||
* @returns the first existing well-known location on Windows (PowerShell 7
|
||||
* install, a PATH entry such as the Microsoft Store install, then Windows
|
||||
* PowerShell 5.1), else `pwsh` for PATH resolution.
|
||||
*/
|
||||
export function resolvePwshPath(
|
||||
configured?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (configured !== undefined && configured.length > 0) return configured
|
||||
if (platform === 'win32') {
|
||||
for (const candidate of candidatePwshPaths(env)) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
return 'pwsh'
|
||||
}
|
||||
|
||||
/** Project a settled collect-mode reader into the final CollectedOutput shape. */
|
||||
function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
|
||||
const read = reader.readFrom(0)
|
||||
return {
|
||||
text: read.text,
|
||||
truncated: read.lossy,
|
||||
...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`pwsh-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
* this executor supplies their configured budgets per spawn.
|
||||
*/
|
||||
export class PwshLocalExecutor extends BashExecutor {
|
||||
static inject = ['subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
|
||||
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)
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request into a fully-specified spec: fill `workdir` from
|
||||
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
|
||||
* `config.timeoutMs`, capped at `config.maxTimeoutMs`.
|
||||
*/
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
const timeoutMs = clampTimeout(
|
||||
request.timeoutMs,
|
||||
this.config.timeoutMs,
|
||||
this.config.maxTimeoutMs,
|
||||
'pwsh-local: request.timeoutMs',
|
||||
)
|
||||
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
|
||||
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
stdout: collect(stdoutMaxBytes),
|
||||
stderr: collect(this.config.maxOutputBytes),
|
||||
},
|
||||
graceMs: this.config.graceMs,
|
||||
signal,
|
||||
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
|
||||
}
|
||||
}
|
||||
|
||||
/** The collect-mode readers the executor itself requested (present by construction). */
|
||||
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
|
||||
const { stdout, stderr } = handle.collected
|
||||
/* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
|
||||
if (stdout === undefined || stderr === undefined) {
|
||||
throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
return {
|
||||
...outcome,
|
||||
timedOut,
|
||||
aborted,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: finalOutput(collected.stdout),
|
||||
stderr: finalOutput(collected.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
const note = spawnFailureNote ?? ''
|
||||
spawnFailureNote = undefined
|
||||
return note
|
||||
}
|
||||
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: running.done.then((outcome) => {
|
||||
// Any signal termination is killed, including a command signaling itself.
|
||||
if (proc.status === 'running') {
|
||||
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
const err = collected.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// A failed spawn never produced process output, so the note and real
|
||||
// stderr text are mutually exclusive.
|
||||
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.terminate()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
}
|
||||
|
||||
export default PwshLocalExecutor
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
|
||||
* @module @deepseek-ai/dsh-pwsh-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user