@deepseek-ai/dsh-subprocess-local 0.1.5-rc.2 → 0.1.6-alpha.2

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,12 +1,13 @@
1
- import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
1
+ import { OutputCollector, prepareManagedProcessBinding } from "./output.js";
2
+ import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
2
4
  import { basename, dirname, extname, isAbsolute, join } from "node:path";
5
+ import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require";
3
6
  import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
4
7
  import { execFileSync, spawn, spawnSync } from "node:child_process";
5
- import { randomBytes } from "node:crypto";
6
- import { tmpdir } from "node:os";
7
8
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
9
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
9
- import koffi from "koffi";
10
+ import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
10
11
  import { getSystemErrorMessage, getSystemErrorName, inspect } from "node:util";
11
12
  import { fileURLToPath } from "node:url";
12
13
  //#region lib/types/managed-owner.js
@@ -38,6 +39,30 @@ async function waitWithAbort(pending, signal) {
38
39
  }
39
40
  }
40
41
  //#endregion
42
+ //#region lib/types/control-spawn.js
43
+ /** Parent-side setup for one explicitly requested inherited control pipe. */
44
+ /**
45
+ * Read the optional extra pipe from Node's stdio tuple.
46
+ * @param child - child whose requested extra pipe was allocated by Node.
47
+ * @param control - requested transport, or undefined when absent.
48
+ * @returns the parent duplex endpoint, absent when not requested or native startup failed.
49
+ */
50
+ function controlPipe(child, control) {
51
+ const streams = child.stdio;
52
+ return control === "pipe" ? streams[SUBPROCESS_CONTROL_FD] : void 0;
53
+ }
54
+ /**
55
+ * Stamp the private marker on a fresh child environment after rejecting a caller override.
56
+ * @param env - newly materialized child environment, owned by the caller.
57
+ * @param control - requested control transport, or undefined when absent.
58
+ * @returns the same environment with the provider-owned launch marker when requested.
59
+ */
60
+ function controlEnvironment(env, control) {
61
+ for (const [key, value] of Object.entries(env)) if (key.toUpperCase() === SUBPROCESS_CONTROL_ENV && value !== void 0) throw new Error(`${SUBPROCESS_CONTROL_ENV} is reserved for subprocess control-channel setup`);
62
+ if (control === "pipe") Object.assign(env, { [SUBPROCESS_CONTROL_ENV]: "pipe" });
63
+ return env;
64
+ }
65
+ //#endregion
41
66
  //#region lib/types/windows-inspector.js
42
67
  /**
43
68
  * Windows process-table operations for terminal readiness, signalling, and
@@ -49,6 +74,7 @@ async function waitWithAbort(pending, signal) {
49
74
  * an injectable internals boundary so suites can pin it on any host.
50
75
  * @module dsh-subprocess-local/windows-inspector
51
76
  */
77
+ const requireKoffi$1 = createLazyRequire("koffi", import.meta.url);
52
78
  /**
53
79
  * Walk a process table from one root in children-first order, retaining only
54
80
  * members whose start identity is readable (unreadable members are detector
@@ -150,7 +176,6 @@ function isInvalidHandle(value) {
150
176
  const asBigInt = value;
151
177
  return asBigInt === 0n || asBigInt === 18446744073709551615n || asBigInt === -1n;
152
178
  }
153
- const PVOID = koffi.pointer("void");
154
179
  /**
155
180
  * Resolve the koffi Win32 struct types once. Registration is lazy and cached
156
181
  * because koffi's type registry is global per process: test runners that
@@ -159,6 +184,8 @@ const PVOID = koffi.pointer("void");
159
184
  */
160
185
  function win32Structs() {
161
186
  if (cachedStructs !== void 0) return cachedStructs;
187
+ const koffi = requireKoffi$1();
188
+ const PVOID = koffi.pointer("void");
162
189
  const PROCESSENTRY32W = koffi.struct("PROCESSENTRY32W", {
163
190
  dwSize: "uint32",
164
191
  cntUsage: "uint32",
@@ -179,6 +206,7 @@ function win32Structs() {
179
206
  if (PROCESSENTRY32W.size !== 568) throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`);
180
207
  /* v8 ignore stop */
181
208
  cachedStructs = {
209
+ PVOID,
182
210
  PROCESSENTRY32W,
183
211
  FILETIME
184
212
  };
@@ -195,7 +223,8 @@ let cachedBindings;
195
223
  */
196
224
  function win32Bindings() {
197
225
  if (cachedBindings !== void 0) return cachedBindings;
198
- const { PROCESSENTRY32W, FILETIME } = win32Structs();
226
+ const koffi = requireKoffi$1();
227
+ const { PVOID, PROCESSENTRY32W, FILETIME } = win32Structs();
199
228
  const kernel32 = koffi.load("kernel32.dll");
200
229
  const bind = (name, result, args) => kernel32.func("__stdcall", name, result, args);
201
230
  cachedBindings = {
@@ -227,10 +256,11 @@ function win32Bindings() {
227
256
  * @returns the branded allocation pointer.
228
257
  */
229
258
  function allocNative(type, count) {
230
- return koffi.alloc(type, count);
259
+ return requireKoffi$1().alloc(type, count);
231
260
  }
232
261
  /** Enumerate the current process table through Toolhelp32. */
233
262
  function snapshotWindowsProcesses(bindings) {
263
+ const koffi = requireKoffi$1();
234
264
  const { PROCESSENTRY32W } = win32Structs();
235
265
  const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
236
266
  /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
@@ -256,6 +286,7 @@ function snapshotWindowsProcesses(bindings) {
256
286
  }
257
287
  /** Read one process's creation identity and current wait state. */
258
288
  function windowsProcessState(bindings, pid) {
289
+ const koffi = requireKoffi$1();
259
290
  const { FILETIME } = win32Structs();
260
291
  const handle = bindings.openProcess(1052672, 0, pid);
261
292
  if (isInvalidHandle(handle)) return void 0;
@@ -392,7 +423,7 @@ function numericEntries(internals, path) {
392
423
  try {
393
424
  return internals.readDir(path).filter((entry) => /^\d+$/.test(entry)).map(Number);
394
425
  } catch (_unreadableProcDirectory) {
395
- return [];
426
+ return;
396
427
  }
397
428
  }
398
429
  function readSyscall(internals, pid, tid) {
@@ -491,9 +522,11 @@ function quiescent(state) {
491
522
  }
492
523
  var PosixProcessSnapshot = class {
493
524
  rows;
525
+ complete;
494
526
  byPid;
495
- constructor(rows) {
527
+ constructor(rows, complete) {
496
528
  this.rows = rows;
529
+ this.complete = complete;
497
530
  this.byPid = new Map(rows.map((row) => [row.pid, row]));
498
531
  }
499
532
  tree(rootPid) {
@@ -550,10 +583,10 @@ var LinuxProcessInspector = class extends PosixProcessInspector {
550
583
  if (shell === void 0) return false;
551
584
  const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice);
552
585
  if (terminalDevice === void 0) return false;
553
- for (const pid of numericEntries(this.internals, "/proc")) {
586
+ for (const pid of numericEntries(this.internals, "/proc") ?? []) {
554
587
  const process = readLinuxStat(this.internals, pid);
555
588
  if (process?.pgrp !== pgid) continue;
556
- for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
589
+ for (const tid of numericEntries(this.internals, `/proc/${pid}/task`) ?? []) {
557
590
  const syscall = readSyscall(this.internals, pid, tid);
558
591
  if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables) && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true;
559
592
  }
@@ -565,8 +598,12 @@ var LinuxProcessInspector = class extends PosixProcessInspector {
565
598
  return stat?.started === identity.started && !quiescent(stat.state);
566
599
  }
567
600
  snapshot() {
568
- return new PosixProcessSnapshot(numericEntries(this.internals, "/proc").flatMap((pid) => {
601
+ const pids = numericEntries(this.internals, "/proc");
602
+ if (pids === void 0) throw new Error("Cannot inspect processes: /proc directory is unreadable");
603
+ let complete = true;
604
+ return new PosixProcessSnapshot(pids.flatMap((pid) => {
569
605
  const stat = readLinuxStat(this.internals, pid);
606
+ if (stat === void 0) complete = false;
570
607
  return stat === void 0 ? [] : [{
571
608
  pid,
572
609
  parentPid: stat.parentPid,
@@ -574,21 +611,28 @@ var LinuxProcessInspector = class extends PosixProcessInspector {
574
611
  session: stat.session,
575
612
  state: stat.state
576
613
  }];
577
- }));
614
+ }), complete);
578
615
  }
579
616
  };
580
617
  function macProcessTable(internals) {
581
- return internals.exec("/bin/ps", ["-axo", "pid=,ppid=,lstart="]).split("\n").flatMap((line) => {
582
- const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
583
- if (match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0) return [];
584
- return [{
585
- pid: Number(match[1]),
586
- parentPid: Number(match[2]),
587
- started: match[3],
588
- session: void 0,
589
- state: void 0
590
- }];
591
- });
618
+ let complete = true;
619
+ return {
620
+ rows: internals.exec("/bin/ps", ["-axo", "pid=,ppid=,lstart="]).split("\n").flatMap((line) => {
621
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
622
+ if (match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0) {
623
+ if (line.trim().length > 0) complete = false;
624
+ return [];
625
+ }
626
+ return [{
627
+ pid: Number(match[1]),
628
+ parentPid: Number(match[2]),
629
+ started: match[3],
630
+ session: void 0,
631
+ state: void 0
632
+ }];
633
+ }),
634
+ complete
635
+ };
592
636
  }
593
637
  var MacProcessInspector = class extends PosixProcessInspector {
594
638
  foregroundPgid(shellPid) {
@@ -608,10 +652,11 @@ var MacProcessInspector = class extends PosixProcessInspector {
608
652
  return false;
609
653
  }
610
654
  isAlive(identity) {
611
- return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
655
+ return macProcessTable(this.internals).rows.some((entry) => entry.pid === identity.pid && entry.started === identity.started);
612
656
  }
613
657
  snapshot() {
614
- return new PosixProcessSnapshot(macProcessTable(this.internals));
658
+ const table = macProcessTable(this.internals);
659
+ return new PosixProcessSnapshot(table.rows, table.complete);
615
660
  }
616
661
  };
617
662
  /**
@@ -669,169 +714,6 @@ function childEnv(extra) {
669
714
  function sleepTick() {
670
715
  return setTimeout$1(15);
671
716
  }
672
- let spillCounter = 0;
673
- let defaultSpillDir;
674
- /**
675
- * The default spill location: a private (0700) per-process directory under
676
- * the OS tmpdir, created lazily. Predictable world-readable paths would let
677
- * other local users read command output or pre-create symlinks. At a
678
- * JavaScript-observable process exit the directory is removed only when it
679
- * holds no completed spill file (spill files are retained as full-output
680
- * recovery artifacts until an external cleanup).
681
- */
682
- function privateSpillDir() {
683
- defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
684
- return defaultSpillDir;
685
- }
686
- /* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
687
- process.once("exit", () => {
688
- if (defaultSpillDir === void 0) return;
689
- try {
690
- rmdirSync(defaultSpillDir);
691
- } catch {}
692
- });
693
- /**
694
- * Prepare fallible output storage before starting a managed native process.
695
- * @param internals - optional caller-owned spill directory.
696
- * @returns binding inputs whose spill directory is ready for use.
697
- */
698
- function prepareManagedProcessBinding(internals = {}) {
699
- return { spillDir: internals.spillDir ?? privateSpillDir() };
700
- }
701
- /**
702
- * Collects one stream with a bounded in-memory tail. With a spill cap, on
703
- * first overflow a spill file is created and every chunk (including those
704
- * already collected) is appended there while the full stream remains within
705
- * the cap; without one, only the in-memory tail is ever retained (the
706
- * diagnostic-tail shape — a language server's stderr).
707
- *
708
- * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
709
- * end of command output; the spill file covers the head.
710
- */
711
- var OutputCollector = class {
712
- maxBytes;
713
- maxSpillBytes;
714
- label;
715
- spillDir;
716
- chunks = [];
717
- bytes = 0;
718
- dropped = false;
719
- spillFd;
720
- spillFile;
721
- spillDisabled;
722
- /** Total bytes ever pushed (not just retained). */
723
- total = 0;
724
- constructor(maxBytes, maxSpillBytes, label, spillDir) {
725
- this.maxBytes = maxBytes;
726
- this.maxSpillBytes = maxSpillBytes;
727
- this.label = label;
728
- this.spillDir = spillDir;
729
- this.spillDisabled = maxSpillBytes === void 0;
730
- }
731
- /**
732
- * Ingest one stream chunk, counting it toward the whole-stream total. On
733
- * first overflow of the in-memory cap a spill file is opened (when spilling
734
- * is enabled) and every chunk (already-collected ones included) is appended
735
- * there from then on; the in-memory tail then drops whole chunks from its
736
- * head (or the head of a single over-cap chunk) until it fits the cap again.
737
- * @param chunk - the raw bytes from one stream 'data' event.
738
- */
739
- push(chunk) {
740
- this.total += chunk.length;
741
- const overflows = this.bytes + chunk.length > this.maxBytes;
742
- if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
743
- this.chunks.push(chunk);
744
- this.bytes += chunk.length;
745
- while (this.bytes > this.maxBytes) {
746
- const head = this.chunks[0];
747
- const excess = this.bytes - this.maxBytes;
748
- if (head.length <= excess) {
749
- this.chunks.shift();
750
- this.bytes -= head.length;
751
- } else {
752
- this.chunks[0] = head.subarray(excess);
753
- this.bytes -= excess;
754
- }
755
- this.dropped = true;
756
- }
757
- }
758
- /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
759
- spillAll(chunk) {
760
- if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
761
- this.discardSpill();
762
- return;
763
- }
764
- if (this.spillFd === void 0) {
765
- this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
766
- this.spillFd = openSync(this.spillFile, "wx", 384);
767
- for (const prior of this.chunks) writeSync(this.spillFd, prior);
768
- }
769
- writeSync(this.spillFd, chunk);
770
- }
771
- /** Stop spilling and remove the file once it can no longer hold the complete stream. */
772
- discardSpill() {
773
- const fd = this.spillFd;
774
- const file = this.spillFile;
775
- this.spillFd = void 0;
776
- this.spillFile = void 0;
777
- this.spillDisabled = true;
778
- if (fd !== void 0) try {
779
- closeSync(fd);
780
- } catch {
781
- this.spillFd = fd;
782
- }
783
- if (file !== void 0) try {
784
- unlinkSync(file);
785
- } catch {}
786
- }
787
- /**
788
- * Incremental read in whole-stream byte coordinates: returns everything
789
- * pushed since `fromByte`. When `fromByte` has already slid out of the
790
- * in-memory tail window, the read is `lossy` — it returns the whole
791
- * retained tail and the gap is only recoverable from the spill file.
792
- * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
793
- * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
794
- */
795
- readFrom(fromByte) {
796
- const windowStart = this.total - this.bytes;
797
- const buffer = Buffer.concat(this.chunks);
798
- const lossy = fromByte < windowStart;
799
- return {
800
- text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
801
- nextOffset: this.total,
802
- lossy,
803
- ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
804
- };
805
- }
806
- /**
807
- * Close the spill file once the stream has ended. A failed close (delayed
808
- * writeback fault) stops advertising the spill path — the file may be
809
- * missing its tail — while every in-memory read keeps working. Idempotent;
810
- * the spawn path seals both collectors at settlement so reads after exit
811
- * never point at a still-open file.
812
- */
813
- seal() {
814
- if (this.spillFd === void 0) return;
815
- try {
816
- closeSync(this.spillFd);
817
- } catch {
818
- this.spillFile = void 0;
819
- }
820
- this.spillFd = void 0;
821
- }
822
- /**
823
- * Seal the spill file and return the final output.
824
- * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
825
- */
826
- finalize() {
827
- this.seal();
828
- return {
829
- text: Buffer.concat(this.chunks).toString("utf8"),
830
- truncated: this.dropped,
831
- ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
832
- };
833
- }
834
- };
835
717
  /**
836
718
  * Terminate one Windows process tree with `taskkill /T /F`. Contained like
837
719
  * POSIX group signalling — delivery races tree exit, so an absent tree, a
@@ -1113,6 +995,7 @@ function bindManagedProcess(spec, launch, internals = {}) {
1113
995
  stdin: stdinMode === "pipe" ? stdin ?? void 0 : void 0,
1114
996
  stdout: outMode === "pipe" ? stdout ?? void 0 : void 0,
1115
997
  stderr: errMode === "pipe" ? stderr ?? void 0 : void 0,
998
+ control: launch.control,
1116
999
  /* v8 ignore stop */
1117
1000
  collected: {
1118
1001
  ...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
@@ -1134,14 +1017,19 @@ function spawnSubprocess(spec, internals = {}) {
1134
1017
  const binding = prepareManagedProcessBinding(internals);
1135
1018
  const platform = internals.platform ?? process.platform;
1136
1019
  const [program, ...args] = spec.argv;
1020
+ const stdio = [
1021
+ spec.stdio.stdin === "ignore" ? "ignore" : "pipe",
1022
+ spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
1023
+ spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
1024
+ ];
1025
+ if (spec.stdio.control === "pipe") {
1026
+ while (stdio.length < SUBPROCESS_CONTROL_FD) stdio.push("ignore");
1027
+ stdio.push("overlapped");
1028
+ }
1137
1029
  const child = (internals.spawn ?? spawn)(program, args, {
1138
1030
  cwd: spec.cwd,
1139
- env: childEnv(spec.env),
1140
- stdio: [
1141
- spec.stdio.stdin === "ignore" ? "ignore" : "pipe",
1142
- spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
1143
- spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
1144
- ],
1031
+ env: controlEnvironment(childEnv(spec.env), spec.stdio.control),
1032
+ stdio,
1145
1033
  detached: platform !== "win32",
1146
1034
  windowsHide: platform === "win32"
1147
1035
  });
@@ -1152,6 +1040,7 @@ function spawnSubprocess(spec, internals = {}) {
1152
1040
  stdin: child.stdin,
1153
1041
  stdout: child.stdout,
1154
1042
  stderr: child.stderr,
1043
+ control: controlPipe(child, spec.stdio.control),
1155
1044
  direct,
1156
1045
  owner
1157
1046
  }, binding);
@@ -1159,6 +1048,7 @@ function spawnSubprocess(spec, internals = {}) {
1159
1048
  //#endregion
1160
1049
  //#region lib/types/linux-execve.js
1161
1050
  /** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */
1051
+ const requireKoffi = createLazyRequire("koffi", import.meta.url);
1162
1052
  const STANDARD_FILE_DESCRIPTORS = [
1163
1053
  0,
1164
1054
  1,
@@ -1186,11 +1076,13 @@ function systemError(errno, syscall, path) {
1186
1076
  */
1187
1077
  function loadLinuxExecve() {
1188
1078
  if (cachedExecve !== void 0) return cachedExecve;
1079
+ const koffi = requireKoffi();
1189
1080
  const libc = koffi.load(null);
1190
1081
  const nativeExecve = libc.func("int execve(const char *pathname, const char **argv, const char **envp)");
1191
1082
  const nativeFcntl = libc.func("int fcntl(int fd, int cmd, int arg)");
1192
- cachedExecve = (file, argv, env) => {
1193
- for (const fd of STANDARD_FILE_DESCRIPTORS) {
1083
+ cachedExecve = (file, argv, env, control) => {
1084
+ const descriptors = control === "pipe" ? [...STANDARD_FILE_DESCRIPTORS, SUBPROCESS_CONTROL_FD] : STANDARD_FILE_DESCRIPTORS;
1085
+ for (const fd of descriptors) {
1194
1086
  const flags = nativeFcntl(fd, F_GETFD, 0);
1195
1087
  if (flags === -1) throw systemError(koffi.errno(), "fcntl");
1196
1088
  if ((flags & FD_CLOEXEC) === 0) continue;
@@ -1277,10 +1169,11 @@ function consumeLinuxLaunchRequest(requestPath) {
1277
1169
  const text = readFileSync(requestPath, "utf8");
1278
1170
  unlinkSync(requestPath);
1279
1171
  const value = JSON.parse(text);
1280
- if (!isRecord(value) || !hasExactKeys(value, ["cwd", "env"]) || typeof value.cwd !== "string" || !isStringRecord(value.env)) throw new Error("subprocess runner received an invalid Linux launch request");
1172
+ if (!isRecord(value) || !hasExactKeys(value, ["cwd", "env"], ["control"]) || typeof value.cwd !== "string" || !isStringRecord(value.env) || value.control !== void 0 && value.control !== "pipe") throw new Error("subprocess runner received an invalid Linux launch request");
1281
1173
  return {
1282
1174
  cwd: value.cwd,
1283
- env: value.env
1175
+ env: value.env,
1176
+ ...value.control === "pipe" ? { control: "pipe" } : {}
1284
1177
  };
1285
1178
  }
1286
1179
  /**
@@ -1315,11 +1208,12 @@ function parseWindowsStartRequest(value) {
1315
1208
  "type",
1316
1209
  "cwd",
1317
1210
  "env"
1318
- ]) || value.type !== "start" || typeof value.cwd !== "string" || !isStringRecord(value.env)) throw new Error("subprocess runner received an invalid Windows start request");
1211
+ ], ["control"]) || value.type !== "start" || typeof value.cwd !== "string" || !isStringRecord(value.env) || value.control !== void 0 && value.control !== "pipe") throw new Error("subprocess runner received an invalid Windows start request");
1319
1212
  return {
1320
1213
  type: "start",
1321
1214
  cwd: value.cwd,
1322
- env: value.env
1215
+ env: value.env,
1216
+ ...value.control === "pipe" ? { control: "pipe" } : {}
1323
1217
  };
1324
1218
  }
1325
1219
  /**
@@ -1477,7 +1371,7 @@ function parseRunnerTargetArgv(argv) {
1477
1371
  }
1478
1372
  /**
1479
1373
  * Build direct Linux target stdio, or isolated Windows runner stdio with IPC
1480
- * on fd 3 and target carriers on fd 4 through fd 6.
1374
+ * on fd 3 and target carriers on fd 4 through fd 6; optional control uses fd 7.
1481
1375
  * @param spec - ordinary subprocess request whose stdio modes are preserved.
1482
1376
  * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
1483
1377
  * @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
@@ -1489,8 +1383,14 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
1489
1383
  spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
1490
1384
  spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
1491
1385
  ];
1492
- if (!ipc) return targetStdio;
1493
- return [
1386
+ if (!ipc) {
1387
+ if (spec.stdio.control === "pipe") {
1388
+ while (targetStdio.length < SUBPROCESS_CONTROL_FD) targetStdio.push("ignore");
1389
+ targetStdio.push("overlapped");
1390
+ }
1391
+ return targetStdio;
1392
+ }
1393
+ const runner = [
1494
1394
  "ignore",
1495
1395
  "ignore",
1496
1396
  "ignore",
@@ -1499,6 +1399,8 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
1499
1399
  spec.stdio.stdout === "inherit" ? 1 : "pipe",
1500
1400
  spec.stdio.stderr === "inherit" ? 2 : "pipe"
1501
1401
  ];
1402
+ if (spec.stdio.control === "pipe") runner.push("overlapped");
1403
+ return runner;
1502
1404
  }
1503
1405
  function windowsEnvironmentValue(env, name) {
1504
1406
  for (const key of Object.keys(env).sort()) if (key.toUpperCase() === name) return env[key];
@@ -1617,7 +1519,7 @@ function targetEnvironment(spec) {
1617
1519
  validateNoNullByte(`options.env['${key}']`, key);
1618
1520
  validateNoNullByte(`options.env['${key}']`, value);
1619
1521
  }
1620
- return env;
1522
+ return controlEnvironment(env, spec.stdio?.control);
1621
1523
  }
1622
1524
  //#endregion
1623
- export { bindManagedProcess as C, validateSubprocessSpec as D, spawnSubprocess as E, createProcessInspector as O, loadLinuxExecve as S, prepareManagedProcessBinding as T, parseWindowsRunnerResult as _, resolveWindowsExecutable as a, serializeRunnerError as b, runnerStdio as c, cleanupLinuxLaunchFiles as d, consumeLinuxLaunchRequest as f, linuxLaunchFilesFromLocator as g, isWindowsTerminateRequest as h, parseRunnerTargetArgv as i, spawnRunnerInvocation as l, deserializeRunnerError as m, WINDOWS_RUNNER_SELECTION as n, runnerEnvironment as o, createLinuxLaunchFiles as p, consumeRunnerSelection as r, runnerInvocationAvailable as s, SUBPROCESS_RUNNER_ENV as t, targetEnvironment as u, parseWindowsStartRequest as v, childEnv as w, writeLinuxStartupError as x, readLinuxStartupError as y };
1525
+ export { bindManagedProcess as C, createProcessInspector as D, validateSubprocessSpec as E, controlPipe as O, loadLinuxExecve as S, spawnSubprocess as T, parseWindowsRunnerResult as _, resolveWindowsExecutable as a, serializeRunnerError as b, runnerStdio as c, cleanupLinuxLaunchFiles as d, consumeLinuxLaunchRequest as f, linuxLaunchFilesFromLocator as g, isWindowsTerminateRequest as h, parseRunnerTargetArgv as i, spawnRunnerInvocation as l, deserializeRunnerError as m, WINDOWS_RUNNER_SELECTION as n, runnerEnvironment as o, createLinuxLaunchFiles as p, consumeRunnerSelection as r, runnerInvocationAvailable as s, SUBPROCESS_RUNNER_ENV as t, targetEnvironment as u, parseWindowsStartRequest as v, childEnv as w, writeLinuxStartupError as x, readLinuxStartupError as y };
package/lib/runner.js CHANGED
@@ -1,11 +1,12 @@
1
- import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-COYGu0Dl.js";
1
+ import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-DGV26RBf.js";
2
2
  import { closeSync } from "node:fs";
3
+ import { SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
3
4
  import { Win32Error, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, pollProcessExit, spawnCurrentTokenJobProcess, terminateJob } from "@deepseek-ai/dsh-win32-process";
4
5
  //#region lib/types/spawn-runner.js
5
6
  /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */
6
7
  const defaultInternals = {
7
8
  /* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */
8
- execve: (file, argv, env) => loadLinuxExecve()(file, argv, env),
9
+ execve: (file, argv, env, control) => loadLinuxExecve()(file, argv, env, control),
9
10
  loadWin32ProcessBindings,
10
11
  spawnCurrentTokenJobProcess,
11
12
  closeFileDescriptor: closeSync,
@@ -58,28 +59,32 @@ function linuxPathNotFoundError(program) {
58
59
  spawnargs: []
59
60
  });
60
61
  }
61
- function execLinuxFile(file, argv, env, internals) {
62
+ function execLinuxFile(file, argv, env, internals, control) {
62
63
  try {
63
- return internals.execve(file, argv, env);
64
+ return control === void 0 ? internals.execve(file, argv, env) : internals.execve(file, argv, env, control);
64
65
  } catch (error) {
65
66
  if (error.code !== "ENOEXEC") throw error;
66
- return internals.execve("/bin/sh", [
67
+ return control === void 0 ? internals.execve("/bin/sh", [
67
68
  "/bin/sh",
68
69
  file,
69
70
  ...argv.slice(1)
70
- ], env);
71
+ ], env) : internals.execve("/bin/sh", [
72
+ "/bin/sh",
73
+ file,
74
+ ...argv.slice(1)
75
+ ], env, control);
71
76
  }
72
77
  }
73
78
  function execLinuxTarget(request, argv, internals) {
74
79
  const program = argv[0];
75
- if (program.includes("/")) return execLinuxFile(program, argv, request.env, internals);
80
+ if (program.includes("/")) return execLinuxFile(program, argv, request.env, internals, request.control);
76
81
  const path = request.env.PATH ?? "/usr/bin:/bin";
77
82
  let permissionFailure;
78
83
  for (const directory of path.split(":")) {
79
84
  const root = directory.startsWith("/") ? directory : `${request.cwd}${request.cwd.endsWith("/") ? "" : "/"}${directory}`;
80
85
  const candidate = `${root}${root.endsWith("/") ? "" : "/"}${program}`;
81
86
  try {
82
- return execLinuxFile(candidate, argv, request.env, internals);
87
+ return execLinuxFile(candidate, argv, request.env, internals, request.control);
83
88
  } catch (error) {
84
89
  const code = error.code;
85
90
  if (code === "EACCES") {
@@ -227,12 +232,18 @@ var WindowsJobRunner = class {
227
232
  stdio: {
228
233
  stdin: 4,
229
234
  stdout: 5,
230
- stderr: 6
235
+ stderr: 6,
236
+ ...request.control === "pipe" ? { control: SUBPROCESS_CONTROL_FD } : {}
231
237
  }
232
238
  });
233
239
  this.processHandle = spawned.process;
234
240
  this.jobHandle = spawned.job;
235
- for (const fileDescriptor of [
241
+ for (const fileDescriptor of request.control === "pipe" ? [
242
+ 4,
243
+ 5,
244
+ 6,
245
+ SUBPROCESS_CONTROL_FD
246
+ ] : [
236
247
  4,
237
248
  5,
238
249
  6
@@ -0,0 +1,19 @@
1
+ /** Parent-side setup for one explicitly requested inherited control pipe. */
2
+ import type { Duplex, Readable, Writable } from 'node:stream';
3
+ /**
4
+ * Read the optional extra pipe from Node's stdio tuple.
5
+ * @param child - child whose requested extra pipe was allocated by Node.
6
+ * @param control - requested transport, or undefined when absent.
7
+ * @returns the parent duplex endpoint, absent when not requested or native startup failed.
8
+ */
9
+ export declare function controlPipe(child: {
10
+ readonly stdio: ReadonlyArray<Readable | Writable | null | undefined>;
11
+ }, control?: 'pipe'): Duplex | undefined;
12
+ /**
13
+ * Stamp the private marker on a fresh child environment after rejecting a caller override.
14
+ * @param env - newly materialized child environment, owned by the caller.
15
+ * @param control - requested control transport, or undefined when absent.
16
+ * @returns the same environment with the provider-owned launch marker when requested.
17
+ */
18
+ export declare function controlEnvironment<T extends NodeJS.ProcessEnv>(env: T, control?: 'pipe'): T;
19
+ //# sourceMappingURL=control-spawn.d.ts.map
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { Context } from '@deepseek-ai/cordis';
11
11
  import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
12
- import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
12
+ import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
13
13
  import type { SpawnInternals } from './spawn.ts';
14
14
  import type { ProcessInspector } from './process-inspector.ts';
15
15
  /**
@@ -24,6 +24,8 @@ export declare class LocalSubprocessRuntime extends SubprocessRuntime {
24
24
  private live;
25
25
  /** Live terminals retained through normal quiescence or host-exit finalization. */
26
26
  private terminals;
27
+ /** Caller endpoints retained until close, independently of managed process lifetime. */
28
+ private controlChannels;
27
29
  /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
28
30
  internals: SpawnInternals;
29
31
  /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
@@ -40,6 +42,8 @@ export declare class LocalSubprocessRuntime extends SubprocessRuntime {
40
42
  spawn(spec: SubprocessSpawnSpec): SubprocessHandle;
41
43
  private selectContainmentMode;
42
44
  private warnFallback;
45
+ /** @inheritdoc */
46
+ terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>;
43
47
  spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>;
44
48
  }
45
49
  export default LocalSubprocessRuntime;
@@ -1,6 +1,6 @@
1
1
  /** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */
2
2
  /** Replace the current process image while preserving the supplied argv and environment. */
3
- export type LinuxExecve = (file: string, argv: string[], env: Record<string, string>) => never;
3
+ export type LinuxExecve = (file: string, argv: string[], env: Record<string, string>, control?: 'pipe') => never;
4
4
  /**
5
5
  * Load libc's execve and fcntl symbols on first use and retain the native bindings.
6
6
  * @returns a process-replacing execve operation that throws Node-style errors on failure.