@myagentroam/marctl 0.10.1 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/marctl",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/client.mjs CHANGED
@@ -86,6 +86,14 @@ export class MarctlClient {
86
86
  };
87
87
  }
88
88
 
89
+ async openDirectTerminal(options) {
90
+ const { openDirectTerminalProcess } = await import('./direct-process.mjs');
91
+ return openDirectTerminalProcess({
92
+ ...options,
93
+ connection: { serverUrl: this.#serverUrl, key: this.#key }
94
+ });
95
+ }
96
+
89
97
  async createAttachTicket({ nodeId, terminalId, afterSequence }) {
90
98
  validateNodeId(nodeId, this.#translate);
91
99
  validateTerminalId(terminalId, this.#translate);
@@ -0,0 +1,351 @@
1
+ import { fork } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DirectFallbackError, DirectUnsafeError } from './direct.mjs';
5
+
6
+ const CLEANUP_TIMEOUT_MS = 1000;
7
+ const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
8
+ const ipcBytes = new WeakMap();
9
+
10
+ function sendIpc(target, message, onError = () => {}, onSent = () => {}) {
11
+ const bytes = Buffer.byteLength(JSON.stringify(message));
12
+ const pending = ipcBytes.get(target) ?? 0;
13
+ if (!target.connected || pending + bytes > MAX_BUFFER_BYTES) {
14
+ onError(new Error('TERMINAL_CONNECTION_LOST'));
15
+ return false;
16
+ }
17
+ ipcBytes.set(target, pending + bytes);
18
+ try {
19
+ target.send(message, (error) => {
20
+ ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
21
+ if (error) onError(error);
22
+ else onSent();
23
+ });
24
+ return true;
25
+ } catch (error) {
26
+ ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
27
+ onError(error);
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function openDirectTerminalProcess({
33
+ connection,
34
+ nodeId,
35
+ terminalId,
36
+ manifest,
37
+ afterSequence = 0,
38
+ timeoutMs = 15000,
39
+ heartbeatMs = 10000,
40
+ onPrepared,
41
+ onIceConnected,
42
+ onTransportReady,
43
+ signal,
44
+ forkImpl = fork
45
+ }) {
46
+ if (signal?.aborted) return Promise.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
47
+ return new Promise((resolve, reject) => {
48
+ let child;
49
+ try {
50
+ child = forkImpl(new URL('./direct-process.mjs', import.meta.url), [], {
51
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
52
+ execArgv: []
53
+ });
54
+ } catch {
55
+ reject(new DirectFallbackError('DIRECT_UNAVAILABLE'));
56
+ return;
57
+ }
58
+ let settled = false;
59
+ let opening = false;
60
+ let stopping = false;
61
+ let exited = false;
62
+ let cleanupTimer;
63
+ const stop = () => {
64
+ if (stopping) return;
65
+ stopping = true;
66
+ clearTimeout(setupTimer);
67
+ signal?.removeEventListener('abort', onAbort);
68
+ if (exited) return;
69
+ sendIpc(child, { type: 'abort' });
70
+ // Native cleanup may block. Only this connection's process is terminated.
71
+ cleanupTimer = setTimeout(() => child.kill('SIGKILL'), CLEANUP_TIMEOUT_MS);
72
+ };
73
+ const transport = new IsolatedDirectTransport(child, stop);
74
+ const fail = (error) => {
75
+ if (settled) {
76
+ transport.fail(error);
77
+ } else {
78
+ settled = true;
79
+ reject(error);
80
+ stop();
81
+ }
82
+ };
83
+ const setupError = () =>
84
+ opening
85
+ ? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
86
+ : new DirectFallbackError('DIRECT_UNAVAILABLE');
87
+ const setupTimer = setTimeout(() => fail(setupError()), timeoutMs);
88
+ const onAbort = () =>
89
+ fail(
90
+ opening
91
+ ? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
92
+ : new DirectFallbackError('DIRECT_RACE_CANCELLED')
93
+ );
94
+ child.on('message', (message) => {
95
+ if (message.type === 'callback') {
96
+ const callback = { prepared: onPrepared, ice: onIceConnected, transport: onTransportReady }[
97
+ message.event
98
+ ];
99
+ if (message.event === 'opening') opening = true;
100
+ Promise.resolve()
101
+ .then(() => {
102
+ if (stopping) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
103
+ return callback?.();
104
+ })
105
+ .then(
106
+ () =>
107
+ sendIpc(child, { type: 'callback.result', id: message.id }, () => fail(setupError())),
108
+ () => sendIpc(child, { type: 'callback.result', id: message.id, failed: true })
109
+ );
110
+ } else if (message.type === 'attached' && !settled) {
111
+ settled = true;
112
+ clearTimeout(setupTimer);
113
+ resolve(transport);
114
+ } else if (message.type === 'failure') {
115
+ const error = settled
116
+ ? new Error(message.code ?? 'TERMINAL_CONNECTION_LOST')
117
+ : message.name === 'DirectUnsafeError' ||
118
+ (opening && message.name !== 'DirectFallbackError')
119
+ ? new DirectUnsafeError(message.code)
120
+ : new DirectFallbackError(message.code);
121
+ fail(error);
122
+ } else if (message.type === 'frame') {
123
+ transport.frame(message.frame, message.isBinary);
124
+ } else if (message.type === 'send.result') {
125
+ transport.written(message.id, message.code);
126
+ } else if (message.type === 'finished') {
127
+ clearTimeout(cleanupTimer);
128
+ }
129
+ });
130
+ child.on('error', () => fail(setupError()));
131
+ child.on('disconnect', () => {
132
+ if (!stopping) fail(setupError());
133
+ });
134
+ child.on('exit', () => {
135
+ exited = true;
136
+ clearTimeout(cleanupTimer);
137
+ clearTimeout(setupTimer);
138
+ signal?.removeEventListener('abort', onAbort);
139
+ if (!stopping) fail(setupError());
140
+ });
141
+ signal?.addEventListener('abort', onAbort, { once: true });
142
+ sendIpc(
143
+ child,
144
+ {
145
+ type: 'start',
146
+ options: { connection, nodeId, terminalId, manifest, afterSequence, timeoutMs, heartbeatMs }
147
+ },
148
+ () => fail(setupError())
149
+ );
150
+ if (signal?.aborted) onAbort();
151
+ });
152
+ }
153
+
154
+ class IsolatedDirectTransport extends EventEmitter {
155
+ #child;
156
+ #stop;
157
+ #active = false;
158
+ #closed = false;
159
+ #events = [];
160
+ #bytes = 0;
161
+ #writes = new Map();
162
+ #writeBytes = 0;
163
+ #nextId = 0;
164
+
165
+ constructor(child, stop) {
166
+ super();
167
+ this.#child = child;
168
+ this.#stop = stop;
169
+ }
170
+
171
+ activate() {
172
+ if (this.#active) return;
173
+ this.#active = true;
174
+ const events = this.#events;
175
+ this.#events = [];
176
+ this.#bytes = 0;
177
+ for (const [type, value, isBinary] of events) this.#emit(type, value, isBinary);
178
+ }
179
+
180
+ frame(frame, isBinary = false) {
181
+ if (this.#closed) return;
182
+ if (!this.#active) {
183
+ this.#bytes += Buffer.byteLength(frame);
184
+ if (this.#bytes > MAX_BUFFER_BYTES)
185
+ return this.fail(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
186
+ }
187
+ this.#emit('message', frame, isBinary);
188
+ }
189
+
190
+ send(frame) {
191
+ if (this.#closed) return Promise.reject(new Error('TERMINAL_CONNECTION_LOST'));
192
+ const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
193
+ const bytes = Buffer.byteLength(value);
194
+ if (this.#writeBytes + bytes > MAX_BUFFER_BYTES)
195
+ return Promise.reject(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
196
+ const id = ++this.#nextId;
197
+ return new Promise((resolve, reject) => {
198
+ this.#writeBytes += bytes;
199
+ this.#writes.set(id, { resolve, reject, bytes });
200
+ sendIpc(this.#child, { type: 'send', id, frame: value }, () =>
201
+ this.fail(new Error('TERMINAL_CONNECTION_LOST'))
202
+ );
203
+ });
204
+ }
205
+
206
+ written(id, code) {
207
+ const write = this.#writes.get(id);
208
+ if (!write) return;
209
+ this.#writes.delete(id);
210
+ this.#writeBytes -= write.bytes;
211
+ if (code) write.reject(new Error(code));
212
+ else write.resolve();
213
+ }
214
+
215
+ fail(error) {
216
+ if (this.#closed) return;
217
+ this.#emit(
218
+ 'error',
219
+ new Error(
220
+ error instanceof DirectFallbackError || error instanceof DirectUnsafeError
221
+ ? 'TERMINAL_CONNECTION_LOST'
222
+ : error.message
223
+ )
224
+ );
225
+ this.close();
226
+ }
227
+
228
+ close() {
229
+ if (this.#closed) return;
230
+ this.#closed = true;
231
+ for (const { reject } of this.#writes.values()) reject(new Error('TERMINAL_CONNECTION_LOST'));
232
+ this.#writes.clear();
233
+ this.#writeBytes = 0;
234
+ this.#stop();
235
+ this.#emit('close');
236
+ }
237
+
238
+ #emit(type, value, isBinary = false) {
239
+ if (!this.#active) this.#events.push([type, value, isBinary]);
240
+ else if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, value, isBinary);
241
+ }
242
+ }
243
+
244
+ function runChild() {
245
+ const controller = new AbortController();
246
+ const callbacks = new Map();
247
+ let nextId = 0;
248
+ let transport;
249
+ let native;
250
+ let cleaning = false;
251
+ const cleanup = () => {
252
+ if (cleaning) return;
253
+ cleaning = true;
254
+ setImmediate(() => {
255
+ try {
256
+ native?.cleanup();
257
+ } catch {
258
+ /* Process exit also releases native resources. */
259
+ }
260
+ sendIpc(
261
+ process,
262
+ { type: 'finished' },
263
+ () => process.exit(0),
264
+ () => process.exit(0)
265
+ );
266
+ });
267
+ };
268
+ const callback = (event) =>
269
+ new Promise((resolve, reject) => {
270
+ const id = ++nextId;
271
+ callbacks.set(id, { resolve, reject });
272
+ sendIpc(process, { type: 'callback', id, event }, reject);
273
+ });
274
+ const abort = () => {
275
+ controller.abort();
276
+ for (const { reject } of callbacks.values())
277
+ reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
278
+ callbacks.clear();
279
+ if (transport) transport.close();
280
+ };
281
+ process.on('disconnect', () => {
282
+ abort();
283
+ cleanup();
284
+ });
285
+ process.on('message', async (message) => {
286
+ if (message.type === 'callback.result') {
287
+ const pending = callbacks.get(message.id);
288
+ callbacks.delete(message.id);
289
+ if (message.failed) pending?.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
290
+ else pending?.resolve();
291
+ } else if (message.type === 'abort') {
292
+ abort();
293
+ } else if (message.type === 'send') {
294
+ try {
295
+ await transport.send(message.frame);
296
+ sendIpc(process, { type: 'send.result', id: message.id });
297
+ } catch {
298
+ sendIpc(process, { type: 'send.result', id: message.id, code: 'TERMINAL_INPUT_FAILED' });
299
+ }
300
+ } else if (message.type === 'start') {
301
+ try {
302
+ const { MarctlClient } = await import('./client.mjs');
303
+ const { openDirectTerminal } = await import('./direct.mjs');
304
+ const { default: WebSocketClass } = await import('ws');
305
+ native = await import('node-datachannel');
306
+ const { connection, ...options } = message.options;
307
+ let iceSelection;
308
+ transport = await openDirectTerminal({
309
+ ...options,
310
+ client: new MarctlClient(connection),
311
+ WebSocketClass,
312
+ peerConnectionFactory: (name, config) => new native.PeerConnection(name, config),
313
+ cleanupNative: cleanup,
314
+ signal: controller.signal,
315
+ onPrepared: () => callback('prepared'),
316
+ onIceConnected: () => {
317
+ iceSelection = callback('ice');
318
+ iceSelection.catch(() => controller.abort());
319
+ },
320
+ onTransportReady: async () => {
321
+ await iceSelection;
322
+ await callback('transport');
323
+ },
324
+ beforeOpen: () => callback('opening')
325
+ });
326
+ sendIpc(process, { type: 'attached' });
327
+ transport.on('message', (frame, isBinary) =>
328
+ sendIpc(process, { type: 'frame', frame: String(frame), isBinary }, abort)
329
+ );
330
+ transport.on('error', (error) => {
331
+ sendIpc(process, { type: 'failure', code: error.message });
332
+ abort();
333
+ });
334
+ transport.on('close', () => {
335
+ sendIpc(process, { type: 'failure', code: 'TERMINAL_DIRECT_CLOSED' });
336
+ cleanup();
337
+ });
338
+ transport.activate();
339
+ } catch (error) {
340
+ sendIpc(process, {
341
+ type: 'failure',
342
+ name: error instanceof DirectUnsafeError ? 'DirectUnsafeError' : 'DirectFallbackError',
343
+ code: error.code ?? 'DIRECT_UNAVAILABLE'
344
+ });
345
+ cleanup();
346
+ }
347
+ }
348
+ });
349
+ }
350
+
351
+ if (process.send && process.argv[1] === fileURLToPath(import.meta.url)) runChild();
package/src/direct.mjs CHANGED
@@ -22,21 +22,26 @@ export class DirectUnsafeError extends Error {
22
22
  }
23
23
  }
24
24
 
25
- export async function openDirectTerminal({
26
- client,
27
- WebSocketClass,
28
- nodeId,
29
- terminalId,
30
- manifest,
31
- afterSequence = 0,
32
- timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
33
- heartbeatMs = DIRECT_HEARTBEAT_MS,
34
- peerConnectionFactory,
35
- onPrepared,
36
- onIceConnected,
37
- onTransportReady,
38
- signal
39
- }) {
25
+ export async function openDirectTerminal(options) {
26
+ const {
27
+ client,
28
+ WebSocketClass,
29
+ nodeId,
30
+ terminalId,
31
+ afterSequence = 0,
32
+ timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
33
+ heartbeatMs = DIRECT_HEARTBEAT_MS,
34
+ peerConnectionFactory,
35
+ onPrepared,
36
+ onIceConnected,
37
+ onTransportReady,
38
+ beforeOpen,
39
+ cleanupNative: cleanupNativeImpl,
40
+ signal
41
+ } = options;
42
+ let { manifest } = options;
43
+ if (peerConnectionFactory === undefined && typeof client.openDirectTerminal === 'function')
44
+ return client.openDirectTerminal(options);
40
45
  if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
41
46
  throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
42
47
 
@@ -50,7 +55,7 @@ export async function openDirectTerminal({
50
55
  let unsafeFallback = false;
51
56
  let transportControlCleanup;
52
57
  let transportNegotiationActive = true;
53
- let cleanupNative;
58
+ let cleanupNative = cleanupNativeImpl;
54
59
  try {
55
60
  manifest ??= await client.getTerminalManifest(nodeId);
56
61
  if (
@@ -82,7 +87,7 @@ export async function openDirectTerminal({
82
87
 
83
88
  const native =
84
89
  peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
85
- cleanupNative = native?.cleanup;
90
+ cleanupNative ??= native?.cleanup;
86
91
  const createPeer =
87
92
  peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
88
93
  peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
@@ -252,6 +257,7 @@ export async function openDirectTerminal({
252
257
  heartbeatMs,
253
258
  cleanupNative
254
259
  });
260
+ if (beforeOpen) await beforeOpen();
255
261
  await withTimeout(
256
262
  transport.waitForAttachment(terminalPrepared.grant),
257
263
  timeoutMs,
package/src/terminal.mjs CHANGED
@@ -583,7 +583,9 @@ function runAttachedTerminal({
583
583
  const onResize = () => {
584
584
  try {
585
585
  const { rows, cols } = getDimensions(stdout);
586
- send({ type: 'resize', terminalId, rows, cols });
586
+ Promise.resolve(send({ type: 'resize', terminalId, rows, cols })).catch(() =>
587
+ fail('errors.terminalResizeFailed')
588
+ );
587
589
  } catch {
588
590
  fail('errors.terminalResizeFailed');
589
591
  }
@@ -686,7 +688,7 @@ function rawToString(raw) {
686
688
  function sendCloseFrame(socket, terminalId) {
687
689
  if (!terminalId || !isSocketOpen(socket)) return;
688
690
  try {
689
- socket.send(JSON.stringify({ type: 'close', terminalId }));
691
+ Promise.resolve(socket.send(JSON.stringify({ type: 'close', terminalId }))).catch(() => {});
690
692
  } catch {
691
693
  // The DELETE request below remains the authoritative cleanup path.
692
694
  }