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