@banbolee/dsh-fish-shell 0.4.0 → 0.5.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 +151 -41
- package/cordis.patch.yml +39 -32
- package/package.json +40 -4
- package/persistent.js +639 -0
- package/policy.js +346 -0
- package/terminal-fish.js +317 -0
- package/tool.js +347 -111
- package/presets/fish/agent.cordis.yml +0 -241
package/tool.js
CHANGED
|
@@ -9,64 +9,51 @@
|
|
|
9
9
|
* the standalone-spawn version this module used to carry was removed because
|
|
10
10
|
* it bypassed all of that.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
* fish.
|
|
12
|
+
* The functional surface mirrors the official `@deepseek-ai/dsh-tool-bash`
|
|
13
|
+
* (the harness's model-facing `bash` tool) with shell-specific wording
|
|
14
|
+
* swapped to fish: background execution through `ctx.jobs` (with lossy-read
|
|
15
|
+
* and sandbox notices), same-turn sandbox escalation through
|
|
16
|
+
* `ctx.approval` (strictly-wider modes only, fail-closed), canonical
|
|
17
|
+
* foreground results carrying the complete sandbox facts
|
|
18
|
+
* (`enforcement`/`runnerFailed`), terminal/generic UI presentation, and the
|
|
19
|
+
* `tool:fish` exit-code prompt section. On top of that it keeps this
|
|
20
|
+
* bundle's fish-specific teaching: the description translates bash idioms,
|
|
21
|
+
* and failed runs carry a `[fish syntax]` hint naming the exact fix.
|
|
15
22
|
*
|
|
16
23
|
* @module @banbolee/dsh-fish-shell/tool
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { isAbsolute, resolve } from 'node:path'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
27
|
+
import { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
|
28
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
29
|
+
import {
|
|
30
|
+
ESCALATION_TARGETS,
|
|
31
|
+
approveEscalation,
|
|
32
|
+
escalationHintMarker,
|
|
33
|
+
sandboxDenialMarker,
|
|
34
|
+
validateEscalationArgs,
|
|
35
|
+
} from '@deepseek-ai/dsh-sandbox'
|
|
36
|
+
import { DSH_ENV_PREFIX, parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
22
37
|
|
|
23
38
|
export const name = 'fish-tool'
|
|
24
|
-
export const inject = ['tools', 'shell', 'shellEnv']
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Bundled fish agent preset: a copy of the shipped `standard` preset with
|
|
28
|
-
* the shell section removed (the `fish` tool is mounted host-globally by
|
|
29
|
-
* this plugin, so the preset needs no shell row).
|
|
30
|
-
*/
|
|
31
|
-
const BUNDLED_PRESET_URL = new URL('./presets/fish/agent.cordis.yml', import.meta.url)
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Install the bundled fish agent preset under the agent-presets user root,
|
|
35
|
-
* create-only: an existing target (even a user-edited one) is left alone,
|
|
36
|
-
* and a failed write is a warning, never a load failure.
|
|
37
|
-
*/
|
|
38
|
-
function installPreset() {
|
|
39
|
-
const home = resolveDshHome()
|
|
40
|
-
const targetDir = `${home}/.agent-presets/fish`
|
|
41
|
-
const target = `${targetDir}/agent.cordis.yml`
|
|
42
|
-
let exists = true
|
|
43
|
-
try {
|
|
44
|
-
readFileSync(target)
|
|
45
|
-
} catch {
|
|
46
|
-
exists = false
|
|
47
|
-
}
|
|
48
|
-
if (exists) return
|
|
49
|
-
try {
|
|
50
|
-
mkdirSync(targetDir, { recursive: true })
|
|
51
|
-
writeFileSync(target, readFileSync(BUNDLED_PRESET_URL, 'utf8'))
|
|
52
|
-
} catch (error) {
|
|
53
|
-
console.warn('@banbolee/dsh-fish-shell: could not install the fish agent preset:', String(error))
|
|
54
|
-
}
|
|
55
|
-
}
|
|
39
|
+
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
|
|
56
40
|
|
|
57
41
|
/**
|
|
58
42
|
* The model-facing tool description. Written from the model's perspective:
|
|
59
43
|
* the most important fact is that this is FISH, not bash, so the model must
|
|
60
|
-
* translate bash idioms before running them.
|
|
44
|
+
* translate bash idioms before running them. Structured like the official
|
|
45
|
+
* bash tool description (markers, `DSH_*` environment facts, background
|
|
46
|
+
* execution, sandbox escalation) on top of the fish syntax teaching.
|
|
61
47
|
*/
|
|
62
|
-
const
|
|
48
|
+
const BASE_TOOL_DESCRIPTION = `Execute a fish shell command (\`fish -c\`) and return its stdout/stderr. Each call runs in a fresh non-login fish: no state (cwd, variables, functions, history) persists between calls — pass \`workdir\` instead of using \`cd\`. This tool uses the FISH shell, NOT bash; translate bash commands to fish syntax first:
|
|
63
49
|
- Variables: \`set -gx NAME value\` (not \`export NAME=value\`); read them as \`$NAME\`. Use \`env\` to see exported variables.
|
|
64
50
|
- Conditionals/loops: \`if ...; ...; end\`, \`for x in ...; ...; end\`, \`while ...; ...; end\`, \`switch\` instead of \`case\`.
|
|
65
51
|
- Chaining: \`a; and b\` / \`a; or b\` (fish 3 also accepts \`&&\` / \`||\`); background a job with \`cmd &\` and wait with \`wait\`.
|
|
66
52
|
- Command substitution: \`(cmd)\` (fish 3 also accepts \`$(cmd)\`); arithmetic: \`math '1 + 2'\`; string processing: \`string ...\`.
|
|
67
53
|
- Expansion: \`~\`, \`$VAR\`, and globs like \`*.md\` expand; an unmatched glob is an error, not a literal.
|
|
68
|
-
- Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]
|
|
69
|
-
- Non-zero exits are reported as \`[exit code: N]\`. Long output is truncated to its tail.
|
|
54
|
+
- Commands may run under a file sandbox; a blocked file operation is reported as \`[sandbox: file access denied under <mode> mode]\` — a policy denial, not a bug in the command; do not retry another way.
|
|
55
|
+
- Non-zero exits are reported as \`[exit code: N]\`. Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.
|
|
56
|
+
- Set \`run_in_background: true\` for long-running commands: the call returns a job id immediately; read its output with \`job_output\` and stop it with \`job_kill\`. No timeout applies.
|
|
70
57
|
Bash idioms fish REJECTS with exit 127 — translate before running:
|
|
71
58
|
\`VAR=x\` / \`export VAR=x\` → \`set VAR x\` / \`set -gx VAR x\`
|
|
72
59
|
\`for i in ...; do ...; done\` → \`for i in ...; ...; end\`
|
|
@@ -74,6 +61,14 @@ Bash idioms fish REJECTS with exit 127 — translate before running:
|
|
|
74
61
|
\`cmd <<'EOF' ... EOF\` (heredoc) → \`printf '...' | cmd\` (fish has no heredoc)
|
|
75
62
|
If fish rejects your command, retry with the fish form from the table — failed results carry a [fish syntax] hint naming the exact fix.`
|
|
76
63
|
|
|
64
|
+
const ESCALATION_TOOL_DESCRIPTION = 'Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.'
|
|
65
|
+
|
|
66
|
+
const TOOL_DESCRIPTION = `${BASE_TOOL_DESCRIPTION}\n${ESCALATION_TOOL_DESCRIPTION}`
|
|
67
|
+
|
|
68
|
+
function fishDescription(escalationModes) {
|
|
69
|
+
return escalationModes.length === 0 ? BASE_TOOL_DESCRIPTION : TOOL_DESCRIPTION
|
|
70
|
+
}
|
|
71
|
+
|
|
77
72
|
/**
|
|
78
73
|
* Targeted fish-syntax correction for a failed run. Models default to bash
|
|
79
74
|
* idioms; when fish rejects a command the stderr carries a diagnostic like
|
|
@@ -120,11 +115,15 @@ function fishSyntaxHint(stderrText) {
|
|
|
120
115
|
* Shape one finished run into the text the model sees: stdout, then a marked
|
|
121
116
|
* stderr section, then sandbox/timeout/signal/exit markers — the same marker
|
|
122
117
|
* contract the harness bash tool uses, so the model reads both shells
|
|
123
|
-
* identically.
|
|
118
|
+
* identically. A denied run under an escalation-advertising composition
|
|
119
|
+
* additionally carries the shared same-turn escalation hint.
|
|
124
120
|
* @param {object} result - the settled foreground result from the executor.
|
|
121
|
+
* @param {readonly string[]} [escalationModes] - the escalation targets this
|
|
122
|
+
* composition advertises; non-empty appends the same-turn escalation hint
|
|
123
|
+
* after a denial marker (default `[]`: no hint).
|
|
125
124
|
* @returns {string} the model-facing text.
|
|
126
125
|
*/
|
|
127
|
-
function renderResult(result) {
|
|
126
|
+
function renderResult(result, escalationModes = []) {
|
|
128
127
|
const streamText = (stream) => {
|
|
129
128
|
if (!stream.truncated) return stream.text
|
|
130
129
|
return `${stream.text}\n[output truncated${stream.spillPath !== undefined
|
|
@@ -152,7 +151,8 @@ function renderResult(result) {
|
|
|
152
151
|
|
|
153
152
|
const markers = []
|
|
154
153
|
if (result.sandbox?.denied) {
|
|
155
|
-
markers.push(
|
|
154
|
+
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
|
155
|
+
if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
|
|
156
156
|
}
|
|
157
157
|
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs ?? 0}ms]`)
|
|
158
158
|
if (result.signal !== null) {
|
|
@@ -165,6 +165,134 @@ function renderResult(result) {
|
|
|
165
165
|
return body + markers.join('\n')
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Shape one background-process read into the `job_output` delta the model
|
|
170
|
+
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
|
171
|
+
* spill paths) when in-memory truncation dropped unread bytes, and the
|
|
172
|
+
* settled sandbox facts (runner failure / denial + escalation hint) when the
|
|
173
|
+
* process was confined. Empty-delta rendering (`(no new output)`) is the
|
|
174
|
+
* generic job controller's job.
|
|
175
|
+
* @param {object} read - one incremental read from the process handle.
|
|
176
|
+
* @param {object} [sandbox] - settled sandbox facts, when this was a confined process.
|
|
177
|
+
* @param {readonly string[]} [escalationModes] - escalation targets advertised by this composition.
|
|
178
|
+
* @returns {string} the delta text with any loss or sandbox notice appended.
|
|
179
|
+
*/
|
|
180
|
+
function renderFishProcessRead(read, sandbox, escalationModes = []) {
|
|
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
|
+
}
|
|
186
|
+
if (sandbox?.runnerFailed) {
|
|
187
|
+
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
|
188
|
+
} else if (sandbox?.denied) {
|
|
189
|
+
notices.push(sandboxDenialMarker(sandbox.mode))
|
|
190
|
+
if (escalationModes.length > 0) notices.push(escalationHintMarker('command'))
|
|
191
|
+
}
|
|
192
|
+
if (notices.length === 0) return read.delta
|
|
193
|
+
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Map a settled background process onto the generic task-outcome vocabulary:
|
|
198
|
+
* `killed` stays `killed` (detail: the signal when one is known), everything
|
|
199
|
+
* else is `completed` with the exit code as detail. A nonzero command exit is
|
|
200
|
+
* reported, not failed, exactly like the foreground rendering.
|
|
201
|
+
* @param {object} proc - the settled process handle.
|
|
202
|
+
* @returns {{ status: 'completed' | 'killed', detail: string }} the outcome for the `ctx.jobs` registration.
|
|
203
|
+
*/
|
|
204
|
+
function processOutcome(proc) {
|
|
205
|
+
if (proc.status === 'killed') {
|
|
206
|
+
return {
|
|
207
|
+
status: 'killed',
|
|
208
|
+
detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
status: 'completed',
|
|
213
|
+
detail: `exit code: ${proc.exitCode ?? 0}`,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
|
218
|
+
function canonicalFishResult(result) {
|
|
219
|
+
const stream = (s) => ({
|
|
220
|
+
text: s.text,
|
|
221
|
+
truncated: s.truncated,
|
|
222
|
+
...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
|
|
223
|
+
})
|
|
224
|
+
return {
|
|
225
|
+
exitCode: result.exitCode,
|
|
226
|
+
signal: result.signal,
|
|
227
|
+
timedOut: result.timedOut,
|
|
228
|
+
aborted: result.aborted,
|
|
229
|
+
timeoutMs: result.timeoutMs,
|
|
230
|
+
stdout: stream(result.stdout),
|
|
231
|
+
stderr: stream(result.stderr),
|
|
232
|
+
...result.sandbox !== undefined ? { sandbox: {
|
|
233
|
+
mode: result.sandbox.mode,
|
|
234
|
+
denied: result.sandbox.denied,
|
|
235
|
+
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
|
236
|
+
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
|
237
|
+
} } : {},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Present the PENDING state of one call in a UI: a foreground command is a
|
|
243
|
+
* terminal card (cwd-headed); a background call is a generic execute card.
|
|
244
|
+
* Total over malformed replay args: a non-object or a missing/non-string
|
|
245
|
+
* command/description returns `undefined` so the UI falls back to its
|
|
246
|
+
* generic presentation instead of throwing or minting an invalid title.
|
|
247
|
+
*/
|
|
248
|
+
function presentFishCall(args) {
|
|
249
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
250
|
+
if (typeof args.command !== 'string' || typeof args.description !== 'string') return undefined
|
|
251
|
+
if (args.run_in_background === true) {
|
|
252
|
+
return {
|
|
253
|
+
card: 'generic',
|
|
254
|
+
title: args.command,
|
|
255
|
+
kind: 'execute',
|
|
256
|
+
rawInput: args.command,
|
|
257
|
+
content: [{ type: 'text', text: args.description }],
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
card: 'terminal',
|
|
262
|
+
title: args.command,
|
|
263
|
+
description: args.description,
|
|
264
|
+
...typeof args.workdir === 'string' ? { cwd: args.workdir } : {},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Present completed foreground output as a terminal card with an exit-status
|
|
270
|
+
* pill (recovered via `parseExitStatus`); background acknowledgements and
|
|
271
|
+
* execution errors use generic fenced output without an exit-status pill.
|
|
272
|
+
* Total over malformed replay args/results: invalid inputs return
|
|
273
|
+
* `undefined` (generic UI fallback) instead of throwing.
|
|
274
|
+
*/
|
|
275
|
+
function presentFishResult(args, result) {
|
|
276
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
|
|
277
|
+
if (typeof args.command !== 'string') return undefined
|
|
278
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result) || !Array.isArray(result.content)) return undefined
|
|
279
|
+
const block = result.content.length === 1 ? result.content[0] : undefined
|
|
280
|
+
if (block === undefined || block.type !== 'text') return undefined
|
|
281
|
+
const raw = block.text
|
|
282
|
+
if (args.run_in_background === true || result.isError) {
|
|
283
|
+
return {
|
|
284
|
+
card: 'generic',
|
|
285
|
+
content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }],
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const { body, ...exit } = parseExitStatus(raw)
|
|
289
|
+
return {
|
|
290
|
+
card: 'terminal',
|
|
291
|
+
output: body,
|
|
292
|
+
...exit,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
168
296
|
/** Object-root JSON Schema for the tool's parameters (raw-registered). */
|
|
169
297
|
const PARAMETERS = {
|
|
170
298
|
type: 'object',
|
|
@@ -193,50 +321,98 @@ const PARAMETERS = {
|
|
|
193
321
|
},
|
|
194
322
|
}
|
|
195
323
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
324
|
+
/**
|
|
325
|
+
* The tool's parameters for one composition: the base parameters plus
|
|
326
|
+
* `run_in_background`, and the escalation fields only when the mounted
|
|
327
|
+
* executor advertises confinement (an unadvertised field is never parsed).
|
|
328
|
+
*/
|
|
329
|
+
function fishParameters(escalationModes) {
|
|
330
|
+
return {
|
|
331
|
+
...PARAMETERS,
|
|
201
332
|
properties: {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
timeoutMs: { type: 'number' },
|
|
208
|
-
stdout: {
|
|
209
|
-
type: 'object',
|
|
210
|
-
additionalProperties: false,
|
|
211
|
-
required: ['text', 'truncated'],
|
|
212
|
-
properties: {
|
|
213
|
-
text: { type: 'string' },
|
|
214
|
-
truncated: { type: 'boolean' },
|
|
215
|
-
spillPath: { type: 'string' },
|
|
216
|
-
},
|
|
333
|
+
...PARAMETERS.properties,
|
|
334
|
+
run_in_background: {
|
|
335
|
+
type: 'boolean',
|
|
336
|
+
description: 'Run in the background and return a job id immediately '
|
|
337
|
+
+ '(collect with job_output, stop with job_kill). No timeout applies.',
|
|
217
338
|
},
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
339
|
+
...escalationModes.length > 0 ? {
|
|
340
|
+
sandbox_permissions: {
|
|
341
|
+
type: 'string',
|
|
342
|
+
enum: [...escalationModes],
|
|
343
|
+
description: 'The wider sandbox mode this command needs. Only valid as a one-shot '
|
|
344
|
+
+ 'retry of a command the sandbox just denied; requires justification and user approval.',
|
|
345
|
+
},
|
|
346
|
+
justification: {
|
|
347
|
+
type: 'string',
|
|
348
|
+
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
|
349
|
+
+ 'why this exact command needs the wider access.',
|
|
226
350
|
},
|
|
351
|
+
} : {},
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Canonical output declaration: a background acknowledgment or a foreground
|
|
357
|
+
* run. The rc.1 ToolRuntime dialect forbids `type`/`additionalProperties`
|
|
358
|
+
* 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. */
|
|
360
|
+
const OUTPUT_SCHEMA = {
|
|
361
|
+
oneOf: [
|
|
362
|
+
{
|
|
363
|
+
type: 'object',
|
|
364
|
+
additionalProperties: false,
|
|
365
|
+
properties: {
|
|
366
|
+
kind: { type: 'string', const: 'background' },
|
|
367
|
+
jobId: { type: 'string' },
|
|
227
368
|
},
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
369
|
+
required: ['kind', 'jobId'],
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
type: 'object',
|
|
373
|
+
additionalProperties: false,
|
|
374
|
+
properties: {
|
|
375
|
+
kind: { type: 'string', const: 'foreground' },
|
|
376
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
377
|
+
signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
378
|
+
timedOut: { type: 'boolean' },
|
|
379
|
+
aborted: { type: 'boolean' },
|
|
380
|
+
timeoutMs: { type: 'number' },
|
|
381
|
+
stdout: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
additionalProperties: false,
|
|
384
|
+
properties: {
|
|
385
|
+
text: { type: 'string' },
|
|
386
|
+
truncated: { type: 'boolean' },
|
|
387
|
+
spillPath: { type: 'string' },
|
|
388
|
+
},
|
|
389
|
+
required: ['text', 'truncated'],
|
|
390
|
+
},
|
|
391
|
+
stderr: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
additionalProperties: false,
|
|
394
|
+
properties: {
|
|
395
|
+
text: { type: 'string' },
|
|
396
|
+
truncated: { type: 'boolean' },
|
|
397
|
+
spillPath: { type: 'string' },
|
|
398
|
+
},
|
|
399
|
+
required: ['text', 'truncated'],
|
|
400
|
+
},
|
|
401
|
+
sandbox: {
|
|
402
|
+
type: 'object',
|
|
403
|
+
additionalProperties: false,
|
|
404
|
+
properties: {
|
|
405
|
+
mode: { type: 'string' },
|
|
406
|
+
denied: { type: 'boolean' },
|
|
407
|
+
enforcement: { type: 'string' },
|
|
408
|
+
runnerFailed: { type: 'boolean' },
|
|
409
|
+
},
|
|
410
|
+
required: ['mode', 'denied'],
|
|
235
411
|
},
|
|
236
412
|
},
|
|
413
|
+
required: ['kind', 'exitCode', 'signal', 'timedOut', 'aborted', 'timeoutMs', 'stdout', 'stderr'],
|
|
237
414
|
},
|
|
238
|
-
|
|
239
|
-
render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
|
|
415
|
+
],
|
|
240
416
|
}
|
|
241
417
|
|
|
242
418
|
/** Resolve an explicit workdir: the sandbox policy's workspace root wins as
|
|
@@ -251,20 +427,19 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
|
|
|
251
427
|
}
|
|
252
428
|
|
|
253
429
|
/**
|
|
254
|
-
* Cordis plugin entry:
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
430
|
+
* Cordis plugin entry: register the `fish` tool once the tool registry, the
|
|
431
|
+
* `fish` executor, and the system-prompt registry are up. The per-agent fish
|
|
432
|
+
* policy lives in the sibling `policy.js` module (mounted by the bundle
|
|
433
|
+
* patch).
|
|
258
434
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
|
|
259
435
|
*/
|
|
260
436
|
export function apply(ctx) {
|
|
261
|
-
installPreset()
|
|
262
|
-
|
|
263
437
|
// Mirror dsh-tool-bash's policy path: when the mounted executor confines,
|
|
264
438
|
// resolve each call's standing policy against the calling session so
|
|
265
439
|
// /permission switches and the session workspace are honored, and fail loud
|
|
266
440
|
// at load when the policy owner is missing.
|
|
267
441
|
const defaultMode = ctx.shell.sandboxMode
|
|
442
|
+
const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
|
268
443
|
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
|
269
444
|
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
|
270
445
|
throw new Error('tool-fish: the mounted fish executor confines but ctx.sandboxPolicy is missing')
|
|
@@ -274,6 +449,37 @@ export function apply(ctx) {
|
|
|
274
449
|
return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
275
450
|
}
|
|
276
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
|
454
|
+
* anything executes, delegating the shared fail-closed sequence (strict
|
|
455
|
+
* widening, channel resolution, outcome mapping) to approveEscalation —
|
|
456
|
+
* the same composition guard and ingredients as the official bash tool.
|
|
457
|
+
*/
|
|
458
|
+
const approveFishEscalation = (mode, justification, exec, standingPolicy) => {
|
|
459
|
+
if (escalationModes.length === 0) {
|
|
460
|
+
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
|
461
|
+
}
|
|
462
|
+
const effectiveMode = standingPolicy.mode
|
|
463
|
+
return approveEscalation({
|
|
464
|
+
requestedMode: mode,
|
|
465
|
+
justification,
|
|
466
|
+
effectiveMode,
|
|
467
|
+
subject: 'command',
|
|
468
|
+
}, {
|
|
469
|
+
approver: ctx.get('approval'),
|
|
470
|
+
agent: exec.agent,
|
|
471
|
+
callId: exec.callId,
|
|
472
|
+
toolName: 'fish',
|
|
473
|
+
signal: exec.signal,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
ctx.systemPrompt.section({
|
|
478
|
+
name: 'tool:fish',
|
|
479
|
+
order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
|
|
480
|
+
text: 'Check the [exit code: N] marker on every fish result; investigate failures before moving on.',
|
|
481
|
+
})
|
|
482
|
+
|
|
277
483
|
async function execute(args, exec) {
|
|
278
484
|
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
279
485
|
throw new Error('invalid command: expected a non-empty string')
|
|
@@ -284,54 +490,84 @@ export function apply(ctx) {
|
|
|
284
490
|
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
285
491
|
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
|
286
492
|
}
|
|
493
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
|
287
494
|
const standingPolicy = resolveSandboxPolicy(exec)
|
|
495
|
+
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
496
|
+
? await approveFishEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
|
497
|
+
: undefined
|
|
498
|
+
const policy = approvedMode === undefined ? standingPolicy : {
|
|
499
|
+
...standingPolicy,
|
|
500
|
+
mode: approvedMode,
|
|
501
|
+
}
|
|
288
502
|
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
|
|
289
503
|
const request = {
|
|
290
504
|
command: args.command,
|
|
291
505
|
...workdir !== undefined ? { workdir } : {},
|
|
292
506
|
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
|
293
507
|
dshEnv: ctx.shellEnv.collect(exec),
|
|
294
|
-
...
|
|
508
|
+
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
|
509
|
+
}
|
|
510
|
+
if (args.run_in_background === true) {
|
|
511
|
+
const jobs = ctx.get('jobs')
|
|
512
|
+
if (jobs === undefined) {
|
|
513
|
+
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
514
|
+
}
|
|
515
|
+
if (exec.signal.aborted) {
|
|
516
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
517
|
+
error.name = 'AbortError'
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
kind: 'background',
|
|
522
|
+
jobId: jobs.start({
|
|
523
|
+
kind: 'fish',
|
|
524
|
+
label: args.command,
|
|
525
|
+
...exec.agent !== undefined ? { owner: exec.agent } : {},
|
|
526
|
+
run: () => {
|
|
527
|
+
const proc = ctx.shell.start(ctx.shell.resolve(request))
|
|
528
|
+
return {
|
|
529
|
+
cancel: () => void proc.kill(),
|
|
530
|
+
done: proc.done.then(() => processOutcome(proc)),
|
|
531
|
+
readOutput: () => renderFishProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
}),
|
|
535
|
+
}
|
|
295
536
|
}
|
|
296
537
|
const result = await ctx.shell.run(ctx.shell.resolve({
|
|
297
538
|
...request,
|
|
298
539
|
signal: exec?.signal,
|
|
299
540
|
}))
|
|
300
541
|
if (result.aborted) {
|
|
301
|
-
const error = new
|
|
542
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
302
543
|
error.name = 'AbortError'
|
|
303
544
|
throw error
|
|
304
545
|
}
|
|
305
|
-
const stream = (s) => ({
|
|
306
|
-
text: s.text,
|
|
307
|
-
truncated: s.truncated,
|
|
308
|
-
...s.spillPath !== undefined ? { spillPath: s.spillPath } : {},
|
|
309
|
-
})
|
|
310
546
|
return {
|
|
311
547
|
kind: 'foreground',
|
|
312
|
-
|
|
313
|
-
signal: result.signal,
|
|
314
|
-
timedOut: result.timedOut,
|
|
315
|
-
aborted: result.aborted,
|
|
316
|
-
timeoutMs: result.timeoutMs,
|
|
317
|
-
stdout: stream(result.stdout),
|
|
318
|
-
stderr: stream(result.stderr),
|
|
319
|
-
...result.sandbox !== undefined
|
|
320
|
-
? { sandbox: { mode: result.sandbox.mode, denied: result.sandbox.denied } }
|
|
321
|
-
: {},
|
|
548
|
+
...canonicalFishResult(result),
|
|
322
549
|
}
|
|
323
550
|
}
|
|
324
551
|
|
|
325
552
|
ctx.tools.register({
|
|
326
553
|
name: 'fish',
|
|
327
|
-
description:
|
|
328
|
-
parameters:
|
|
329
|
-
output:
|
|
554
|
+
description: fishDescription(escalationModes),
|
|
555
|
+
parameters: fishParameters(escalationModes),
|
|
556
|
+
output: {
|
|
557
|
+
schema: OUTPUT_SCHEMA,
|
|
558
|
+
render: (_args, value) => [{
|
|
559
|
+
type: 'text',
|
|
560
|
+
text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
|
|
561
|
+
}],
|
|
562
|
+
},
|
|
330
563
|
execute,
|
|
564
|
+
presentCall: presentFishCall,
|
|
565
|
+
presentResult: presentFishResult,
|
|
331
566
|
})
|
|
332
567
|
}
|
|
333
568
|
|
|
334
|
-
// Deterministic test surface: the tool description
|
|
335
|
-
// pure, so unit tests can assert the bash→fish
|
|
336
|
-
//
|
|
337
|
-
|
|
569
|
+
// Deterministic test surface: the tool description, the result renderers, and
|
|
570
|
+
// the UI presenters are pure, so unit tests can assert the bash→fish
|
|
571
|
+
// guidance, the syntax-hint injection, and the escalation-aware rendering
|
|
572
|
+
// without booting a harness or running a live shell.
|
|
573
|
+
export { FISH_SYNTAX_HINTS, TOOL_DESCRIPTION, fishDescription, fishSyntaxHint, presentFishCall, presentFishResult, renderResult }
|