@runneth/cli 0.0.0-sha.19a36f654ef6.production

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.
@@ -0,0 +1,608 @@
1
+ import { spawn, } from "node:child_process";
2
+ import { rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ const ALLOWED_PROCESS_SIGNALS = [
6
+ "SIGHUP",
7
+ "SIGINT",
8
+ "SIGKILL",
9
+ "SIGTERM",
10
+ ];
11
+ const DEFAULT_EXEC_TIMEOUT_MS = 600_000;
12
+ const DEFAULT_MASTER_READY_TIMEOUT_MS = 10_000;
13
+ const DEFAULT_MAX_FRAME_BYTES = 1_048_576;
14
+ const DEFAULT_MAX_PROCESSES = 16;
15
+ const PROCESS_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
16
+ const nowIso = () => {
17
+ return new Date().toISOString();
18
+ };
19
+ const isRecord = (value) => {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ };
22
+ const errorMessage = (error) => {
23
+ return error instanceof Error ? error.message : String(error);
24
+ };
25
+ const parseString = (record, key) => {
26
+ const value = record[key];
27
+ if (typeof value !== "string" || value.length === 0) {
28
+ throw new Error(`${key} must be a non-empty string`);
29
+ }
30
+ return value;
31
+ };
32
+ const parseProcessId = (value) => {
33
+ if (!PROCESS_ID_PATTERN.test(value)) {
34
+ throw new Error(`Invalid process id: ${value}. Use letters, numbers, dot, underscore, dash, and colon only.`);
35
+ }
36
+ return value;
37
+ };
38
+ const parsePositiveInteger = (record, key) => {
39
+ const value = record[key];
40
+ if (value === undefined) {
41
+ return undefined;
42
+ }
43
+ if (!Number.isInteger(value) || typeof value !== "number" || value <= 0) {
44
+ throw new Error(`${key} must be a positive integer`);
45
+ }
46
+ return value;
47
+ };
48
+ const parseParams = (record) => {
49
+ const params = record.params;
50
+ if (!isRecord(params)) {
51
+ throw new Error("params must be a JSON object");
52
+ }
53
+ return params;
54
+ };
55
+ const parseProcessSignal = (record) => {
56
+ const value = parseString(record, "signal");
57
+ if (!ALLOWED_PROCESS_SIGNALS.includes(value)) {
58
+ throw new Error(`Unsupported signal: ${value}`);
59
+ }
60
+ return value;
61
+ };
62
+ export const parseRunnethSshStdioRequest = (value) => {
63
+ if (!isRecord(value)) {
64
+ throw new Error("Request must be a JSON object");
65
+ }
66
+ const id = parseProcessId(parseString(value, "id"));
67
+ const method = parseString(value, "method");
68
+ switch (method) {
69
+ case "exec": {
70
+ const params = parseParams(value);
71
+ const timeoutMs = parsePositiveInteger(params, "timeoutMs");
72
+ return {
73
+ id,
74
+ method,
75
+ params: {
76
+ command: parseString(params, "command"),
77
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
78
+ },
79
+ };
80
+ }
81
+ case "spawn": {
82
+ const params = parseParams(value);
83
+ return {
84
+ id,
85
+ method,
86
+ params: {
87
+ command: parseString(params, "command"),
88
+ },
89
+ };
90
+ }
91
+ case "stdin": {
92
+ const params = parseParams(value);
93
+ const data = params.data;
94
+ if (typeof data !== "string") {
95
+ throw new Error("data must be a string");
96
+ }
97
+ return {
98
+ id,
99
+ method,
100
+ params: {
101
+ data,
102
+ },
103
+ };
104
+ }
105
+ case "signal": {
106
+ const params = parseParams(value);
107
+ return {
108
+ id,
109
+ method,
110
+ params: {
111
+ signal: parseProcessSignal(params),
112
+ },
113
+ };
114
+ }
115
+ case "close":
116
+ case "list":
117
+ case "ping": {
118
+ return {
119
+ id,
120
+ method,
121
+ };
122
+ }
123
+ default: {
124
+ throw new Error(`Unknown SSH stdio method: ${method}`);
125
+ }
126
+ }
127
+ };
128
+ const writeChunk = async (stream, chunk) => {
129
+ if (stream.write(chunk)) {
130
+ return;
131
+ }
132
+ await new Promise((resolve) => {
133
+ stream.once("drain", () => {
134
+ resolve();
135
+ });
136
+ });
137
+ };
138
+ class JsonLineWriter {
139
+ #stream;
140
+ #queue = Promise.resolve();
141
+ constructor(stream) {
142
+ this.#stream = stream;
143
+ }
144
+ async flush() {
145
+ await this.#queue;
146
+ }
147
+ write(payload) {
148
+ const line = `${JSON.stringify(payload)}\n`;
149
+ const next = this.#queue.then(async () => {
150
+ await writeChunk(this.#stream, line);
151
+ });
152
+ this.#queue = next.then(() => undefined, () => undefined);
153
+ return next;
154
+ }
155
+ }
156
+ const waitForChildExit = async (child) => {
157
+ return await new Promise((resolve, reject) => {
158
+ child.once("error", reject);
159
+ child.once("close", (code, signal) => {
160
+ resolve({ code, signal });
161
+ });
162
+ });
163
+ };
164
+ const runProcessExitCode = async (input) => {
165
+ return await new Promise((resolve, reject) => {
166
+ const child = spawn(input.sshCommand, [...input.args], {
167
+ env: input.env,
168
+ stdio: ["ignore", "ignore", "ignore"],
169
+ });
170
+ child.once("error", reject);
171
+ child.once("close", (code, signal) => {
172
+ if (signal !== null) {
173
+ resolve(1);
174
+ return;
175
+ }
176
+ resolve(code ?? 1);
177
+ });
178
+ });
179
+ };
180
+ class RunnethSshControlMaster {
181
+ #controlPath;
182
+ #env;
183
+ #masterReadyTimeoutMs;
184
+ #setup;
185
+ #sshCommand;
186
+ #master = null;
187
+ #masterClosed = false;
188
+ #masterExit = null;
189
+ #masterStderr = "";
190
+ constructor(options) {
191
+ this.#controlPath = options.controlPath;
192
+ this.#env = options.env;
193
+ this.#masterReadyTimeoutMs = options.masterReadyTimeoutMs;
194
+ this.#setup = options.setup;
195
+ this.#sshCommand = options.sshCommand;
196
+ }
197
+ async start() {
198
+ await rm(this.#controlPath, { force: true });
199
+ const master = spawn(this.#sshCommand, [
200
+ "-F",
201
+ this.#setup.configPath,
202
+ "-S",
203
+ this.#controlPath,
204
+ "-o",
205
+ "ControlMaster=yes",
206
+ "-o",
207
+ "ControlPersist=no",
208
+ "-N",
209
+ this.#setup.hostAlias,
210
+ ], {
211
+ env: this.#env,
212
+ stdio: ["ignore", "ignore", "pipe"],
213
+ });
214
+ this.#master = master;
215
+ master.stderr?.on("data", (chunk) => {
216
+ this.#masterStderr += String(chunk);
217
+ });
218
+ this.#masterExit = waitForChildExit(master).then((exit) => {
219
+ this.#masterClosed = true;
220
+ return exit;
221
+ });
222
+ const deadline = Date.now() + this.#masterReadyTimeoutMs;
223
+ while (Date.now() < deadline) {
224
+ if (this.#masterClosed) {
225
+ throw new Error(this.#formatMasterExitError());
226
+ }
227
+ if ((await this.check()) === 0) {
228
+ return;
229
+ }
230
+ await delay(50);
231
+ }
232
+ throw new Error(this.#formatMasterExitError("Timed out opening SSH master"));
233
+ }
234
+ async check() {
235
+ return await runProcessExitCode({
236
+ args: [
237
+ "-F",
238
+ this.#setup.configPath,
239
+ "-S",
240
+ this.#controlPath,
241
+ "-O",
242
+ "check",
243
+ this.#setup.hostAlias,
244
+ ],
245
+ env: this.#env,
246
+ sshCommand: this.#sshCommand,
247
+ });
248
+ }
249
+ async requireReady() {
250
+ if (this.#masterClosed || (await this.check()) !== 0) {
251
+ throw new Error(this.#formatMasterExitError("SSH master is not running"));
252
+ }
253
+ }
254
+ spawnRemoteCommand(command) {
255
+ return spawn(this.#sshCommand, [
256
+ "-F",
257
+ this.#setup.configPath,
258
+ "-S",
259
+ this.#controlPath,
260
+ "-o",
261
+ "ControlMaster=no",
262
+ this.#setup.hostAlias,
263
+ command,
264
+ ], {
265
+ env: this.#env,
266
+ stdio: "pipe",
267
+ });
268
+ }
269
+ async stop() {
270
+ const master = this.#master;
271
+ if (master === null || this.#masterExit === null || this.#masterClosed) {
272
+ await rm(this.#controlPath, { force: true });
273
+ return;
274
+ }
275
+ master.kill("SIGTERM");
276
+ const timeout = delay(5_000).then(() => {
277
+ if (!this.#masterClosed) {
278
+ master.kill("SIGKILL");
279
+ }
280
+ });
281
+ await Promise.race([this.#masterExit, timeout]);
282
+ await this.#masterExit;
283
+ await rm(this.#controlPath, { force: true });
284
+ }
285
+ #formatMasterExitError(prefix = "SSH master exited before it was ready") {
286
+ const stderr = this.#masterStderr.trim();
287
+ if (stderr.length === 0) {
288
+ return prefix;
289
+ }
290
+ return `${prefix}: ${stderr}`;
291
+ }
292
+ }
293
+ class RunnethSshStdioController {
294
+ #defaultTimeoutMs;
295
+ #master;
296
+ #maxFrameBytes;
297
+ #maxProcesses;
298
+ #processes = new Map();
299
+ #setup;
300
+ #stdin;
301
+ #stderr;
302
+ #writer;
303
+ #stopping = false;
304
+ constructor(options) {
305
+ const setup = options.setup;
306
+ const env = options.env ?? process.env;
307
+ const controlPath = path.join(path.dirname(setup.configPath), "control.sock");
308
+ this.#defaultTimeoutMs =
309
+ options.defaultTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
310
+ this.#maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
311
+ this.#maxProcesses = options.maxProcesses ?? DEFAULT_MAX_PROCESSES;
312
+ this.#setup = setup;
313
+ this.#stdin = options.streams?.stdin ?? process.stdin;
314
+ this.#stderr = options.streams?.stderr ?? process.stderr;
315
+ this.#writer = new JsonLineWriter(options.streams?.stdout ?? process.stdout);
316
+ this.#master = new RunnethSshControlMaster({
317
+ controlPath,
318
+ env,
319
+ masterReadyTimeoutMs: options.masterReadyTimeoutMs ?? DEFAULT_MASTER_READY_TIMEOUT_MS,
320
+ setup,
321
+ sshCommand: options.sshCommand ?? "ssh",
322
+ });
323
+ }
324
+ async start() {
325
+ await this.#master.start();
326
+ await writeChunk(this.#stderr, `SSH stdio master ${this.#setup.hostAlias} ready\n`);
327
+ await this.#writer.write({
328
+ hostAlias: this.#setup.hostAlias,
329
+ ok: true,
330
+ protocol: "runneth-ssh-stdio",
331
+ type: "ready",
332
+ version: 1,
333
+ ...(this.#setup.targetName === undefined
334
+ ? {}
335
+ : { targetName: this.#setup.targetName }),
336
+ });
337
+ }
338
+ async run() {
339
+ let buffer = "";
340
+ for await (const chunk of this.#stdin) {
341
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
342
+ let newlineIndex = buffer.indexOf("\n");
343
+ while (newlineIndex !== -1) {
344
+ const rawLine = buffer.slice(0, newlineIndex);
345
+ buffer = buffer.slice(newlineIndex + 1);
346
+ if (Buffer.byteLength(rawLine, "utf8") > this.#maxFrameBytes) {
347
+ await this.#writer.write({
348
+ message: `SSH stdio frame exceeds ${String(this.#maxFrameBytes)} bytes`,
349
+ ok: false,
350
+ type: "error",
351
+ });
352
+ return;
353
+ }
354
+ if (rawLine.trim().length > 0) {
355
+ await this.#handleLine(rawLine);
356
+ }
357
+ newlineIndex = buffer.indexOf("\n");
358
+ }
359
+ if (Buffer.byteLength(buffer, "utf8") > this.#maxFrameBytes) {
360
+ await this.#writer.write({
361
+ message: `SSH stdio frame exceeds ${String(this.#maxFrameBytes)} bytes`,
362
+ ok: false,
363
+ type: "error",
364
+ });
365
+ return;
366
+ }
367
+ }
368
+ }
369
+ async stop() {
370
+ this.#stopping = true;
371
+ for (const processInfo of this.#processes.values()) {
372
+ processInfo.child.kill("SIGTERM");
373
+ }
374
+ await this.#master.stop();
375
+ await this.#writer.flush();
376
+ }
377
+ async #handleLine(rawLine) {
378
+ let request;
379
+ try {
380
+ request = parseRunnethSshStdioRequest(JSON.parse(rawLine));
381
+ }
382
+ catch (error) {
383
+ await this.#writer.write({
384
+ message: errorMessage(error),
385
+ ok: false,
386
+ type: "error",
387
+ });
388
+ return;
389
+ }
390
+ try {
391
+ switch (request.method) {
392
+ case "exec": {
393
+ void this.#exec(request).catch((error) => {
394
+ void this.#writeRequestError(request.id, error);
395
+ });
396
+ return;
397
+ }
398
+ case "spawn": {
399
+ await this.#spawn(request);
400
+ return;
401
+ }
402
+ case "stdin": {
403
+ await this.#writeProcessStdin(request);
404
+ return;
405
+ }
406
+ case "signal": {
407
+ await this.#signalProcess(request);
408
+ return;
409
+ }
410
+ case "close": {
411
+ await this.#closeProcess(request.id);
412
+ return;
413
+ }
414
+ case "list": {
415
+ await this.#writer.write({
416
+ id: request.id,
417
+ ok: true,
418
+ processes: [...this.#processes.values()].map((processInfo) => {
419
+ return {
420
+ command: processInfo.command,
421
+ processId: processInfo.processId,
422
+ startedAt: processInfo.startedAt,
423
+ };
424
+ }),
425
+ type: "list",
426
+ });
427
+ return;
428
+ }
429
+ case "ping": {
430
+ await this.#writer.write({
431
+ id: request.id,
432
+ ok: true,
433
+ type: "pong",
434
+ });
435
+ return;
436
+ }
437
+ default: {
438
+ const exhaustiveCheck = request;
439
+ return exhaustiveCheck;
440
+ }
441
+ }
442
+ }
443
+ catch (error) {
444
+ await this.#writeRequestError(request.id, error);
445
+ }
446
+ }
447
+ async #exec(request) {
448
+ await this.#master.requireReady();
449
+ const child = this.#master.spawnRemoteCommand(request.params.command);
450
+ const timeoutMs = request.params.timeoutMs ?? this.#defaultTimeoutMs;
451
+ let timedOut = false;
452
+ const timeout = setTimeout(() => {
453
+ timedOut = true;
454
+ void this.#writer.write({
455
+ id: request.id,
456
+ message: `Command timed out after ${String(timeoutMs)}ms`,
457
+ ok: false,
458
+ type: "error",
459
+ });
460
+ child.kill("SIGTERM");
461
+ }, timeoutMs);
462
+ child.stdout.on("data", (chunk) => {
463
+ void this.#writer.write({
464
+ data: String(chunk),
465
+ id: request.id,
466
+ type: "stdout",
467
+ });
468
+ });
469
+ child.stderr.on("data", (chunk) => {
470
+ void this.#writer.write({
471
+ data: String(chunk),
472
+ id: request.id,
473
+ type: "stderr",
474
+ });
475
+ });
476
+ child.once("error", (error) => {
477
+ clearTimeout(timeout);
478
+ void this.#writeRequestError(request.id, error);
479
+ });
480
+ child.once("close", (code, signal) => {
481
+ clearTimeout(timeout);
482
+ void this.#writer.write({
483
+ code,
484
+ id: request.id,
485
+ ok: !timedOut,
486
+ signal,
487
+ type: "exit",
488
+ });
489
+ });
490
+ }
491
+ async #spawn(request) {
492
+ if (this.#processes.has(request.id)) {
493
+ throw new Error(`Process is already running: ${request.id}`);
494
+ }
495
+ if (this.#processes.size >= this.#maxProcesses) {
496
+ throw new Error(`SSH stdio process limit reached: ${String(this.#maxProcesses)}`);
497
+ }
498
+ await this.#master.requireReady();
499
+ const child = this.#master.spawnRemoteCommand(request.params.command);
500
+ const processInfo = {
501
+ child,
502
+ command: request.params.command,
503
+ processId: request.id,
504
+ startedAt: nowIso(),
505
+ };
506
+ this.#processes.set(request.id, processInfo);
507
+ child.once("spawn", () => {
508
+ void this.#writer.write({
509
+ command: request.params.command,
510
+ id: request.id,
511
+ ok: true,
512
+ processId: request.id,
513
+ type: "started",
514
+ });
515
+ });
516
+ child.stdout.on("data", (chunk) => {
517
+ void this.#writer.write({
518
+ data: String(chunk),
519
+ id: request.id,
520
+ processId: request.id,
521
+ type: "stdout",
522
+ });
523
+ });
524
+ child.stderr.on("data", (chunk) => {
525
+ void this.#writer.write({
526
+ data: String(chunk),
527
+ id: request.id,
528
+ processId: request.id,
529
+ type: "stderr",
530
+ });
531
+ });
532
+ child.once("error", (error) => {
533
+ this.#processes.delete(request.id);
534
+ void this.#writeRequestError(request.id, error);
535
+ });
536
+ child.once("close", (code, signal) => {
537
+ this.#processes.delete(request.id);
538
+ if (this.#stopping) {
539
+ return;
540
+ }
541
+ void this.#writer.write({
542
+ code,
543
+ id: request.id,
544
+ ok: true,
545
+ processId: request.id,
546
+ signal,
547
+ type: "exit",
548
+ });
549
+ });
550
+ }
551
+ async #writeProcessStdin(request) {
552
+ const processInfo = this.#requireProcess(request.id);
553
+ await writeChunk(processInfo.child.stdin, request.params.data);
554
+ await this.#writer.write({
555
+ id: request.id,
556
+ ok: true,
557
+ type: "ack",
558
+ });
559
+ }
560
+ async #signalProcess(request) {
561
+ const processInfo = this.#requireProcess(request.id);
562
+ if (!processInfo.child.kill(request.params.signal)) {
563
+ throw new Error(`Unable to signal process: ${request.id}`);
564
+ }
565
+ await this.#writer.write({
566
+ id: request.id,
567
+ ok: true,
568
+ signal: request.params.signal,
569
+ type: "ack",
570
+ });
571
+ }
572
+ async #closeProcess(processId) {
573
+ const processInfo = this.#requireProcess(processId);
574
+ if (!processInfo.child.kill("SIGTERM")) {
575
+ throw new Error(`Unable to close process: ${processId}`);
576
+ }
577
+ await this.#writer.write({
578
+ id: processId,
579
+ ok: true,
580
+ type: "ack",
581
+ });
582
+ }
583
+ #requireProcess(processId) {
584
+ const processInfo = this.#processes.get(processId);
585
+ if (processInfo === undefined) {
586
+ throw new Error(`Process is not running: ${processId}`);
587
+ }
588
+ return processInfo;
589
+ }
590
+ async #writeRequestError(id, error) {
591
+ await this.#writer.write({
592
+ id,
593
+ message: errorMessage(error),
594
+ ok: false,
595
+ type: "error",
596
+ });
597
+ }
598
+ }
599
+ export const runRunnethSshStdio = async (options) => {
600
+ const controller = new RunnethSshStdioController(options);
601
+ await controller.start();
602
+ try {
603
+ await controller.run();
604
+ }
605
+ finally {
606
+ await controller.stop();
607
+ }
608
+ };