From dbcab765f95ada1fb0eb09ba304bfefcd86d4513 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:29:33 +0800 Subject: [PATCH] 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 --- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 6 +- packages/shell/tool-bash/src/background.ts | 21 ++++++- packages/shell/tool-bash/src/index.ts | 59 ++++++++++++++----- .../shell/tool-bash/tests/background.spec.ts | 44 +++++++++++++- packages/shell/tool-bash/tests/tools.spec.ts | 44 ++++++++++++-- packages/shell/tool-bash/tsconfig.json | 3 + packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 6 +- packages/shell/tool-pwsh/src/background.ts | 21 ++++++- packages/shell/tool-pwsh/src/index.ts | 59 ++++++++++++++----- .../shell/tool-pwsh/tests/background.spec.ts | 51 +++++++++++++++- packages/shell/tool-pwsh/tests/tools.spec.ts | 44 +++++++++++++- packages/shell/tool-pwsh/tsconfig.json | 3 + pnpm-lock.yaml | 6 ++ 19 files changed, 322 insertions(+), 61 deletions(-) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index c1244306b1..625910149b 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -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 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 045344c881..411d5da79f 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -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: `. 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 `, 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: `. 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 `, 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 diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 53d2b1984e..7f222c91aa 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` #### 模型看到什么 -验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`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: `。本包的稳定消息包括 `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 `、升权配对失败、`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 影响 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 3d7102e3a9..b2314e05f9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -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:*" } } diff --git a/packages/shell/tool-bash/src/background.ts b/packages/shell/tool-bash/src/background.ts index 49da48b00b..8835c6603c 100644 --- a/packages/shell/tool-bash/src/background.ts +++ b/packages/shell/tool-bash/src/background.ts @@ -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, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (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, } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 39a2a20369..46554fc192 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -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 => { 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) } }, diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index add9714ac4..58129b3a0b 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -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([]) }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index d3f20572f8..554b7f4e35 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -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 } } }) + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 5efa5088e1..42510cdbdd 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index ed32de4eef..8032f1b349 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -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 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 051f3cd4a2..34bc941529 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -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: `. 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 `, 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: `. 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 `, 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 diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 90e2b726c1..f81c92a602 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到什么 -验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`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: `。本包的稳定消息包括 `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 `、升权配对失败、`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 影响 diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 360190080e..284751c48c 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -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:*" } } diff --git a/packages/shell/tool-pwsh/src/background.ts b/packages/shell/tool-pwsh/src/background.ts index 67d937df79..120e595b04 100644 --- a/packages/shell/tool-pwsh/src/background.ts +++ b/packages/shell/tool-pwsh/src/background.ts @@ -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, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (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 */ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index a3472b4357..0ef0fa260b 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -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 => { 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) }, diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 2794df944a..48b01dde16 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -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([]) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 731da47a4c..00fb01724f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -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 } } }) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 80a56fcda6..4015c265db 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90d655e2af..4b81d0ed5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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