dsh-gitbash-shell 0.38.5 → 0.39.1
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/CHANGELOG.md +91 -0
- package/README.md +47 -33
- package/README_EN.md +44 -27
- package/cordis.patch.yml +23 -30
- package/dsh.plugin.json +2 -2
- package/package.json +12 -3
- package/src/bash-path.js +7 -4
- package/src/bash-render.js +239 -0
- package/src/bash-tool.js +589 -0
- package/src/client.js +48 -45
- package/src/compositions.js +3 -3
- package/src/fastctx-bridge.js +49 -10
- package/src/index.js +28 -35
- package/src/inline-tools.js +180 -385
- package/src/shell.js +13 -9
package/src/bash-tool.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell/bash-tool — the plugin's OWN bash tool (v0.39.0, route B).
|
|
3
|
+
*
|
|
4
|
+
* WHY AN OWN TOOL. Until v0.38.5 the plugin replaced the host `ctx.shell`
|
|
5
|
+
* executor (patch: disable `pwsh-sandbox`, insert a Git Bash executor) and
|
|
6
|
+
* mounted the OFFICIAL `@deepseek-ai/dsh-tool-bash` module per agent, so the
|
|
7
|
+
* model's `bash` tool ran Git Bash through the replaced service. The DSH-Store
|
|
8
|
+
* catalog gate forbids bundle patches that disable official components — and
|
|
9
|
+
* honestly so: replacing the shell executor IS overriding an official
|
|
10
|
+
* component. Route B keeps the official executor exactly where it is and
|
|
11
|
+
* instead ships an OWN tool:
|
|
12
|
+
*
|
|
13
|
+
* - the executor is a private `GitBashSandboxExecutor` instance constructed
|
|
14
|
+
* through cordis's own plugin path in a `shell`-isolated scope (never
|
|
15
|
+
* registered as the ROOT `ctx.shell`, which stays PowerShell); it inherits
|
|
16
|
+
* the shipped bash-sandbox semantics (deadline, output capture, argv
|
|
17
|
+
* execution) and points them at Git for Windows' bash.exe;
|
|
18
|
+
* - the tool definition mirrors `@deepseek-ai/dsh-tool-bash` call-for-call
|
|
19
|
+
* (schema, output union, background jobs, sandbox escalation, rendering)
|
|
20
|
+
* with exactly ONE substitution: `ctx.shell.resolve/execute` becomes
|
|
21
|
+
* `executor.resolve/execute`. The background surface, the promotion on
|
|
22
|
+
* timeout, the approval escalation and the exit-marker contract are the
|
|
23
|
+
* official ones, mirrored verbatim in this file and bash-render.js;
|
|
24
|
+
* - the official `pwsh` tool keeps working against the official executor —
|
|
25
|
+
* it is REAL PowerShell on this host again, so the fake-tool denial
|
|
26
|
+
* retires with the executor swap (the tool is merely HIDDEN by default,
|
|
27
|
+
* a pure product choice driven by the row switch).
|
|
28
|
+
*
|
|
29
|
+
* The module is mounted per agent through cordis's own plugin path
|
|
30
|
+
* (`agent.ctx.plugin(mod)` — the inject declaration must ride the module
|
|
31
|
+
* object, v0.38.4) inside an agent-owned scope, exactly like the official
|
|
32
|
+
* module it replaces, so every registration reverts at agent disposal.
|
|
33
|
+
*
|
|
34
|
+
* Host-package dependencies (`@deepseek-ai/dsh-tools` for `defineTool`,
|
|
35
|
+
* `@deepseek-ai/dsh-sandbox` for the escalation helpers) resolve through the
|
|
36
|
+
* host's own loader channel; when absent the definition degrades to a plain
|
|
37
|
+
* JSON-schema object and the escalation surface is not advertised at all —
|
|
38
|
+
* the tool keeps working, strictly narrower.
|
|
39
|
+
*
|
|
40
|
+
* @module dsh-gitbash-shell/bash-tool
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
ESCALATION_TARGETS,
|
|
45
|
+
parseExitStatus,
|
|
46
|
+
renderJobRead,
|
|
47
|
+
renderPromoted,
|
|
48
|
+
renderResult,
|
|
49
|
+
processJob,
|
|
50
|
+
processOutcome,
|
|
51
|
+
processSources,
|
|
52
|
+
ringDelta,
|
|
53
|
+
} from './bash-render.js'
|
|
54
|
+
|
|
55
|
+
/** The official abort code (`@deepseek-ai/dsh-tools` TOOL_ABORTED). */
|
|
56
|
+
const TOOL_ABORTED = 'ABORTED'
|
|
57
|
+
|
|
58
|
+
/** Official DSH_* managed-environment prefix (subprocess types.ts:13). */
|
|
59
|
+
const DSH_ENV_PREFIX = 'DSH'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a host package through the two proven channels (v0.38.1): the
|
|
63
|
+
* host's own loader first (it reaches inside the desktop app.asar), a plain
|
|
64
|
+
* createRequire second. Mirrors inline-tools' former resolveToolBashEntry.
|
|
65
|
+
* @param {object|undefined} ctx - any host context carrying `loader`/`baseUrl`.
|
|
66
|
+
* @param {string} name - the package to resolve.
|
|
67
|
+
* @returns {Promise<object|undefined>} the module, or undefined when absent.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveHostModule(ctx, name) {
|
|
70
|
+
try {
|
|
71
|
+
const internal = ctx?.loader?.internal
|
|
72
|
+
if (internal && typeof internal.import === 'function') {
|
|
73
|
+
const mod = await internal.import(name, ctx?.baseUrl, {})
|
|
74
|
+
if (mod) return mod
|
|
75
|
+
}
|
|
76
|
+
} catch { /* fall through */ }
|
|
77
|
+
try {
|
|
78
|
+
const { createRequire } = await import('node:module')
|
|
79
|
+
const { pathToFileURL } = await import('node:url')
|
|
80
|
+
const req = createRequire(ctx?.baseUrl ?? import.meta.url)
|
|
81
|
+
const href = pathToFileURL(req.resolve(name)).href
|
|
82
|
+
return await import(href)
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The official bash description (tool-bash index.ts:90), verbatim.
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function bashDescription() {
|
|
93
|
+
return 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
|
94
|
+
+ 'Each call runs in a fresh shell; pass `workdir` instead of using `cd`. '
|
|
95
|
+
+ `Managed \`$${DSH_ENV_PREFIX}*\` variables expose current harness environment facts. `
|
|
96
|
+
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
|
97
|
+
+ 'Provide `description` before `command` in the arguments. '
|
|
98
|
+
+ 'Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. '
|
|
99
|
+
+ 'An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. '
|
|
100
|
+
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The official foreground/present call view (index.ts:108). */
|
|
104
|
+
function presentBashCall(args) {
|
|
105
|
+
if (args.run_in_background === true) {
|
|
106
|
+
return {
|
|
107
|
+
card: 'generic',
|
|
108
|
+
title: args.command,
|
|
109
|
+
kind: 'execute',
|
|
110
|
+
rawInput: args.command,
|
|
111
|
+
content: [{ type: 'text', text: args.description }],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
card: 'terminal',
|
|
116
|
+
title: args.command,
|
|
117
|
+
description: args.description,
|
|
118
|
+
...(args.workdir !== undefined ? { cwd: args.workdir } : {}),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The official completed-result presentation (index.ts:130). */
|
|
123
|
+
function presentBashResult(args, result) {
|
|
124
|
+
const block = result.content.length === 1 ? result.content[0] : undefined
|
|
125
|
+
if (block === undefined || block.type !== 'text') return undefined
|
|
126
|
+
const raw = block.text
|
|
127
|
+
const isBackground = typeof args === 'object' && args !== null && args.run_in_background === true
|
|
128
|
+
const isPromoted = result.value && result.value.kind === 'promoted'
|
|
129
|
+
if (isBackground || isPromoted || result.isError) {
|
|
130
|
+
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
|
131
|
+
}
|
|
132
|
+
const { body, ...exit } = parseExitStatus(raw)
|
|
133
|
+
return { card: 'terminal', output: body, ...exit }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The official relative-workdir resolution (index.ts:146). */
|
|
137
|
+
function resolveWorkdir(modelWorkdir, sessionCwd) {
|
|
138
|
+
if (modelWorkdir === undefined) return sessionCwd
|
|
139
|
+
if (sessionCwd !== undefined && !modelWorkdir.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(modelWorkdir) && !modelWorkdir.startsWith('\\\\')) {
|
|
140
|
+
return `${sessionCwd}/${modelWorkdir}`
|
|
141
|
+
}
|
|
142
|
+
return modelWorkdir
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Detach the executor DTO into plain JSON data (index.ts:155). */
|
|
146
|
+
function canonicalBashResult(result, cwd) {
|
|
147
|
+
const output = (stream) => ({
|
|
148
|
+
text: stream.text,
|
|
149
|
+
truncated: stream.truncated,
|
|
150
|
+
...(stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}),
|
|
151
|
+
})
|
|
152
|
+
return {
|
|
153
|
+
cwd,
|
|
154
|
+
exitCode: result.exitCode,
|
|
155
|
+
signal: result.signal,
|
|
156
|
+
timedOut: result.timedOut,
|
|
157
|
+
aborted: result.aborted,
|
|
158
|
+
timeoutMs: result.timeoutMs,
|
|
159
|
+
stdout: output(result.stdout),
|
|
160
|
+
stderr: output(result.stderr),
|
|
161
|
+
...(result.sandbox !== undefined ? {
|
|
162
|
+
sandbox: {
|
|
163
|
+
mode: result.sandbox.mode,
|
|
164
|
+
denied: result.sandbox.denied,
|
|
165
|
+
...(result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}),
|
|
166
|
+
...(result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}),
|
|
167
|
+
},
|
|
168
|
+
} : {}),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The structured abort the foreground paths throw when the caller cancels the call. */
|
|
173
|
+
function toolAborted(HarnessError) {
|
|
174
|
+
if (typeof HarnessError === 'function') {
|
|
175
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
|
176
|
+
error.name = 'AbortError'
|
|
177
|
+
return error
|
|
178
|
+
}
|
|
179
|
+
const error = new Error('tool call aborted')
|
|
180
|
+
error.code = TOOL_ABORTED
|
|
181
|
+
error.name = 'AbortError'
|
|
182
|
+
return error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Canonical background-handle properties (index.ts:196). */
|
|
186
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
187
|
+
kind: { type: 'string', required: true, const: 'background' },
|
|
188
|
+
jobId: { type: 'string', required: true },
|
|
189
|
+
cwd: { type: 'string', required: true },
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The official output union in the spec dialect defineTool consumes (index.ts:402). */
|
|
193
|
+
function outputSchemaSpec() {
|
|
194
|
+
return {
|
|
195
|
+
oneOf: [
|
|
196
|
+
{ type: 'object', additionalProperties: false, properties: { ...BACKGROUND_OUTPUT_PROPERTIES } },
|
|
197
|
+
{
|
|
198
|
+
type: 'object',
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
properties: {
|
|
201
|
+
kind: { type: 'string', required: true, const: 'promoted' },
|
|
202
|
+
cwd: { type: 'string', required: true },
|
|
203
|
+
jobId: { type: 'string', required: true },
|
|
204
|
+
timeoutMs: { type: 'number', required: true },
|
|
205
|
+
output: { type: 'string', required: true },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'object',
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
properties: {
|
|
212
|
+
kind: { type: 'string', required: true, const: 'foreground' },
|
|
213
|
+
cwd: { type: 'string', required: true },
|
|
214
|
+
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
215
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
216
|
+
timedOut: { type: 'boolean', required: true },
|
|
217
|
+
aborted: { type: 'boolean', required: true },
|
|
218
|
+
stopped: { type: 'string' },
|
|
219
|
+
timeoutMs: { type: 'number', required: true },
|
|
220
|
+
stdout: {
|
|
221
|
+
type: 'object', additionalProperties: false, required: true,
|
|
222
|
+
properties: {
|
|
223
|
+
text: { type: 'string', required: true },
|
|
224
|
+
truncated: { type: 'boolean', required: true },
|
|
225
|
+
spillPath: { type: 'string' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
stderr: {
|
|
229
|
+
type: 'object', additionalProperties: false, required: true,
|
|
230
|
+
properties: {
|
|
231
|
+
text: { type: 'string', required: true },
|
|
232
|
+
truncated: { type: 'boolean', required: true },
|
|
233
|
+
spillPath: { type: 'string' },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
sandbox: {
|
|
237
|
+
type: 'object', additionalProperties: false,
|
|
238
|
+
properties: {
|
|
239
|
+
mode: { type: 'string', required: true },
|
|
240
|
+
denied: { type: 'boolean', required: true },
|
|
241
|
+
enforcement: { type: 'string' },
|
|
242
|
+
runnerFailed: { type: 'boolean' },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Plain JSON-schema variant of the same union (fallback when defineTool is absent). */
|
|
252
|
+
function outputSchemaJson() {
|
|
253
|
+
const convert = (item) => {
|
|
254
|
+
const out = { type: item.type, additionalProperties: item.additionalProperties, properties: {} }
|
|
255
|
+
const required = []
|
|
256
|
+
for (const [key, value] of Object.entries(item.properties)) {
|
|
257
|
+
if (value.required === true) { required.push(key) }
|
|
258
|
+
const { required: _drop, ...rest } = value
|
|
259
|
+
if (rest.oneOf !== undefined) delete rest.required
|
|
260
|
+
out.properties[key] = rest
|
|
261
|
+
}
|
|
262
|
+
return required.length > 0 ? { ...out, required } : out
|
|
263
|
+
}
|
|
264
|
+
return { oneOf: outputSchemaSpec().oneOf.map(convert) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The official args validation (index.ts:73). */
|
|
268
|
+
function validateBashArgs(args, effectiveMode, validateEscalation) {
|
|
269
|
+
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
270
|
+
throw new Error('invalid command: expected a non-empty string')
|
|
271
|
+
}
|
|
272
|
+
if (typeof args.description !== 'string' || args.description.trim().length === 0) {
|
|
273
|
+
throw new Error('invalid description: expected a non-empty string')
|
|
274
|
+
}
|
|
275
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
276
|
+
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
|
277
|
+
}
|
|
278
|
+
if (args.sandbox_permissions !== undefined && args.sandbox_permissions === effectiveMode) return
|
|
279
|
+
const justification = args.sandbox_permissions === undefined && args.justification !== undefined && args.justification.trim() === ''
|
|
280
|
+
? undefined
|
|
281
|
+
: args.justification
|
|
282
|
+
if (typeof validateEscalation === 'function') {
|
|
283
|
+
validateEscalation(args.sandbox_permissions, justification)
|
|
284
|
+
} else if (args.sandbox_permissions !== undefined) {
|
|
285
|
+
throw new Error('sandbox_permissions is not available in this composition (no sandbox helper)')
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build the module object for one agent scope. The executor instance is
|
|
291
|
+
* constructed ONCE per process (private scope) and shared read-only; the
|
|
292
|
+
* host-package modules are resolved once per process too.
|
|
293
|
+
* @param {object} options
|
|
294
|
+
* @param {object} options.executor - the private GitBashSandboxExecutor instance.
|
|
295
|
+
* @param {object|undefined} [options.toolsModule] - `@deepseek-ai/dsh-tools` (defineTool), when resolvable.
|
|
296
|
+
* @param {object|undefined} [options.sandboxModule] - `@deepseek-ai/dsh-sandbox` (escalation helpers), when resolvable.
|
|
297
|
+
* @param {object|undefined} [options.llmModule] - `@deepseek-ai/dsh-llm` (HarnessError), when resolvable.
|
|
298
|
+
* @param {(message: string) => void} options.log
|
|
299
|
+
* @returns {{ inject: string[], apply: (ctx: object, config?: object) => void }}
|
|
300
|
+
*/
|
|
301
|
+
export function createBashToolModule({ executor, toolsModule, sandboxModule, llmModule, log }) {
|
|
302
|
+
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
|
|
303
|
+
throw new Error('bash-tool: an executor with resolve()/execute() is required')
|
|
304
|
+
}
|
|
305
|
+
const defineTool = typeof toolsModule?.defineTool === 'function' ? toolsModule.defineTool : undefined
|
|
306
|
+
const approveEscalation = typeof sandboxModule?.approveEscalation === 'function' ? sandboxModule.approveEscalation : undefined
|
|
307
|
+
const validateEscalationArgs = typeof sandboxModule?.validateEscalationArgs === 'function' ? sandboxModule.validateEscalationArgs : undefined
|
|
308
|
+
const sandboxPermissionsDescription = typeof sandboxModule?.sandboxPermissionsDescription === 'function'
|
|
309
|
+
? sandboxModule.sandboxPermissionsDescription
|
|
310
|
+
: (subject) => `The narrowest wider sandbox mode for a one-shot retry of the exact ${subject} the sandbox just denied; the retry asks the user for approval.`
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
inject: ['tools', 'systemPrompt', 'shellEnv', 'workingDirectory'],
|
|
314
|
+
apply(ctx, config = {}) {
|
|
315
|
+
const backgroundEnabled = config?.enableRunInBackground ?? true
|
|
316
|
+
const promoteOnTimeout = (config?.promoteOnTimeout ?? true) && backgroundEnabled
|
|
317
|
+
// The capability fact rides the executor's constructor read of
|
|
318
|
+
// `ctx.sandboxPolicy.defaultMode` — the same source the official
|
|
319
|
+
// tool-bash uses through `ctx.shell.sandboxMode`.
|
|
320
|
+
const defaultMode = executor.sandboxMode
|
|
321
|
+
const escalationReady = defaultMode !== undefined && typeof approveEscalation === 'function'
|
|
322
|
+
const escalationModes = escalationReady ? ESCALATION_TARGETS : []
|
|
323
|
+
const resolveSandboxPolicy = (exec) => {
|
|
324
|
+
if (!escalationReady) return undefined
|
|
325
|
+
const policy = ctx.get('sandboxPolicy')
|
|
326
|
+
return policy ? policy.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session }) : undefined
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const approveBashEscalation = async (mode, justification, exec, standingPolicy) => {
|
|
330
|
+
if (!escalationReady) {
|
|
331
|
+
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
|
332
|
+
}
|
|
333
|
+
const effectiveMode = standingPolicy.mode
|
|
334
|
+
return approveEscalation(
|
|
335
|
+
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
|
336
|
+
{ approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId, toolName: 'bash', signal: exec.signal },
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
|
|
341
|
+
try {
|
|
342
|
+
ctx.systemPrompt.section({
|
|
343
|
+
name: 'tool:bash',
|
|
344
|
+
order: ctx.systemPrompt.getSectionOrder('TOOL_BASH'),
|
|
345
|
+
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
|
346
|
+
})
|
|
347
|
+
} catch (error) {
|
|
348
|
+
log(`bash tool prompt section failed: ${error?.message ?? error}`)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One registration of the `bash` tool (index.ts:262, shell → executor). */
|
|
352
|
+
const bashTool = (jobs) => {
|
|
353
|
+
const background = jobs !== undefined
|
|
354
|
+
const promote = background && promoteOnTimeout
|
|
355
|
+
|
|
356
|
+
const startJob = (registry, args, exec, spec) => {
|
|
357
|
+
let proc
|
|
358
|
+
let stopped
|
|
359
|
+
const id = registry.start({
|
|
360
|
+
kind: 'bash',
|
|
361
|
+
label: args.command,
|
|
362
|
+
...(exec.agent ? { owner: exec.agent.id } : {}),
|
|
363
|
+
output: processSources(() => proc),
|
|
364
|
+
run: () => {
|
|
365
|
+
const hooks = processJob(
|
|
366
|
+
async (signal) => {
|
|
367
|
+
proc = await executor.execute({ ...spec, signal })
|
|
368
|
+
return proc
|
|
369
|
+
},
|
|
370
|
+
(started) => processOutcome(started, escalationModes),
|
|
371
|
+
)
|
|
372
|
+
return {
|
|
373
|
+
done: hooks.done,
|
|
374
|
+
cancel: (reason) => {
|
|
375
|
+
stopped = reason
|
|
376
|
+
hooks.cancel(reason)
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
return { id, process: () => proc, stopped: () => stopped }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const waitOnJob = async (registry, attached, exec, spec) => {
|
|
385
|
+
const owner = exec.agent?.id
|
|
386
|
+
const timeoutMs = spec.timeoutMs
|
|
387
|
+
const stop = async (reason) => {
|
|
388
|
+
registry.kill(attached.id, owner, reason)
|
|
389
|
+
const settled = await registry.wait(attached.id, timeoutMs, owner)
|
|
390
|
+
if (settled.status !== 'running' && settled.status !== 'stopping') registry.remove(attached.id, owner)
|
|
391
|
+
return settled
|
|
392
|
+
}
|
|
393
|
+
let view
|
|
394
|
+
try {
|
|
395
|
+
view = await registry.wait(attached.id, timeoutMs, owner, exec.signal)
|
|
396
|
+
} catch {
|
|
397
|
+
await stop('tool call aborted')
|
|
398
|
+
throw toolAborted(llmModule?.HarnessError)
|
|
399
|
+
}
|
|
400
|
+
if ((view.status === 'running' || view.status === 'stopping') && attached.process() === undefined) {
|
|
401
|
+
await stop('timed out during preparation')
|
|
402
|
+
return {
|
|
403
|
+
kind: 'foreground',
|
|
404
|
+
cwd: spec.workdir,
|
|
405
|
+
exitCode: null,
|
|
406
|
+
signal: null,
|
|
407
|
+
timedOut: true,
|
|
408
|
+
aborted: false,
|
|
409
|
+
timeoutMs,
|
|
410
|
+
stdout: { text: '', truncated: false },
|
|
411
|
+
stderr: { text: '', truncated: false },
|
|
412
|
+
...(spec.sandboxPolicy !== undefined ? { sandbox: { mode: spec.sandboxPolicy.mode, denied: false } } : {}),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (view.status === 'running' || view.status === 'stopping') {
|
|
416
|
+
const read = registry.read(attached.id, owner)
|
|
417
|
+
return {
|
|
418
|
+
kind: 'promoted',
|
|
419
|
+
cwd: spec.workdir,
|
|
420
|
+
jobId: attached.id,
|
|
421
|
+
timeoutMs,
|
|
422
|
+
output: renderJobRead(
|
|
423
|
+
ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], attached.process()?.sandbox, escalationModes,
|
|
424
|
+
),
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
registry.remove(attached.id, owner)
|
|
428
|
+
const process = attached.process()
|
|
429
|
+
if (process === undefined) throw new Error(view.detail)
|
|
430
|
+
const result = await process.result()
|
|
431
|
+
const stopped = attached.stopped()
|
|
432
|
+
return { kind: 'foreground', ...canonicalBashResult(result, spec.workdir), ...(stopped !== undefined ? { stopped } : {}) }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The parameter spec in the defineTool dialect (index.ts:370). */
|
|
436
|
+
const parametersSpec = () => ({
|
|
437
|
+
description: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
required: true,
|
|
440
|
+
description: 'Clear, concise description of what this command does in active voice, '
|
|
441
|
+
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
|
442
|
+
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
|
|
443
|
+
},
|
|
444
|
+
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
|
445
|
+
timeoutMs: {
|
|
446
|
+
type: 'number',
|
|
447
|
+
description: promote
|
|
448
|
+
? 'Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed.'
|
|
449
|
+
: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.',
|
|
450
|
+
},
|
|
451
|
+
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
|
452
|
+
...(background ? {
|
|
453
|
+
run_in_background: { type: 'boolean', description: 'Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies.' },
|
|
454
|
+
} : {}),
|
|
455
|
+
...(escalationModes.length > 0 ? {
|
|
456
|
+
sandbox_permissions: {
|
|
457
|
+
type: 'string',
|
|
458
|
+
enum: [...escalationModes],
|
|
459
|
+
description: sandboxPermissionsDescription('command'),
|
|
460
|
+
},
|
|
461
|
+
justification: {
|
|
462
|
+
type: 'string',
|
|
463
|
+
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. '
|
|
464
|
+
+ 'Use the language of the user’s current request.',
|
|
465
|
+
},
|
|
466
|
+
} : {}),
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
/** The execute body, identical across both definition shapes (index.ts:477). */
|
|
470
|
+
const executeBody = async (args, exec) => {
|
|
471
|
+
const standingPolicy = resolveSandboxPolicy(exec)
|
|
472
|
+
validateBashArgs(args, standingPolicy?.mode, validateEscalationArgs)
|
|
473
|
+
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
474
|
+
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
|
475
|
+
: undefined
|
|
476
|
+
const policy = approvedMode === undefined
|
|
477
|
+
? standingPolicy
|
|
478
|
+
: { ...standingPolicy, mode: approvedMode }
|
|
479
|
+
const cwd = exec.agent === undefined ? undefined : await ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
480
|
+
const workdir = resolveWorkdir(args.workdir, cwd)
|
|
481
|
+
const dshEnv = ctx.shellEnv.collect(exec)
|
|
482
|
+
const request = {
|
|
483
|
+
command: args.command,
|
|
484
|
+
...(workdir !== undefined ? { workdir } : {}),
|
|
485
|
+
...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
|
|
486
|
+
dshEnv,
|
|
487
|
+
...(policy !== undefined ? { sandboxPolicy: policy } : {}),
|
|
488
|
+
}
|
|
489
|
+
if (args.run_in_background === true) {
|
|
490
|
+
if (!backgroundEnabled) {
|
|
491
|
+
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
|
|
492
|
+
}
|
|
493
|
+
if (jobs === undefined) {
|
|
494
|
+
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
495
|
+
}
|
|
496
|
+
if (exec.signal.aborted) throw toolAborted(llmModule?.HarnessError)
|
|
497
|
+
const spec = executor.resolve({ ...request, onExpiry: 'none' })
|
|
498
|
+
return { kind: 'background', jobId: startJob(jobs, args, exec, spec).id, cwd: spec.workdir }
|
|
499
|
+
}
|
|
500
|
+
if (jobs !== undefined && promote) {
|
|
501
|
+
const spec = executor.resolve({ ...request, onExpiry: 'none' })
|
|
502
|
+
let attached
|
|
503
|
+
try {
|
|
504
|
+
attached = startJob(jobs, args, exec, spec)
|
|
505
|
+
} catch (error) {
|
|
506
|
+
ctx.logger?.warn?.(`bash: job registration refused, running in the foreground with the timeout kill instead: ${String(error)}`)
|
|
507
|
+
}
|
|
508
|
+
if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec)
|
|
509
|
+
}
|
|
510
|
+
const spec = executor.resolve({ ...request, signal: exec.signal })
|
|
511
|
+
const foreground = await executor.execute(spec)
|
|
512
|
+
const result = await foreground.result()
|
|
513
|
+
if (result.aborted) throw toolAborted(llmModule?.HarnessError)
|
|
514
|
+
return { kind: 'foreground', ...canonicalBashResult(result, spec.workdir) }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (typeof defineTool === 'function') {
|
|
518
|
+
return defineTool({
|
|
519
|
+
name: 'bash',
|
|
520
|
+
description: bashDescription(),
|
|
521
|
+
parameters: parametersSpec(),
|
|
522
|
+
output: {
|
|
523
|
+
schema: outputSchemaSpec(),
|
|
524
|
+
render: (_args, value) => [{
|
|
525
|
+
type: 'text',
|
|
526
|
+
text: value.kind === 'background'
|
|
527
|
+
? `started background job ${value.jobId}`
|
|
528
|
+
: value.kind === 'promoted'
|
|
529
|
+
? renderPromoted(value)
|
|
530
|
+
: renderResult(value, escalationModes),
|
|
531
|
+
}],
|
|
532
|
+
presentationMeta: (_args, value) => ({ cwd: value.cwd }),
|
|
533
|
+
},
|
|
534
|
+
execute: executeBody,
|
|
535
|
+
presentCall: presentBashCall,
|
|
536
|
+
presentResult: presentBashResult,
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
// Plain-object fallback: same surface, JSON-schema dialect, no arg
|
|
540
|
+
// soft-validation wrapper (the registry still validates against the
|
|
541
|
+
// declared schema). Only reachable on hosts where the loader channel
|
|
542
|
+
// cannot reach `@deepseek-ai/dsh-tools`.
|
|
543
|
+
log('bash tool: @deepseek-ai/dsh-tools not resolvable; registering a plain definition')
|
|
544
|
+
return {
|
|
545
|
+
name: 'bash',
|
|
546
|
+
description: bashDescription(),
|
|
547
|
+
// No additionalProperties:false — the official spec lets undeclared
|
|
548
|
+
// keys reach execute too, where enableRunInBackground enforces them
|
|
549
|
+
// ("Undeclared keys are allowed, so schema omission also needs
|
|
550
|
+
// enforcement", index.ts:498).
|
|
551
|
+
parameters: { type: 'object', properties: parametersSpec(), required: ['description', 'command'] },
|
|
552
|
+
output: {
|
|
553
|
+
schema: outputSchemaJson(),
|
|
554
|
+
render: (_args, value) => [{
|
|
555
|
+
type: 'text',
|
|
556
|
+
text: value.kind === 'background'
|
|
557
|
+
? `started background job ${value.jobId}`
|
|
558
|
+
: value.kind === 'promoted'
|
|
559
|
+
? renderPromoted(value)
|
|
560
|
+
: renderResult(value, escalationModes),
|
|
561
|
+
}],
|
|
562
|
+
presentationMeta: (_args, value) => ({ cwd: value.cwd }),
|
|
563
|
+
},
|
|
564
|
+
execute: executeBody,
|
|
565
|
+
presentCall: presentBashCall,
|
|
566
|
+
presentResult: presentBashResult,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The background surface follows the registry (index.ts:536), verbatim.
|
|
571
|
+
if (!backgroundEnabled) {
|
|
572
|
+
ctx.tools.register(bashTool(undefined))
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
let foregroundOnly = ctx.get('jobs') === undefined ? ctx.tools.register(bashTool(undefined)) : undefined
|
|
576
|
+
ctx.inject(['jobs'], (jobCtx) => {
|
|
577
|
+
foregroundOnly?.()
|
|
578
|
+
foregroundOnly = undefined
|
|
579
|
+
const unregister = ctx.tools.register(bashTool(jobCtx.jobs))
|
|
580
|
+
jobCtx.effect(() => () => {
|
|
581
|
+
unregister()
|
|
582
|
+
try {
|
|
583
|
+
if (ctx.fiber?.state === 'active') foregroundOnly = ctx.tools.register(bashTool(undefined))
|
|
584
|
+
} catch { /* the scope is going away */ }
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
}
|