dsh-win-multi-bash 0.1.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.
@@ -0,0 +1,382 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
4
+ import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from "../vendor/helpers.js";
5
+ import { lstatSync } from "node:fs";
6
+ import { basename, dirname, join } from "node:path";
7
+ /**
8
+ * Derive a config schema from a base object schema WITHOUT mutating it.
9
+ * schemastery's Schema#set() mutates the shared `dict` in place, so chaining
10
+ * .set() on LocalBashExecutor.Config lets whichever subclass module loads
11
+ * last overwrite the earlier one's fields — git-bash's probeTimeoutMs default
12
+ * silently became wsl-bash's 30000 and its sandbox union gained the invalid
13
+ * 'bwrap' value. Spreading the base dict into a fresh z.object makes every
14
+ * derivation independent of both the base and other subclasses.
15
+ */
16
+ function deriveConfig(base, fields) {
17
+ const derived = z.object({ ...base.dict });
18
+ if (base.meta !== void 0) derived.meta = { ...base.meta };
19
+ for (const key of Object.keys(fields)) derived.set(key, fields[key]);
20
+ return derived;
21
+ }
22
+ /**
23
+ * The PATH handed to MSYS bash: the resolved Git installation's layout dirs
24
+ * (`<root>\cmd`, `<root>\bin`, `<root>\usr\bin`) prepended to the inherited
25
+ * PATH. The spawn environment is the scrubbed parent env with explicit
26
+ * overrides, so without this the Git toolchain (sleep, grep, git, ...) would
27
+ * be invisible inside bash even though bash.exe itself is spawned by absolute
28
+ * path. The root is inferred from the bash location (`<root>\usr\bin\bash.exe`
29
+ * or `<root>\bin\bash.exe`); when the location does not look like a Git layout
30
+ * (e.g. a PATH-resolved foreign bash) only its own directory is prepended.
31
+ */
32
+ function gitToolPath(bashPath, inheritedPath) {
33
+ const binDir = dirname(bashPath);
34
+ const base = basename(binDir).toLowerCase();
35
+ const parent = dirname(binDir);
36
+ const parentBase = basename(parent).toLowerCase();
37
+ let root;
38
+ if (base === "cmd") root = parent;
39
+ else if (base === "bin") root = parentBase === "usr" ? dirname(parent) : parent;
40
+ const dirs = [];
41
+ if (root !== void 0) {
42
+ for (const dir of [join(root, "cmd"), join(root, "bin"), join(root, "usr", "bin")]) {
43
+ if (!dirs.includes(dir)) dirs.push(dir);
44
+ }
45
+ } else {
46
+ dirs.push(binDir);
47
+ }
48
+ const rest = (inheritedPath ?? "").split(";").filter((entry) => entry.length > 0);
49
+ return [...dirs, ...rest].join(";");
50
+ }
51
+ //#region lib/types/resolve.js
52
+ /**
53
+ * Git Bash executable resolution for the bash-git executor, dependency-free
54
+ * and parameterized (env/platform) so resolution is a pure function of its
55
+ * inputs on every platform — the twin of dsh-pwsh-local's resolvePwshPath.
56
+ * @module @deepseek-ai/dsh-bash-git/resolve
57
+ */
58
+ /**
59
+ * Infer Git for Windows install roots from PATH entries shaped like its
60
+ * layout directories (`<root>\cmd`, `<root>\bin`, `<root>\usr\bin`) that
61
+ * actually contain `git.exe`. This lets resolution find a Git Bash that lives
62
+ * outside the well-known Program Files probes (e.g. `D:\Program Files\Git`
63
+ * with only `cmd` on PATH) without any manual pin. Only directories carrying a
64
+ * real `git.exe` qualify, so unrelated `bin` dirs (Strawberry Perl, Scoop
65
+ * shims, ...) are never mistaken for Git roots.
66
+ * @param env - the environment to probe; defaults to the process environment.
67
+ * @returns Git install roots in PATH order.
68
+ */
69
+ function gitRootCandidates(env = process.env) {
70
+ const roots = [];
71
+ const seen = new Set();
72
+ for (const entry of (env.PATH ?? "").split(";")) {
73
+ const trimmed = entry.trim().replace(/^"|"$/g, "");
74
+ if (trimmed.length === 0) continue;
75
+ const base = basename(trimmed).toLowerCase();
76
+ if (base !== "cmd" && base !== "bin") continue;
77
+ const parent = dirname(trimmed);
78
+ const root = base === "cmd" || basename(parent).toLowerCase() !== "usr" ? parent : dirname(parent);
79
+ if (!candidateExists(join(trimmed, "git.exe"))) continue;
80
+ const key = root.toLowerCase();
81
+ if (!seen.has(key)) {
82
+ seen.add(key);
83
+ roots.push(root);
84
+ }
85
+ }
86
+ return roots;
87
+ }
88
+ /**
89
+ * Well-known Git for Windows install locations plus PATH entries. The Windows
90
+ * WSL launcher (`System32\bash.exe`) is deliberately excluded: this executor is
91
+ * MSYS Git Bash, and silently routing it into WSL would make a non-WSL tool
92
+ * depend on WSL — resolution then either finds a real Git Bash or fails loud
93
+ * with the `bashPath` hint. PATH entries shaped like Git layout directories
94
+ * with a real `git.exe` additionally contribute their `<root>\usr\bin` and
95
+ * `<root>\bin` bash candidates, so an install reachable through `git` on PATH
96
+ * is found automatically.
97
+ * @param env - the environment to probe; defaults to the process environment.
98
+ * @returns candidate `bash.exe` paths in resolution order.
99
+ */
100
+ function candidateBashPaths(env = process.env) {
101
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
102
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
103
+ const wslLauncher = join(env.SystemRoot ?? "C:\\Windows", "System32", "bash.exe").toLowerCase();
104
+ const candidates = [
105
+ join(programFiles, "Git", "bin", "bash.exe"),
106
+ join(programFiles, "Git", "usr", "bin", "bash.exe"),
107
+ join(programFilesX86, "Git", "bin", "bash.exe")
108
+ ];
109
+ for (const entry of (env.PATH ?? "").split(";")) {
110
+ const trimmed = entry.trim().replace(/^"|"$/g, "");
111
+ if (trimmed.length === 0) continue;
112
+ const candidate = join(trimmed, "bash.exe");
113
+ if (candidate.toLowerCase() === wslLauncher) continue;
114
+ candidates.push(candidate);
115
+ }
116
+ for (const root of gitRootCandidates(env)) {
117
+ for (const candidate of [join(root, "usr", "bin", "bash.exe"), join(root, "bin", "bash.exe")]) {
118
+ if (!candidates.includes(candidate)) candidates.push(candidate);
119
+ }
120
+ }
121
+ return candidates;
122
+ }
123
+ function candidateExists(candidate) {
124
+ try {
125
+ const stat = lstatSync(candidate);
126
+ return stat.isFile() || stat.isSymbolicLink();
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * Resolve the Git Bash executable this executor spawns. Returns undefined on
133
+ * win32 when no candidate exists (the executor turns that into a loud
134
+ * route-time error naming the probes); non-win32 hosts resolve a bare
135
+ * `bash` for PATH resolution, mirroring resolvePwshPath.
136
+ * @param configured - an explicit `bashPath` config value, trusted as-is.
137
+ * @param env - the environment to probe on Windows; defaults to the process environment.
138
+ * @param platform - the platform to resolve for; defaults to the process platform.
139
+ * @returns the first existing well-known location on win32, else `bash`, else undefined.
140
+ */
141
+ function resolveBashPath(configured, env = process.env, platform = process.platform) {
142
+ if (configured !== void 0 && configured.length > 0) return configured;
143
+ if (platform === "win32") {
144
+ for (const candidate of candidateBashPaths(env)) if (candidateExists(candidate)) return candidate;
145
+ return;
146
+ }
147
+ return "bash";
148
+ }
149
+ /**
150
+ * Registry-backed Git Bash locations: Git for Windows always records its
151
+ * install path at `HKLM\SOFTWARE\GitForWindows\InstallPath`, which is the
152
+ * reliable signal for installs outside every probe path (custom drives,
153
+ * portable installs, ...). Read via `reg query` (spawnSync) — any failure is
154
+ * an empty list, so a missing registry never breaks resolution.
155
+ * @returns the first existing bash.exe under the registered install, or undefined.
156
+ */
157
+ function registryBashPaths() {
158
+ if (process.platform !== "win32") return;
159
+ try {
160
+ const run = spawnSync("reg", ["query", "HKLM\\SOFTWARE\\GitForWindows", "/v", "InstallPath"], {
161
+ encoding: "utf8",
162
+ timeout: 1e4
163
+ });
164
+ if (run.status !== 0 || run.error !== void 0) return;
165
+ const line = run.stdout.split(/\r?\n/).find((l) => /InstallPath/i.test(l) && /REG_SZ/i.test(l));
166
+ const install = line?.split("REG_SZ")[1]?.trim();
167
+ if (install === void 0 || install.length === 0) return;
168
+ for (const candidate of [join(install, "usr", "bin", "bash.exe"), join(install, "bin", "bash.exe")]) {
169
+ if (candidateExists(candidate)) return candidate;
170
+ }
171
+ } catch {
172
+ /* registry probe must never break resolution */
173
+ }
174
+ }
175
+ //#endregion
176
+ //#region lib/types/index.js
177
+ /**
178
+ * Git Bash Service Provider for the bash capability seam: a LocalBashExecutor
179
+ * whose argv is `[bashPath, '-c', command]` (MSYS bash, clean profile-less
180
+ * environment). Sandbox policy `auto` probes whether the windows-acl runner
181
+ * can launch Cygwin bash (once, lazily, at the first sandboxMode read or the
182
+ * first routed command); when the probe fails, commands run unconfined and
183
+ * results honestly carry no sandbox facts (the declared auto contract).
184
+ * `none` never consults `ctx.sandbox`. The executable is resolved lazily at
185
+ * the first routed command and fails loud, naming the probes, when Git Bash
186
+ * is missing.
187
+ * @module @deepseek-ai/dsh-bash-git
188
+ */
189
+ /**
190
+ * Git Bash executor over the local bash mechanics: argv is the resolved
191
+ * `bash.exe` followed by `-c` and the command, and the
192
+ * sandbox stance (`auto`/`none`) is applied lazily per the
193
+ * module doc's probe contract.
194
+ */
195
+ var GitBashExecutor = class extends LocalBashExecutor {
196
+ static Config = deriveConfig(LocalBashExecutor.Config, {
197
+ bashPath: z.string(),
198
+ sandbox: z.union([z.const("auto"), z.const("none")]).default("auto"),
199
+ probeTimeoutMs: z.natural().default(1e4),
200
+ requireSandbox: z.boolean().default(false)
201
+ });
202
+ /** Test hook mirroring the sandbox-local internals pattern. */
203
+ internals = {};
204
+ sandboxStance;
205
+ probeTimeoutMs;
206
+ confinedVerdict;
207
+ constructor(ctx, config) {
208
+ super(ctx, config);
209
+ const entry = config;
210
+ this.sandboxStance = entry.sandbox;
211
+ this.probeTimeoutMs = entry.probeTimeoutMs;
212
+ this.requireSandbox = entry.requireSandbox;
213
+ }
214
+ /**
215
+ * The capability fact: the policy default mode while confinement is usable.
216
+ * With `requireSandbox`, the mode is declared even when the probe fails, so
217
+ * the tool layer advertises escalation (`sandbox_permissions`) and the
218
+ * executor can refuse unconfined runs outside danger-full-access.
219
+ */
220
+ get sandboxMode() {
221
+ if (this.sandboxStance === "none") return void 0;
222
+ if (this.requireSandbox) return this.ctx.sandboxPolicy.defaultMode;
223
+ return this.probeConfinedOnce() ? this.ctx.sandboxPolicy.defaultMode : void 0;
224
+ }
225
+ /** The exact argv every (un)confined command runs through. */
226
+ gitArgv(spec) {
227
+ return [
228
+ this.bashPath(),
229
+ "-c",
230
+ spec.command
231
+ ];
232
+ }
233
+ /** The resolved Git Bash executable; undefined resolves to a loud route-time error. */
234
+ tryBashPath() {
235
+ const path = (this.internals.resolveBashPath ?? ((configured) => resolveBashPath(configured)))(this.config.bashPath);
236
+ if (path !== void 0) return path;
237
+ return (this.internals.registryBashPaths ?? registryBashPaths)();
238
+ }
239
+ /** Route-time loud failure naming the probes, per the spec error contract. */
240
+ bashPath() {
241
+ const path = this.tryBashPath();
242
+ if (path !== void 0) return path;
243
+ throw new Error(`bash-git: Git Bash was not found (probed ${candidateBashPaths().join(", ")} and the GitForWindows registry). Install Git for Windows or set gitBash.bashPath in the shell-select config.`);
244
+ }
245
+ /**
246
+ * Probe whether the windows-acl runner can launch bash.exe, once per
247
+ * executor lifetime (lazy: the first sandboxMode read or the first routed
248
+ * command). Runs a trivial confined `exit 0` synchronously (mirroring
249
+ * sandbox-local's defaultProbeWindowsAcl shape); a spawn rejection, a
250
+ * runner-failure exit, or a missing bash.exe all mean no confinement.
251
+ */
252
+ probeConfinedOnce() {
253
+ if (this.confinedVerdict !== void 0) return this.confinedVerdict;
254
+ const probe = this.internals.probeConfined;
255
+ if (probe !== void 0) return this.confinedVerdict = probe();
256
+ const bashPath = this.tryBashPath();
257
+ if (bashPath === void 0) return this.confinedVerdict = false;
258
+ const root = this.ctx.sandboxPolicy.resolve().workspaceRoot;
259
+ let confined;
260
+ try {
261
+ confined = this.ctx.sandbox.confine([
262
+ bashPath,
263
+ "-c",
264
+ "exit 0"
265
+ ], {
266
+ mode: "read-only",
267
+ workspaceRoot: root
268
+ });
269
+ } catch {
270
+ return this.confinedVerdict = false;
271
+ }
272
+ const runner = confined.argv[0];
273
+ if (runner === void 0) return this.confinedVerdict = false;
274
+ const probeRun = spawnSync(runner, confined.argv.slice(1), {
275
+ timeout: this.probeTimeoutMs,
276
+ cwd: root,
277
+ stdio: "ignore"
278
+ });
279
+ return this.confinedVerdict = probeRun.status === 0;
280
+ }
281
+ confinedPolicy(spec) {
282
+ return spec.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
283
+ }
284
+ async run(spec) {
285
+ const policy = this.confinedPolicy(spec);
286
+ if (policy.mode === "danger-full-access") return {
287
+ ...await this.runArgv(spec, this.gitArgv(spec)),
288
+ sandbox: {
289
+ mode: "danger-full-access",
290
+ denied: false
291
+ }
292
+ };
293
+ if (this.sandboxStance === "none") return this.runArgv(spec, this.gitArgv(spec));
294
+ if (!this.probeConfinedOnce()) {
295
+ if (this.requireSandbox) throw new Error("bash-git: sandbox unavailable — the windows-acl runner cannot confine MSYS bash (probe failed); refusing to run unconfined under " + policy.mode + " mode. Fix the sandbox or retry the command with sandbox_permissions: \"danger-full-access\" and a justification.");
296
+ return this.runArgv(spec, this.gitArgv(spec));
297
+ }
298
+ const confined = this.ctx.sandbox.confine(this.gitArgv(spec), {
299
+ ...policy,
300
+ mode: policy.mode
301
+ });
302
+ let result;
303
+ try {
304
+ result = await this.runArgv(spec, confined.argv);
305
+ } catch (error) {
306
+ if (spec.signal?.aborted === true) spec.signal.throwIfAborted();
307
+ if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) return this.runArgv(spec, this.gitArgv(spec));
308
+ throw error;
309
+ }
310
+ if (classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) !== void 0) return this.runArgv(spec, this.gitArgv(spec));
311
+ return {
312
+ ...result,
313
+ sandbox: {
314
+ mode: policy.mode,
315
+ denied: classifyDenial(result, confined.denialSignatures),
316
+ enforcement: confined.enforcement
317
+ }
318
+ };
319
+ }
320
+ /** Per-process confinement facts retained until settlement (bash-sandbox pattern). */
321
+ processFacts = /* @__PURE__ */ new Map();
322
+ start(spec) {
323
+ const policy = this.confinedPolicy(spec);
324
+ if (policy.mode === "danger-full-access") return this.startArgv(spec, this.gitArgv(spec));
325
+ if (this.sandboxStance === "none") return this.startArgv(spec, this.gitArgv(spec));
326
+ if (!this.probeConfinedOnce()) {
327
+ if (this.requireSandbox) throw new Error("bash-git: sandbox unavailable — the windows-acl runner cannot confine MSYS bash (probe failed); refusing to run unconfined under " + policy.mode + " mode. Fix the sandbox or retry the command with sandbox_permissions: \"danger-full-access\" and a justification.");
328
+ return this.startArgv(spec, this.gitArgv(spec));
329
+ }
330
+ const confined = this.ctx.sandbox.confine(this.gitArgv(spec), {
331
+ ...policy,
332
+ mode: policy.mode
333
+ });
334
+ let proc;
335
+ try {
336
+ proc = this.startArgv(spec, confined.argv);
337
+ } catch (error) {
338
+ if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) return this.startArgv(spec, this.gitArgv(spec));
339
+ throw error;
340
+ }
341
+ this.processFacts.set(proc, {
342
+ mode: policy.mode,
343
+ enforcement: confined.enforcement,
344
+ denialSignatures: confined.denialSignatures,
345
+ runnerFailureRules: confined.runnerFailureRules,
346
+ runnerProgram: confined.argv[0],
347
+ workdir: spec.workdir
348
+ });
349
+ return proc;
350
+ }
351
+ /** Stamp per-process sandbox facts before `done` settles (bash-sandbox semantics). */
352
+ onProcessDone(proc, stderr, spawnFailed, spawnError) {
353
+ const facts = this.processFacts.get(proc);
354
+ if (facts !== void 0) {
355
+ this.processFacts.delete(proc);
356
+ proc.sandbox = (spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== void 0) ? {
357
+ mode: facts.mode,
358
+ denied: false,
359
+ enforcement: facts.enforcement,
360
+ runnerFailed: true
361
+ } : {
362
+ mode: facts.mode,
363
+ denied: matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
364
+ enforcement: facts.enforcement
365
+ };
366
+ }
367
+ super.onProcessDone(proc, stderr, spawnFailed, spawnError);
368
+ }
369
+ resolve(request) {
370
+ const bashPath = this.tryBashPath();
371
+ const env = bashPath === void 0 ? request.env : {
372
+ ...request.env,
373
+ PATH: gitToolPath(bashPath, request.env?.PATH ?? process.env.PATH ?? "")
374
+ };
375
+ return {
376
+ ...super.resolve({ ...request, env }),
377
+ sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
378
+ };
379
+ }
380
+ };
381
+ //#endregion
382
+ export { GitBashExecutor, GitBashExecutor as default, candidateBashPaths, gitRootCandidates, gitToolPath, registryBashPaths, resolveBashPath };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-bash-git`.
4
+ * @module @deepseek-ai/dsh-bash-git/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-bash-git";
7
+ /** Cordis companion plugin name. */
8
+ const name = "bash-git-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this package exposes no independent event sequence or mutable data relation
13
+ * beyond contracts enforced at its owning seam.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };