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:
1 parent
c14b6860b4
commit
dbcab765f9
19 files changed
+322
-61
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user