@myagentroam/marctl 0.9.121 → 0.9.123

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.9.121",
3
+ "version": "0.9.123",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/direct.mjs CHANGED
@@ -27,14 +27,17 @@ export async function openDirectTerminal({
27
27
  WebSocketClass,
28
28
  nodeId,
29
29
  terminalId,
30
+ manifest,
30
31
  afterSequence = 0,
31
32
  timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
32
33
  heartbeatMs = DIRECT_HEARTBEAT_MS,
33
34
  peerConnectionFactory,
35
+ onPrepared,
36
+ onIceConnected,
34
37
  onTransportReady,
35
38
  signal
36
39
  }) {
37
- if (typeof client.getTerminalManifest !== 'function')
40
+ if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
38
41
  throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
39
42
 
40
43
  let live;
@@ -49,7 +52,7 @@ export async function openDirectTerminal({
49
52
  let transportNegotiationActive = true;
50
53
  let cleanupNative;
51
54
  try {
52
- const manifest = await client.getTerminalManifest(nodeId);
55
+ manifest ??= await client.getTerminalManifest(nodeId);
53
56
  if (
54
57
  typeof manifest?.nodeGeneration !== 'string' ||
55
58
  manifest.nodeGeneration.length < 8 ||
@@ -84,7 +87,12 @@ export async function openDirectTerminal({
84
87
  peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
85
88
  peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
86
89
 
90
+ // Start negotiation only after both paths finish their control-plane preparation.
91
+ if (onPrepared) await onPrepared();
92
+ if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
93
+
87
94
  const transportReady = new Promise((resolve, reject) => {
95
+ let iceConnected = false;
88
96
  let connected = false;
89
97
  let serverOpened = false;
90
98
  let settled = false;
@@ -162,6 +170,17 @@ export async function openDirectTerminal({
162
170
  )
163
171
  finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
164
172
  });
173
+ peer.onIceStateChange((state) => {
174
+ if (!transportNegotiationActive || settled || iceConnected) return;
175
+ const normalizedState = String(state).toLowerCase();
176
+ if (normalizedState !== 'connected' && normalizedState !== 'completed') return;
177
+ iceConnected = true;
178
+ try {
179
+ onIceConnected?.();
180
+ } catch (error) {
181
+ finish(error);
182
+ }
183
+ });
165
184
  peer.onStateChange((state) => {
166
185
  if (!transportNegotiationActive || settled) return;
167
186
  const normalizedState = String(state).toLowerCase();
@@ -4,6 +4,8 @@ const { link, lstat, open, rename, unlink } = require('node:fs/promises');
4
4
  const path = require('node:path');
5
5
 
6
6
  const CHUNK = 32 * 1024;
7
+ const WINDOW = 8;
8
+ const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
7
9
  const [mode, encodedPath, sizeArgument, encodedMetadata, preserveArgument, encodedSourceBasename] =
8
10
  process.argv.slice(1);
9
11
  const target = Buffer.from(encodedPath ?? '', 'base64url').toString('utf8');
@@ -16,7 +18,7 @@ async function readExact(length) {
16
18
  const next = await input.next();
17
19
  if (next.done) throw new Error('SCP_STREAM_TRUNCATED');
18
20
  pending = Buffer.concat([pending, next.value]);
19
- if (pending.length > CHUNK * 4) throw new Error('SCP_INPUT_OVERFLOW');
21
+ if (pending.length > MAX_BUFFERED_BYTES) throw new Error('SCP_INPUT_OVERFLOW');
20
22
  }
21
23
  const data = pending.subarray(0, length);
22
24
  pending = pending.subarray(length);
@@ -111,6 +113,8 @@ async function source() {
111
113
  const hash = createHash('sha256');
112
114
  const buffer = Buffer.alloc(CHUNK);
113
115
  let offset = 0;
116
+ let acknowledged = Promise.resolve();
117
+ const pendingAcks = [];
114
118
  while (offset < current.size) {
115
119
  const { bytesRead } = await handle.read(
116
120
  buffer,
@@ -121,9 +125,16 @@ async function source() {
121
125
  if (bytesRead === 0) throw new Error('SCP_SOURCE_CHANGED');
122
126
  hash.update(buffer.subarray(0, bytesRead));
123
127
  await send(Buffer.concat([frameLength(bytesRead), buffer.subarray(0, bytesRead)]));
124
- if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
128
+ // Serialize ACK reads without making each file read wait for a round trip.
129
+ acknowledged = acknowledged.then(async () => {
130
+ if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
131
+ });
132
+ acknowledged.catch(() => undefined);
133
+ pendingAcks.push(acknowledged);
125
134
  offset += bytesRead;
135
+ if (pendingAcks.length >= WINDOW) await pendingAcks.shift();
126
136
  }
137
+ await acknowledged;
127
138
  const after = await handle.stat();
128
139
  if (
129
140
  after.size !== current.size ||
package/src/scp.mjs CHANGED
@@ -6,6 +6,8 @@ import { MarctlError } from './config.mjs';
6
6
  import { parseFrame, runTerminalSession } from './terminal.mjs';
7
7
 
8
8
  const CHUNK = 32 * 1024;
9
+ const WINDOW = 8;
10
+ const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
9
11
  const script = gzipSync(readFileSync(new URL('./scp-worker.cjs', import.meta.url))).toString(
10
12
  'base64'
11
13
  );
@@ -64,8 +66,11 @@ class ByteChannel {
64
66
  #sending = new Map();
65
67
  #ended;
66
68
  #failure;
69
+ #controller;
67
70
 
68
- constructor(send, signal) {
71
+ constructor(send, controller) {
72
+ this.#controller = controller;
73
+ const signal = controller.signal;
69
74
  this.send = (bytes) => {
70
75
  if (this.#failure) throw this.#failure;
71
76
  if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
@@ -86,22 +91,18 @@ class ByteChannel {
86
91
  });
87
92
  };
88
93
  if (signal) {
89
- if (signal.aborted) this.fail(new MarctlError('Copy was interrupted.'));
94
+ if (signal.aborted) this.fail(signal.reason);
90
95
  else
91
- signal.addEventListener(
92
- 'abort',
93
- () => this.fail(new MarctlError('Copy was interrupted.')),
94
- {
95
- once: true
96
- }
97
- );
96
+ signal.addEventListener('abort', () => this.fail(signal.reason), {
97
+ once: true
98
+ });
98
99
  }
99
100
  }
100
101
 
101
102
  feed(bytes) {
102
103
  if (this.#failure) return;
103
104
  this.#buffer = Buffer.concat([this.#buffer, bytes]);
104
- if (this.#buffer.length > CHUNK * 4)
105
+ if (this.#buffer.length > MAX_BUFFERED_BYTES)
105
106
  return this.fail(new MarctlError('Copy output exceeded the bounded window.'));
106
107
  this.#wake();
107
108
  }
@@ -120,10 +121,13 @@ class ByteChannel {
120
121
  }
121
122
 
122
123
  fail(error) {
123
- this.#failure ??= error;
124
+ if (this.#failure !== undefined) return;
125
+ this.#failure = error;
124
126
  for (const reject of this.#sending.keys()) reject(this.#failure);
125
127
  this.#sending.clear();
126
128
  this.#wake();
129
+ // Any endpoint failure must wake reads and writes across the entire copy.
130
+ this.#controller.abort(error);
127
131
  }
128
132
 
129
133
  #wake() {
@@ -160,6 +164,8 @@ async function copyBytes(source, destination) {
160
164
  if ((await target.read(1))[0] !== 1)
161
165
  throw new MarctlError('Copy target did not accept the file.');
162
166
  let remaining = Number(size);
167
+ let acknowledged = Promise.resolve();
168
+ const pending = [];
163
169
  for (;;) {
164
170
  const header = await source.read(4);
165
171
  const length = header.readUInt32BE();
@@ -167,22 +173,42 @@ async function copyBytes(source, destination) {
167
173
  throw new MarctlError('Copy source sent an invalid chunk.');
168
174
  await target.send(header);
169
175
  if (length === 0) {
176
+ await acknowledged;
170
177
  await target.send(await source.read(32));
171
178
  if (remaining !== 0 || (await target.read(1))[0] !== 2)
172
179
  throw new MarctlError('Copy target did not verify the file.');
173
180
  break;
174
181
  }
175
182
  await target.send(await source.read(length));
176
- if ((await target.read(1))[0] !== 1) throw new MarctlError('Copy target rejected the chunk.');
177
- await source.send(Buffer.from([1]));
183
+ // Keep one ordered ACK reader while sending up to a bounded window ahead.
184
+ acknowledged = acknowledged.then(async () => {
185
+ if ((await target.read(1))[0] !== 1)
186
+ throw new MarctlError('Copy target rejected the chunk.');
187
+ await source.send(Buffer.from([1]));
188
+ });
189
+ acknowledged.catch((error) => {
190
+ // ACK failures use the same copy-wide cancellation as transport failures.
191
+ source.fail(error);
192
+ });
193
+ pending.push(acknowledged);
178
194
  remaining -= length;
195
+ if (pending.length >= WINDOW) await pending.shift();
179
196
  }
180
197
  await target.done();
181
198
  });
182
199
  await source.done();
183
200
  }
184
201
 
185
- async function withLocalWorker(mode, file, size, metadata, preserve, sourceBasename, use, signal) {
202
+ async function withLocalWorker(
203
+ mode,
204
+ file,
205
+ size,
206
+ metadata,
207
+ preserve,
208
+ sourceBasename,
209
+ use,
210
+ controller
211
+ ) {
186
212
  const child = spawn(
187
213
  process.execPath,
188
214
  ['-e', expression, ...workerArguments(mode, file, size, metadata, preserve, sourceBasename)],
@@ -193,7 +219,13 @@ async function withLocalWorker(mode, file, size, metadata, preserve, sourceBasen
193
219
  );
194
220
  child.stdin.on('error', () => undefined);
195
221
  child.stderr.resume();
196
- const channel = new ByteChannel((bytes) => child.stdin.write(bytes), signal);
222
+ const channel = new ByteChannel(
223
+ (bytes) =>
224
+ new Promise((resolve, reject) => {
225
+ child.stdin.write(bytes, (error) => (error ? reject(error) : resolve()));
226
+ }),
227
+ controller
228
+ );
197
229
  child.stdout.on('data', (bytes) => channel.feed(bytes));
198
230
  child.once('error', (error) => channel.fail(error));
199
231
  child.once('close', (code) => channel.finish(code ?? 1));
@@ -216,7 +248,7 @@ async function withRemoteWorker(
216
248
  preserve,
217
249
  sourceBasename,
218
250
  translate,
219
- signal
251
+ controller
220
252
  },
221
253
  use
222
254
  ) {
@@ -234,7 +266,7 @@ async function withRemoteWorker(
234
266
  transport.send(
235
267
  JSON.stringify({ type: 'input', terminalId, data: bytes.toString('base64') })
236
268
  ),
237
- signal
269
+ controller
238
270
  );
239
271
  let sequence = 0;
240
272
  const onMessage = (raw, isBinary) => {
@@ -285,7 +317,7 @@ async function withWorker(endpoint, mode, size, metadata, context, use) {
285
317
  context.preserve,
286
318
  context.sourceBasename,
287
319
  use,
288
- context.signal
320
+ context.controller
289
321
  );
290
322
  return withRemoteWorker(
291
323
  {
@@ -321,7 +353,7 @@ export async function runScp({
321
353
  ...(endpoint.host ? { nodeId: ids[references.indexOf(endpoint.host)] } : {})
322
354
  });
323
355
  const controller = new AbortController();
324
- const interrupt = () => controller.abort();
356
+ const interrupt = () => controller.abort(new MarctlError('Copy was interrupted.'));
325
357
  signals.on?.('SIGINT', interrupt);
326
358
  signals.on?.('SIGTERM', interrupt);
327
359
  try {
@@ -329,7 +361,7 @@ export async function runScp({
329
361
  client,
330
362
  WebSocketClass,
331
363
  translate,
332
- signal: controller.signal,
364
+ controller,
333
365
  preserve,
334
366
  sourceBasename
335
367
  };
@@ -340,6 +372,9 @@ export async function runScp({
340
372
  })
341
373
  );
342
374
  return 0;
375
+ } catch (error) {
376
+ controller.abort(error);
377
+ throw error;
343
378
  } finally {
344
379
  signals.removeListener?.('SIGINT', interrupt);
345
380
  signals.removeListener?.('SIGTERM', interrupt);
package/src/terminal.mjs CHANGED
@@ -28,13 +28,16 @@ export async function runTerminalSession({
28
28
  let transport;
29
29
  let result;
30
30
  let primaryError;
31
+ let manifest;
31
32
 
32
33
  try {
33
- if (
34
- binary &&
35
- (command === undefined || (await client.getTerminalManifest(nodeId))?.binaryTerminal !== true)
36
- )
37
- throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
34
+ if (binary) {
35
+ if (command === undefined)
36
+ throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
37
+ manifest = await client.getTerminalManifest(nodeId);
38
+ if (manifest?.binaryTerminal !== true)
39
+ throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
40
+ }
38
41
  const created = await client.createTerminal({
39
42
  nodeId,
40
43
  ...dimensions,
@@ -47,17 +50,18 @@ export async function runTerminalSession({
47
50
  try {
48
51
  const openDirectTerminal =
49
52
  openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
53
+ if (raceConnections && manifest === undefined)
54
+ manifest = await client.getTerminalManifest(nodeId).catch(() => undefined);
50
55
  const directOptions = {
51
56
  client,
52
57
  WebSocketClass,
53
58
  nodeId,
54
59
  terminalId,
60
+ manifest,
55
61
  afterSequence: 0,
56
62
  timeoutMs: connectionTimeoutMs
57
63
  };
58
- const canRace =
59
- raceConnections &&
60
- (await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
64
+ const canRace = raceConnections && manifest?.deferredRelay === true;
61
65
  transport = canRace
62
66
  ? await raceTerminalConnections({
63
67
  openDirectTerminal,
@@ -137,6 +141,16 @@ async function raceTerminalConnections({
137
141
  }) {
138
142
  const abort = new AbortController();
139
143
  const relayAbort = new AbortController();
144
+ const preparing = new Set(['direct', 'relay']);
145
+ let start;
146
+ const preparation = new Promise((resolve) => {
147
+ start = resolve;
148
+ });
149
+ const prepared = (candidate) => {
150
+ preparing.delete(candidate);
151
+ if (preparing.size === 0) start();
152
+ return preparation;
153
+ };
140
154
  let selected;
141
155
  let announce;
142
156
  const first = new Promise((resolve) => {
@@ -153,12 +167,19 @@ async function raceTerminalConnections({
153
167
  const direct = openDirectTerminal({
154
168
  ...directOptions,
155
169
  signal: abort.signal,
156
- onTransportReady: () => {
170
+ onPrepared: () => prepared('direct'),
171
+ onIceConnected: () => {
157
172
  if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
173
+ },
174
+ onTransportReady: () => {
175
+ if (selected !== 'direct') throw new Error('DIRECT_RACE_CANCELLED');
158
176
  }
159
177
  }).then(
160
178
  (value) => ({ value }),
161
- (error) => ({ error })
179
+ (error) => {
180
+ prepared('direct');
181
+ return { error };
182
+ }
162
183
  );
163
184
  const relay = prepareRelayTransport({
164
185
  client,
@@ -167,13 +188,17 @@ async function raceTerminalConnections({
167
188
  terminalId,
168
189
  translate,
169
190
  connectionTimeoutMs,
170
- signal: relayAbort.signal
191
+ signal: relayAbort.signal,
192
+ onPrepared: () => prepared('relay')
171
193
  }).then(
172
194
  (value) => {
173
195
  if (!select('relay')) value.close();
174
196
  return { value };
175
197
  },
176
- (error) => ({ error })
198
+ (error) => {
199
+ prepared('relay');
200
+ return { error };
201
+ }
177
202
  );
178
203
  const winner = await Promise.race([
179
204
  first,
@@ -253,9 +278,11 @@ async function prepareRelayTransport({
253
278
  terminalId,
254
279
  translate,
255
280
  connectionTimeoutMs,
256
- signal
281
+ signal,
282
+ onPrepared
257
283
  }) {
258
284
  const ticket = await client.createAttachTicket({ nodeId, terminalId });
285
+ if (onPrepared) await onPrepared();
259
286
  if (signal?.aborted) throw new MarctlError(translate('errors.terminalAttachConnection'));
260
287
  const socket = client.openTerminalWebSocket(WebSocketClass, {
261
288
  terminalId,