@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 +7 -0
- package/package.json +1 -1
- package/src/cli.mjs +1 -1
- package/src/config.mjs +1 -0
- package/src/direct.mjs +0 -3
- package/src/scp-worker.cjs +14 -2
- package/src/scp.mjs +55 -20
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
package/src/cli.mjs
CHANGED
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 = () => {
|
package/src/scp-worker.cjs
CHANGED
|
@@ -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 >
|
|
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
|
-
|
|
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,
|
|
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(
|
|
94
|
+
if (signal.aborted) this.fail(signal.reason);
|
|
90
95
|
else
|
|
91
|
-
signal.addEventListener(
|
|
92
|
-
|
|
93
|
-
|
|
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 >
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|