@myagentroam/marctl 0.11.2 → 0.11.4

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.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -1,10 +1,26 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { MarctlClient } from './client.mjs';
3
- import { MarctlError, loadConfig } from './config.mjs';
3
+ import { loadConfig } from './config.mjs';
4
+ import { MarctlError } from './errors.mjs';
4
5
  import { createTranslator } from './i18n/index.mjs';
5
6
  import { runTerminalSession } from './terminal.mjs';
6
7
  import { runScp } from './scp.mjs';
7
- import { parseSessionArgs, runSessionCommand, sessionExitCode } from './sessions.mjs';
8
+ import { parseSessionArgs } from './session-args.mjs';
9
+ import { runSessionCommand, sessionExitCode } from './sessions.mjs';
10
+
11
+ async function withCommandSignals(run) {
12
+ const controller = new AbortController();
13
+ const onSigint = () => controller.abort('SIGINT');
14
+ const onSigterm = () => controller.abort('SIGTERM');
15
+ process.once('SIGINT', onSigint);
16
+ process.once('SIGTERM', onSigterm);
17
+ try {
18
+ return await run(controller.signal);
19
+ } finally {
20
+ process.off('SIGINT', onSigint);
21
+ process.off('SIGTERM', onSigterm);
22
+ }
23
+ }
8
24
 
9
25
  export async function main(
10
26
  argv = process.argv.slice(2),
@@ -60,42 +76,51 @@ export async function main(
60
76
  return 0;
61
77
  }
62
78
  if (args.command === 'sessions') {
63
- const result = await runSessionCommand({
64
- args,
65
- client: marctlClient,
66
- env,
67
- stdin,
68
- WebSocketClass,
69
- translate
70
- });
79
+ const execute = (signal) =>
80
+ runSessionCommand({
81
+ args,
82
+ client: marctlClient,
83
+ env,
84
+ stdin,
85
+ WebSocketClass,
86
+ translate,
87
+ signal
88
+ });
89
+ const result = args.action === 'wait' ? await withCommandSignals(execute) : await execute();
71
90
  writeLine(stdout, JSON.stringify(result, null, args.json ? undefined : 2));
72
91
  return 0;
73
92
  }
74
93
  if (args.command === 'scp') {
75
94
  const socketClass = WebSocketClass ?? (await import('ws')).default;
76
- return await runScp({
77
- source: args.source,
78
- destination: args.destination,
79
- preserve: args.preserve,
80
- client: marctlClient,
81
- WebSocketClass: socketClass,
82
- resolveNodeId: (reference) => resolveNodeId(marctlClient, reference, translate),
83
- translate
84
- });
95
+ return await withCommandSignals((signal) =>
96
+ runScp({
97
+ source: args.source,
98
+ destination: args.destination,
99
+ preserve: args.preserve,
100
+ client: marctlClient,
101
+ WebSocketClass: socketClass,
102
+ resolveNodeId: (reference) => resolveNodeId(marctlClient, reference, translate),
103
+ translate,
104
+ signal
105
+ })
106
+ );
85
107
  }
86
108
 
87
109
  const nodeId = await resolveNodeId(marctlClient, args.nodeId, translate);
88
110
  const socketClass = WebSocketClass ?? (await import('ws')).default;
89
- return await runTerminalSession({
90
- client: marctlClient,
91
- WebSocketClass: socketClass,
92
- nodeId,
93
- command: args.remoteCommand,
94
- raceConnections: true,
95
- stdout,
96
- stderr,
97
- translate
98
- });
111
+ return await withCommandSignals((signal) =>
112
+ runTerminalSession({
113
+ client: marctlClient,
114
+ WebSocketClass: socketClass,
115
+ nodeId,
116
+ command: args.remoteCommand,
117
+ raceConnections: true,
118
+ stdout,
119
+ stderr,
120
+ translate,
121
+ signal
122
+ })
123
+ );
99
124
  } catch (error) {
100
125
  if (argv[0] === 'sessions') {
101
126
  const failure = {
package/src/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { MarctlError } from './config.mjs';
1
+ import { MarctlError } from './errors.mjs';
2
2
  import { createTranslator } from './i18n/index.mjs';
3
3
 
4
4
  const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
package/src/config.mjs CHANGED
@@ -1,16 +1,6 @@
1
1
  import { createTranslator } from './i18n/index.mjs';
2
2
 
3
- export class MarctlError extends Error {
4
- constructor(message, options = {}) {
5
- super(message, options);
6
- this.name = 'MarctlError';
7
- this.code = options.code;
8
- this.requestId = options.requestId;
9
- this.upstreamRequestId = options.upstreamRequestId;
10
- this.details = options.details;
11
- this.statusCode = options.statusCode;
12
- }
13
- }
3
+ import { MarctlError } from './errors.mjs';
14
4
 
15
5
  export async function loadConfig({ env = process.env, translate = createTranslator(env) } = {}) {
16
6
  const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
@@ -0,0 +1,225 @@
1
+ import { DirectFallbackError, directControlError } from './direct-errors.mjs';
2
+
3
+ export class LiveControl {
4
+ #socket;
5
+ #timeoutMs;
6
+ #ready = false;
7
+ #closed = false;
8
+ #waiters = new Set();
9
+ #listeners = new Set();
10
+ #onMessage;
11
+ #onError;
12
+ #onClose;
13
+
14
+ constructor(socket, timeoutMs) {
15
+ this.#socket = socket;
16
+ this.#timeoutMs = timeoutMs;
17
+ this.#onMessage = (raw, isBinary) => {
18
+ if (isBinary) return;
19
+ let message;
20
+ try {
21
+ message = JSON.parse(rawToString(raw));
22
+ } catch {
23
+ return;
24
+ }
25
+ if (!isRecord(message) || typeof message.type !== 'string') return;
26
+ for (const listener of this.#listeners) listener(message);
27
+ for (const waiter of [...this.#waiters]) {
28
+ if (
29
+ ((waiter.request?.type === 'terminal.direct.prepare' &&
30
+ message.type === 'terminal.direct.error') ||
31
+ (waiter.request?.type === 'direct.prepare' && message.type === 'direct.error')) &&
32
+ message.requestId === waiter.request.requestId
33
+ ) {
34
+ this.#waiters.delete(waiter);
35
+ clearTimeout(waiter.timer);
36
+ waiter.reject(directControlError(message.code));
37
+ continue;
38
+ }
39
+ if (!waiter.predicate(message, waiter.request)) continue;
40
+ this.#waiters.delete(waiter);
41
+ clearTimeout(waiter.timer);
42
+ waiter.resolve(message);
43
+ }
44
+ };
45
+ this.#onError = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
46
+ this.#onClose = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
47
+ socket.on?.('message', this.#onMessage);
48
+ socket.on?.('error', this.#onError);
49
+ socket.on?.('close', this.#onClose);
50
+ }
51
+
52
+ async openAndResume() {
53
+ await this.#waitForSocket('open', 'DIRECT_CONTROL_TIMEOUT');
54
+ const ready = this.waitFor(
55
+ (message) =>
56
+ message.type === 'workbench.ready' && typeof message.workbenchConnectionId === 'string',
57
+ undefined,
58
+ 'DIRECT_RESUME_TIMEOUT'
59
+ );
60
+ if (!this.send({ type: 'resume' })) {
61
+ ready.catch(() => undefined);
62
+ throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
63
+ }
64
+ await ready;
65
+ this.#ready = true;
66
+ }
67
+
68
+ request(message, predicate, fallbackCode) {
69
+ if (!this.#ready) throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
70
+ const response = this.waitFor(predicate, message, fallbackCode);
71
+ if (!this.send(message)) {
72
+ response.catch(() => undefined);
73
+ throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
74
+ }
75
+ return response;
76
+ }
77
+
78
+ waitFor(predicate, request, fallbackCode, timeoutMs = this.#timeoutMs) {
79
+ return new Promise((resolve, reject) => {
80
+ if (this.#closed) {
81
+ reject(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
82
+ return;
83
+ }
84
+ const waiter = {
85
+ predicate,
86
+ request,
87
+ resolve,
88
+ reject,
89
+ timer: setTimeout(() => {
90
+ this.#waiters.delete(waiter);
91
+ reject(new DirectFallbackError(fallbackCode ?? 'DIRECT_SETUP_TIMEOUT'));
92
+ }, timeoutMs)
93
+ };
94
+ this.#waiters.add(waiter);
95
+ });
96
+ }
97
+
98
+ onControl(listener) {
99
+ if (this.#closed) return () => {};
100
+ this.#listeners.add(listener);
101
+ return () => this.#listeners.delete(listener);
102
+ }
103
+
104
+ send(message) {
105
+ if (this.#closed || (this.#socket.readyState !== undefined && this.#socket.readyState !== 1))
106
+ return false;
107
+ try {
108
+ this.#socket.send(JSON.stringify(message));
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ close() {
116
+ if (this.#closed) return;
117
+ this.#closed = true;
118
+ this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
119
+ this.#listeners.clear();
120
+ this.#socket.removeListener?.('message', this.#onMessage);
121
+ this.#socket.removeListener?.('error', this.#onError);
122
+ this.#socket.removeListener?.('close', this.#onClose);
123
+ try {
124
+ this.#socket.close();
125
+ } catch {
126
+ // The control socket is already closed.
127
+ }
128
+ }
129
+
130
+ #waitForSocket(event, code) {
131
+ if (this.#socket.readyState === 1) {
132
+ return Promise.resolve();
133
+ }
134
+ return new Promise((resolve, reject) => {
135
+ const onEvent = () => {
136
+ cleanup();
137
+ resolve();
138
+ };
139
+ const onError = () => {
140
+ cleanup();
141
+ reject(new DirectFallbackError(code));
142
+ };
143
+ const onClose = () => {
144
+ cleanup();
145
+ reject(new DirectFallbackError(code));
146
+ };
147
+ const timer = setTimeout(() => {
148
+ cleanup();
149
+ reject(new DirectFallbackError(code));
150
+ }, this.#timeoutMs);
151
+ const cleanup = () => {
152
+ clearTimeout(timer);
153
+ this.#socket.removeListener?.(event, onEvent);
154
+ this.#socket.removeListener?.('error', onError);
155
+ this.#socket.removeListener?.('close', onClose);
156
+ };
157
+ this.#socket.once?.(event, onEvent);
158
+ this.#socket.once?.('error', onError);
159
+ this.#socket.once?.('close', onClose);
160
+ });
161
+ }
162
+
163
+ #fail(error) {
164
+ if (this.#closed && this.#waiters.size === 0) return;
165
+ for (const waiter of [...this.#waiters]) {
166
+ this.#waiters.delete(waiter);
167
+ clearTimeout(waiter.timer);
168
+ waiter.reject(error);
169
+ }
170
+ }
171
+ }
172
+
173
+ export async function cancelSession(live, type, directSessionId) {
174
+ if (live === undefined) return;
175
+ const closed = live.waitFor(
176
+ (message) =>
177
+ message.type === `${type.replace('.cancel', '.closed')}` &&
178
+ message.directSessionId === directSessionId,
179
+ undefined,
180
+ 'DIRECT_CANCEL_TIMEOUT',
181
+ 1000
182
+ );
183
+ live.send({
184
+ type,
185
+ directSessionId,
186
+ ...(type === 'direct.cancel' ? { reason: 'CLI_FALLBACK' } : {})
187
+ });
188
+ await closed.catch(() => undefined);
189
+ }
190
+
191
+ export function withTimeout(promise, timeoutMs, code, signal) {
192
+ let timer;
193
+ let onAbort;
194
+ return Promise.race([
195
+ promise,
196
+ new Promise((_, reject) => {
197
+ timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
198
+ }),
199
+ ...(signal
200
+ ? [
201
+ new Promise((_, reject) => {
202
+ onAbort = () => reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
203
+ if (signal.aborted) onAbort();
204
+ else signal.addEventListener('abort', onAbort, { once: true });
205
+ })
206
+ ]
207
+ : [])
208
+ ]).finally(() => {
209
+ clearTimeout(timer);
210
+ if (onAbort) signal.removeEventListener('abort', onAbort);
211
+ });
212
+ }
213
+
214
+ export function rawToString(raw) {
215
+ if (typeof raw === 'string') return raw;
216
+ if (Buffer.isBuffer(raw)) return raw.toString('utf8');
217
+ if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
218
+ if (ArrayBuffer.isView(raw))
219
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
220
+ return String(raw);
221
+ }
222
+
223
+ export function isRecord(value) {
224
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
225
+ }
@@ -0,0 +1,48 @@
1
+ export class DirectFallbackError extends Error {
2
+ constructor(code = 'DIRECT_UNAVAILABLE') {
3
+ super(code);
4
+ this.name = 'DirectFallbackError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ export class DirectUnsafeError extends Error {
10
+ constructor(code = 'DIRECT_CANCEL_UNCONFIRMED') {
11
+ super(code);
12
+ this.name = 'DirectUnsafeError';
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ const directRejections = new Set([
18
+ 'DIRECT_TRANSFER_NOT_AUTHORIZED',
19
+ 'DIRECT_SESSION_LIMIT_REACHED',
20
+ 'DIRECT_NODE_GENERATION_MISMATCH',
21
+ 'DIRECT_TRANSFER_SESSION_CONFLICT',
22
+ 'TERMINAL_NOT_AUTHORIZED',
23
+ 'TERMINAL_DIRECT_LEASE_EXPIRED',
24
+ 'TERMINAL_NOT_FOUND',
25
+ 'TERMINAL_CLOSED',
26
+ 'TERMINAL_WORKSPACE_MISMATCH',
27
+ 'TERMINAL_GIT_OWNER_REQUIRED',
28
+ 'TERMINAL_ATTACH_CONFLICT',
29
+ 'TERMINAL_RELAY_LIMIT_REACHED'
30
+ ]);
31
+
32
+ export class DirectRejectedError extends Error {
33
+ constructor(code) {
34
+ super(code);
35
+ this.name = 'DirectRejectedError';
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ export function directControlError(code) {
41
+ const safeCode =
42
+ typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/u.test(code)
43
+ ? code
44
+ : 'TERMINAL_DIRECT_UNAVAILABLE';
45
+ return directRejections.has(safeCode)
46
+ ? new DirectRejectedError(safeCode)
47
+ : new DirectFallbackError(safeCode);
48
+ }
@@ -1,7 +1,12 @@
1
1
  import { fork } from 'node:child_process';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { DirectFallbackError, DirectUnsafeError } from './direct.mjs';
4
+ import {
5
+ DirectFallbackError,
6
+ DirectUnsafeError,
7
+ DirectRejectedError,
8
+ directControlError
9
+ } from './direct-errors.mjs';
5
10
 
6
11
  const CLEANUP_TIMEOUT_MS = 1000;
7
12
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
@@ -113,11 +118,16 @@ export function openDirectTerminalProcess({
113
118
  resolve(transport);
114
119
  } else if (message.type === 'failure') {
115
120
  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
+ ? message.name === 'DirectRejectedError' ||
122
+ directControlError(message.code) instanceof DirectRejectedError
123
+ ? directControlError(message.code)
124
+ : new Error(message.code ?? 'TERMINAL_CONNECTION_LOST')
125
+ : message.name === 'DirectRejectedError'
126
+ ? directControlError(message.code)
127
+ : message.name === 'DirectUnsafeError' ||
128
+ (opening && message.name !== 'DirectFallbackError')
129
+ ? new DirectUnsafeError(message.code)
130
+ : new DirectFallbackError(message.code);
121
131
  fail(error);
122
132
  } else if (message.type === 'frame') {
123
133
  transport.frame(message.frame, message.isBinary);
@@ -216,11 +226,13 @@ class IsolatedDirectTransport extends EventEmitter {
216
226
  if (this.#closed) return;
217
227
  this.#emit(
218
228
  'error',
219
- new Error(
220
- error instanceof DirectFallbackError || error instanceof DirectUnsafeError
221
- ? 'TERMINAL_CONNECTION_LOST'
222
- : error.message
223
- )
229
+ error instanceof DirectRejectedError
230
+ ? error
231
+ : new Error(
232
+ error instanceof DirectFallbackError || error instanceof DirectUnsafeError
233
+ ? 'TERMINAL_CONNECTION_LOST'
234
+ : error.message
235
+ )
224
236
  );
225
237
  this.close();
226
238
  }
@@ -298,6 +310,7 @@ function runChild() {
298
310
  sendIpc(process, { type: 'send.result', id: message.id, code: 'TERMINAL_INPUT_FAILED' });
299
311
  }
300
312
  } else if (message.type === 'start') {
313
+ let rejectionSent = false;
301
314
  try {
302
315
  const { MarctlClient } = await import('./client.mjs');
303
316
  const { openDirectTerminal } = await import('./direct.mjs');
@@ -312,6 +325,10 @@ function runChild() {
312
325
  peerConnectionFactory: (name, config) => new native.PeerConnection(name, config),
313
326
  cleanupNative: cleanup,
314
327
  signal: controller.signal,
328
+ onRejected: (error) => {
329
+ rejectionSent = true;
330
+ sendIpc(process, { type: 'failure', name: 'DirectRejectedError', code: error.code });
331
+ },
315
332
  onPrepared: () => callback('prepared'),
316
333
  onIceConnected: () => {
317
334
  iceSelection = callback('ice');
@@ -328,7 +345,7 @@ function runChild() {
328
345
  sendIpc(process, { type: 'frame', frame: String(frame), isBinary }, abort)
329
346
  );
330
347
  transport.on('error', (error) => {
331
- sendIpc(process, { type: 'failure', code: error.message });
348
+ sendIpc(process, { type: 'failure', code: error.code ?? error.message });
332
349
  abort();
333
350
  });
334
351
  transport.on('close', () => {
@@ -337,11 +354,17 @@ function runChild() {
337
354
  });
338
355
  transport.activate();
339
356
  } catch (error) {
340
- sendIpc(process, {
341
- type: 'failure',
342
- name: error instanceof DirectUnsafeError ? 'DirectUnsafeError' : 'DirectFallbackError',
343
- code: error.code ?? 'DIRECT_UNAVAILABLE'
344
- });
357
+ if (!rejectionSent)
358
+ sendIpc(process, {
359
+ type: 'failure',
360
+ name:
361
+ error instanceof DirectRejectedError
362
+ ? 'DirectRejectedError'
363
+ : error instanceof DirectUnsafeError
364
+ ? 'DirectUnsafeError'
365
+ : 'DirectFallbackError',
366
+ code: error.code ?? 'DIRECT_UNAVAILABLE'
367
+ });
345
368
  cleanup();
346
369
  }
347
370
  }