@myagentroam/marctl 0.9.122 → 0.10.1

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/NOTICE CHANGED
@@ -2,3 +2,10 @@ MyAgentRoam
2
2
  Copyright 2026 MyAgentRoam contributors
3
3
 
4
4
  This product includes software developed by the MyAgentRoam project.
5
+
6
+ Enterprise implementation and product composition:
7
+ Copyright 2026 aCHAOS
8
+ Author and licensing contact: aCHAOS (achaosxyz@gmail.com)
9
+ Licensed under MyAgentRoam Enterprise Source-Available License 1.0; see
10
+ ee/LICENSE and LICENSING.md. Independent core and third-party notices remain
11
+ applicable; this attribution does not transfer any contributor's copyright.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/marctl",
3
- "version": "0.9.122",
3
+ "version": "0.10.1",
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
@@ -113,7 +113,7 @@ export function parseArgs(argv, translate = createTranslator()) {
113
113
  let envFile;
114
114
  let command;
115
115
  let nodeId;
116
- let remoteArgs = [];
116
+ const remoteArgs = [];
117
117
  let separator = false;
118
118
  let help = false;
119
119
  let version = false;
package/src/config.mjs CHANGED
@@ -54,6 +54,7 @@ export async function loadConfig({
54
54
 
55
55
  const keyValue = nonEmptyString(values.MAR_CTL_KEY);
56
56
  if (keyValue === undefined) throw new MarctlError(translate('errors.configKeyMissing'));
57
+ // eslint-disable-next-line no-control-regex -- Reject control characters explicitly at the input boundary.
57
58
  if (/[\u0000-\u001f\u007f]/u.test(keyValue))
58
59
  throw new MarctlError(translate('errors.configKeyInvalid'));
59
60
 
package/src/direct.mjs CHANGED
@@ -288,7 +288,6 @@ export async function openDirectTerminal({
288
288
  class LiveControl {
289
289
  #socket;
290
290
  #timeoutMs;
291
- #opened = false;
292
291
  #ready = false;
293
292
  #closed = false;
294
293
  #waiters = new Set();
@@ -404,13 +403,11 @@ class LiveControl {
404
403
 
405
404
  #waitForSocket(event, code) {
406
405
  if (this.#socket.readyState === 1) {
407
- this.#opened = true;
408
406
  return Promise.resolve();
409
407
  }
410
408
  return new Promise((resolve, reject) => {
411
409
  const onEvent = () => {
412
410
  cleanup();
413
- this.#opened = true;
414
411
  resolve();
415
412
  };
416
413
  const onError = () => {
@@ -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);
@@ -80,6 +82,7 @@ async function destinationPath() {
80
82
  sourceBasename.includes('\0') ||
81
83
  targetPath.basename(sourceBasename) !== sourceBasename ||
82
84
  (process.platform === 'win32' &&
85
+ // eslint-disable-next-line no-control-regex -- Reject control characters explicitly at the input boundary.
83
86
  (/[<>:"/\\|?*\x00-\x1f]/u.test(sourceBasename) ||
84
87
  /[. ]$/u.test(sourceBasename) ||
85
88
  /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/iu.test(sourceBasename)))
@@ -111,6 +114,8 @@ async function source() {
111
114
  const hash = createHash('sha256');
112
115
  const buffer = Buffer.alloc(CHUNK);
113
116
  let offset = 0;
117
+ let acknowledged = Promise.resolve();
118
+ const pendingAcks = [];
114
119
  while (offset < current.size) {
115
120
  const { bytesRead } = await handle.read(
116
121
  buffer,
@@ -121,9 +126,16 @@ async function source() {
121
126
  if (bytesRead === 0) throw new Error('SCP_SOURCE_CHANGED');
122
127
  hash.update(buffer.subarray(0, bytesRead));
123
128
  await send(Buffer.concat([frameLength(bytesRead), buffer.subarray(0, bytesRead)]));
124
- if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
129
+ // Serialize ACK reads without making each file read wait for a round trip.
130
+ acknowledged = acknowledged.then(async () => {
131
+ if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
132
+ });
133
+ acknowledged.catch(() => undefined);
134
+ pendingAcks.push(acknowledged);
125
135
  offset += bytesRead;
136
+ if (pendingAcks.length >= WINDOW) await pendingAcks.shift();
126
137
  }
138
+ await acknowledged;
127
139
  const after = await handle.stat();
128
140
  if (
129
141
  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);