dsh-bash-terminal-ts 0.4.0 → 0.5.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/lib/index.js 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
  import { lstatSync } from "node:fs";
24
25
  import { isAbsolute, join, resolve } from "node:path";
25
26
  import { ESCALATION_TARGETS, TOOL_ABORTED, approveEscalation, defineTool, escalationHintMarker, clampTimeout, deadline, parseExitStatus, timeoutOf, sandboxDenialMarker, validateEscalationArgs, z, HarnessError } from "./dsh.js";
@@ -120,34 +121,30 @@ export function candidateGitBashPaths(env = process.env) {
120
121
  return candidates;
121
122
  }
122
123
  /**
123
- * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin first,
124
- * then bin\bash.exe, and `msys2.exe` dead last.
124
+ * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
125
+ * first, then bin\bash.exe, and `msys2.exe` dead last.
125
126
  *
126
127
  * msys2.exe is NOT a usable backend for piped execution: it is the console-
127
128
  * allocating Cygwin launcher, so a spawn with piped stdio returns exit 0 with
128
- * zero bytes on both stdout and stderr (measured on MSYS2 with bash 5.3.15).
129
- * Keeping it in the list only as a last-resort fallback preserves the path the
130
- * config docs reference, but a working bash.exe always wins.
129
+ * zero bytes on both stdout and stderr (measured on this machine against
130
+ * MSYS2 with bash 5.3.15). Keeping it in the list only as a last-resort
131
+ * fallback preserves the path the config docs reference, but a working
132
+ * bash.exe always wins.
131
133
  *
132
- * MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run under
133
- * the DSH Windows ACL restricted-token sandbox.
134
+ * MSYS2 uses the same Cygwin/MSYS2 runtime as Git Bash, so it cannot run
135
+ * under the DSH Windows ACL restricted-token sandbox.
134
136
  */
135
137
  export function candidateMsys2Paths(env = process.env) {
136
- const programFiles = env.ProgramFiles ?? "C:\\Program Files";
137
138
  const candidates = [
138
139
  "C:\\msys64\\usr\\bin\\bash.exe",
139
140
  "C:\\msys64\\bin\\bash.exe"
140
141
  ];
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
142
  for (const entry of (env.PATH ?? "").split(";")) {
147
143
  const trimmed = entry.trim().replace(/^"|"$/g, "");
148
144
  if (trimmed.length === 0)
149
145
  continue;
150
- if (trimmed.toLowerCase().includes("msys64") || trimmed.toLowerCase().includes("mingw64")) {
146
+ const lower = trimmed.toLowerCase();
147
+ if (lower.includes("msys64") || lower.includes("mingw64")) {
151
148
  candidates.push(join(trimmed, "bash.exe"));
152
149
  }
153
150
  }
@@ -195,40 +192,42 @@ export function buildArgv(shell, command, paths, distro) {
195
192
  }
196
193
  /**
197
194
  * 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.
195
+ * variables explicitly listed in WSLENV cross the boundary, so every DSH_*
196
+ * key is appended there — and the list is *layered onto* whatever WSLENV the
197
+ * host already had (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`),
198
+ * never rebuilt from scratch: WSLENV is an allow-list, so dropping the
199
+ * inherited entries would silently stop them crossing into WSL.
206
200
  *
207
- * @param inheritedWslenv - the WSLENV the child would otherwise inherit.
201
+ * @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
202
+ * replace the child environment wholesale (the PTY path) must pass
203
+ * `process.env.WSLENV` explicitly, because the ambient value is not visible
204
+ * through `dshEnv`.
208
205
  */
209
206
  export function buildEnv(shell, dshEnv, inheritedWslenv = process.env.WSLENV) {
210
207
  const env = { ...ENV_OVERRIDES, ...dshEnv };
211
208
  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.
209
+ // Picks the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
210
+ // PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
211
+ // environment. An explicit caller-supplied value wins.
214
212
  if (env.MSYSTEM === undefined)
215
213
  env.MSYSTEM = "MINGW64";
216
214
  }
217
215
  if (shell === "wsl") {
218
216
  const keys = Object.keys(dshEnv ?? {});
219
217
  if (keys.length > 0) {
220
- // An explicit WSLENV from the caller wins over what we would inherit.
218
+ // An explicit WSLENV in dshEnv wins over the inherited one; otherwise the
219
+ // inherited value is the base. Either way the base is SPLIT into entries
220
+ // (the host value ends with a trailing ":", so string concatenation would
221
+ // produce a malformed empty entry) and the WSLENV key itself is excluded
222
+ // (it is a key of dshEnv too, and appending it would add a bogus entry).
221
223
  const declared = Object.prototype.hasOwnProperty.call(dshEnv ?? {}, "WSLENV");
222
224
  const base = declared
223
225
  ? env.WSLENV
224
226
  : (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
227
  const parts = typeof base === "string" ? base.split(":") : [];
229
228
  env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
230
- .map((part) => part.trim())
231
- .filter((part) => part.length > 0)
229
+ .map((p) => p.trim())
230
+ .filter((p) => p.length > 0)
232
231
  .join(":");
233
232
  }
234
233
  }
@@ -252,7 +251,7 @@ function spawnSpec(resolved, argv, env, signal) {
252
251
  function collectedOutput(handle) {
253
252
  const { stdout, stderr } = handle.collected;
254
253
  if (!isCollectedStream(stdout) || !isCollectedStream(stderr)) {
255
- throw new Error("dsh-bash-terminal-ts: subprocess implementation dropped a requested collect stream");
254
+ throw new Error("dsh-bash-terminal: subprocess implementation dropped a requested collect stream");
256
255
  }
257
256
  return { stdout, stderr };
258
257
  }
@@ -293,7 +292,7 @@ async function runForeground(ctx, argv, resolved, env, signal, timeoutMs) {
293
292
  function requireSubprocess(ctx) {
294
293
  const subprocess = ctx.subprocess;
295
294
  if (subprocess === null) {
296
- throw new Error("dsh-bash-terminal-ts: ctx.subprocess seam unavailable (missing inject service)");
295
+ throw new Error("dsh-bash-terminal: ctx.subprocess seam unavailable (missing inject service)");
297
296
  }
298
297
  return subprocess;
299
298
  }
@@ -442,8 +441,9 @@ export function validateArgs(args) {
442
441
  * Git Bash and MSYS2 are also not confined: DSH's Windows ACL restricted-token
443
442
  * runner cannot start Cygwin/MSYS2 (CreateFileMapping Win32 error 5), so
444
443
  * attempting to wrap them would abort every Git Bash / MSYS2 command. A
445
- * requested confined mode with no usable backend throws the fail-closed
446
- * SandboxUnavailableError, exactly like the shipped executors.
444
+ * requested confined mode
445
+ * with no usable backend throws the fail-closed SandboxUnavailableError,
446
+ * exactly like the shipped executors.
447
447
  */
448
448
  function confineSpawn(ctx, argv, policy, shell) {
449
449
  if (policy.mode === "danger-full-access" || shell === "wsl" || shell === "gitbash" || shell === "msys2") {
@@ -495,14 +495,14 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
495
495
  // ---- plugin -------------------------------------------------------------------
496
496
  export function apply(ctx, config = {}) {
497
497
  if (process.platform !== "win32") {
498
- ctx.logger?.info?.("dsh-bash-terminal-ts: only meaningful on win32; skipping tool registration");
498
+ ctx.logger?.info?.("dsh-bash-terminal: only meaningful on win32; skipping tool registration");
499
499
  return;
500
500
  }
501
501
  const backgroundEnabled = true;
502
502
  const paths = resolveAllPaths(config);
503
503
  const defaultShell = config.defaultShell ?? DEFAULT_SHELL;
504
504
  if (!SHELLS.includes(defaultShell)) {
505
- throw new Error(`dsh-bash-terminal-ts: invalid defaultShell ${JSON.stringify(defaultShell)}`);
505
+ throw new Error(`dsh-bash-terminal: invalid defaultShell ${JSON.stringify(defaultShell)}`);
506
506
  }
507
507
  const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, z.object({ defaultShell: z.union(SHELLS.map((s) => z.const(s))).default(defaultShell) }), { base: { defaultShell } });
508
508
  /** Official sandbox-escalation surface (mirrors tool-bash): advertise the
@@ -525,7 +525,7 @@ export function apply(ctx, config = {}) {
525
525
  ctx.systemPrompt.section({
526
526
  name: "tool:bash-terminal",
527
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."
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, 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."
529
529
  });
530
530
  const toolName = "shell";
531
531
  const terminalRegistry = createTerminalRegistry(ctx);
@@ -647,7 +647,7 @@ export function apply(ctx, config = {}) {
647
647
  const shell = settingsScope.get().defaultShell;
648
648
  const argv0 = buildArgv(shell, v.command, paths, v.distro);
649
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.`);
650
+ throw new Error(`dsh-bash-terminal: ${shell} backend unavailable - executable not found. Install it or set the corresponding *Path config.`);
651
651
  }
652
652
  // Only element 0 (the resolved executable) can be undefined; guarded above.
653
653
  const argv = argv0;
package/lib/terminal.d.ts CHANGED
@@ -4,14 +4,23 @@ export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
4
4
  interface TerminalBufferRead {
5
5
  delta: string;
6
6
  nextOffset: number;
7
+ truncated: boolean;
7
8
  }
8
9
  interface TerminalBuffer {
9
10
  append(chunk: string): void;
10
11
  readFrom(offset: number): TerminalBufferRead;
11
12
  snapshot(): string;
13
+ wasTruncated(): boolean;
14
+ lastAppendAt(): number;
12
15
  }
13
16
  /** Interactive-shell argv (no -c: the terminal itself is the session). */
14
17
  export declare function terminalArgv(shell: string, paths: ResolvedPaths, distro?: string): Array<string | undefined>;
18
+ export interface TerminalSessionInfo {
19
+ sessionId: string;
20
+ shell: string;
21
+ pid: number;
22
+ closed: boolean;
23
+ }
15
24
  interface TerminalSession {
16
25
  id: string;
17
26
  handle: TerminalHandle;
@@ -19,6 +28,9 @@ interface TerminalSession {
19
28
  shell: string;
20
29
  distro?: string;
21
30
  closed: boolean;
31
+ idleMs: number;
32
+ idleTimer?: NodeJS.Timeout;
33
+ touch: () => void;
22
34
  }
23
35
  export interface TerminalOpenOptions {
24
36
  argv: string[];
@@ -29,6 +41,7 @@ export interface TerminalOpenOptions {
29
41
  cols: number;
30
42
  distro?: string;
31
43
  initial?: string;
44
+ idleMs?: number;
32
45
  }
33
46
  export interface TerminalRegistry {
34
47
  open(options: TerminalOpenOptions): Promise<TerminalSession>;
@@ -37,6 +50,7 @@ export interface TerminalRegistry {
37
50
  read(id: string): TerminalBufferRead;
38
51
  signal(id: string, sig: string): Promise<TerminalBufferRead>;
39
52
  close(id: string): Promise<boolean>;
53
+ list(): TerminalSessionInfo[];
40
54
  }
41
55
  /**
42
56
  * Terminal-session registry owned by the plugin fiber: opens PTYs through
@@ -51,6 +65,7 @@ export type ValidatedTerminalArgs = {
51
65
  command?: string;
52
66
  distro?: string;
53
67
  workdir?: string;
68
+ idleMs?: number;
54
69
  } | {
55
70
  action: "send";
56
71
  sessionId: string;
@@ -65,11 +80,14 @@ export type ValidatedTerminalArgs = {
65
80
  } | {
66
81
  action: "close";
67
82
  sessionId: string;
83
+ } | {
84
+ action: "list";
68
85
  };
69
86
  export declare function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs;
70
87
  export interface TerminalOpenResult {
71
88
  kind: "open";
72
89
  sessionId: string;
90
+ jobId?: string;
73
91
  pid: number;
74
92
  shell: string;
75
93
  output: string;
@@ -77,11 +95,16 @@ export interface TerminalOpenResult {
77
95
  export interface TerminalSessionResult {
78
96
  kind: "session";
79
97
  output: string;
98
+ truncated?: boolean;
80
99
  }
81
100
  export interface TerminalClosedResult {
82
101
  kind: "closed";
83
102
  sessionId: string;
84
103
  }
85
- export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult;
104
+ export interface TerminalListResult {
105
+ kind: "list";
106
+ sessions: TerminalSessionInfo[];
107
+ }
108
+ export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult | TerminalListResult;
86
109
  export declare function terminalTool(ctx: BashTerminalContext, registry: TerminalRegistry, paths: ResolvedPaths, defaultShell: () => string): import("./dsh-types.js").ToolDefinition;
87
110
  export {};