dsh-gitbash-shell 0.38.5 → 0.39.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.
@@ -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 PRIVATE scope (never registered as
15
+ * `ctx.shell`, which stays the official PowerShell executor); 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
+ }