dsh-win-multi-bash 0.2.0 → 0.3.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.
@@ -254,7 +254,10 @@ var GitBashExecutor = class extends LocalBashExecutor {
254
254
  internals = {};
255
255
  sandboxStance;
256
256
  probeTimeoutMs;
257
- confinedVerdict;
257
+ /** Memoized probe promise; the provider's `confine` is async as of 0.1.7. */
258
+ confinedProbe;
259
+ /** The verdict `resolveSandboxMode` settled on, read back by the sync getter. */
260
+ sandboxModeVerdict;
258
261
  constructor(ctx, config) {
259
262
  super(ctx, config);
260
263
  const entry = config;
@@ -264,14 +267,24 @@ var GitBashExecutor = class extends LocalBashExecutor {
264
267
  }
265
268
  /**
266
269
  * The capability fact: the policy default mode while confinement is usable.
267
- * With `requireSandbox`, the mode is declared even when the probe fails, so
268
- * the tool layer advertises escalation (`sandbox_permissions`) and the
269
- * executor can refuse unconfined runs outside danger-full-access.
270
+ * The probe is async (the provider's `confine` returns a promise), so the
271
+ * tool layer awaits {@link resolveSandboxMode} before reading this; until
272
+ * then the mode is undeclared.
270
273
  */
271
274
  get sandboxMode() {
272
- if (this.sandboxStance === "none") return void 0;
273
- if (this.requireSandbox) return this.ctx.sandboxPolicy.defaultMode;
274
- return this.probeConfinedOnce() ? this.ctx.sandboxPolicy.defaultMode : void 0;
275
+ return this.sandboxModeVerdict;
276
+ }
277
+ /**
278
+ * Settle this executor's advertised sandbox mode. With `requireSandbox`,
279
+ * the mode is declared even when the probe fails, so the tool layer
280
+ * advertises escalation (`sandbox_permissions`) and the executor can refuse
281
+ * unconfined runs outside danger-full-access.
282
+ * @returns the mode to advertise, or `undefined` for an unconfining deployment.
283
+ */
284
+ async resolveSandboxMode() {
285
+ if (this.sandboxStance === "none") return this.sandboxModeVerdict = void 0;
286
+ if (this.requireSandbox) return this.sandboxModeVerdict = this.ctx.sandboxPolicy.defaultMode;
287
+ return this.sandboxModeVerdict = await this.probeConfinedOnce() ? this.ctx.sandboxPolicy.defaultMode : void 0;
275
288
  }
276
289
  /** The exact argv every (un)confined command runs through. */
277
290
  gitArgv(spec) {
@@ -291,25 +304,29 @@ var GitBashExecutor = class extends LocalBashExecutor {
291
304
  bashPath() {
292
305
  const path = this.tryBashPath();
293
306
  if (path !== void 0) return path;
294
- 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.`);
307
+ 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 on the git_bash tool row.`);
295
308
  }
296
309
  /**
297
310
  * Probe whether the windows-acl runner can launch bash.exe, once per
298
- * executor lifetime (lazy: the first sandboxMode read or the first routed
299
- * command). Runs a trivial confined `exit 0` synchronously (mirroring
300
- * sandbox-local's defaultProbeWindowsAcl shape); a spawn rejection, a
301
- * runner-failure exit, or a missing bash.exe all mean no confinement.
311
+ * executor lifetime (lazy: the first {@link resolveSandboxMode} await or the
312
+ * first routed command). Wraps a trivial `exit 0` through the provider and
313
+ * runs it (mirroring sandbox-local's defaultProbeWindowsAcl shape); a wrap
314
+ * rejection, a spawn rejection, or a non-zero exit all mean no confinement.
315
+ * @returns the memoized verdict.
302
316
  */
303
317
  probeConfinedOnce() {
304
- if (this.confinedVerdict !== void 0) return this.confinedVerdict;
318
+ this.confinedProbe ??= this.probeConfined();
319
+ return this.confinedProbe;
320
+ }
321
+ async probeConfined() {
305
322
  const probe = this.internals.probeConfined;
306
- if (probe !== void 0) return this.confinedVerdict = probe();
323
+ if (probe !== void 0) return probe();
307
324
  const bashPath = this.tryBashPath();
308
- if (bashPath === void 0) return this.confinedVerdict = false;
325
+ if (bashPath === void 0) return false;
309
326
  const root = this.ctx.sandboxPolicy.resolve().workspaceRoot;
310
327
  let confined;
311
328
  try {
312
- confined = this.ctx.sandbox.confine([
329
+ confined = await this.ctx.sandbox.confine([
313
330
  bashPath,
314
331
  "-c",
315
332
  "exit 0"
@@ -318,93 +335,115 @@ var GitBashExecutor = class extends LocalBashExecutor {
318
335
  workspaceRoot: root
319
336
  });
320
337
  } catch {
321
- return this.confinedVerdict = false;
338
+ return false;
322
339
  }
323
340
  const runner = confined.argv[0];
324
- if (runner === void 0) return this.confinedVerdict = false;
341
+ if (runner === void 0) return false;
325
342
  const probeRun = spawnSync(runner, confined.argv.slice(1), {
326
343
  timeout: this.probeTimeoutMs,
327
344
  cwd: root,
328
345
  stdio: "ignore"
329
346
  });
330
- return this.confinedVerdict = probeRun.status === 0;
347
+ return probeRun.status === 0;
331
348
  }
332
349
  confinedPolicy(spec) {
333
350
  return spec.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
334
351
  }
335
- async run(spec) {
352
+ /** Per-process confinement facts retained until settlement (bash-sandbox pattern). */
353
+ processFacts = /* @__PURE__ */ new Map();
354
+ /**
355
+ * Execute one resolved spec through the confined (or unconfined) Git Bash
356
+ * argv. Foreground is a property of awaiting `result()`, not of the call —
357
+ * one handle serves both call styles, as the 0.1.7 seam defines it.
358
+ * Confinement is probe-gated; runner evidence at execution time falls back
359
+ * to an unconfined run (the documented lazy contract) so a windows-acl
360
+ * runner that cannot launch MSYS bash never bricks the backend.
361
+ * @param spec - the resolved execution spec from {@link resolve}.
362
+ * @returns the live handle; sandbox facts land on both `proc.sandbox` (at
363
+ * settlement) and the decorated `result()`.
364
+ */
365
+ async execute(spec) {
336
366
  const policy = this.confinedPolicy(spec);
337
- if (policy.mode === "danger-full-access") return {
338
- ...await this.runArgv(spec, this.gitArgv(spec)),
367
+ if (policy.mode === "danger-full-access") return GitBashExecutor.decorateResult(await this.executeArgv(spec, this.gitArgv(spec)), (result) => ({
368
+ ...result,
339
369
  sandbox: {
340
370
  mode: "danger-full-access",
341
371
  denied: false
342
372
  }
343
- };
344
- if (this.sandboxStance === "none") return this.runArgv(spec, this.gitArgv(spec));
345
- if (!this.probeConfinedOnce()) {
373
+ }));
374
+ if (this.sandboxStance === "none") return this.executeArgv(spec, this.gitArgv(spec));
375
+ if (!await this.probeConfinedOnce()) {
346
376
  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.");
347
- return this.runArgv(spec, this.gitArgv(spec));
377
+ return this.executeArgv(spec, this.gitArgv(spec));
348
378
  }
349
- const confined = this.ctx.sandbox.confine(this.gitArgv(spec), {
350
- ...policy,
351
- mode: policy.mode
352
- });
353
- let result;
354
- try {
355
- result = await this.runArgv(spec, confined.argv);
356
- } catch (error) {
357
- if (spec.signal?.aborted === true) spec.signal.throwIfAborted();
358
- if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) return this.runArgv(spec, this.gitArgv(spec));
359
- throw error;
360
- }
361
- if (classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) !== void 0) return this.runArgv(spec, this.gitArgv(spec));
362
- return {
363
- ...result,
364
- sandbox: {
379
+ let confined;
380
+ const proc = await this.executeArgv(spec, async (signal) => {
381
+ confined = await this.ctx.sandbox.confine(this.gitArgv(spec), {
382
+ ...policy,
383
+ mode: policy.mode
384
+ }, signal);
385
+ return confined.argv;
386
+ }, (started) => {
387
+ const facts = confined;
388
+ if (facts === void 0) return;
389
+ this.processFacts.set(started, {
365
390
  mode: policy.mode,
366
- denied: classifyDenial(result, confined.denialSignatures),
367
- enforcement: confined.enforcement
368
- }
369
- };
370
- }
371
- /** Per-process confinement facts retained until settlement (bash-sandbox pattern). */
372
- processFacts = /* @__PURE__ */ new Map();
373
- start(spec) {
374
- const policy = this.confinedPolicy(spec);
375
- if (policy.mode === "danger-full-access") return this.startArgv(spec, this.gitArgv(spec));
376
- if (this.sandboxStance === "none") return this.startArgv(spec, this.gitArgv(spec));
377
- if (!this.probeConfinedOnce()) {
378
- 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.");
379
- return this.startArgv(spec, this.gitArgv(spec));
380
- }
381
- const confined = this.ctx.sandbox.confine(this.gitArgv(spec), {
382
- ...policy,
383
- mode: policy.mode
391
+ enforcement: facts.enforcement,
392
+ denialSignatures: facts.denialSignatures,
393
+ runnerFailureRules: facts.runnerFailureRules,
394
+ runnerProgram: facts.argv[0],
395
+ workdir: spec.workdir
396
+ });
384
397
  });
385
- let proc;
386
- try {
387
- proc = this.startArgv(spec, confined.argv);
388
- } catch (error) {
389
- if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) return this.startArgv(spec, this.gitArgv(spec));
398
+ /** Re-run the command unconfined after runner evidence proved confinement unavailable. */
399
+ const unconfinedFallback = async () => (await this.executeArgv(spec, this.gitArgv(spec))).result();
400
+ return GitBashExecutor.decorateResult(proc, async (result) => {
401
+ if (confined === void 0) return {
402
+ ...result,
403
+ sandbox: {
404
+ mode: policy.mode,
405
+ denied: false
406
+ }
407
+ };
408
+ if (classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) !== void 0) return unconfinedFallback();
409
+ return {
410
+ ...result,
411
+ sandbox: {
412
+ mode: policy.mode,
413
+ denied: classifyDenial(result, confined.denialSignatures),
414
+ enforcement: confined.enforcement
415
+ }
416
+ };
417
+ }, async (error) => {
418
+ if (spec.signal?.aborted === true) spec.signal.throwIfAborted();
419
+ if (confined !== void 0 && isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) return unconfinedFallback();
390
420
  throw error;
391
- }
392
- this.processFacts.set(proc, {
393
- mode: policy.mode,
394
- enforcement: confined.enforcement,
395
- denialSignatures: confined.denialSignatures,
396
- runnerFailureRules: confined.runnerFailureRules,
397
- runnerProgram: confined.argv[0],
398
- workdir: spec.workdir
399
421
  });
422
+ }
423
+ /**
424
+ * Decorate the handle's foreground projection in place, memoized once. The
425
+ * handle keeps its identity (never wrapped in a second object) because the
426
+ * per-process facts and `onProcessDone` key on the exact instance.
427
+ * @param proc - the live execution handle.
428
+ * @param map - maps the settled result.
429
+ * @param mapError - maps a provider rejection.
430
+ * @returns the same handle, with `result()` decorated.
431
+ */
432
+ static decorateResult(proc, map, mapError) {
433
+ const base = proc.result.bind(proc);
434
+ let decorated;
435
+ proc.result = () => {
436
+ decorated ??= base().then(map, mapError);
437
+ return decorated;
438
+ };
400
439
  return proc;
401
440
  }
402
441
  /** Stamp per-process sandbox facts before `done` settles (bash-sandbox semantics). */
403
- onProcessDone(proc, stderr, spawnFailed, spawnError) {
442
+ onProcessDone(proc, stderr, providerRejected, providerError) {
404
443
  const facts = this.processFacts.get(proc);
405
444
  if (facts !== void 0) {
406
445
  this.processFacts.delete(proc);
407
- proc.sandbox = (spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== void 0) ? {
446
+ proc.sandbox = (providerRejected ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== void 0) ? {
408
447
  mode: facts.mode,
409
448
  denied: false,
410
449
  enforcement: facts.enforcement,
@@ -415,7 +454,7 @@ var GitBashExecutor = class extends LocalBashExecutor {
415
454
  enforcement: facts.enforcement
416
455
  };
417
456
  }
418
- super.onProcessDone(proc, stderr, spawnFailed, spawnError);
457
+ super.onProcessDone(proc, stderr, providerRejected, providerError);
419
458
  }
420
459
  resolve(request) {
421
460
  const bashPath = this.tryBashPath();
@@ -165,6 +165,16 @@ var WslBashExecutor = class extends LocalBashExecutor {
165
165
  if (this.requireSandbox) return this.ctx.sandboxPolicy.defaultMode;
166
166
  return this.requireBwrapUsable() ? this.ctx.sandboxPolicy.defaultMode : void 0;
167
167
  }
168
+ /**
169
+ * The awaitable form of {@link sandboxMode}. The bwrap probe spawns
170
+ * `wsl.exe` synchronously, so the verdict is already available; this exists
171
+ * so both backends present the tool layer the same contract (git-bash's
172
+ * provider probe is genuinely async).
173
+ * @returns the mode to advertise, or `undefined` for an unconfining deployment.
174
+ */
175
+ async resolveSandboxMode() {
176
+ return this.sandboxMode;
177
+ }
168
178
  /** Auto: false means run unconfined. Explicit bwrap: a failed probe throws loud. */
169
179
  requireBwrapUsable() {
170
180
  if (this.probeBwrapOnce()) return true;
@@ -223,19 +233,26 @@ var WslBashExecutor = class extends LocalBashExecutor {
223
233
  payload(command) {
224
234
  return `echo ${Buffer.from(command, "utf8").toString("base64")} | base64 -d | bash`;
225
235
  }
226
- /** The workdir `resolve()` fills when a request omits one. */
227
- defaultWorkdir() {
228
- return this.config.cwd ?? process.cwd();
229
- }
230
236
  /**
231
- * The plain WSL argv for one spec (no sandbox). `--cd` is passed only when
232
- * the workdir differs from the default, so the subprocess cwd and the WSL
233
- * start directory agree without a redundant wsl.exe translation; a Linux
234
- * workdir passes through verbatim.
237
+ * The plain WSL argv for one spec (no sandbox).
238
+ *
239
+ * `--cd` always carries the resolved workdir. That is the auto-cd contract:
240
+ * a command the caller started without an explicit `workdir` still lands in
241
+ * the WSL view of the session directory (`/mnt/<drive>/...`) rather than
242
+ * wherever the distro happens to default to. wsl.exe does translate an
243
+ * inherited Windows cwd on its own, but only for a path it can translate
244
+ * and only under the distro's automount settings — passing the workdir
245
+ * explicitly keeps the start directory a property of the spec instead of a
246
+ * property of the distro. A Linux workdir passes through verbatim.
247
+ *
248
+ * (This used to skip `--cd` when the workdir equaled the configured
249
+ * default. That branch was dead — it compared against the `cwd` volatile
250
+ * *wrapper* rather than its value, so it never matched — and skipping is
251
+ * not worth reviving: the explicit form is what the auto-cd feature is
252
+ * specified as, and it is the behaviour every deployment already runs.)
235
253
  */
236
254
  argv(spec) {
237
- const head = [this.wslPath()];
238
- if (spec.workdir !== this.defaultWorkdir()) head.push("--cd", spec.workdir);
255
+ const head = [this.wslPath(), "--cd", spec.workdir];
239
256
  const distro = this.distro();
240
257
  if (distro !== void 0) head.push("-d", distro);
241
258
  head.push("--", "bash", "-c", this.payload(spec.command));
@@ -248,8 +265,7 @@ var WslBashExecutor = class extends LocalBashExecutor {
248
265
  bwrapArgv(spec) {
249
266
  const policy = this.confinedPolicy(spec);
250
267
  const linuxRoot = toWslPath(policy.workspaceRoot);
251
- const head = [this.wslPath()];
252
- if (spec.workdir !== this.defaultWorkdir()) head.push("--cd", spec.workdir);
268
+ const head = [this.wslPath(), "--cd", spec.workdir];
253
269
  const distro = this.distro();
254
270
  if (distro !== void 0) head.push("-d", distro);
255
271
  const mode = policy.mode;
@@ -259,22 +275,37 @@ var WslBashExecutor = class extends LocalBashExecutor {
259
275
  }), "bash", "-c", this.payload(spec.command));
260
276
  return head;
261
277
  }
262
- async run(spec) {
278
+ /** Per-process confinement facts retained until settlement (bash-sandbox pattern). */
279
+ processFacts = /* @__PURE__ */ new Map();
280
+ /**
281
+ * Execute one resolved spec through the bwrap-wrapped (or plain) WSL argv.
282
+ * Foreground is a property of awaiting `result()`, not of the call — one
283
+ * handle serves both call styles, as the 0.1.7 seam defines it. A runner
284
+ * failure is stamped as `runnerFailed` rather than retried: unlike the
285
+ * windows-acl probe, a distro missing bubblewrap is evidence the deployment
286
+ * has no confinement at all, and the spec's error contract surfaces it.
287
+ * @param spec - the resolved execution spec from {@link resolve}.
288
+ * @returns the live handle; sandbox facts land on both `proc.sandbox` (at
289
+ * settlement) and the decorated `result()`.
290
+ */
291
+ async execute(spec) {
263
292
  const policy = this.confinedPolicy(spec);
264
- if (policy.mode === "danger-full-access") return {
265
- ...await this.runArgv(spec, this.argv(spec)),
293
+ if (policy.mode === "danger-full-access") return WslBashExecutor.decorateResult(await this.executeArgv(spec, this.argv(spec)), (result) => ({
294
+ ...result,
266
295
  sandbox: {
267
296
  mode: "danger-full-access",
268
297
  denied: false
269
298
  }
270
- };
271
- if (this.sandboxStance === "none") return this.runArgv(spec, this.argv(spec));
299
+ }));
300
+ if (this.sandboxStance === "none") return this.executeArgv(spec, this.argv(spec));
272
301
  if (!this.requireBwrapUsable()) {
273
302
  if (this.requireSandbox) throw new Error("bash-wsl: sandbox unavailable — bwrap was not found in the WSL distro; refusing to run unconfined under " + policy.mode + " mode. Install bubblewrap or retry the command with sandbox_permissions: \"danger-full-access\" and a justification.");
274
- return this.runArgv(spec, this.argv(spec));
303
+ return this.executeArgv(spec, this.argv(spec));
275
304
  }
276
- const result = await this.runArgv(spec, this.bwrapArgv(spec));
277
- if (classifyRunnerFailure(result.exitCode, result.stderr.text, BWRAP_RUNNER_FAILURE_RULES) !== void 0) return {
305
+ const proc = await this.executeArgv(spec, this.bwrapArgv(spec), (started) => {
306
+ this.processFacts.set(started, { mode: policy.mode });
307
+ });
308
+ return WslBashExecutor.decorateResult(proc, (result) => classifyRunnerFailure(result.exitCode, result.stderr.text, BWRAP_RUNNER_FAILURE_RULES) !== void 0 ? {
278
309
  ...result,
279
310
  sandbox: {
280
311
  mode: policy.mode,
@@ -282,32 +313,35 @@ var WslBashExecutor = class extends LocalBashExecutor {
282
313
  enforcement: "full",
283
314
  runnerFailed: true
284
315
  }
285
- };
286
- return {
316
+ } : {
287
317
  ...result,
288
318
  sandbox: {
289
319
  mode: policy.mode,
290
320
  denied: classifyDenial(result, BWRAP_DENIAL_SIGNATURES),
291
321
  enforcement: "full"
292
322
  }
293
- };
323
+ });
294
324
  }
295
- /** Per-process confinement facts retained until settlement (bash-sandbox pattern). */
296
- processFacts = /* @__PURE__ */ new Map();
297
- start(spec) {
298
- const policy = this.confinedPolicy(spec);
299
- if (policy.mode === "danger-full-access") return this.startArgv(spec, this.argv(spec));
300
- if (this.sandboxStance === "none") return this.startArgv(spec, this.argv(spec));
301
- if (!this.requireBwrapUsable()) {
302
- if (this.requireSandbox) throw new Error("bash-wsl: sandbox unavailable — bwrap was not found in the WSL distro; refusing to run unconfined under " + policy.mode + " mode. Install bubblewrap or retry the command with sandbox_permissions: \"danger-full-access\" and a justification.");
303
- return this.startArgv(spec, this.argv(spec));
304
- }
305
- const proc = this.startArgv(spec, this.bwrapArgv(spec));
306
- this.processFacts.set(proc, { mode: policy.mode });
325
+ /**
326
+ * Decorate the handle's foreground projection in place, memoized once. The
327
+ * handle keeps its identity (never wrapped in a second object) because the
328
+ * per-process facts and `onProcessDone` key on the exact instance.
329
+ * @param proc - the live execution handle.
330
+ * @param map - maps the settled result.
331
+ * @param mapError - maps a provider rejection.
332
+ * @returns the same handle, with `result()` decorated.
333
+ */
334
+ static decorateResult(proc, map, mapError) {
335
+ const base = proc.result.bind(proc);
336
+ let decorated;
337
+ proc.result = () => {
338
+ decorated ??= base().then(map, mapError);
339
+ return decorated;
340
+ };
307
341
  return proc;
308
342
  }
309
343
  /** Stamp per-process sandbox facts before `done` settles (bash-sandbox semantics). */
310
- onProcessDone(proc, stderr, spawnFailed, spawnError) {
344
+ onProcessDone(proc, stderr, providerRejected, providerError) {
311
345
  const facts = this.processFacts.get(proc);
312
346
  if (facts !== void 0) {
313
347
  this.processFacts.delete(proc);
@@ -322,7 +356,7 @@ var WslBashExecutor = class extends LocalBashExecutor {
322
356
  enforcement: "full"
323
357
  };
324
358
  }
325
- super.onProcessDone(proc, stderr, spawnFailed, spawnError);
359
+ super.onProcessDone(proc, stderr, providerRejected, providerError);
326
360
  }
327
361
  resolve(request) {
328
362
  return {
package/lib/index.js CHANGED
@@ -4,16 +4,19 @@
4
4
  // DeepSeek Harness project's shell packages (see THIRD_PARTY_NOTICES),
5
5
  // compiled to plain ESM JS:
6
6
  //
7
- // lib/shell-select/ — ShellSelectExecutor (the ctx.shell selector)
8
7
  // lib/bash-git/ — GitBashExecutor (MSYS / Git for Windows)
9
8
  // lib/bash-wsl/ — WslBashExecutor (WSL distros, base64 payloads)
10
- // lib/tool-bash/ — defineShellTool factory + git_bash / wsl_bash instances
11
- // lib/vendor/ — helpers.js (bash-sandbox classification) and
12
- // bwrap-profiles.js (bwrap rules/args) — the two
13
- // helper modules not exported by the base runtime.
9
+ // lib/tool-bash/ — defineShellTool factory + git_bash / wsl_bash
10
+ // instances, each owning its own executor
11
+ // (types/backend.js) instead of sharing the ctx.shell
12
+ // seat: dsh 0.1.7 removed ShellExecRequest.shell, the
13
+ // routing field the old shell-select design used.
14
+ // lib/vendor/ — helpers.js (bash-sandbox classification, including the
15
+ // anchored runner-failure rule the base runtime does
16
+ // not export) and bwrap-profiles.js (bwrap rules/args).
14
17
  //
15
18
  // Only published @deepseek-ai base packages are imported (dsh-shell,
16
- // dsh-sandbox, dsh-pwsh-sandbox, dsh-tools, dsh-llm, ...), so the plugin runs
19
+ // dsh-sandbox, dsh-bash-local, dsh-tools, dsh-llm, ...), so the plugin runs
17
20
  // on any standard deployment — no additional runtime packages required.
18
21
  //
19
22
  // The composition wiring lives in cordis.patch.yml.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Backend ownership for the bundled shell tools.
3
+ *
4
+ * dsh 0.1.7 removed the routing field from the shell seam: `ShellExecRequest`
5
+ * no longer carries `shell`, so the pre-0.1.7 design — one selector executor
6
+ * occupying `ctx.shell` and dispatching each request to a named backend — has
7
+ * no routing input left. The tool is what knows which shell it wants, so each
8
+ * tool now owns exactly one executor instance and drives it directly. The
9
+ * `ctx.shell` seat is left entirely to the base bundle's own executor
10
+ * (`pwsh-sandbox` on win32), which also frees every plugin that injects
11
+ * `shell` (dsh's `tool-pwsh`, `permission-presets`, …) from waiting on us.
12
+ *
13
+ * `ShellExecutor` is a cordis `Service`: constructing one registers it under
14
+ * `shell` on the context it is handed, and a second registration on the same
15
+ * context throws (cordis' standard duplicate-service behavior). An owned
16
+ * executor is therefore built on a child fiber whose `shell` scope is
17
+ * isolated, so it never contends for the seat.
18
+ *
19
+ * @module dsh-win-multi-bash/tool-bash/backend
20
+ */
21
+
22
+ /** Services every bundled executor needs before it can be constructed. */
23
+ const EXECUTOR_INJECT = ['subprocess', 'sandbox', 'sandboxPolicy'];
24
+
25
+ /**
26
+ * Build one executor instance on an isolated child fiber.
27
+ * @param ctx - the owning tool's context; the tool must already inject
28
+ * {@link EXECUTOR_INJECT} so those services are present here.
29
+ * @param Executor - the executor class to construct.
30
+ * @param config - the executor's resolved config partition.
31
+ * @returns the instance plus the fiber that owns it; dispose the fiber to tear
32
+ * the backend down with the tool.
33
+ */
34
+ export function ownExecutor(ctx, Executor, config) {
35
+ const fiber = ctx.plugin({
36
+ inject: EXECUTOR_INJECT,
37
+ apply: () => {},
38
+ });
39
+ const backendCtx = fiber.ctx.isolate('shell', Symbol(Executor.name));
40
+ return { executor: new Executor(backendCtx, config), fiber };
41
+ }
@@ -22,4 +22,70 @@ export function processOutcome(proc) {
22
22
  }
23
23
  return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` };
24
24
  }
25
+ /**
26
+ * The process's non-consuming stream readers as registry pull sources. They
27
+ * bind lazily because the process is spawned inside the starter, after the
28
+ * registry admitted the job; a read before the spawn yields nothing, and the
29
+ * pump keeps the model's consuming cursor untouched. A rejected spawn's
30
+ * stderr reader carries the provider's `subprocess failed before reporting an
31
+ * outcome: …` note.
32
+ *
33
+ * As of dsh 0.1.7 the registry owns the output ring and pumps these sources
34
+ * itself; a background handle no longer hands its reads back through the
35
+ * starter's return value.
36
+ * @param proc - the started process, once the starter has spawned it (or `undefined` before).
37
+ * @returns one source per stream, stdout first.
38
+ */
39
+ export function processSources(proc) {
40
+ const source = (channel) => ({
41
+ channel,
42
+ read: (fromByte) => {
43
+ const live = proc();
44
+ return live === undefined
45
+ ? { text: '', nextOffset: fromByte, lossy: false }
46
+ : live.observed[channel].readFrom(fromByte);
47
+ },
48
+ });
49
+ return [source('stdout'), source('stderr')];
50
+ }
51
+ /**
52
+ * Adapt asynchronous shell preparation after job admission without exposing a
53
+ * partial process. The returned hooks settle only once preparation AND the
54
+ * process have; a cancel before the spawn aborts preparation, and a cancel
55
+ * after it kills the process.
56
+ * @param start - starts the process with the job-owned cancellation signal.
57
+ * @param outcome - projects the settled process into the job outcome.
58
+ * @returns synchronous job hooks whose `done` includes preparation and settlement.
59
+ */
60
+ export function processJob(start, outcome) {
61
+ const controller = new AbortController();
62
+ let process;
63
+ return {
64
+ cancel: (reason) => {
65
+ if (controller.signal.aborted)
66
+ return;
67
+ controller.abort(reason);
68
+ process?.kill();
69
+ },
70
+ done: (async () => {
71
+ try {
72
+ process = await start(controller.signal);
73
+ try {
74
+ if (controller.signal.aborted)
75
+ process.kill();
76
+ }
77
+ finally {
78
+ await process.done;
79
+ }
80
+ return outcome(process);
81
+ }
82
+ catch (error) {
83
+ return {
84
+ status: controller.signal.aborted && process === undefined ? 'killed' : 'failed',
85
+ detail: error instanceof Error ? error.message : String(error),
86
+ };
87
+ }
88
+ })(),
89
+ };
90
+ }
25
91
  //# sourceMappingURL=background.js.map