dsh-bash-terminal-ts 0.6.1 → 0.6.2
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/dist/client.core.js +141 -139
- package/dist/client.js +150 -148
- package/lib/client.js +150 -148
- package/lib/dsh-types.d.ts +259 -259
- package/lib/dsh.d.ts +35 -35
- package/lib/index.d.ts +200 -200
- package/lib/index.js +765 -765
- package/package.json +119 -115
- package/screenshots.json +5 -5
- package/src/client.tsx +192 -186
package/lib/index.js
CHANGED
|
@@ -1,765 +1,765 @@
|
|
|
1
|
-
// dsh-bash-terminal - one shell tool, four Windows terminals.
|
|
2
|
-
//
|
|
3
|
-
// Registers a model-facing shell tool. The terminal backend (powershell /
|
|
4
|
-
// gitbash / msys2 / wsl) is chosen by the USER in the Web UI settings (default
|
|
5
|
-
// terminal); the model cannot pick it — the tool always obeys the user's
|
|
6
|
-
// choice:
|
|
7
|
-
// - powershell: pwsh -NoLogo -NoProfile -NonInteractive -Command <cmd>
|
|
8
|
-
// - gitbash: Git for Windows bash -lc <cmd> (POSIX; /d/... paths)
|
|
9
|
-
// - msys2: MSYS2 bash -lc <cmd> (POSIX; /c/... paths, full GCC/mingw64 toolchain)
|
|
10
|
-
// - wsl: wsl [-d <distro>] -e bash -lc <cmd> (Linux; /mnt/d/... paths)
|
|
11
|
-
//
|
|
12
|
-
// The tool spawns through the shared ctx.subprocess seam (process-tree
|
|
13
|
-
// termination, SIGTERM->grace->SIGKILL, spill files) and registers background
|
|
14
|
-
// handles with the generic ctx.jobs registry, mirroring the shipped
|
|
15
|
-
// dsh-tool-bash / dsh-tool-pwsh story call-for-call. It deliberately
|
|
16
|
-
// does NOT consume the ctx.shell capability seam: the platform's own
|
|
17
|
-
// sandboxed PowerShell executor keeps serving the pwsh tool. This tool is
|
|
18
|
-
// an additional, user-selected terminal that still honors the DSH sandbox:
|
|
19
|
-
// confined modes wrap PowerShell's spawn argv through ctx.sandbox
|
|
20
|
-
// (fail-closed), while danger-full-access, Git Bash, MSYS2, and WSL run
|
|
21
|
-
// unconfined. Git Bash and MSYS2 are unconfined because the DSH Windows ACL
|
|
22
|
-
// restricted-token runner cannot host Cygwin/MSYS2: bash aborts with
|
|
23
|
-
// "CreateFileMapping ... Win32 error 5" during startup.
|
|
24
|
-
import { lstatSync } from "node:fs";
|
|
25
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
26
|
-
import { ESCALATION_TARGETS, TOOL_ABORTED, approveEscalation, defineTool, escalationHintMarker, clampTimeout, deadline, parseExitStatus, timeoutOf, sandboxDenialMarker, validateEscalationArgs, z, HarnessError } from "./dsh.js";
|
|
27
|
-
import { createTerminalRegistry, terminalTool } from "./terminal.js";
|
|
28
|
-
/** Stable Cordis plugin name. */
|
|
29
|
-
export const name = "bash-terminal";
|
|
30
|
-
/** Services required before the tool can register. */
|
|
31
|
-
export const inject = ["tools", "systemPrompt", "shellEnv", "subprocess", "settings", "sandbox", "sandboxPolicy"];
|
|
32
|
-
/** The terminal backends this tool exposes, in catalog order. */
|
|
33
|
-
export const SHELLS = ["powershell", "gitbash", "msys2", "wsl"];
|
|
34
|
-
/** The backend used when the caller does not name one. */
|
|
35
|
-
export const DEFAULT_SHELL = "powershell";
|
|
36
|
-
/** Default per-command timeout (ms). */
|
|
37
|
-
const DEFAULT_TIMEOUT_MS = 120000;
|
|
38
|
-
/** Upper bound a caller's timeoutMs is capped to. */
|
|
39
|
-
const MAX_TIMEOUT_MS = 600000;
|
|
40
|
-
/** SIGTERM->SIGKILL grace (ms), matching dsh-bash-local's default. */
|
|
41
|
-
const DEFAULT_GRACE_MS = 3000;
|
|
42
|
-
/** Per-stream in-memory cap before spilling (bytes). */
|
|
43
|
-
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
44
|
-
/** Per-stream spill file cap (bytes). */
|
|
45
|
-
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024;
|
|
46
|
-
/** Timeout code stamped on the deadline's TimeoutReason. */
|
|
47
|
-
const TIMEOUT_CODE = "SHELL_TIMEOUT";
|
|
48
|
-
/** Model-friendly environment overrides (same set dsh-bash-local hardcodes). */
|
|
49
|
-
const ENV_OVERRIDES = {
|
|
50
|
-
NO_COLOR: "1",
|
|
51
|
-
TERM: "dumb",
|
|
52
|
-
PAGER: "cat",
|
|
53
|
-
GIT_PAGER: "cat"
|
|
54
|
-
};
|
|
55
|
-
/** Runtime configuration schema. */
|
|
56
|
-
export const Config = z.object({
|
|
57
|
-
defaultShell: z.string().default(DEFAULT_SHELL).volatile(),
|
|
58
|
-
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
|
|
59
|
-
maxTimeoutMs: z.number().default(MAX_TIMEOUT_MS),
|
|
60
|
-
pwshPath: z.string().default(""),
|
|
61
|
-
gitBashPath: z.string().default(""),
|
|
62
|
-
msys2Path: z.string().default(""),
|
|
63
|
-
wslPath: z.string().default("")
|
|
64
|
-
});
|
|
65
|
-
// ---- executable resolution ------------------------------------------------
|
|
66
|
-
function candidateExists(candidate) {
|
|
67
|
-
try {
|
|
68
|
-
const stat = lstatSync(candidate);
|
|
69
|
-
return stat.isFile() || stat.isSymbolicLink();
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function resolveFromCandidates(candidates) {
|
|
76
|
-
for (const candidate of candidates) {
|
|
77
|
-
if (candidateExists(candidate))
|
|
78
|
-
return candidate;
|
|
79
|
-
}
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
/** Well-known PowerShell install locations plus PATH entries, newest first. */
|
|
83
|
-
export function candidatePwshPaths(env = process.env) {
|
|
84
|
-
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
85
|
-
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
86
|
-
const candidates = [join(programFiles, "PowerShell", "7", "pwsh.exe")];
|
|
87
|
-
for (const entry of (env.PATH ?? "").split(";")) {
|
|
88
|
-
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
89
|
-
if (trimmed.length === 0)
|
|
90
|
-
continue;
|
|
91
|
-
candidates.push(join(trimmed, "pwsh.exe"));
|
|
92
|
-
}
|
|
93
|
-
candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
|
|
94
|
-
return candidates;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Git for Windows locations, then PATH bash.exe entries EXCLUDING the
|
|
98
|
-
* System32 launcher (c:\\windows\\system32\\bash.exe is the WSL
|
|
99
|
-
* forwarder, not a Git Bash shell).
|
|
100
|
-
*/
|
|
101
|
-
export function candidateGitBashPaths(env = process.env) {
|
|
102
|
-
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
103
|
-
const systemRoot = (env.SystemRoot ?? "C:\\Windows").toLowerCase();
|
|
104
|
-
const localAppData = env.LOCALAPPDATA ?? "";
|
|
105
|
-
const candidates = [
|
|
106
|
-
join(programFiles, "Git", "bin", "bash.exe"),
|
|
107
|
-
join(programFiles, "Git", "usr", "bin", "bash.exe")
|
|
108
|
-
];
|
|
109
|
-
if (localAppData.length > 0)
|
|
110
|
-
candidates.push(join(localAppData, "Programs", "Git", "bin", "bash.exe"));
|
|
111
|
-
for (const entry of (env.PATH ?? "").split(";")) {
|
|
112
|
-
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
113
|
-
if (trimmed.length === 0)
|
|
114
|
-
continue;
|
|
115
|
-
if (trimmed.toLowerCase().includes(systemRoot))
|
|
116
|
-
continue;
|
|
117
|
-
candidates.push(join(trimmed, "bash.exe"));
|
|
118
|
-
}
|
|
119
|
-
return candidates;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
|
|
123
|
-
* first, then bin\bash.exe, and `msys2.exe` dead last.
|
|
124
|
-
*
|
|
125
|
-
* msys2.exe is NOT a usable backend for piped execution: it is the console-
|
|
126
|
-
* allocating Cygwin launcher, so a spawn with piped stdio returns exit 0 with
|
|
127
|
-
* zero bytes on both stdout and stderr (measured on this machine against
|
|
128
|
-
* MSYS2 with bash 5.3.15). Keeping it in the list only as a last-resort
|
|
129
|
-
* fallback preserves the path the config docs reference, but a working
|
|
130
|
-
* bash.exe always wins.
|
|
131
|
-
*
|
|
132
|
-
* MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run
|
|
133
|
-
* under the DSH Windows ACL restricted-token sandbox.
|
|
134
|
-
*/
|
|
135
|
-
export function candidateMsys2Paths(env = process.env) {
|
|
136
|
-
const candidates = [
|
|
137
|
-
"C:\\msys64\\usr\\bin\\bash.exe",
|
|
138
|
-
"C:\\msys64\\bin\\bash.exe"
|
|
139
|
-
];
|
|
140
|
-
for (const entry of (env.PATH ?? "").split(";")) {
|
|
141
|
-
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
142
|
-
if (trimmed.length === 0)
|
|
143
|
-
continue;
|
|
144
|
-
const lower = trimmed.toLowerCase();
|
|
145
|
-
if (lower.includes("msys64") || lower.includes("mingw64")) {
|
|
146
|
-
candidates.push(join(trimmed, "bash.exe"));
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
candidates.push("C:\\msys64\\msys2.exe"); // last resort: see the note above
|
|
150
|
-
return candidates;
|
|
151
|
-
}
|
|
152
|
-
export function defaultWslPath(env = process.env) {
|
|
153
|
-
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
154
|
-
return join(systemRoot, "System32", "wsl.exe");
|
|
155
|
-
}
|
|
156
|
-
export function resolveAllPaths(config = {}, env = process.env) {
|
|
157
|
-
const pwsh = config.pwshPath && config.pwshPath.trim().length > 0
|
|
158
|
-
? config.pwshPath
|
|
159
|
-
: resolveFromCandidates(candidatePwshPaths(env));
|
|
160
|
-
const gitbash = config.gitBashPath && config.gitBashPath.trim().length > 0
|
|
161
|
-
? config.gitBashPath
|
|
162
|
-
: resolveFromCandidates(candidateGitBashPaths(env));
|
|
163
|
-
const msys2 = config.msys2Path && config.msys2Path.trim().length > 0
|
|
164
|
-
? config.msys2Path
|
|
165
|
-
: resolveFromCandidates(candidateMsys2Paths(env));
|
|
166
|
-
const wsl = config.wslPath && config.wslPath.trim().length > 0
|
|
167
|
-
? config.wslPath
|
|
168
|
-
: defaultWslPath(env);
|
|
169
|
-
return { pwsh, gitbash, msys2, wsl };
|
|
170
|
-
}
|
|
171
|
-
// ---- argv / env construction ------------------------------------------------
|
|
172
|
-
export function buildArgv(shell, command, paths, distro) {
|
|
173
|
-
switch (shell) {
|
|
174
|
-
case "powershell":
|
|
175
|
-
return [paths.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command];
|
|
176
|
-
case "gitbash":
|
|
177
|
-
return [paths.gitbash, "-lc", command];
|
|
178
|
-
case "msys2":
|
|
179
|
-
// -lc, not -c: a login shell sources /etc/profile, which is what puts
|
|
180
|
-
// /usr/bin and /mingw64/bin on PATH. With a bare -c, `tr`, `sed`, `gcc`
|
|
181
|
-
// and friends are all "command not found".
|
|
182
|
-
return [paths.msys2, "-lc", command];
|
|
183
|
-
case "wsl": {
|
|
184
|
-
const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
|
|
185
|
-
return [paths.wsl, ...distroArg, "-e", "bash", "-lc", command];
|
|
186
|
-
}
|
|
187
|
-
default:
|
|
188
|
-
throw new Error(`invalid shell: ${JSON.stringify(shell)} (expected one of ${SHELLS.join(", ")})`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Merge the DSH_* environment over the process environment. For WSL, only
|
|
193
|
-
* variables explicitly listed in WSLENV cross the boundary, so every DSH_*
|
|
194
|
-
* key is appended there — and the list is *layered onto* whatever WSLENV the
|
|
195
|
-
* host already had (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`),
|
|
196
|
-
* never rebuilt from scratch: WSLENV is an allow-list, so dropping the
|
|
197
|
-
* inherited entries would silently stop them crossing into WSL.
|
|
198
|
-
*
|
|
199
|
-
* @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
|
|
200
|
-
* replace the child environment wholesale (the PTY path) must pass
|
|
201
|
-
* `process.env.WSLENV` explicitly, because the ambient value is not visible
|
|
202
|
-
* through `dshEnv`.
|
|
203
|
-
*/
|
|
204
|
-
export function buildEnv(shell, dshEnv, inheritedWslenv = process.env.WSLENV) {
|
|
205
|
-
const env = { ...ENV_OVERRIDES, ...dshEnv };
|
|
206
|
-
if (shell === "msys2") {
|
|
207
|
-
// Picks the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
|
|
208
|
-
// PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
|
|
209
|
-
// environment. An explicit caller-supplied value wins.
|
|
210
|
-
if (env.MSYSTEM === undefined)
|
|
211
|
-
env.MSYSTEM = "MINGW64";
|
|
212
|
-
}
|
|
213
|
-
if (shell === "wsl") {
|
|
214
|
-
const keys = Object.keys(dshEnv ?? {});
|
|
215
|
-
if (keys.length > 0) {
|
|
216
|
-
// An explicit WSLENV in dshEnv wins over the inherited one; otherwise the
|
|
217
|
-
// inherited value is the base. Either way the base is SPLIT into entries
|
|
218
|
-
// (the host value ends with a trailing ":", so string concatenation would
|
|
219
|
-
// produce a malformed empty entry) and the WSLENV key itself is excluded
|
|
220
|
-
// (it is a key of dshEnv too, and appending it would add a bogus entry).
|
|
221
|
-
const declared = Object.prototype.hasOwnProperty.call(dshEnv ?? {}, "WSLENV");
|
|
222
|
-
const base = declared
|
|
223
|
-
? env.WSLENV
|
|
224
|
-
: (typeof inheritedWslenv === "string" && inheritedWslenv.length > 0 ? inheritedWslenv : env.WSLENV);
|
|
225
|
-
const parts = typeof base === "string" ? base.split(":") : [];
|
|
226
|
-
env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
|
|
227
|
-
.map((p) => p.trim())
|
|
228
|
-
.filter((p) => p.length > 0)
|
|
229
|
-
.join(":");
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return env;
|
|
233
|
-
}
|
|
234
|
-
function spawnSpec(resolved, argv, env, signal) {
|
|
235
|
-
const collect = (maxBytes) => ({ maxBytes, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } });
|
|
236
|
-
return {
|
|
237
|
-
argv,
|
|
238
|
-
cwd: resolved.workdir,
|
|
239
|
-
stdio: {
|
|
240
|
-
stdin: resolved.stdin !== undefined ? { data: resolved.stdin } : "ignore",
|
|
241
|
-
stdout: collect(resolved.stdoutMaxBytes),
|
|
242
|
-
stderr: collect(DEFAULT_MAX_OUTPUT_BYTES)
|
|
243
|
-
},
|
|
244
|
-
graceMs: DEFAULT_GRACE_MS,
|
|
245
|
-
signal,
|
|
246
|
-
env
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
function collectedOutput(handle) {
|
|
250
|
-
const { stdout, stderr } = handle.collected;
|
|
251
|
-
if (!isCollectedStream(stdout) || !isCollectedStream(stderr)) {
|
|
252
|
-
throw new Error("dsh-bash-terminal: subprocess implementation dropped a requested collect stream");
|
|
253
|
-
}
|
|
254
|
-
return { stdout, stderr };
|
|
255
|
-
}
|
|
256
|
-
function isCollectedStream(value) {
|
|
257
|
-
return typeof value === "object" && value !== null && typeof value.readFrom === "function";
|
|
258
|
-
}
|
|
259
|
-
function finalOutput(reader) {
|
|
260
|
-
const read = reader.readFrom(0);
|
|
261
|
-
return {
|
|
262
|
-
text: read.text,
|
|
263
|
-
truncated: read.lossy,
|
|
264
|
-
...(read.spillPath !== undefined ? { spillPath: read.spillPath } : {})
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
async function runForeground(ctx, argv, resolved, env, signal, timeoutMs) {
|
|
268
|
-
const subprocess = requireSubprocess(ctx);
|
|
269
|
-
const d = deadline(signal, timeoutMs, TIMEOUT_CODE);
|
|
270
|
-
try {
|
|
271
|
-
const handle = subprocess.spawn(spawnSpec(resolved, argv, env, d.signal));
|
|
272
|
-
const outcome = await handle.done;
|
|
273
|
-
const collected = collectedOutput(handle);
|
|
274
|
-
const timedOut = timeoutOf(d.signal, TIMEOUT_CODE) !== undefined;
|
|
275
|
-
const aborted = d.signal.aborted && !timedOut;
|
|
276
|
-
return {
|
|
277
|
-
exitCode: outcome.exitCode,
|
|
278
|
-
signal: outcome.signal,
|
|
279
|
-
timedOut,
|
|
280
|
-
aborted,
|
|
281
|
-
timeoutMs,
|
|
282
|
-
stdout: finalOutput(collected.stdout),
|
|
283
|
-
stderr: finalOutput(collected.stderr)
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
finally {
|
|
287
|
-
d[Symbol.dispose]();
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
function requireSubprocess(ctx) {
|
|
291
|
-
const subprocess = ctx.subprocess;
|
|
292
|
-
if (subprocess === null) {
|
|
293
|
-
throw new Error("dsh-bash-terminal: ctx.subprocess seam unavailable (missing inject service)");
|
|
294
|
-
}
|
|
295
|
-
return subprocess;
|
|
296
|
-
}
|
|
297
|
-
function startBackground(ctx, argv, resolved, env, signal) {
|
|
298
|
-
const subprocess = requireSubprocess(ctx);
|
|
299
|
-
const running = subprocess.spawn(spawnSpec(resolved, argv, env, signal));
|
|
300
|
-
const collected = collectedOutput(running);
|
|
301
|
-
let stdoutOffset = 0;
|
|
302
|
-
let stderrOffset = 0;
|
|
303
|
-
let spawnFailureNote;
|
|
304
|
-
const consumeSpawnFailure = () => {
|
|
305
|
-
const note = spawnFailureNote ?? "";
|
|
306
|
-
spawnFailureNote = undefined;
|
|
307
|
-
return note;
|
|
308
|
-
};
|
|
309
|
-
const proc = {
|
|
310
|
-
status: "running",
|
|
311
|
-
exitCode: null,
|
|
312
|
-
signal: null,
|
|
313
|
-
done: running.done.then((outcome) => {
|
|
314
|
-
if (proc.status === "running") {
|
|
315
|
-
proc.status = signal?.aborted === true || outcome.signal !== null ? "killed" : "completed";
|
|
316
|
-
}
|
|
317
|
-
proc.exitCode = outcome.exitCode;
|
|
318
|
-
proc.signal = outcome.signal;
|
|
319
|
-
}, (error) => {
|
|
320
|
-
proc.status = "killed";
|
|
321
|
-
spawnFailureNote = `spawn failed: ${String(error)}`;
|
|
322
|
-
}),
|
|
323
|
-
readOutput: () => {
|
|
324
|
-
const out = collected.stdout.readFrom(stdoutOffset);
|
|
325
|
-
const err = collected.stderr.readFrom(stderrOffset);
|
|
326
|
-
stdoutOffset = out.nextOffset;
|
|
327
|
-
stderrOffset = err.nextOffset;
|
|
328
|
-
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure();
|
|
329
|
-
const separator = out.text.length > 0 && !out.text.endsWith("\n") ? "\n" : "";
|
|
330
|
-
return {
|
|
331
|
-
delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ""),
|
|
332
|
-
lossy: out.lossy || err.lossy,
|
|
333
|
-
...(out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}),
|
|
334
|
-
...(err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {})
|
|
335
|
-
};
|
|
336
|
-
},
|
|
337
|
-
kill: () => {
|
|
338
|
-
if (proc.status !== "running")
|
|
339
|
-
return false;
|
|
340
|
-
proc.status = "killed";
|
|
341
|
-
running.terminate();
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
};
|
|
345
|
-
return proc;
|
|
346
|
-
}
|
|
347
|
-
function processOutcome(proc) {
|
|
348
|
-
if (proc.status === "killed") {
|
|
349
|
-
return { status: "killed", detail: proc.signal !== null ? `signal: ${proc.signal}` : "killed before exit" };
|
|
350
|
-
}
|
|
351
|
-
return { status: "completed", detail: `exit code: ${proc.exitCode ?? 0}` };
|
|
352
|
-
}
|
|
353
|
-
// ---- model-facing rendering ---------------------------------------------------
|
|
354
|
-
function streamText(output) {
|
|
355
|
-
if (!output.truncated)
|
|
356
|
-
return output.text;
|
|
357
|
-
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? "(unavailable)"}]`;
|
|
358
|
-
}
|
|
359
|
-
export function renderResult(result) {
|
|
360
|
-
const out = streamText(result.stdout);
|
|
361
|
-
const err = streamText(result.stderr);
|
|
362
|
-
let body = out;
|
|
363
|
-
if (err.length > 0) {
|
|
364
|
-
if (body.length > 0 && !body.endsWith("\n"))
|
|
365
|
-
body += "\n";
|
|
366
|
-
body += `[stderr]\n${err}`;
|
|
367
|
-
}
|
|
368
|
-
if (body.length === 0)
|
|
369
|
-
body = "(no output)";
|
|
370
|
-
const markers = [];
|
|
371
|
-
if (result.timedOut)
|
|
372
|
-
markers.push(`[timed out after ${result.timeoutMs}ms]`);
|
|
373
|
-
if (result.signal !== null)
|
|
374
|
-
markers.push(`[killed by signal: ${result.signal}]`);
|
|
375
|
-
else if (result.exitCode !== 0)
|
|
376
|
-
markers.push(`[exit code: ${result.exitCode}]`);
|
|
377
|
-
if (markers.length === 0)
|
|
378
|
-
return body;
|
|
379
|
-
if (!body.endsWith("\n"))
|
|
380
|
-
body += "\n";
|
|
381
|
-
return body + markers.join("\n");
|
|
382
|
-
}
|
|
383
|
-
function renderProcessRead(read) {
|
|
384
|
-
const notices = [];
|
|
385
|
-
if (read.lossy) {
|
|
386
|
-
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p) => p !== undefined);
|
|
387
|
-
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(", ") : "(unavailable)"}]`);
|
|
388
|
-
}
|
|
389
|
-
if (notices.length === 0)
|
|
390
|
-
return read.delta;
|
|
391
|
-
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith("\n") ? "\n" : ""}${notices.join("\n")}`;
|
|
392
|
-
}
|
|
393
|
-
// ---- tool description / validation --------------------------------------------
|
|
394
|
-
/** Model-facing lead sentence for each backend. The user's default terminal is
|
|
395
|
-
* stated up front so the model never has to guess which syntax applies. */
|
|
396
|
-
export const SHELL_DESCRIPTIONS = {
|
|
397
|
-
powershell: "Execute a PowerShell command (pwsh -NoLogo -NoProfile -NonInteractive -Command <command>) and return its stdout/stderr. PowerShell syntax; native Windows paths (C:\\...); environment variables via $env:NAME.",
|
|
398
|
-
gitbash: "Execute a bash command (Git for Windows bash -lc <command>) and return its stdout/stderr. POSIX syntax; paths like /d/WorkSpace; PATH includes /usr/bin and /mingw64/bin so git, npm, ssh etc. work; environment variables via $NAME.",
|
|
399
|
-
msys2: "Execute a bash command (MSYS2 bash -lc <command>) and return its stdout/stderr. POSIX syntax; paths like /c/...; PATH includes /usr/bin and /mingw64/bin so git, npm, gcc, make etc. work; environment variables via $NAME. MSYS2 provides a full GCC/mingw64 toolchain.",
|
|
400
|
-
wsl: "Execute a Linux bash command (wsl [-d <distro>] -e bash -lc <command>) and return its stdout/stderr. Linux syntax; Windows files under /mnt/d/...; environment variables via $NAME."
|
|
401
|
-
};
|
|
402
|
-
/**
|
|
403
|
-
* Render the model-facing tool description for one backend. The backend is
|
|
404
|
-
* whatever the user chose in Settings -> General -> Default terminal; the
|
|
405
|
-
* description names it explicitly so the model writes the right syntax.
|
|
406
|
-
* @param backgroundEnabled - advertise `run_in_background` controls.
|
|
407
|
-
* @param shell - active backend; unknown values fall back to the default.
|
|
408
|
-
*/
|
|
409
|
-
export function toolDescription(backgroundEnabled, shell = DEFAULT_SHELL) {
|
|
410
|
-
const active = SHELLS.includes(shell) ? shell : DEFAULT_SHELL;
|
|
411
|
-
const lead = SHELL_DESCRIPTIONS[active];
|
|
412
|
-
const base = [
|
|
413
|
-
`The user's chosen default terminal (Settings -> General -> Default terminal) is ${active}; commands run there.`,
|
|
414
|
-
lead,
|
|
415
|
-
"Each call spawns a fresh shell: no state (cwd, variables, aliases) persists between calls - pass workdir instead of using cd. Non-zero exits are reported as [exit code: N] markers; investigate failures before moving on. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands run under the DSH sandbox: confined modes (read-only / workspace-write) are enforced through ctx.sandbox and deny fail-closed for PowerShell; danger-full-access, Git Bash, MSYS2, and WSL run unconfined."
|
|
416
|
-
].join(" ");
|
|
417
|
-
if (!backgroundEnabled)
|
|
418
|
-
return base;
|
|
419
|
-
return base + " 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 to background runs.";
|
|
420
|
-
}
|
|
421
|
-
export function validateArgs(args) {
|
|
422
|
-
if (typeof args.command !== "string" || args.command.trim().length === 0) {
|
|
423
|
-
throw new Error("invalid command: expected a non-empty string");
|
|
424
|
-
}
|
|
425
|
-
if (typeof args.description !== "string" || args.description.trim().length === 0) {
|
|
426
|
-
throw new Error("invalid description: expected a non-empty string");
|
|
427
|
-
}
|
|
428
|
-
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
429
|
-
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`);
|
|
430
|
-
}
|
|
431
|
-
validateEscalationArgs(args.sandbox_permissions, args.justification);
|
|
432
|
-
return args;
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Official sandbox seam (mirrors dsh-tool-bash / dsh-pwsh-sandbox): resolve the
|
|
436
|
-
* per-call policy from ctx.sandboxPolicy, and — unless the call runs
|
|
437
|
-
* danger-full-access — confine the spawn argv through ctx.sandbox. WSL is a
|
|
438
|
-
* self-contained Linux VM and is not confined (its isolation IS the sandbox).
|
|
439
|
-
* Git Bash and MSYS2 are also not confined: DSH's Windows ACL restricted-token
|
|
440
|
-
* runner cannot start Cygwin/MSYS2 (CreateFileMapping Win32 error 5), so
|
|
441
|
-
* attempting to wrap them would abort every Git Bash / MSYS2 command. A
|
|
442
|
-
* requested confined mode
|
|
443
|
-
* with no usable backend throws the fail-closed SandboxUnavailableError,
|
|
444
|
-
* exactly like the shipped executors.
|
|
445
|
-
*/
|
|
446
|
-
function confineSpawn(ctx, argv, policy, shell) {
|
|
447
|
-
if (policy.mode === "danger-full-access" || shell === "wsl" || shell === "gitbash" || shell === "msys2") {
|
|
448
|
-
const sandbox = shell === "wsl"
|
|
449
|
-
? { mode: policy.mode, enforcement: "wsl-isolation" }
|
|
450
|
-
: shell === "gitbash"
|
|
451
|
-
? { mode: policy.mode, enforcement: "gitbash-unconfined" }
|
|
452
|
-
: shell === "msys2"
|
|
453
|
-
? { mode: policy.mode, enforcement: "msys2-unconfined" }
|
|
454
|
-
: undefined;
|
|
455
|
-
return { argv, sandbox };
|
|
456
|
-
}
|
|
457
|
-
const confined = ctx.sandbox.confine(argv, policy);
|
|
458
|
-
return {
|
|
459
|
-
argv: confined.argv,
|
|
460
|
-
sandbox: { mode: policy.mode, enforcement: confined.enforcement, denialSignatures: confined.denialSignatures }
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
function resolveWorkdir(modelWorkdir, exec) {
|
|
464
|
-
const headerCwd = exec.agent?.session.header.cwd;
|
|
465
|
-
if (modelWorkdir === undefined)
|
|
466
|
-
return headerCwd;
|
|
467
|
-
if (headerCwd !== undefined && !isAbsolute(modelWorkdir))
|
|
468
|
-
return resolve(headerCwd, modelWorkdir);
|
|
469
|
-
return modelWorkdir;
|
|
470
|
-
}
|
|
471
|
-
function canonicalResult(result) {
|
|
472
|
-
const output = (stream) => ({
|
|
473
|
-
text: stream.text,
|
|
474
|
-
truncated: stream.truncated,
|
|
475
|
-
...(stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {})
|
|
476
|
-
});
|
|
477
|
-
return {
|
|
478
|
-
kind: "foreground",
|
|
479
|
-
exitCode: result.exitCode,
|
|
480
|
-
signal: result.signal,
|
|
481
|
-
timedOut: result.timedOut,
|
|
482
|
-
aborted: result.aborted,
|
|
483
|
-
timeoutMs: result.timeoutMs,
|
|
484
|
-
stdout: output(result.stdout),
|
|
485
|
-
stderr: output(result.stderr),
|
|
486
|
-
...(result.sandbox !== undefined ? { sandbox: result.sandbox } : {})
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
490
|
-
kind: { type: "string", required: true, const: "background" },
|
|
491
|
-
jobId: { type: "string", required: true }
|
|
492
|
-
};
|
|
493
|
-
// ---- plugin -------------------------------------------------------------------
|
|
494
|
-
export function apply(ctx, config = {}) {
|
|
495
|
-
if (process.platform !== "win32") {
|
|
496
|
-
ctx.logger?.info?.("dsh-bash-terminal: only meaningful on win32; skipping tool registration");
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
const backgroundEnabled = true;
|
|
500
|
-
const paths = resolveAllPaths(config);
|
|
501
|
-
// 0.1.7: `defaultShell` is a `.volatile()` config field — the loader hands
|
|
502
|
-
// `apply` a live ref and the latest value is resolved on every read; there
|
|
503
|
-
// is no settings registration call anymore (removed with the 0.1.7 host).
|
|
504
|
-
const currentShell = () => {
|
|
505
|
-
const v = config.defaultShell;
|
|
506
|
-
if (typeof v === "string")
|
|
507
|
-
return v;
|
|
508
|
-
const got = typeof v?.get === "function" ? v.get() : undefined;
|
|
509
|
-
return typeof got === "string" ? got : DEFAULT_SHELL;
|
|
510
|
-
};
|
|
511
|
-
if (!SHELLS.includes(currentShell())) {
|
|
512
|
-
throw new Error(`dsh-bash-terminal: invalid defaultShell ${JSON.stringify(currentShell())}`);
|
|
513
|
-
}
|
|
514
|
-
/** Official sandbox-escalation surface (mirrors tool-bash): advertise the
|
|
515
|
-
* escalation modes whenever the deployment confines. */
|
|
516
|
-
const escalationModes = ESCALATION_TARGETS;
|
|
517
|
-
const approveShellEscalation = (mode, justification, exec, standingPolicy) => {
|
|
518
|
-
return approveEscalation({
|
|
519
|
-
requestedMode: mode,
|
|
520
|
-
justification,
|
|
521
|
-
effectiveMode: standingPolicy.mode,
|
|
522
|
-
subject: "command"
|
|
523
|
-
}, {
|
|
524
|
-
approver: ctx.get("approval"),
|
|
525
|
-
agent: exec.agent,
|
|
526
|
-
callId: exec.callId,
|
|
527
|
-
toolName: "shell",
|
|
528
|
-
signal: exec.signal
|
|
529
|
-
});
|
|
530
|
-
};
|
|
531
|
-
ctx.systemPrompt.section({
|
|
532
|
-
name: "tool:bash-terminal",
|
|
533
|
-
order: 105,
|
|
534
|
-
text: "Use the shell tool for terminal commands: it runs in the terminal the user chose in Settings -> General -> Default terminal (PowerShell, Git Bash, MSYS2, or WSL) and honors the DSH sandbox. Prefer it over the pwsh tool for everyday commands; keep the pwsh tool for cases that specifically need the sandboxed PowerShell surface."
|
|
535
|
-
});
|
|
536
|
-
const toolName = "shell";
|
|
537
|
-
const terminalRegistry = createTerminalRegistry(ctx);
|
|
538
|
-
ctx.tools.register(terminalTool(ctx, terminalRegistry, paths, () => currentShell()));
|
|
539
|
-
// The model-facing description must track the user's chosen default
|
|
540
|
-
// terminal: a static description listing every backend leaves the model
|
|
541
|
-
// guessing which syntax applies. Re-render it on every prompt assembly —
|
|
542
|
-
// settings are hot-reloaded, so a change shows up on the next request
|
|
543
|
-
// without a restart.
|
|
544
|
-
ctx.on("system-prompt/assemble", async (_assembly, _context, next) => {
|
|
545
|
-
const assembled = await next();
|
|
546
|
-
const description = toolDescription(backgroundEnabled, currentShell());
|
|
547
|
-
const tools = Array.isArray(assembled.tools)
|
|
548
|
-
? assembled.tools.map((tool) => (tool.name === toolName ? { ...tool, description } : tool))
|
|
549
|
-
: assembled.tools;
|
|
550
|
-
return { ...assembled, tools };
|
|
551
|
-
});
|
|
552
|
-
ctx.tools.register(defineTool({
|
|
553
|
-
name: toolName,
|
|
554
|
-
description: toolDescription(backgroundEnabled, currentShell()),
|
|
555
|
-
parameters: {
|
|
556
|
-
command: {
|
|
557
|
-
type: "string",
|
|
558
|
-
required: true,
|
|
559
|
-
description: "The command to execute in the selected terminal."
|
|
560
|
-
},
|
|
561
|
-
description: {
|
|
562
|
-
type: "string",
|
|
563
|
-
required: true,
|
|
564
|
-
description: "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" -> \"List files in current directory\"; \"git status\" -> \"Show working tree status\"; \"npm install\" -> \"Install package dependencies\"."
|
|
565
|
-
},
|
|
566
|
-
distro: {
|
|
567
|
-
type: "string",
|
|
568
|
-
description: "WSL distribution to use (only when the configured default terminal is wsl). Defaults to the system default distribution."
|
|
569
|
-
},
|
|
570
|
-
sandbox_permissions: {
|
|
571
|
-
type: "string",
|
|
572
|
-
enum: escalationModes,
|
|
573
|
-
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."
|
|
574
|
-
},
|
|
575
|
-
justification: {
|
|
576
|
-
type: "string",
|
|
577
|
-
description: "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
|
|
578
|
-
},
|
|
579
|
-
workdir: {
|
|
580
|
-
type: "string",
|
|
581
|
-
description: "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
|
|
582
|
-
},
|
|
583
|
-
timeoutMs: {
|
|
584
|
-
type: "number",
|
|
585
|
-
description: "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
|
|
586
|
-
},
|
|
587
|
-
run_in_background: {
|
|
588
|
-
type: "boolean",
|
|
589
|
-
description: "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
|
|
590
|
-
}
|
|
591
|
-
},
|
|
592
|
-
output: {
|
|
593
|
-
schema: {
|
|
594
|
-
oneOf: [
|
|
595
|
-
{ type: "object", additionalProperties: false, properties: BACKGROUND_OUTPUT_PROPERTIES },
|
|
596
|
-
{
|
|
597
|
-
type: "object",
|
|
598
|
-
additionalProperties: false,
|
|
599
|
-
properties: {
|
|
600
|
-
kind: { type: "string", required: true, const: "foreground" },
|
|
601
|
-
exitCode: { required: true, oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
602
|
-
signal: { required: true, oneOf: [{ type: "string" }, { type: "null" }] },
|
|
603
|
-
timedOut: { type: "boolean", required: true },
|
|
604
|
-
aborted: { type: "boolean", required: true },
|
|
605
|
-
timeoutMs: { type: "number", required: true },
|
|
606
|
-
sandbox: {
|
|
607
|
-
type: "object",
|
|
608
|
-
additionalProperties: false,
|
|
609
|
-
properties: {
|
|
610
|
-
mode: { type: "string", required: true },
|
|
611
|
-
enforcement: { type: "string", required: true },
|
|
612
|
-
denied: { type: "boolean" }
|
|
613
|
-
}
|
|
614
|
-
},
|
|
615
|
-
stdout: {
|
|
616
|
-
type: "object",
|
|
617
|
-
additionalProperties: false,
|
|
618
|
-
required: true,
|
|
619
|
-
properties: {
|
|
620
|
-
text: { type: "string", required: true },
|
|
621
|
-
truncated: { type: "boolean", required: true },
|
|
622
|
-
spillPath: { type: "string" }
|
|
623
|
-
}
|
|
624
|
-
},
|
|
625
|
-
stderr: {
|
|
626
|
-
type: "object",
|
|
627
|
-
additionalProperties: false,
|
|
628
|
-
required: true,
|
|
629
|
-
properties: {
|
|
630
|
-
text: { type: "string", required: true },
|
|
631
|
-
truncated: { type: "boolean", required: true },
|
|
632
|
-
spillPath: { type: "string" }
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
]
|
|
638
|
-
},
|
|
639
|
-
render: (_args, value) => {
|
|
640
|
-
if (value.kind === "background")
|
|
641
|
-
return [{ type: "text", text: `started background job ${value.jobId}` }];
|
|
642
|
-
let text = renderResult(value);
|
|
643
|
-
if (value.sandbox?.denied === true) {
|
|
644
|
-
if (!text.endsWith("\n"))
|
|
645
|
-
text += "\n";
|
|
646
|
-
text += sandboxDenialMarker(value.sandbox.mode) + "\n" + escalationHintMarker("command");
|
|
647
|
-
}
|
|
648
|
-
return [{ type: "text", text }];
|
|
649
|
-
}
|
|
650
|
-
},
|
|
651
|
-
async execute(args, exec) {
|
|
652
|
-
const v = validateArgs(args);
|
|
653
|
-
const shell = currentShell();
|
|
654
|
-
const argv0 = buildArgv(shell, v.command, paths, v.distro);
|
|
655
|
-
if (argv0[0] === undefined) {
|
|
656
|
-
throw new Error(`dsh-bash-terminal: ${shell} backend unavailable - executable not found. Install it or set the corresponding *Path config.`);
|
|
657
|
-
}
|
|
658
|
-
// Only element 0 (the resolved executable) can be undefined; guarded above.
|
|
659
|
-
const argv = argv0;
|
|
660
|
-
const workdir = resolveWorkdir(v.workdir, exec);
|
|
661
|
-
const timeoutMs = clampTimeout(v.timeoutMs, config.timeoutMs ?? DEFAULT_TIMEOUT_MS, config.maxTimeoutMs ?? MAX_TIMEOUT_MS, "shell timeoutMs");
|
|
662
|
-
const dshEnv = ctx.shellEnv.collect(exec);
|
|
663
|
-
const env = buildEnv(shell, dshEnv);
|
|
664
|
-
let policy = ctx.sandboxPolicy.resolve(exec.agent ? { session: exec.agent.session } : {});
|
|
665
|
-
if (v.sandbox_permissions !== undefined && v.justification !== undefined) {
|
|
666
|
-
const approvedMode = await approveShellEscalation(v.sandbox_permissions, v.justification, exec, policy);
|
|
667
|
-
policy = { ...policy, mode: approvedMode };
|
|
668
|
-
}
|
|
669
|
-
const { argv: confinedArgv, sandbox } = confineSpawn(ctx, argv, policy, shell);
|
|
670
|
-
const resolved = {
|
|
671
|
-
command: v.command,
|
|
672
|
-
workdir: workdir ?? process.cwd(),
|
|
673
|
-
timeoutMs,
|
|
674
|
-
stdoutMaxBytes: DEFAULT_MAX_OUTPUT_BYTES,
|
|
675
|
-
...(v.stdin !== undefined ? { stdin: v.stdin } : {})
|
|
676
|
-
};
|
|
677
|
-
if (v.run_in_background === true) {
|
|
678
|
-
const jobs = ctx.get("jobs");
|
|
679
|
-
if (jobs === undefined)
|
|
680
|
-
throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
|
|
681
|
-
if (exec.signal.aborted) {
|
|
682
|
-
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
683
|
-
error.name = "AbortError";
|
|
684
|
-
throw error;
|
|
685
|
-
}
|
|
686
|
-
return {
|
|
687
|
-
kind: "background",
|
|
688
|
-
jobId: jobs.start({
|
|
689
|
-
kind: `shell/${shell}`,
|
|
690
|
-
label: v.command,
|
|
691
|
-
...(exec.agent ? { owner: exec.agent } : {}),
|
|
692
|
-
run: () => {
|
|
693
|
-
const proc = startBackground(ctx, confinedArgv, resolved, env, exec.signal);
|
|
694
|
-
return {
|
|
695
|
-
cancel: () => void proc.kill(),
|
|
696
|
-
done: proc.done.then(() => processOutcome(proc)),
|
|
697
|
-
readOutput: () => renderProcessRead(proc.readOutput())
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
})
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
const result = await runForeground(ctx, confinedArgv, resolved, env, exec.signal, timeoutMs);
|
|
704
|
-
if (result.aborted) {
|
|
705
|
-
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
706
|
-
error.name = "AbortError";
|
|
707
|
-
throw error;
|
|
708
|
-
}
|
|
709
|
-
const denied = sandbox?.denialSignatures !== undefined
|
|
710
|
-
? sandbox.denialSignatures.some((sig) => result.stderr.text.toLowerCase().includes(sig.toLowerCase()))
|
|
711
|
-
: false;
|
|
712
|
-
return canonicalResult({
|
|
713
|
-
...result,
|
|
714
|
-
...(sandbox !== undefined ? { sandbox: { mode: sandbox.mode, enforcement: sandbox.enforcement, denied } } : {})
|
|
715
|
-
});
|
|
716
|
-
},
|
|
717
|
-
presentCall: (args) => {
|
|
718
|
-
if (args.run_in_background === true) {
|
|
719
|
-
return {
|
|
720
|
-
card: "generic",
|
|
721
|
-
title: args.command,
|
|
722
|
-
kind: "execute",
|
|
723
|
-
rawInput: args.command,
|
|
724
|
-
content: [{ type: "text", text: args.description }]
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
return {
|
|
728
|
-
card: "terminal",
|
|
729
|
-
title: args.command,
|
|
730
|
-
description: args.description,
|
|
731
|
-
...(args.workdir !== undefined ? { cwd: args.workdir } : {})
|
|
732
|
-
};
|
|
733
|
-
},
|
|
734
|
-
presentResult: (args, result) => {
|
|
735
|
-
const block = result.content.length === 1 ? result.content[0] : undefined;
|
|
736
|
-
if (block === undefined || block.type !== "text")
|
|
737
|
-
return undefined;
|
|
738
|
-
const raw = block.text ?? "";
|
|
739
|
-
if (args.run_in_background === true || result.isError) {
|
|
740
|
-
return { card: "generic", content: [{ type: "text", text: "```console\n" + raw.replace(/\n+$/, "") + "\n```" }] };
|
|
741
|
-
}
|
|
742
|
-
const { body, ...exit } = parseExitStatus(raw);
|
|
743
|
-
return { card: "terminal", output: body, ...exit };
|
|
744
|
-
}
|
|
745
|
-
}));
|
|
746
|
-
}
|
|
747
|
-
//#region internals for tests
|
|
748
|
-
export const internals = {
|
|
749
|
-
candidateExists,
|
|
750
|
-
resolveAllPaths,
|
|
751
|
-
resolveWorkdir,
|
|
752
|
-
renderResult,
|
|
753
|
-
processOutcome,
|
|
754
|
-
startBackground,
|
|
755
|
-
runForeground,
|
|
756
|
-
spawnSpec,
|
|
757
|
-
buildEnv,
|
|
758
|
-
buildArgv,
|
|
759
|
-
validateArgs,
|
|
760
|
-
toolDescription,
|
|
761
|
-
SHELL_DESCRIPTIONS,
|
|
762
|
-
DEFAULT_TIMEOUT_MS,
|
|
763
|
-
MAX_TIMEOUT_MS,
|
|
764
|
-
DEFAULT_MAX_OUTPUT_BYTES
|
|
765
|
-
};
|
|
1
|
+
// dsh-bash-terminal - one shell tool, four Windows terminals.
|
|
2
|
+
//
|
|
3
|
+
// Registers a model-facing shell tool. The terminal backend (powershell /
|
|
4
|
+
// gitbash / msys2 / wsl) is chosen by the USER in the Web UI settings (default
|
|
5
|
+
// terminal); the model cannot pick it — the tool always obeys the user's
|
|
6
|
+
// choice:
|
|
7
|
+
// - powershell: pwsh -NoLogo -NoProfile -NonInteractive -Command <cmd>
|
|
8
|
+
// - gitbash: Git for Windows bash -lc <cmd> (POSIX; /d/... paths)
|
|
9
|
+
// - msys2: MSYS2 bash -lc <cmd> (POSIX; /c/... paths, full GCC/mingw64 toolchain)
|
|
10
|
+
// - wsl: wsl [-d <distro>] -e bash -lc <cmd> (Linux; /mnt/d/... paths)
|
|
11
|
+
//
|
|
12
|
+
// The tool spawns through the shared ctx.subprocess seam (process-tree
|
|
13
|
+
// termination, SIGTERM->grace->SIGKILL, spill files) and registers background
|
|
14
|
+
// handles with the generic ctx.jobs registry, mirroring the shipped
|
|
15
|
+
// dsh-tool-bash / dsh-tool-pwsh story call-for-call. It deliberately
|
|
16
|
+
// does NOT consume the ctx.shell capability seam: the platform's own
|
|
17
|
+
// sandboxed PowerShell executor keeps serving the pwsh tool. This tool is
|
|
18
|
+
// an additional, user-selected terminal that still honors the DSH sandbox:
|
|
19
|
+
// confined modes wrap PowerShell's spawn argv through ctx.sandbox
|
|
20
|
+
// (fail-closed), while danger-full-access, Git Bash, MSYS2, and WSL run
|
|
21
|
+
// unconfined. Git Bash and MSYS2 are unconfined because the DSH Windows ACL
|
|
22
|
+
// restricted-token runner cannot host Cygwin/MSYS2: bash aborts with
|
|
23
|
+
// "CreateFileMapping ... Win32 error 5" during startup.
|
|
24
|
+
import { lstatSync } from "node:fs";
|
|
25
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
26
|
+
import { ESCALATION_TARGETS, TOOL_ABORTED, approveEscalation, defineTool, escalationHintMarker, clampTimeout, deadline, parseExitStatus, timeoutOf, sandboxDenialMarker, validateEscalationArgs, z, HarnessError } from "./dsh.js";
|
|
27
|
+
import { createTerminalRegistry, terminalTool } from "./terminal.js";
|
|
28
|
+
/** Stable Cordis plugin name. */
|
|
29
|
+
export const name = "bash-terminal";
|
|
30
|
+
/** Services required before the tool can register. */
|
|
31
|
+
export const inject = ["tools", "systemPrompt", "shellEnv", "subprocess", "settings", "sandbox", "sandboxPolicy"];
|
|
32
|
+
/** The terminal backends this tool exposes, in catalog order. */
|
|
33
|
+
export const SHELLS = ["powershell", "gitbash", "msys2", "wsl"];
|
|
34
|
+
/** The backend used when the caller does not name one. */
|
|
35
|
+
export const DEFAULT_SHELL = "powershell";
|
|
36
|
+
/** Default per-command timeout (ms). */
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
38
|
+
/** Upper bound a caller's timeoutMs is capped to. */
|
|
39
|
+
const MAX_TIMEOUT_MS = 600000;
|
|
40
|
+
/** SIGTERM->SIGKILL grace (ms), matching dsh-bash-local's default. */
|
|
41
|
+
const DEFAULT_GRACE_MS = 3000;
|
|
42
|
+
/** Per-stream in-memory cap before spilling (bytes). */
|
|
43
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
44
|
+
/** Per-stream spill file cap (bytes). */
|
|
45
|
+
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024;
|
|
46
|
+
/** Timeout code stamped on the deadline's TimeoutReason. */
|
|
47
|
+
const TIMEOUT_CODE = "SHELL_TIMEOUT";
|
|
48
|
+
/** Model-friendly environment overrides (same set dsh-bash-local hardcodes). */
|
|
49
|
+
const ENV_OVERRIDES = {
|
|
50
|
+
NO_COLOR: "1",
|
|
51
|
+
TERM: "dumb",
|
|
52
|
+
PAGER: "cat",
|
|
53
|
+
GIT_PAGER: "cat"
|
|
54
|
+
};
|
|
55
|
+
/** Runtime configuration schema. */
|
|
56
|
+
export const Config = z.object({
|
|
57
|
+
defaultShell: z.string().default(DEFAULT_SHELL).volatile(),
|
|
58
|
+
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
|
|
59
|
+
maxTimeoutMs: z.number().default(MAX_TIMEOUT_MS),
|
|
60
|
+
pwshPath: z.string().default(""),
|
|
61
|
+
gitBashPath: z.string().default(""),
|
|
62
|
+
msys2Path: z.string().default(""),
|
|
63
|
+
wslPath: z.string().default("")
|
|
64
|
+
});
|
|
65
|
+
// ---- executable resolution ------------------------------------------------
|
|
66
|
+
function candidateExists(candidate) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = lstatSync(candidate);
|
|
69
|
+
return stat.isFile() || stat.isSymbolicLink();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function resolveFromCandidates(candidates) {
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (candidateExists(candidate))
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Well-known PowerShell install locations plus PATH entries, newest first. */
|
|
83
|
+
export function candidatePwshPaths(env = process.env) {
|
|
84
|
+
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
85
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
86
|
+
const candidates = [join(programFiles, "PowerShell", "7", "pwsh.exe")];
|
|
87
|
+
for (const entry of (env.PATH ?? "").split(";")) {
|
|
88
|
+
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
89
|
+
if (trimmed.length === 0)
|
|
90
|
+
continue;
|
|
91
|
+
candidates.push(join(trimmed, "pwsh.exe"));
|
|
92
|
+
}
|
|
93
|
+
candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
|
|
94
|
+
return candidates;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Git for Windows locations, then PATH bash.exe entries EXCLUDING the
|
|
98
|
+
* System32 launcher (c:\\windows\\system32\\bash.exe is the WSL
|
|
99
|
+
* forwarder, not a Git Bash shell).
|
|
100
|
+
*/
|
|
101
|
+
export function candidateGitBashPaths(env = process.env) {
|
|
102
|
+
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
|
|
103
|
+
const systemRoot = (env.SystemRoot ?? "C:\\Windows").toLowerCase();
|
|
104
|
+
const localAppData = env.LOCALAPPDATA ?? "";
|
|
105
|
+
const candidates = [
|
|
106
|
+
join(programFiles, "Git", "bin", "bash.exe"),
|
|
107
|
+
join(programFiles, "Git", "usr", "bin", "bash.exe")
|
|
108
|
+
];
|
|
109
|
+
if (localAppData.length > 0)
|
|
110
|
+
candidates.push(join(localAppData, "Programs", "Git", "bin", "bash.exe"));
|
|
111
|
+
for (const entry of (env.PATH ?? "").split(";")) {
|
|
112
|
+
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
113
|
+
if (trimmed.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
if (trimmed.toLowerCase().includes(systemRoot))
|
|
116
|
+
continue;
|
|
117
|
+
candidates.push(join(trimmed, "bash.exe"));
|
|
118
|
+
}
|
|
119
|
+
return candidates;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
|
|
123
|
+
* first, then bin\bash.exe, and `msys2.exe` dead last.
|
|
124
|
+
*
|
|
125
|
+
* msys2.exe is NOT a usable backend for piped execution: it is the console-
|
|
126
|
+
* allocating Cygwin launcher, so a spawn with piped stdio returns exit 0 with
|
|
127
|
+
* zero bytes on both stdout and stderr (measured on this machine against
|
|
128
|
+
* MSYS2 with bash 5.3.15). Keeping it in the list only as a last-resort
|
|
129
|
+
* fallback preserves the path the config docs reference, but a working
|
|
130
|
+
* bash.exe always wins.
|
|
131
|
+
*
|
|
132
|
+
* MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run
|
|
133
|
+
* under the DSH Windows ACL restricted-token sandbox.
|
|
134
|
+
*/
|
|
135
|
+
export function candidateMsys2Paths(env = process.env) {
|
|
136
|
+
const candidates = [
|
|
137
|
+
"C:\\msys64\\usr\\bin\\bash.exe",
|
|
138
|
+
"C:\\msys64\\bin\\bash.exe"
|
|
139
|
+
];
|
|
140
|
+
for (const entry of (env.PATH ?? "").split(";")) {
|
|
141
|
+
const trimmed = entry.trim().replace(/^"|"$/g, "");
|
|
142
|
+
if (trimmed.length === 0)
|
|
143
|
+
continue;
|
|
144
|
+
const lower = trimmed.toLowerCase();
|
|
145
|
+
if (lower.includes("msys64") || lower.includes("mingw64")) {
|
|
146
|
+
candidates.push(join(trimmed, "bash.exe"));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
candidates.push("C:\\msys64\\msys2.exe"); // last resort: see the note above
|
|
150
|
+
return candidates;
|
|
151
|
+
}
|
|
152
|
+
export function defaultWslPath(env = process.env) {
|
|
153
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
154
|
+
return join(systemRoot, "System32", "wsl.exe");
|
|
155
|
+
}
|
|
156
|
+
export function resolveAllPaths(config = {}, env = process.env) {
|
|
157
|
+
const pwsh = config.pwshPath && config.pwshPath.trim().length > 0
|
|
158
|
+
? config.pwshPath
|
|
159
|
+
: resolveFromCandidates(candidatePwshPaths(env));
|
|
160
|
+
const gitbash = config.gitBashPath && config.gitBashPath.trim().length > 0
|
|
161
|
+
? config.gitBashPath
|
|
162
|
+
: resolveFromCandidates(candidateGitBashPaths(env));
|
|
163
|
+
const msys2 = config.msys2Path && config.msys2Path.trim().length > 0
|
|
164
|
+
? config.msys2Path
|
|
165
|
+
: resolveFromCandidates(candidateMsys2Paths(env));
|
|
166
|
+
const wsl = config.wslPath && config.wslPath.trim().length > 0
|
|
167
|
+
? config.wslPath
|
|
168
|
+
: defaultWslPath(env);
|
|
169
|
+
return { pwsh, gitbash, msys2, wsl };
|
|
170
|
+
}
|
|
171
|
+
// ---- argv / env construction ------------------------------------------------
|
|
172
|
+
export function buildArgv(shell, command, paths, distro) {
|
|
173
|
+
switch (shell) {
|
|
174
|
+
case "powershell":
|
|
175
|
+
return [paths.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command];
|
|
176
|
+
case "gitbash":
|
|
177
|
+
return [paths.gitbash, "-lc", command];
|
|
178
|
+
case "msys2":
|
|
179
|
+
// -lc, not -c: a login shell sources /etc/profile, which is what puts
|
|
180
|
+
// /usr/bin and /mingw64/bin on PATH. With a bare -c, `tr`, `sed`, `gcc`
|
|
181
|
+
// and friends are all "command not found".
|
|
182
|
+
return [paths.msys2, "-lc", command];
|
|
183
|
+
case "wsl": {
|
|
184
|
+
const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
|
|
185
|
+
return [paths.wsl, ...distroArg, "-e", "bash", "-lc", command];
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
throw new Error(`invalid shell: ${JSON.stringify(shell)} (expected one of ${SHELLS.join(", ")})`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Merge the DSH_* environment over the process environment. For WSL, only
|
|
193
|
+
* variables explicitly listed in WSLENV cross the boundary, so every DSH_*
|
|
194
|
+
* key is appended there — and the list is *layered onto* whatever WSLENV the
|
|
195
|
+
* host already had (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`),
|
|
196
|
+
* never rebuilt from scratch: WSLENV is an allow-list, so dropping the
|
|
197
|
+
* inherited entries would silently stop them crossing into WSL.
|
|
198
|
+
*
|
|
199
|
+
* @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
|
|
200
|
+
* replace the child environment wholesale (the PTY path) must pass
|
|
201
|
+
* `process.env.WSLENV` explicitly, because the ambient value is not visible
|
|
202
|
+
* through `dshEnv`.
|
|
203
|
+
*/
|
|
204
|
+
export function buildEnv(shell, dshEnv, inheritedWslenv = process.env.WSLENV) {
|
|
205
|
+
const env = { ...ENV_OVERRIDES, ...dshEnv };
|
|
206
|
+
if (shell === "msys2") {
|
|
207
|
+
// Picks the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
|
|
208
|
+
// PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
|
|
209
|
+
// environment. An explicit caller-supplied value wins.
|
|
210
|
+
if (env.MSYSTEM === undefined)
|
|
211
|
+
env.MSYSTEM = "MINGW64";
|
|
212
|
+
}
|
|
213
|
+
if (shell === "wsl") {
|
|
214
|
+
const keys = Object.keys(dshEnv ?? {});
|
|
215
|
+
if (keys.length > 0) {
|
|
216
|
+
// An explicit WSLENV in dshEnv wins over the inherited one; otherwise the
|
|
217
|
+
// inherited value is the base. Either way the base is SPLIT into entries
|
|
218
|
+
// (the host value ends with a trailing ":", so string concatenation would
|
|
219
|
+
// produce a malformed empty entry) and the WSLENV key itself is excluded
|
|
220
|
+
// (it is a key of dshEnv too, and appending it would add a bogus entry).
|
|
221
|
+
const declared = Object.prototype.hasOwnProperty.call(dshEnv ?? {}, "WSLENV");
|
|
222
|
+
const base = declared
|
|
223
|
+
? env.WSLENV
|
|
224
|
+
: (typeof inheritedWslenv === "string" && inheritedWslenv.length > 0 ? inheritedWslenv : env.WSLENV);
|
|
225
|
+
const parts = typeof base === "string" ? base.split(":") : [];
|
|
226
|
+
env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
|
|
227
|
+
.map((p) => p.trim())
|
|
228
|
+
.filter((p) => p.length > 0)
|
|
229
|
+
.join(":");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return env;
|
|
233
|
+
}
|
|
234
|
+
function spawnSpec(resolved, argv, env, signal) {
|
|
235
|
+
const collect = (maxBytes) => ({ maxBytes, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } });
|
|
236
|
+
return {
|
|
237
|
+
argv,
|
|
238
|
+
cwd: resolved.workdir,
|
|
239
|
+
stdio: {
|
|
240
|
+
stdin: resolved.stdin !== undefined ? { data: resolved.stdin } : "ignore",
|
|
241
|
+
stdout: collect(resolved.stdoutMaxBytes),
|
|
242
|
+
stderr: collect(DEFAULT_MAX_OUTPUT_BYTES)
|
|
243
|
+
},
|
|
244
|
+
graceMs: DEFAULT_GRACE_MS,
|
|
245
|
+
signal,
|
|
246
|
+
env
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function collectedOutput(handle) {
|
|
250
|
+
const { stdout, stderr } = handle.collected;
|
|
251
|
+
if (!isCollectedStream(stdout) || !isCollectedStream(stderr)) {
|
|
252
|
+
throw new Error("dsh-bash-terminal: subprocess implementation dropped a requested collect stream");
|
|
253
|
+
}
|
|
254
|
+
return { stdout, stderr };
|
|
255
|
+
}
|
|
256
|
+
function isCollectedStream(value) {
|
|
257
|
+
return typeof value === "object" && value !== null && typeof value.readFrom === "function";
|
|
258
|
+
}
|
|
259
|
+
function finalOutput(reader) {
|
|
260
|
+
const read = reader.readFrom(0);
|
|
261
|
+
return {
|
|
262
|
+
text: read.text,
|
|
263
|
+
truncated: read.lossy,
|
|
264
|
+
...(read.spillPath !== undefined ? { spillPath: read.spillPath } : {})
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function runForeground(ctx, argv, resolved, env, signal, timeoutMs) {
|
|
268
|
+
const subprocess = requireSubprocess(ctx);
|
|
269
|
+
const d = deadline(signal, timeoutMs, TIMEOUT_CODE);
|
|
270
|
+
try {
|
|
271
|
+
const handle = subprocess.spawn(spawnSpec(resolved, argv, env, d.signal));
|
|
272
|
+
const outcome = await handle.done;
|
|
273
|
+
const collected = collectedOutput(handle);
|
|
274
|
+
const timedOut = timeoutOf(d.signal, TIMEOUT_CODE) !== undefined;
|
|
275
|
+
const aborted = d.signal.aborted && !timedOut;
|
|
276
|
+
return {
|
|
277
|
+
exitCode: outcome.exitCode,
|
|
278
|
+
signal: outcome.signal,
|
|
279
|
+
timedOut,
|
|
280
|
+
aborted,
|
|
281
|
+
timeoutMs,
|
|
282
|
+
stdout: finalOutput(collected.stdout),
|
|
283
|
+
stderr: finalOutput(collected.stderr)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
d[Symbol.dispose]();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function requireSubprocess(ctx) {
|
|
291
|
+
const subprocess = ctx.subprocess;
|
|
292
|
+
if (subprocess === null) {
|
|
293
|
+
throw new Error("dsh-bash-terminal: ctx.subprocess seam unavailable (missing inject service)");
|
|
294
|
+
}
|
|
295
|
+
return subprocess;
|
|
296
|
+
}
|
|
297
|
+
function startBackground(ctx, argv, resolved, env, signal) {
|
|
298
|
+
const subprocess = requireSubprocess(ctx);
|
|
299
|
+
const running = subprocess.spawn(spawnSpec(resolved, argv, env, signal));
|
|
300
|
+
const collected = collectedOutput(running);
|
|
301
|
+
let stdoutOffset = 0;
|
|
302
|
+
let stderrOffset = 0;
|
|
303
|
+
let spawnFailureNote;
|
|
304
|
+
const consumeSpawnFailure = () => {
|
|
305
|
+
const note = spawnFailureNote ?? "";
|
|
306
|
+
spawnFailureNote = undefined;
|
|
307
|
+
return note;
|
|
308
|
+
};
|
|
309
|
+
const proc = {
|
|
310
|
+
status: "running",
|
|
311
|
+
exitCode: null,
|
|
312
|
+
signal: null,
|
|
313
|
+
done: running.done.then((outcome) => {
|
|
314
|
+
if (proc.status === "running") {
|
|
315
|
+
proc.status = signal?.aborted === true || outcome.signal !== null ? "killed" : "completed";
|
|
316
|
+
}
|
|
317
|
+
proc.exitCode = outcome.exitCode;
|
|
318
|
+
proc.signal = outcome.signal;
|
|
319
|
+
}, (error) => {
|
|
320
|
+
proc.status = "killed";
|
|
321
|
+
spawnFailureNote = `spawn failed: ${String(error)}`;
|
|
322
|
+
}),
|
|
323
|
+
readOutput: () => {
|
|
324
|
+
const out = collected.stdout.readFrom(stdoutOffset);
|
|
325
|
+
const err = collected.stderr.readFrom(stderrOffset);
|
|
326
|
+
stdoutOffset = out.nextOffset;
|
|
327
|
+
stderrOffset = err.nextOffset;
|
|
328
|
+
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure();
|
|
329
|
+
const separator = out.text.length > 0 && !out.text.endsWith("\n") ? "\n" : "";
|
|
330
|
+
return {
|
|
331
|
+
delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ""),
|
|
332
|
+
lossy: out.lossy || err.lossy,
|
|
333
|
+
...(out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}),
|
|
334
|
+
...(err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {})
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
kill: () => {
|
|
338
|
+
if (proc.status !== "running")
|
|
339
|
+
return false;
|
|
340
|
+
proc.status = "killed";
|
|
341
|
+
running.terminate();
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
return proc;
|
|
346
|
+
}
|
|
347
|
+
function processOutcome(proc) {
|
|
348
|
+
if (proc.status === "killed") {
|
|
349
|
+
return { status: "killed", detail: proc.signal !== null ? `signal: ${proc.signal}` : "killed before exit" };
|
|
350
|
+
}
|
|
351
|
+
return { status: "completed", detail: `exit code: ${proc.exitCode ?? 0}` };
|
|
352
|
+
}
|
|
353
|
+
// ---- model-facing rendering ---------------------------------------------------
|
|
354
|
+
function streamText(output) {
|
|
355
|
+
if (!output.truncated)
|
|
356
|
+
return output.text;
|
|
357
|
+
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? "(unavailable)"}]`;
|
|
358
|
+
}
|
|
359
|
+
export function renderResult(result) {
|
|
360
|
+
const out = streamText(result.stdout);
|
|
361
|
+
const err = streamText(result.stderr);
|
|
362
|
+
let body = out;
|
|
363
|
+
if (err.length > 0) {
|
|
364
|
+
if (body.length > 0 && !body.endsWith("\n"))
|
|
365
|
+
body += "\n";
|
|
366
|
+
body += `[stderr]\n${err}`;
|
|
367
|
+
}
|
|
368
|
+
if (body.length === 0)
|
|
369
|
+
body = "(no output)";
|
|
370
|
+
const markers = [];
|
|
371
|
+
if (result.timedOut)
|
|
372
|
+
markers.push(`[timed out after ${result.timeoutMs}ms]`);
|
|
373
|
+
if (result.signal !== null)
|
|
374
|
+
markers.push(`[killed by signal: ${result.signal}]`);
|
|
375
|
+
else if (result.exitCode !== 0)
|
|
376
|
+
markers.push(`[exit code: ${result.exitCode}]`);
|
|
377
|
+
if (markers.length === 0)
|
|
378
|
+
return body;
|
|
379
|
+
if (!body.endsWith("\n"))
|
|
380
|
+
body += "\n";
|
|
381
|
+
return body + markers.join("\n");
|
|
382
|
+
}
|
|
383
|
+
function renderProcessRead(read) {
|
|
384
|
+
const notices = [];
|
|
385
|
+
if (read.lossy) {
|
|
386
|
+
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p) => p !== undefined);
|
|
387
|
+
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(", ") : "(unavailable)"}]`);
|
|
388
|
+
}
|
|
389
|
+
if (notices.length === 0)
|
|
390
|
+
return read.delta;
|
|
391
|
+
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith("\n") ? "\n" : ""}${notices.join("\n")}`;
|
|
392
|
+
}
|
|
393
|
+
// ---- tool description / validation --------------------------------------------
|
|
394
|
+
/** Model-facing lead sentence for each backend. The user's default terminal is
|
|
395
|
+
* stated up front so the model never has to guess which syntax applies. */
|
|
396
|
+
export const SHELL_DESCRIPTIONS = {
|
|
397
|
+
powershell: "Execute a PowerShell command (pwsh -NoLogo -NoProfile -NonInteractive -Command <command>) and return its stdout/stderr. PowerShell syntax; native Windows paths (C:\\...); environment variables via $env:NAME.",
|
|
398
|
+
gitbash: "Execute a bash command (Git for Windows bash -lc <command>) and return its stdout/stderr. POSIX syntax; paths like /d/WorkSpace; PATH includes /usr/bin and /mingw64/bin so git, npm, ssh etc. work; environment variables via $NAME.",
|
|
399
|
+
msys2: "Execute a bash command (MSYS2 bash -lc <command>) and return its stdout/stderr. POSIX syntax; paths like /c/...; PATH includes /usr/bin and /mingw64/bin so git, npm, gcc, make etc. work; environment variables via $NAME. MSYS2 provides a full GCC/mingw64 toolchain.",
|
|
400
|
+
wsl: "Execute a Linux bash command (wsl [-d <distro>] -e bash -lc <command>) and return its stdout/stderr. Linux syntax; Windows files under /mnt/d/...; environment variables via $NAME."
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Render the model-facing tool description for one backend. The backend is
|
|
404
|
+
* whatever the user chose in Settings -> General -> Default terminal; the
|
|
405
|
+
* description names it explicitly so the model writes the right syntax.
|
|
406
|
+
* @param backgroundEnabled - advertise `run_in_background` controls.
|
|
407
|
+
* @param shell - active backend; unknown values fall back to the default.
|
|
408
|
+
*/
|
|
409
|
+
export function toolDescription(backgroundEnabled, shell = DEFAULT_SHELL) {
|
|
410
|
+
const active = SHELLS.includes(shell) ? shell : DEFAULT_SHELL;
|
|
411
|
+
const lead = SHELL_DESCRIPTIONS[active];
|
|
412
|
+
const base = [
|
|
413
|
+
`The user's chosen default terminal (Settings -> General -> Default terminal) is ${active}; commands run there.`,
|
|
414
|
+
lead,
|
|
415
|
+
"Each call spawns a fresh shell: no state (cwd, variables, aliases) persists between calls - pass workdir instead of using cd. Non-zero exits are reported as [exit code: N] markers; investigate failures before moving on. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands run under the DSH sandbox: confined modes (read-only / workspace-write) are enforced through ctx.sandbox and deny fail-closed for PowerShell; danger-full-access, Git Bash, MSYS2, and WSL run unconfined."
|
|
416
|
+
].join(" ");
|
|
417
|
+
if (!backgroundEnabled)
|
|
418
|
+
return base;
|
|
419
|
+
return base + " 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 to background runs.";
|
|
420
|
+
}
|
|
421
|
+
export function validateArgs(args) {
|
|
422
|
+
if (typeof args.command !== "string" || args.command.trim().length === 0) {
|
|
423
|
+
throw new Error("invalid command: expected a non-empty string");
|
|
424
|
+
}
|
|
425
|
+
if (typeof args.description !== "string" || args.description.trim().length === 0) {
|
|
426
|
+
throw new Error("invalid description: expected a non-empty string");
|
|
427
|
+
}
|
|
428
|
+
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
429
|
+
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`);
|
|
430
|
+
}
|
|
431
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification);
|
|
432
|
+
return args;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Official sandbox seam (mirrors dsh-tool-bash / dsh-pwsh-sandbox): resolve the
|
|
436
|
+
* per-call policy from ctx.sandboxPolicy, and — unless the call runs
|
|
437
|
+
* danger-full-access — confine the spawn argv through ctx.sandbox. WSL is a
|
|
438
|
+
* self-contained Linux VM and is not confined (its isolation IS the sandbox).
|
|
439
|
+
* Git Bash and MSYS2 are also not confined: DSH's Windows ACL restricted-token
|
|
440
|
+
* runner cannot start Cygwin/MSYS2 (CreateFileMapping Win32 error 5), so
|
|
441
|
+
* attempting to wrap them would abort every Git Bash / MSYS2 command. A
|
|
442
|
+
* requested confined mode
|
|
443
|
+
* with no usable backend throws the fail-closed SandboxUnavailableError,
|
|
444
|
+
* exactly like the shipped executors.
|
|
445
|
+
*/
|
|
446
|
+
function confineSpawn(ctx, argv, policy, shell) {
|
|
447
|
+
if (policy.mode === "danger-full-access" || shell === "wsl" || shell === "gitbash" || shell === "msys2") {
|
|
448
|
+
const sandbox = shell === "wsl"
|
|
449
|
+
? { mode: policy.mode, enforcement: "wsl-isolation" }
|
|
450
|
+
: shell === "gitbash"
|
|
451
|
+
? { mode: policy.mode, enforcement: "gitbash-unconfined" }
|
|
452
|
+
: shell === "msys2"
|
|
453
|
+
? { mode: policy.mode, enforcement: "msys2-unconfined" }
|
|
454
|
+
: undefined;
|
|
455
|
+
return { argv, sandbox };
|
|
456
|
+
}
|
|
457
|
+
const confined = ctx.sandbox.confine(argv, policy);
|
|
458
|
+
return {
|
|
459
|
+
argv: confined.argv,
|
|
460
|
+
sandbox: { mode: policy.mode, enforcement: confined.enforcement, denialSignatures: confined.denialSignatures }
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function resolveWorkdir(modelWorkdir, exec) {
|
|
464
|
+
const headerCwd = exec.agent?.session.header.cwd;
|
|
465
|
+
if (modelWorkdir === undefined)
|
|
466
|
+
return headerCwd;
|
|
467
|
+
if (headerCwd !== undefined && !isAbsolute(modelWorkdir))
|
|
468
|
+
return resolve(headerCwd, modelWorkdir);
|
|
469
|
+
return modelWorkdir;
|
|
470
|
+
}
|
|
471
|
+
function canonicalResult(result) {
|
|
472
|
+
const output = (stream) => ({
|
|
473
|
+
text: stream.text,
|
|
474
|
+
truncated: stream.truncated,
|
|
475
|
+
...(stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {})
|
|
476
|
+
});
|
|
477
|
+
return {
|
|
478
|
+
kind: "foreground",
|
|
479
|
+
exitCode: result.exitCode,
|
|
480
|
+
signal: result.signal,
|
|
481
|
+
timedOut: result.timedOut,
|
|
482
|
+
aborted: result.aborted,
|
|
483
|
+
timeoutMs: result.timeoutMs,
|
|
484
|
+
stdout: output(result.stdout),
|
|
485
|
+
stderr: output(result.stderr),
|
|
486
|
+
...(result.sandbox !== undefined ? { sandbox: result.sandbox } : {})
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
490
|
+
kind: { type: "string", required: true, const: "background" },
|
|
491
|
+
jobId: { type: "string", required: true }
|
|
492
|
+
};
|
|
493
|
+
// ---- plugin -------------------------------------------------------------------
|
|
494
|
+
export function apply(ctx, config = {}) {
|
|
495
|
+
if (process.platform !== "win32") {
|
|
496
|
+
ctx.logger?.info?.("dsh-bash-terminal: only meaningful on win32; skipping tool registration");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const backgroundEnabled = true;
|
|
500
|
+
const paths = resolveAllPaths(config);
|
|
501
|
+
// 0.1.7: `defaultShell` is a `.volatile()` config field — the loader hands
|
|
502
|
+
// `apply` a live ref and the latest value is resolved on every read; there
|
|
503
|
+
// is no settings registration call anymore (removed with the 0.1.7 host).
|
|
504
|
+
const currentShell = () => {
|
|
505
|
+
const v = config.defaultShell;
|
|
506
|
+
if (typeof v === "string")
|
|
507
|
+
return v;
|
|
508
|
+
const got = typeof v?.get === "function" ? v.get() : undefined;
|
|
509
|
+
return typeof got === "string" ? got : DEFAULT_SHELL;
|
|
510
|
+
};
|
|
511
|
+
if (!SHELLS.includes(currentShell())) {
|
|
512
|
+
throw new Error(`dsh-bash-terminal: invalid defaultShell ${JSON.stringify(currentShell())}`);
|
|
513
|
+
}
|
|
514
|
+
/** Official sandbox-escalation surface (mirrors tool-bash): advertise the
|
|
515
|
+
* escalation modes whenever the deployment confines. */
|
|
516
|
+
const escalationModes = ESCALATION_TARGETS;
|
|
517
|
+
const approveShellEscalation = (mode, justification, exec, standingPolicy) => {
|
|
518
|
+
return approveEscalation({
|
|
519
|
+
requestedMode: mode,
|
|
520
|
+
justification,
|
|
521
|
+
effectiveMode: standingPolicy.mode,
|
|
522
|
+
subject: "command"
|
|
523
|
+
}, {
|
|
524
|
+
approver: ctx.get("approval"),
|
|
525
|
+
agent: exec.agent,
|
|
526
|
+
callId: exec.callId,
|
|
527
|
+
toolName: "shell",
|
|
528
|
+
signal: exec.signal
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
ctx.systemPrompt.section({
|
|
532
|
+
name: "tool:bash-terminal",
|
|
533
|
+
order: 105,
|
|
534
|
+
text: "Use the shell tool for terminal commands: it runs in the terminal the user chose in Settings -> General -> Default terminal (PowerShell, Git Bash, MSYS2, or WSL) and honors the DSH sandbox. Prefer it over the pwsh tool for everyday commands; keep the pwsh tool for cases that specifically need the sandboxed PowerShell surface."
|
|
535
|
+
});
|
|
536
|
+
const toolName = "shell";
|
|
537
|
+
const terminalRegistry = createTerminalRegistry(ctx);
|
|
538
|
+
ctx.tools.register(terminalTool(ctx, terminalRegistry, paths, () => currentShell()));
|
|
539
|
+
// The model-facing description must track the user's chosen default
|
|
540
|
+
// terminal: a static description listing every backend leaves the model
|
|
541
|
+
// guessing which syntax applies. Re-render it on every prompt assembly —
|
|
542
|
+
// settings are hot-reloaded, so a change shows up on the next request
|
|
543
|
+
// without a restart.
|
|
544
|
+
ctx.on("system-prompt/assemble", async (_assembly, _context, next) => {
|
|
545
|
+
const assembled = await next();
|
|
546
|
+
const description = toolDescription(backgroundEnabled, currentShell());
|
|
547
|
+
const tools = Array.isArray(assembled.tools)
|
|
548
|
+
? assembled.tools.map((tool) => (tool.name === toolName ? { ...tool, description } : tool))
|
|
549
|
+
: assembled.tools;
|
|
550
|
+
return { ...assembled, tools };
|
|
551
|
+
});
|
|
552
|
+
ctx.tools.register(defineTool({
|
|
553
|
+
name: toolName,
|
|
554
|
+
description: toolDescription(backgroundEnabled, currentShell()),
|
|
555
|
+
parameters: {
|
|
556
|
+
command: {
|
|
557
|
+
type: "string",
|
|
558
|
+
required: true,
|
|
559
|
+
description: "The command to execute in the selected terminal."
|
|
560
|
+
},
|
|
561
|
+
description: {
|
|
562
|
+
type: "string",
|
|
563
|
+
required: true,
|
|
564
|
+
description: "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" -> \"List files in current directory\"; \"git status\" -> \"Show working tree status\"; \"npm install\" -> \"Install package dependencies\"."
|
|
565
|
+
},
|
|
566
|
+
distro: {
|
|
567
|
+
type: "string",
|
|
568
|
+
description: "WSL distribution to use (only when the configured default terminal is wsl). Defaults to the system default distribution."
|
|
569
|
+
},
|
|
570
|
+
sandbox_permissions: {
|
|
571
|
+
type: "string",
|
|
572
|
+
enum: escalationModes,
|
|
573
|
+
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."
|
|
574
|
+
},
|
|
575
|
+
justification: {
|
|
576
|
+
type: "string",
|
|
577
|
+
description: "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
|
|
578
|
+
},
|
|
579
|
+
workdir: {
|
|
580
|
+
type: "string",
|
|
581
|
+
description: "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
|
|
582
|
+
},
|
|
583
|
+
timeoutMs: {
|
|
584
|
+
type: "number",
|
|
585
|
+
description: "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
|
|
586
|
+
},
|
|
587
|
+
run_in_background: {
|
|
588
|
+
type: "boolean",
|
|
589
|
+
description: "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
output: {
|
|
593
|
+
schema: {
|
|
594
|
+
oneOf: [
|
|
595
|
+
{ type: "object", additionalProperties: false, properties: BACKGROUND_OUTPUT_PROPERTIES },
|
|
596
|
+
{
|
|
597
|
+
type: "object",
|
|
598
|
+
additionalProperties: false,
|
|
599
|
+
properties: {
|
|
600
|
+
kind: { type: "string", required: true, const: "foreground" },
|
|
601
|
+
exitCode: { required: true, oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
602
|
+
signal: { required: true, oneOf: [{ type: "string" }, { type: "null" }] },
|
|
603
|
+
timedOut: { type: "boolean", required: true },
|
|
604
|
+
aborted: { type: "boolean", required: true },
|
|
605
|
+
timeoutMs: { type: "number", required: true },
|
|
606
|
+
sandbox: {
|
|
607
|
+
type: "object",
|
|
608
|
+
additionalProperties: false,
|
|
609
|
+
properties: {
|
|
610
|
+
mode: { type: "string", required: true },
|
|
611
|
+
enforcement: { type: "string", required: true },
|
|
612
|
+
denied: { type: "boolean" }
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
stdout: {
|
|
616
|
+
type: "object",
|
|
617
|
+
additionalProperties: false,
|
|
618
|
+
required: true,
|
|
619
|
+
properties: {
|
|
620
|
+
text: { type: "string", required: true },
|
|
621
|
+
truncated: { type: "boolean", required: true },
|
|
622
|
+
spillPath: { type: "string" }
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
stderr: {
|
|
626
|
+
type: "object",
|
|
627
|
+
additionalProperties: false,
|
|
628
|
+
required: true,
|
|
629
|
+
properties: {
|
|
630
|
+
text: { type: "string", required: true },
|
|
631
|
+
truncated: { type: "boolean", required: true },
|
|
632
|
+
spillPath: { type: "string" }
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
]
|
|
638
|
+
},
|
|
639
|
+
render: (_args, value) => {
|
|
640
|
+
if (value.kind === "background")
|
|
641
|
+
return [{ type: "text", text: `started background job ${value.jobId}` }];
|
|
642
|
+
let text = renderResult(value);
|
|
643
|
+
if (value.sandbox?.denied === true) {
|
|
644
|
+
if (!text.endsWith("\n"))
|
|
645
|
+
text += "\n";
|
|
646
|
+
text += sandboxDenialMarker(value.sandbox.mode) + "\n" + escalationHintMarker("command");
|
|
647
|
+
}
|
|
648
|
+
return [{ type: "text", text }];
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
async execute(args, exec) {
|
|
652
|
+
const v = validateArgs(args);
|
|
653
|
+
const shell = currentShell();
|
|
654
|
+
const argv0 = buildArgv(shell, v.command, paths, v.distro);
|
|
655
|
+
if (argv0[0] === undefined) {
|
|
656
|
+
throw new Error(`dsh-bash-terminal: ${shell} backend unavailable - executable not found. Install it or set the corresponding *Path config.`);
|
|
657
|
+
}
|
|
658
|
+
// Only element 0 (the resolved executable) can be undefined; guarded above.
|
|
659
|
+
const argv = argv0;
|
|
660
|
+
const workdir = resolveWorkdir(v.workdir, exec);
|
|
661
|
+
const timeoutMs = clampTimeout(v.timeoutMs, config.timeoutMs ?? DEFAULT_TIMEOUT_MS, config.maxTimeoutMs ?? MAX_TIMEOUT_MS, "shell timeoutMs");
|
|
662
|
+
const dshEnv = ctx.shellEnv.collect(exec);
|
|
663
|
+
const env = buildEnv(shell, dshEnv);
|
|
664
|
+
let policy = ctx.sandboxPolicy.resolve(exec.agent ? { session: exec.agent.session } : {});
|
|
665
|
+
if (v.sandbox_permissions !== undefined && v.justification !== undefined) {
|
|
666
|
+
const approvedMode = await approveShellEscalation(v.sandbox_permissions, v.justification, exec, policy);
|
|
667
|
+
policy = { ...policy, mode: approvedMode };
|
|
668
|
+
}
|
|
669
|
+
const { argv: confinedArgv, sandbox } = confineSpawn(ctx, argv, policy, shell);
|
|
670
|
+
const resolved = {
|
|
671
|
+
command: v.command,
|
|
672
|
+
workdir: workdir ?? process.cwd(),
|
|
673
|
+
timeoutMs,
|
|
674
|
+
stdoutMaxBytes: DEFAULT_MAX_OUTPUT_BYTES,
|
|
675
|
+
...(v.stdin !== undefined ? { stdin: v.stdin } : {})
|
|
676
|
+
};
|
|
677
|
+
if (v.run_in_background === true) {
|
|
678
|
+
const jobs = ctx.get("jobs");
|
|
679
|
+
if (jobs === undefined)
|
|
680
|
+
throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
|
|
681
|
+
if (exec.signal.aborted) {
|
|
682
|
+
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
683
|
+
error.name = "AbortError";
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
kind: "background",
|
|
688
|
+
jobId: jobs.start({
|
|
689
|
+
kind: `shell/${shell}`,
|
|
690
|
+
label: v.command,
|
|
691
|
+
...(exec.agent ? { owner: exec.agent } : {}),
|
|
692
|
+
run: () => {
|
|
693
|
+
const proc = startBackground(ctx, confinedArgv, resolved, env, exec.signal);
|
|
694
|
+
return {
|
|
695
|
+
cancel: () => void proc.kill(),
|
|
696
|
+
done: proc.done.then(() => processOutcome(proc)),
|
|
697
|
+
readOutput: () => renderProcessRead(proc.readOutput())
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const result = await runForeground(ctx, confinedArgv, resolved, env, exec.signal, timeoutMs);
|
|
704
|
+
if (result.aborted) {
|
|
705
|
+
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
706
|
+
error.name = "AbortError";
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
const denied = sandbox?.denialSignatures !== undefined
|
|
710
|
+
? sandbox.denialSignatures.some((sig) => result.stderr.text.toLowerCase().includes(sig.toLowerCase()))
|
|
711
|
+
: false;
|
|
712
|
+
return canonicalResult({
|
|
713
|
+
...result,
|
|
714
|
+
...(sandbox !== undefined ? { sandbox: { mode: sandbox.mode, enforcement: sandbox.enforcement, denied } } : {})
|
|
715
|
+
});
|
|
716
|
+
},
|
|
717
|
+
presentCall: (args) => {
|
|
718
|
+
if (args.run_in_background === true) {
|
|
719
|
+
return {
|
|
720
|
+
card: "generic",
|
|
721
|
+
title: args.command,
|
|
722
|
+
kind: "execute",
|
|
723
|
+
rawInput: args.command,
|
|
724
|
+
content: [{ type: "text", text: args.description }]
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
card: "terminal",
|
|
729
|
+
title: args.command,
|
|
730
|
+
description: args.description,
|
|
731
|
+
...(args.workdir !== undefined ? { cwd: args.workdir } : {})
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
presentResult: (args, result) => {
|
|
735
|
+
const block = result.content.length === 1 ? result.content[0] : undefined;
|
|
736
|
+
if (block === undefined || block.type !== "text")
|
|
737
|
+
return undefined;
|
|
738
|
+
const raw = block.text ?? "";
|
|
739
|
+
if (args.run_in_background === true || result.isError) {
|
|
740
|
+
return { card: "generic", content: [{ type: "text", text: "```console\n" + raw.replace(/\n+$/, "") + "\n```" }] };
|
|
741
|
+
}
|
|
742
|
+
const { body, ...exit } = parseExitStatus(raw);
|
|
743
|
+
return { card: "terminal", output: body, ...exit };
|
|
744
|
+
}
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
//#region internals for tests
|
|
748
|
+
export const internals = {
|
|
749
|
+
candidateExists,
|
|
750
|
+
resolveAllPaths,
|
|
751
|
+
resolveWorkdir,
|
|
752
|
+
renderResult,
|
|
753
|
+
processOutcome,
|
|
754
|
+
startBackground,
|
|
755
|
+
runForeground,
|
|
756
|
+
spawnSpec,
|
|
757
|
+
buildEnv,
|
|
758
|
+
buildArgv,
|
|
759
|
+
validateArgs,
|
|
760
|
+
toolDescription,
|
|
761
|
+
SHELL_DESCRIPTIONS,
|
|
762
|
+
DEFAULT_TIMEOUT_MS,
|
|
763
|
+
MAX_TIMEOUT_MS,
|
|
764
|
+
DEFAULT_MAX_OUTPUT_BYTES
|
|
765
|
+
};
|