@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.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- 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 };
|