@tiphareth/dsh-hardssh 0.1.2 → 0.2.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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/lib/subprocess.js CHANGED
@@ -1,533 +1,7 @@
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";
1
+ import { i as isPathUnderAnchor } from "./ledger-D2ezq1iW.js";
2
+ import { t as anchorRoot } from "./ledger-B-LXlftp.js";
6
3
  import { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess";
7
4
  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
5
  //#region src/switch/switch-subprocess.ts
532
6
  /**
533
7
  * The `ctx.subprocess` switching facade: routes every spawn to the execution
@@ -539,6 +13,36 @@ var SshSubprocessRuntime = class extends SubprocessRuntime {
539
13
  *
540
14
  * @module dsh-hardssh/switch-subprocess
541
15
  */
16
+ const DEFAULT_CLIENT_TOOL_NAMES = [
17
+ "pwsh",
18
+ "powershell",
19
+ "cmd"
20
+ ];
21
+ const CLIENT_EXECUTABLE_RE = /\.(exe|cmd|bat|ps1|com)$/i;
22
+ /**
23
+ * Client-packaged helpers that search the WORKSPACE's files (the bundled
24
+ * ripgrep behind the glob/grep tools). They are client binaries, but they are
25
+ * not shells: running one locally while the session's world is remote searches
26
+ * the local anchor directory (an empty placeholder) and reports "no matches",
27
+ * and sending the client's absolute PATH to the server cannot work either.
28
+ * Either way the answer is wrong, so a path-shaped invocation is refused
29
+ * loudly. A BARE `rg` is left alone: that is an ordinary server command and
30
+ * belongs to the remote world like any other.
31
+ */
32
+ const CLIENT_WORKSPACE_SEARCH_RE = /^(rg|ripgrep)(\.exe)?$/i;
33
+ /** True for a PATH-shaped (not bare-name) invocation of a search helper. */
34
+ function isClientSearchHelperPath(exe) {
35
+ if (!exe.includes("/") && !exe.includes("\\")) return false;
36
+ const base = exe.split(/[\\/]/).pop() ?? exe;
37
+ return CLIENT_WORKSPACE_SEARCH_RE.test(base);
38
+ }
39
+ /** A client-native executable: Windows-format path/extension, or a bare name
40
+ * on the declared client-tool list (remote POSIX hosts never carry these). */
41
+ function isClientNativeExecutable(exe, names) {
42
+ if (exe === "") return false;
43
+ if (/^[a-zA-Z]:[\\/]/.test(exe) || exe.includes("\\") || CLIENT_EXECUTABLE_RE.test(exe)) return true;
44
+ return names.includes(exe);
45
+ }
542
46
  /** Workspace-routing subprocess facade. */
543
47
  var SwitchSubprocessRuntime = class extends SubprocessRuntime {
544
48
  deps;
@@ -546,55 +50,94 @@ var SwitchSubprocessRuntime = class extends SubprocessRuntime {
546
50
  super(ctx);
547
51
  this.deps = deps;
548
52
  }
549
- /** The runtime for one spec (by its cwd). */
53
+ /** The runtime for one spec (by its cwd); undefined means LOCAL. */
550
54
  runtimeFor(cwd) {
551
55
  return this.deps.worldFor(cwd);
552
56
  }
57
+ /** Client binaries run on THIS machine even from a bound workspace — their
58
+ * executables cannot exist on the remote POSIX host, and the spawn cwd is
59
+ * the local anchor (which exists locally), so local execution is sound.
60
+ * Everything else runs in the session's world (remote on a bound host).
61
+ *
62
+ * ONE exception is refused rather than routed: a client-side SEARCH helper
63
+ * (rg) in a remote-bound session would search the empty local anchor and
64
+ * report "no matches" — a confidently wrong answer about the server's
65
+ * contents. There is no way to make a local index-of-files tool read the
66
+ * remote workspace, so the caller is told to use the remote tools.
67
+ *
68
+ * Locality comes from the routing answer (`undefined`), never from an
69
+ * identity comparison against `deps.local`: that comparison silently failed
70
+ * once and refused local ripgrep launches for every session. */
71
+ effectiveRuntime(spec) {
72
+ const runtime = this.runtimeFor(spec.cwd);
73
+ if (runtime === void 0) return this.deps.local;
74
+ const names = this.deps.clientToolNames ?? DEFAULT_CLIENT_TOOL_NAMES;
75
+ const exe = spec.argv !== void 0 && spec.argv.length > 0 ? spec.argv[0] : "";
76
+ if (isClientSearchHelperPath(exe)) throw new Error(`dsh-hardssh: '${exe.split(/[\\/]/).pop()}' is a client-side search tool and cannot read the remote workspace bound to this session (its working directory is the local anchor placeholder, not the server). Search remote content with the remote_search tool (mode="glob" for file names, mode="grep" for content) or ssh_exec instead.`);
77
+ return isClientNativeExecutable(exe, names) ? this.deps.local : runtime;
78
+ }
553
79
  /** @inheritdoc */
554
80
  resolveExecutable(command, env, signal) {
555
81
  return this.deps.local.resolveExecutable(command, env, signal);
556
82
  }
557
83
  /** @inheritdoc */
558
84
  spawn(spec) {
559
- return this.runtimeFor(spec.cwd).spawn(spec);
85
+ return this.effectiveRuntime(spec).spawn(spec);
560
86
  }
561
87
  /** @inheritdoc */
562
88
  spawnTerminal(spec) {
563
- return this.runtimeFor(spec.cwd).spawnTerminal(spec);
89
+ return this.effectiveRuntime(spec).spawnTerminal(spec);
564
90
  }
565
91
  };
566
92
  //#endregion
567
93
  //#region src/subprocess.ts
568
94
  /** Stable cordis plugin name. */
569
95
  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
- });
96
+ /** Workspace routing is exclusively provided by the generic core. */
97
+ const inject = ["workspaceCore"];
98
+ /** The record behind a router connection (sync snapshot lookup). */
99
+ function genericRecordFor(core, id) {
100
+ return core.ledger.snapshotSync().records.find((record) => record.id === id);
579
101
  }
580
- /** Mount the switching subprocess facade. */
102
+ /** Resolve the subprocess runtime owning a cwd, or undefined when local. */
103
+ function genericSubprocessFor(core, cwd, reservedAnchorRoots) {
104
+ if (!core.isReady()) {
105
+ if (cwd !== void 0 && reservedAnchorRoots.some((root) => isPathUnderAnchor(root, cwd))) throw new Error("subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)");
106
+ return;
107
+ }
108
+ const connection = core.router.fromAnchor(cwd);
109
+ if (connection === void 0) return void 0;
110
+ const record = genericRecordFor(core, connection.workspaceId);
111
+ if (record === void 0) throw new Error(`subprocess-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`);
112
+ const runtime = connection.get("workspace.process");
113
+ if (runtime === void 0) throw new Error(`subprocess-workspace: workspace '${record.id}' provides no workspace.process capability`);
114
+ return runtime;
115
+ }
116
+ /** Mount the generic switching subprocess facade. */
581
117
  function apply(ctx) {
582
- const core = ctx.hardsshCore;
583
118
  const localCtx = ctx.isolate("subprocess");
584
119
  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();
120
+ const ws = ctx.workspaceCore;
121
+ const anchorRootDir = anchorRoot();
587
122
  let warnedUnready = false;
588
123
  new SwitchSubprocessRuntime(ctx, {
589
124
  local: localSubprocess,
590
125
  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");
126
+ if (!ws.isReady()) {
127
+ if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) {
128
+ if (!warnedUnready) {
129
+ warnedUnready = true;
130
+ console.warn("[dsh-hardssh] subprocess routing is not ready yet (workspace core still initializing or failed) — refusing to run beneath the workspace anchor root");
131
+ }
132
+ throw new Error("subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)");
133
+ }
134
+ return;
594
135
  }
595
- return core.seams.runtimeForSub(cwd) ?? localSubprocess;
136
+ const runtime = genericSubprocessFor(ws, cwd, [anchorRootDir]);
137
+ if (runtime !== void 0) return runtime;
138
+ if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) throw new Error(`subprocess-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`);
596
139
  }
597
140
  });
598
141
  }
599
142
  //#endregion
600
- export { apply, inject, name };
143
+ export { apply, genericSubprocessFor, inject, name };