@deepseek-ai/dsh-subprocess-local 0.1.5-rc.2 → 0.1.6-alpha.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/lib/output.js ADDED
@@ -0,0 +1,181 @@
1
+ import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ //#region lib/types/output.js
6
+ /** Bounded output tails and private spill files shared by process providers. */
7
+ let spillCounter = 0;
8
+ let defaultSpillDir;
9
+ /**
10
+ * The default spill location: a private (0700) per-process directory under
11
+ * the OS tmpdir, created lazily. Predictable world-readable paths would let
12
+ * other local users read command output or pre-create symlinks. At a
13
+ * JavaScript-observable process exit the directory is removed only when it
14
+ * holds no completed spill file (spill files are retained as full-output
15
+ * recovery artifacts until an external cleanup).
16
+ */
17
+ function privateSpillDir() {
18
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
19
+ return defaultSpillDir;
20
+ }
21
+ /* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
22
+ process.once("exit", () => {
23
+ if (defaultSpillDir === void 0) return;
24
+ try {
25
+ rmdirSync(defaultSpillDir);
26
+ } catch {}
27
+ });
28
+ /**
29
+ * Prepare fallible output storage before starting a managed native process.
30
+ * @param internals - optional caller-owned spill directory.
31
+ * @returns binding inputs whose spill directory is ready for use.
32
+ */
33
+ function prepareManagedProcessBinding(internals = {}) {
34
+ return { spillDir: internals.spillDir ?? privateSpillDir() };
35
+ }
36
+ /**
37
+ * Collects one stream with a bounded in-memory tail. With a spill cap, on
38
+ * first overflow a spill file is created and every chunk (including those
39
+ * already collected) is appended there while the full stream remains within
40
+ * the cap; without one, only the in-memory tail is ever retained (the
41
+ * diagnostic-tail shape — a language server's stderr).
42
+ *
43
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
44
+ * end of command output; the spill file covers the head.
45
+ */
46
+ var OutputCollector = class {
47
+ maxBytes;
48
+ maxSpillBytes;
49
+ label;
50
+ spillDir;
51
+ chunks = [];
52
+ bytes = 0;
53
+ dropped = false;
54
+ spillFd;
55
+ spillFile;
56
+ spillDisabled;
57
+ /** Total bytes ever pushed (not just retained). */
58
+ total = 0;
59
+ constructor(maxBytes, maxSpillBytes, label, spillDir) {
60
+ this.maxBytes = maxBytes;
61
+ this.maxSpillBytes = maxSpillBytes;
62
+ this.label = label;
63
+ this.spillDir = spillDir;
64
+ this.spillDisabled = maxSpillBytes === void 0;
65
+ }
66
+ /**
67
+ * Ingest one stream chunk, counting it toward the whole-stream total. On
68
+ * first overflow of the in-memory cap a spill file is opened (when spilling
69
+ * is enabled) and every chunk (already-collected ones included) is appended
70
+ * there from then on; the in-memory tail then drops whole chunks from its
71
+ * head (or the head of a single over-cap chunk) until it fits the cap again.
72
+ * @param chunk - the raw bytes from one stream 'data' event.
73
+ */
74
+ push(chunk) {
75
+ this.total += chunk.length;
76
+ const overflows = this.bytes + chunk.length > this.maxBytes;
77
+ if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
78
+ this.chunks.push(chunk);
79
+ this.bytes += chunk.length;
80
+ while (this.bytes > this.maxBytes) {
81
+ const head = this.chunks[0];
82
+ const excess = this.bytes - this.maxBytes;
83
+ if (head.length <= excess) {
84
+ this.chunks.shift();
85
+ this.bytes -= head.length;
86
+ } else {
87
+ this.chunks[0] = head.subarray(excess);
88
+ this.bytes -= excess;
89
+ }
90
+ this.dropped = true;
91
+ }
92
+ }
93
+ /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
94
+ spillAll(chunk) {
95
+ if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
96
+ this.discardSpill();
97
+ return;
98
+ }
99
+ if (this.spillFd === void 0) {
100
+ this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
101
+ this.spillFd = openSync(this.spillFile, "wx", 384);
102
+ for (const prior of this.chunks) writeSync(this.spillFd, prior);
103
+ }
104
+ writeSync(this.spillFd, chunk);
105
+ }
106
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
107
+ discardSpill() {
108
+ const fd = this.spillFd;
109
+ const file = this.spillFile;
110
+ this.spillFd = void 0;
111
+ this.spillFile = void 0;
112
+ this.spillDisabled = true;
113
+ if (fd !== void 0) try {
114
+ closeSync(fd);
115
+ } catch {
116
+ this.spillFd = fd;
117
+ }
118
+ if (file !== void 0) try {
119
+ unlinkSync(file);
120
+ } catch {}
121
+ }
122
+ /**
123
+ * Incremental read in whole-stream byte coordinates: returns everything
124
+ * pushed since `fromByte`. When `fromByte` has already slid out of the
125
+ * in-memory tail window, the read is `lossy` — it returns the whole
126
+ * retained tail and the gap is only recoverable from the spill file.
127
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
128
+ * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
129
+ */
130
+ readFrom(fromByte) {
131
+ const windowStart = this.total - this.bytes;
132
+ const buffer = Buffer.concat(this.chunks);
133
+ const lossy = fromByte < windowStart;
134
+ return {
135
+ text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
136
+ nextOffset: this.total,
137
+ lossy,
138
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
139
+ };
140
+ }
141
+ /**
142
+ * Copy the retained raw tail with its position in the complete observed stream.
143
+ * @returns independent tail bytes and the total byte count before truncation.
144
+ */
145
+ snapshot() {
146
+ return {
147
+ bytes: Buffer.concat(this.chunks),
148
+ totalBytes: this.total
149
+ };
150
+ }
151
+ /**
152
+ * Close the spill file once the stream has ended. A failed close (delayed
153
+ * writeback fault) stops advertising the spill path — the file may be
154
+ * missing its tail — while every in-memory read keeps working. Idempotent;
155
+ * the spawn path seals both collectors at settlement so reads after exit
156
+ * never point at a still-open file.
157
+ */
158
+ seal() {
159
+ if (this.spillFd === void 0) return;
160
+ try {
161
+ closeSync(this.spillFd);
162
+ } catch {
163
+ this.spillFile = void 0;
164
+ }
165
+ this.spillFd = void 0;
166
+ }
167
+ /**
168
+ * Seal the spill file and return the final output.
169
+ * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
170
+ */
171
+ finalize() {
172
+ this.seal();
173
+ return {
174
+ text: Buffer.concat(this.chunks).toString("utf8"),
175
+ truncated: this.dropped,
176
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
177
+ };
178
+ }
179
+ };
180
+ //#endregion
181
+ export { OutputCollector, prepareManagedProcessBinding };
@@ -1,11 +1,12 @@
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";
3
5
  import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
4
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
5
- import { randomBytes } from "node:crypto";
6
- import { tmpdir } from "node:os";
7
7
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
8
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
9
+ import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
9
10
  import koffi from "koffi";
10
11
  import { getSystemErrorMessage, getSystemErrorName, inspect } from "node:util";
11
12
  import { fileURLToPath } from "node:url";
@@ -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
@@ -669,169 +694,6 @@ function childEnv(extra) {
669
694
  function sleepTick() {
670
695
  return setTimeout$1(15);
671
696
  }
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
697
  /**
836
698
  * Terminate one Windows process tree with `taskkill /T /F`. Contained like
837
699
  * POSIX group signalling — delivery races tree exit, so an absent tree, a
@@ -1113,6 +975,7 @@ function bindManagedProcess(spec, launch, internals = {}) {
1113
975
  stdin: stdinMode === "pipe" ? stdin ?? void 0 : void 0,
1114
976
  stdout: outMode === "pipe" ? stdout ?? void 0 : void 0,
1115
977
  stderr: errMode === "pipe" ? stderr ?? void 0 : void 0,
978
+ control: launch.control,
1116
979
  /* v8 ignore stop */
1117
980
  collected: {
1118
981
  ...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
@@ -1134,14 +997,19 @@ function spawnSubprocess(spec, internals = {}) {
1134
997
  const binding = prepareManagedProcessBinding(internals);
1135
998
  const platform = internals.platform ?? process.platform;
1136
999
  const [program, ...args] = spec.argv;
1000
+ const stdio = [
1001
+ spec.stdio.stdin === "ignore" ? "ignore" : "pipe",
1002
+ spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
1003
+ spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
1004
+ ];
1005
+ if (spec.stdio.control === "pipe") {
1006
+ while (stdio.length < SUBPROCESS_CONTROL_FD) stdio.push("ignore");
1007
+ stdio.push("overlapped");
1008
+ }
1137
1009
  const child = (internals.spawn ?? spawn)(program, args, {
1138
1010
  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
- ],
1011
+ env: controlEnvironment(childEnv(spec.env), spec.stdio.control),
1012
+ stdio,
1145
1013
  detached: platform !== "win32",
1146
1014
  windowsHide: platform === "win32"
1147
1015
  });
@@ -1152,6 +1020,7 @@ function spawnSubprocess(spec, internals = {}) {
1152
1020
  stdin: child.stdin,
1153
1021
  stdout: child.stdout,
1154
1022
  stderr: child.stderr,
1023
+ control: controlPipe(child, spec.stdio.control),
1155
1024
  direct,
1156
1025
  owner
1157
1026
  }, binding);
@@ -1189,8 +1058,9 @@ function loadLinuxExecve() {
1189
1058
  const libc = koffi.load(null);
1190
1059
  const nativeExecve = libc.func("int execve(const char *pathname, const char **argv, const char **envp)");
1191
1060
  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) {
1061
+ cachedExecve = (file, argv, env, control) => {
1062
+ const descriptors = control === "pipe" ? [...STANDARD_FILE_DESCRIPTORS, SUBPROCESS_CONTROL_FD] : STANDARD_FILE_DESCRIPTORS;
1063
+ for (const fd of descriptors) {
1194
1064
  const flags = nativeFcntl(fd, F_GETFD, 0);
1195
1065
  if (flags === -1) throw systemError(koffi.errno(), "fcntl");
1196
1066
  if ((flags & FD_CLOEXEC) === 0) continue;
@@ -1277,10 +1147,11 @@ function consumeLinuxLaunchRequest(requestPath) {
1277
1147
  const text = readFileSync(requestPath, "utf8");
1278
1148
  unlinkSync(requestPath);
1279
1149
  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");
1150
+ 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
1151
  return {
1282
1152
  cwd: value.cwd,
1283
- env: value.env
1153
+ env: value.env,
1154
+ ...value.control === "pipe" ? { control: "pipe" } : {}
1284
1155
  };
1285
1156
  }
1286
1157
  /**
@@ -1315,11 +1186,12 @@ function parseWindowsStartRequest(value) {
1315
1186
  "type",
1316
1187
  "cwd",
1317
1188
  "env"
1318
- ]) || value.type !== "start" || typeof value.cwd !== "string" || !isStringRecord(value.env)) throw new Error("subprocess runner received an invalid Windows start request");
1189
+ ], ["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
1190
  return {
1320
1191
  type: "start",
1321
1192
  cwd: value.cwd,
1322
- env: value.env
1193
+ env: value.env,
1194
+ ...value.control === "pipe" ? { control: "pipe" } : {}
1323
1195
  };
1324
1196
  }
1325
1197
  /**
@@ -1477,7 +1349,7 @@ function parseRunnerTargetArgv(argv) {
1477
1349
  }
1478
1350
  /**
1479
1351
  * 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.
1352
+ * on fd 3 and target carriers on fd 4 through fd 6; optional control uses fd 7.
1481
1353
  * @param spec - ordinary subprocess request whose stdio modes are preserved.
1482
1354
  * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
1483
1355
  * @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
@@ -1489,8 +1361,14 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
1489
1361
  spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
1490
1362
  spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
1491
1363
  ];
1492
- if (!ipc) return targetStdio;
1493
- return [
1364
+ if (!ipc) {
1365
+ if (spec.stdio.control === "pipe") {
1366
+ while (targetStdio.length < SUBPROCESS_CONTROL_FD) targetStdio.push("ignore");
1367
+ targetStdio.push("overlapped");
1368
+ }
1369
+ return targetStdio;
1370
+ }
1371
+ const runner = [
1494
1372
  "ignore",
1495
1373
  "ignore",
1496
1374
  "ignore",
@@ -1499,6 +1377,8 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
1499
1377
  spec.stdio.stdout === "inherit" ? 1 : "pipe",
1500
1378
  spec.stdio.stderr === "inherit" ? 2 : "pipe"
1501
1379
  ];
1380
+ if (spec.stdio.control === "pipe") runner.push("overlapped");
1381
+ return runner;
1502
1382
  }
1503
1383
  function windowsEnvironmentValue(env, name) {
1504
1384
  for (const key of Object.keys(env).sort()) if (key.toUpperCase() === name) return env[key];
@@ -1617,7 +1497,7 @@ function targetEnvironment(spec) {
1617
1497
  validateNoNullByte(`options.env['${key}']`, key);
1618
1498
  validateNoNullByte(`options.env['${key}']`, value);
1619
1499
  }
1620
- return env;
1500
+ return controlEnvironment(env, spec.stdio?.control);
1621
1501
  }
1622
1502
  //#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 };
1503
+ 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-BnjugUhn.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.