@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/index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { a as SshRpcPeer } from "./protocol-C43fD--H.js";
|
|
2
|
+
import { s as helloSchema } from "./schemas-DBIgsx0q.js";
|
|
3
|
+
import { n as authenticateStream } from "./stream-security-DJlQsPG-.js";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createConnection } from "node:net";
|
|
8
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
9
|
+
import schema from "@deepseek-ai/schemastery";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
//#region lib/types/index.js
|
|
12
|
+
/** OpenSSH connection owner for one version-matched POSIX helper and its independent forwarded streams. */
|
|
13
|
+
/** One non-reconnecting SSH session; loss invalidates all active operations. */
|
|
14
|
+
var SshConnection = class extends Service {
|
|
15
|
+
static Config = schema.object({
|
|
16
|
+
host: schema.string().required(),
|
|
17
|
+
node: schema.string().required(),
|
|
18
|
+
helper: schema.string().required(),
|
|
19
|
+
helperHash: schema.string().required(),
|
|
20
|
+
workspace: schema.string().required(),
|
|
21
|
+
bootstrapPath: schema.string(),
|
|
22
|
+
bootstrapHash: schema.string(),
|
|
23
|
+
requestTimeoutMs: schema.number().default(3e4),
|
|
24
|
+
maxFrameBytes: schema.number().default(64 * 1024 * 1024),
|
|
25
|
+
maxPending: schema.number().default(128),
|
|
26
|
+
leaseMs: schema.number().default(3e4)
|
|
27
|
+
});
|
|
28
|
+
/** Verified remote helper coordinates; callers must await this before launch. */
|
|
29
|
+
ready;
|
|
30
|
+
rpc;
|
|
31
|
+
child;
|
|
32
|
+
childClosed;
|
|
33
|
+
directory;
|
|
34
|
+
heartbeat;
|
|
35
|
+
closed = false;
|
|
36
|
+
lifetime = new AbortController();
|
|
37
|
+
operations = /* @__PURE__ */ new Set();
|
|
38
|
+
disposal;
|
|
39
|
+
failure;
|
|
40
|
+
sockets = /* @__PURE__ */ new Set();
|
|
41
|
+
nextSocket = 0;
|
|
42
|
+
config;
|
|
43
|
+
remote;
|
|
44
|
+
constructor(ctx, config) {
|
|
45
|
+
super(ctx, "ssh");
|
|
46
|
+
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("SSH runtime requires a POSIX client");
|
|
47
|
+
this.config = z.object({
|
|
48
|
+
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.@-]*$/),
|
|
49
|
+
node: z.string().startsWith("/"),
|
|
50
|
+
helper: z.string().startsWith("/"),
|
|
51
|
+
helperHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
52
|
+
workspace: z.string().startsWith("/"),
|
|
53
|
+
requestTimeoutMs: z.number().int().positive().max(2147483647),
|
|
54
|
+
bootstrapPath: z.string().startsWith("/").optional(),
|
|
55
|
+
bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
56
|
+
maxFrameBytes: z.number().int().positive().max(64 * 1024 * 1024),
|
|
57
|
+
maxPending: z.number().int().positive().max(128),
|
|
58
|
+
leaseMs: z.number().int().min(3e3).max(6e5)
|
|
59
|
+
}).refine((value) => value.bootstrapPath === void 0 === (value.bootstrapHash === void 0), "bootstrapPath and bootstrapHash must be paired").parse(config);
|
|
60
|
+
this.ready = this.start();
|
|
61
|
+
this.ready.catch((error) => {
|
|
62
|
+
this.fail(error);
|
|
63
|
+
});
|
|
64
|
+
ctx.effect(() => () => this.dispose());
|
|
65
|
+
}
|
|
66
|
+
/** Hold plugin readiness until the remote identity and helper digest are verified. */
|
|
67
|
+
async [Service.init]() {
|
|
68
|
+
await this.ready;
|
|
69
|
+
}
|
|
70
|
+
/** Verified remote Node executable for the paired PTC runtime. */
|
|
71
|
+
get nodeExecutable() {
|
|
72
|
+
if (this.remote === void 0) throw new Error("SSH helper is not ready");
|
|
73
|
+
return this.remote.node;
|
|
74
|
+
}
|
|
75
|
+
/** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
|
|
76
|
+
get bootstrapPath() {
|
|
77
|
+
if (this.remote === void 0 || this.config.bootstrapPath === void 0) throw new Error("SSH PTC requires a verified bootstrapPath and bootstrapHash");
|
|
78
|
+
return this.config.bootstrapPath;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Send a helper operation; cancellation never replays an ambiguous mutation.
|
|
82
|
+
* @param method - the private helper operation.
|
|
83
|
+
* @param params - JSON request fields validated by the helper.
|
|
84
|
+
* @param result - response validation before returning provider-visible data.
|
|
85
|
+
* @param signal - cancellation, which does not undo completed remote effects.
|
|
86
|
+
* @param wait - allow a process observation to outlast the administrative deadline.
|
|
87
|
+
* @returns the validated remote result.
|
|
88
|
+
*/
|
|
89
|
+
async request(method, params, result, signal, wait = false) {
|
|
90
|
+
this.assertOpen();
|
|
91
|
+
await this.ready;
|
|
92
|
+
this.assertOpen();
|
|
93
|
+
const bounded = wait ? signal : signal === void 0 ? AbortSignal.timeout(this.config.requestTimeoutMs) : AbortSignal.any([signal, AbortSignal.timeout(this.config.requestTimeoutMs)]);
|
|
94
|
+
return this.rpc.request(method, params, result, bounded);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Forward one authenticated stream through an independent SSH channel.
|
|
98
|
+
* @param endpoint - private coordinates issued by this connection's helper.
|
|
99
|
+
* @param signal - cancellation of allocation and the resulting socket.
|
|
100
|
+
* @returns a paused socket; attach a consumer before resuming it.
|
|
101
|
+
*/
|
|
102
|
+
async connectStream(endpoint, signal) {
|
|
103
|
+
return this.track(this.establishStream(endpoint, signal));
|
|
104
|
+
}
|
|
105
|
+
async establishStream(endpoint, signal) {
|
|
106
|
+
const hello = await this.ready;
|
|
107
|
+
this.assertOpen();
|
|
108
|
+
signal = signal === void 0 ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal]);
|
|
109
|
+
const remote = endpoint.path;
|
|
110
|
+
if (!remote.startsWith(`${hello.root}/`) || /[:\r\n\0]/u.test(remote)) throw new Error("SSH helper returned an invalid stream path");
|
|
111
|
+
signal.throwIfAborted();
|
|
112
|
+
const local = join(this.directory, `s${this.nextSocket++}`);
|
|
113
|
+
const forward = `${local}:${remote}`;
|
|
114
|
+
const cancelForward = async () => {
|
|
115
|
+
if (!this.closed) await this.controlCommand([
|
|
116
|
+
"-O",
|
|
117
|
+
"cancel",
|
|
118
|
+
"-L",
|
|
119
|
+
forward
|
|
120
|
+
]).catch(() => {});
|
|
121
|
+
await rm(local, { force: true });
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
await this.controlCommand([
|
|
125
|
+
"-O",
|
|
126
|
+
"forward",
|
|
127
|
+
"-o",
|
|
128
|
+
"ExitOnForwardFailure=yes",
|
|
129
|
+
"-L",
|
|
130
|
+
forward
|
|
131
|
+
], signal);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
await cancelForward();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
signal.throwIfAborted();
|
|
137
|
+
const socket = createConnection({
|
|
138
|
+
path: local,
|
|
139
|
+
allowHalfOpen: true
|
|
140
|
+
});
|
|
141
|
+
this.sockets.add(socket);
|
|
142
|
+
socket.once("close", () => {
|
|
143
|
+
this.sockets.delete(socket);
|
|
144
|
+
this.track(cancelForward()).catch(() => {});
|
|
145
|
+
});
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
signal.removeEventListener("abort", aborted);
|
|
149
|
+
socket.off("connect", connected);
|
|
150
|
+
socket.off("error", failed);
|
|
151
|
+
socket.off("close", closed);
|
|
152
|
+
};
|
|
153
|
+
const connected = () => {
|
|
154
|
+
cleanup();
|
|
155
|
+
resolve();
|
|
156
|
+
};
|
|
157
|
+
const failed = (error) => {
|
|
158
|
+
cleanup();
|
|
159
|
+
reject(error);
|
|
160
|
+
};
|
|
161
|
+
const closed = () => {
|
|
162
|
+
failed(/* @__PURE__ */ new Error("SSH connection closed before stream establishment"));
|
|
163
|
+
};
|
|
164
|
+
const aborted = () => {
|
|
165
|
+
socket.destroy(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
|
|
166
|
+
};
|
|
167
|
+
socket.once("connect", connected);
|
|
168
|
+
socket.once("error", failed);
|
|
169
|
+
socket.once("close", closed);
|
|
170
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
171
|
+
});
|
|
172
|
+
const authenticated = await authenticateStream(socket, endpoint.capability, this.config.requestTimeoutMs, signal);
|
|
173
|
+
this.sockets.add(authenticated);
|
|
174
|
+
authenticated.on("error", () => {
|
|
175
|
+
authenticated.destroy();
|
|
176
|
+
});
|
|
177
|
+
authenticated.once("close", () => {
|
|
178
|
+
this.sockets.delete(authenticated);
|
|
179
|
+
});
|
|
180
|
+
return authenticated;
|
|
181
|
+
}
|
|
182
|
+
/** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
|
|
183
|
+
dispose() {
|
|
184
|
+
this.disposal ??= this.disposeOnce();
|
|
185
|
+
return this.disposal;
|
|
186
|
+
}
|
|
187
|
+
async disposeOnce() {
|
|
188
|
+
this.closed = true;
|
|
189
|
+
this.lifetime.abort(/* @__PURE__ */ new Error("SSH connection is closing"));
|
|
190
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
191
|
+
try {
|
|
192
|
+
await this.ready.catch(() => {});
|
|
193
|
+
if (this.failure === void 0) await this.rpc?.request("close", {}, z.null(), AbortSignal.timeout(this.config.requestTimeoutMs));
|
|
194
|
+
} finally {
|
|
195
|
+
this.rpc?.close();
|
|
196
|
+
const socketClosures = [...this.sockets].reverse().map((socket) => new Promise((resolve) => {
|
|
197
|
+
if (socket.closed) resolve();
|
|
198
|
+
else {
|
|
199
|
+
socket.once("close", () => {
|
|
200
|
+
resolve();
|
|
201
|
+
});
|
|
202
|
+
socket.destroy();
|
|
203
|
+
}
|
|
204
|
+
}));
|
|
205
|
+
this.child?.kill("SIGTERM");
|
|
206
|
+
const force = setTimeout(() => {
|
|
207
|
+
this.child?.kill("SIGKILL");
|
|
208
|
+
}, this.config.requestTimeoutMs);
|
|
209
|
+
try {
|
|
210
|
+
await this.childClosed;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(force);
|
|
213
|
+
}
|
|
214
|
+
await Promise.all(socketClosures);
|
|
215
|
+
while (this.operations.size > 0) await Promise.allSettled([...this.operations]);
|
|
216
|
+
if (this.directory !== void 0) await rm(this.directory, {
|
|
217
|
+
recursive: true,
|
|
218
|
+
force: true
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
controlPath() {
|
|
223
|
+
return join(this.directory, "master");
|
|
224
|
+
}
|
|
225
|
+
assertOpen() {
|
|
226
|
+
if (this.closed) throw new Error("SSH connection is closed");
|
|
227
|
+
if (this.failure !== void 0) throw this.failure;
|
|
228
|
+
}
|
|
229
|
+
track(operation) {
|
|
230
|
+
this.operations.add(operation);
|
|
231
|
+
operation.finally(() => {
|
|
232
|
+
this.operations.delete(operation);
|
|
233
|
+
}).catch(() => {});
|
|
234
|
+
return operation;
|
|
235
|
+
}
|
|
236
|
+
async controlCommand(args, signal) {
|
|
237
|
+
const signals = [this.lifetime.signal, AbortSignal.timeout(this.config.requestTimeoutMs)];
|
|
238
|
+
if (signal !== void 0) signals.push(signal);
|
|
239
|
+
const combined = AbortSignal.any(signals);
|
|
240
|
+
combined.throwIfAborted();
|
|
241
|
+
const result = Promise.withResolvers();
|
|
242
|
+
const command = execFile("ssh", [
|
|
243
|
+
"-S",
|
|
244
|
+
this.controlPath(),
|
|
245
|
+
...args,
|
|
246
|
+
this.config.host
|
|
247
|
+
], {
|
|
248
|
+
signal: combined,
|
|
249
|
+
maxBuffer: 64 * 1024
|
|
250
|
+
}, (error) => {
|
|
251
|
+
if (error === null) result.resolve(void 0);
|
|
252
|
+
else result.reject(error);
|
|
253
|
+
});
|
|
254
|
+
const closed = new Promise((resolve) => {
|
|
255
|
+
command.once("close", () => {
|
|
256
|
+
resolve();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
let force;
|
|
260
|
+
const escalate = () => {
|
|
261
|
+
force = setTimeout(() => {
|
|
262
|
+
command.kill("SIGKILL");
|
|
263
|
+
}, this.config.requestTimeoutMs);
|
|
264
|
+
force.unref();
|
|
265
|
+
};
|
|
266
|
+
combined.addEventListener("abort", escalate, { once: true });
|
|
267
|
+
try {
|
|
268
|
+
await result.promise;
|
|
269
|
+
} finally {
|
|
270
|
+
await closed;
|
|
271
|
+
combined.removeEventListener("abort", escalate);
|
|
272
|
+
if (force !== void 0) clearTimeout(force);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
fail(error) {
|
|
276
|
+
if (this.failure !== void 0) return;
|
|
277
|
+
this.failure = error;
|
|
278
|
+
this.lifetime.abort(error);
|
|
279
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
280
|
+
this.rpc?.close(error);
|
|
281
|
+
for (const socket of [...this.sockets].reverse()) socket.destroy(error);
|
|
282
|
+
this.child?.kill("SIGTERM");
|
|
283
|
+
}
|
|
284
|
+
async start() {
|
|
285
|
+
this.directory = await mkdtemp("/tmp/dsh-ssh-");
|
|
286
|
+
if (this.closed) throw new Error("SSH connection closed before startup");
|
|
287
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
288
|
+
const command = [
|
|
289
|
+
this.config.node,
|
|
290
|
+
"--disable-sigusr1",
|
|
291
|
+
this.config.helper
|
|
292
|
+
].map(quote).join(" ");
|
|
293
|
+
const child = spawn("ssh", [
|
|
294
|
+
"-T",
|
|
295
|
+
"-M",
|
|
296
|
+
"-S",
|
|
297
|
+
this.controlPath(),
|
|
298
|
+
"-o",
|
|
299
|
+
"ControlPersist=no",
|
|
300
|
+
"-o",
|
|
301
|
+
"BatchMode=yes",
|
|
302
|
+
"-o",
|
|
303
|
+
"StrictHostKeyChecking=yes",
|
|
304
|
+
"-o",
|
|
305
|
+
"ForwardAgent=no",
|
|
306
|
+
"-o",
|
|
307
|
+
"ClearAllForwardings=yes",
|
|
308
|
+
"-o",
|
|
309
|
+
"ServerAliveInterval=10",
|
|
310
|
+
"-o",
|
|
311
|
+
"ServerAliveCountMax=3",
|
|
312
|
+
this.config.host,
|
|
313
|
+
command
|
|
314
|
+
], { stdio: [
|
|
315
|
+
"pipe",
|
|
316
|
+
"pipe",
|
|
317
|
+
"pipe"
|
|
318
|
+
] });
|
|
319
|
+
this.child = child;
|
|
320
|
+
this.childClosed = new Promise((resolve) => {
|
|
321
|
+
child.once("close", () => {
|
|
322
|
+
resolve();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
child.stderr.resume();
|
|
326
|
+
child.once("error", (error) => {
|
|
327
|
+
this.fail(error);
|
|
328
|
+
});
|
|
329
|
+
child.once("close", () => {
|
|
330
|
+
this.fail(/* @__PURE__ */ new Error("SSH helper disconnected; remote outcomes and cleanup are unknown"));
|
|
331
|
+
});
|
|
332
|
+
const rpc = new SshRpcPeer(child.stdout, child.stdin, this.config.maxFrameBytes, this.config.maxPending);
|
|
333
|
+
this.rpc = rpc;
|
|
334
|
+
rpc.once("closed", (error) => {
|
|
335
|
+
this.fail(error);
|
|
336
|
+
});
|
|
337
|
+
const hello = await rpc.request("hello", {
|
|
338
|
+
protocol: 1,
|
|
339
|
+
workspace: this.config.workspace,
|
|
340
|
+
leaseMs: this.config.leaseMs,
|
|
341
|
+
...this.config.bootstrapPath === void 0 ? {} : { bootstrapPath: this.config.bootstrapPath }
|
|
342
|
+
}, helloSchema, AbortSignal.timeout(this.config.requestTimeoutMs));
|
|
343
|
+
if (hello.hash !== this.config.helperHash) throw new Error("SSH helper digest differs from the configured artifact");
|
|
344
|
+
if (hello.bootstrapHash !== this.config.bootstrapHash) throw new Error("SSH PTC bootstrap digest differs from the configured artifact");
|
|
345
|
+
this.remote = hello;
|
|
346
|
+
let heartbeatPending;
|
|
347
|
+
this.heartbeat = setInterval(() => {
|
|
348
|
+
heartbeatPending ??= rpc.request("heartbeat", {}, z.null(), AbortSignal.timeout(this.config.leaseMs / 2)).catch((error) => {
|
|
349
|
+
this.fail(error);
|
|
350
|
+
}).finally(() => {
|
|
351
|
+
heartbeatPending = void 0;
|
|
352
|
+
});
|
|
353
|
+
}, Math.floor(this.config.leaseMs / 3));
|
|
354
|
+
this.heartbeat.unref();
|
|
355
|
+
return hello;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
//#endregion
|
|
359
|
+
export { SshConnection, SshConnection as default };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
//#region lib/types/protocol.js
|
|
5
|
+
/** Bounded, versioned requests between an SSH client and its private remote helper. */
|
|
6
|
+
/** Wire version shared by the installed helper and client package. */
|
|
7
|
+
const SSH_PROTOCOL_VERSION = 1;
|
|
8
|
+
/** Maximum prepared or running process handles owned by one helper. */
|
|
9
|
+
const SSH_MAX_PROCESS_HANDLES = 128;
|
|
10
|
+
/** Maximum open text iterators owned by one helper. */
|
|
11
|
+
const SSH_MAX_TEXT_STREAMS = 128;
|
|
12
|
+
const managementLimits = {
|
|
13
|
+
heartbeat: 1,
|
|
14
|
+
close: 1,
|
|
15
|
+
"process.terminate": 128,
|
|
16
|
+
"fs.streamClose": 128
|
|
17
|
+
};
|
|
18
|
+
function requestClass(method) {
|
|
19
|
+
switch (method) {
|
|
20
|
+
case "heartbeat":
|
|
21
|
+
case "close":
|
|
22
|
+
case "process.terminate":
|
|
23
|
+
case "fs.streamClose": return method;
|
|
24
|
+
default: return "ordinary";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const errorSchema = z.object({
|
|
28
|
+
name: z.string(),
|
|
29
|
+
message: z.string(),
|
|
30
|
+
code: z.string().optional()
|
|
31
|
+
}).strict();
|
|
32
|
+
const requestIdSchema = z.string().transform((value) => value);
|
|
33
|
+
const frameSchema = z.discriminatedUnion("type", [
|
|
34
|
+
z.object({
|
|
35
|
+
type: z.literal("request"),
|
|
36
|
+
id: requestIdSchema,
|
|
37
|
+
method: z.string(),
|
|
38
|
+
params: z.unknown()
|
|
39
|
+
}).strict(),
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal("result"),
|
|
42
|
+
id: requestIdSchema,
|
|
43
|
+
value: z.unknown()
|
|
44
|
+
}).strict(),
|
|
45
|
+
z.object({
|
|
46
|
+
type: z.literal("error"),
|
|
47
|
+
id: requestIdSchema,
|
|
48
|
+
error: errorSchema
|
|
49
|
+
}).strict(),
|
|
50
|
+
z.object({
|
|
51
|
+
type: z.literal("cancel"),
|
|
52
|
+
id: requestIdSchema
|
|
53
|
+
}).strict()
|
|
54
|
+
]);
|
|
55
|
+
function operationError(error) {
|
|
56
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
57
|
+
}
|
|
58
|
+
/** A remote error retains its typed filesystem or sandbox code. */
|
|
59
|
+
var RemoteOperationError = class extends Error {
|
|
60
|
+
code;
|
|
61
|
+
constructor(message, code) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.name = "RemoteOperationError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** The peer owns pending calls and rejects ambiguous operations on connection loss; it never replays requests. */
|
|
68
|
+
var SshRpcPeer = class extends EventEmitter {
|
|
69
|
+
input;
|
|
70
|
+
output;
|
|
71
|
+
maxFrameBytes;
|
|
72
|
+
maxPending;
|
|
73
|
+
handler;
|
|
74
|
+
pending = /* @__PURE__ */ new Map();
|
|
75
|
+
active = /* @__PURE__ */ new Map();
|
|
76
|
+
writeTail = Promise.resolve();
|
|
77
|
+
queuedBytes = 0;
|
|
78
|
+
failure;
|
|
79
|
+
constructor(input, output, maxFrameBytes, maxPending, handler) {
|
|
80
|
+
super();
|
|
81
|
+
this.input = input;
|
|
82
|
+
this.output = output;
|
|
83
|
+
this.maxFrameBytes = maxFrameBytes;
|
|
84
|
+
this.maxPending = maxPending;
|
|
85
|
+
this.handler = handler;
|
|
86
|
+
input.on("error", (error) => {
|
|
87
|
+
this.close(error);
|
|
88
|
+
});
|
|
89
|
+
output.on("error", (error) => {
|
|
90
|
+
this.close(error);
|
|
91
|
+
});
|
|
92
|
+
output.on("close", () => {
|
|
93
|
+
this.close();
|
|
94
|
+
});
|
|
95
|
+
this.readFrames().catch((error) => {
|
|
96
|
+
this.close(operationError(error));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Send one request and validate its response before exposing it to the caller.
|
|
101
|
+
* @param method - the private helper operation.
|
|
102
|
+
* @param params - JSON request fields.
|
|
103
|
+
* @param schema - validation for the remote response.
|
|
104
|
+
* @param signal - cancellation without rollback of remote effects.
|
|
105
|
+
* @returns the validated response or a transport/remote-operation rejection.
|
|
106
|
+
*/
|
|
107
|
+
async request(method, params, schema, signal) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
if (this.failure !== void 0) throw this.failure;
|
|
110
|
+
const kind = requestClass(method);
|
|
111
|
+
if (this.atCapacity(kind, this.pending.values())) throw new Error("SSH helper pending request limit reached");
|
|
112
|
+
const id = randomUUID();
|
|
113
|
+
const result = Promise.withResolvers();
|
|
114
|
+
result.promise.catch(() => {});
|
|
115
|
+
this.pending.set(id, {
|
|
116
|
+
...result,
|
|
117
|
+
requestClass: kind
|
|
118
|
+
});
|
|
119
|
+
const abort = () => {
|
|
120
|
+
result.reject(/* @__PURE__ */ new Error("SSH operation cancelled; a completed remote mutation is not rolled back"));
|
|
121
|
+
this.send({
|
|
122
|
+
type: "cancel",
|
|
123
|
+
id
|
|
124
|
+
}).catch(() => {});
|
|
125
|
+
};
|
|
126
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
127
|
+
try {
|
|
128
|
+
this.send({
|
|
129
|
+
type: "request",
|
|
130
|
+
id,
|
|
131
|
+
method,
|
|
132
|
+
params
|
|
133
|
+
}).catch((error) => {
|
|
134
|
+
this.pending.delete(id);
|
|
135
|
+
result.reject(operationError(error));
|
|
136
|
+
});
|
|
137
|
+
return schema.parse(await result.promise);
|
|
138
|
+
} finally {
|
|
139
|
+
signal?.removeEventListener("abort", abort);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fail pending operations and abort remote handlers without claiming rollback.
|
|
144
|
+
* @param error - the transport failure reported to all pending operations.
|
|
145
|
+
*/
|
|
146
|
+
close(error = /* @__PURE__ */ new Error("SSH connection lost; remote operation outcome and cleanup are unknown")) {
|
|
147
|
+
if (this.failure !== void 0) return;
|
|
148
|
+
this.failure = error;
|
|
149
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
150
|
+
this.pending.clear();
|
|
151
|
+
for (const { controller } of this.active.values()) controller.abort(error);
|
|
152
|
+
this.active.clear();
|
|
153
|
+
this.input.destroy();
|
|
154
|
+
this.output.destroy();
|
|
155
|
+
this.emit("closed", error);
|
|
156
|
+
}
|
|
157
|
+
async send(frame) {
|
|
158
|
+
if (this.failure !== void 0) return Promise.reject(this.failure);
|
|
159
|
+
const body = Buffer.from(JSON.stringify(frame));
|
|
160
|
+
if (body.length > this.maxFrameBytes || this.queuedBytes + body.length + 4 > this.maxFrameBytes * 2) return Promise.reject(/* @__PURE__ */ new Error("SSH helper frame or write queue limit exceeded"));
|
|
161
|
+
const header = Buffer.alloc(4);
|
|
162
|
+
header.writeUInt32BE(body.length);
|
|
163
|
+
const bytes = Buffer.concat([header, body]);
|
|
164
|
+
this.queuedBytes += bytes.length;
|
|
165
|
+
const write = this.writeTail.then(async () => {
|
|
166
|
+
if (this.failure !== void 0) throw this.failure;
|
|
167
|
+
if (!this.output.write(bytes)) await new Promise((resolve, reject) => {
|
|
168
|
+
const cleanup = () => {
|
|
169
|
+
this.output.off("drain", drained);
|
|
170
|
+
this.off("closed", closed);
|
|
171
|
+
};
|
|
172
|
+
const drained = () => {
|
|
173
|
+
cleanup();
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
const closed = (error) => {
|
|
177
|
+
cleanup();
|
|
178
|
+
reject(error);
|
|
179
|
+
};
|
|
180
|
+
this.output.once("drain", drained);
|
|
181
|
+
this.once("closed", closed);
|
|
182
|
+
if (this.failure !== void 0) closed(this.failure);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
this.writeTail = write.catch((error) => {
|
|
186
|
+
this.close(operationError(error));
|
|
187
|
+
});
|
|
188
|
+
return write.finally(() => {
|
|
189
|
+
this.queuedBytes -= bytes.length;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async readFrames() {
|
|
193
|
+
const header = Buffer.alloc(4);
|
|
194
|
+
let headerBytes = 0;
|
|
195
|
+
let payload;
|
|
196
|
+
let payloadBytes = 0;
|
|
197
|
+
for await (const raw of this.input) {
|
|
198
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
199
|
+
let offset = 0;
|
|
200
|
+
while (offset < chunk.length) {
|
|
201
|
+
if (payload === void 0) {
|
|
202
|
+
const count = Math.min(4 - headerBytes, chunk.length - offset);
|
|
203
|
+
chunk.copy(header, headerBytes, offset, offset + count);
|
|
204
|
+
headerBytes += count;
|
|
205
|
+
offset += count;
|
|
206
|
+
if (headerBytes < 4) continue;
|
|
207
|
+
const size = header.readUInt32BE(0);
|
|
208
|
+
if (size === 0 || size > this.maxFrameBytes) throw new Error("SSH helper sent an invalid frame length");
|
|
209
|
+
payload = Buffer.alloc(size);
|
|
210
|
+
payloadBytes = 0;
|
|
211
|
+
}
|
|
212
|
+
const count = Math.min(payload.length - payloadBytes, chunk.length - offset);
|
|
213
|
+
chunk.copy(payload, payloadBytes, offset, offset + count);
|
|
214
|
+
payloadBytes += count;
|
|
215
|
+
offset += count;
|
|
216
|
+
if (payloadBytes === payload.length) {
|
|
217
|
+
const frame = frameSchema.parse(JSON.parse(payload.toString("utf8")));
|
|
218
|
+
payload = void 0;
|
|
219
|
+
headerBytes = 0;
|
|
220
|
+
this.receive(frame);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw new Error(headerBytes > 0 || payload !== void 0 ? "SSH helper disconnected during a frame; outcome is unknown" : "SSH helper disconnected; outcome is unknown");
|
|
225
|
+
}
|
|
226
|
+
receive(frame) {
|
|
227
|
+
if (frame.type === "result" || frame.type === "error") {
|
|
228
|
+
const pending = this.pending.get(frame.id);
|
|
229
|
+
if (pending === void 0) return;
|
|
230
|
+
this.pending.delete(frame.id);
|
|
231
|
+
if (frame.type === "result") pending.resolve(frame.value);
|
|
232
|
+
else pending.reject(new RemoteOperationError(frame.error.message, frame.error.code));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (frame.type === "cancel") {
|
|
236
|
+
this.active.get(frame.id)?.controller.abort(/* @__PURE__ */ new Error("SSH caller cancelled the operation"));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const kind = requestClass(frame.method);
|
|
240
|
+
if (this.handler === void 0 || this.active.has(frame.id) || this.atCapacity(kind, this.active.values())) throw new Error("SSH helper received an unexpected or excessive request");
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
this.active.set(frame.id, {
|
|
243
|
+
controller,
|
|
244
|
+
requestClass: kind
|
|
245
|
+
});
|
|
246
|
+
this.handler(frame.method, frame.params, controller.signal).then((value) => this.send({
|
|
247
|
+
type: "result",
|
|
248
|
+
id: frame.id,
|
|
249
|
+
value: value ?? null
|
|
250
|
+
}), (error) => {
|
|
251
|
+
const detail = operationError(error);
|
|
252
|
+
const code = "code" in detail && typeof detail.code === "string" ? detail.code : void 0;
|
|
253
|
+
return this.send({
|
|
254
|
+
type: "error",
|
|
255
|
+
id: frame.id,
|
|
256
|
+
error: {
|
|
257
|
+
name: detail.name,
|
|
258
|
+
message: detail.message,
|
|
259
|
+
...code === void 0 ? {} : { code }
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}).catch((error) => {
|
|
263
|
+
this.close(operationError(error));
|
|
264
|
+
}).finally(() => {
|
|
265
|
+
this.active.delete(frame.id);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
atCapacity(kind, requests) {
|
|
269
|
+
const limit = kind === "ordinary" ? this.maxPending : managementLimits[kind];
|
|
270
|
+
let count = 0;
|
|
271
|
+
for (const request of requests) if (request.requestClass === kind) count++;
|
|
272
|
+
return count >= limit;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
//#endregion
|
|
276
|
+
export { SshRpcPeer as a, SSH_PROTOCOL_VERSION as i, SSH_MAX_PROCESS_HANDLES as n, SSH_MAX_TEXT_STREAMS as r, RemoteOperationError as t };
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as SshRpcPeer, i as SSH_PROTOCOL_VERSION, n as SSH_MAX_PROCESS_HANDLES, r as SSH_MAX_TEXT_STREAMS, t as RemoteOperationError } from "./protocol-C43fD--H.js";
|
|
2
|
+
export { RemoteOperationError, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION, SshRpcPeer };
|