fix(shell): keep coded failures on the bash and pwsh job path

A foreground bash or pwsh call that waits on a job rethrew a failed
preparation as new Error(view.detail), dropping the original class and
code, so even SandboxUnavailableError reached tool/result without
error.name or error.code. processJob now keeps the original error for a
failed job and the tool rethrows it; launch and spawn rejections on
every path keep a HarnessError's identity, and a platform spawn error
becomes SubprocessError (SUBPROCESS_SPAWN_NOT_FOUND or _SPAWN_FAILED).

Argument violations are ToolArgsError, a missing sandboxing executor
for sandbox_permissions is SandboxEscalationError
SANDBOX_ESCALATION_UNAVAILABLE, a disabled background call is JobError
JOB_BACKGROUND_DISABLED, and a missing job runtime is
ToolContextUnavailableError.

Refs #7869
This commit is contained in:
sjw1231 committed 2026-11-06 16:39:36 +08:00
1 parent c14b6860b4
commit dbcab765f9
19 files changed
+322 -61

No files matched your search

+18 -3
View File
@@ -11,6 +11,16 @@ import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sand
import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell'
import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs'
/** Job hooks plus the original error of a job that failed before its process produced an outcome. */
export interface ProcessJobHooks extends JobHooks {
/**
* The error that ended a `failed` job, kept with its own class and code;
* undefined while the job runs and for a completed or killed job.
* @returns the original failure, boxed so any thrown value is preserved.
*/
failure(): { error: unknown } | undefined
}
/**
* Sandbox facts worth the terminal detail: a runner that never ran the
* command, or a denial (with the escalation hint this composition offers).
@@ -94,14 +104,16 @@ export function ringDelta(chunks: readonly JobChunk[]): string {
* Adapt asynchronous shell preparation after job admission without exposing a partial process.
* @param start - starts the process with job-owned cancellation.
* @param outcome - projects the settled process into the job outcome.
* @returns synchronous job hooks whose completion includes preparation and process settlement.
* @returns synchronous job hooks whose completion includes preparation and process settlement,
* and the original error of a failed preparation for a caller that reports it.
*/
export function processJob(
start: (signal: AbortSignal) => Promise<ShellProcess>,
outcome: (process: ShellProcess) => JobOutcome,
): JobHooks {
): ProcessJobHooks {
const controller = new AbortController()
let process: ShellProcess | undefined
let failure: { error: unknown } | undefined
const done: Promise<JobOutcome> = (async () => {
try {
process = await start(controller.signal)
@@ -112,8 +124,10 @@ export function processJob(
}
return outcome(process)
} catch (error: unknown) {
const killed = controller.signal.aborted && process === undefined
if (!killed) failure = { error }
return {
status: controller.signal.aborted && process === undefined ? 'killed' : 'failed',
status: killed ? 'killed' : 'failed',
detail: error instanceof Error ? error.message : String(error),
}
}
@@ -125,5 +139,6 @@ export function processJob(
process?.kill()
},
done,
failure: () => failure,
}
}
+43 -16
View File
@@ -16,14 +16,16 @@ import { FiberState } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { isAbsolute, sep } from 'node:path'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import { defineTool, TOOL_ABORTED, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { JobError } from '@deepseek-ai/dsh-jobs'
import type { JobId, JobRegistry, JobView } from '@deepseek-ai/dsh-jobs'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-shell-env'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, SandboxEscalationError, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { subprocessSpawnFailure } from '@deepseek-ai/dsh-subprocess'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
@@ -72,13 +74,13 @@ interface BashToolArgs {
function validateBashArgs(args: BashToolArgs, effectiveMode: SandboxMode | undefined): void {
if (args.command.trim().length === 0) {
throw new Error('invalid command: expected a non-empty string')
throw new ToolArgsError(['invalid command: expected a non-empty string'])
}
if (args.description.trim().length === 0) {
throw new Error('invalid description: expected a non-empty string')
throw new ToolArgsError(['invalid description: expected a non-empty string'])
}
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
throw new ToolArgsError([`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`])
}
if (args.sandbox_permissions !== undefined && args.sandbox_permissions === effectiveMode) return
const justification = args.sandbox_permissions === undefined && args.justification?.trim() === ''
@@ -185,11 +187,23 @@ function toolAborted(): HarnessError {
return error
}
/**
* Report a launch or preparation rejection as this tool's failure. A coded
* error keeps its class and code; this call's cancellation stays as thrown so
* the registry records it as aborted; any other value is classified as a
* subprocess spawn failure with the original as `cause`.
*/
function launchFailure(error: unknown, signal: AbortSignal): unknown {
return signal.aborted ? error : subprocessSpawnFailure(error, 'bash')
}
/** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */
interface StartedJob {
readonly id: JobId
readonly process: () => ShellExecution | undefined
readonly stopped: () => string | undefined
/** The original error of a registration whose preparation failed before the process published. */
readonly failure: () => { error: unknown } | undefined
}
/** Canonical background-handle properties shared by the bash output union. */
@@ -232,7 +246,10 @@ export function apply(ctx: Context, config: Config = {}): void {
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
throw new SandboxEscalationError(
'sandbox_permissions is not available in this composition (no sandboxing executor to escalate)',
'SANDBOX_ESCALATION_UNAVAILABLE',
)
}
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
@@ -266,6 +283,8 @@ export function apply(ctx: Context, config: Config = {}): void {
const startJob = (registry: JobRegistry, args: BashToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => {
let proc: ShellExecution | undefined
let stopped: string | undefined
// The registry runs the starter inside start(), so this is assigned before start() returns.
let failure!: () => { error: unknown } | undefined
const id = registry.start({
kind: 'bash',
label: args.command,
@@ -280,6 +299,7 @@ export function apply(ctx: Context, config: Config = {}): void {
},
started => processOutcome(started, escalationModes),
)
failure = () => hooks.failure()
return {
done: hooks.done,
// A kill from outside this call (the human's, a parallel job_kill)
@@ -291,7 +311,7 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
return { id, process: () => proc, stopped: () => stopped }
return { id, process: () => proc, stopped: () => stopped, failure: () => failure() }
}
/** Wait on a registered foreground command until it settles or the timeout passes. */
const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => {
@@ -357,11 +377,14 @@ export function apply(ctx: Context, config: Config = {}): void {
// the split streams and classification the foreground result needs.
registry.remove(attached.id, owner)
const process = attached.process()
// No process was published: the starter failed before the spawn, and the
// job's terminal detail is the failure the foreground projection would
// have rejected with.
if (process === undefined) throw new Error(view.detail)
const result = await process.result()
// No process was published: the starter failed before the spawn. The
// original error keeps its class and code, exactly as the foreground
// projection would have rejected; a kill before the spawn has only its detail.
if (process === undefined) {
const failure = attached.failure()
throw failure === undefined ? new Error(view.detail) : launchFailure(failure.error, exec.signal)
}
const result = await process.result().catch((error: unknown) => { throw launchFailure(error, exec.signal) })
const stopped = attached.stopped()
return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} }
}
@@ -498,10 +521,10 @@ export function apply(ctx: Context, config: Config = {}): void {
if (args.run_in_background === true) {
// Undeclared keys are allowed, so schema omission also needs enforcement.
if (!backgroundEnabled) {
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
throw new JobError('run_in_background is disabled for this deployment (enableRunInBackground: false)', 'JOB_BACKGROUND_DISABLED')
}
if (jobs === undefined) {
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
throw new ToolContextUnavailableError('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
}
// The caller owns cancellation until ctx.jobs commits detached ownership.
if (exec.signal.aborted) throw toolAborted()
@@ -524,8 +547,12 @@ export function apply(ctx: Context, config: Config = {}): void {
if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec)
}
const spec = ctx.shell.resolve({ ...request, signal: exec.signal })
const foreground = await ctx.shell.execute(spec)
const result = await foreground.result()
let result: ShellRunResult
try {
result = await (await ctx.shell.execute(spec)).result()
} catch (error) {
throw launchFailure(error, exec.signal)
}
if (result.aborted) throw toolAborted()
return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir) }
},