@deepseek-ai/dsh-ssh 0.1.6-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +104 -0
- package/README.zh.md +104 -0
- package/lib/helper.js +826 -0
- package/lib/index.js +359 -0
- package/lib/protocol-C43fD--H.js +276 -0
- package/lib/protocol.js +2 -0
- package/lib/schemas-DBIgsx0q.js +177 -0
- package/lib/schemas.js +2 -0
- package/lib/stream-security-DJlQsPG-.js +75 -0
- package/lib/types/helper-entry.d.ts +2 -0
- package/lib/types/helper-processes.d.ts +79 -0
- package/lib/types/helper.d.ts +18 -0
- package/lib/types/index.d.ts +93 -0
- package/lib/types/protocol.d.ts +50 -0
- package/lib/types/schemas.d.ts +203 -0
- package/lib/types/stream-security.d.ts +19 -0
- package/package.json +83 -0
package/lib/helper.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { a as SshRpcPeer, t as RemoteOperationError } from "./protocol-C43fD--H.js";
|
|
2
|
+
import { _ as remotePath, a as environmentSchema, b as targetSchema, d as outputSnapshotFrameLimit, g as processIdSchema, l as intentSchema, m as policySchema, r as editSchema, v as spawnSchema, x as textStreamIdSchema } from "./schemas-DBIgsx0q.js";
|
|
3
|
+
import { t as SSH_STREAM_TLS_OPTIONS } from "./stream-security-DJlQsPG-.js";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:tls";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
13
|
+
import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
|
|
14
|
+
import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
|
|
15
|
+
import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
|
|
16
|
+
import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
|
|
17
|
+
import { SandboxPolicyService } from "@deepseek-ai/dsh-sandbox-policy";
|
|
18
|
+
import { SessionProjectionRegistry } from "@deepseek-ai/dsh-session-projection";
|
|
19
|
+
import { finished, pipeline } from "node:stream/promises";
|
|
20
|
+
import { OutputCollector, prepareManagedProcessBinding } from "@deepseek-ai/dsh-subprocess-local/output";
|
|
21
|
+
//#region lib/types/helper-processes.js
|
|
22
|
+
/** Remote process ownership and separately forwarded byte streams. */
|
|
23
|
+
/** Join TLS, underlying socket, and listener closure before removing their directory. */
|
|
24
|
+
async function closeEndpoint(endpoint) {
|
|
25
|
+
const sockets = [...new Set([...endpoint.socket === void 0 ? [] : [endpoint.socket], ...endpoint.pending])];
|
|
26
|
+
const closed = sockets.map((socket) => new Promise((resolve) => {
|
|
27
|
+
if (socket.closed) resolve();
|
|
28
|
+
else socket.once("close", () => {
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
}));
|
|
32
|
+
const listenerClosed = new Promise((resolve) => {
|
|
33
|
+
endpoint.server.close(() => {
|
|
34
|
+
resolve();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
for (const socket of sockets) socket.destroy();
|
|
38
|
+
await Promise.all([listenerClosed, ...closed]);
|
|
39
|
+
}
|
|
40
|
+
/** Coalesce live tail updates while capture continues independently of network readers. */
|
|
41
|
+
var CollectedOutputForwarder = class {
|
|
42
|
+
collector;
|
|
43
|
+
peer;
|
|
44
|
+
dirty = false;
|
|
45
|
+
stopped = false;
|
|
46
|
+
running;
|
|
47
|
+
constructor(socket, collector, maxBytes) {
|
|
48
|
+
this.collector = collector;
|
|
49
|
+
this.peer = new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(maxBytes), 1);
|
|
50
|
+
this.peer.once("closed", () => {
|
|
51
|
+
this.stopped = true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
offer() {
|
|
55
|
+
if (this.stopped) return;
|
|
56
|
+
this.dirty = true;
|
|
57
|
+
if (this.running !== void 0) return;
|
|
58
|
+
this.running = this.flush();
|
|
59
|
+
}
|
|
60
|
+
async flush() {
|
|
61
|
+
try {
|
|
62
|
+
while (this.dirty && !this.stopped) {
|
|
63
|
+
this.dirty = false;
|
|
64
|
+
const snapshot = this.collector.snapshot();
|
|
65
|
+
await this.peer.request("snapshot", {
|
|
66
|
+
tail: snapshot.bytes.toString("base64"),
|
|
67
|
+
totalBytes: snapshot.totalBytes
|
|
68
|
+
}, z.null());
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
this.stopped = true;
|
|
72
|
+
this.peer.close();
|
|
73
|
+
} finally {
|
|
74
|
+
this.running = void 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async finish() {
|
|
78
|
+
this.offer();
|
|
79
|
+
while (this.running !== void 0) await this.running;
|
|
80
|
+
this.peer.close();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** Owns remote launch reservations through final process-range quiescence. */
|
|
84
|
+
var RemoteProcesses = class {
|
|
85
|
+
ctx;
|
|
86
|
+
root;
|
|
87
|
+
limit;
|
|
88
|
+
preparationMs;
|
|
89
|
+
records = /* @__PURE__ */ new Map();
|
|
90
|
+
completed = /* @__PURE__ */ new Map();
|
|
91
|
+
cleanups = /* @__PURE__ */ new Set();
|
|
92
|
+
closing = false;
|
|
93
|
+
constructor(ctx, root, limit, preparationMs) {
|
|
94
|
+
this.ctx = ctx;
|
|
95
|
+
this.root = root;
|
|
96
|
+
this.limit = limit;
|
|
97
|
+
this.preparationMs = preparationMs;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Allocate private stream listeners; no target executes until start().
|
|
101
|
+
* @param raw - untrusted process request received over SSH.
|
|
102
|
+
* @returns the reservation id and authenticated stream coordinates.
|
|
103
|
+
*/
|
|
104
|
+
async prepare(raw) {
|
|
105
|
+
if (this.closing || this.records.size >= this.limit) throw new Error("SSH process capacity unavailable");
|
|
106
|
+
const request = spawnSchema.parse(raw);
|
|
107
|
+
const id = randomUUID();
|
|
108
|
+
const directory = join(this.root, id);
|
|
109
|
+
const record = {
|
|
110
|
+
request,
|
|
111
|
+
directory,
|
|
112
|
+
endpoints: {},
|
|
113
|
+
controller: new AbortController(),
|
|
114
|
+
expiry: setTimeout(() => {
|
|
115
|
+
this.release(id).catch(() => {});
|
|
116
|
+
}, this.preparationMs)
|
|
117
|
+
};
|
|
118
|
+
this.records.set(id, record);
|
|
119
|
+
try {
|
|
120
|
+
record.preparing = (async () => {
|
|
121
|
+
await mkdir(directory, { mode: 448 });
|
|
122
|
+
record.controller.signal.throwIfAborted();
|
|
123
|
+
const names = request.terminal === void 0 ? [
|
|
124
|
+
"stdout",
|
|
125
|
+
"stderr",
|
|
126
|
+
...request.stdio?.stdin === "pipe" ? ["stdin"] : [],
|
|
127
|
+
...request.stdio?.control === "pipe" ? ["control"] : []
|
|
128
|
+
] : ["terminal"];
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
record.endpoints[name] = await this.endpoint(join(directory, name));
|
|
131
|
+
record.controller.signal.throwIfAborted();
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
await record.preparing;
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
streams: Object.fromEntries(Object.entries(record.endpoints).map(([name, endpoint]) => [name, {
|
|
138
|
+
path: endpoint.path,
|
|
139
|
+
capability: endpoint.capability
|
|
140
|
+
}]))
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await this.release(id);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Start once all data channels are authenticated; duplicate starts refuse.
|
|
149
|
+
* @param id - the prepared process reservation.
|
|
150
|
+
* @param signal - cancellation of pending process publication.
|
|
151
|
+
* @returns the terminal pid when the request owns a PTY.
|
|
152
|
+
*/
|
|
153
|
+
async start(id, signal) {
|
|
154
|
+
const record = this.record(id);
|
|
155
|
+
if (record.start !== void 0) throw new Error("SSH process launch was already requested");
|
|
156
|
+
signal?.throwIfAborted();
|
|
157
|
+
const abort = () => {
|
|
158
|
+
record.controller.abort(signal?.reason);
|
|
159
|
+
};
|
|
160
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
161
|
+
record.start = this.startOnce(id, record);
|
|
162
|
+
try {
|
|
163
|
+
await record.start;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const failed = Promise.reject(error);
|
|
166
|
+
failed.catch(() => {});
|
|
167
|
+
record.done = failed;
|
|
168
|
+
await this.finishFailed(id, record, failed);
|
|
169
|
+
throw error;
|
|
170
|
+
} finally {
|
|
171
|
+
signal?.removeEventListener("abort", abort);
|
|
172
|
+
}
|
|
173
|
+
return record.terminal === void 0 ? {} : { pid: record.terminal.pid };
|
|
174
|
+
}
|
|
175
|
+
async startOnce(id, record) {
|
|
176
|
+
await Promise.all(Object.values(record.endpoints).map((endpoint) => endpoint.connected));
|
|
177
|
+
clearTimeout(record.expiry);
|
|
178
|
+
if (this.closing) throw new Error("SSH helper is closing");
|
|
179
|
+
record.controller.signal.throwIfAborted();
|
|
180
|
+
const request = record.request;
|
|
181
|
+
const cwd = this.ctx.fs.processPath(await this.ctx.fs.resolve(request.cwd, { signal: record.controller.signal }));
|
|
182
|
+
record.controller.signal.throwIfAborted();
|
|
183
|
+
const env = request.env === void 0 ? {} : Object.fromEntries(Object.entries(request.env).map(([key, value]) => [key, value ?? void 0]));
|
|
184
|
+
if (request.terminal !== void 0) {
|
|
185
|
+
const terminal = await this.ctx.subprocess.spawnTerminal({
|
|
186
|
+
argv: request.argv,
|
|
187
|
+
cwd,
|
|
188
|
+
env: Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0)),
|
|
189
|
+
graceMs: request.graceMs,
|
|
190
|
+
...request.terminal,
|
|
191
|
+
signal: record.controller.signal
|
|
192
|
+
});
|
|
193
|
+
record.terminal = terminal;
|
|
194
|
+
record.controller.signal.throwIfAborted();
|
|
195
|
+
const socket = await record.endpoints.terminal.connected;
|
|
196
|
+
const output = pipeline(terminal.output, socket).catch(() => {});
|
|
197
|
+
const done = terminal.done.then((outcome) => ({
|
|
198
|
+
outcome,
|
|
199
|
+
spills: {},
|
|
200
|
+
collected: {}
|
|
201
|
+
}));
|
|
202
|
+
record.done = done;
|
|
203
|
+
done.then(async () => {
|
|
204
|
+
await terminal.terminate();
|
|
205
|
+
await output;
|
|
206
|
+
await this.rememberCompleted(id, record, done);
|
|
207
|
+
}, () => this.finishFailed(id, record, done)).catch(() => {});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const stdio = request.stdio;
|
|
211
|
+
const spec = {
|
|
212
|
+
argv: request.argv,
|
|
213
|
+
cwd,
|
|
214
|
+
env,
|
|
215
|
+
graceMs: request.graceMs,
|
|
216
|
+
signal: record.controller.signal,
|
|
217
|
+
stdio: {
|
|
218
|
+
stdin: stdio.stdin,
|
|
219
|
+
stdout: "pipe",
|
|
220
|
+
stderr: "pipe",
|
|
221
|
+
...stdio.control === void 0 ? {} : { control: stdio.control }
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const ordinary = this.ctx.subprocess.spawn(spec);
|
|
225
|
+
record.ordinary = ordinary;
|
|
226
|
+
const control = ordinary.control;
|
|
227
|
+
if (record.endpoints.control !== void 0 && control === void 0) {
|
|
228
|
+
ordinary.done.catch(() => {});
|
|
229
|
+
throw new Error("Remote subprocess provider did not establish fd 7");
|
|
230
|
+
}
|
|
231
|
+
const collectors = {};
|
|
232
|
+
const stopCapture = [];
|
|
233
|
+
const forwarders = [];
|
|
234
|
+
const forwarded = [];
|
|
235
|
+
const streams = [];
|
|
236
|
+
for (const name of ["stdout", "stderr"]) {
|
|
237
|
+
const stream = ordinary[name];
|
|
238
|
+
const mode = stdio[name];
|
|
239
|
+
const socket = await record.endpoints[name].connected;
|
|
240
|
+
if (typeof mode === "object") {
|
|
241
|
+
const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, name, prepareManagedProcessBinding().spillDir);
|
|
242
|
+
collectors[name] = collector;
|
|
243
|
+
const forwarder = new CollectedOutputForwarder(socket, collector, mode.maxBytes);
|
|
244
|
+
forwarders.push(forwarder);
|
|
245
|
+
const receive = (chunk) => {
|
|
246
|
+
collector.push(chunk);
|
|
247
|
+
forwarder.offer();
|
|
248
|
+
};
|
|
249
|
+
stream.on("data", receive);
|
|
250
|
+
stopCapture.push(() => {
|
|
251
|
+
stream.off("data", receive);
|
|
252
|
+
stream.destroy();
|
|
253
|
+
collector.seal();
|
|
254
|
+
});
|
|
255
|
+
} else streams.push(pipeline(stream, socket).catch(() => {}));
|
|
256
|
+
}
|
|
257
|
+
if (record.endpoints.stdin !== void 0) {
|
|
258
|
+
const socket = await record.endpoints.stdin.connected;
|
|
259
|
+
socket.end();
|
|
260
|
+
pipeline(socket, ordinary.stdin).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
if (record.endpoints.control !== void 0) {
|
|
263
|
+
const channel = control;
|
|
264
|
+
const socket = await record.endpoints.control.connected;
|
|
265
|
+
socket.pipe(channel).pipe(socket);
|
|
266
|
+
socket.on("error", () => {
|
|
267
|
+
channel.destroy();
|
|
268
|
+
});
|
|
269
|
+
channel.on("error", () => {
|
|
270
|
+
socket.destroy();
|
|
271
|
+
});
|
|
272
|
+
streams.push(finished(socket, {
|
|
273
|
+
readable: false,
|
|
274
|
+
cleanup: true
|
|
275
|
+
}).catch(() => {}));
|
|
276
|
+
}
|
|
277
|
+
const done = ordinary.done.finally(() => {
|
|
278
|
+
for (const stop of stopCapture) stop();
|
|
279
|
+
}).then(async (outcome) => {
|
|
280
|
+
for (const forwarder of forwarders) forwarded.push(forwarder.finish());
|
|
281
|
+
await Promise.race([Promise.all(streams), new Promise((resolve) => {
|
|
282
|
+
setTimeout(resolve, request.graceMs).unref();
|
|
283
|
+
})]);
|
|
284
|
+
const spills = {};
|
|
285
|
+
const collected = {};
|
|
286
|
+
for (const name of ["stdout", "stderr"]) {
|
|
287
|
+
const collector = collectors[name];
|
|
288
|
+
if (collector === void 0) continue;
|
|
289
|
+
const snapshot = collector.snapshot();
|
|
290
|
+
collected[name] = {
|
|
291
|
+
tail: snapshot.bytes.toString("base64"),
|
|
292
|
+
totalBytes: snapshot.totalBytes
|
|
293
|
+
};
|
|
294
|
+
const path = collector.readFrom(0).spillPath;
|
|
295
|
+
if (path !== void 0) spills[name] = path;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
outcome,
|
|
299
|
+
spills,
|
|
300
|
+
collected
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
record.done = done;
|
|
304
|
+
done.then(async () => {
|
|
305
|
+
await ordinary.waitForExit();
|
|
306
|
+
await Promise.all([...streams, ...forwarded]);
|
|
307
|
+
await this.rememberCompleted(id, record, done);
|
|
308
|
+
}, () => this.finishFailed(id, record, done)).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Await the direct result without claiming all descendants have exited.
|
|
312
|
+
* @param id - the started process reservation.
|
|
313
|
+
* @returns the exit observation and remote spill paths.
|
|
314
|
+
* @throws the original startup or process failure while its completion is retained.
|
|
315
|
+
*/
|
|
316
|
+
async done(id) {
|
|
317
|
+
if (this.completed.has(id)) return this.completed.get(id);
|
|
318
|
+
const record = this.record(id);
|
|
319
|
+
if (record.start === void 0) throw new Error("SSH process has not started");
|
|
320
|
+
await record.start;
|
|
321
|
+
return record.done;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Observe the native managed range used for termination.
|
|
325
|
+
* @param id - the started process reservation.
|
|
326
|
+
* @param signal - cancellation of this observation, leaving ownership intact.
|
|
327
|
+
* @returns whether the owned process range is empty.
|
|
328
|
+
*/
|
|
329
|
+
async wait(id, signal) {
|
|
330
|
+
if (this.completed.has(id)) return true;
|
|
331
|
+
const record = this.record(id);
|
|
332
|
+
await record.start;
|
|
333
|
+
if (record.ordinary !== void 0) return record.ordinary.waitForExit(signal);
|
|
334
|
+
if (record.terminal !== void 0) {
|
|
335
|
+
await record.terminal.terminate();
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
throw new Error("SSH process was not started");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Terminate and await the managed range independently of output readers.
|
|
342
|
+
* @param id - the process reservation to stop.
|
|
343
|
+
*/
|
|
344
|
+
async terminate(id) {
|
|
345
|
+
if (this.completed.has(id)) return;
|
|
346
|
+
const record = this.record(id);
|
|
347
|
+
record.controller.abort(/* @__PURE__ */ new Error("SSH process termination requested"));
|
|
348
|
+
record.ordinary?.terminate();
|
|
349
|
+
if (record.ordinary !== void 0) await record.ordinary.waitForExit();
|
|
350
|
+
if (record.terminal !== void 0) await record.terminal.terminate();
|
|
351
|
+
if (record.ordinary === void 0 && record.terminal === void 0) await this.release(id);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Operate on the terminal owned by the request id.
|
|
355
|
+
* @param id - the terminal reservation.
|
|
356
|
+
* @param operation - terminal input, foreground observation, or signal delivery.
|
|
357
|
+
* @param value - input bytes as text or the signal name.
|
|
358
|
+
* @returns the operation's wire result.
|
|
359
|
+
*/
|
|
360
|
+
async terminal(id, operation, value) {
|
|
361
|
+
const terminal = this.record(id).terminal;
|
|
362
|
+
if (terminal === void 0) throw new Error("SSH handle does not own a terminal");
|
|
363
|
+
if (operation === "write") {
|
|
364
|
+
await terminal.write(z.string().parse(value));
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
if (operation === "inspect") return await terminal.inspectForeground() ?? null;
|
|
368
|
+
return terminal.signalForeground(z.enum([
|
|
369
|
+
"SIGINT",
|
|
370
|
+
"SIGTERM",
|
|
371
|
+
"SIGKILL",
|
|
372
|
+
"SIGTSTP",
|
|
373
|
+
"SIGHUP"
|
|
374
|
+
]).parse(value));
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Resize an allocated terminal without replacing its process.
|
|
378
|
+
* @param id - terminal reservation.
|
|
379
|
+
* @param cols - positive terminal width.
|
|
380
|
+
* @param rows - positive terminal height.
|
|
381
|
+
* @returns after the local provider accepts the dimensions.
|
|
382
|
+
*/
|
|
383
|
+
async resizeTerminal(id, cols, rows) {
|
|
384
|
+
const terminal = this.record(id).terminal;
|
|
385
|
+
if (terminal === void 0) throw new Error("SSH handle does not own a terminal");
|
|
386
|
+
await terminal.resize(cols, rows);
|
|
387
|
+
}
|
|
388
|
+
/** Stop every owned process on lease expiry or disconnect. */
|
|
389
|
+
async close() {
|
|
390
|
+
if (this.closing) return;
|
|
391
|
+
this.closing = true;
|
|
392
|
+
const releases = [...this.records.keys()].map((id) => this.release(id));
|
|
393
|
+
const outcomes = await Promise.allSettled([...releases, ...this.cleanups]);
|
|
394
|
+
const errors = [...new Set(outcomes.flatMap((result) => result.status === "rejected" ? [result.reason] : []))];
|
|
395
|
+
if (errors.length > 0) throw new AggregateError(errors, "SSH remote process cleanup failed");
|
|
396
|
+
}
|
|
397
|
+
async release(id) {
|
|
398
|
+
const record = this.record(id);
|
|
399
|
+
record.release ??= this.trackCleanup(async () => {
|
|
400
|
+
clearTimeout(record.expiry);
|
|
401
|
+
record.controller.abort(/* @__PURE__ */ new Error("SSH process reservation closed"));
|
|
402
|
+
await record.preparing?.catch(() => {});
|
|
403
|
+
await Promise.all(Object.values(record.endpoints).map(closeEndpoint));
|
|
404
|
+
await record.start?.catch(() => {});
|
|
405
|
+
record.ordinary?.terminate();
|
|
406
|
+
if (record.ordinary !== void 0) await record.ordinary.waitForExit();
|
|
407
|
+
if (record.terminal !== void 0) await record.terminal.terminate();
|
|
408
|
+
this.records.delete(id);
|
|
409
|
+
await rm(record.directory, {
|
|
410
|
+
recursive: true,
|
|
411
|
+
force: true
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
await record.release;
|
|
415
|
+
}
|
|
416
|
+
record(id) {
|
|
417
|
+
const record = this.records.get(id);
|
|
418
|
+
if (record === void 0) throw new Error("Unknown or expired SSH process handle");
|
|
419
|
+
return record;
|
|
420
|
+
}
|
|
421
|
+
async finishFailed(id, record, result) {
|
|
422
|
+
clearTimeout(record.expiry);
|
|
423
|
+
record.ordinary?.terminate();
|
|
424
|
+
if (record.ordinary !== void 0) await record.ordinary.waitForExit();
|
|
425
|
+
if (record.terminal !== void 0) await record.terminal.terminate();
|
|
426
|
+
await this.rememberCompleted(id, record, result);
|
|
427
|
+
}
|
|
428
|
+
async rememberCompleted(id, record, result) {
|
|
429
|
+
if (this.records.get(id) !== record || record.release !== void 0) return;
|
|
430
|
+
const cleanup = this.trackCleanup(async () => {
|
|
431
|
+
await Promise.all(Object.values(record.endpoints).map(closeEndpoint));
|
|
432
|
+
await rm(record.directory, {
|
|
433
|
+
recursive: true,
|
|
434
|
+
force: true
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
this.records.delete(id);
|
|
438
|
+
this.completed.set(id, result);
|
|
439
|
+
if (this.completed.size > this.limit * 4) this.completed.delete(this.completed.keys().next().value);
|
|
440
|
+
await cleanup;
|
|
441
|
+
}
|
|
442
|
+
trackCleanup(work) {
|
|
443
|
+
const pending = Promise.resolve().then(work);
|
|
444
|
+
this.cleanups.add(pending);
|
|
445
|
+
return pending.finally(() => {
|
|
446
|
+
this.cleanups.delete(pending);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
async endpoint(path) {
|
|
450
|
+
const connected = Promise.withResolvers();
|
|
451
|
+
const capability = randomBytes(32);
|
|
452
|
+
const server = createServer({
|
|
453
|
+
...SSH_STREAM_TLS_OPTIONS,
|
|
454
|
+
allowHalfOpen: true,
|
|
455
|
+
handshakeTimeout: this.preparationMs,
|
|
456
|
+
pskCallback: (_socket, identity) => identity === "dsh-stream" ? capability : null
|
|
457
|
+
});
|
|
458
|
+
const endpoint = {
|
|
459
|
+
path,
|
|
460
|
+
capability: capability.toString("hex"),
|
|
461
|
+
server,
|
|
462
|
+
connected: connected.promise,
|
|
463
|
+
pending: /* @__PURE__ */ new Set()
|
|
464
|
+
};
|
|
465
|
+
server.maxConnections = 8;
|
|
466
|
+
connected.promise.catch(() => {});
|
|
467
|
+
server.on("connection", (socket) => {
|
|
468
|
+
endpoint.pending.add(socket);
|
|
469
|
+
socket.once("close", () => {
|
|
470
|
+
endpoint.pending.delete(socket);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
server.on("tlsClientError", (_error, socket) => {
|
|
474
|
+
socket.destroy();
|
|
475
|
+
});
|
|
476
|
+
server.on("secureConnection", (socket) => {
|
|
477
|
+
if (endpoint.socket !== void 0) {
|
|
478
|
+
socket.destroy();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
socket.disableRenegotiation();
|
|
482
|
+
socket.pause();
|
|
483
|
+
socket.on("error", () => {});
|
|
484
|
+
endpoint.socket = socket;
|
|
485
|
+
server.close();
|
|
486
|
+
connected.resolve(socket);
|
|
487
|
+
});
|
|
488
|
+
server.on("error", (error) => {
|
|
489
|
+
connected.reject(error);
|
|
490
|
+
});
|
|
491
|
+
server.on("close", () => {
|
|
492
|
+
if (endpoint.socket === void 0) connected.reject(/* @__PURE__ */ new Error("SSH stream reservation closed"));
|
|
493
|
+
});
|
|
494
|
+
try {
|
|
495
|
+
await new Promise((resolve, reject) => {
|
|
496
|
+
server.once("error", reject);
|
|
497
|
+
server.listen(path, resolve);
|
|
498
|
+
});
|
|
499
|
+
await chmod(path, 384);
|
|
500
|
+
return endpoint;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
await closeEndpoint(endpoint);
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region lib/types/helper.js
|
|
509
|
+
/** Private POSIX SSH helper; filesystem and process effects use the installed local providers. */
|
|
510
|
+
const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
511
|
+
const MAX_TEXT_BYTES = 8 * 1024 * 1024;
|
|
512
|
+
const object = z.object({}).strict();
|
|
513
|
+
const processIdRequest = z.object({ id: processIdSchema }).strict();
|
|
514
|
+
const textStreamIdRequest = z.object({ id: textStreamIdSchema }).strict();
|
|
515
|
+
async function services() {
|
|
516
|
+
const ctx = new Context();
|
|
517
|
+
const fibers = [await ctx.plugin(SessionProjectionRegistry)];
|
|
518
|
+
fibers.push(await ctx.plugin(SandboxPolicyService, {
|
|
519
|
+
mode: "read-only",
|
|
520
|
+
workspaceRoot: process.cwd()
|
|
521
|
+
}));
|
|
522
|
+
fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: process.cwd() }));
|
|
523
|
+
fibers.push(await ctx.plugin(LocalSubprocessRuntime));
|
|
524
|
+
fibers.push(await ctx.plugin(LocalSandboxProvider));
|
|
525
|
+
return {
|
|
526
|
+
ctx,
|
|
527
|
+
close: async () => {
|
|
528
|
+
for (const fiber of fibers.reverse()) await fiber.dispose();
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Run a helper until its channel closes or its client lease expires.
|
|
534
|
+
* @param transport - private process streams, entry identity, and cancellation.
|
|
535
|
+
*/
|
|
536
|
+
async function runSshHelper(transport) {
|
|
537
|
+
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("SSH helper requires a POSIX host");
|
|
538
|
+
transport.signal.throwIfAborted();
|
|
539
|
+
const runtime = await services();
|
|
540
|
+
const { ctx } = runtime;
|
|
541
|
+
const root = await mkdtemp("/tmp/dsh-ssh-");
|
|
542
|
+
const processes = new RemoteProcesses(ctx, root, 128, 3e4);
|
|
543
|
+
const lifetime = new AbortController();
|
|
544
|
+
const iterators = /* @__PURE__ */ new Map();
|
|
545
|
+
let lease;
|
|
546
|
+
let leaseMs = 3e4;
|
|
547
|
+
let initialized = false;
|
|
548
|
+
let workspace = process.cwd();
|
|
549
|
+
let cleanup;
|
|
550
|
+
const close = () => {
|
|
551
|
+
cleanup ??= (async () => {
|
|
552
|
+
if (lease !== void 0) clearTimeout(lease);
|
|
553
|
+
lifetime.abort(/* @__PURE__ */ new Error("SSH helper is closing"));
|
|
554
|
+
for (const record of iterators.values()) record.controller.abort(lifetime.signal.reason);
|
|
555
|
+
await Promise.allSettled([...iterators.values()].map(async (record) => record.iterator.return?.()));
|
|
556
|
+
iterators.clear();
|
|
557
|
+
try {
|
|
558
|
+
await processes.close();
|
|
559
|
+
} finally {
|
|
560
|
+
try {
|
|
561
|
+
await runtime.close();
|
|
562
|
+
} finally {
|
|
563
|
+
await rm(root, {
|
|
564
|
+
recursive: true,
|
|
565
|
+
force: true
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
})();
|
|
570
|
+
return cleanup;
|
|
571
|
+
};
|
|
572
|
+
const touchLease = () => {
|
|
573
|
+
if (lease !== void 0) clearTimeout(lease);
|
|
574
|
+
lease = setTimeout(() => {
|
|
575
|
+
peer.close(/* @__PURE__ */ new Error("SSH helper client lease expired"));
|
|
576
|
+
}, leaseMs);
|
|
577
|
+
};
|
|
578
|
+
const policy = async (raw, signal) => {
|
|
579
|
+
const parsed = policySchema.parse(raw);
|
|
580
|
+
const target = await ctx.fs.resolve(parsed.workspaceRoot, { signal });
|
|
581
|
+
return {
|
|
582
|
+
...parsed,
|
|
583
|
+
workspaceRoot: ctx.fs.processPath(target)
|
|
584
|
+
};
|
|
585
|
+
};
|
|
586
|
+
const asTarget = (raw) => targetSchema.parse(raw);
|
|
587
|
+
const peer = new SshRpcPeer(transport.input, transport.output, MAX_FRAME_BYTES, 128, async (method, raw, requestSignal) => {
|
|
588
|
+
const signal = AbortSignal.any([requestSignal, lifetime.signal]);
|
|
589
|
+
if (method === "hello") {
|
|
590
|
+
if (initialized) throw new Error("SSH helper handshake already completed");
|
|
591
|
+
const input = z.object({
|
|
592
|
+
protocol: z.literal(1),
|
|
593
|
+
workspace: remotePath,
|
|
594
|
+
leaseMs: z.number().int().min(3e3).max(6e5),
|
|
595
|
+
bootstrapPath: remotePath.optional()
|
|
596
|
+
}).strict().parse(raw);
|
|
597
|
+
workspace = ctx.fs.processPath(await ctx.fs.resolve(input.workspace, { signal }));
|
|
598
|
+
leaseMs = input.leaseMs;
|
|
599
|
+
initialized = true;
|
|
600
|
+
touchLease();
|
|
601
|
+
return {
|
|
602
|
+
protocol: 1,
|
|
603
|
+
hash: createHash("sha256").update(readFileSync(transport.entryPath)).digest("hex"),
|
|
604
|
+
platform: process.platform,
|
|
605
|
+
nodeVersion: process.version,
|
|
606
|
+
node: process.execPath,
|
|
607
|
+
root,
|
|
608
|
+
workspace,
|
|
609
|
+
...input.bootstrapPath === void 0 ? {} : { bootstrapHash: createHash("sha256").update(readFileSync(input.bootstrapPath)).digest("hex") }
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (!initialized || cleanup !== void 0) throw new Error("SSH helper is not accepting operations");
|
|
613
|
+
if (method === "heartbeat") {
|
|
614
|
+
object.parse(raw);
|
|
615
|
+
touchLease();
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (method === "close") {
|
|
619
|
+
object.parse(raw);
|
|
620
|
+
await close();
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
if (method === "process.prepare") return processes.prepare(raw);
|
|
624
|
+
if (method === "process.start") return processes.start(processIdRequest.parse(raw).id, signal);
|
|
625
|
+
if (method === "process.done") return processes.done(processIdRequest.parse(raw).id);
|
|
626
|
+
if (method === "process.wait") return processes.wait(processIdRequest.parse(raw).id, signal);
|
|
627
|
+
if (method === "process.terminate") {
|
|
628
|
+
await processes.terminate(processIdRequest.parse(raw).id);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (method === "terminal.environment") {
|
|
632
|
+
object.parse(raw);
|
|
633
|
+
return ctx.subprocess.terminalEnvironment(signal);
|
|
634
|
+
}
|
|
635
|
+
if (method === "terminal.resize") {
|
|
636
|
+
const input = z.object({
|
|
637
|
+
id: processIdSchema,
|
|
638
|
+
cols: z.number().int().positive(),
|
|
639
|
+
rows: z.number().int().positive()
|
|
640
|
+
}).strict().parse(raw);
|
|
641
|
+
await processes.resizeTerminal(input.id, input.cols, input.rows);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (method === "terminal.write" || method === "terminal.inspect" || method === "terminal.signal") {
|
|
645
|
+
const input = z.object({
|
|
646
|
+
id: processIdSchema,
|
|
647
|
+
value: z.string().optional()
|
|
648
|
+
}).strict().parse(raw);
|
|
649
|
+
return processes.terminal(input.id, method === "terminal.write" ? "write" : method === "terminal.inspect" ? "inspect" : "signal", input.value);
|
|
650
|
+
}
|
|
651
|
+
if (method === "executable") {
|
|
652
|
+
const input = z.object({
|
|
653
|
+
command: z.string(),
|
|
654
|
+
env: environmentSchema.optional()
|
|
655
|
+
}).strict().parse(raw);
|
|
656
|
+
const env = input.env === void 0 ? void 0 : Object.fromEntries(Object.entries(input.env).filter((entry) => entry[1] !== null));
|
|
657
|
+
try {
|
|
658
|
+
return await ctx.subprocess.resolveExecutable(input.command, env, signal);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (error instanceof SubprocessExecutableNotFoundError) throw new RemoteOperationError(error.message, "SUBPROCESS_EXECUTABLE_NOT_FOUND");
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (method === "sandbox") {
|
|
665
|
+
const input = z.object({
|
|
666
|
+
argv: z.array(z.string()).min(1),
|
|
667
|
+
policy: policySchema
|
|
668
|
+
}).strict().parse(raw);
|
|
669
|
+
const resolved = await policy(input.policy, signal);
|
|
670
|
+
if (resolved.mode === "danger-full-access") throw new Error("Unconfined argv does not need a sandbox wrapper");
|
|
671
|
+
return ctx.sandbox.confine(input.argv, resolved, signal);
|
|
672
|
+
}
|
|
673
|
+
if (method === "fs.resolve" || method === "fs.lstat") {
|
|
674
|
+
const input = z.object({
|
|
675
|
+
path: z.string(),
|
|
676
|
+
cwd: remotePath.optional()
|
|
677
|
+
}).strict().parse(raw);
|
|
678
|
+
return method === "fs.resolve" ? ctx.fs.resolve(input.path, {
|
|
679
|
+
cwd: input.cwd ?? workspace,
|
|
680
|
+
signal
|
|
681
|
+
}) : await ctx.fs.lstat(input.path, { cwd: input.cwd ?? workspace }, signal) ?? null;
|
|
682
|
+
}
|
|
683
|
+
if (method === "fs.stat" || method === "fs.list" || method === "fs.readText" || method === "fs.stream") {
|
|
684
|
+
const target = asTarget(z.object({ target: targetSchema }).strict().parse(raw).target);
|
|
685
|
+
if (method === "fs.stat") return await ctx.fs.stat(target, signal) ?? null;
|
|
686
|
+
if (method === "fs.list") return ctx.fs.listDir(target, signal);
|
|
687
|
+
if (method === "fs.stream") {
|
|
688
|
+
if (iterators.size >= 128) throw new Error("SSH text stream limit reached");
|
|
689
|
+
const controller = new AbortController();
|
|
690
|
+
const iterator = (await ctx.fs.streamText(target, AbortSignal.any([signal, controller.signal])))[Symbol.asyncIterator]();
|
|
691
|
+
try {
|
|
692
|
+
signal.throwIfAborted();
|
|
693
|
+
if (iterators.size >= 128) throw new Error("SSH text stream limit reached");
|
|
694
|
+
const id = randomUUID();
|
|
695
|
+
iterators.set(id, {
|
|
696
|
+
iterator,
|
|
697
|
+
controller
|
|
698
|
+
});
|
|
699
|
+
return id;
|
|
700
|
+
} catch (error) {
|
|
701
|
+
controller.abort(error);
|
|
702
|
+
await iterator.return?.();
|
|
703
|
+
throw error;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const stream = await ctx.fs.streamText(target, signal);
|
|
707
|
+
let text = "";
|
|
708
|
+
let bytes = 0;
|
|
709
|
+
for await (const chunk of stream) {
|
|
710
|
+
bytes += Buffer.byteLength(chunk);
|
|
711
|
+
if (bytes > MAX_TEXT_BYTES) throw new FsError("SSH whole-text transfer exceeds its bounded frame budget; use streaming", "FS_TOO_LARGE");
|
|
712
|
+
text += chunk;
|
|
713
|
+
}
|
|
714
|
+
return text;
|
|
715
|
+
}
|
|
716
|
+
if (method === "fs.next" || method === "fs.streamClose") {
|
|
717
|
+
const { id } = textStreamIdRequest.parse(raw);
|
|
718
|
+
const record = iterators.get(id);
|
|
719
|
+
if (record === void 0) throw new Error("Unknown SSH text stream");
|
|
720
|
+
if (method === "fs.streamClose") {
|
|
721
|
+
iterators.delete(id);
|
|
722
|
+
record.controller.abort(/* @__PURE__ */ new Error("SSH text stream closed"));
|
|
723
|
+
await record.iterator.return?.();
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
const abort = () => {
|
|
727
|
+
record.controller.abort(signal.reason);
|
|
728
|
+
};
|
|
729
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
730
|
+
try {
|
|
731
|
+
signal.throwIfAborted();
|
|
732
|
+
const next = await record.iterator.next();
|
|
733
|
+
if (next.done) iterators.delete(id);
|
|
734
|
+
return {
|
|
735
|
+
done: next.done ?? false,
|
|
736
|
+
value: next.done ? "" : next.value
|
|
737
|
+
};
|
|
738
|
+
} catch (error) {
|
|
739
|
+
iterators.delete(id);
|
|
740
|
+
record.controller.abort(error);
|
|
741
|
+
await record.iterator.return?.();
|
|
742
|
+
throw error;
|
|
743
|
+
} finally {
|
|
744
|
+
signal.removeEventListener("abort", abort);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (method === "fs.readBytes" || method === "fs.readRange") {
|
|
748
|
+
const input = z.object({
|
|
749
|
+
target: targetSchema,
|
|
750
|
+
maxBytes: z.number().int().nonnegative().optional(),
|
|
751
|
+
offset: z.number().int().nonnegative().optional(),
|
|
752
|
+
length: z.number().int().nonnegative().optional()
|
|
753
|
+
}).strict().parse(raw);
|
|
754
|
+
const target = asTarget(input.target);
|
|
755
|
+
const bytes = method === "fs.readBytes" ? await ctx.fs.readBytes(target, signal, Math.min(z.number().int().nonnegative().parse(input.maxBytes), MAX_TEXT_BYTES)) : await ctx.fs.readByteRange(target, {
|
|
756
|
+
offset: z.number().int().nonnegative().parse(input.offset),
|
|
757
|
+
length: z.number().int().nonnegative().max(MAX_TEXT_BYTES).parse(input.length)
|
|
758
|
+
}, signal);
|
|
759
|
+
return Buffer.from(bytes).toString("base64");
|
|
760
|
+
}
|
|
761
|
+
if (method === "fs.write" || method === "fs.edit") {
|
|
762
|
+
const input = z.object({
|
|
763
|
+
target: targetSchema,
|
|
764
|
+
content: z.string().optional(),
|
|
765
|
+
edit: editSchema.optional(),
|
|
766
|
+
expected: z.union([intentSchema, z.object({ version: z.string() }).strict()]).optional(),
|
|
767
|
+
policy: policySchema
|
|
768
|
+
}).strict().parse(raw);
|
|
769
|
+
const target = asTarget(input.target);
|
|
770
|
+
const resolved = await policy(input.policy, signal);
|
|
771
|
+
if (method === "fs.write") return ctx.fs.writeText(target, z.string().parse(input.content), input.expected === void 0 ? void 0 : intentSchema.parse(input.expected), signal, resolved);
|
|
772
|
+
return ctx.fs.editText(target, editSchema.parse(input.edit), input.expected === void 0 ? void 0 : z.object({ version: z.string() }).strict().parse(input.expected), signal, resolved);
|
|
773
|
+
}
|
|
774
|
+
throw new Error(`Unknown SSH helper operation: ${method}`);
|
|
775
|
+
});
|
|
776
|
+
const closed = Promise.withResolvers();
|
|
777
|
+
peer.once("closed", () => {
|
|
778
|
+
close().catch(() => {});
|
|
779
|
+
closed.resolve(void 0);
|
|
780
|
+
});
|
|
781
|
+
const onAbort = () => {
|
|
782
|
+
peer.close(/* @__PURE__ */ new Error("SSH helper transport was terminated"));
|
|
783
|
+
};
|
|
784
|
+
transport.signal.addEventListener("abort", onAbort, { once: true });
|
|
785
|
+
if (transport.signal.aborted) onAbort();
|
|
786
|
+
else touchLease();
|
|
787
|
+
try {
|
|
788
|
+
await closed.promise;
|
|
789
|
+
await close();
|
|
790
|
+
} finally {
|
|
791
|
+
transport.signal.removeEventListener("abort", onAbort);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
//#endregion
|
|
795
|
+
//#region lib/types/helper-entry.js
|
|
796
|
+
/** Private OpenSSH process entry; the helper module owns request and cleanup behavior. */
|
|
797
|
+
/* v8 ignore file -- launched through plain Node in SSH acceptance; helper behavior is exercised through its explicit streams. */
|
|
798
|
+
const controller = new AbortController();
|
|
799
|
+
const stop = () => {
|
|
800
|
+
controller.abort(/* @__PURE__ */ new Error("SSH helper process terminated"));
|
|
801
|
+
};
|
|
802
|
+
for (const signal of [
|
|
803
|
+
"SIGTERM",
|
|
804
|
+
"SIGHUP",
|
|
805
|
+
"SIGINT"
|
|
806
|
+
]) process.once(signal, stop);
|
|
807
|
+
try {
|
|
808
|
+
if (process.argv.length !== 2) throw new Error("SSH helper accepts no command arguments");
|
|
809
|
+
await runSshHelper({
|
|
810
|
+
input: process.stdin,
|
|
811
|
+
output: process.stdout,
|
|
812
|
+
entryPath: fileURLToPath(import.meta.url),
|
|
813
|
+
signal: controller.signal
|
|
814
|
+
});
|
|
815
|
+
} catch (error) {
|
|
816
|
+
process.stderr.write(`dsh-ssh-sandbox: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
817
|
+
process.exitCode = 127;
|
|
818
|
+
} finally {
|
|
819
|
+
for (const signal of [
|
|
820
|
+
"SIGTERM",
|
|
821
|
+
"SIGHUP",
|
|
822
|
+
"SIGINT"
|
|
823
|
+
]) process.off(signal, stop);
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
export {};
|