@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/marctl",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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),
@@ -59,45 +75,54 @@ export async function main(
59
75
  }
60
76
  return 0;
61
77
  }
62
- if (args.command === 'sessions') {
63
- const result = await runSessionCommand({
64
- args,
65
- client: marctlClient,
66
- env,
67
- stdin,
68
- WebSocketClass,
69
- translate
70
- });
78
+ if (args.command === 'agents') {
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
- if (argv[0] === 'sessions') {
125
+ if (argv[0] === 'agents') {
101
126
  const failure = {
102
127
  code: error.code ?? 'MARCTL_COMMAND_FAILED',
103
128
  message: error instanceof MarctlError ? error.message : translate('errors.commandFailed'),
@@ -151,7 +176,7 @@ export function executableArgs(executable, args) {
151
176
  }
152
177
 
153
178
  export function parseArgs(argv, translate = createTranslator()) {
154
- if (argv[0] === 'sessions') return parseSessionArgs(argv.slice(1), translate);
179
+ if (argv[0] === 'agents') return parseSessionArgs(argv.slice(1), translate);
155
180
  let command;
156
181
  let nodeId;
157
182
  const remoteArgs = [];
@@ -183,7 +208,7 @@ export function parseArgs(argv, translate = createTranslator()) {
183
208
  argument !== 'nodes' &&
184
209
  argument !== 'ssh' &&
185
210
  argument !== 'scp' &&
186
- argument !== 'sessions' &&
211
+ argument !== 'agents' &&
187
212
  argument !== 'version'
188
213
  )
189
214
  throw unsupportedSshOption(argument, translate);
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;
@@ -49,11 +49,13 @@ export class MarctlClient {
49
49
  });
50
50
  }
51
51
 
52
- async sessionCommand(body) {
52
+ async sessionCommand(body, { timeoutMs, signal } = {}) {
53
53
  const result = await this.#requestJson('/api/marctl/sessions', {
54
54
  method: 'POST',
55
55
  body,
56
- preserveError: true
56
+ preserveError: true,
57
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
58
+ ...(signal === undefined ? {} : { signal })
57
59
  });
58
60
  if (!isRecord(result))
59
61
  throw new MarctlError(this.#translate('errors.serverInvalidJson'), {
@@ -171,19 +173,33 @@ export class MarctlClient {
171
173
  return this.#request(pathname, { ...options, json: true });
172
174
  }
173
175
 
174
- async #request(pathname, { method = 'GET', body, json = false, preserveError = false } = {}) {
176
+ async #request(
177
+ pathname,
178
+ {
179
+ method = 'GET',
180
+ body,
181
+ json = false,
182
+ preserveError = false,
183
+ timeoutMs = this.#requestTimeoutMs,
184
+ signal
185
+ } = {}
186
+ ) {
175
187
  const headers = {
176
188
  Accept: 'application/json',
177
189
  Authorization: `Bearer ${this.#key}`
178
190
  };
179
191
  const controller = new AbortController();
180
- const request = { method, headers, signal: controller.signal };
192
+ const request = {
193
+ method,
194
+ headers,
195
+ signal: signal ? AbortSignal.any([controller.signal, signal]) : controller.signal
196
+ };
181
197
  if (body !== undefined) {
182
198
  headers['Content-Type'] = 'application/json';
183
199
  request.body = JSON.stringify(body);
184
200
  }
185
201
 
186
- const timer = setTimeout(() => controller.abort(), this.#requestTimeoutMs);
202
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
187
203
  try {
188
204
  const response = await this.#fetch(new URL(pathname, `${this.#serverUrl}/`), request);
189
205
  if (!response?.ok) {
@@ -227,6 +243,10 @@ export class MarctlClient {
227
243
  );
228
244
  }
229
245
  } catch (error) {
246
+ if (signal?.aborted)
247
+ throw new MarctlError(this.#translate('errors.sessionWaitCancelled'), {
248
+ code: 'MARCTL_WAIT_CANCELLED'
249
+ });
230
250
  if (error instanceof MarctlError) throw error;
231
251
  throw new MarctlError(
232
252
  this.#translate(
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
  }