@banbolee/dsh-fish-shell 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/README.zh.md +63 -0
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +45 -43
- package/persistent.js +238 -45
- package/policy.js +73 -14
- package/terminal-fish.js +146 -36
- package/tool.js +258 -60
package/tool.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The functional surface mirrors the official `@deepseek-ai/dsh-tool-bash`
|
|
13
13
|
* (the harness's model-facing `bash` tool) with shell-specific wording
|
|
14
|
-
* swapped to fish: background execution through `ctx.jobs` (
|
|
15
|
-
*
|
|
14
|
+
* swapped to fish: background execution through `ctx.jobs` (the process's
|
|
15
|
+
* non-consuming stream readers as the registry's pull sources, with the
|
|
16
|
+
* sandbox facts joined to the job outcome), same-turn sandbox escalation through
|
|
16
17
|
* `ctx.approval` (strictly-wider modes only, fail-closed), canonical
|
|
17
18
|
* foreground results carrying the complete sandbox facts
|
|
18
19
|
* (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
|
|
@@ -23,6 +24,8 @@
|
|
|
23
24
|
* @module @banbolee/dsh-fish-shell/tool
|
|
24
25
|
*/
|
|
25
26
|
|
|
27
|
+
/// <reference path="./job-kind.d.ts" />
|
|
28
|
+
|
|
26
29
|
import { isAbsolute, resolve } from 'node:path'
|
|
27
30
|
import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
|
28
31
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
@@ -35,6 +38,62 @@ import {
|
|
|
35
38
|
} from '@deepseek-ai/dsh-sandbox'
|
|
36
39
|
import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Type-only reference so `@deepseek-ai/dsh-shell-env`'s module augmentation
|
|
43
|
+
* (`ctx.shellEnv`, the service `inject` above requires) joins this checkJs
|
|
44
|
+
* program: the bundle patch mounts the package at runtime, and this module has
|
|
45
|
+
* no reason to import it as a value.
|
|
46
|
+
* @typedef {import('@deepseek-ai/dsh-shell-env').ShellEnvRegistry} ShellEnvRegistry
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One captured stream with its truncation facts.
|
|
51
|
+
* @typedef {object} FishOutputStream
|
|
52
|
+
* @property {string} text
|
|
53
|
+
* @property {boolean} truncated
|
|
54
|
+
* @property {string} [spillPath]
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sandbox facts carried into the canonical result (`ShellSandboxInfo` as
|
|
59
|
+
* lossless JSON).
|
|
60
|
+
* @typedef {object} FishSandboxFacts
|
|
61
|
+
* @property {import('@deepseek-ai/dsh-sandbox').SandboxMode} mode
|
|
62
|
+
* @property {boolean} denied
|
|
63
|
+
* @property {import('@deepseek-ai/dsh-sandbox').SandboxEnforcement} [enforcement]
|
|
64
|
+
* @property {boolean} [runnerFailed]
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The `fish` tool's parsed arguments. Registration is raw JSON Schema, so the
|
|
69
|
+
* registry hands the body `unknown`; these are the fields the tool validates
|
|
70
|
+
* and narrows at runtime, typed as the schema admits them (every one optional).
|
|
71
|
+
* @typedef {object} FishToolArgs
|
|
72
|
+
* @property {string} [command] - the fish command to execute.
|
|
73
|
+
* @property {string} [description] - the one-line UI description.
|
|
74
|
+
* @property {string} [workdir] - optional working directory (session-relative when not absolute).
|
|
75
|
+
* @property {number} [timeoutMs] - optional foreground deadline.
|
|
76
|
+
* @property {boolean} [run_in_background] - run as a `ctx.jobs` background job.
|
|
77
|
+
* @property {string} [sandbox_permissions] - one-shot escalation target.
|
|
78
|
+
* @property {string} [justification] - the escalation's one-sentence reason.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The canonical JSON value of one foreground fish run — the `foreground`
|
|
83
|
+
* branch of `OUTPUT_SCHEMA` and what `renderResult` renders. Structural on
|
|
84
|
+
* purpose: a replay holds this projection, not the executor's
|
|
85
|
+
* `ShellRunResult`.
|
|
86
|
+
* @typedef {object} FishForegroundValue
|
|
87
|
+
* @property {number | null} exitCode
|
|
88
|
+
* @property {string | null} signal
|
|
89
|
+
* @property {boolean} timedOut
|
|
90
|
+
* @property {boolean} aborted
|
|
91
|
+
* @property {number} timeoutMs
|
|
92
|
+
* @property {FishOutputStream} stdout
|
|
93
|
+
* @property {FishOutputStream} stderr
|
|
94
|
+
* @property {FishSandboxFacts} [sandbox]
|
|
95
|
+
*/
|
|
96
|
+
|
|
38
97
|
export const name = 'fish-tool'
|
|
39
98
|
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
|
|
40
99
|
|
|
@@ -65,6 +124,12 @@ const ESCALATION_TOOL_DESCRIPTION = 'Attempting a command the sandbox may deny i
|
|
|
65
124
|
|
|
66
125
|
const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
|
|
67
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The tool description for one composition: the escalation prose is appended
|
|
129
|
+
* only when the composition advertises escalation targets.
|
|
130
|
+
* @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets this composition advertises.
|
|
131
|
+
* @returns {string} the model-facing description.
|
|
132
|
+
*/
|
|
68
133
|
function fishDescription(escalationModes) {
|
|
69
134
|
return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
|
|
70
135
|
}
|
|
@@ -103,7 +168,11 @@ const FISH_SYNTAX_HINTS = [
|
|
|
103
168
|
},
|
|
104
169
|
]
|
|
105
170
|
|
|
106
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Find a fish syntax hint for one stderr text, or '' when none applies.
|
|
173
|
+
* @param {string} stderrText - the failed run's stderr text.
|
|
174
|
+
* @returns {string} the mapped hint, or `''` when nothing applies.
|
|
175
|
+
*/
|
|
107
176
|
function fishSyntaxHint(stderrText) {
|
|
108
177
|
for (const { pattern, hint } of FISH_SYNTAX_HINTS) {
|
|
109
178
|
if (pattern.test(stderrText)) return hint
|
|
@@ -117,13 +186,17 @@ function fishSyntaxHint(stderrText) {
|
|
|
117
186
|
* contract the harness bash tool uses, so the model reads both shells
|
|
118
187
|
* identically. A denied run under an escalation-advertising composition
|
|
119
188
|
* additionally carries the shared same-turn escalation hint.
|
|
120
|
-
* @param {
|
|
189
|
+
* @param {FishForegroundValue} result - the canonical foreground value from the executor.
|
|
121
190
|
* @param {readonly string[]} [escalationModes] - the escalation targets this
|
|
122
191
|
* composition advertises; non-empty appends the same-turn escalation hint
|
|
123
192
|
* after a denial marker (default `[]`: no hint).
|
|
124
193
|
* @returns {string} the model-facing text.
|
|
125
194
|
*/
|
|
126
195
|
function renderResult(result, escalationModes = []) {
|
|
196
|
+
/**
|
|
197
|
+
* @param {FishOutputStream} stream - one captured stream of the canonical value.
|
|
198
|
+
* @returns {string} its text plus the truncation notice when it was cut.
|
|
199
|
+
*/
|
|
127
200
|
const streamText = (stream) => {
|
|
128
201
|
if (!stream.truncated) return stream.text
|
|
129
202
|
return `${stream.text}\n[output truncated${stream.spillPath !== undefined
|
|
@@ -166,56 +239,128 @@ function renderResult(result, escalationModes = []) {
|
|
|
166
239
|
}
|
|
167
240
|
|
|
168
241
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* @param {
|
|
176
|
-
* @
|
|
177
|
-
* @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
|
|
178
|
-
* @returns {string} the delta text with any loss or sandbox notice appended.
|
|
242
|
+
* Sandbox facts worth the job's terminal detail: a runner that never ran the
|
|
243
|
+
* command, or a denial (with the escalation hint this composition offers).
|
|
244
|
+
* The `ctx.jobs` registry owns the read path now, so these notes travel on the
|
|
245
|
+
* outcome's detail line — the one line every reader (the model's status line,
|
|
246
|
+
* the job roster row) shows — instead of being appended to each read.
|
|
247
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellSandboxInfo | undefined} sandbox - settled sandbox facts, when this was a confined process.
|
|
248
|
+
* @param {readonly string[]} escalationModes - escalation targets advertised by this composition.
|
|
249
|
+
* @returns {string[]} the notes to append, oldest first.
|
|
179
250
|
*/
|
|
180
|
-
function
|
|
181
|
-
const notices = []
|
|
182
|
-
if (read.lossy) {
|
|
183
|
-
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined)
|
|
184
|
-
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
|
185
|
-
}
|
|
251
|
+
function sandboxNotes(sandbox, escalationModes) {
|
|
186
252
|
if (sandbox?.runnerFailed) {
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
253
|
+
return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`]
|
|
254
|
+
}
|
|
255
|
+
if (sandbox?.denied) {
|
|
256
|
+
const notes = [sandboxDenialMarker(sandbox.mode)]
|
|
257
|
+
if (escalationModes.length > 0) notes.push(escalationHintMarker('command'))
|
|
258
|
+
return notes
|
|
191
259
|
}
|
|
192
|
-
|
|
193
|
-
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
|
260
|
+
return []
|
|
194
261
|
}
|
|
195
262
|
|
|
196
263
|
/**
|
|
197
264
|
* Map a settled background process onto the generic task-outcome vocabulary:
|
|
198
265
|
* `killed` stays `killed` (detail: the signal when one is known), everything
|
|
199
266
|
* else is `completed` with the exit code as detail. A nonzero command exit is
|
|
200
|
-
* reported, not failed, exactly like the foreground rendering.
|
|
201
|
-
*
|
|
267
|
+
* reported, not failed, exactly like the foreground rendering. Sandbox facts
|
|
268
|
+
* join the detail, since a job's terminal reason is the one line every
|
|
269
|
+
* reader — the model's status line, the roster row — shows.
|
|
270
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellProcess} proc - the settled process handle.
|
|
271
|
+
* @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
|
|
202
272
|
* @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
|
|
203
273
|
*/
|
|
204
|
-
function processOutcome(proc) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
274
|
+
function processOutcome(proc, escalationModes = []) {
|
|
275
|
+
/** @type {{ status: 'completed' | 'killed', detail: string }} */
|
|
276
|
+
const base = proc.status === 'killed'
|
|
277
|
+
? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
|
278
|
+
: { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
|
279
|
+
const notes = sandboxNotes(proc.sandbox, escalationModes)
|
|
280
|
+
return notes.length === 0 ? base : {
|
|
281
|
+
...base,
|
|
282
|
+
detail: `${base.detail}; ${notes.join(' ')}`,
|
|
210
283
|
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The process's non-consuming stream readers as `ctx.jobs` pull sources
|
|
288
|
+
* (ported from the official `@deepseek-ai/dsh-tool-bash`'s background
|
|
289
|
+
* adaptation: the registry pumps these instead of the removed
|
|
290
|
+
* `JobHooks.readOutput`). They bind lazily because the handle is published
|
|
291
|
+
* only after asynchronous preparation; a read before then yields nothing, and
|
|
292
|
+
* the pump keeps the model's consuming cursor untouched.
|
|
293
|
+
* @param {() => import('@deepseek-ai/dsh-shell').ShellProcess | undefined} process - resolves the published handle, once preparation has one.
|
|
294
|
+
* @returns {readonly import('@deepseek-ai/dsh-jobs').JobOutputSource[]} one source per stream, stdout first.
|
|
295
|
+
*/
|
|
296
|
+
function processSources(process) {
|
|
297
|
+
/**
|
|
298
|
+
* @param {'stdout' | 'stderr'} channel - stream label the registry attaches to this source's chunks.
|
|
299
|
+
* @returns {import('@deepseek-ai/dsh-jobs').JobOutputSource} one non-consuming pull source.
|
|
300
|
+
*/
|
|
301
|
+
const source = (channel) => ({
|
|
302
|
+
channel,
|
|
303
|
+
/** @param {number} fromByte - whole-stream offset to resume from (a prior read's `nextOffset`). */
|
|
304
|
+
read: (fromByte) => {
|
|
305
|
+
const live = process()
|
|
306
|
+
return live === undefined
|
|
307
|
+
? { text: '', nextOffset: fromByte, lossy: false }
|
|
308
|
+
: live.observed[channel].readFrom(fromByte)
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
return [source('stdout'), source('stderr')]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Synchronous job hooks over asynchronous shell preparation (ported from the
|
|
316
|
+
* official `@deepseek-ai/dsh-tool-bash`): `ctx.jobs` admits the job and calls
|
|
317
|
+
* `run()` synchronously, while `ctx.shell.execute` publishes its handle only
|
|
318
|
+
* after preparation. Cancellation aborts the preparation signal (which also
|
|
319
|
+
* stops the command during preparation) and kills an already-published
|
|
320
|
+
* process; `done` spans preparation and settlement, and never rejects.
|
|
321
|
+
* @param {(signal: AbortSignal) => Promise<import('@deepseek-ai/dsh-shell').ShellExecution>} start - starts the process under a job-owned cancellation signal.
|
|
322
|
+
* @param {(proc: import('@deepseek-ai/dsh-shell').ShellProcess) => { status: 'completed' | 'killed', detail: string }} outcome - projects the settled process into the job outcome.
|
|
323
|
+
* @returns {{ cancel: (reason?: string) => void, done: Promise<{ status: 'completed' | 'killed' | 'failed', detail: string }> }} the job's hooks.
|
|
324
|
+
*/
|
|
325
|
+
function processJob(start, outcome) {
|
|
326
|
+
const controller = new AbortController()
|
|
327
|
+
/** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
|
|
328
|
+
let proc
|
|
211
329
|
return {
|
|
212
|
-
|
|
213
|
-
|
|
330
|
+
cancel: (reason) => {
|
|
331
|
+
if (controller.signal.aborted) return
|
|
332
|
+
controller.abort(reason)
|
|
333
|
+
proc?.kill()
|
|
334
|
+
},
|
|
335
|
+
done: (async () => {
|
|
336
|
+
try {
|
|
337
|
+
proc = await start(controller.signal)
|
|
338
|
+
try {
|
|
339
|
+
if (controller.signal.aborted) proc.kill()
|
|
340
|
+
} finally {
|
|
341
|
+
await proc.done
|
|
342
|
+
}
|
|
343
|
+
return outcome(proc)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
status: controller.signal.aborted && proc === undefined ? 'killed' : 'failed',
|
|
347
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
})(),
|
|
214
351
|
}
|
|
215
352
|
}
|
|
216
353
|
|
|
217
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Detach the executor DTO from readonly Service Definition types into plain JSON data.
|
|
356
|
+
* @param {import('@deepseek-ai/dsh-shell').ShellRunResult} result - the settled foreground result.
|
|
357
|
+
* @returns {FishForegroundValue} the canonical value declared by OUTPUT_SCHEMA.
|
|
358
|
+
*/
|
|
218
359
|
function canonicalFishResult(result) {
|
|
360
|
+
/**
|
|
361
|
+
* @param {import('@deepseek-ai/dsh-shell').CollectedOutput} s - one captured stream of the settled result.
|
|
362
|
+
* @returns {FishOutputStream} its lossless-JSON projection.
|
|
363
|
+
*/
|
|
219
364
|
const stream = (s) => ({
|
|
220
365
|
text: s.text,
|
|
221
366
|
truncated: s.truncated,
|
|
@@ -244,6 +389,8 @@ function canonicalFishResult(result) {
|
|
|
244
389
|
* Total over malformed replay args: a non-object or a missing/non-string
|
|
245
390
|
* command/description returns `undefined` so the UI falls back to its
|
|
246
391
|
* generic presentation instead of throwing or minting an invalid title.
|
|
392
|
+
* @param {any} args - the call's parsed arguments.
|
|
393
|
+
* @returns {import('@deepseek-ai/dsh-tools').ToolCallView | undefined} the pending card intent.
|
|
247
394
|
*/
|
|
248
395
|
function presentFishCall(args) {
|
|
249
396
|
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
@@ -271,6 +418,9 @@ function presentFishCall(args) {
|
|
|
271
418
|
* execution errors use generic fenced output without an exit-status pill.
|
|
272
419
|
* Total over malformed replay args/results: invalid inputs return
|
|
273
420
|
* `undefined` (generic UI fallback) instead of throwing.
|
|
421
|
+
* @param {any} args - the call's parsed arguments.
|
|
422
|
+
* @param {any} result - the durable result projection (validated here, so malformed replays cannot throw).
|
|
423
|
+
* @returns {import('@deepseek-ai/dsh-tools').ToolResultView | undefined} the completed card intent.
|
|
274
424
|
*/
|
|
275
425
|
function presentFishResult(args, result) {
|
|
276
426
|
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
@@ -325,6 +475,7 @@ const PARAMETERS = {
|
|
|
325
475
|
* The tool's parameters for one composition: the base parameters plus
|
|
326
476
|
* `run_in_background`, and the escalation fields only when the mounted
|
|
327
477
|
* executor advertises confinement (an unadvertised field is never parsed).
|
|
478
|
+
* @param {readonly import('@deepseek-ai/dsh-sandbox').SandboxMode[]} escalationModes - targets the composition advertises; empty hides the escalation fields.
|
|
328
479
|
*/
|
|
329
480
|
function fishParameters(escalationModes) {
|
|
330
481
|
return {
|
|
@@ -356,7 +507,8 @@ function fishParameters(escalationModes) {
|
|
|
356
507
|
/** Canonical output declaration: a background acknowledgment or a foreground
|
|
357
508
|
* run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
|
|
358
509
|
* beside a `oneOf` root (exact-one branches), so the union is expressed as a
|
|
359
|
-
* bare `oneOf` with each branch carrying its own object shape.
|
|
510
|
+
* bare `oneOf` with each branch carrying its own object shape.
|
|
511
|
+
* @type {import('@deepseek-ai/dsh-tools').JsonSchemaNode} */
|
|
360
512
|
const OUTPUT_SCHEMA = {
|
|
361
513
|
oneOf: [
|
|
362
514
|
{
|
|
@@ -415,8 +567,14 @@ const OUTPUT_SCHEMA = {
|
|
|
415
567
|
],
|
|
416
568
|
}
|
|
417
569
|
|
|
418
|
-
/**
|
|
419
|
-
*
|
|
570
|
+
/**
|
|
571
|
+
* Resolve an explicit workdir: the sandbox policy's workspace root wins as
|
|
572
|
+
* the base, then the session cwd; a relative path resolves against it.
|
|
573
|
+
* @param {string | undefined} modelWorkdir - the model's `workdir` argument.
|
|
574
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
|
|
575
|
+
* @param {string | undefined} policyWorkspaceRoot - the standing policy's workspace root.
|
|
576
|
+
* @returns {string | undefined} the workdir for this call, or `undefined` to let the executor default it.
|
|
577
|
+
*/
|
|
420
578
|
function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
|
|
421
579
|
const sessionCwd = policyWorkspaceRoot ?? exec?.agent?.session?.header?.cwd
|
|
422
580
|
if (modelWorkdir === undefined) return sessionCwd
|
|
@@ -444,6 +602,10 @@ export function apply(ctx) {
|
|
|
444
602
|
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
|
445
603
|
throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
|
|
446
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution.
|
|
607
|
+
* @returns {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy | undefined} the standing policy for this call's session, or `undefined` without a policy owner.
|
|
608
|
+
*/
|
|
447
609
|
const resolveSandboxPolicy = (exec) => {
|
|
448
610
|
if (sandboxPolicy === undefined) return undefined
|
|
449
611
|
return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
@@ -454,6 +616,11 @@ export function apply(ctx) {
|
|
|
454
616
|
* anything executes, delegating the shared fail-closed sequence (strict
|
|
455
617
|
* widening, channel resolution, outcome mapping) to approveEscalation —
|
|
456
618
|
* the same composition guard and ingredients as the official bash tool.
|
|
619
|
+
* @param {string} mode - the requested `sandbox_permissions` value (the ladder check lives in `approveEscalation`).
|
|
620
|
+
* @param {string} justification - the model's one-sentence reason, shown verbatim to the user.
|
|
621
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution awaiting the decision.
|
|
622
|
+
* @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} standingPolicy - the call's standing policy (present whenever escalation is advertised).
|
|
623
|
+
* @returns {Promise<import('@deepseek-ai/dsh-sandbox').SandboxMode>} the granted mode, consumed by this one call.
|
|
457
624
|
*/
|
|
458
625
|
const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
|
|
459
626
|
if (escalationModes.length === 0) {
|
|
@@ -480,6 +647,13 @@ export function apply(ctx) {
|
|
|
480
647
|
text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
|
|
481
648
|
})
|
|
482
649
|
|
|
650
|
+
/**
|
|
651
|
+
* One `fish` tool call: validate the model's arguments, resolve the standing
|
|
652
|
+
* policy and any same-turn escalation, then run through `ctx.shell`.
|
|
653
|
+
* @param {FishToolArgs} args - the parsed call arguments (validated below).
|
|
654
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec - the tool execution identity, agent, and cancellation.
|
|
655
|
+
* @returns {Promise<{ kind: 'foreground' } & FishForegroundValue | { kind: 'background', jobId: string }>} the canonical value declared by OUTPUT_SCHEMA.
|
|
656
|
+
*/
|
|
483
657
|
async function execute(args, exec) {
|
|
484
658
|
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
485
659
|
throw new Error('invalid command: expected a non-empty string')
|
|
@@ -493,12 +667,17 @@ export function apply(ctx) {
|
|
|
493
667
|
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
|
494
668
|
const standingPolicy = resolveSandboxPolicy(exec)
|
|
495
669
|
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
496
|
-
|
|
670
|
+
// `escalationModes` is non-empty exactly when a policy owner is mounted,
|
|
671
|
+
// so an escalation request always has a standing policy to widen.
|
|
672
|
+
? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, /** @type {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} */ (standingPolicy))
|
|
497
673
|
: undefined
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
674
|
+
// A one-shot escalation widens the standing policy for this call only.
|
|
675
|
+
// `apply` fails loud when a confining executor has no policy owner, so an
|
|
676
|
+
// approved mode implies a definite standing policy — the second guard makes
|
|
677
|
+
// that explicit for the spread instead of spreading a possible `undefined`.
|
|
678
|
+
const policy = approvedMode !== undefined && standingPolicy !== undefined
|
|
679
|
+
? { ...standingPolicy, mode: approvedMode }
|
|
680
|
+
: standingPolicy
|
|
502
681
|
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
|
503
682
|
const request = {
|
|
504
683
|
command: args.command,
|
|
@@ -517,27 +696,39 @@ export function apply(ctx) {
|
|
|
517
696
|
error.name = 'AbortError'
|
|
518
697
|
throw error
|
|
519
698
|
}
|
|
699
|
+
/** @type {import('@deepseek-ai/dsh-shell').ShellExecution | undefined} */
|
|
700
|
+
let proc
|
|
520
701
|
return {
|
|
521
702
|
kind: 'background',
|
|
522
703
|
jobId: jobs.start({
|
|
523
704
|
kind: 'fish',
|
|
524
705
|
label: args.command,
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
706
|
+
// The registry fences access by SESSION id: `owner` is the owning
|
|
707
|
+
// session, not the live agent object (`JobSpec.owner?: SessionId`).
|
|
708
|
+
...exec.agent !== undefined ? { owner: exec.agent.id } : {},
|
|
709
|
+
// The registry pumps output itself (0.1.7-rc.2 removed the
|
|
710
|
+
// `JobHooks.readOutput` consumer), so the process's streams are
|
|
711
|
+
// passed as pull sources bound to the handle published below.
|
|
712
|
+
output: processSources(() => proc),
|
|
713
|
+
run: () => processJob(async (signal) => {
|
|
714
|
+
// Background work carries NO deadline: 0.1.5's `start()` armed no
|
|
715
|
+
// timer at all (`startArgv` spawned with the caller's signal while
|
|
716
|
+
// only `runArgv` built a `deadline(...)`), and the official
|
|
717
|
+
// 0.1.7 `dsh-tool-bash` resolves its job lane the same way.
|
|
718
|
+
// Without `onExpiry: 'none'` the resolved spec would default to
|
|
719
|
+
// `'kill'` and silently reintroduce a timeout the documented
|
|
720
|
+
// contract ("No timeout applies") never had.
|
|
721
|
+
const spec = ctx.shell.resolve({ ...request, onExpiry: 'none' })
|
|
722
|
+
proc = await ctx.shell.execute({ ...spec, signal })
|
|
723
|
+
return proc
|
|
724
|
+
}, (started) => processOutcome(started, escalationModes)),
|
|
534
725
|
}),
|
|
535
726
|
}
|
|
536
727
|
}
|
|
537
|
-
const result = await ctx.shell.
|
|
728
|
+
const result = await (await ctx.shell.execute(ctx.shell.resolve({
|
|
538
729
|
...request,
|
|
539
730
|
signal: exec?.signal,
|
|
540
|
-
}))
|
|
731
|
+
}))).result()
|
|
541
732
|
if (result.aborted) {
|
|
542
733
|
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
543
734
|
error.name = 'AbortError'
|
|
@@ -555,10 +746,17 @@ export function apply(ctx) {
|
|
|
555
746
|
parameters: fishParameters(escalationModes),
|
|
556
747
|
output: {
|
|
557
748
|
schema: OUTPUT_SCHEMA,
|
|
558
|
-
render: (_args, value) =>
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
749
|
+
render: (_args, value) => {
|
|
750
|
+
// The canonical value crosses the lossless-JSON boundary, so it
|
|
751
|
+
// arrives as `JsonValue`; OUTPUT_SCHEMA is what guarantees the shape.
|
|
752
|
+
const record = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}
|
|
753
|
+
return [{
|
|
754
|
+
type: 'text',
|
|
755
|
+
text: record.kind === 'background'
|
|
756
|
+
? `started background job ${String(record.jobId)}`
|
|
757
|
+
: renderResult(/** @type {FishForegroundValue} */ (value), escalationModes),
|
|
758
|
+
}]
|
|
759
|
+
},
|
|
562
760
|
},
|
|
563
761
|
execute,
|
|
564
762
|
presentCall: presentFishCall,
|