@myagentroam/marctl 0.10.1 → 0.10.3

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.10.1",
3
+ "version": "0.10.3",
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
@@ -38,7 +38,7 @@ export async function main(
38
38
  return 0;
39
39
  }
40
40
 
41
- const config = await loadConfig({ env, envFile: args.envFile, translate });
41
+ const config = await loadConfig({ env, translate });
42
42
  const marctlClient = client ?? new MarctlClient({ ...config, translate });
43
43
  if (args.command === 'nodes') {
44
44
  const nodes = await marctlClient.listNodes();
@@ -110,7 +110,6 @@ export function executableArgs(executable, args) {
110
110
  }
111
111
 
112
112
  export function parseArgs(argv, translate = createTranslator()) {
113
- let envFile;
114
113
  let command;
115
114
  let nodeId;
116
115
  const remoteArgs = [];
@@ -133,18 +132,6 @@ export function parseArgs(argv, translate = createTranslator()) {
133
132
  version = true;
134
133
  continue;
135
134
  }
136
- if (!separator && argument === '--env-file') {
137
- envFile = argv[index + 1];
138
- if (!envFile || envFile.startsWith('-'))
139
- throw new MarctlError(translate('errors.envFileValueRequired'));
140
- index += 1;
141
- continue;
142
- }
143
- if (!separator && argument.startsWith('--env-file=')) {
144
- envFile = argument.slice('--env-file='.length);
145
- if (!envFile) throw new MarctlError(translate('errors.envFileValueRequired'));
146
- continue;
147
- }
148
135
  if (!separator && argument === '--') {
149
136
  separator = true;
150
137
  continue;
@@ -186,12 +173,10 @@ export function parseArgs(argv, translate = createTranslator()) {
186
173
 
187
174
  if (help) return { help: true, ...(command === undefined ? {} : { command }) };
188
175
  if (version || command === 'version') return { version: true };
189
- if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0)
190
- return { command, ...(envFile === undefined ? {} : { envFile }) };
176
+ if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0) return { command };
191
177
  if (command === 'scp' && nodeId !== undefined && remoteArgs.length === 1)
192
178
  return {
193
179
  command,
194
- ...(envFile === undefined ? {} : { envFile }),
195
180
  source: nodeId,
196
181
  destination: remoteArgs[0],
197
182
  preserve
@@ -199,7 +184,6 @@ export function parseArgs(argv, translate = createTranslator()) {
199
184
  if (command === 'ssh' && nodeId !== undefined && nodeId !== '')
200
185
  return {
201
186
  command,
202
- ...(envFile === undefined ? {} : { envFile }),
203
187
  nodeId,
204
188
  ...(remoteArgs.length === 0
205
189
  ? {}
package/src/client.mjs CHANGED
@@ -86,6 +86,14 @@ export class MarctlClient {
86
86
  };
87
87
  }
88
88
 
89
+ async openDirectTerminal(options) {
90
+ const { openDirectTerminalProcess } = await import('./direct-process.mjs');
91
+ return openDirectTerminalProcess({
92
+ ...options,
93
+ connection: { serverUrl: this.#serverUrl, key: this.#key }
94
+ });
95
+ }
96
+
89
97
  async createAttachTicket({ nodeId, terminalId, afterSequence }) {
90
98
  validateNodeId(nodeId, this.#translate);
91
99
  validateTerminalId(terminalId, this.#translate);
package/src/config.mjs CHANGED
@@ -1,10 +1,5 @@
1
- import { lstat, readFile } from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
1
  import { createTranslator } from './i18n/index.mjs';
5
2
 
6
- export const CONFIG_KEYS = Object.freeze(['MAR_CTL_SERVER_URL', 'MAR_CTL_KEY']);
7
-
8
3
  export class MarctlError extends Error {
9
4
  constructor(message, options = {}) {
10
5
  super(message, options);
@@ -12,56 +7,17 @@ export class MarctlError extends Error {
12
7
  }
13
8
  }
14
9
 
15
- export function defaultConfigPath({
16
- env = process.env,
17
- platform = process.platform,
18
- homeDirectory = os.homedir()
19
- } = {}) {
20
- const configuredHome =
21
- nonEmptyString(env.HOME) ??
22
- (platform === 'win32' ? nonEmptyString(env.USERPROFILE) : undefined) ??
23
- homeDirectory;
24
- return path.join(configuredHome, '.marctl', 'default.env');
25
- }
26
-
27
- export async function loadConfig({
28
- env = process.env,
29
- envFile,
30
- platform = process.platform,
31
- homeDirectory = os.homedir(),
32
- translate = createTranslator(env)
33
- } = {}) {
34
- if (env.MAR_CTL_CONTEXT_REQUIRED === '1') {
35
- const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
36
- const token = nonEmptyString(env.MAR_CTL_USER_TOKEN);
37
- if (
38
- serverUrl === undefined ||
39
- token === undefined ||
40
- !/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
41
- )
42
- throw new MarctlError(translate('errors.userContextUnavailable'));
43
- return Object.freeze({
44
- serverUrl: validateServerUrl(serverUrl, translate),
45
- key: token
46
- });
47
- }
48
- const configPath = envFile ?? defaultConfigPath({ env, platform, homeDirectory });
49
- const values = await readConfigFile(configPath, { platform, translate });
50
-
51
- const serverUrlValue = nonEmptyString(values.MAR_CTL_SERVER_URL);
52
- if (serverUrlValue === undefined)
53
- throw new MarctlError(translate('errors.configServerUrlMissing'));
54
-
55
- const keyValue = nonEmptyString(values.MAR_CTL_KEY);
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.
58
- if (/[\u0000-\u001f\u007f]/u.test(keyValue))
59
- throw new MarctlError(translate('errors.configKeyInvalid'));
60
-
61
- return Object.freeze({
62
- serverUrl: validateServerUrl(serverUrlValue, translate),
63
- key: keyValue
64
- });
10
+ export async function loadConfig({ env = process.env, translate = createTranslator(env) } = {}) {
11
+ const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
12
+ const token = nonEmptyString(env.MAR_CTL_USER_TOKEN);
13
+ if (
14
+ env.MAR_CTL_CONTEXT_REQUIRED !== '1' ||
15
+ serverUrl === undefined ||
16
+ token === undefined ||
17
+ !/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
18
+ )
19
+ throw new MarctlError(translate('errors.userContextUnavailable'));
20
+ return Object.freeze({ serverUrl: validateServerUrl(serverUrl, translate), key: token });
65
21
  }
66
22
 
67
23
  export function validateServerUrl(value, translate = createTranslator()) {
@@ -86,80 +42,6 @@ export function validateServerUrl(value, translate = createTranslator()) {
86
42
  return url.origin;
87
43
  }
88
44
 
89
- async function readConfigFile(filePath, { platform, translate }) {
90
- let before;
91
- try {
92
- before = await lstat(filePath);
93
- } catch (error) {
94
- if (error?.code === 'ENOENT') throw new MarctlError(translate('errors.envFileMissing'));
95
- throw new MarctlError(translate('errors.envFileRead'));
96
- }
97
- assertSecureConfigFile(before, platform, translate);
98
-
99
- let content;
100
- try {
101
- content = await readFile(filePath, 'utf8');
102
- } catch {
103
- throw new MarctlError(translate('errors.envFileRead'));
104
- }
105
-
106
- let after;
107
- try {
108
- after = await lstat(filePath);
109
- } catch {
110
- throw new MarctlError(translate('errors.envFileRead'));
111
- }
112
- assertSecureConfigFile(after, platform, translate);
113
- if (before.dev !== after.dev || before.ino !== after.ino)
114
- throw new MarctlError(translate('errors.envFileRead'));
115
-
116
- const values = {};
117
- for (const rawLine of content.replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
118
- const line = rawLine.trim();
119
- if (line === '' || line.startsWith('#')) continue;
120
-
121
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
122
- if (match === null) throw new MarctlError(translate('errors.envFileEntry'));
123
-
124
- const [, name, rawValue] = match;
125
- if (!CONFIG_KEYS.includes(name)) throw new MarctlError(translate('errors.envFileUnsupported'));
126
- values[name] = parseEnvValue(rawValue, translate);
127
- }
128
- return values;
129
- }
130
-
131
- function parseEnvValue(rawValue, translate) {
132
- const value = rawValue.trim();
133
- if (value.startsWith('"') || value.startsWith("'")) {
134
- const quote = value[0];
135
- if (!value.endsWith(quote) || value.length < 2)
136
- throw new MarctlError(translate('errors.envFileQuoted'));
137
- const body = value.slice(1, -1);
138
- if (quote === "'") return body;
139
- return body.replace(/\\(["\\nrt])/gu, (_match, escaped) => {
140
- if (escaped === 'n') return '\n';
141
- if (escaped === 'r') return '\r';
142
- if (escaped === 't') return '\t';
143
- return escaped;
144
- });
145
- }
146
- return value;
147
- }
148
-
149
- function assertSecureConfigFile(stats, platform, translate) {
150
- if (stats.isSymbolicLink()) throw new MarctlError(translate('errors.envFileSymlink'));
151
- if (!stats.isFile()) throw new MarctlError(translate('errors.envFileType'));
152
- if (platform === 'win32') return;
153
-
154
- const permissions = stats.mode & 0o7777;
155
- const ownerUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
156
- if (
157
- (ownerUid !== undefined && stats.uid !== ownerUid) ||
158
- (permissions !== 0o600 && permissions !== 0o400)
159
- )
160
- throw new MarctlError(translate('errors.envFilePermissions'));
161
- }
162
-
163
45
  function nonEmptyString(value) {
164
46
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
165
47
  }
@@ -0,0 +1,351 @@
1
+ import { fork } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DirectFallbackError, DirectUnsafeError } from './direct.mjs';
5
+
6
+ const CLEANUP_TIMEOUT_MS = 1000;
7
+ const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
8
+ const ipcBytes = new WeakMap();
9
+
10
+ function sendIpc(target, message, onError = () => {}, onSent = () => {}) {
11
+ const bytes = Buffer.byteLength(JSON.stringify(message));
12
+ const pending = ipcBytes.get(target) ?? 0;
13
+ if (!target.connected || pending + bytes > MAX_BUFFER_BYTES) {
14
+ onError(new Error('TERMINAL_CONNECTION_LOST'));
15
+ return false;
16
+ }
17
+ ipcBytes.set(target, pending + bytes);
18
+ try {
19
+ target.send(message, (error) => {
20
+ ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
21
+ if (error) onError(error);
22
+ else onSent();
23
+ });
24
+ return true;
25
+ } catch (error) {
26
+ ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
27
+ onError(error);
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function openDirectTerminalProcess({
33
+ connection,
34
+ nodeId,
35
+ terminalId,
36
+ manifest,
37
+ afterSequence = 0,
38
+ timeoutMs = 15000,
39
+ heartbeatMs = 10000,
40
+ onPrepared,
41
+ onIceConnected,
42
+ onTransportReady,
43
+ signal,
44
+ forkImpl = fork
45
+ }) {
46
+ if (signal?.aborted) return Promise.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
47
+ return new Promise((resolve, reject) => {
48
+ let child;
49
+ try {
50
+ child = forkImpl(new URL('./direct-process.mjs', import.meta.url), [], {
51
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
52
+ execArgv: []
53
+ });
54
+ } catch {
55
+ reject(new DirectFallbackError('DIRECT_UNAVAILABLE'));
56
+ return;
57
+ }
58
+ let settled = false;
59
+ let opening = false;
60
+ let stopping = false;
61
+ let exited = false;
62
+ let cleanupTimer;
63
+ const stop = () => {
64
+ if (stopping) return;
65
+ stopping = true;
66
+ clearTimeout(setupTimer);
67
+ signal?.removeEventListener('abort', onAbort);
68
+ if (exited) return;
69
+ sendIpc(child, { type: 'abort' });
70
+ // Native cleanup may block. Only this connection's process is terminated.
71
+ cleanupTimer = setTimeout(() => child.kill('SIGKILL'), CLEANUP_TIMEOUT_MS);
72
+ };
73
+ const transport = new IsolatedDirectTransport(child, stop);
74
+ const fail = (error) => {
75
+ if (settled) {
76
+ transport.fail(error);
77
+ } else {
78
+ settled = true;
79
+ reject(error);
80
+ stop();
81
+ }
82
+ };
83
+ const setupError = () =>
84
+ opening
85
+ ? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
86
+ : new DirectFallbackError('DIRECT_UNAVAILABLE');
87
+ const setupTimer = setTimeout(() => fail(setupError()), timeoutMs);
88
+ const onAbort = () =>
89
+ fail(
90
+ opening
91
+ ? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
92
+ : new DirectFallbackError('DIRECT_RACE_CANCELLED')
93
+ );
94
+ child.on('message', (message) => {
95
+ if (message.type === 'callback') {
96
+ const callback = { prepared: onPrepared, ice: onIceConnected, transport: onTransportReady }[
97
+ message.event
98
+ ];
99
+ if (message.event === 'opening') opening = true;
100
+ Promise.resolve()
101
+ .then(() => {
102
+ if (stopping) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
103
+ return callback?.();
104
+ })
105
+ .then(
106
+ () =>
107
+ sendIpc(child, { type: 'callback.result', id: message.id }, () => fail(setupError())),
108
+ () => sendIpc(child, { type: 'callback.result', id: message.id, failed: true })
109
+ );
110
+ } else if (message.type === 'attached' && !settled) {
111
+ settled = true;
112
+ clearTimeout(setupTimer);
113
+ resolve(transport);
114
+ } else if (message.type === 'failure') {
115
+ 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
+ fail(error);
122
+ } else if (message.type === 'frame') {
123
+ transport.frame(message.frame, message.isBinary);
124
+ } else if (message.type === 'send.result') {
125
+ transport.written(message.id, message.code);
126
+ } else if (message.type === 'finished') {
127
+ clearTimeout(cleanupTimer);
128
+ }
129
+ });
130
+ child.on('error', () => fail(setupError()));
131
+ child.on('disconnect', () => {
132
+ if (!stopping) fail(setupError());
133
+ });
134
+ child.on('exit', () => {
135
+ exited = true;
136
+ clearTimeout(cleanupTimer);
137
+ clearTimeout(setupTimer);
138
+ signal?.removeEventListener('abort', onAbort);
139
+ if (!stopping) fail(setupError());
140
+ });
141
+ signal?.addEventListener('abort', onAbort, { once: true });
142
+ sendIpc(
143
+ child,
144
+ {
145
+ type: 'start',
146
+ options: { connection, nodeId, terminalId, manifest, afterSequence, timeoutMs, heartbeatMs }
147
+ },
148
+ () => fail(setupError())
149
+ );
150
+ if (signal?.aborted) onAbort();
151
+ });
152
+ }
153
+
154
+ class IsolatedDirectTransport extends EventEmitter {
155
+ #child;
156
+ #stop;
157
+ #active = false;
158
+ #closed = false;
159
+ #events = [];
160
+ #bytes = 0;
161
+ #writes = new Map();
162
+ #writeBytes = 0;
163
+ #nextId = 0;
164
+
165
+ constructor(child, stop) {
166
+ super();
167
+ this.#child = child;
168
+ this.#stop = stop;
169
+ }
170
+
171
+ activate() {
172
+ if (this.#active) return;
173
+ this.#active = true;
174
+ const events = this.#events;
175
+ this.#events = [];
176
+ this.#bytes = 0;
177
+ for (const [type, value, isBinary] of events) this.#emit(type, value, isBinary);
178
+ }
179
+
180
+ frame(frame, isBinary = false) {
181
+ if (this.#closed) return;
182
+ if (!this.#active) {
183
+ this.#bytes += Buffer.byteLength(frame);
184
+ if (this.#bytes > MAX_BUFFER_BYTES)
185
+ return this.fail(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
186
+ }
187
+ this.#emit('message', frame, isBinary);
188
+ }
189
+
190
+ send(frame) {
191
+ if (this.#closed) return Promise.reject(new Error('TERMINAL_CONNECTION_LOST'));
192
+ const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
193
+ const bytes = Buffer.byteLength(value);
194
+ if (this.#writeBytes + bytes > MAX_BUFFER_BYTES)
195
+ return Promise.reject(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
196
+ const id = ++this.#nextId;
197
+ return new Promise((resolve, reject) => {
198
+ this.#writeBytes += bytes;
199
+ this.#writes.set(id, { resolve, reject, bytes });
200
+ sendIpc(this.#child, { type: 'send', id, frame: value }, () =>
201
+ this.fail(new Error('TERMINAL_CONNECTION_LOST'))
202
+ );
203
+ });
204
+ }
205
+
206
+ written(id, code) {
207
+ const write = this.#writes.get(id);
208
+ if (!write) return;
209
+ this.#writes.delete(id);
210
+ this.#writeBytes -= write.bytes;
211
+ if (code) write.reject(new Error(code));
212
+ else write.resolve();
213
+ }
214
+
215
+ fail(error) {
216
+ if (this.#closed) return;
217
+ this.#emit(
218
+ 'error',
219
+ new Error(
220
+ error instanceof DirectFallbackError || error instanceof DirectUnsafeError
221
+ ? 'TERMINAL_CONNECTION_LOST'
222
+ : error.message
223
+ )
224
+ );
225
+ this.close();
226
+ }
227
+
228
+ close() {
229
+ if (this.#closed) return;
230
+ this.#closed = true;
231
+ for (const { reject } of this.#writes.values()) reject(new Error('TERMINAL_CONNECTION_LOST'));
232
+ this.#writes.clear();
233
+ this.#writeBytes = 0;
234
+ this.#stop();
235
+ this.#emit('close');
236
+ }
237
+
238
+ #emit(type, value, isBinary = false) {
239
+ if (!this.#active) this.#events.push([type, value, isBinary]);
240
+ else if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, value, isBinary);
241
+ }
242
+ }
243
+
244
+ function runChild() {
245
+ const controller = new AbortController();
246
+ const callbacks = new Map();
247
+ let nextId = 0;
248
+ let transport;
249
+ let native;
250
+ let cleaning = false;
251
+ const cleanup = () => {
252
+ if (cleaning) return;
253
+ cleaning = true;
254
+ setImmediate(() => {
255
+ try {
256
+ native?.cleanup();
257
+ } catch {
258
+ /* Process exit also releases native resources. */
259
+ }
260
+ sendIpc(
261
+ process,
262
+ { type: 'finished' },
263
+ () => process.exit(0),
264
+ () => process.exit(0)
265
+ );
266
+ });
267
+ };
268
+ const callback = (event) =>
269
+ new Promise((resolve, reject) => {
270
+ const id = ++nextId;
271
+ callbacks.set(id, { resolve, reject });
272
+ sendIpc(process, { type: 'callback', id, event }, reject);
273
+ });
274
+ const abort = () => {
275
+ controller.abort();
276
+ for (const { reject } of callbacks.values())
277
+ reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
278
+ callbacks.clear();
279
+ if (transport) transport.close();
280
+ };
281
+ process.on('disconnect', () => {
282
+ abort();
283
+ cleanup();
284
+ });
285
+ process.on('message', async (message) => {
286
+ if (message.type === 'callback.result') {
287
+ const pending = callbacks.get(message.id);
288
+ callbacks.delete(message.id);
289
+ if (message.failed) pending?.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
290
+ else pending?.resolve();
291
+ } else if (message.type === 'abort') {
292
+ abort();
293
+ } else if (message.type === 'send') {
294
+ try {
295
+ await transport.send(message.frame);
296
+ sendIpc(process, { type: 'send.result', id: message.id });
297
+ } catch {
298
+ sendIpc(process, { type: 'send.result', id: message.id, code: 'TERMINAL_INPUT_FAILED' });
299
+ }
300
+ } else if (message.type === 'start') {
301
+ try {
302
+ const { MarctlClient } = await import('./client.mjs');
303
+ const { openDirectTerminal } = await import('./direct.mjs');
304
+ const { default: WebSocketClass } = await import('ws');
305
+ native = await import('node-datachannel');
306
+ const { connection, ...options } = message.options;
307
+ let iceSelection;
308
+ transport = await openDirectTerminal({
309
+ ...options,
310
+ client: new MarctlClient(connection),
311
+ WebSocketClass,
312
+ peerConnectionFactory: (name, config) => new native.PeerConnection(name, config),
313
+ cleanupNative: cleanup,
314
+ signal: controller.signal,
315
+ onPrepared: () => callback('prepared'),
316
+ onIceConnected: () => {
317
+ iceSelection = callback('ice');
318
+ iceSelection.catch(() => controller.abort());
319
+ },
320
+ onTransportReady: async () => {
321
+ await iceSelection;
322
+ await callback('transport');
323
+ },
324
+ beforeOpen: () => callback('opening')
325
+ });
326
+ sendIpc(process, { type: 'attached' });
327
+ transport.on('message', (frame, isBinary) =>
328
+ sendIpc(process, { type: 'frame', frame: String(frame), isBinary }, abort)
329
+ );
330
+ transport.on('error', (error) => {
331
+ sendIpc(process, { type: 'failure', code: error.message });
332
+ abort();
333
+ });
334
+ transport.on('close', () => {
335
+ sendIpc(process, { type: 'failure', code: 'TERMINAL_DIRECT_CLOSED' });
336
+ cleanup();
337
+ });
338
+ transport.activate();
339
+ } catch (error) {
340
+ sendIpc(process, {
341
+ type: 'failure',
342
+ name: error instanceof DirectUnsafeError ? 'DirectUnsafeError' : 'DirectFallbackError',
343
+ code: error.code ?? 'DIRECT_UNAVAILABLE'
344
+ });
345
+ cleanup();
346
+ }
347
+ }
348
+ });
349
+ }
350
+
351
+ if (process.send && process.argv[1] === fileURLToPath(import.meta.url)) runChild();
package/src/direct.mjs CHANGED
@@ -22,21 +22,26 @@ export class DirectUnsafeError extends Error {
22
22
  }
23
23
  }
24
24
 
25
- export async function openDirectTerminal({
26
- client,
27
- WebSocketClass,
28
- nodeId,
29
- terminalId,
30
- manifest,
31
- afterSequence = 0,
32
- timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
33
- heartbeatMs = DIRECT_HEARTBEAT_MS,
34
- peerConnectionFactory,
35
- onPrepared,
36
- onIceConnected,
37
- onTransportReady,
38
- signal
39
- }) {
25
+ export async function openDirectTerminal(options) {
26
+ const {
27
+ client,
28
+ WebSocketClass,
29
+ nodeId,
30
+ terminalId,
31
+ afterSequence = 0,
32
+ timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
33
+ heartbeatMs = DIRECT_HEARTBEAT_MS,
34
+ peerConnectionFactory,
35
+ onPrepared,
36
+ onIceConnected,
37
+ onTransportReady,
38
+ beforeOpen,
39
+ cleanupNative: cleanupNativeImpl,
40
+ signal
41
+ } = options;
42
+ let { manifest } = options;
43
+ if (peerConnectionFactory === undefined && typeof client.openDirectTerminal === 'function')
44
+ return client.openDirectTerminal(options);
40
45
  if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
41
46
  throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
42
47
 
@@ -50,7 +55,7 @@ export async function openDirectTerminal({
50
55
  let unsafeFallback = false;
51
56
  let transportControlCleanup;
52
57
  let transportNegotiationActive = true;
53
- let cleanupNative;
58
+ let cleanupNative = cleanupNativeImpl;
54
59
  try {
55
60
  manifest ??= await client.getTerminalManifest(nodeId);
56
61
  if (
@@ -82,7 +87,7 @@ export async function openDirectTerminal({
82
87
 
83
88
  const native =
84
89
  peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
85
- cleanupNative = native?.cleanup;
90
+ cleanupNative ??= native?.cleanup;
86
91
  const createPeer =
87
92
  peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
88
93
  peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
@@ -252,6 +257,7 @@ export async function openDirectTerminal({
252
257
  heartbeatMs,
253
258
  cleanupNative
254
259
  });
260
+ if (beforeOpen) await beforeOpen();
255
261
  await withTimeout(
256
262
  transport.waitForAttachment(terminalPrepared.grant),
257
263
  timeoutMs,
@@ -1,9 +1,7 @@
1
- const configurationHelp = `Configuration:
2
- MAR-launched terminals and Workspace Agent Runs use the current user's Node permissions;
3
- scheduled runs use the task owner. No management key is needed in these contexts.
4
- Outside MAR, use ~/.marctl/default.env (%HOME%\\.marctl\\default.env on Windows;
5
- the system home directory is used when HOME is unset), or select a file with --env-file.
6
- The file must contain MAR_CTL_SERVER_URL and MAR_CTL_KEY. Never pass a key as an argument.`;
1
+ const configurationHelp = `Authorization:
2
+ Node access is available only in MAR-launched terminals and Workspace Agent Runs;
3
+ scheduled runs use the task owner. MAR supplies short-lived user credentials.
4
+ External terminals and persistent administrator keys are not supported.`;
7
5
 
8
6
  export default Object.freeze({
9
7
  help: `marctl — MyAgentRoam command-line interface
@@ -20,7 +18,6 @@ Commands:
20
18
  help Show help for the CLI or a command.
21
19
 
22
20
  Options:
23
- --env-file PATH Use the specified management configuration file.
24
21
  -h, --help Show help and exit.
25
22
  -V, --version Print the CLI package version and exit.
26
23
 
@@ -40,23 +37,21 @@ ${configurationHelp}
40
37
 
41
38
  Notes:
42
39
  Help and version work offline without configuration or authorization.
43
- Put local help/version options before a Node or file endpoint; --env-file may also follow a command.
40
+ Put local help/version options before a Node or file endpoint.
44
41
  Each alias shows only its own command help. Use marctl help <command> for details.
45
42
  Remote access uses authorized Node Terminal transport, not system SSH or SCP.`,
46
43
  commandHelp: Object.freeze({
47
44
  nodes: `{program} — List accessible Nodes
48
45
 
49
46
  Usage:
50
- {program} [--env-file PATH] [options]
47
+ {program} [options]
51
48
 
52
49
  Options:
53
- --env-file PATH Use the specified management configuration file.
54
50
  -h, --help Show help and exit.
55
51
  -V, --version Print the CLI package version and exit.
56
52
 
57
53
  Examples:
58
54
  {program}
59
- {program} --env-file ~/.marctl/production.env
60
55
 
61
56
  Output:
62
57
  Tab-separated columns: ID, NAME, STATUS, PLATFORM.
@@ -66,14 +61,13 @@ ${configurationHelp}`,
66
61
  ssh: `{program} — Open a terminal or execute a remote command
67
62
 
68
63
  Usage:
69
- {program} [--env-file PATH] [options] <node> [command [arg...]]
64
+ {program} [options] <node> [command [arg...]]
70
65
 
71
66
  Arguments:
72
67
  <node> An authorized Node ID or an exact, unique Node name.
73
68
  [command ...] A command for the remote shell; omit it for an interactive shell.
74
69
 
75
70
  Options (before <node>):
76
- --env-file PATH Use the specified management configuration file.
77
71
  -h, --help Show help and exit.
78
72
  -V, --version Print the CLI package version and exit.
79
73
 
@@ -96,7 +90,7 @@ ${configurationHelp}`,
96
90
  scp: `{program} — Copy a regular file
97
91
 
98
92
  Usage:
99
- {program} [--env-file PATH] [options] <source> <destination>
93
+ {program} [options] <source> <destination>
100
94
 
101
95
  Arguments:
102
96
  <source> A local file or an authorized Node file endpoint.
@@ -108,7 +102,6 @@ Arguments:
108
102
  Options:
109
103
  -p Preserve source permissions and access/modification times.
110
104
  -q Suppress progress output (already the default).
111
- --env-file PATH Use the specified management configuration file.
112
105
  -h, --help Show help and exit; place before the first endpoint.
113
106
  -V, --version Print the CLI package version and exit; place before the first endpoint.
114
107
  -- Treat remaining arguments as literal paths.
@@ -156,21 +149,9 @@ or Server connection is required.`
156
149
  disconnected: 'disconnected',
157
150
  errors: Object.freeze({
158
151
  userContextUnavailable: 'This Node session has no valid marctl user authorization.',
159
- configServerUrlMissing: 'MAR_CTL_SERVER_URL is not configured.',
160
- configKeyMissing: 'MAR_CTL_KEY is not configured.',
161
- configKeyInvalid: 'MAR_CTL_KEY contains invalid characters.',
162
152
  serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
163
153
  serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTPS or loopback HTTP.',
164
154
  serverUrlOrigin: 'MAR_CTL_SERVER_URL must be an origin without credentials or query data.',
165
- envFileMissing: 'The requested environment file was not found.',
166
- envFileRead: 'The environment file could not be read.',
167
- envFileSymlink: 'The environment file must not be a symbolic link. Use chmod 600.',
168
- envFileType: 'The environment file must be a regular file. Use chmod 600.',
169
- envFilePermissions:
170
- 'The environment file must be owned by the current user and have mode 0600 or 0400. Use chmod 600.',
171
- envFileEntry: 'Invalid environment entry.',
172
- envFileUnsupported: 'Unsupported environment variable.',
173
- envFileQuoted: 'Invalid quoted environment value.',
174
155
  fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
175
156
  serverInvalidNodes: 'The server returned an invalid node list.',
176
157
  serverInvalidManifest: 'The server returned an invalid node manifest.',
@@ -212,7 +193,6 @@ or Server connection is required.`
212
193
  terminalDirectUnsafeFallback:
213
194
  'Direct terminal setup could not be safely canceled after OPEN; relay fallback was skipped.',
214
195
  invalidArguments: 'Invalid command arguments.',
215
- envFileValueRequired: 'A value is required for --env-file.',
216
196
  sshOptionsUnsupported: 'SSH options are not supported; use marssh <host>.',
217
197
  scpOptionsUnsupported: 'This SCP option is not supported by the Node Terminal transport.',
218
198
  commandFailed: 'The command failed.'
@@ -1,9 +1,6 @@
1
- const configurationHelp = `配置:
2
- MAR 打开的终端和普通 Workspace Agent Run 使用当前用户的节点权限,定时任务使用任务 owner 的权限;
3
- 这些上下文无需管理密钥。
4
- 独立使用时读取 ~/.marctl/default.env(Windows 为 %HOME%\\.marctl\\default.env,
5
- HOME 未设置时使用系统主目录),也可用 --env-file 指定文件。
6
- 文件须包含 MAR_CTL_SERVER_URL 和 MAR_CTL_KEY。不要通过命令参数传递密钥。`;
1
+ const configurationHelp = `授权:
2
+ 仅 MAR 打开的终端和普通 Workspace Agent Run 可访问节点,定时任务使用任务 owner 的权限。
3
+ MAR 自动提供短期用户凭证;不支持外部终端或持久管理员密钥。`;
7
4
 
8
5
  export default Object.freeze({
9
6
  help: `marctl — MyAgentRoam 命令行工具
@@ -20,7 +17,6 @@ export default Object.freeze({
20
17
  help 显示工具或指定子命令的帮助。
21
18
 
22
19
  选项:
23
- --env-file PATH 使用指定的管理配置文件。
24
20
  -h, --help 显示帮助后退出。
25
21
  -V, --version 显示 CLI 包版本后退出。
26
22
 
@@ -40,23 +36,21 @@ ${configurationHelp}
40
36
 
41
37
  说明:
42
38
  帮助和版本查询离线可用,无需配置或授权。
43
- 本机帮助、版本选项须放在节点或文件端点前;--env-file 也可放在子命令后。
39
+ 本机帮助、版本选项须放在节点或文件端点前。
44
40
  快捷命令仅显示对应操作的帮助。用 marctl help <子命令> 查看详细说明。
45
41
  远程访问使用授权的 Node Terminal 传输,不是系统 SSH 或 SCP 协议。`,
46
42
  commandHelp: Object.freeze({
47
43
  nodes: `{program} — 列出有权限的节点
48
44
 
49
45
  用法:
50
- {program} [--env-file PATH] [选项]
46
+ {program} [选项]
51
47
 
52
48
  选项:
53
- --env-file PATH 使用指定的管理配置文件。
54
49
  -h, --help 显示帮助后退出。
55
50
  -V, --version 显示 CLI 包版本后退出。
56
51
 
57
52
  示例:
58
53
  {program}
59
- {program} --env-file ~/.marctl/production.env
60
54
 
61
55
  输出:
62
56
  以制表符分隔的节点 ID、名称、状态、平台列。
@@ -66,14 +60,13 @@ ${configurationHelp}`,
66
60
  ssh: `{program} — 打开终端或执行远程命令
67
61
 
68
62
  用法:
69
- {program} [--env-file PATH] [选项] <节点> [command [arg...]]
63
+ {program} [选项] <节点> [command [arg...]]
70
64
 
71
65
  参数:
72
66
  <节点> 有权限的节点 ID,或精确、唯一的节点名称。
73
67
  [command ...] 交给远端 Shell 的命令;省略时打开交互 Shell。
74
68
 
75
69
  选项(放在节点前):
76
- --env-file PATH 使用指定的管理配置文件。
77
70
  -h, --help 显示帮助后退出。
78
71
  -V, --version 显示 CLI 包版本后退出。
79
72
 
@@ -94,7 +87,7 @@ ${configurationHelp}`,
94
87
  scp: `{program} — 复制普通文件
95
88
 
96
89
  用法:
97
- {program} [--env-file PATH] [选项] <源> <目标>
90
+ {program} [选项] <源> <目标>
98
91
 
99
92
  参数:
100
93
  <源> 本机文件或有权限的节点文件端点。
@@ -106,7 +99,6 @@ ${configurationHelp}`,
106
99
  选项:
107
100
  -p 保留源文件权限及访问、修改时间。
108
101
  -q 不显示进度(默认已不显示)。
109
- --env-file PATH 使用指定的管理配置文件。
110
102
  -h, --help 显示帮助后退出,须放在第一个文件端点前。
111
103
  -V, --version 显示 CLI 包版本后退出,须放在第一个文件端点前。
112
104
  -- 后续参数按字面路径处理。
@@ -151,20 +143,9 @@ ${configurationHelp}`,
151
143
  disconnected: '离线',
152
144
  errors: Object.freeze({
153
145
  userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
154
- configServerUrlMissing: '未配置 MAR_CTL_SERVER_URL。',
155
- configKeyMissing: '未配置 MAR_CTL_KEY。',
156
- configKeyInvalid: 'MAR_CTL_KEY 包含无效字符。',
157
146
  serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
158
147
  serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
159
148
  serverUrlOrigin: 'MAR_CTL_SERVER_URL 必须是不含凭据或查询参数的 Origin。',
160
- envFileMissing: '指定的环境文件不存在。',
161
- envFileRead: '无法读取环境文件。',
162
- envFileSymlink: '环境文件不能是符号链接。请使用 chmod 600。',
163
- envFileType: '环境文件必须是普通文件。请使用 chmod 600。',
164
- envFilePermissions: '环境文件必须归当前用户所有,权限必须为 0600 或 0400。请使用 chmod 600。',
165
- envFileEntry: '环境文件条目无效。',
166
- envFileUnsupported: '环境变量不受支持。',
167
- envFileQuoted: '带引号的环境变量值无效。',
168
149
  fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
169
150
  serverInvalidNodes: 'Server 返回的节点列表无效。',
170
151
  serverInvalidManifest: 'Server 返回的节点清单无效。',
@@ -203,7 +184,6 @@ ${configurationHelp}`,
203
184
  terminalSessionFailed: '终端会话失败。',
204
185
  terminalDirectUnsafeFallback: 'OPEN 后无法安全取消直连终端,已跳过 relay 回退。',
205
186
  invalidArguments: '命令参数无效。',
206
- envFileValueRequired: '--env-file 必须提供值。',
207
187
  sshOptionsUnsupported: '不支持 SSH 选项,请使用 marssh <节点>。',
208
188
  scpOptionsUnsupported: 'Node Terminal 传输不支持此 SCP 选项。',
209
189
  terminalBinaryUnavailable: '二进制复制需要支持 pipe 终端的 Server 与 Node。',
package/src/terminal.mjs CHANGED
@@ -583,7 +583,9 @@ function runAttachedTerminal({
583
583
  const onResize = () => {
584
584
  try {
585
585
  const { rows, cols } = getDimensions(stdout);
586
- send({ type: 'resize', terminalId, rows, cols });
586
+ Promise.resolve(send({ type: 'resize', terminalId, rows, cols })).catch(() =>
587
+ fail('errors.terminalResizeFailed')
588
+ );
587
589
  } catch {
588
590
  fail('errors.terminalResizeFailed');
589
591
  }
@@ -686,7 +688,7 @@ function rawToString(raw) {
686
688
  function sendCloseFrame(socket, terminalId) {
687
689
  if (!terminalId || !isSocketOpen(socket)) return;
688
690
  try {
689
- socket.send(JSON.stringify({ type: 'close', terminalId }));
691
+ Promise.resolve(socket.send(JSON.stringify({ type: 'close', terminalId }))).catch(() => {});
690
692
  } catch {
691
693
  // The DELETE request below remains the authoritative cleanup path.
692
694
  }