@tiphareth/dsh-hardssh 0.1.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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,600 @@
1
+ import { i as shellQuote, n as readScrubbedRemoteEnvironment, o as isPathUnderAnchor, r as serializeEnvironment } from "./environment-BL1jddfB.js";
2
+ import { randomBytes } from "node:crypto";
3
+ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, posix } from "node:path";
6
+ import { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess";
7
+ import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
8
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
9
+ import { PassThrough } from "node:stream";
10
+ //#region src/remote/output.ts
11
+ /**
12
+ * Bounded host-side projection of one remote output stream with a local
13
+ * spill file. Ported from UynajGI/dsh-ssh (MIT) — verbatim semantics.
14
+ */
15
+ let spillCounter = 0;
16
+ let defaultSpillDir;
17
+ /** Private (0700) per-process spill directory under the OS tmpdir, created lazily. */
18
+ function privateSpillDir() {
19
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-ssh-"));
20
+ return defaultSpillDir;
21
+ }
22
+ /**
23
+ * Collects one remote stream with a bounded in-memory tail. On first overflow
24
+ * a spill file is opened (when a spill cap is configured) and every chunk —
25
+ * already-collected ones included — is appended there while the full stream
26
+ * stays within the cap; without one, only the in-memory tail is retained.
27
+ */
28
+ var SshOutputCollector = class {
29
+ maxBytes;
30
+ maxSpillBytes;
31
+ label;
32
+ spillDir;
33
+ chunks = [];
34
+ retained = 0;
35
+ total = 0;
36
+ dropped = false;
37
+ spillFd;
38
+ spillFile;
39
+ spillDisabled;
40
+ constructor(maxBytes, maxSpillBytes, label, spillDir = privateSpillDir()) {
41
+ this.maxBytes = maxBytes;
42
+ this.maxSpillBytes = maxSpillBytes;
43
+ this.label = label;
44
+ this.spillDir = spillDir;
45
+ this.spillDisabled = maxSpillBytes === void 0;
46
+ }
47
+ /** Append one byte-faithful remote chunk, trimming the retained tail to the cap. */
48
+ push(chunk) {
49
+ if (chunk.length === 0) return;
50
+ const buffer = Buffer.from(chunk);
51
+ this.total += buffer.length;
52
+ const overflows = this.retained + buffer.length > this.maxBytes;
53
+ if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(buffer);
54
+ this.chunks.push(buffer);
55
+ this.retained += buffer.length;
56
+ while (this.retained > this.maxBytes) {
57
+ const head = this.chunks[0];
58
+ const excess = this.retained - this.maxBytes;
59
+ if (head.length <= excess) {
60
+ this.chunks.shift();
61
+ this.retained -= head.length;
62
+ } else {
63
+ this.chunks[0] = head.subarray(excess);
64
+ this.retained -= excess;
65
+ }
66
+ this.dropped = true;
67
+ }
68
+ }
69
+ /** @inheritdoc */
70
+ readFrom(fromByte) {
71
+ const retained = Buffer.concat(this.chunks, this.retained);
72
+ const windowStart = this.total - this.retained;
73
+ const lossy = fromByte < windowStart;
74
+ const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - windowStart));
75
+ return {
76
+ text: retained.subarray(start).toString("utf8"),
77
+ nextOffset: this.total,
78
+ lossy,
79
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
80
+ };
81
+ }
82
+ /** Open the spill file lazily and append one chunk (plus any prior chunks once). */
83
+ spillAll(chunk) {
84
+ if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
85
+ this.discardSpill();
86
+ return;
87
+ }
88
+ if (this.spillFd === void 0) {
89
+ this.spillFile = join(this.spillDir, `dsh-subprocess-ssh-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
90
+ this.spillFd = openSync(this.spillFile, "wx", 384);
91
+ for (const prior of this.chunks) writeSync(this.spillFd, prior);
92
+ }
93
+ writeSync(this.spillFd, chunk);
94
+ }
95
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
96
+ discardSpill() {
97
+ const fd = this.spillFd;
98
+ const file = this.spillFile;
99
+ this.spillFd = void 0;
100
+ this.spillFile = void 0;
101
+ this.spillDisabled = true;
102
+ if (fd !== void 0) try {
103
+ closeSync(fd);
104
+ } catch {
105
+ this.spillFd = fd;
106
+ }
107
+ if (file !== void 0) try {
108
+ unlinkSync(file);
109
+ } catch {}
110
+ }
111
+ /** Close the spill file once the stream has ended; stop advertising it on a failed close. */
112
+ seal() {
113
+ if (this.spillFd === void 0) return;
114
+ try {
115
+ closeSync(this.spillFd);
116
+ } catch {
117
+ this.spillFile = void 0;
118
+ }
119
+ this.spillFd = void 0;
120
+ }
121
+ /** Seal the spill and return the final collected output. */
122
+ finalize() {
123
+ this.seal();
124
+ return {
125
+ text: Buffer.concat(this.chunks).toString("utf8"),
126
+ truncated: this.dropped,
127
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
128
+ };
129
+ }
130
+ };
131
+ //#endregion
132
+ //#region src/remote/remote-process.ts
133
+ /**
134
+ * One asynchronously-started SSH command projected onto the subprocess seam.
135
+ * Ported from UynajGI/dsh-ssh (MIT) — the raw ssh2 channel is replaced by the
136
+ * engine's streaming ExecSession.
137
+ */
138
+ function isCollect(mode) {
139
+ return mode !== "pipe" && mode !== "inherit";
140
+ }
141
+ /** Resolve the remote working directory for one spawn spec. */
142
+ function resolveRemoteCwd(state, cwd) {
143
+ const root = state.remoteRoot;
144
+ if (root === void 0) throw new Error("subprocess-ssh: remote workspace root is not set");
145
+ if (cwd !== void 0 && cwd.startsWith("/")) return cwd;
146
+ return root;
147
+ }
148
+ /** Build the remote command text: cd, env -i scrub, exec the argv. */
149
+ async function buildCommand(engine, alias, state, spec) {
150
+ const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, alias), spec.env);
151
+ const argv = spec.argv.map(shellQuote).join(" ");
152
+ return `cd -- ${shellQuote(resolveRemoteCwd(state, spec.cwd))} && exec env -i -- ${environment} ${argv}`;
153
+ }
154
+ /** SSH-backed subprocess handle. The channel does not expose a remote pid, so `pid` is `-1`. */
155
+ var SshSubprocessHandle = class {
156
+ engine;
157
+ getState;
158
+ spec;
159
+ spillDir;
160
+ stdin;
161
+ stdout;
162
+ stderr;
163
+ collected;
164
+ done;
165
+ terminationController = new AbortController();
166
+ stdoutCollector;
167
+ stderrCollector;
168
+ session;
169
+ graceTimer;
170
+ settled = false;
171
+ constructor(engine, getState, spec, spillDir) {
172
+ this.engine = engine;
173
+ this.getState = getState;
174
+ this.spec = spec;
175
+ this.spillDir = spillDir;
176
+ const outMode = spec.stdio.stdout;
177
+ const errMode = spec.stdio.stderr;
178
+ this.stdout = outMode === "pipe" ? new PassThrough() : void 0;
179
+ this.stderr = errMode === "pipe" ? new PassThrough() : void 0;
180
+ this.stdoutCollector = isCollect(outMode) ? new SshOutputCollector(outMode.maxBytes, outMode.spill?.maxBytes, "stdout", spillDir) : void 0;
181
+ this.stderrCollector = isCollect(errMode) ? new SshOutputCollector(errMode.maxBytes, errMode.spill?.maxBytes, "stderr", spillDir) : void 0;
182
+ this.collected = {
183
+ ...this.stdoutCollector !== void 0 ? { stdout: this.stdoutCollector } : {},
184
+ ...this.stderrCollector !== void 0 ? { stderr: this.stderrCollector } : {}
185
+ };
186
+ this.stdin = spec.stdio.stdin === "pipe" ? new PassThrough() : void 0;
187
+ spec.signal?.addEventListener("abort", this.onAbort, { once: true });
188
+ this.done = this.run();
189
+ this.done.catch(() => {});
190
+ if (spec.signal?.aborted === true) this.terminate();
191
+ }
192
+ /** Remote process id; `-1` because the SSH channel does not expose one. */
193
+ get pid() {
194
+ return -1;
195
+ }
196
+ /** @inheritdoc */
197
+ terminate() {
198
+ if (this.settled || this.terminationController.signal.aborted) return;
199
+ this.terminationController.abort(/* @__PURE__ */ new Error("subprocess-ssh: command terminated"));
200
+ const session = this.session;
201
+ if (session !== void 0) this.signalTerm(session);
202
+ }
203
+ /** @inheritdoc */
204
+ waitForExit(signal) {
205
+ if (this.settled) return Promise.resolve(true);
206
+ if (signal?.aborted === true) return Promise.resolve(false);
207
+ if (signal === void 0) return this.done.then(() => true, () => true);
208
+ return new Promise((resolve) => {
209
+ const onAbort = () => {
210
+ cleanup();
211
+ resolve(false);
212
+ };
213
+ const cleanup = () => {
214
+ signal.removeEventListener("abort", onAbort);
215
+ };
216
+ signal.addEventListener("abort", onAbort, { once: true });
217
+ this.done.then(() => {
218
+ cleanup();
219
+ resolve(true);
220
+ }, () => {
221
+ cleanup();
222
+ resolve(true);
223
+ });
224
+ });
225
+ }
226
+ onAbort = () => {
227
+ this.terminate();
228
+ };
229
+ signalTerm(session) {
230
+ try {
231
+ session.signal("TERM");
232
+ } catch {}
233
+ this.graceTimer = setTimeout(() => {
234
+ try {
235
+ session.signal("KILL");
236
+ } catch {}
237
+ }, this.spec.graceMs);
238
+ }
239
+ settle() {
240
+ if (this.settled) return;
241
+ this.settled = true;
242
+ if (this.graceTimer !== void 0) clearTimeout(this.graceTimer);
243
+ this.graceTimer = void 0;
244
+ this.stdoutCollector?.seal();
245
+ this.stderrCollector?.seal();
246
+ this.spec.signal?.removeEventListener("abort", this.onAbort);
247
+ }
248
+ async run() {
249
+ let session;
250
+ try {
251
+ const state = this.getState();
252
+ if (state.mode !== "remote" || state.alias === void 0) throw new Error("subprocess-ssh: not in remote mode — switch the GUI to SSH mode first");
253
+ const command = await buildCommand(this.engine, state.alias, state, this.spec);
254
+ session = await this.engine.openExec(state.alias, command);
255
+ } catch (error) {
256
+ this.settle();
257
+ throw error;
258
+ }
259
+ this.session = session;
260
+ if (this.terminationController.signal.aborted) this.signalTerm(session);
261
+ this.wireStdout(session);
262
+ this.wireStderr(session);
263
+ if (this.stdin !== void 0) this.stdin.pipe({
264
+ write: (chunk, encoding, callback) => {
265
+ try {
266
+ session.send(chunk.toString(encoding ?? "utf8"));
267
+ callback?.();
268
+ } catch (error) {
269
+ callback?.(error instanceof Error ? error : new Error(String(error)));
270
+ }
271
+ },
272
+ end: (callback) => {
273
+ session.end();
274
+ callback?.();
275
+ }
276
+ });
277
+ else if (typeof this.spec.stdio.stdin === "object") session.end(this.spec.stdio.stdin.data);
278
+ return await new Promise((resolve, reject) => {
279
+ session.onExit = (code, error) => {
280
+ this.settle();
281
+ if (error !== void 0) reject(/* @__PURE__ */ new Error(`subprocess-ssh: ${error}`));
282
+ else resolve({
283
+ exitCode: code,
284
+ signal: null
285
+ });
286
+ };
287
+ });
288
+ }
289
+ wireStdout(session) {
290
+ const mode = this.spec.stdio.stdout;
291
+ session.onData = (data) => {
292
+ if (mode === "pipe") this.stdout?.write(data);
293
+ else if (mode === "inherit") process.stdout.write(data);
294
+ else this.stdoutCollector?.push(data);
295
+ };
296
+ }
297
+ wireStderr(session) {
298
+ const mode = this.spec.stdio.stderr;
299
+ session.onErrData = (data) => {
300
+ if (mode === "pipe") this.stderr?.write(data);
301
+ else if (mode === "inherit") process.stderr.write(data);
302
+ else this.stderrCollector?.push(data);
303
+ };
304
+ }
305
+ };
306
+ //#endregion
307
+ //#region src/remote/remote-terminal.ts
308
+ /**
309
+ * SSH PTY allocation and process-session ownership for the subprocess seam.
310
+ * Ported from UynajGI/dsh-ssh (MIT) — the raw ssh2 shell channel is replaced
311
+ * by the engine's openShell session.
312
+ */
313
+ /** Resolve after one duration. */
314
+ function delay(ms) {
315
+ return new Promise((resolve) => setTimeout(resolve, ms));
316
+ }
317
+ /** One SSH PTY and its remote login shell, projected onto the subprocess terminal seam. */
318
+ var SshTerminalHandle = class {
319
+ closeChannel;
320
+ sendChannel;
321
+ signalChannel;
322
+ graceMs;
323
+ pid = -1;
324
+ output = new PassThrough();
325
+ done;
326
+ exitResolve;
327
+ topLevelExited = false;
328
+ cleanup;
329
+ constructor(closeChannel, sendChannel, signalChannel, graceMs) {
330
+ this.closeChannel = closeChannel;
331
+ this.sendChannel = sendChannel;
332
+ this.signalChannel = signalChannel;
333
+ this.graceMs = graceMs;
334
+ this.done = new Promise((resolve) => {
335
+ this.exitResolve = resolve;
336
+ });
337
+ }
338
+ /** Settle the exit promise (called by the runtime when the channel closes). */
339
+ resolveExit(outcome) {
340
+ this.exitResolve(outcome);
341
+ }
342
+ /** @inheritdoc */
343
+ write(data) {
344
+ if (this.topLevelExited) return Promise.reject(/* @__PURE__ */ new Error("terminal process has exited"));
345
+ return new Promise((resolve, reject) => {
346
+ try {
347
+ this.sendChannel(data);
348
+ resolve();
349
+ } catch (error) {
350
+ reject(error instanceof Error ? error : new Error(String(error)));
351
+ }
352
+ });
353
+ }
354
+ /** The SSH channel does not expose a foreground process group. */
355
+ inspectForeground() {
356
+ return Promise.resolve(void 0);
357
+ }
358
+ /** @inheritdoc */
359
+ signalForeground(_signal) {
360
+ return Promise.reject(/* @__PURE__ */ new Error("subprocess-ssh: cannot resolve the foreground process group over an SSH channel"));
361
+ }
362
+ /** @inheritdoc */
363
+ terminate() {
364
+ if (this.cleanup !== void 0) return this.cleanup;
365
+ const cleanup = this.closeOnce();
366
+ this.cleanup = cleanup;
367
+ cleanup.catch(() => {
368
+ this.cleanup = void 0;
369
+ });
370
+ return cleanup;
371
+ }
372
+ signal(name) {
373
+ try {
374
+ this.signalChannel(name);
375
+ } catch {}
376
+ }
377
+ async closeOnce() {
378
+ this.signal("TERM");
379
+ await Promise.race([this.done.then(() => void 0, () => void 0), delay(this.graceMs)]);
380
+ if (!this.topLevelExited) this.signal("KILL");
381
+ await Promise.race([this.done.then(() => void 0, () => void 0), delay(this.graceMs)]);
382
+ if (!this.topLevelExited) {
383
+ this.closeChannel();
384
+ throw new Error("subprocess-ssh: terminal cleanup failed; channel still open");
385
+ }
386
+ }
387
+ };
388
+ /**
389
+ * Allocate an SSH PTY, replace its login shell with the requested argv, and
390
+ * return the live terminal handle.
391
+ */
392
+ async function spawnSshTerminal(engine, getState, spec) {
393
+ spec.signal?.throwIfAborted();
394
+ const program = spec.argv[0];
395
+ if (program === void 0 || program.length === 0) throw new Error("subprocess-ssh: terminal argv must contain a program");
396
+ const state = getState();
397
+ if (state.mode !== "remote" || state.alias === void 0) throw new Error("subprocess-ssh: not in remote mode — switch the GUI to SSH mode first");
398
+ const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, state.alias), spec.env);
399
+ const session = await engine.openShell(state.alias, {
400
+ cols: spec.cols,
401
+ rows: spec.rows
402
+ });
403
+ const root = state.remoteRoot;
404
+ if (root === void 0) throw new Error("subprocess-ssh: remote workspace root is not set");
405
+ const cwd = spec.cwd !== void 0 && spec.cwd.startsWith("/") ? spec.cwd : root;
406
+ const handle = new SshTerminalHandle(() => session.close(), (data) => session.send(data), (name) => session.signal(name), spec.graceMs);
407
+ session.onData = (data) => {
408
+ if (!handle.output.destroyed) handle.output.write(data);
409
+ };
410
+ session.onExit = (code, error) => {
411
+ handle.topLevelExited = true;
412
+ handle.output.end();
413
+ handle.resolveExit({
414
+ exitCode: error !== void 0 ? null : code,
415
+ signal: null
416
+ });
417
+ };
418
+ const argv = spec.argv.map(shellQuote).join(" ");
419
+ await handle.write(`cd ${shellQuote(cwd)} && exec env -i -- ${environment} ${argv}\r`);
420
+ spec.signal?.throwIfAborted();
421
+ return handle;
422
+ }
423
+ //#endregion
424
+ //#region src/remote/remote-subprocess.ts
425
+ /**
426
+ * Remote subprocess provider for the `ctx.subprocess` capability seam: each
427
+ * spawn opens a streaming exec channel (or a PTY for terminals) on the
428
+ * current SSH-mode host through the dsh-ssh engine; output spill files stay
429
+ * on the local host. Ported from UynajGI/dsh-ssh (MIT).
430
+ *
431
+ * @module dsh-hardssh/remote-subprocess
432
+ */
433
+ /**
434
+ * Enforce the seam's documented grace bound (positive, finite, one Node timer).
435
+ */
436
+ function requireRepresentableGrace(graceMs) {
437
+ if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > MAX_TIMER_DELAY_MS) throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
438
+ }
439
+ /** SSH command manager registered as `ctx.subprocess` (remote mode). */
440
+ var SshSubprocessRuntime = class extends SubprocessRuntime {
441
+ engine;
442
+ getState;
443
+ live = /* @__PURE__ */ new Set();
444
+ terminals = /* @__PURE__ */ new Set();
445
+ spillDir = mkdtempSync(join(tmpdir(), "dsh-subprocess-ssh-"));
446
+ disposing = false;
447
+ constructor(ctx, engine, getState) {
448
+ super(ctx);
449
+ this.engine = engine;
450
+ this.getState = getState;
451
+ ctx.effect(() => async () => {
452
+ this.disposing = true;
453
+ const handles = [...this.live];
454
+ const terminals = [...this.terminals];
455
+ const pending = [];
456
+ for (const handle of handles) {
457
+ handle.terminate();
458
+ pending.push(handle.waitForExit().then(() => {
459
+ this.live.delete(handle);
460
+ }));
461
+ }
462
+ for (const terminal of terminals) pending.push(terminal.terminate().then(() => {
463
+ this.terminals.delete(terminal);
464
+ }));
465
+ const failures = (await Promise.allSettled(pending)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
466
+ if (failures.length === 1) throw failures[0];
467
+ if (failures.length > 1) throw new AggregateError(failures, "subprocess-ssh: teardown failed");
468
+ }, "ssh subprocess teardown");
469
+ }
470
+ /** @inheritdoc */
471
+ async resolveExecutable(command, env, signal) {
472
+ if (command.length === 0) throw new Error("subprocess-ssh: executable name must be non-empty");
473
+ signal?.throwIfAborted();
474
+ const state = this.getState();
475
+ if (state.mode !== "remote" || state.alias === void 0) throw new Error("subprocess-ssh: not in remote mode — switch the GUI to SSH mode first");
476
+ if (posix.isAbsolute(command)) {
477
+ const result = await this.engine.exec(state.alias, `test -f ${shellQuote(command)} -a -x ${shellQuote(command)}`, 1e4);
478
+ signal?.throwIfAborted();
479
+ if (!result.success || result.exitCode !== 0) throw new Error(`subprocess-ssh: command ${JSON.stringify(command)} is not an executable file`);
480
+ return command;
481
+ }
482
+ if (command.includes("/")) throw new Error(`subprocess-ssh: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`);
483
+ const path = env?.PATH;
484
+ const prefix = path === void 0 ? "" : `PATH=${shellQuote(path)} `;
485
+ const result = await this.engine.exec(state.alias, `${prefix}command -v -- ${shellQuote(command)}`, 1e4);
486
+ signal?.throwIfAborted();
487
+ const executable = result.stdout.trim();
488
+ if (!result.success || result.exitCode !== 0 || executable.length === 0 || executable.includes("\n") || !posix.isAbsolute(executable) && !executable.includes("/")) throw new Error(`subprocess-ssh: executable ${JSON.stringify(command)} did not resolve to one absolute path`);
489
+ const root = state.remoteRoot;
490
+ if (root === void 0) throw new Error("subprocess-ssh: remote workspace root is not set");
491
+ return posix.isAbsolute(executable) ? executable : posix.resolve(root, executable);
492
+ }
493
+ /** @inheritdoc */
494
+ spawn(spec) {
495
+ if (this.disposing) throw new Error("subprocess-ssh: service is disposing");
496
+ const program = spec.argv[0];
497
+ if (program === void 0 || program.length === 0) throw new Error("invalid argv: expected a non-empty program name at argv[0]");
498
+ requireRepresentableGrace(spec.graceMs);
499
+ if (spec.signal?.aborted === true) throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`);
500
+ const handle = new SshSubprocessHandle(this.engine, this.getState, spec, this.spillDir);
501
+ this.live.add(handle);
502
+ const release = async () => {
503
+ await handle.waitForExit();
504
+ this.live.delete(handle);
505
+ };
506
+ handle.done.then(release, release).catch(() => {});
507
+ return handle;
508
+ }
509
+ /** @inheritdoc */
510
+ async spawnTerminal(spec) {
511
+ if (this.disposing) throw new Error("subprocess-ssh: service is disposing");
512
+ const program = spec.argv[0];
513
+ if (program === void 0 || program.length === 0) throw new Error("subprocess-ssh: terminal argv must contain a program");
514
+ requireRepresentableGrace(spec.graceMs);
515
+ spec.signal?.throwIfAborted();
516
+ const terminal = await spawnSshTerminal(this.engine, this.getState, spec);
517
+ if (this.disposing) {
518
+ await terminal.terminate();
519
+ throw new Error("subprocess-ssh: service disposed during terminal setup");
520
+ }
521
+ this.terminals.add(terminal);
522
+ const release = async () => {
523
+ await terminal.terminate();
524
+ this.terminals.delete(terminal);
525
+ };
526
+ terminal.done.then(release, release).catch(() => {});
527
+ return terminal;
528
+ }
529
+ };
530
+ //#endregion
531
+ //#region src/switch/switch-subprocess.ts
532
+ /**
533
+ * The `ctx.subprocess` switching facade: routes every spawn to the execution
534
+ * world of the SESSION's workspace — local sessions spawn locally, SSH-bound
535
+ * workspaces spawn on the bound host. The routing anchor is the spawn spec's
536
+ * `cwd` (bash tools default it to the session cwd); the resolver lives in
537
+ * the deps. One instance provides `ctx.subprocess` after the profile patch
538
+ * disabled the plain subprocess row, like the fs facade.
539
+ *
540
+ * @module dsh-hardssh/switch-subprocess
541
+ */
542
+ /** Workspace-routing subprocess facade. */
543
+ var SwitchSubprocessRuntime = class extends SubprocessRuntime {
544
+ deps;
545
+ constructor(ctx, deps) {
546
+ super(ctx);
547
+ this.deps = deps;
548
+ }
549
+ /** The runtime for one spec (by its cwd). */
550
+ runtimeFor(cwd) {
551
+ return this.deps.worldFor(cwd);
552
+ }
553
+ /** @inheritdoc */
554
+ resolveExecutable(command, env, signal) {
555
+ return this.deps.local.resolveExecutable(command, env, signal);
556
+ }
557
+ /** @inheritdoc */
558
+ spawn(spec) {
559
+ return this.runtimeFor(spec.cwd).spawn(spec);
560
+ }
561
+ /** @inheritdoc */
562
+ spawnTerminal(spec) {
563
+ return this.runtimeFor(spec.cwd).spawnTerminal(spec);
564
+ }
565
+ };
566
+ //#endregion
567
+ //#region src/subprocess.ts
568
+ /** Stable cordis plugin name. */
569
+ const name = "hardssh-subprocess";
570
+ /** Services required: the shared workspace core (mode store + engine). */
571
+ const inject = ["hardsshCore"];
572
+ /** A remote runtime fixed to one SSH workspace's alias + remote root. */
573
+ function fixedRemoteState(alias, remoteRoot) {
574
+ return () => ({
575
+ mode: "remote",
576
+ alias,
577
+ remoteRoot
578
+ });
579
+ }
580
+ /** Mount the switching subprocess facade. */
581
+ function apply(ctx) {
582
+ const core = ctx.hardsshCore;
583
+ const localCtx = ctx.isolate("subprocess");
584
+ const localSubprocess = new LocalSubprocessRuntime(localCtx);
585
+ core.seams.bindSub((record) => new SshSubprocessRuntime(ctx.isolate("subprocess"), core.engine, fixedRemoteState(record.alias, record.remoteRoot)));
586
+ const anchorRoot = core.ledger.anchorsRoot();
587
+ let warnedUnready = false;
588
+ new SwitchSubprocessRuntime(ctx, {
589
+ local: localSubprocess,
590
+ worldFor: (cwd) => {
591
+ if (!core.seams.isReady() && !warnedUnready && cwd !== void 0 && isPathUnderAnchor(anchorRoot, cwd)) {
592
+ warnedUnready = true;
593
+ console.warn("[dsh-hardssh] subprocess routing is not ready yet (workspace ledger still loading) — running locally until the snapshot is applied");
594
+ }
595
+ return core.seams.runtimeForSub(cwd) ?? localSubprocess;
596
+ }
597
+ });
598
+ }
599
+ //#endregion
600
+ export { apply, inject, name };