@myagentroam/marctl 0.9.120
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 +202 -0
- package/NOTICE +4 -0
- package/bin/marctl.mjs +6 -0
- package/bin/marnodes.mjs +6 -0
- package/bin/marscp.mjs +6 -0
- package/bin/marssh.mjs +6 -0
- package/package.json +32 -0
- package/src/cli.mjs +219 -0
- package/src/client.mjs +220 -0
- package/src/config.mjs +164 -0
- package/src/direct.mjs +791 -0
- package/src/i18n/en-US.mjs +220 -0
- package/src/i18n/index.mjs +20 -0
- package/src/i18n/zh-CN.mjs +212 -0
- package/src/scp-worker.cjs +254 -0
- package/src/scp.mjs +321 -0
- package/src/terminal.mjs +661 -0
package/src/terminal.mjs
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { MarctlError } from './config.mjs';
|
|
3
|
+
import { createTranslator } from './i18n/index.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_INPUT_BYTES = 65536;
|
|
6
|
+
const DEFAULT_ROWS = 24;
|
|
7
|
+
const DEFAULT_COLS = 80;
|
|
8
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
export async function runTerminalSession({
|
|
11
|
+
client,
|
|
12
|
+
WebSocketClass,
|
|
13
|
+
nodeId,
|
|
14
|
+
command,
|
|
15
|
+
binary = false,
|
|
16
|
+
attached,
|
|
17
|
+
stdin = process.stdin,
|
|
18
|
+
stdout = process.stdout,
|
|
19
|
+
signals = process,
|
|
20
|
+
translate = createTranslator(),
|
|
21
|
+
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
22
|
+
openDirectTerminal: openDirectTerminalImpl,
|
|
23
|
+
raceConnections = false
|
|
24
|
+
}) {
|
|
25
|
+
const dimensions = getDimensions(stdout);
|
|
26
|
+
let terminalId;
|
|
27
|
+
let transport;
|
|
28
|
+
let result;
|
|
29
|
+
let primaryError;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
if (
|
|
33
|
+
binary &&
|
|
34
|
+
(command === undefined || (await client.getTerminalManifest(nodeId))?.binaryTerminal !== true)
|
|
35
|
+
)
|
|
36
|
+
throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
|
|
37
|
+
const created = await client.createTerminal({
|
|
38
|
+
nodeId,
|
|
39
|
+
...dimensions,
|
|
40
|
+
...(command === undefined ? {} : { command }),
|
|
41
|
+
...(binary ? { binary: true } : {})
|
|
42
|
+
});
|
|
43
|
+
terminalId = binary ? created?.terminalId : created;
|
|
44
|
+
if (binary && created?.binary !== true)
|
|
45
|
+
throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
|
|
46
|
+
try {
|
|
47
|
+
const openDirectTerminal =
|
|
48
|
+
openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
|
|
49
|
+
const directOptions = {
|
|
50
|
+
client,
|
|
51
|
+
WebSocketClass,
|
|
52
|
+
nodeId,
|
|
53
|
+
terminalId,
|
|
54
|
+
afterSequence: 0,
|
|
55
|
+
timeoutMs: connectionTimeoutMs
|
|
56
|
+
};
|
|
57
|
+
const canRace =
|
|
58
|
+
raceConnections &&
|
|
59
|
+
(await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
|
|
60
|
+
transport = canRace
|
|
61
|
+
? await raceTerminalConnections({
|
|
62
|
+
openDirectTerminal,
|
|
63
|
+
directOptions,
|
|
64
|
+
client,
|
|
65
|
+
WebSocketClass,
|
|
66
|
+
nodeId,
|
|
67
|
+
terminalId,
|
|
68
|
+
translate,
|
|
69
|
+
connectionTimeoutMs
|
|
70
|
+
})
|
|
71
|
+
: await openDirectTerminal(directOptions);
|
|
72
|
+
result =
|
|
73
|
+
attached === undefined
|
|
74
|
+
? await runAttachedTerminal({
|
|
75
|
+
transport,
|
|
76
|
+
terminalId,
|
|
77
|
+
commandMode: command !== undefined,
|
|
78
|
+
stdin,
|
|
79
|
+
stdout,
|
|
80
|
+
signals,
|
|
81
|
+
translate
|
|
82
|
+
})
|
|
83
|
+
: await attached({ transport, terminalId, translate });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error?.name === 'DirectUnsafeError')
|
|
86
|
+
throw new MarctlError(translate('errors.terminalDirectUnsafeFallback'));
|
|
87
|
+
if (error?.name !== 'DirectFallbackError') throw error;
|
|
88
|
+
transport = await openRelayTransport({
|
|
89
|
+
client,
|
|
90
|
+
WebSocketClass,
|
|
91
|
+
nodeId,
|
|
92
|
+
terminalId,
|
|
93
|
+
translate,
|
|
94
|
+
connectionTimeoutMs
|
|
95
|
+
});
|
|
96
|
+
result =
|
|
97
|
+
attached === undefined
|
|
98
|
+
? await runAttachedTerminal({
|
|
99
|
+
transport,
|
|
100
|
+
terminalId,
|
|
101
|
+
commandMode: command !== undefined,
|
|
102
|
+
stdin,
|
|
103
|
+
stdout,
|
|
104
|
+
signals,
|
|
105
|
+
translate
|
|
106
|
+
})
|
|
107
|
+
: await attached({ transport, terminalId, translate });
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
primaryError = toMarctlError(error, translate);
|
|
111
|
+
} finally {
|
|
112
|
+
transport?.close?.();
|
|
113
|
+
if (terminalId !== undefined) {
|
|
114
|
+
try {
|
|
115
|
+
await client.deleteTerminal({ nodeId, terminalId });
|
|
116
|
+
} catch {
|
|
117
|
+
if (primaryError === undefined && result === undefined)
|
|
118
|
+
primaryError = new MarctlError(translate('errors.terminalCloseFailed'));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (primaryError !== undefined) throw primaryError;
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function raceTerminalConnections({
|
|
128
|
+
openDirectTerminal,
|
|
129
|
+
directOptions,
|
|
130
|
+
client,
|
|
131
|
+
WebSocketClass,
|
|
132
|
+
nodeId,
|
|
133
|
+
terminalId,
|
|
134
|
+
translate,
|
|
135
|
+
connectionTimeoutMs
|
|
136
|
+
}) {
|
|
137
|
+
const abort = new AbortController();
|
|
138
|
+
const relayAbort = new AbortController();
|
|
139
|
+
let selected;
|
|
140
|
+
let announce;
|
|
141
|
+
const first = new Promise((resolve) => {
|
|
142
|
+
announce = resolve;
|
|
143
|
+
});
|
|
144
|
+
const select = (candidate) => {
|
|
145
|
+
if (selected) return false;
|
|
146
|
+
selected = candidate;
|
|
147
|
+
announce(candidate);
|
|
148
|
+
if (candidate === 'relay') abort.abort();
|
|
149
|
+
else relayAbort.abort();
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
const direct = openDirectTerminal({
|
|
153
|
+
...directOptions,
|
|
154
|
+
signal: abort.signal,
|
|
155
|
+
onTransportReady: () => {
|
|
156
|
+
if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
|
|
157
|
+
}
|
|
158
|
+
}).then(
|
|
159
|
+
(value) => ({ value }),
|
|
160
|
+
(error) => ({ error })
|
|
161
|
+
);
|
|
162
|
+
const relay = prepareRelayTransport({
|
|
163
|
+
client,
|
|
164
|
+
WebSocketClass,
|
|
165
|
+
nodeId,
|
|
166
|
+
terminalId,
|
|
167
|
+
translate,
|
|
168
|
+
connectionTimeoutMs,
|
|
169
|
+
signal: relayAbort.signal
|
|
170
|
+
}).then(
|
|
171
|
+
(value) => {
|
|
172
|
+
if (!select('relay')) value.close();
|
|
173
|
+
return { value };
|
|
174
|
+
},
|
|
175
|
+
(error) => ({ error })
|
|
176
|
+
);
|
|
177
|
+
const winner = await Promise.race([
|
|
178
|
+
first,
|
|
179
|
+
Promise.all([direct, relay]).then(() => selected ?? 'none')
|
|
180
|
+
]);
|
|
181
|
+
if (winner === 'direct') {
|
|
182
|
+
const result = await direct;
|
|
183
|
+
if (result.error) throw result.error;
|
|
184
|
+
return result.value;
|
|
185
|
+
}
|
|
186
|
+
if (winner === 'relay') {
|
|
187
|
+
const result = await relay;
|
|
188
|
+
if (result.error) throw result.error;
|
|
189
|
+
return result.value.activate();
|
|
190
|
+
}
|
|
191
|
+
const [directResult, relayResult] = await Promise.all([direct, relay]);
|
|
192
|
+
throw relayResult.error ?? directResult.error;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function splitInput(text, maxBytes = MAX_INPUT_BYTES) {
|
|
196
|
+
const value = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
|
|
197
|
+
const chunks = [];
|
|
198
|
+
let chunk = '';
|
|
199
|
+
let chunkBytes = 0;
|
|
200
|
+
|
|
201
|
+
for (const character of value) {
|
|
202
|
+
const characterBytes = Buffer.byteLength(character);
|
|
203
|
+
if (chunk !== '' && chunkBytes + characterBytes > maxBytes) {
|
|
204
|
+
chunks.push(chunk);
|
|
205
|
+
chunk = '';
|
|
206
|
+
chunkBytes = 0;
|
|
207
|
+
}
|
|
208
|
+
if (characterBytes > maxBytes) continue;
|
|
209
|
+
chunk += character;
|
|
210
|
+
chunkBytes += characterBytes;
|
|
211
|
+
}
|
|
212
|
+
if (chunk !== '') chunks.push(chunk);
|
|
213
|
+
return chunks;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getDimensions(stdout) {
|
|
217
|
+
const rows = Number.isSafeInteger(stdout.rows) && stdout.rows > 0 ? stdout.rows : DEFAULT_ROWS;
|
|
218
|
+
const cols =
|
|
219
|
+
Number.isSafeInteger(stdout.columns) && stdout.columns > 0 ? stdout.columns : DEFAULT_COLS;
|
|
220
|
+
return { rows: Math.min(rows, 1000), cols: Math.min(cols, 1000) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function openRelayTransport({
|
|
224
|
+
client,
|
|
225
|
+
WebSocketClass,
|
|
226
|
+
nodeId,
|
|
227
|
+
terminalId,
|
|
228
|
+
translate,
|
|
229
|
+
connectionTimeoutMs
|
|
230
|
+
}) {
|
|
231
|
+
const ticket = await client.createAttachTicket({ nodeId, terminalId });
|
|
232
|
+
const socket = client.openTerminalWebSocket(WebSocketClass, { terminalId, ticket });
|
|
233
|
+
const transport = new BufferedRelayTransport({
|
|
234
|
+
socket,
|
|
235
|
+
terminalId,
|
|
236
|
+
translate,
|
|
237
|
+
connectionTimeoutMs
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
await transport.waitForAttachment();
|
|
241
|
+
return transport;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
transport.close();
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function prepareRelayTransport({
|
|
249
|
+
client,
|
|
250
|
+
WebSocketClass,
|
|
251
|
+
nodeId,
|
|
252
|
+
terminalId,
|
|
253
|
+
translate,
|
|
254
|
+
connectionTimeoutMs,
|
|
255
|
+
signal
|
|
256
|
+
}) {
|
|
257
|
+
const ticket = await client.createAttachTicket({ nodeId, terminalId });
|
|
258
|
+
if (signal?.aborted) throw new MarctlError(translate('errors.terminalAttachConnection'));
|
|
259
|
+
const socket = client.openTerminalWebSocket(WebSocketClass, {
|
|
260
|
+
terminalId,
|
|
261
|
+
ticket,
|
|
262
|
+
deferred: true
|
|
263
|
+
});
|
|
264
|
+
// Keep an error listener after readiness until the socket is discarded.
|
|
265
|
+
socket.on('error', () => {});
|
|
266
|
+
let activated = false;
|
|
267
|
+
const ready = new Promise((resolve, reject) => {
|
|
268
|
+
const timer = setTimeout(() => fail(), connectionTimeoutMs);
|
|
269
|
+
const cleanup = () => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
socket.removeListener?.('message', onMessage);
|
|
272
|
+
socket.removeListener?.('error', fail);
|
|
273
|
+
socket.removeListener?.('close', fail);
|
|
274
|
+
signal?.removeEventListener('abort', fail);
|
|
275
|
+
};
|
|
276
|
+
const fail = () => {
|
|
277
|
+
cleanup();
|
|
278
|
+
try {
|
|
279
|
+
socket.close();
|
|
280
|
+
} catch {
|
|
281
|
+
/* Connection not yet established. */
|
|
282
|
+
}
|
|
283
|
+
reject(new MarctlError(translate('errors.terminalAttachConnection')));
|
|
284
|
+
};
|
|
285
|
+
const onMessage = (raw) => {
|
|
286
|
+
let frame;
|
|
287
|
+
try {
|
|
288
|
+
frame = JSON.parse(String(raw));
|
|
289
|
+
} catch {
|
|
290
|
+
return fail();
|
|
291
|
+
}
|
|
292
|
+
if (frame?.type !== 'ready' || frame.terminalId !== terminalId) return fail();
|
|
293
|
+
cleanup();
|
|
294
|
+
resolve();
|
|
295
|
+
};
|
|
296
|
+
socket.on('message', onMessage);
|
|
297
|
+
socket.on('error', fail);
|
|
298
|
+
socket.on('close', fail);
|
|
299
|
+
signal?.addEventListener('abort', fail, { once: true });
|
|
300
|
+
if (signal?.aborted) fail();
|
|
301
|
+
});
|
|
302
|
+
await ready;
|
|
303
|
+
return {
|
|
304
|
+
async activate() {
|
|
305
|
+
if (activated || socket.readyState !== 1)
|
|
306
|
+
throw new MarctlError(translate('errors.terminalAttachConnection'));
|
|
307
|
+
activated = true;
|
|
308
|
+
const transport = new BufferedRelayTransport({
|
|
309
|
+
socket,
|
|
310
|
+
terminalId,
|
|
311
|
+
translate,
|
|
312
|
+
connectionTimeoutMs
|
|
313
|
+
});
|
|
314
|
+
try {
|
|
315
|
+
socket.send(JSON.stringify({ type: 'activate', terminalId }));
|
|
316
|
+
await transport.waitForAttachment();
|
|
317
|
+
return transport;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
transport.close();
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
close() {
|
|
324
|
+
socket.close();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
class BufferedRelayTransport extends EventEmitter {
|
|
330
|
+
#socket;
|
|
331
|
+
#terminalId;
|
|
332
|
+
#translate;
|
|
333
|
+
#attached = false;
|
|
334
|
+
#active = false;
|
|
335
|
+
#pendingEvents = [];
|
|
336
|
+
#attachmentResolve;
|
|
337
|
+
#attachmentReject;
|
|
338
|
+
#openTimer;
|
|
339
|
+
#attachTimer;
|
|
340
|
+
#closed = false;
|
|
341
|
+
#onOpen;
|
|
342
|
+
#onMessage;
|
|
343
|
+
#onError;
|
|
344
|
+
#onClose;
|
|
345
|
+
|
|
346
|
+
constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
|
|
347
|
+
super();
|
|
348
|
+
this.#socket = socket;
|
|
349
|
+
this.#terminalId = terminalId;
|
|
350
|
+
this.#translate = translate;
|
|
351
|
+
this.#onOpen = () => {
|
|
352
|
+
clearTimeout(this.#openTimer);
|
|
353
|
+
this.#armAttachTimer(connectionTimeoutMs);
|
|
354
|
+
};
|
|
355
|
+
this.#onMessage = (raw, isBinary) => {
|
|
356
|
+
try {
|
|
357
|
+
const frame = parseFrame(raw, isBinary, this.#terminalId, this.#translate);
|
|
358
|
+
if (!this.#attached) {
|
|
359
|
+
if (frame.type === 'attach') {
|
|
360
|
+
this.#attached = true;
|
|
361
|
+
clearTimeout(this.#attachTimer);
|
|
362
|
+
this.#attachmentResolve?.();
|
|
363
|
+
this.#attachmentResolve = undefined;
|
|
364
|
+
this.#attachmentReject = undefined;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (frame.type === 'error')
|
|
368
|
+
throw new MarctlError(this.#translate('errors.terminalRemoteError'));
|
|
369
|
+
this.#queueOrEmit('message', raw, isBinary);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.#queueOrEmit('message', raw, isBinary);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
this.#fail(
|
|
375
|
+
error instanceof MarctlError
|
|
376
|
+
? error
|
|
377
|
+
: new MarctlError(this.#translate('errors.terminalFrameInvalid'))
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
this.#onError = () => {
|
|
382
|
+
const error = new MarctlError(
|
|
383
|
+
this.#translate(
|
|
384
|
+
this.#attached ? 'errors.terminalConnectionFailed' : 'errors.terminalAttachConnection'
|
|
385
|
+
)
|
|
386
|
+
);
|
|
387
|
+
if (this.#attached) this.#queueOrEmit('error', error);
|
|
388
|
+
else this.#fail(error);
|
|
389
|
+
};
|
|
390
|
+
this.#onClose = () => {
|
|
391
|
+
const error = new MarctlError(
|
|
392
|
+
this.#translate(
|
|
393
|
+
this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
if (this.#attached) this.#queueOrEmit('close');
|
|
397
|
+
else this.#fail(error);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
socket.on?.('open', this.#onOpen);
|
|
401
|
+
socket.on?.('message', this.#onMessage);
|
|
402
|
+
socket.on?.('error', this.#onError);
|
|
403
|
+
socket.on?.('close', this.#onClose);
|
|
404
|
+
if (socket.readyState === 1) this.#onOpen();
|
|
405
|
+
else
|
|
406
|
+
this.#openTimer = setTimeout(
|
|
407
|
+
() => this.#fail(new MarctlError(this.#translate('errors.terminalConnectionOpen'))),
|
|
408
|
+
connectionTimeoutMs
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
waitForAttachment() {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
if (this.#closed) {
|
|
415
|
+
reject(new MarctlError(this.#translate('errors.terminalAttachLost')));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.#attachmentResolve = resolve;
|
|
419
|
+
this.#attachmentReject = reject;
|
|
420
|
+
if (this.#attached) {
|
|
421
|
+
this.#attachmentResolve = undefined;
|
|
422
|
+
this.#attachmentReject = undefined;
|
|
423
|
+
resolve();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
activate() {
|
|
429
|
+
if (this.#active) return;
|
|
430
|
+
this.#active = true;
|
|
431
|
+
const pendingEvents = this.#pendingEvents;
|
|
432
|
+
this.#pendingEvents = [];
|
|
433
|
+
for (const { type, args } of pendingEvents) this.emit(type, ...args);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
get readyState() {
|
|
437
|
+
return this.#socket.readyState;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
send(value) {
|
|
441
|
+
if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
|
|
442
|
+
this.#socket.send(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
close() {
|
|
446
|
+
if (this.#closed) return;
|
|
447
|
+
this.#closed = true;
|
|
448
|
+
clearTimeout(this.#openTimer);
|
|
449
|
+
clearTimeout(this.#attachTimer);
|
|
450
|
+
this.#socket.removeListener?.('open', this.#onOpen);
|
|
451
|
+
this.#socket.removeListener?.('message', this.#onMessage);
|
|
452
|
+
this.#socket.removeListener?.('error', this.#onError);
|
|
453
|
+
this.#socket.removeListener?.('close', this.#onClose);
|
|
454
|
+
try {
|
|
455
|
+
this.#socket.close();
|
|
456
|
+
} catch {
|
|
457
|
+
// The connection is already unavailable.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#armAttachTimer(connectionTimeoutMs) {
|
|
462
|
+
if (this.#attached) return;
|
|
463
|
+
this.#attachTimer = setTimeout(
|
|
464
|
+
() => this.#fail(new MarctlError(this.#translate('errors.terminalAttachTimeout'))),
|
|
465
|
+
connectionTimeoutMs
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#fail(error) {
|
|
470
|
+
if (this.#closed) return;
|
|
471
|
+
clearTimeout(this.#openTimer);
|
|
472
|
+
clearTimeout(this.#attachTimer);
|
|
473
|
+
this.#attachmentResolve = undefined;
|
|
474
|
+
const reject = this.#attachmentReject;
|
|
475
|
+
this.#attachmentReject = undefined;
|
|
476
|
+
if (!this.#attached) reject?.(error);
|
|
477
|
+
else this.#queueOrEmit('error', error);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#queueOrEmit(type, ...args) {
|
|
481
|
+
if (this.#active) {
|
|
482
|
+
if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.#pendingEvents.push({ type, args });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function runAttachedTerminal({
|
|
490
|
+
transport,
|
|
491
|
+
terminalId,
|
|
492
|
+
commandMode,
|
|
493
|
+
stdin,
|
|
494
|
+
stdout,
|
|
495
|
+
signals,
|
|
496
|
+
translate
|
|
497
|
+
}) {
|
|
498
|
+
return new Promise((resolve, reject) => {
|
|
499
|
+
let settled = false;
|
|
500
|
+
let inputEnabled = false;
|
|
501
|
+
let rawModeEnabled = false;
|
|
502
|
+
|
|
503
|
+
const cleanup = () => {
|
|
504
|
+
transport.removeListener?.('message', onMessage);
|
|
505
|
+
transport.removeListener?.('error', onError);
|
|
506
|
+
transport.removeListener?.('close', onClose);
|
|
507
|
+
stdin.removeListener?.('data', onInput);
|
|
508
|
+
stdout.removeListener?.('resize', onResize);
|
|
509
|
+
signals.removeListener?.('SIGINT', onSigint);
|
|
510
|
+
signals.removeListener?.('SIGTERM', onSigterm);
|
|
511
|
+
if (rawModeEnabled) {
|
|
512
|
+
try {
|
|
513
|
+
stdin.setRawMode(false);
|
|
514
|
+
} catch {
|
|
515
|
+
// The terminal may already be gone.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
stdin.pause?.();
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const finish = (error, exitCode) => {
|
|
522
|
+
if (settled) return;
|
|
523
|
+
settled = true;
|
|
524
|
+
cleanup();
|
|
525
|
+
sendCloseFrame(transport, terminalId);
|
|
526
|
+
transport.close?.();
|
|
527
|
+
if (error === undefined) resolve(exitCode);
|
|
528
|
+
else reject(error);
|
|
529
|
+
};
|
|
530
|
+
const fail = (key) => finish(new MarctlError(translate(key)));
|
|
531
|
+
const send = (frame) => {
|
|
532
|
+
transport.send(JSON.stringify(frame));
|
|
533
|
+
};
|
|
534
|
+
const onInput = (data) => {
|
|
535
|
+
try {
|
|
536
|
+
for (const chunk of splitInput(data)) send({ type: 'input', terminalId, data: chunk });
|
|
537
|
+
} catch {
|
|
538
|
+
fail('errors.terminalInputFailed');
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const onResize = () => {
|
|
542
|
+
try {
|
|
543
|
+
const { rows, cols } = getDimensions(stdout);
|
|
544
|
+
send({ type: 'resize', terminalId, rows, cols });
|
|
545
|
+
} catch {
|
|
546
|
+
fail('errors.terminalResizeFailed');
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
const onMessage = (raw, isBinary) => {
|
|
550
|
+
try {
|
|
551
|
+
const frame = parseFrame(raw, isBinary, terminalId, translate);
|
|
552
|
+
if (frame.type === 'output') stdout.write(frame.data);
|
|
553
|
+
else if (frame.type === 'exit') {
|
|
554
|
+
finish(undefined, frame.exitCode === null ? 1 : frame.exitCode);
|
|
555
|
+
} else if (frame.type === 'error') fail('errors.terminalRemoteError');
|
|
556
|
+
else fail('errors.terminalUnexpectedFrame');
|
|
557
|
+
} catch (error) {
|
|
558
|
+
finish(
|
|
559
|
+
error instanceof MarctlError
|
|
560
|
+
? error
|
|
561
|
+
: new MarctlError(translate('errors.terminalFrameInvalid'))
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const onError = () => finish(new MarctlError(translate('errors.terminalConnectionFailed')));
|
|
566
|
+
const onClose = () => finish(new MarctlError(translate('errors.terminalConnectionLost')));
|
|
567
|
+
const onSigint = () => finish(undefined, 130);
|
|
568
|
+
const onSigterm = () => finish(undefined, 143);
|
|
569
|
+
const startInput = () => {
|
|
570
|
+
if (settled || inputEnabled) return;
|
|
571
|
+
inputEnabled = true;
|
|
572
|
+
try {
|
|
573
|
+
if (!commandMode) {
|
|
574
|
+
if (typeof stdin.setRawMode !== 'function') {
|
|
575
|
+
fail('errors.terminalInteractiveRequired');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
stdin.setRawMode(true);
|
|
579
|
+
rawModeEnabled = true;
|
|
580
|
+
}
|
|
581
|
+
stdin.resume?.();
|
|
582
|
+
stdin.on?.('data', onInput);
|
|
583
|
+
stdout.on?.('resize', onResize);
|
|
584
|
+
onResize();
|
|
585
|
+
} catch {
|
|
586
|
+
fail(commandMode ? 'errors.terminalSessionFailed' : 'errors.terminalInteractiveInit');
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
transport.on?.('message', onMessage);
|
|
590
|
+
transport.on?.('error', onError);
|
|
591
|
+
transport.on?.('close', onClose);
|
|
592
|
+
signals.on?.('SIGINT', onSigint);
|
|
593
|
+
signals.on?.('SIGTERM', onSigterm);
|
|
594
|
+
transport.activate?.();
|
|
595
|
+
startInput();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function parseFrame(raw, isBinary, terminalId, translate) {
|
|
600
|
+
if (isBinary) throw new MarctlError(translate('errors.terminalBinaryFrame'));
|
|
601
|
+
let frame;
|
|
602
|
+
try {
|
|
603
|
+
frame = JSON.parse(rawToString(raw));
|
|
604
|
+
} catch {
|
|
605
|
+
throw new MarctlError(translate('errors.terminalInvalidJson'));
|
|
606
|
+
}
|
|
607
|
+
if (frame === null || typeof frame !== 'object' || frame.terminalId !== terminalId)
|
|
608
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
609
|
+
|
|
610
|
+
if (frame.type === 'attach') {
|
|
611
|
+
if (!Number.isSafeInteger(frame.afterSequence) || frame.afterSequence < 0)
|
|
612
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
613
|
+
return frame;
|
|
614
|
+
}
|
|
615
|
+
if (frame.type === 'output') {
|
|
616
|
+
if (
|
|
617
|
+
!Number.isSafeInteger(frame.sequence) ||
|
|
618
|
+
frame.sequence < 1 ||
|
|
619
|
+
typeof frame.data !== 'string' ||
|
|
620
|
+
Buffer.byteLength(frame.data) > MAX_INPUT_BYTES
|
|
621
|
+
)
|
|
622
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
623
|
+
return frame;
|
|
624
|
+
}
|
|
625
|
+
if (frame.type === 'exit') {
|
|
626
|
+
if (frame.exitCode !== null && !Number.isSafeInteger(frame.exitCode))
|
|
627
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
628
|
+
return frame;
|
|
629
|
+
}
|
|
630
|
+
if (frame.type === 'error' && typeof frame.code === 'string' && frame.code.length > 0)
|
|
631
|
+
return frame;
|
|
632
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function rawToString(raw) {
|
|
636
|
+
if (typeof raw === 'string') return raw;
|
|
637
|
+
if (Buffer.isBuffer(raw)) return raw.toString('utf8');
|
|
638
|
+
if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
|
|
639
|
+
if (ArrayBuffer.isView(raw))
|
|
640
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
|
641
|
+
return String(raw);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function sendCloseFrame(socket, terminalId) {
|
|
645
|
+
if (!terminalId || !isSocketOpen(socket)) return;
|
|
646
|
+
try {
|
|
647
|
+
socket.send(JSON.stringify({ type: 'close', terminalId }));
|
|
648
|
+
} catch {
|
|
649
|
+
// The DELETE request below remains the authoritative cleanup path.
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isSocketOpen(socket) {
|
|
654
|
+
return socket.readyState === undefined || socket.readyState === 1;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function toMarctlError(error, translate) {
|
|
658
|
+
return error instanceof MarctlError
|
|
659
|
+
? error
|
|
660
|
+
: new MarctlError(translate('errors.terminalSessionFailed'));
|
|
661
|
+
}
|