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.
- package/README.de.md +205 -0
- package/README.en.md +27 -7
- package/README.es.md +205 -0
- package/README.fr.md +205 -0
- package/README.it.md +205 -0
- package/README.md +29 -8
- package/README.ru.md +205 -0
- package/dist/client.core.js +46 -1
- package/dist/client.js +46 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +46 -1
- package/lib/terminal.js +18 -0
- package/package.json +125 -119
- package/src/client.tsx +249 -207
- package/src/terminal.ts +16 -2
- package/template/README.md +82 -82
- package/template/terminal-adapter.ts +343 -343
|
@@ -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
|
+
};
|