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
@@ -111,8 +111,8 @@
|
||||
en: 2d4f2c8605eccab3
|
||||
zh: 67f1f0218f73c6bf
|
||||
/deepseek-ai-dsh-tool-bash/model-experience/tool-errors/what-the-model-sees:
|
||||
en: d6d7068e23fc1b93
|
||||
zh: 515e8aa586415179
|
||||
en: f4cebcfd09cd3b13
|
||||
zh: 4e3954f417b4a9ed
|
||||
/deepseek-ai-dsh-tool-bash/model-experience/tool-errors/token-effect:
|
||||
en: a3a78ef476ec5e56
|
||||
zh: dd2c8ad0f69e8ff1
|
||||
|
||||
@@ -196,7 +196,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`.
|
||||
Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got <value>`, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`).
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]`
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
验证与策略失败统一为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。
|
||||
验证与策略失败统一为 `Error: <message>`。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got <value>`、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
@@ -73,6 +74,7 @@
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
|
||||
import type { ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processSources, ringDelta } from '../src/background.ts'
|
||||
@@ -279,10 +280,49 @@ describe('foreground commands as jobs', () => {
|
||||
|
||||
it('a command whose start fails reports the failure as an error result and leaves no job behind', async () => {
|
||||
const ctx = await setup()
|
||||
vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new Error('spawn bash ENOENT'))
|
||||
vi.spyOn(ctx.shell, 'execute').mockRejectedValue(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' }))
|
||||
const result = await call(ctx, { command: 'true', description: 'test command' })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } },
|
||||
})
|
||||
expect(text(result)).toBe('Error: cannot start "bash": the program or the working directory does not exist (spawn bash ENOENT)')
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a spawn that fails after the process published reports a coded failure and leaves no job behind', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, { command: 'true', description: 'test command', workdir: join(spillDir, 'missing-workdir') })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } },
|
||||
})
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a job stopped from outside before its process published reports the stop', async () => {
|
||||
const ctx = await setup()
|
||||
vi.spyOn(ctx.shell, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => {
|
||||
spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true })
|
||||
}))
|
||||
const pending = call(ctx, { command: 'ls', description: 'test command' })
|
||||
await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) })
|
||||
ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user')
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toBe('Error: spawn bash ENOENT')
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user')
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a coded preparation failure keeps its name and code through the job path', async () => {
|
||||
const ctx = await setup()
|
||||
vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new SandboxUnavailableError('workspace-write', 'bwrap: setting up uid map: Permission denied'))
|
||||
const result = await call(ctx, { command: 'true', description: 'test command' })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' } },
|
||||
})
|
||||
expect(text(result)).toContain('Runner failure: bwrap: setting up uid map: Permission denied')
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ describe('bash tool', () => {
|
||||
])('rejects value-invalid args %j', async (args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } })
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
@@ -640,7 +640,10 @@ describe('background execution through the job runtime', () => {
|
||||
it('fails loud when the job runtime is not loaded', async () => {
|
||||
const ctx = await setup() // no LocalJobRegistry / ToolJobs
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } },
|
||||
})
|
||||
expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
||||
})
|
||||
|
||||
@@ -717,7 +720,7 @@ describe('background execution through the job runtime', () => {
|
||||
|
||||
// Schema omission is advertising; execution must also enforce the opt-out.
|
||||
const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } })
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
||||
const foreground = await call(ctx, 'bash', { command: 'echo hi', description: 'test command' })
|
||||
expect(foreground.isError).toBe(false)
|
||||
@@ -755,7 +758,10 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
{ command: 'true', description: 'd', justification: 'why' },
|
||||
{ command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
|
||||
]) {
|
||||
expect((await call(ctx, 'bash', args)).isError).toBe(true)
|
||||
expect(await call(ctx, 'bash', args)).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_INVALID_ARGUMENT' } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -793,7 +799,9 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
|
||||
it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => {
|
||||
const plain = await setup()
|
||||
expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition')
|
||||
const unavailable = await call(plain, 'bash', escalate)
|
||||
expect(text(unavailable)).toContain('not available in this composition')
|
||||
expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } })
|
||||
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
const prompted = vi.fn()
|
||||
@@ -1543,3 +1551,29 @@ describe('resolved launch directory metadata', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('launch failures without a job registry', () => {
|
||||
it('classifies a platform spawn error and keeps this call\'s cancellation', async () => {
|
||||
const ctx = await setup()
|
||||
const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' }))
|
||||
expect(await call(ctx, 'bash', { command: 'true', description: 'd' })).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } },
|
||||
})
|
||||
missing.mockRestore()
|
||||
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller stopped')
|
||||
vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => {
|
||||
controller.abort(reason)
|
||||
return Promise.reject(reason)
|
||||
})
|
||||
const aborted = await executeToolFixture(ctx, {
|
||||
signal: controller.signal,
|
||||
callId: ToolCallId('launch-aborted'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'd' },
|
||||
})
|
||||
expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } })
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session/working-directory"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -108,8 +108,8 @@
|
||||
en: 2d4f2c8605eccab3
|
||||
zh: 67f1f0218f73c6bf
|
||||
/deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/what-the-model-sees:
|
||||
en: f0802a10dbe0f7d2
|
||||
zh: 80f8b312dcfc0065
|
||||
en: d45b05d98cf6e7e3
|
||||
zh: b3c59fb3603f511e
|
||||
/deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/token-effect:
|
||||
en: 243bad012dd99c7f
|
||||
zh: 5ee74905020bd49a
|
||||
|
||||
@@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`.
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got <value>`, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`).
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
验证与基础设施失败统一为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。
|
||||
验证与基础设施失败统一为 `Error: <message>`。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got <value>`、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
@@ -72,6 +73,7 @@
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*"
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */
|
||||
|
||||
/**
|
||||
@@ -96,14 +106,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)
|
||||
@@ -114,8 +126,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),
|
||||
}
|
||||
}
|
||||
@@ -127,6 +141,7 @@ export function processJob(
|
||||
process?.kill()
|
||||
},
|
||||
done,
|
||||
failure: () => failure,
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
@@ -25,14 +25,16 @@ import type {} from '@deepseek-ai/dsh-working-directory'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
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-shell-env'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
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 type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
|
||||
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
|
||||
@@ -106,13 +108,13 @@ interface PwshForegroundResult {
|
||||
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
|
||||
function validatePwshArgs(args: PwshToolArgs): 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)}`])
|
||||
}
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
}
|
||||
@@ -193,11 +195,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, 'pwsh')
|
||||
}
|
||||
|
||||
/** 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 pwsh output union. */
|
||||
@@ -245,7 +259,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(
|
||||
@@ -281,6 +298,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
const startJob = (registry: JobRegistry, args: PwshToolArgs, 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: 'pwsh',
|
||||
label: args.command,
|
||||
@@ -295,6 +314,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)
|
||||
@@ -306,7 +326,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) => {
|
||||
@@ -372,11 +392,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 { ...canonicalPwshResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} }
|
||||
}
|
||||
@@ -521,10 +544,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()
|
||||
@@ -547,8 +570,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 canonicalPwshResult(result, spec.workdir)
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { unsupportedModel, provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
|
||||
import { SandboxPreparationError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
|
||||
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell'
|
||||
import { renderPwshPromoted } from '../src/render.ts'
|
||||
@@ -346,8 +347,56 @@ describe('foreground commands as jobs (pwsh)', () => {
|
||||
const { ctx, pwsh } = await setup()
|
||||
pwsh.backgroundHandler = () => { throw new Error('spawn pwsh ENOENT') }
|
||||
const result = await call(ctx, { command: 'Get-Missing', description: 'test command' })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_FAILED' } },
|
||||
})
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh ENOENT')
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a spawn that fails after the process published reports a coded failure', async () => {
|
||||
const { ctx, pwsh } = await setup()
|
||||
pwsh.backgroundHandler = () => {
|
||||
const started = observableProcess()
|
||||
started.finish()
|
||||
return Object.assign(started.proc, {
|
||||
result: () => Promise.reject(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })),
|
||||
})
|
||||
}
|
||||
const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } },
|
||||
})
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a job stopped from outside before its process published reports the stop', async () => {
|
||||
const { ctx, pwsh } = await setup()
|
||||
vi.spyOn(pwsh, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => {
|
||||
spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true })
|
||||
}))
|
||||
const pending = call(ctx, { command: 'Get-ChildItem', description: 'test command' })
|
||||
await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) })
|
||||
ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user')
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: spawn pwsh ENOENT')
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user')
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('a Windows ACL preparation failure keeps its name and code through the job path', async () => {
|
||||
const { ctx, pwsh } = await setup()
|
||||
const cause = new Error('SetNamedSecurityInfoW failed (Win32 5): C:\\ws')
|
||||
pwsh.backgroundHandler = () => {
|
||||
throw new SandboxPreparationError('sandbox-local: windows-acl workspace grant failed', 'SANDBOX_PREPARATION_FAILED', { cause })
|
||||
}
|
||||
const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED' } },
|
||||
})
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -434,6 +434,13 @@ describe('argument validation', () => {
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
|
||||
.toContain('invalid timeoutMs: expected a positive number')
|
||||
for (const args of [
|
||||
{ command: ' ', description: 'd' },
|
||||
{ command: 'Write-Output hi', description: ' ' },
|
||||
{ command: 'Write-Output hi', description: 'd', timeoutMs: -1 },
|
||||
]) {
|
||||
expect(await call(ctx, 'pwsh', args)).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -676,7 +683,9 @@ describe('sandbox escalation through ctx.approval', () => {
|
||||
|
||||
it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => {
|
||||
const plain = await setup()
|
||||
expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
|
||||
const unavailable = await call(plain.ctx, 'pwsh', escalate)
|
||||
expect(text(unavailable)).toContain('not available in this composition')
|
||||
expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } })
|
||||
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
const prompted = vi.fn()
|
||||
@@ -861,7 +870,10 @@ describe('background execution through the job runtime', () => {
|
||||
it('fails loud when the job runtime is not loaded', async () => {
|
||||
const { ctx } = await setup() // no LocalJobRegistry / ToolJobs
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } },
|
||||
})
|
||||
expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
||||
})
|
||||
|
||||
@@ -912,7 +924,7 @@ describe('background execution through the job runtime', () => {
|
||||
|
||||
// Schema omission is advertising; execution must also enforce the opt-out.
|
||||
const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } })
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
||||
const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
|
||||
expect(foreground.isError).toBe(false)
|
||||
@@ -1280,3 +1292,29 @@ describe('resolved launch directory metadata', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('launch failures without a job registry', () => {
|
||||
it('classifies a platform spawn error and keeps this call\'s cancellation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' }))
|
||||
expect(await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'd' })).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } },
|
||||
})
|
||||
missing.mockRestore()
|
||||
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller stopped')
|
||||
vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => {
|
||||
controller.abort(reason)
|
||||
return Promise.reject(reason)
|
||||
})
|
||||
const aborted = await executeToolFixture(ctx, {
|
||||
signal: controller.signal,
|
||||
callId: ToolCallId('launch-aborted'),
|
||||
name: 'pwsh',
|
||||
arguments: { command: 'Write-Output ok', description: 'd' },
|
||||
})
|
||||
expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } })
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session/working-directory"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+6
@@ -15586,6 +15586,9 @@ importers:
|
||||
'@deepseek-ai/dsh-shell-env':
|
||||
specifier: workspace:*
|
||||
version: link:../shell-env
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess-local
|
||||
@@ -15735,6 +15738,9 @@ importers:
|
||||
'@deepseek-ai/dsh-shell-env':
|
||||
specifier: workspace:*
|
||||
version: link:../shell-env
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess-local
|
||||
|
||||
Reference in new issue
Block a user