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

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

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

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

No files matched your search

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