dsh-win-multi-bash 0.1.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/LICENSE +21 -0
- package/README.i18n.yaml +8 -0
- package/README.md +162 -0
- package/README.zh.md +162 -0
- package/THIRD_PARTY_NOTICES +43 -0
- package/cordis.patch.yml +64 -0
- package/lib/bash-git/index.js +382 -0
- package/lib/bash-git/invariant.js +23 -0
- package/lib/bash-wsl/index.js +335 -0
- package/lib/bash-wsl/invariant.js +23 -0
- package/lib/index.js +20 -0
- package/lib/shell-select/index.js +169 -0
- package/lib/shell-select/invariant.js +23 -0
- package/lib/tool-bash/index.js +526 -0
- package/lib/tool-bash/invariant.js +23 -0
- package/lib/tool-bash/types/background.js +25 -0
- package/lib/tool-bash/types/factory.js +390 -0
- package/lib/tool-bash/types/git-bash.js +13 -0
- package/lib/tool-bash/types/index.js +16 -0
- package/lib/tool-bash/types/invariant.js +22 -0
- package/lib/tool-bash/types/render.js +96 -0
- package/lib/tool-bash/types/wsl-bash.js +13 -0
- package/lib/vendor/bwrap-profiles.js +32 -0
- package/lib/vendor/helpers.js +111 -0
- package/package.json +95 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing Consumer of the `ctx.shell` capability seam. Background calls
|
|
3
|
+
* register process handles with `ctx.jobs`; their work uses job cancellation
|
|
4
|
+
* rather than the tool-call signal after an id is returned.
|
|
5
|
+
*
|
|
6
|
+
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
|
|
7
|
+
* sandboxing executors; see docs/architecture.md § Where new behavior goes.
|
|
8
|
+
* @module @deepseek-ai/dsh-tool-bash/factory
|
|
9
|
+
*/
|
|
10
|
+
import z from '@deepseek-ai/schemastery';
|
|
11
|
+
import { isAbsolute, resolve as resolvePath } from 'node:path';
|
|
12
|
+
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox';
|
|
15
|
+
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell';
|
|
16
|
+
import { processOutcome } from "./background.js";
|
|
17
|
+
import { parseExitStatus, renderProcessRead, renderResult } from "./render.js";
|
|
18
|
+
const DIALECT_FACTS = {
|
|
19
|
+
posix: { shell: 'bash', invoke: 'bash -c', paths: 'POSIX paths', env: '$VAR', toolchain: 'the full Unix toolchain' },
|
|
20
|
+
msys: { shell: 'Git Bash (MSYS2)', invoke: 'bash -c', paths: 'MSYS paths such as /d/WorkSpace or native C:\\...', env: '$VAR', toolchain: 'the Git for Windows toolchain (git, Windows .exe tools, POSIX utilities)', conversion: 'MSYS auto-converts leading-slash arguments to Windows paths whenever a native Windows executable is called, so POSIX paths get mangled (e.g. `wsl.exe -e ls /root` fails on `D:/Program Files/Git/root`); prefix such calls with `MSYS_NO_PATHCONV=1` to pass arguments verbatim' },
|
|
21
|
+
wsl: { shell: 'WSL Linux', invoke: 'bash -c', paths: 'Linux paths such as /mnt/c/... (Windows paths auto-converted by wsl.exe)', env: '$VAR', toolchain: 'the Linux userland (apt, gcc, python, ...)', conversion: 'the command rides as a base64 payload, so quoting and Linux paths reach the distro verbatim (no MSYS-style mangling)' },
|
|
22
|
+
};
|
|
23
|
+
/** Runtime configuration schema for a shell tool instance. */
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
enableRunInBackground: z.boolean().default(true),
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* The model-facing description of one shell tool instance. The POSIX variant
|
|
29
|
+
* keeps the legacy wording byte-for-byte (the ACP/headless tool-schema
|
|
30
|
+
* fixtures pin it); msys/wsl instances describe their dialect facts.
|
|
31
|
+
* @param dialect - the shell dialect the instance runs.
|
|
32
|
+
* @param backgroundEnabled - whether `run_in_background` is advertised.
|
|
33
|
+
* @param escalationModes - the escalation targets this composition advertises;
|
|
34
|
+
* empty adds no same-turn escalation guidance.
|
|
35
|
+
* @returns the tool description passed to `defineTool`.
|
|
36
|
+
*/
|
|
37
|
+
export function shellDescription(dialect, backgroundEnabled, escalationModes) {
|
|
38
|
+
const background = backgroundEnabled
|
|
39
|
+
? '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`.'
|
|
40
|
+
: 'Background execution is not available; long-running commands must finish within the timeout.';
|
|
41
|
+
if (dialect === 'posix') {
|
|
42
|
+
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
|
43
|
+
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
|
44
|
+
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
|
45
|
+
+ `Current harness environment facts are exposed through managed \`\$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
|
|
46
|
+
+ '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. '
|
|
47
|
+
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
|
48
|
+
+ background;
|
|
49
|
+
return base + escalationTail(escalationModes);
|
|
50
|
+
}
|
|
51
|
+
const facts = DIALECT_FACTS[dialect];
|
|
52
|
+
const base = `Execute a ${facts.shell} command (${facts.invoke}) and return its stdout/stderr. `
|
|
53
|
+
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
|
54
|
+
+ 'pass `workdir` instead of using `cd`. '
|
|
55
|
+
+ `Paths use ${facts.paths}; read environment variables with ${facts.env}; ${facts.toolchain} is available. `
|
|
56
|
+
+ `Note: ${facts.conversion}. `
|
|
57
|
+
+ 'Non-zero exits are reported as `[exit code: N]`. '
|
|
58
|
+
+ `Current harness environment facts are exposed through managed \`\$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
|
|
59
|
+
+ '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. '
|
|
60
|
+
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
|
61
|
+
+ background;
|
|
62
|
+
return base + escalationTail(escalationModes);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The same-turn escalation guidance appended after a denial marker. Kept in
|
|
66
|
+
* one place because every dialect shares the exact approval contract.
|
|
67
|
+
* @param escalationModes - the escalation targets this composition advertises.
|
|
68
|
+
* @returns the guidance sentence, or '' when no escalation is advertised.
|
|
69
|
+
*/
|
|
70
|
+
function escalationTail(escalationModes) {
|
|
71
|
+
if (escalationModes.length === 0)
|
|
72
|
+
return '';
|
|
73
|
+
return ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
|
74
|
+
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
|
|
75
|
+
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
|
|
76
|
+
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
|
|
77
|
+
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
|
|
78
|
+
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
|
|
79
|
+
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
|
|
80
|
+
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
|
|
81
|
+
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
|
|
82
|
+
+ 'A rejected escalation is final for that command — stop and explain, never work around '
|
|
83
|
+
+ 'it — but it does not forbid attempting or escalating other commands later.';
|
|
84
|
+
}
|
|
85
|
+
function validateShellArgs(args) {
|
|
86
|
+
if (args.command.trim().length === 0) {
|
|
87
|
+
throw new Error('invalid command: expected a non-empty string');
|
|
88
|
+
}
|
|
89
|
+
if (args.description.trim().length === 0) {
|
|
90
|
+
throw new Error('invalid description: expected a non-empty string');
|
|
91
|
+
}
|
|
92
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
93
|
+
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`);
|
|
94
|
+
}
|
|
95
|
+
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
|
|
96
|
+
// the shared rule both enforcing families validate identically.
|
|
97
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification);
|
|
98
|
+
}
|
|
99
|
+
function presentShellCall(args) {
|
|
100
|
+
if (args.run_in_background === true) {
|
|
101
|
+
return {
|
|
102
|
+
card: 'generic',
|
|
103
|
+
title: args.command,
|
|
104
|
+
kind: 'execute',
|
|
105
|
+
rawInput: args.command,
|
|
106
|
+
content: [{ type: 'text', text: args.description }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
card: 'terminal',
|
|
111
|
+
title: args.command,
|
|
112
|
+
description: args.description,
|
|
113
|
+
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Present completed foreground output as a terminal; background acknowledgements
|
|
118
|
+
* and execution errors use generic fenced output without an exit-status pill.
|
|
119
|
+
*/
|
|
120
|
+
function presentShellResult(args, result) {
|
|
121
|
+
const block = result.content.length === 1 ? result.content[0] : undefined;
|
|
122
|
+
if (block === undefined || block.type !== 'text')
|
|
123
|
+
return undefined;
|
|
124
|
+
const raw = block.text;
|
|
125
|
+
const isBackground = typeof args === 'object' && args !== null && args.run_in_background === true;
|
|
126
|
+
// Background acknowledgements and errors have no terminal exit status.
|
|
127
|
+
if (isBackground || result.isError) {
|
|
128
|
+
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] };
|
|
129
|
+
}
|
|
130
|
+
// The exit marker becomes the card's exit pill, so it leaves the output body.
|
|
131
|
+
const { body, ...exit } = parseExitStatus(raw);
|
|
132
|
+
return { card: 'terminal', output: body, ...exit };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
|
|
136
|
+
* otherwise use the filesystem identity of the session cwd and leave executor
|
|
137
|
+
* defaulting as the fallback. A resolved sandbox-policy root wins so workdir
|
|
138
|
+
* and confinement use the exact same per-call identity.
|
|
139
|
+
*/
|
|
140
|
+
function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
|
|
141
|
+
const headerCwd = exec.agent?.session.header.cwd;
|
|
142
|
+
const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd));
|
|
143
|
+
if (modelWorkdir === undefined)
|
|
144
|
+
return sessionCwd;
|
|
145
|
+
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
|
|
146
|
+
return resolvePath(sessionCwd, modelWorkdir);
|
|
147
|
+
}
|
|
148
|
+
return modelWorkdir;
|
|
149
|
+
}
|
|
150
|
+
/** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
|
|
151
|
+
function canonicalShellResult(result) {
|
|
152
|
+
const output = (stream) => ({
|
|
153
|
+
text: stream.text,
|
|
154
|
+
truncated: stream.truncated,
|
|
155
|
+
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
exitCode: result.exitCode,
|
|
159
|
+
signal: result.signal,
|
|
160
|
+
timedOut: result.timedOut,
|
|
161
|
+
aborted: result.aborted,
|
|
162
|
+
timeoutMs: result.timeoutMs,
|
|
163
|
+
stdout: output(result.stdout),
|
|
164
|
+
stderr: output(result.stderr),
|
|
165
|
+
...result.sandbox !== undefined ? {
|
|
166
|
+
sandbox: {
|
|
167
|
+
mode: result.sandbox.mode,
|
|
168
|
+
denied: result.sandbox.denied,
|
|
169
|
+
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
|
170
|
+
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
|
171
|
+
},
|
|
172
|
+
} : {},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Canonical background-handle properties shared by the shell output union. */
|
|
176
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
177
|
+
kind: { type: 'string', required: true, const: 'background' },
|
|
178
|
+
jobId: { type: 'string', required: true },
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Build a model-facing shell tool plugin. The instance name doubles as the
|
|
182
|
+
* tool name, the approval subject, the prompt section name (`tool:<toolName>`),
|
|
183
|
+
* and the `ctx.jobs` kind; the instance module declares its kind in
|
|
184
|
+
* {@link JobKindMap} via declaration merging.
|
|
185
|
+
* @param def - the instance definition: `toolName` (also the job kind),
|
|
186
|
+
* `shell` (routed to the selector executor when defined), and `dialect`.
|
|
187
|
+
* @returns the complete plugin object (name/inject/Config/apply).
|
|
188
|
+
*/
|
|
189
|
+
export function defineShellTool(def) {
|
|
190
|
+
return {
|
|
191
|
+
name: `tool-${def.toolName}`,
|
|
192
|
+
inject: ['tools', 'shell', 'systemPrompt', 'shellEnv'],
|
|
193
|
+
Config,
|
|
194
|
+
apply(ctx, config = {}) {
|
|
195
|
+
const backgroundEnabled = config.enableRunInBackground ?? true;
|
|
196
|
+
const defaultMode = ctx.shell.sandboxMode;
|
|
197
|
+
const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS;
|
|
198
|
+
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy');
|
|
199
|
+
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
|
200
|
+
throw new Error(`tool-${def.toolName}: the mounted bash executor confines but ctx.sandboxPolicy is missing`);
|
|
201
|
+
}
|
|
202
|
+
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
|
|
203
|
+
const resolveSandboxPolicy = (exec) => sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
|
|
204
|
+
/**
|
|
205
|
+
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
|
206
|
+
* anything executes, delegating the shared fail-closed sequence (strict
|
|
207
|
+
* widening, channel resolution, outcome mapping) to
|
|
208
|
+
* {@link approveEscalation}. This tool contributes only the composition
|
|
209
|
+
* guard (the fields are unadvertised without a sandboxing executor, yet
|
|
210
|
+
* schema validation checks advertised keys only, so an unadvertised
|
|
211
|
+
* `sandbox_permissions` still reaches execute) and the approval
|
|
212
|
+
* ingredients. The shared policy resolver is required whenever the executor
|
|
213
|
+
* advertises confinement, so a split composition fails at tool-plugin load.
|
|
214
|
+
*/
|
|
215
|
+
const approveShellEscalation = (mode, justification, exec, standingPolicy) => {
|
|
216
|
+
if (escalationModes.length === 0) {
|
|
217
|
+
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)');
|
|
218
|
+
}
|
|
219
|
+
const effectiveMode = standingPolicy.mode;
|
|
220
|
+
return approveEscalation({ requestedMode: mode, justification, effectiveMode, subject: 'command' }, {
|
|
221
|
+
approver: ctx.get('approval'),
|
|
222
|
+
agent: exec.agent,
|
|
223
|
+
callId: exec.callId,
|
|
224
|
+
toolName: def.toolName,
|
|
225
|
+
signal: exec.signal,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
|
|
229
|
+
ctx.systemPrompt.section({
|
|
230
|
+
name: `tool:${def.toolName}`,
|
|
231
|
+
order: 105,
|
|
232
|
+
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
|
233
|
+
});
|
|
234
|
+
ctx.tools.register(defineTool({
|
|
235
|
+
name: def.toolName,
|
|
236
|
+
description: shellDescription(def.dialect, backgroundEnabled, escalationModes),
|
|
237
|
+
parameters: {
|
|
238
|
+
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
|
239
|
+
description: {
|
|
240
|
+
type: 'string',
|
|
241
|
+
required: true,
|
|
242
|
+
description: 'Clear, concise description of what this command does in active voice, '
|
|
243
|
+
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
|
244
|
+
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
|
|
245
|
+
},
|
|
246
|
+
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
|
247
|
+
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
|
248
|
+
...backgroundEnabled ? {
|
|
249
|
+
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.' },
|
|
250
|
+
} : {},
|
|
251
|
+
...escalationModes.length > 0 ? {
|
|
252
|
+
sandbox_permissions: {
|
|
253
|
+
type: 'string',
|
|
254
|
+
enum: [...escalationModes],
|
|
255
|
+
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
|
|
256
|
+
},
|
|
257
|
+
justification: {
|
|
258
|
+
type: 'string',
|
|
259
|
+
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
|
|
260
|
+
},
|
|
261
|
+
} : {},
|
|
262
|
+
},
|
|
263
|
+
output: {
|
|
264
|
+
schema: {
|
|
265
|
+
oneOf: [
|
|
266
|
+
{
|
|
267
|
+
type: 'object',
|
|
268
|
+
additionalProperties: false,
|
|
269
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: 'object',
|
|
273
|
+
additionalProperties: false,
|
|
274
|
+
properties: {
|
|
275
|
+
kind: { type: 'string', required: true, const: 'foreground' },
|
|
276
|
+
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
277
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
278
|
+
timedOut: { type: 'boolean', required: true },
|
|
279
|
+
aborted: { type: 'boolean', required: true },
|
|
280
|
+
timeoutMs: { type: 'number', required: true },
|
|
281
|
+
stdout: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
additionalProperties: false,
|
|
284
|
+
required: true,
|
|
285
|
+
properties: {
|
|
286
|
+
text: { type: 'string', required: true },
|
|
287
|
+
truncated: { type: 'boolean', required: true },
|
|
288
|
+
spillPath: { type: 'string' },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
stderr: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
required: true,
|
|
295
|
+
properties: {
|
|
296
|
+
text: { type: 'string', required: true },
|
|
297
|
+
truncated: { type: 'boolean', required: true },
|
|
298
|
+
spillPath: { type: 'string' },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
sandbox: {
|
|
302
|
+
type: 'object',
|
|
303
|
+
additionalProperties: false,
|
|
304
|
+
properties: {
|
|
305
|
+
mode: { type: 'string', required: true },
|
|
306
|
+
denied: { type: 'boolean', required: true },
|
|
307
|
+
enforcement: { type: 'string' },
|
|
308
|
+
runnerFailed: { type: 'boolean' },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
render: (_args, value) => [{
|
|
316
|
+
type: 'text',
|
|
317
|
+
text: value.kind === 'background'
|
|
318
|
+
? `started background job ${value.jobId}`
|
|
319
|
+
: renderResult(value, escalationModes),
|
|
320
|
+
}],
|
|
321
|
+
},
|
|
322
|
+
async execute(args, exec) {
|
|
323
|
+
validateShellArgs(args);
|
|
324
|
+
// Description is display metadata; workdir defaults to the caller's session.
|
|
325
|
+
const standingPolicy = resolveSandboxPolicy(exec);
|
|
326
|
+
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
327
|
+
? await approveShellEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
|
328
|
+
: undefined;
|
|
329
|
+
const policy = approvedMode === undefined
|
|
330
|
+
? standingPolicy
|
|
331
|
+
: { ...standingPolicy, mode: approvedMode };
|
|
332
|
+
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot);
|
|
333
|
+
const dshEnv = ctx.shellEnv.collect(exec);
|
|
334
|
+
const request = {
|
|
335
|
+
command: args.command,
|
|
336
|
+
...workdir !== undefined ? { workdir } : {},
|
|
337
|
+
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
|
338
|
+
...def.shell !== undefined ? { shell: def.shell } : {},
|
|
339
|
+
dshEnv,
|
|
340
|
+
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
|
341
|
+
};
|
|
342
|
+
if (args.run_in_background === true) {
|
|
343
|
+
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
|
344
|
+
if (!backgroundEnabled) {
|
|
345
|
+
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)');
|
|
346
|
+
}
|
|
347
|
+
const jobs = ctx.get('jobs');
|
|
348
|
+
if (jobs === undefined) {
|
|
349
|
+
throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs');
|
|
350
|
+
}
|
|
351
|
+
// The caller owns cancellation until ctx.jobs commits detached ownership.
|
|
352
|
+
if (exec.signal.aborted) {
|
|
353
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED);
|
|
354
|
+
error.name = 'AbortError';
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
// Task preflight finishes before the starter can spawn a process.
|
|
358
|
+
const id = jobs.start({
|
|
359
|
+
kind: def.toolName,
|
|
360
|
+
label: args.command,
|
|
361
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
362
|
+
run: () => {
|
|
363
|
+
const proc = ctx.shell.start(ctx.shell.resolve(request));
|
|
364
|
+
return {
|
|
365
|
+
cancel: () => void proc.kill(),
|
|
366
|
+
done: proc.done.then(() => processOutcome(proc)),
|
|
367
|
+
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
return { kind: 'background', jobId: id };
|
|
372
|
+
}
|
|
373
|
+
const result = await ctx.shell.run(ctx.shell.resolve({
|
|
374
|
+
...request,
|
|
375
|
+
signal: exec.signal,
|
|
376
|
+
}));
|
|
377
|
+
if (result.aborted) {
|
|
378
|
+
const error = new HarnessError('tool call aborted', TOOL_ABORTED);
|
|
379
|
+
error.name = 'AbortError';
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
return { kind: 'foreground', ...canonicalShellResult(result) };
|
|
383
|
+
},
|
|
384
|
+
presentCall: presentShellCall,
|
|
385
|
+
presentResult: presentShellResult,
|
|
386
|
+
}));
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
//# sourceMappingURL=factory.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Git Bash (MSYS) tool instance: routes `request.shell` to the
|
|
3
|
+
* 'git-bash' backend so a selector executor can serve it beside pwsh and WSL.
|
|
4
|
+
* @module @deepseek-ai/dsh-tool-bash/git-bash
|
|
5
|
+
*/
|
|
6
|
+
import { defineShellTool } from "./factory.js";
|
|
7
|
+
/** The Git Bash tool: routes request.shell to the 'git-bash' backend. */
|
|
8
|
+
const tool = defineShellTool({ toolName: 'git_bash', shell: 'git-bash', dialect: 'msys' });
|
|
9
|
+
export const name = tool.name;
|
|
10
|
+
export const inject = tool.inject;
|
|
11
|
+
export const Config = tool.Config;
|
|
12
|
+
export const apply = tool.apply;
|
|
13
|
+
//# sourceMappingURL=git-bash.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The POSIX bash tool instance built by the shared shell-tool factory. Its
|
|
3
|
+
* tool contract is unchanged: no `shell` field, so a selector executor
|
|
4
|
+
* routes it to its configured default; single-backend compositions never
|
|
5
|
+
* notice the field.
|
|
6
|
+
* @module @deepseek-ai/dsh-tool-bash
|
|
7
|
+
*/
|
|
8
|
+
import { defineShellTool } from "./factory.js";
|
|
9
|
+
/** The POSIX bash tool (unchanged contract). */
|
|
10
|
+
const bashTool = defineShellTool({ toolName: 'bash', dialect: 'posix' });
|
|
11
|
+
export const name = bashTool.name;
|
|
12
|
+
export const inject = bashTool.inject;
|
|
13
|
+
export const Config = bashTool.Config;
|
|
14
|
+
export const apply = bashTool.apply;
|
|
15
|
+
export { defineShellTool } from "./factory.js";
|
|
16
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`.
|
|
3
|
+
* @module @deepseek-ai/dsh-tool-bash/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'tool-bash-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: the environment registry validates ownership and collected values at each
|
|
12
|
+
* mutation/read; it publishes no independent snapshot that a companion could cross-check.
|
|
13
|
+
*/
|
|
14
|
+
const install = () => { };
|
|
15
|
+
/**
|
|
16
|
+
* Register this package's invariant companion.
|
|
17
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
18
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
19
|
+
*/
|
|
20
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
21
|
+
/* jscpd:ignore-end */
|
|
22
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing result rendering for the bash tool.
|
|
3
|
+
*
|
|
4
|
+
* @module @deepseek-ai/dsh-tool-bash/render
|
|
5
|
+
*/
|
|
6
|
+
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox';
|
|
7
|
+
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
|
8
|
+
function streamText(output) {
|
|
9
|
+
if (!output.truncated)
|
|
10
|
+
return output.text;
|
|
11
|
+
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Shape one finished run into the text the model sees: stdout, then a marked
|
|
15
|
+
* stderr section, then exit-status markers. Non-zero exits are reported, not
|
|
16
|
+
* errored — the model decides how to react; only infrastructure failures
|
|
17
|
+
* (spawn errors, aborts) surface as isError results.
|
|
18
|
+
* @param result - the completed foreground run from the executor.
|
|
19
|
+
* @param escalationModes - the escalation targets this composition advertises;
|
|
20
|
+
* non-empty adds the same-turn escalation hint after a denial marker
|
|
21
|
+
* (default `[]`: no hint).
|
|
22
|
+
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
|
23
|
+
*/
|
|
24
|
+
export function renderResult(result, escalationModes = []) {
|
|
25
|
+
const out = streamText(result.stdout);
|
|
26
|
+
const err = streamText(result.stderr);
|
|
27
|
+
let body = out;
|
|
28
|
+
if (err.length > 0) {
|
|
29
|
+
// Single newline between sections (stdout usually ends with one already).
|
|
30
|
+
if (body.length > 0 && !body.endsWith('\n'))
|
|
31
|
+
body += '\n';
|
|
32
|
+
body += `[stderr]\n${err}`;
|
|
33
|
+
}
|
|
34
|
+
if (body.length === 0)
|
|
35
|
+
body = '(no output)';
|
|
36
|
+
const markers = [];
|
|
37
|
+
// Keep the exit marker last because parseExitStatus anchors there.
|
|
38
|
+
if (result.sandbox?.denied) {
|
|
39
|
+
markers.push(sandboxDenialMarker(result.sandbox.mode));
|
|
40
|
+
// Hint only when the composition exposes escalation, before the final exit marker.
|
|
41
|
+
if (escalationModes.length > 0) {
|
|
42
|
+
markers.push(escalationHintMarker('command'));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
|
|
46
|
+
if (result.timedOut)
|
|
47
|
+
markers.push(`[timed out after ${result.timeoutMs}ms]`);
|
|
48
|
+
if (result.signal !== null) {
|
|
49
|
+
markers.push(`[killed by signal: ${result.signal}]`);
|
|
50
|
+
}
|
|
51
|
+
else if (result.exitCode !== 0) {
|
|
52
|
+
markers.push(`[exit code: ${result.exitCode}]`);
|
|
53
|
+
}
|
|
54
|
+
if (markers.length === 0)
|
|
55
|
+
return body;
|
|
56
|
+
if (!body.endsWith('\n'))
|
|
57
|
+
body += '\n';
|
|
58
|
+
return body + markers.join('\n');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Shape one background-process read into the `job_output` delta the model
|
|
62
|
+
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
|
63
|
+
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
|
64
|
+
* rendering (`(no new output)`) is the generic job controller's job.
|
|
65
|
+
* @param read - one incremental read from the process handle.
|
|
66
|
+
* @param sandbox - settled sandbox facts, when this was a confined process.
|
|
67
|
+
* @param escalationModes - escalation targets advertised by this composition.
|
|
68
|
+
* @returns the delta text with any loss or sandbox notice appended.
|
|
69
|
+
*/
|
|
70
|
+
export function renderProcessRead(read, sandbox, escalationModes = []) {
|
|
71
|
+
const notices = [];
|
|
72
|
+
if (read.lossy) {
|
|
73
|
+
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined);
|
|
74
|
+
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`);
|
|
75
|
+
}
|
|
76
|
+
if (sandbox?.runnerFailed) {
|
|
77
|
+
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]`);
|
|
78
|
+
}
|
|
79
|
+
else if (sandbox?.denied) {
|
|
80
|
+
notices.push(sandboxDenialMarker(sandbox.mode));
|
|
81
|
+
if (escalationModes.length > 0) {
|
|
82
|
+
notices.push(escalationHintMarker('command'));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (notices.length === 0)
|
|
86
|
+
return read.delta;
|
|
87
|
+
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The exit-status parse is the shared marker-contract half of the shell-tool
|
|
91
|
+
* rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses
|
|
92
|
+
* it (its renderer emits the same markers). Re-exported here to keep
|
|
93
|
+
* `../src/render.ts` a single import root for bash-tool consumers.
|
|
94
|
+
*/
|
|
95
|
+
export { parseExitStatus } from '@deepseek-ai/dsh-shell';
|
|
96
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WSL bash tool instance: routes `request.shell` to the 'wsl-bash'
|
|
3
|
+
* backend so a selector executor can serve it beside pwsh and Git Bash.
|
|
4
|
+
* @module @deepseek-ai/dsh-tool-bash/wsl-bash
|
|
5
|
+
*/
|
|
6
|
+
import { defineShellTool } from "./factory.js";
|
|
7
|
+
/** The WSL bash tool: routes request.shell to the 'wsl-bash' backend. */
|
|
8
|
+
const tool = defineShellTool({ toolName: 'wsl_bash', shell: 'wsl-bash', dialect: 'wsl' });
|
|
9
|
+
export const name = tool.name;
|
|
10
|
+
export const inject = tool.inject;
|
|
11
|
+
export const Config = tool.Config;
|
|
12
|
+
export const apply = tool.apply;
|
|
13
|
+
//# sourceMappingURL=wsl-bash.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled bwrap profile helpers for the dsh-win-multi-bash plugin.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `@deepseek-ai/dsh-sandbox-local/profiles` (which the base
|
|
5
|
+
* runtime does NOT export as a subpath, and which pulls in the landlock
|
|
6
|
+
* native addon at top level). Only the bwrap parts the bundled wsl-bash
|
|
7
|
+
* executor needs are kept, self-contained.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-win-multi-bash/vendor/bwrap-profiles
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Structured runner-failure evidence for the bwrap runner (shared with in-distro consumers).
|
|
14
|
+
* Anchored to the line start: bwrap's own diagnostics always begin with
|
|
15
|
+
* `bwrap: `, so a wrapped command echoing the same text mid-line is not
|
|
16
|
+
* misread as a runner failure.
|
|
17
|
+
*/
|
|
18
|
+
export const BWRAP_RUNNER_FAILURE_RULES = [{ fatalSignatures: ['bwrap: '], anchored: true }];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the bwrap profile arguments for one file-effect policy.
|
|
22
|
+
* @param policy - file-effect policy to express as bwrap mounts.
|
|
23
|
+
* @returns profile arguments before the trailing separator and command argv.
|
|
24
|
+
*/
|
|
25
|
+
export function bwrapProfileArgs(policy) {
|
|
26
|
+
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'];
|
|
27
|
+
if (policy.mode === 'workspace-write') {
|
|
28
|
+
args.push('--tmpfs', '/tmp');
|
|
29
|
+
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot);
|
|
30
|
+
}
|
|
31
|
+
return args;
|
|
32
|
+
}
|