@myagentroam/marctl 0.11.3 → 0.11.5

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,177 @@
1
+ import { MarctlError } from './errors.mjs';
2
+ import { DirectRejectedError } from './direct-errors.mjs';
3
+ import { parseFrame } from './terminal-frame.mjs';
4
+
5
+ const MAX_INPUT_BYTES = 65536;
6
+ const DEFAULT_ROWS = 24;
7
+ const DEFAULT_COLS = 80;
8
+
9
+ export function splitInput(text, maxBytes = MAX_INPUT_BYTES) {
10
+ const value = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
11
+ const chunks = [];
12
+ let chunk = '';
13
+ let chunkBytes = 0;
14
+
15
+ for (const character of value) {
16
+ const characterBytes = Buffer.byteLength(character);
17
+ if (chunk !== '' && chunkBytes + characterBytes > maxBytes) {
18
+ chunks.push(chunk);
19
+ chunk = '';
20
+ chunkBytes = 0;
21
+ }
22
+ if (characterBytes > maxBytes) continue;
23
+ chunk += character;
24
+ chunkBytes += characterBytes;
25
+ }
26
+ if (chunk !== '') chunks.push(chunk);
27
+ return chunks;
28
+ }
29
+
30
+ export function runAttachedTerminal({
31
+ transport,
32
+ terminalId,
33
+ commandMode,
34
+ stdin,
35
+ stdout,
36
+ signal,
37
+ translate
38
+ }) {
39
+ return new Promise((resolve, reject) => {
40
+ let settled = false;
41
+ let inputEnabled = false;
42
+ let rawModeEnabled = false;
43
+ let inputTasks = 0;
44
+
45
+ const cleanup = () => {
46
+ transport.removeListener?.('message', onMessage);
47
+ transport.removeListener?.('error', onError);
48
+ transport.removeListener?.('close', onClose);
49
+ stdin.removeListener?.('data', onInput);
50
+ stdout.removeListener?.('resize', onResize);
51
+ signal?.removeEventListener('abort', onAbort);
52
+ if (rawModeEnabled) {
53
+ try {
54
+ stdin.setRawMode(false);
55
+ } catch {
56
+ // The terminal may already be gone.
57
+ }
58
+ }
59
+ stdin.pause?.();
60
+ };
61
+
62
+ const finish = (error, exitCode) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ cleanup();
66
+ sendCloseFrame(transport, terminalId);
67
+ transport.close?.();
68
+ if (error === undefined) resolve(exitCode);
69
+ else reject(error);
70
+ };
71
+ const fail = (key) => finish(new MarctlError(translate(key)));
72
+ const send = (frame) => {
73
+ return transport.send(JSON.stringify(frame));
74
+ };
75
+ const onInput = async (data) => {
76
+ inputTasks += 1;
77
+ stdin.pause?.();
78
+ try {
79
+ for (const chunk of splitInput(data)) {
80
+ if (settled) return;
81
+ await send({ type: 'input', terminalId, data: chunk });
82
+ }
83
+ } catch {
84
+ fail('errors.terminalInputFailed');
85
+ } finally {
86
+ inputTasks -= 1;
87
+ if (!settled && inputTasks === 0) stdin.resume?.();
88
+ }
89
+ };
90
+ const onResize = () => {
91
+ try {
92
+ const { rows, cols } = getDimensions(stdout);
93
+ Promise.resolve(send({ type: 'resize', terminalId, rows, cols })).catch(() =>
94
+ fail('errors.terminalResizeFailed')
95
+ );
96
+ } catch {
97
+ fail('errors.terminalResizeFailed');
98
+ }
99
+ };
100
+ const onMessage = (raw, isBinary) => {
101
+ try {
102
+ const frame = parseFrame(raw, isBinary, terminalId, translate);
103
+ if (frame.type === 'output') stdout.write(frame.data);
104
+ else if (frame.type === 'exit') {
105
+ finish(undefined, frame.exitCode === null ? 1 : frame.exitCode);
106
+ } else if (frame.type === 'error') fail('errors.terminalRemoteError');
107
+ else fail('errors.terminalUnexpectedFrame');
108
+ } catch (error) {
109
+ finish(
110
+ error instanceof MarctlError
111
+ ? error
112
+ : new MarctlError(translate('errors.terminalFrameInvalid'))
113
+ );
114
+ }
115
+ };
116
+ const onError = (error) =>
117
+ finish(
118
+ error instanceof DirectRejectedError
119
+ ? new MarctlError(`${translate('errors.terminalConnectionFailed')} [${error.code}]`, {
120
+ code: error.code
121
+ })
122
+ : new MarctlError(translate('errors.terminalConnectionFailed'))
123
+ );
124
+ const onClose = () => finish(new MarctlError(translate('errors.terminalConnectionLost')));
125
+ const onAbort = () => finish(undefined, signal.reason === 'SIGTERM' ? 143 : 130);
126
+ const startInput = () => {
127
+ if (settled || inputEnabled) return;
128
+ inputEnabled = true;
129
+ try {
130
+ if (!commandMode) {
131
+ if (typeof stdin.setRawMode !== 'function') {
132
+ fail('errors.terminalInteractiveRequired');
133
+ return;
134
+ }
135
+ stdin.setRawMode(true);
136
+ rawModeEnabled = true;
137
+ }
138
+ stdin.resume?.();
139
+ stdin.on?.('data', onInput);
140
+ stdout.on?.('resize', onResize);
141
+ onResize();
142
+ } catch {
143
+ fail(commandMode ? 'errors.terminalSessionFailed' : 'errors.terminalInteractiveInit');
144
+ }
145
+ };
146
+ transport.on?.('message', onMessage);
147
+ transport.on?.('error', onError);
148
+ transport.on?.('close', onClose);
149
+ signal?.addEventListener('abort', onAbort, { once: true });
150
+ if (signal?.aborted) {
151
+ onAbort();
152
+ return;
153
+ }
154
+ transport.activate?.();
155
+ startInput();
156
+ });
157
+ }
158
+
159
+ function sendCloseFrame(socket, terminalId) {
160
+ if (!terminalId || !isSocketOpen(socket)) return;
161
+ try {
162
+ Promise.resolve(socket.send(JSON.stringify({ type: 'close', terminalId }))).catch(() => {});
163
+ } catch {
164
+ // The DELETE request below remains the authoritative cleanup path.
165
+ }
166
+ }
167
+
168
+ function isSocketOpen(socket) {
169
+ return socket.readyState === undefined || socket.readyState === 1;
170
+ }
171
+
172
+ export function getDimensions(stdout) {
173
+ const rows = Number.isSafeInteger(stdout.rows) && stdout.rows > 0 ? stdout.rows : DEFAULT_ROWS;
174
+ const cols =
175
+ Number.isSafeInteger(stdout.columns) && stdout.columns > 0 ? stdout.columns : DEFAULT_COLS;
176
+ return { rows: Math.min(rows, 1000), cols: Math.min(cols, 1000) };
177
+ }
@@ -0,0 +1,117 @@
1
+ import { prepareRelayTransport } from './terminal-relay.mjs';
2
+ import { DirectFallbackError, DirectRejectedError } from './direct-errors.mjs';
3
+
4
+ export async function raceTerminalConnections({
5
+ openDirectTerminal,
6
+ directOptions,
7
+ client,
8
+ WebSocketClass,
9
+ nodeId,
10
+ terminalId,
11
+ translate,
12
+ connectionTimeoutMs,
13
+ signal
14
+ }) {
15
+ if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
16
+ const abort = new AbortController();
17
+ const relayAbort = new AbortController();
18
+ const preparing = new Set(['direct', 'relay']);
19
+ let start;
20
+ const preparation = new Promise((resolve) => {
21
+ start = resolve;
22
+ });
23
+ const prepared = (candidate) => {
24
+ preparing.delete(candidate);
25
+ if (preparing.size === 0) start();
26
+ return preparation;
27
+ };
28
+ let selected;
29
+ let announce;
30
+ const first = new Promise((resolve) => {
31
+ announce = resolve;
32
+ });
33
+ const select = (candidate) => {
34
+ if (selected) return false;
35
+ selected = candidate;
36
+ announce(candidate);
37
+ if (candidate === 'relay') abort.abort();
38
+ else relayAbort.abort();
39
+ return true;
40
+ };
41
+ const cancel = () => {
42
+ abort.abort();
43
+ relayAbort.abort();
44
+ if (selected) return;
45
+ selected = 'cancel';
46
+ start();
47
+ announce('cancel');
48
+ };
49
+ signal?.addEventListener('abort', cancel, { once: true });
50
+ const direct = openDirectTerminal({
51
+ ...directOptions,
52
+ signal: abort.signal,
53
+ onPrepared: () => prepared('direct'),
54
+ onRejected: () => select('direct'),
55
+ onIceConnected: () => {
56
+ if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
57
+ },
58
+ onTransportReady: () => {
59
+ if (selected !== 'direct') throw new Error('DIRECT_RACE_CANCELLED');
60
+ }
61
+ }).then(
62
+ (value) => {
63
+ if (selected === 'cancel' || signal?.aborted) value.close();
64
+ return { value };
65
+ },
66
+ (error) => {
67
+ prepared('direct');
68
+ if (error instanceof DirectRejectedError) select('direct');
69
+ return { error };
70
+ }
71
+ );
72
+ const relay = prepareRelayTransport({
73
+ client,
74
+ WebSocketClass,
75
+ nodeId,
76
+ terminalId,
77
+ translate,
78
+ connectionTimeoutMs,
79
+ signal: relayAbort.signal,
80
+ onPrepared: () => prepared('relay')
81
+ }).then(
82
+ (value) => {
83
+ if (!select('relay')) value.close();
84
+ return { value };
85
+ },
86
+ (error) => {
87
+ prepared('relay');
88
+ return { error };
89
+ }
90
+ );
91
+ const winner = await Promise.race([
92
+ first,
93
+ Promise.all([direct, relay]).then(() => selected ?? 'none')
94
+ ]);
95
+ if (winner === 'cancel' || signal?.aborted) {
96
+ signal?.removeEventListener('abort', cancel);
97
+ abort.abort();
98
+ relayAbort.abort();
99
+ throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
100
+ }
101
+ if (winner === 'direct') {
102
+ const result = await direct;
103
+ signal?.removeEventListener('abort', cancel);
104
+ if (result.error) throw result.error;
105
+ return result.value;
106
+ }
107
+ if (winner === 'relay') {
108
+ const result = await relay;
109
+ signal?.removeEventListener('abort', cancel);
110
+ if (result.error) throw result.error;
111
+ return result.value.activate(signal);
112
+ }
113
+ const [directResult, relayResult] = await Promise.all([direct, relay]);
114
+ signal?.removeEventListener('abort', cancel);
115
+ if (directResult.error instanceof DirectRejectedError) throw directResult.error;
116
+ throw relayResult.error ?? directResult.error;
117
+ }
@@ -0,0 +1,305 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { MarctlError } from './errors.mjs';
3
+ import { TerminalRelaySender } from './terminal-relay-sender.mjs';
4
+ import { parseFrame } from './terminal-frame.mjs';
5
+ import { DirectFallbackError } from './direct-errors.mjs';
6
+
7
+ export async function openRelayTransport({
8
+ client,
9
+ WebSocketClass,
10
+ nodeId,
11
+ terminalId,
12
+ translate,
13
+ connectionTimeoutMs,
14
+ signal
15
+ }) {
16
+ const ticket = await client.createAttachTicket({ nodeId, terminalId });
17
+ if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
18
+ const socket = client.openTerminalWebSocket(WebSocketClass, { terminalId, ticket });
19
+ const transport = new BufferedRelayTransport({
20
+ socket,
21
+ terminalId,
22
+ translate,
23
+ connectionTimeoutMs
24
+ });
25
+ try {
26
+ await waitForAttachment(transport, signal);
27
+ return transport;
28
+ } catch (error) {
29
+ transport.close();
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export async function prepareRelayTransport({
35
+ client,
36
+ WebSocketClass,
37
+ nodeId,
38
+ terminalId,
39
+ translate,
40
+ connectionTimeoutMs,
41
+ signal,
42
+ onPrepared
43
+ }) {
44
+ const ticket = await client.createAttachTicket({ nodeId, terminalId });
45
+ if (onPrepared) await onPrepared();
46
+ if (signal?.aborted) throw new MarctlError(translate('errors.terminalAttachConnection'));
47
+ const socket = client.openTerminalWebSocket(WebSocketClass, {
48
+ terminalId,
49
+ ticket,
50
+ deferred: true
51
+ });
52
+ // Keep an error listener after readiness until the socket is discarded.
53
+ socket.on('error', () => {});
54
+ let activated = false;
55
+ const ready = new Promise((resolve, reject) => {
56
+ const timer = setTimeout(() => fail(), connectionTimeoutMs);
57
+ const cleanup = () => {
58
+ clearTimeout(timer);
59
+ socket.removeListener?.('message', onMessage);
60
+ socket.removeListener?.('error', fail);
61
+ socket.removeListener?.('close', fail);
62
+ signal?.removeEventListener('abort', fail);
63
+ };
64
+ const fail = () => {
65
+ cleanup();
66
+ try {
67
+ socket.close();
68
+ } catch {
69
+ /* Connection not yet established. */
70
+ }
71
+ reject(new MarctlError(translate('errors.terminalAttachConnection')));
72
+ };
73
+ const onMessage = (raw) => {
74
+ let frame;
75
+ try {
76
+ frame = JSON.parse(String(raw));
77
+ } catch {
78
+ return fail();
79
+ }
80
+ if (frame?.type !== 'ready' || frame.terminalId !== terminalId) return fail();
81
+ cleanup();
82
+ resolve();
83
+ };
84
+ socket.on('message', onMessage);
85
+ socket.on('error', fail);
86
+ socket.on('close', fail);
87
+ signal?.addEventListener('abort', fail, { once: true });
88
+ if (signal?.aborted) fail();
89
+ });
90
+ await ready;
91
+ return {
92
+ async activate(signal) {
93
+ if (signal?.aborted) {
94
+ socket.close();
95
+ throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
96
+ }
97
+ if (activated || socket.readyState !== 1)
98
+ throw new MarctlError(translate('errors.terminalAttachConnection'));
99
+ activated = true;
100
+ const transport = new BufferedRelayTransport({
101
+ socket,
102
+ terminalId,
103
+ translate,
104
+ connectionTimeoutMs
105
+ });
106
+ try {
107
+ socket.send(JSON.stringify({ type: 'activate', terminalId }));
108
+ await waitForAttachment(transport, signal);
109
+ return transport;
110
+ } catch (error) {
111
+ transport.close();
112
+ throw error;
113
+ }
114
+ },
115
+ close() {
116
+ socket.close();
117
+ }
118
+ };
119
+ }
120
+
121
+ async function waitForAttachment(transport, signal) {
122
+ if (!signal) return transport.waitForAttachment();
123
+ if (signal.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
124
+ let cancel;
125
+ const cancelled = new Promise((_, reject) => {
126
+ cancel = () => {
127
+ transport.close();
128
+ reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
129
+ };
130
+ signal.addEventListener('abort', cancel, { once: true });
131
+ });
132
+ try {
133
+ return await Promise.race([transport.waitForAttachment(), cancelled]);
134
+ } finally {
135
+ signal.removeEventListener('abort', cancel);
136
+ }
137
+ }
138
+
139
+ class BufferedRelayTransport extends EventEmitter {
140
+ #socket;
141
+ #sender;
142
+ #terminalId;
143
+ #translate;
144
+ #attached = false;
145
+ #active = false;
146
+ #pendingEvents = [];
147
+ #attachmentResolve;
148
+ #attachmentReject;
149
+ #openTimer;
150
+ #attachTimer;
151
+ #closed = false;
152
+ #onOpen;
153
+ #onMessage;
154
+ #onError;
155
+ #onClose;
156
+
157
+ constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
158
+ super();
159
+ this.#socket = socket;
160
+ this.#sender = new TerminalRelaySender(socket);
161
+ this.#terminalId = terminalId;
162
+ this.#translate = translate;
163
+ this.#onOpen = () => {
164
+ clearTimeout(this.#openTimer);
165
+ this.#armAttachTimer(connectionTimeoutMs);
166
+ };
167
+ this.#onMessage = (raw, isBinary) => {
168
+ try {
169
+ const frame = parseFrame(raw, isBinary, this.#terminalId, this.#translate);
170
+ if (!this.#attached) {
171
+ if (frame.type === 'attach') {
172
+ this.#attached = true;
173
+ clearTimeout(this.#attachTimer);
174
+ this.#attachmentResolve?.();
175
+ this.#attachmentResolve = undefined;
176
+ this.#attachmentReject = undefined;
177
+ return;
178
+ }
179
+ if (frame.type === 'error')
180
+ throw new MarctlError(this.#translate('errors.terminalRemoteError'));
181
+ this.#queueOrEmit('message', raw, isBinary);
182
+ return;
183
+ }
184
+ this.#queueOrEmit('message', raw, isBinary);
185
+ } catch (error) {
186
+ this.#fail(
187
+ error instanceof MarctlError
188
+ ? error
189
+ : new MarctlError(this.#translate('errors.terminalFrameInvalid'))
190
+ );
191
+ }
192
+ };
193
+ this.#onError = () => {
194
+ const error = new MarctlError(
195
+ this.#translate(
196
+ this.#attached ? 'errors.terminalConnectionFailed' : 'errors.terminalAttachConnection'
197
+ )
198
+ );
199
+ if (this.#attached) this.#queueOrEmit('error', error);
200
+ else this.#fail(error);
201
+ };
202
+ this.#onClose = () => {
203
+ this.#sender.close();
204
+ const error = new MarctlError(
205
+ this.#translate(
206
+ this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
207
+ )
208
+ );
209
+ if (this.#attached) this.#queueOrEmit('close');
210
+ else this.#fail(error);
211
+ };
212
+
213
+ socket.on?.('open', this.#onOpen);
214
+ socket.on?.('message', this.#onMessage);
215
+ socket.on?.('error', this.#onError);
216
+ socket.on?.('close', this.#onClose);
217
+ if (socket.readyState === 1) this.#onOpen();
218
+ else
219
+ this.#openTimer = setTimeout(
220
+ () => this.#fail(new MarctlError(this.#translate('errors.terminalConnectionOpen'))),
221
+ connectionTimeoutMs
222
+ );
223
+ }
224
+
225
+ waitForAttachment() {
226
+ return new Promise((resolve, reject) => {
227
+ if (this.#closed) {
228
+ reject(new MarctlError(this.#translate('errors.terminalAttachLost')));
229
+ return;
230
+ }
231
+ this.#attachmentResolve = resolve;
232
+ this.#attachmentReject = reject;
233
+ if (this.#attached) {
234
+ this.#attachmentResolve = undefined;
235
+ this.#attachmentReject = undefined;
236
+ resolve();
237
+ }
238
+ });
239
+ }
240
+
241
+ activate() {
242
+ if (this.#active) return;
243
+ this.#active = true;
244
+ const pendingEvents = this.#pendingEvents;
245
+ this.#pendingEvents = [];
246
+ for (const { type, args } of pendingEvents) this.emit(type, ...args);
247
+ }
248
+
249
+ get readyState() {
250
+ return this.#socket.readyState;
251
+ }
252
+
253
+ send(value) {
254
+ if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
255
+ if (JSON.parse(value).type === 'input') return this.#sender.send(value);
256
+ this.#socket.send(value);
257
+ }
258
+
259
+ close() {
260
+ if (this.#closed) return;
261
+ this.#closed = true;
262
+ this.#attachmentReject?.(new MarctlError(this.#translate('errors.terminalAttachLost')));
263
+ this.#attachmentReject = undefined;
264
+ this.#attachmentResolve = undefined;
265
+ this.#sender.close();
266
+ clearTimeout(this.#openTimer);
267
+ clearTimeout(this.#attachTimer);
268
+ this.#socket.removeListener?.('open', this.#onOpen);
269
+ this.#socket.removeListener?.('message', this.#onMessage);
270
+ this.#socket.removeListener?.('error', this.#onError);
271
+ this.#socket.removeListener?.('close', this.#onClose);
272
+ try {
273
+ this.#socket.close();
274
+ } catch {
275
+ // The connection is already unavailable.
276
+ }
277
+ }
278
+
279
+ #armAttachTimer(connectionTimeoutMs) {
280
+ if (this.#attached) return;
281
+ this.#attachTimer = setTimeout(
282
+ () => this.#fail(new MarctlError(this.#translate('errors.terminalAttachTimeout'))),
283
+ connectionTimeoutMs
284
+ );
285
+ }
286
+
287
+ #fail(error) {
288
+ if (this.#closed) return;
289
+ clearTimeout(this.#openTimer);
290
+ clearTimeout(this.#attachTimer);
291
+ this.#attachmentResolve = undefined;
292
+ const reject = this.#attachmentReject;
293
+ this.#attachmentReject = undefined;
294
+ if (!this.#attached) reject?.(error);
295
+ else this.#queueOrEmit('error', error);
296
+ }
297
+
298
+ #queueOrEmit(type, ...args) {
299
+ if (this.#active) {
300
+ if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
301
+ return;
302
+ }
303
+ this.#pendingEvents.push({ type, args });
304
+ }
305
+ }