dsh-bash-terminal-ts 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-bash-terminal-ts - one shell tool, four Windows terminals.
1
+ // dsh-bash-terminal - one shell tool, four Windows terminals.
2
2
  //
3
3
  // Registers a model-facing shell tool. The terminal backend (powershell /
4
4
  // gitbash / msys2 / wsl) is chosen by the USER in the Web UI settings (default
@@ -6,11 +6,7 @@
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: MSYS2 bash -lc <cmd> (POSIX; /c/... paths, full GCC/mingw64 toolchain)
14
10
  // - wsl: wsl [-d <distro>] -e bash -lc <cmd> (Linux; /mnt/d/... paths)
15
11
  //
16
12
  // The tool spawns through the shared ctx.subprocess seam (process-tree
@@ -18,8 +14,13 @@
18
14
  // handles with the generic ctx.jobs registry, mirroring the shipped
19
15
  // dsh-tool-bash / dsh-tool-pwsh story call-for-call. It deliberately
20
16
  // 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.
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.
23
24
 
24
25
  import { lstatSync } from "node:fs";
25
26
  import { isAbsolute, join, resolve } from "node:path";
@@ -60,8 +61,6 @@ export const inject = ["tools", "systemPrompt", "shellEnv", "subprocess", "setti
60
61
  export const SHELLS = ["powershell", "gitbash", "msys2", "wsl"] as const;
61
62
  /** The backend used when the caller does not name one. */
62
63
  export const DEFAULT_SHELL: ShellId = "powershell";
63
- /** Settings namespace backing the user-chosen default terminal. */
64
- export const SETTINGS_NAMESPACE = "bash-terminal";
65
64
  /** Default per-command timeout (ms). */
66
65
  const DEFAULT_TIMEOUT_MS = 120000;
67
66
  /** Upper bound a caller's timeoutMs is capped to. */
@@ -83,6 +82,11 @@ const ENV_OVERRIDES: Record<string, string> = {
83
82
  GIT_PAGER: "cat"
84
83
  };
85
84
 
85
+ /** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
86
+ interface VolatileRef<T> {
87
+ get(): T;
88
+ }
89
+
86
90
  /** Static shape of the runtime configuration schema. */
87
91
  export interface ConfigValues {
88
92
  defaultShell: string;
@@ -96,7 +100,7 @@ export interface ConfigValues {
96
100
 
97
101
  /** Runtime configuration schema. */
98
102
  export const Config = z.object({
99
- defaultShell: z.string().default(DEFAULT_SHELL),
103
+ defaultShell: z.string().default(DEFAULT_SHELL).volatile(),
100
104
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
101
105
  maxTimeoutMs: z.number().default(MAX_TIMEOUT_MS),
102
106
  pwshPath: z.string().default(""),
@@ -161,33 +165,29 @@ export function candidateGitBashPaths(env: NodeJS.ProcessEnv = process.env): str
161
165
  }
162
166
 
163
167
  /**
164
- * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin first,
165
- * then bin\bash.exe, and `msys2.exe` dead last.
168
+ * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
169
+ * first, then bin\bash.exe, and `msys2.exe` dead last.
166
170
  *
167
171
  * msys2.exe is NOT a usable backend for piped execution: it is the console-
168
172
  * allocating Cygwin launcher, so a spawn with piped stdio returns exit 0 with
169
- * zero bytes on both stdout and stderr (measured on MSYS2 with bash 5.3.15).
170
- * Keeping it in the list only as a last-resort fallback preserves the path the
171
- * config docs reference, but a working bash.exe always wins.
173
+ * zero bytes on both stdout and stderr (measured on this machine against
174
+ * MSYS2 with bash 5.3.15). Keeping it in the list only as a last-resort
175
+ * fallback preserves the path the config docs reference, but a working
176
+ * bash.exe always wins.
172
177
  *
173
- * MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run under
174
- * the DSH Windows ACL restricted-token sandbox.
178
+ * MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run
179
+ * under the DSH Windows ACL restricted-token sandbox.
175
180
  */
176
181
  export function candidateMsys2Paths(env: NodeJS.ProcessEnv = process.env): string[] {
177
- const programFiles = env.ProgramFiles ?? "C:\\Program Files";
178
182
  const candidates = [
179
183
  "C:\\msys64\\usr\\bin\\bash.exe",
180
184
  "C:\\msys64\\bin\\bash.exe"
181
185
  ];
182
- // Also check 32-bit variant
183
- const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
184
- if (programFilesX86 !== programFiles) {
185
- candidates.push("C:\\msys64\\usr\\bin\\bash.exe"); // same path regardless of arch
186
- }
187
186
  for (const entry of (env.PATH ?? "").split(";")) {
188
187
  const trimmed = entry.trim().replace(/^"|"$/g, "");
189
188
  if (trimmed.length === 0) continue;
190
- if (trimmed.toLowerCase().includes("msys64") || trimmed.toLowerCase().includes("mingw64")) {
189
+ const lower = trimmed.toLowerCase();
190
+ if (lower.includes("msys64") || lower.includes("mingw64")) {
191
191
  candidates.push(join(trimmed, "bash.exe"));
192
192
  }
193
193
  }
@@ -250,16 +250,16 @@ export function buildArgv(
250
250
 
251
251
  /**
252
252
  * Merge the DSH_* environment over the process environment. For WSL, only
253
- * variables explicitly listed in WSLENV cross the boundary, so every DSH_* key is
254
- * appended to it (WSLENV is a `:` separated VAR[/flag] list).
255
- *
256
- * WSLENV usually already exists for reasons unrelated to us -- Windows Terminal
257
- * exports e.g. `WT_SESSION:WT_PROFILE_ID:` -- so the list is layered rather than
258
- * rebuilt, or those entries would be silently dropped from every WSL call.
259
- * Callers that spawn through a seam which replaces the parent environment
260
- * wholesale (the PTY path) must pass the inherited value explicitly.
253
+ * variables explicitly listed in WSLENV cross the boundary, so every DSH_*
254
+ * key is appended there — and the list is *layered onto* whatever WSLENV the
255
+ * host already had (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`),
256
+ * never rebuilt from scratch: WSLENV is an allow-list, so dropping the
257
+ * inherited entries would silently stop them crossing into WSL.
261
258
  *
262
- * @param inheritedWslenv - the WSLENV the child would otherwise inherit.
259
+ * @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
260
+ * replace the child environment wholesale (the PTY path) must pass
261
+ * `process.env.WSLENV` explicitly, because the ambient value is not visible
262
+ * through `dshEnv`.
263
263
  */
264
264
  export function buildEnv(
265
265
  shell: string,
@@ -268,25 +268,27 @@ export function buildEnv(
268
268
  ): Record<string, string | undefined> {
269
269
  const env: Record<string, string | undefined> = { ...ENV_OVERRIDES, ...dshEnv };
270
270
  if (shell === "msys2") {
271
- // Picks the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins PATH
272
- // via /etc/profile. Without it MSYS2 defaults to the bare MSYS environment.
271
+ // Picks the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
272
+ // PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
273
+ // environment. An explicit caller-supplied value wins.
273
274
  if (env.MSYSTEM === undefined) env.MSYSTEM = "MINGW64";
274
275
  }
275
276
  if (shell === "wsl") {
276
277
  const keys = Object.keys(dshEnv ?? {});
277
278
  if (keys.length > 0) {
278
- // An explicit WSLENV from the caller wins over what we would inherit.
279
+ // An explicit WSLENV in dshEnv wins over the inherited one; otherwise the
280
+ // inherited value is the base. Either way the base is SPLIT into entries
281
+ // (the host value ends with a trailing ":", so string concatenation would
282
+ // produce a malformed empty entry) and the WSLENV key itself is excluded
283
+ // (it is a key of dshEnv too, and appending it would add a bogus entry).
279
284
  const declared = Object.prototype.hasOwnProperty.call(dshEnv ?? {}, "WSLENV");
280
285
  const base = declared
281
286
  ? env.WSLENV
282
287
  : (typeof inheritedWslenv === "string" && inheritedWslenv.length > 0 ? inheritedWslenv : env.WSLENV);
283
- // Windows Terminal's own value ends with a trailing ':' and the caller's
284
- // may too, so split/normalise instead of string-concatenating -- a naive
285
- // join yields an empty entry ("A:B::DSH_X"), which WSL flags as malformed.
286
288
  const parts = typeof base === "string" ? base.split(":") : [];
287
289
  env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
288
- .map((part) => part.trim())
289
- .filter((part) => part.length > 0)
290
+ .map((p) => p.trim())
291
+ .filter((p) => p.length > 0)
290
292
  .join(":");
291
293
  }
292
294
  }
@@ -322,7 +324,7 @@ function spawnSpec(resolved: SpawnResolution, argv: string[], env: Record<string
322
324
  function collectedOutput(handle: { collected: { stdout: unknown; stderr: unknown } }) {
323
325
  const { stdout, stderr } = handle.collected;
324
326
  if (!isCollectedStream(stdout) || !isCollectedStream(stderr)) {
325
- throw new Error("dsh-bash-terminal-ts: subprocess implementation dropped a requested collect stream");
327
+ throw new Error("dsh-bash-terminal: subprocess implementation dropped a requested collect stream");
326
328
  }
327
329
  return { stdout, stderr };
328
330
  }
@@ -393,7 +395,7 @@ async function runForeground(
393
395
  function requireSubprocess(ctx: BashTerminalContext) {
394
396
  const subprocess = ctx.subprocess;
395
397
  if (subprocess === null) {
396
- throw new Error("dsh-bash-terminal-ts: ctx.subprocess seam unavailable (missing inject service)");
398
+ throw new Error("dsh-bash-terminal: ctx.subprocess seam unavailable (missing inject service)");
397
399
  }
398
400
  return subprocess;
399
401
  }
@@ -586,8 +588,9 @@ export function validateArgs(args: Record<string, unknown>): ValidatedShellArgs
586
588
  * Git Bash and MSYS2 are also not confined: DSH's Windows ACL restricted-token
587
589
  * runner cannot start Cygwin/MSYS2 (CreateFileMapping Win32 error 5), so
588
590
  * attempting to wrap them would abort every Git Bash / MSYS2 command. A
589
- * requested confined mode with no usable backend throws the fail-closed
590
- * SandboxUnavailableError, exactly like the shipped executors.
591
+ * requested confined mode
592
+ * with no usable backend throws the fail-closed SandboxUnavailableError,
593
+ * exactly like the shipped executors.
591
594
  */
592
595
  function confineSpawn(
593
596
  ctx: BashTerminalContext,
@@ -666,20 +669,23 @@ const BACKGROUND_OUTPUT_PROPERTIES: Record<string, JsonSchemaNode> = {
666
669
 
667
670
  export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> = {}): void {
668
671
  if (process.platform !== "win32") {
669
- ctx.logger?.info?.("dsh-bash-terminal-ts: only meaningful on win32; skipping tool registration");
672
+ ctx.logger?.info?.("dsh-bash-terminal: only meaningful on win32; skipping tool registration");
670
673
  return;
671
674
  }
672
675
  const backgroundEnabled = true;
673
676
  const paths = resolveAllPaths(config);
674
- const defaultShell = config.defaultShell ?? DEFAULT_SHELL;
675
- if (!(SHELLS as readonly string[]).includes(defaultShell)) {
676
- throw new Error(`dsh-bash-terminal-ts: invalid defaultShell ${JSON.stringify(defaultShell)}`);
677
+ // 0.1.7: `defaultShell` is a `.volatile()` config field — the loader hands
678
+ // `apply` a live ref and the latest value is resolved on every read; there
679
+ // is no settings registration call anymore (removed with the 0.1.7 host).
680
+ const currentShell = (): string => {
681
+ const v = config.defaultShell as string | VolatileRef<string> | undefined;
682
+ if (typeof v === "string") return v;
683
+ const got = typeof v?.get === "function" ? v.get() : undefined;
684
+ return typeof got === "string" ? got : DEFAULT_SHELL;
685
+ };
686
+ if (!(SHELLS as readonly string[]).includes(currentShell())) {
687
+ throw new Error(`dsh-bash-terminal: invalid defaultShell ${JSON.stringify(currentShell())}`);
677
688
  }
678
- const settingsScope = ctx.settings.register(
679
- SETTINGS_NAMESPACE,
680
- z.object({ defaultShell: z.union(SHELLS.map((s) => z.const(s))).default(defaultShell) }),
681
- { base: { defaultShell } }
682
- );
683
689
  /** Official sandbox-escalation surface (mirrors tool-bash): advertise the
684
690
  * escalation modes whenever the deployment confines. */
685
691
  const escalationModes = ESCALATION_TARGETS;
@@ -706,12 +712,12 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
706
712
  ctx.systemPrompt.section({
707
713
  name: "tool:bash-terminal",
708
714
  order: 105,
709
- 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."
715
+ 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."
710
716
  });
711
717
 
712
718
  const toolName = "shell";
713
719
  const terminalRegistry = createTerminalRegistry(ctx);
714
- ctx.tools.register(terminalTool(ctx, terminalRegistry, paths, () => settingsScope.get().defaultShell));
720
+ ctx.tools.register(terminalTool(ctx, terminalRegistry, paths, () => currentShell()));
715
721
 
716
722
  // The model-facing description must track the user's chosen default
717
723
  // terminal: a static description listing every backend leaves the model
@@ -720,7 +726,7 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
720
726
  // without a restart.
721
727
  ctx.on("system-prompt/assemble", async (_assembly, _context, next) => {
722
728
  const assembled = await next();
723
- const description = toolDescription(backgroundEnabled, settingsScope.get().defaultShell);
729
+ const description = toolDescription(backgroundEnabled, currentShell());
724
730
  const tools = Array.isArray(assembled.tools)
725
731
  ? assembled.tools.map((tool) => (tool.name === toolName ? { ...tool, description } : tool))
726
732
  : assembled.tools;
@@ -729,7 +735,7 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
729
735
 
730
736
  ctx.tools.register(defineTool<ShellToolResult>({
731
737
  name: toolName,
732
- description: toolDescription(backgroundEnabled, settingsScope.get().defaultShell),
738
+ description: toolDescription(backgroundEnabled, currentShell()),
733
739
  parameters: {
734
740
  command: {
735
741
  type: "string",
@@ -826,10 +832,10 @@ export function apply(ctx: BashTerminalContext, config: Partial<ConfigValues> =
826
832
  },
827
833
  async execute(args, exec) {
828
834
  const v = validateArgs(args);
829
- const shell = settingsScope.get().defaultShell;
835
+ const shell = currentShell();
830
836
  const argv0 = buildArgv(shell, v.command, paths, v.distro);
831
837
  if (argv0[0] === undefined) {
832
- throw new Error(`dsh-bash-terminal-ts: ${shell} backend unavailable - executable not found. Install it or set the corresponding *Path config.`);
838
+ throw new Error(`dsh-bash-terminal: ${shell} backend unavailable - executable not found. Install it or set the corresponding *Path config.`);
833
839
  }
834
840
  // Only element 0 (the resolved executable) can be undefined; guarded above.
835
841
  const argv = argv0 as string[];