117 lines
5.0 KiB
TypeScript
117 lines
5.0 KiB
TypeScript
/**
|
|
* Internal sandbox-result classification helpers.
|
|
*
|
|
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
|
*/
|
|
|
|
import { accessSync, constants, statSync } from 'node:fs'
|
|
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
|
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
|
|
|
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
|
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
|
|
|
/** Whether the caller-owned spawn cwd can be entered. */
|
|
function isUsableWorkdir(path: string): boolean {
|
|
try {
|
|
if (!statSync(path).isDirectory()) return false
|
|
accessSync(path, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
|
* after independently ruling out the caller-owned cwd. A supplied error path
|
|
* must exactly identify the runner; without one, the syscall must. With a
|
|
* usable cwd, these codes describe resolution or execute permission for that
|
|
* argv[0] or its shebang interpreter.
|
|
* The workdir is checked at classification time, not atomically with spawn;
|
|
* concurrent path replacement may change attribution but cannot permit an
|
|
* unconfined execution.
|
|
* @param error - the original spawn rejection.
|
|
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
|
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
|
* @returns whether the rejection has executable-specific runner evidence.
|
|
*/
|
|
export function isRunnerSpawnFailure(
|
|
error: unknown,
|
|
runnerProgram: string | undefined,
|
|
workdir: string,
|
|
): boolean {
|
|
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
|
if (typeof error !== 'object' || error === null) return false
|
|
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
|
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
|
if (typeof syscall !== 'string') return false
|
|
const exactSyscall = `spawn ${runnerProgram}`
|
|
if (path === undefined) return syscall === exactSyscall
|
|
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
|
return syscall === 'spawn' || syscall === exactSyscall
|
|
}
|
|
|
|
/** Fatal runner evidence retained for infrastructure-error detail. */
|
|
interface RunnerFailureMatch {
|
|
/** The original stderr line that matched a fatal signature. */
|
|
detail: string
|
|
}
|
|
|
|
/**
|
|
* Classify a failed run against the selected backend's denial dialect.
|
|
* @param result - settled foreground run.
|
|
* @param signatures - case-insensitive denial substrings from the active wrap.
|
|
* @returns whether the failed run matches that denial dialect.
|
|
*/
|
|
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
|
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
|
}
|
|
|
|
/**
|
|
* Classify one settled process against the selected backend's structured
|
|
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
|
* exit-code gate, and a fatal signature on one stderr line after exact
|
|
* informational lines are excluded.
|
|
* @param exitCode - process exit code; null means signal termination.
|
|
* @param stderr - collected stderr text, left unchanged.
|
|
* @param rules - structured runner-failure rules from the active wrap.
|
|
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
|
*/
|
|
export function classifyRunnerFailure(
|
|
exitCode: number | null,
|
|
stderr: string,
|
|
rules: readonly RunnerFailureRule[],
|
|
): RunnerFailureMatch | undefined {
|
|
if (exitCode === null || exitCode === 0) return undefined
|
|
const lines = stderr.split(/\r?\n/)
|
|
for (const rule of rules) {
|
|
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
|
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
|
// An empty or whitespace-only substring is not meaningful runner evidence.
|
|
// Ignore it while keeping any valid signatures beside it active.
|
|
const fatalSignatures = rule.fatalSignatures
|
|
.filter(signature => signature.trim().length > 0)
|
|
.map(signature => signature.toLowerCase())
|
|
for (const line of lines) {
|
|
const lowered = line.toLowerCase()
|
|
if (informationalLines.has(lowered)) continue
|
|
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Match a non-zero exit against case-insensitive stderr signatures.
|
|
* @param exitCode - process exit code; null means signal termination.
|
|
* @param stderr - collected stderr text.
|
|
* @param signatures - substrings identifying the selected backend's dialect.
|
|
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
|
*/
|
|
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
|
if (exitCode === null || exitCode === 0) return false
|
|
const lowered = stderr.toLowerCase()
|
|
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
|
}
|