fix(code-runtime): settle Python provider contracts

This commit is contained in:
Tianyi Cui
2026-08-31 15:50:45 +08:00
parent 8e9d5467b0
commit 7f84a825c9
33 changed files with 1145 additions and 223 deletions
@@ -2,8 +2,8 @@
* CPython subprocess code runtime: a fresh `python3` process runs each model program under an
* asyncio event loop with top-level ``await``. Binding calls travel on fd 3 as JSON-lines,
* leaving stdout/stderr free for the program's own output. This is containment, not a security
* boundary: model code has bash-equivalent trust, contained by an empty environment, RLIMIT_CPU
* + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
* boundary: model code has bash-equivalent trust, contained by a tempdir-only environment,
* RLIMIT_CPU + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
*
* The package owns the versionless fd-3 wire protocol between the Node host and
* the CPython subprocess. The protocol's host-side codec and hostile-frame
@@ -12,7 +12,7 @@
* @module @deepseek-ai/dsh-experimental-code-runtime-python
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
@@ -84,8 +84,8 @@ export interface Config {
/** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
graceMs?: number
/**
* Absolute path or basename of the CPython interpreter to spawn. Resolved
* through `PATH` when a basename is given.
* Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
* Resolved and validated once at plugin load; a basename searches `PATH`.
*/
pythonBin?: string
}
@@ -393,41 +393,31 @@ export function readProcessStart(pid: number): string | undefined {
}
/**
* Resolve `pythonBin` to an absolute path against the CURRENT process `PATH`,
* BEFORE the child spawns with an empty environment. A basename (the default
* `python3`) would otherwise fail: `env: {}` drops `PATH`, so Node's own lookup
* falls back to the platform default (`/usr/bin:/bin`) and misses interpreters
* Resolve `pythonBin` to one executable absolute path at plugin load. A basename
* (the default `python3`) searches the current process `PATH`; the child receives
* no `PATH`, so Node's own lookup would otherwise fall back to the platform
* default (`/usr/bin:/bin`) and miss interpreters
* that live only on the caller's `PATH` (Nix, pyenv, Homebrew, conda). An
* absolute or explicitly relative path is validated directly: it must exist,
* be executable, and be a regular file — a missing, non-executable, or
* directory path is a self-contained configuration error that must fail at
* load, not at the first run (the child spawns with an empty environment, so
* execvp's platform default would otherwise silently mask the mistake). A
* relative explicit path resolves against the host CWD, mirroring where
* `spawn` would have looked for it. When no `PATH` entry holds an executable
* match, `undefined` is returned and the LOAD check rejects the configuration:
* falling back to the bare name would let spawn's `env: {}` execvp silently
* start a system interpreter from the platform default PATH that the caller
* never asked for.
* @param bin - the configured interpreter (absolute or relative path, or bare command).
* absolute path is verified in place, and an explicitly relative path is first
* resolved against the load-time working directory. When no candidate is an
* executable regular file, `undefined` is returned and the load check rejects
* the configuration: falling back to the bare name would let spawn's scrubbed env
* execvp silently start a system interpreter from the platform default PATH
* that the caller never asked for.
* @param bin - the configured interpreter (absolute path, relative path, or bare command).
* @returns an absolute path when resolvable, else `undefined`.
*/
export function resolvePythonBin(bin: string): string | undefined {
if (isAbsolute(bin) || bin.includes('/')) {
// An explicit path is used as given (resolved against the host CWD when
// relative), but only when it is a real executable regular file. The same
// checks as the PATH branch below: `accessSync(X_OK)` admits directories,
// so `isFile` narrows further, and a path that fails either is not a
// usable interpreter.
const candidate = resolve(bin)
const executableFile = (candidate: string): string | undefined => {
try {
accessSync(candidate, fsConstants.X_OK)
if (!statSync(candidate).isFile()) return undefined
return candidate
return statSync(candidate).isFile() ? candidate : undefined
} catch {
return undefined
}
}
if (isAbsolute(bin)) return executableFile(bin)
if (bin.includes('/')) return executableFile(resolve(bin))
const path = process.env.PATH
/* v8 ignore next -- PATH is set in every environment the runtime boots in; the guard is defensive. */
if (path === undefined) return undefined
@@ -438,21 +428,55 @@ export function resolvePythonBin(bin: string): string | undefined {
// path — spawn() resolves a relative pythonBin against the host CWD, which
// is outside the seam contract.
if (dir === '' || !isAbsolute(dir)) continue
const candidate = join(dir, bin)
try {
accessSync(candidate, fsConstants.X_OK)
// A directory passes X_OK too, so require a regular file: a PATH entry
// named like the interpreter (e.g. a `python3` directory) must not be
// chosen over a later real interpreter.
if (!statSync(candidate).isFile()) continue
return candidate
} catch {
// Not executable here; try the next PATH entry.
}
const executable = executableFile(join(dir, bin))
if (executable !== undefined) return executable
}
return undefined
}
/** Lowest CPython version supported by the bootstrap and its traceback behavior. */
const MIN_CPYTHON = { major: 3, minor: 10 } as const
/** Fixed load-time probe bound; a configured executable must not hang plugin activation. */
const PYTHON_PROBE_TIMEOUT_MS = 5_000
/** The only host environment fact exposed to the child. */
function pythonEnvironment(): NodeJS.ProcessEnv {
return { TMPDIR: tmpdir() }
}
/** Fail load unless `bin` is a responsive CPython 3.10+ interpreter. */
function validatePythonBin(bin: string): void {
let output: string
try {
output = execFileSync(bin, [
'-I',
'-c',
'import sys; print(sys.implementation.name, sys.version_info.major, sys.version_info.minor, sys.version_info.micro)',
], {
encoding: 'utf8',
env: pythonEnvironment(),
timeout: PYTHON_PROBE_TIMEOUT_MS,
maxBuffer: 1_024,
}).trim()
} catch (error: unknown) {
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(bin)} failed the CPython version probe: ${messageOf(error)}`)
}
const match = /^(\S+) (\d+) (\d+) (\d+)$/.exec(output)
if (match === null) {
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(bin)} did not report a CPython version`)
}
const [, implementation, majorText, minorText, patchText] = match
const major = Number(majorText)
const minor = Number(minorText)
if (implementation !== 'cpython') {
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython, got ${implementation}`)
}
if (major < MIN_CPYTHON.major || (major === MIN_CPYTHON.major && minor < MIN_CPYTHON.minor)) {
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython ${MIN_CPYTHON.major}.${MIN_CPYTHON.minor} or newer, got ${implementation} ${majorText}.${minorText}.${patchText}`)
}
}
/** The marker appended when a diagnostic message is byte-capped host-side. */
const TRUNCATION_MARKER = '… [truncated]'
@@ -731,6 +755,7 @@ export class PythonCodeRuntime extends CodeRuntime {
readonly isolation = 'process'
private readonly config: ResolvedConfig
private readonly pythonBin: string
private readonly live = new Set<LiveRun>()
private disposed = false
@@ -785,26 +810,13 @@ export class PythonCodeRuntime extends CodeRuntime {
// throws `ERR_INVALID_ARG_TYPE` — both from inside `run()`, so the method
// REJECTS instead of resolving the `worker-exit` the seam promises for a
// child that cannot start. A basename with no `PATH` match would silently
// fall to execvp's platform default `PATH` under the empty spawn
// fall to execvp's platform default `PATH` under the minimal spawn
// environment (see the resolvePythonBin JSDoc), so it is rejected here
// too. All three are self-contained configuration errors that fail at
// load.
if (this.config.pythonBin === '' || this.config.pythonBin.includes('\0')) {
throw new Error(`dsh-code-runtime-python: config.pythonBin must be a non-empty path without NUL bytes, got ${JSON.stringify(this.config.pythonBin)}`)
}
// An explicit path that is not an executable regular file must fail at load
// like any other self-contained configuration error (the empty/NUL cases
// above); a basename that is not on PATH must fail at load, not silently
// fall to execvp's platform default PATH (spawn runs with an EMPTY
// environment, so execvp would resolve /usr/bin:/bin and could start a
// system interpreter the caller never asked for). resolvePythonBin applies
// the executable-regular-file check to both forms and returns undefined for
// either failure; the message distinguishes the two so the fix is obvious.
const resolvedBin = resolvePythonBin(this.config.pythonBin)
if (resolvedBin === undefined) {
const explicit = isAbsolute(this.config.pythonBin) || this.config.pythonBin.includes('/')
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(this.config.pythonBin)} ${explicit ? 'is not an executable regular file' : 'does not resolve on PATH'}`)
}
// `maxWallMs` and `graceMs` are armed with setTimeout, which clamps any
// delay past MAX_TIMER_DELAY_MS to 1 ms without a word — turning a
// generous ceiling into an instant timeout and a generous grace period into
@@ -905,6 +917,19 @@ export class PythonCodeRuntime extends CodeRuntime {
throw new Error(`dsh-code-runtime-python: config.${key} times the ${OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE}x worst-case Unicode expansion must fit within the ${budgetableBytes} bytes left after the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline within the ${addressSpaceBytes}-byte addressSpaceMb, so a near-budget output truncates rather than breaching RLIMIT_AS as worker-exit; got ${String(this.config[key])} against a limit of ${admissibleBudget}`)
}
}
// Resolve and validate the executable ONCE, after the pure config checks.
// Re-resolving a basename in each run would let a later PATH change silently
// switch interpreters, while an unchecked explicit path would turn
// self-contained misconfiguration into a late worker-exit. A missing or
// unsupported interpreter is a load failure. Later filesystem mutation is
// outside config validation; a missing executable settles as worker-exit.
const pythonBin = resolvePythonBin(this.config.pythonBin)
if (pythonBin === undefined) {
const explicit = isAbsolute(this.config.pythonBin) || this.config.pythonBin.includes('/')
throw new Error(`dsh-code-runtime-python: config.pythonBin ${JSON.stringify(this.config.pythonBin)} ${explicit ? 'is not an executable regular file' : 'does not resolve on PATH'}`)
}
validatePythonBin(pythonBin)
this.pythonBin = pythonBin
ctx.effect(() => () => this.teardown(), 'python code-runtime teardown')
}
@@ -1061,8 +1086,8 @@ export class PythonCodeRuntime extends CodeRuntime {
// This run's own staging directory, removed at settlement.
const bootstrapDir = dirname(bootstrapPath)
// Explicit pipe count of 4 puts the framed-JSON channel at fd 3 in the child.
// Resolve the interpreter against the current PATH first: the child's empty
// env would otherwise strip PATH and miss a basename python3 (see resolvePythonBin).
// The constructor resolved and validated the interpreter once; runs keep that
// exact path even if the host later changes PATH.
// `spawn` can throw SYNCHRONOUSLY — a descriptor-exhausted host (EMFILE) or a
// libuv-level failure surfaces here, before the Promise executor and its
// settlement path exist. Left uncaught it would REJECT run() (the seam
@@ -1080,15 +1105,11 @@ export class PythonCodeRuntime extends CodeRuntime {
// right after the done frame, before any finalization-time flush could
// run. The `_LogStream` replacement of `sys.stdout`/`sys.stderr` is
// unaffected (it is a Python object, not the C-level stdio buffer).
// Load validated that the configured interpreter resolves to an
// executable regular file (basename through PATH, explicit path
// directly). The type assertion is the load-time contract (see the
// pythonBin load checks); a PATH change between load and run would make
// this undefined and spawn throws synchronously, which the surrounding
// try settles as worker-exit like any other spawn failure.
const resolvedPythonBin = resolvePythonBin(this.config.pythonBin) as string
child = spawn(resolvedPythonBin, ['-u', '-I', bootstrapPath], {
env: {},
child = spawn(this.pythonBin, ['-u', '-I', bootstrapPath], {
// Preserve only the platform temp directory. macOS system Python emits a
// startup warning when TMPDIR is absent; ambient credentials, PATH, HOME,
// and other host state remain unavailable to model code.
env: pythonEnvironment(),
detached: true, // Own process group — kill(-pid, sig) reaches subprocesses the model program spawns.
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
})
@@ -1107,7 +1128,6 @@ export class PythonCodeRuntime extends CodeRuntime {
// inheriting fd 0 would keep the host process from exiting even after the
// closeDeadline forced settlement. The child (and any descendant) reads
// EOF on fd 0 instead, and no host handle survives.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the boot-write-failure fake child has no stdin.
child.stdin?.destroy()
} catch (error: unknown) {
try {
@@ -1229,7 +1249,7 @@ export class PythonCodeRuntime extends CodeRuntime {
// (native prints, C-extension writes) still counts against the ledger.
//
// Output is admitted per LINE, not per transport chunk. `logs` entries
// are joined with `\n` downstream (Code Mode), so each entry must be one
// are joined with `\n` downstream (PTC mode), so each entry must be one
// line: pushing a raw `data` chunk would turn every arbitrary pipe-read
// boundary into a model-visible newline, so a single 200 KiB native write
// split across pipe reads would read back with spurious line breaks. The
@@ -1289,7 +1309,6 @@ export class PythonCodeRuntime extends CodeRuntime {
// A line admitted inside the loop may have exhausted the ledger and
// cleared this pipe (see clearStray); the re-retain below must not
// resurrect the doomed residual.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- admit() (a closure) sets it.
if (logsTruncated) return
stray.chunks = detachResidual(buffered)
stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
@@ -1784,7 +1803,6 @@ export class PythonCodeRuntime extends CodeRuntime {
// after `maxWallMs`, an abort, or dispose already settled the run
// would spend host heap on a frame that is then discarded, and
// binding resolution carries no seam-level byte cap to bound it.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the run can settle while this binding is awaited.
if (settled) return
// The seam requires a lossy resolution to REJECT descriptively,
// not silently coerce: a raw JSON.stringify would turn NaN/
@@ -1804,13 +1822,8 @@ export class PythonCodeRuntime extends CodeRuntime {
// before `sendReply` peeks at `settled`. Dropping the framed
// reply early spares the host heap and time for a run whose
// outcome is already fixed.
// (oxlint block-disable so both `v8 ignore next` and the rule
// suppression land on the `if`: `settled` flips true mid-wait,
// invisible to the type-aware lint, which narrows it to false.)
/* oxlint-disable typescript/no-unnecessary-condition */
/* v8 ignore next -- a rejection arriving after settlement is not schedulable from a test. */
if (settled) return
/* oxlint-enable typescript/no-unnecessary-condition */
sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
} finally {
// Release the in-flight slot on every exit — reply written,