dsh-bash-terminal-ts 0.6.4 → 0.7.1

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.
@@ -1,343 +1,343 @@
1
- // Terminal adapter templates — four Windows terminal backends, ready to copy.
2
- //
3
- // Extracted from dsh-bash-terminal-ts `src/index.ts` and sanitised for reuse:
4
- // no author-local paths, no host-specific values. Copy this file — or just the
5
- // one adapter you need — into your own DSH plugin, then wire the seams listed
6
- // under "Wiring into DSH" in ./README.md.
7
- //
8
- // powershell pwsh -NoLogo -NoProfile -NonInteractive -Command <cmd>
9
- // gitbash Git for Windows bash -lc <cmd> POSIX; /d/... paths
10
- // msys2 MSYS2 bash -lc <cmd> POSIX; /c/... paths, GCC/mingw64
11
- // wsl wsl [-d <distro>] -e bash -lc <cmd> Linux; /mnt/d/... paths
12
- //
13
- // The flag choices here are not guesses: each non-obvious one was measured on
14
- // Windows and carries its reason inline. Read the comment before changing a
15
- // flag — several of them look arbitrary and are not.
16
- //
17
- // Node >= 22. No runtime dependencies beyond node:fs / node:path.
18
-
19
- import { lstatSync } from "node:fs";
20
- import { join } from "node:path";
21
-
22
- /** The four backends, in the order they appear in the settings dropdown. */
23
- export const SHELLS = ["powershell", "gitbash", "msys2", "wsl"] as const;
24
- export type ShellId = (typeof SHELLS)[number];
25
-
26
- /** Chosen when the user has never touched the setting. */
27
- export const DEFAULT_SHELL: ShellId = "powershell";
28
-
29
- /**
30
- * Forced into every child process. A shell tool feeds its output to a model, so
31
- * pagers and colour codes are pure noise: `PAGER=cat` and `NO_COLOR=1` keep the
32
- * captured stdout byte-for-byte the command's actual output.
33
- */
34
- export const ENV_OVERRIDES: Record<string, string> = {
35
- NO_COLOR: "1",
36
- TERM: "dumb",
37
- PAGER: "cat",
38
- GIT_PAGER: "cat"
39
- };
40
-
41
- // ---- executable resolution -------------------------------------------------
42
-
43
- function candidateExists(candidate: string): boolean {
44
- try {
45
- const stat = lstatSync(candidate);
46
- return stat.isFile() || stat.isSymbolicLink();
47
- } catch {
48
- return false;
49
- }
50
- }
51
-
52
- /** First candidate that exists on disk, or undefined when none does. */
53
- export function resolveFromCandidates(candidates: readonly string[]): string | undefined {
54
- for (const candidate of candidates) {
55
- if (candidateExists(candidate)) return candidate;
56
- }
57
- return undefined;
58
- }
59
-
60
- /** Well-known PowerShell install locations, then PATH entries. Newest first. */
61
- export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
62
- const programFiles = env.ProgramFiles ?? "C:\\Program Files";
63
- const systemRoot = env.SystemRoot ?? "C:\\Windows";
64
- // PowerShell 7 first: pwsh.exe is the cross-platform one and is what the
65
- // -NoLogo/-NoProfile/-NonInteractive flag set is written against.
66
- const candidates = [join(programFiles, "PowerShell", "7", "pwsh.exe")];
67
- for (const entry of (env.PATH ?? "").split(";")) {
68
- const trimmed = entry.trim().replace(/^"|"$/g, "");
69
- if (trimmed.length === 0) continue;
70
- candidates.push(join(trimmed, "pwsh.exe"));
71
- }
72
- // Windows PowerShell 5.1 as the floor — present on every Windows install.
73
- candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
74
- return candidates;
75
- }
76
-
77
- /**
78
- * Git for Windows locations, then PATH `bash.exe` entries EXCLUDING the
79
- * System32 launcher.
80
- *
81
- * That exclusion is the whole point of this function: `C:\Windows\System32\
82
- * bash.exe` also exists on machines with WSL and is the **WSL forwarder**, not
83
- * a Git Bash shell. Picking it up silently sends Git Bash commands into a Linux
84
- * VM, where `/d/...` paths and the Git for Windows toolchain do not exist.
85
- */
86
- export function candidateGitBashPaths(env: NodeJS.ProcessEnv = process.env): string[] {
87
- const programFiles = env.ProgramFiles ?? "C:\\Program Files";
88
- const systemRoot = (env.SystemRoot ?? "C:\\Windows").toLowerCase();
89
- const localAppData = env.LOCALAPPDATA ?? "";
90
- const candidates = [
91
- join(programFiles, "Git", "bin", "bash.exe"),
92
- // usr\bin\bash.exe is the MSYS-rooted twin; some installs only expose this one.
93
- join(programFiles, "Git", "usr", "bin", "bash.exe")
94
- ];
95
- // Per-user installs land here.
96
- if (localAppData.length > 0) candidates.push(join(localAppData, "Programs", "Git", "bin", "bash.exe"));
97
- for (const entry of (env.PATH ?? "").split(";")) {
98
- const trimmed = entry.trim().replace(/^"|"$/g, "");
99
- if (trimmed.length === 0) continue;
100
- if (trimmed.toLowerCase().includes(systemRoot)) continue; // ← the WSL forwarder
101
- candidates.push(join(trimmed, "bash.exe"));
102
- }
103
- return candidates;
104
- }
105
-
106
- /**
107
- * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
108
- * first, then bin\bash.exe, and `msys2.exe` dead last.
109
- *
110
- * `msys2.exe` is NOT a usable backend for piped execution. It is the
111
- * console-allocating Cygwin launcher: spawned with piped stdio it returns exit
112
- * 0 with zero bytes on both stdout and stderr, so every command looks like it
113
- * succeeded and printed nothing. It stays in the list only as a last resort;
114
- * a working `bash.exe` always wins.
115
- */
116
- export function candidateMsys2Paths(env: NodeJS.ProcessEnv = process.env): string[] {
117
- const candidates = [
118
- "C:\\msys64\\usr\\bin\\bash.exe",
119
- "C:\\msys64\\bin\\bash.exe"
120
- ];
121
- for (const entry of (env.PATH ?? "").split(";")) {
122
- const trimmed = entry.trim().replace(/^"|"$/g, "");
123
- if (trimmed.length === 0) continue;
124
- const lower = trimmed.toLowerCase();
125
- if (lower.includes("msys64") || lower.includes("mingw64")) {
126
- candidates.push(join(trimmed, "bash.exe"));
127
- }
128
- }
129
- candidates.push("C:\\msys64\\msys2.exe"); // last resort — see the note above
130
- return candidates;
131
- }
132
-
133
- /** `wsl.exe` always ships at this fixed location; it is never "not installed". */
134
- export function defaultWslPath(env: NodeJS.ProcessEnv = process.env): string {
135
- const systemRoot = env.SystemRoot ?? "C:\\Windows";
136
- return join(systemRoot, "System32", "wsl.exe");
137
- }
138
-
139
- export interface ResolvedPaths {
140
- pwsh?: string;
141
- gitbash?: string;
142
- msys2?: string;
143
- wsl?: string;
144
- }
145
-
146
- /** Per-backend executable overrides; an empty string means "auto-detect". */
147
- export interface PathConfig {
148
- pwshPath?: string;
149
- gitBashPath?: string;
150
- msys2Path?: string;
151
- wslPath?: string;
152
- }
153
-
154
- /**
155
- * Resolve all four executables. A backend resolves to `undefined` when it is
156
- * not installed — surface that as a loud per-call error rather than a silent
157
- * fallback to another shell, or the user's explicit choice gets ignored.
158
- */
159
- export function resolveAllPaths(config: PathConfig = {}, env: NodeJS.ProcessEnv = process.env): ResolvedPaths {
160
- const pick = (override: string | undefined, candidates: readonly string[]): string | undefined =>
161
- override !== undefined && override.trim().length > 0 ? override : resolveFromCandidates(candidates);
162
- return {
163
- pwsh: pick(config.pwshPath, candidatePwshPaths(env)),
164
- gitbash: pick(config.gitBashPath, candidateGitBashPaths(env)),
165
- msys2: pick(config.msys2Path, candidateMsys2Paths(env)),
166
- // wsl.exe is not probed: it is a Windows component, so treat it as present.
167
- wsl: config.wslPath !== undefined && config.wslPath.trim().length > 0 ? config.wslPath : defaultWslPath(env)
168
- };
169
- }
170
-
171
- // ---- argv construction -----------------------------------------------------
172
-
173
- /** Prepend the backend-specific command flags. `command` always goes last. */
174
- export function buildArgv(
175
- shell: ShellId,
176
- command: string,
177
- paths: ResolvedPaths,
178
- distro?: string
179
- ): Array<string | undefined> {
180
- switch (shell) {
181
- case "powershell":
182
- // -NonInteractive so a stray prompt can never hang the capture; the
183
- // model's command is one complete script, not a REPL session.
184
- return [paths.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command];
185
- case "gitbash":
186
- // -l (login shell) matters: it sources /etc/profile, which is what puts
187
- // /usr/bin and /mingw64/bin on PATH. With a bare -c, `git`, `ssh` and
188
- // friends are "command not found".
189
- return [paths.gitbash, "-lc", command];
190
- case "msys2":
191
- // Same -l reasoning as gitbash, and it also picks up /etc/profile's
192
- // MSYSTEM handling that puts gcc/make on PATH.
193
- return [paths.msys2, "-lc", command];
194
- case "wsl": {
195
- const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
196
- // `-e bash -lc` — not `wsl bash -lc`: `-e` runs the command directly and
197
- // without a shell wrapper, so quoting survives intact.
198
- return [paths.wsl, ...distroArg, "-e", "bash", "-lc", command];
199
- }
200
- default: {
201
- const exhaustive: never = shell;
202
- throw new Error(`invalid shell: ${JSON.stringify(exhaustive)} (expected one of ${SHELLS.join(", ")})`);
203
- }
204
- }
205
- }
206
-
207
- // ---- environment -----------------------------------------------------------
208
-
209
- /**
210
- * Merge the host environment with per-backend fixups.
211
- *
212
- * @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
213
- * replace the child environment wholesale (a PTY path, say) must pass
214
- * `process.env.WSLENV` explicitly, because the ambient value is not visible
215
- * through `dshEnv`.
216
- */
217
- export function buildEnv(
218
- shell: ShellId,
219
- hostEnv?: Record<string, string>,
220
- inheritedWslenv: string | undefined = process.env.WSLENV
221
- ): Record<string, string | undefined> {
222
- const env: Record<string, string | undefined> = { ...ENV_OVERRIDES, ...hostEnv };
223
- if (shell === "msys2") {
224
- // Selects the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
225
- // PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
226
- // environment and the toolchain is invisible. An explicit caller value wins.
227
- if (env.MSYSTEM === undefined) env.MSYSTEM = "MINGW64";
228
- }
229
- if (shell === "wsl") {
230
- const keys = Object.keys(hostEnv ?? {});
231
- if (keys.length > 0) {
232
- // WSLENV is an allow-list: only the variables named in it cross into WSL.
233
- // LAYER onto the inherited value, never rebuild it — dropping inherited
234
- // entries (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`)
235
- // would silently stop them crossing.
236
- //
237
- // The base is SPLIT on ":" rather than concatenated because the host
238
- // value ends with a trailing ":", so string concatenation would produce a
239
- // malformed empty entry.
240
- const declared = Object.prototype.hasOwnProperty.call(hostEnv ?? {}, "WSLENV");
241
- const base = declared
242
- ? env.WSLENV
243
- : (typeof inheritedWslenv === "string" && inheritedWslenv.length > 0 ? inheritedWslenv : env.WSLENV);
244
- const parts = typeof base === "string" ? base.split(":") : [];
245
- env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
246
- .map((p) => p.trim())
247
- .filter((p) => p.length > 0)
248
- .join(":");
249
- }
250
- }
251
- return env;
252
- }
253
-
254
- // ---- sandbox confinement ---------------------------------------------------
255
-
256
- export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
257
-
258
- /**
259
- * Whether a backend can be wrapped by your sandbox seam, and what to report
260
- * when it cannot.
261
- *
262
- * Only PowerShell is confinable on Windows. Git Bash and MSYS2 must NOT be
263
- * wrapped: DSH's Windows ACL restricted-token runner cannot start a
264
- * Cygwin/MSYS2 process — bash aborts during startup with
265
- * `CreateFileMapping ... Win32 error 5` — so wrapping them would abort every
266
- * command instead of confining it. WSL is its own Linux VM; that isolation IS
267
- * the sandbox, so wrapping it is redundant.
268
- *
269
- * Publish this honestly: report `*-unconfined` rather than claiming a
270
- * confinement that did not happen.
271
- */
272
- export function confinementFor(mode: SandboxMode, shell: ShellId): string {
273
- if (shell === "wsl") return "wsl-isolation";
274
- if (shell === "gitbash") return "gitbash-unconfined";
275
- if (shell === "msys2") return "msys2-unconfined";
276
- return mode === "danger-full-access" ? "none" : "powershell-confined";
277
- }
278
-
279
- /** True when the spawn argv must be passed through the sandbox facade. */
280
- export function shouldConfine(mode: SandboxMode, shell: ShellId): boolean {
281
- return mode !== "danger-full-access" && shell === "powershell";
282
- }
283
-
284
- // ---- the adapter table -----------------------------------------------------
285
-
286
- export interface TerminalAdapter {
287
- readonly id: ShellId;
288
- /** Shown in the settings dropdown. */
289
- readonly label: string;
290
- /** One line describing the backend, suitable for a settings-row caption. */
291
- readonly summary: string;
292
- /** Model-facing tool description when this backend is active. */
293
- readonly toolDescription: string;
294
- /** Candidate executables, best first. */
295
- candidates(env?: NodeJS.ProcessEnv): string[];
296
- /** Path key in your config schema, or undefined when there is nothing to override. */
297
- readonly configKey?: keyof PathConfig;
298
- /** Whether this backend's spawn argv may be confined. */
299
- readonly confinable: boolean;
300
- }
301
-
302
- export const ADAPTERS: Record<ShellId, TerminalAdapter> = {
303
- powershell: {
304
- id: "powershell",
305
- label: "PowerShell",
306
- summary: "PowerShell 7 (pwsh), the Windows default",
307
- toolDescription:
308
- "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.",
309
- candidates: candidatePwshPaths,
310
- configKey: "pwshPath",
311
- confinable: true
312
- },
313
- gitbash: {
314
- id: "gitbash",
315
- label: "Git Bash",
316
- summary: "Git for Windows bash — POSIX syntax, /d/... paths",
317
- toolDescription:
318
- "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.",
319
- candidates: candidateGitBashPaths,
320
- configKey: "gitBashPath",
321
- confinable: false
322
- },
323
- msys2: {
324
- id: "msys2",
325
- label: "MSYS2",
326
- summary: "MSYS2 bash — POSIX syntax, /c/... paths, full GCC/mingw64 toolchain",
327
- toolDescription:
328
- "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.",
329
- candidates: candidateMsys2Paths,
330
- configKey: "msys2Path",
331
- confinable: false
332
- },
333
- wsl: {
334
- id: "wsl",
335
- label: "WSL",
336
- summary: "WSL Linux — /mnt/d/... paths, optional distro",
337
- toolDescription:
338
- "Execute a bash command in WSL (wsl [-d <distro>] -e bash -lc <command>) and return its stdout/stderr. Linux syntax; paths like /mnt/d/workspace; environment variables via $NAME (crossing the boundary through WSLENV).",
339
- candidates: (env = process.env) => [defaultWslPath(env)],
340
- configKey: "wslPath",
341
- confinable: false
342
- }
343
- };
1
+ // Terminal adapter templates — four Windows terminal backends, ready to copy.
2
+ //
3
+ // Extracted from dsh-bash-terminal-ts `src/index.ts` and sanitised for reuse:
4
+ // no author-local paths, no host-specific values. Copy this file — or just the
5
+ // one adapter you need — into your own DSH plugin, then wire the seams listed
6
+ // under "Wiring into DSH" in ./README.md.
7
+ //
8
+ // powershell pwsh -NoLogo -NoProfile -NonInteractive -Command <cmd>
9
+ // gitbash Git for Windows bash -lc <cmd> POSIX; /d/... paths
10
+ // msys2 MSYS2 bash -lc <cmd> POSIX; /c/... paths, GCC/mingw64
11
+ // wsl wsl [-d <distro>] -e bash -lc <cmd> Linux; /mnt/d/... paths
12
+ //
13
+ // The flag choices here are not guesses: each non-obvious one was measured on
14
+ // Windows and carries its reason inline. Read the comment before changing a
15
+ // flag — several of them look arbitrary and are not.
16
+ //
17
+ // Node >= 22. No runtime dependencies beyond node:fs / node:path.
18
+
19
+ import { lstatSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ /** The four backends, in the order they appear in the settings dropdown. */
23
+ export const SHELLS = ["powershell", "gitbash", "msys2", "wsl"] as const;
24
+ export type ShellId = (typeof SHELLS)[number];
25
+
26
+ /** Chosen when the user has never touched the setting. */
27
+ export const DEFAULT_SHELL: ShellId = "powershell";
28
+
29
+ /**
30
+ * Forced into every child process. A shell tool feeds its output to a model, so
31
+ * pagers and colour codes are pure noise: `PAGER=cat` and `NO_COLOR=1` keep the
32
+ * captured stdout byte-for-byte the command's actual output.
33
+ */
34
+ export const ENV_OVERRIDES: Record<string, string> = {
35
+ NO_COLOR: "1",
36
+ TERM: "dumb",
37
+ PAGER: "cat",
38
+ GIT_PAGER: "cat"
39
+ };
40
+
41
+ // ---- executable resolution -------------------------------------------------
42
+
43
+ function candidateExists(candidate: string): boolean {
44
+ try {
45
+ const stat = lstatSync(candidate);
46
+ return stat.isFile() || stat.isSymbolicLink();
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** First candidate that exists on disk, or undefined when none does. */
53
+ export function resolveFromCandidates(candidates: readonly string[]): string | undefined {
54
+ for (const candidate of candidates) {
55
+ if (candidateExists(candidate)) return candidate;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** Well-known PowerShell install locations, then PATH entries. Newest first. */
61
+ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
62
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
63
+ const systemRoot = env.SystemRoot ?? "C:\\Windows";
64
+ // PowerShell 7 first: pwsh.exe is the cross-platform one and is what the
65
+ // -NoLogo/-NoProfile/-NonInteractive flag set is written against.
66
+ const candidates = [join(programFiles, "PowerShell", "7", "pwsh.exe")];
67
+ for (const entry of (env.PATH ?? "").split(";")) {
68
+ const trimmed = entry.trim().replace(/^"|"$/g, "");
69
+ if (trimmed.length === 0) continue;
70
+ candidates.push(join(trimmed, "pwsh.exe"));
71
+ }
72
+ // Windows PowerShell 5.1 as the floor — present on every Windows install.
73
+ candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
74
+ return candidates;
75
+ }
76
+
77
+ /**
78
+ * Git for Windows locations, then PATH `bash.exe` entries EXCLUDING the
79
+ * System32 launcher.
80
+ *
81
+ * That exclusion is the whole point of this function: `C:\Windows\System32\
82
+ * bash.exe` also exists on machines with WSL and is the **WSL forwarder**, not
83
+ * a Git Bash shell. Picking it up silently sends Git Bash commands into a Linux
84
+ * VM, where `/d/...` paths and the Git for Windows toolchain do not exist.
85
+ */
86
+ export function candidateGitBashPaths(env: NodeJS.ProcessEnv = process.env): string[] {
87
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
88
+ const systemRoot = (env.SystemRoot ?? "C:\\Windows").toLowerCase();
89
+ const localAppData = env.LOCALAPPDATA ?? "";
90
+ const candidates = [
91
+ join(programFiles, "Git", "bin", "bash.exe"),
92
+ // usr\bin\bash.exe is the MSYS-rooted twin; some installs only expose this one.
93
+ join(programFiles, "Git", "usr", "bin", "bash.exe")
94
+ ];
95
+ // Per-user installs land here.
96
+ if (localAppData.length > 0) candidates.push(join(localAppData, "Programs", "Git", "bin", "bash.exe"));
97
+ for (const entry of (env.PATH ?? "").split(";")) {
98
+ const trimmed = entry.trim().replace(/^"|"$/g, "");
99
+ if (trimmed.length === 0) continue;
100
+ if (trimmed.toLowerCase().includes(systemRoot)) continue; // ← the WSL forwarder
101
+ candidates.push(join(trimmed, "bash.exe"));
102
+ }
103
+ return candidates;
104
+ }
105
+
106
+ /**
107
+ * MSYS2 locations, in preference order: the real `bash.exe` under usr\bin
108
+ * first, then bin\bash.exe, and `msys2.exe` dead last.
109
+ *
110
+ * `msys2.exe` is NOT a usable backend for piped execution. It is the
111
+ * console-allocating Cygwin launcher: spawned with piped stdio it returns exit
112
+ * 0 with zero bytes on both stdout and stderr, so every command looks like it
113
+ * succeeded and printed nothing. It stays in the list only as a last resort;
114
+ * a working `bash.exe` always wins.
115
+ */
116
+ export function candidateMsys2Paths(env: NodeJS.ProcessEnv = process.env): string[] {
117
+ const candidates = [
118
+ "C:\\msys64\\usr\\bin\\bash.exe",
119
+ "C:\\msys64\\bin\\bash.exe"
120
+ ];
121
+ for (const entry of (env.PATH ?? "").split(";")) {
122
+ const trimmed = entry.trim().replace(/^"|"$/g, "");
123
+ if (trimmed.length === 0) continue;
124
+ const lower = trimmed.toLowerCase();
125
+ if (lower.includes("msys64") || lower.includes("mingw64")) {
126
+ candidates.push(join(trimmed, "bash.exe"));
127
+ }
128
+ }
129
+ candidates.push("C:\\msys64\\msys2.exe"); // last resort — see the note above
130
+ return candidates;
131
+ }
132
+
133
+ /** `wsl.exe` always ships at this fixed location; it is never "not installed". */
134
+ export function defaultWslPath(env: NodeJS.ProcessEnv = process.env): string {
135
+ const systemRoot = env.SystemRoot ?? "C:\\Windows";
136
+ return join(systemRoot, "System32", "wsl.exe");
137
+ }
138
+
139
+ export interface ResolvedPaths {
140
+ pwsh?: string;
141
+ gitbash?: string;
142
+ msys2?: string;
143
+ wsl?: string;
144
+ }
145
+
146
+ /** Per-backend executable overrides; an empty string means "auto-detect". */
147
+ export interface PathConfig {
148
+ pwshPath?: string;
149
+ gitBashPath?: string;
150
+ msys2Path?: string;
151
+ wslPath?: string;
152
+ }
153
+
154
+ /**
155
+ * Resolve all four executables. A backend resolves to `undefined` when it is
156
+ * not installed — surface that as a loud per-call error rather than a silent
157
+ * fallback to another shell, or the user's explicit choice gets ignored.
158
+ */
159
+ export function resolveAllPaths(config: PathConfig = {}, env: NodeJS.ProcessEnv = process.env): ResolvedPaths {
160
+ const pick = (override: string | undefined, candidates: readonly string[]): string | undefined =>
161
+ override !== undefined && override.trim().length > 0 ? override : resolveFromCandidates(candidates);
162
+ return {
163
+ pwsh: pick(config.pwshPath, candidatePwshPaths(env)),
164
+ gitbash: pick(config.gitBashPath, candidateGitBashPaths(env)),
165
+ msys2: pick(config.msys2Path, candidateMsys2Paths(env)),
166
+ // wsl.exe is not probed: it is a Windows component, so treat it as present.
167
+ wsl: config.wslPath !== undefined && config.wslPath.trim().length > 0 ? config.wslPath : defaultWslPath(env)
168
+ };
169
+ }
170
+
171
+ // ---- argv construction -----------------------------------------------------
172
+
173
+ /** Prepend the backend-specific command flags. `command` always goes last. */
174
+ export function buildArgv(
175
+ shell: ShellId,
176
+ command: string,
177
+ paths: ResolvedPaths,
178
+ distro?: string
179
+ ): Array<string | undefined> {
180
+ switch (shell) {
181
+ case "powershell":
182
+ // -NonInteractive so a stray prompt can never hang the capture; the
183
+ // model's command is one complete script, not a REPL session.
184
+ return [paths.pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command];
185
+ case "gitbash":
186
+ // -l (login shell) matters: it sources /etc/profile, which is what puts
187
+ // /usr/bin and /mingw64/bin on PATH. With a bare -c, `git`, `ssh` and
188
+ // friends are "command not found".
189
+ return [paths.gitbash, "-lc", command];
190
+ case "msys2":
191
+ // Same -l reasoning as gitbash, and it also picks up /etc/profile's
192
+ // MSYSTEM handling that puts gcc/make on PATH.
193
+ return [paths.msys2, "-lc", command];
194
+ case "wsl": {
195
+ const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
196
+ // `-e bash -lc` — not `wsl bash -lc`: `-e` runs the command directly and
197
+ // without a shell wrapper, so quoting survives intact.
198
+ return [paths.wsl, ...distroArg, "-e", "bash", "-lc", command];
199
+ }
200
+ default: {
201
+ const exhaustive: never = shell;
202
+ throw new Error(`invalid shell: ${JSON.stringify(exhaustive)} (expected one of ${SHELLS.join(", ")})`);
203
+ }
204
+ }
205
+ }
206
+
207
+ // ---- environment -----------------------------------------------------------
208
+
209
+ /**
210
+ * Merge the host environment with per-backend fixups.
211
+ *
212
+ * @param inheritedWslenv - the ambient WSLENV to layer onto. Callers that
213
+ * replace the child environment wholesale (a PTY path, say) must pass
214
+ * `process.env.WSLENV` explicitly, because the ambient value is not visible
215
+ * through `dshEnv`.
216
+ */
217
+ export function buildEnv(
218
+ shell: ShellId,
219
+ hostEnv?: Record<string, string>,
220
+ inheritedWslenv: string | undefined = process.env.WSLENV
221
+ ): Record<string, string | undefined> {
222
+ const env: Record<string, string | undefined> = { ...ENV_OVERRIDES, ...hostEnv };
223
+ if (shell === "msys2") {
224
+ // Selects the MINGW64 environment, so /mingw64/bin (gcc, make, ...) joins
225
+ // PATH via /etc/profile. Without it MSYS2 defaults to the bare MSYS
226
+ // environment and the toolchain is invisible. An explicit caller value wins.
227
+ if (env.MSYSTEM === undefined) env.MSYSTEM = "MINGW64";
228
+ }
229
+ if (shell === "wsl") {
230
+ const keys = Object.keys(hostEnv ?? {});
231
+ if (keys.length > 0) {
232
+ // WSLENV is an allow-list: only the variables named in it cross into WSL.
233
+ // LAYER onto the inherited value, never rebuild it — dropping inherited
234
+ // entries (Windows Terminal exports e.g. `WT_SESSION:WT_PROFILE_ID:`)
235
+ // would silently stop them crossing.
236
+ //
237
+ // The base is SPLIT on ":" rather than concatenated because the host
238
+ // value ends with a trailing ":", so string concatenation would produce a
239
+ // malformed empty entry.
240
+ const declared = Object.prototype.hasOwnProperty.call(hostEnv ?? {}, "WSLENV");
241
+ const base = declared
242
+ ? env.WSLENV
243
+ : (typeof inheritedWslenv === "string" && inheritedWslenv.length > 0 ? inheritedWslenv : env.WSLENV);
244
+ const parts = typeof base === "string" ? base.split(":") : [];
245
+ env.WSLENV = [...parts, ...keys.filter((k) => k !== "WSLENV").flatMap((k) => k.split(":"))]
246
+ .map((p) => p.trim())
247
+ .filter((p) => p.length > 0)
248
+ .join(":");
249
+ }
250
+ }
251
+ return env;
252
+ }
253
+
254
+ // ---- sandbox confinement ---------------------------------------------------
255
+
256
+ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
257
+
258
+ /**
259
+ * Whether a backend can be wrapped by your sandbox seam, and what to report
260
+ * when it cannot.
261
+ *
262
+ * Only PowerShell is confinable on Windows. Git Bash and MSYS2 must NOT be
263
+ * wrapped: DSH's Windows ACL restricted-token runner cannot start a
264
+ * Cygwin/MSYS2 process — bash aborts during startup with
265
+ * `CreateFileMapping ... Win32 error 5` — so wrapping them would abort every
266
+ * command instead of confining it. WSL is its own Linux VM; that isolation IS
267
+ * the sandbox, so wrapping it is redundant.
268
+ *
269
+ * Publish this honestly: report `*-unconfined` rather than claiming a
270
+ * confinement that did not happen.
271
+ */
272
+ export function confinementFor(mode: SandboxMode, shell: ShellId): string {
273
+ if (shell === "wsl") return "wsl-isolation";
274
+ if (shell === "gitbash") return "gitbash-unconfined";
275
+ if (shell === "msys2") return "msys2-unconfined";
276
+ return mode === "danger-full-access" ? "none" : "powershell-confined";
277
+ }
278
+
279
+ /** True when the spawn argv must be passed through the sandbox facade. */
280
+ export function shouldConfine(mode: SandboxMode, shell: ShellId): boolean {
281
+ return mode !== "danger-full-access" && shell === "powershell";
282
+ }
283
+
284
+ // ---- the adapter table -----------------------------------------------------
285
+
286
+ export interface TerminalAdapter {
287
+ readonly id: ShellId;
288
+ /** Shown in the settings dropdown. */
289
+ readonly label: string;
290
+ /** One line describing the backend, suitable for a settings-row caption. */
291
+ readonly summary: string;
292
+ /** Model-facing tool description when this backend is active. */
293
+ readonly toolDescription: string;
294
+ /** Candidate executables, best first. */
295
+ candidates(env?: NodeJS.ProcessEnv): string[];
296
+ /** Path key in your config schema, or undefined when there is nothing to override. */
297
+ readonly configKey?: keyof PathConfig;
298
+ /** Whether this backend's spawn argv may be confined. */
299
+ readonly confinable: boolean;
300
+ }
301
+
302
+ export const ADAPTERS: Record<ShellId, TerminalAdapter> = {
303
+ powershell: {
304
+ id: "powershell",
305
+ label: "PowerShell",
306
+ summary: "PowerShell 7 (pwsh), the Windows default",
307
+ toolDescription:
308
+ "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.",
309
+ candidates: candidatePwshPaths,
310
+ configKey: "pwshPath",
311
+ confinable: true
312
+ },
313
+ gitbash: {
314
+ id: "gitbash",
315
+ label: "Git Bash",
316
+ summary: "Git for Windows bash — POSIX syntax, /d/... paths",
317
+ toolDescription:
318
+ "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.",
319
+ candidates: candidateGitBashPaths,
320
+ configKey: "gitBashPath",
321
+ confinable: false
322
+ },
323
+ msys2: {
324
+ id: "msys2",
325
+ label: "MSYS2",
326
+ summary: "MSYS2 bash — POSIX syntax, /c/... paths, full GCC/mingw64 toolchain",
327
+ toolDescription:
328
+ "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.",
329
+ candidates: candidateMsys2Paths,
330
+ configKey: "msys2Path",
331
+ confinable: false
332
+ },
333
+ wsl: {
334
+ id: "wsl",
335
+ label: "WSL",
336
+ summary: "WSL Linux — /mnt/d/... paths, optional distro",
337
+ toolDescription:
338
+ "Execute a bash command in WSL (wsl [-d <distro>] -e bash -lc <command>) and return its stdout/stderr. Linux syntax; paths like /mnt/d/workspace; environment variables via $NAME (crossing the boundary through WSLENV).",
339
+ candidates: (env = process.env) => [defaultWslPath(env)],
340
+ configKey: "wslPath",
341
+ confinable: false
342
+ }
343
+ };