@myagentroam/marctl 0.10.2 → 0.10.4
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 +26 -0
- package/package.json +1 -1
- package/src/cli.mjs +36 -19
- package/src/client.mjs +47 -4
- package/src/config.mjs +18 -128
- package/src/i18n/en-US.mjs +21 -28
- package/src/i18n/zh-CN.mjs +18 -27
- package/src/sessions.mjs +386 -0
package/NOTICE
CHANGED
|
@@ -9,3 +9,29 @@ Author and licensing contact: aCHAOS (achaosxyz@gmail.com)
|
|
|
9
9
|
Licensed under MyAgentRoam Enterprise Source-Available License 1.0; see
|
|
10
10
|
ee/LICENSE and LICENSING.md. Independent core and third-party notices remain
|
|
11
11
|
applicable; this attribution does not transfer any contributor's copyright.
|
|
12
|
+
|
|
13
|
+
Third-party supplement: http_ece@1.2.0 (Web Push encryption dependency)
|
|
14
|
+
The npm archive omits its license file; the upstream v1.2.0 text follows.
|
|
15
|
+
Source: https://github.com/martinthomson/encrypted-content-encoding/blob/0562510a30819f52424724a6fd5504becacd98a1/LICENSE
|
|
16
|
+
|
|
17
|
+
The MIT License (MIT)
|
|
18
|
+
|
|
19
|
+
Copyright (c) 2015 Martin Thomson
|
|
20
|
+
|
|
21
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
22
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
23
|
+
in the Software without restriction, including without limitation the rights
|
|
24
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
25
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
26
|
+
furnished to do so, subject to the following conditions:
|
|
27
|
+
|
|
28
|
+
The above copyright notice and this permission notice shall be included in all
|
|
29
|
+
copies or substantial portions of the Software.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { MarctlError, loadConfig } from './config.mjs';
|
|
|
4
4
|
import { createTranslator } from './i18n/index.mjs';
|
|
5
5
|
import { runTerminalSession } from './terminal.mjs';
|
|
6
6
|
import { runScp } from './scp.mjs';
|
|
7
|
+
import { parseSessionArgs, runSessionCommand, sessionExitCode } from './sessions.mjs';
|
|
7
8
|
|
|
8
9
|
export async function main(
|
|
9
10
|
argv = process.argv.slice(2),
|
|
@@ -11,14 +12,16 @@ export async function main(
|
|
|
11
12
|
env = process.env,
|
|
12
13
|
stdout = process.stdout,
|
|
13
14
|
stderr = process.stderr,
|
|
15
|
+
stdin = process.stdin,
|
|
14
16
|
program = 'marctl',
|
|
15
17
|
client,
|
|
16
18
|
WebSocketClass
|
|
17
19
|
} = {}
|
|
18
20
|
) {
|
|
19
21
|
const translate = createTranslator(env);
|
|
22
|
+
let args;
|
|
20
23
|
try {
|
|
21
|
-
|
|
24
|
+
args = parseArgs(argv, translate);
|
|
22
25
|
if (args.help) {
|
|
23
26
|
writeLine(
|
|
24
27
|
stdout,
|
|
@@ -38,7 +41,7 @@ export async function main(
|
|
|
38
41
|
return 0;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
const config = await loadConfig({ env,
|
|
44
|
+
const config = await loadConfig({ env, translate });
|
|
42
45
|
const marctlClient = client ?? new MarctlClient({ ...config, translate });
|
|
43
46
|
if (args.command === 'nodes') {
|
|
44
47
|
const nodes = await marctlClient.listNodes();
|
|
@@ -56,6 +59,18 @@ export async function main(
|
|
|
56
59
|
}
|
|
57
60
|
return 0;
|
|
58
61
|
}
|
|
62
|
+
if (args.command === 'sessions') {
|
|
63
|
+
const result = await runSessionCommand({
|
|
64
|
+
args,
|
|
65
|
+
client: marctlClient,
|
|
66
|
+
env,
|
|
67
|
+
stdin,
|
|
68
|
+
WebSocketClass,
|
|
69
|
+
translate
|
|
70
|
+
});
|
|
71
|
+
writeLine(stdout, JSON.stringify(result, null, args.json ? undefined : 2));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
59
74
|
if (args.command === 'scp') {
|
|
60
75
|
const socketClass = WebSocketClass ?? (await import('ws')).default;
|
|
61
76
|
return await runScp({
|
|
@@ -82,6 +97,22 @@ export async function main(
|
|
|
82
97
|
translate
|
|
83
98
|
});
|
|
84
99
|
} catch (error) {
|
|
100
|
+
if (argv[0] === 'sessions') {
|
|
101
|
+
const failure = {
|
|
102
|
+
code: error.code ?? 'MARCTL_COMMAND_FAILED',
|
|
103
|
+
message: error instanceof MarctlError ? error.message : translate('errors.commandFailed'),
|
|
104
|
+
...(error.requestId === undefined ? {} : { requestId: error.requestId }),
|
|
105
|
+
...(error.upstreamRequestId === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { upstreamRequestId: error.upstreamRequestId }),
|
|
108
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
109
|
+
...(error.clientMessageId === undefined ? {} : { clientMessageId: error.clientMessageId })
|
|
110
|
+
};
|
|
111
|
+
if (args?.json === true || (args === undefined && argv.includes('--json')))
|
|
112
|
+
writeLine(stdout, JSON.stringify({ error: failure }));
|
|
113
|
+
else writeLine(stderr, program + ': ' + failure.message + ' [' + failure.code + ']');
|
|
114
|
+
return sessionExitCode(error);
|
|
115
|
+
}
|
|
85
116
|
writeLine(
|
|
86
117
|
stderr,
|
|
87
118
|
`${program}: ${error instanceof MarctlError ? error.message : translate('errors.commandFailed')}`
|
|
@@ -110,7 +141,7 @@ export function executableArgs(executable, args) {
|
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
export function parseArgs(argv, translate = createTranslator()) {
|
|
113
|
-
|
|
144
|
+
if (argv[0] === 'sessions') return parseSessionArgs(argv.slice(1), translate);
|
|
114
145
|
let command;
|
|
115
146
|
let nodeId;
|
|
116
147
|
const remoteArgs = [];
|
|
@@ -133,18 +164,6 @@ export function parseArgs(argv, translate = createTranslator()) {
|
|
|
133
164
|
version = true;
|
|
134
165
|
continue;
|
|
135
166
|
}
|
|
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
167
|
if (!separator && argument === '--') {
|
|
149
168
|
separator = true;
|
|
150
169
|
continue;
|
|
@@ -154,6 +173,7 @@ export function parseArgs(argv, translate = createTranslator()) {
|
|
|
154
173
|
argument !== 'nodes' &&
|
|
155
174
|
argument !== 'ssh' &&
|
|
156
175
|
argument !== 'scp' &&
|
|
176
|
+
argument !== 'sessions' &&
|
|
157
177
|
argument !== 'version'
|
|
158
178
|
)
|
|
159
179
|
throw unsupportedSshOption(argument, translate);
|
|
@@ -186,12 +206,10 @@ export function parseArgs(argv, translate = createTranslator()) {
|
|
|
186
206
|
|
|
187
207
|
if (help) return { help: true, ...(command === undefined ? {} : { command }) };
|
|
188
208
|
if (version || command === 'version') return { version: true };
|
|
189
|
-
if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0)
|
|
190
|
-
return { command, ...(envFile === undefined ? {} : { envFile }) };
|
|
209
|
+
if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0) return { command };
|
|
191
210
|
if (command === 'scp' && nodeId !== undefined && remoteArgs.length === 1)
|
|
192
211
|
return {
|
|
193
212
|
command,
|
|
194
|
-
...(envFile === undefined ? {} : { envFile }),
|
|
195
213
|
source: nodeId,
|
|
196
214
|
destination: remoteArgs[0],
|
|
197
215
|
preserve
|
|
@@ -199,7 +217,6 @@ export function parseArgs(argv, translate = createTranslator()) {
|
|
|
199
217
|
if (command === 'ssh' && nodeId !== undefined && nodeId !== '')
|
|
200
218
|
return {
|
|
201
219
|
command,
|
|
202
|
-
...(envFile === undefined ? {} : { envFile }),
|
|
203
220
|
nodeId,
|
|
204
221
|
...(remoteArgs.length === 0
|
|
205
222
|
? {}
|
package/src/client.mjs
CHANGED
|
@@ -49,6 +49,19 @@ export class MarctlClient {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
async sessionCommand(body) {
|
|
53
|
+
const result = await this.#requestJson('/api/marctl/sessions', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body,
|
|
56
|
+
preserveError: true
|
|
57
|
+
});
|
|
58
|
+
if (!isRecord(result))
|
|
59
|
+
throw new MarctlError(this.#translate('errors.serverInvalidJson'), {
|
|
60
|
+
code: 'MARCTL_RESPONSE_INVALID'
|
|
61
|
+
});
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
async createTerminal({ nodeId, rows, cols, command, binary = false }) {
|
|
53
66
|
validateNodeId(nodeId, this.#translate);
|
|
54
67
|
validateDimensions(rows, cols, this.#translate);
|
|
@@ -158,7 +171,7 @@ export class MarctlClient {
|
|
|
158
171
|
return this.#request(pathname, { ...options, json: true });
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
async #request(pathname, { method = 'GET', body, json = false } = {}) {
|
|
174
|
+
async #request(pathname, { method = 'GET', body, json = false, preserveError = false } = {}) {
|
|
162
175
|
const headers = {
|
|
163
176
|
Accept: 'application/json',
|
|
164
177
|
Authorization: `Bearer ${this.#key}`
|
|
@@ -173,7 +186,35 @@ export class MarctlClient {
|
|
|
173
186
|
const timer = setTimeout(() => controller.abort(), this.#requestTimeoutMs);
|
|
174
187
|
try {
|
|
175
188
|
const response = await this.#fetch(new URL(pathname, `${this.#serverUrl}/`), request);
|
|
176
|
-
if (!response?.ok)
|
|
189
|
+
if (!response?.ok) {
|
|
190
|
+
if (preserveError) {
|
|
191
|
+
const payload = await response?.json().catch(() => undefined);
|
|
192
|
+
const failure = payload?.error;
|
|
193
|
+
if (
|
|
194
|
+
isRecord(failure) &&
|
|
195
|
+
typeof failure.code === 'string' &&
|
|
196
|
+
/^[A-Za-z][A-Za-z0-9_]{0,127}$/u.test(failure.code) &&
|
|
197
|
+
typeof failure.message === 'string'
|
|
198
|
+
) {
|
|
199
|
+
throw new MarctlError(failure.message.slice(0, 2000), {
|
|
200
|
+
code: failure.code,
|
|
201
|
+
statusCode: response.status,
|
|
202
|
+
requestId:
|
|
203
|
+
typeof failure.requestId === 'string' ? failure.requestId.slice(0, 160) : undefined,
|
|
204
|
+
upstreamRequestId:
|
|
205
|
+
typeof failure.upstreamRequestId === 'string'
|
|
206
|
+
? failure.upstreamRequestId.slice(0, 160)
|
|
207
|
+
: undefined,
|
|
208
|
+
details:
|
|
209
|
+
typeof failure.details === 'string' ? failure.details.slice(0, 4000) : undefined
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw new MarctlError(this.#translate('errors.serverRejected'), {
|
|
214
|
+
code: 'MARCTL_SERVER_REJECTED',
|
|
215
|
+
statusCode: response?.status
|
|
216
|
+
});
|
|
217
|
+
}
|
|
177
218
|
if (!json) return response;
|
|
178
219
|
try {
|
|
179
220
|
return await response.json();
|
|
@@ -181,7 +222,8 @@ export class MarctlClient {
|
|
|
181
222
|
throw new MarctlError(
|
|
182
223
|
this.#translate(
|
|
183
224
|
controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverInvalidJson'
|
|
184
|
-
)
|
|
225
|
+
),
|
|
226
|
+
{ code: controller.signal.aborted ? 'MARCTL_REQUEST_TIMEOUT' : 'MARCTL_RESPONSE_INVALID' }
|
|
185
227
|
);
|
|
186
228
|
}
|
|
187
229
|
} catch (error) {
|
|
@@ -189,7 +231,8 @@ export class MarctlClient {
|
|
|
189
231
|
throw new MarctlError(
|
|
190
232
|
this.#translate(
|
|
191
233
|
controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverUnreachable'
|
|
192
|
-
)
|
|
234
|
+
),
|
|
235
|
+
{ code: controller.signal.aborted ? 'MARCTL_REQUEST_TIMEOUT' : 'MARCTL_SERVER_UNREACHABLE' }
|
|
193
236
|
);
|
|
194
237
|
} finally {
|
|
195
238
|
clearTimeout(timer);
|
package/src/config.mjs
CHANGED
|
@@ -1,67 +1,31 @@
|
|
|
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);
|
|
11
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
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
(
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
15
|
+
export async function loadConfig({ env = process.env, translate = createTranslator(env) } = {}) {
|
|
16
|
+
const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
|
|
17
|
+
const token = nonEmptyString(env.MAR_CTL_USER_TOKEN);
|
|
18
|
+
if (
|
|
19
|
+
env.MAR_CTL_CONTEXT_REQUIRED !== '1' ||
|
|
20
|
+
serverUrl === undefined ||
|
|
21
|
+
token === undefined ||
|
|
22
|
+
!/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
|
|
23
|
+
)
|
|
24
|
+
throw new MarctlError(translate('errors.userContextUnavailable'), {
|
|
25
|
+
code: 'UNAUTHENTICATED',
|
|
26
|
+
statusCode: 401
|
|
46
27
|
});
|
|
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
|
-
});
|
|
28
|
+
return Object.freeze({ serverUrl: validateServerUrl(serverUrl, translate), key: token });
|
|
65
29
|
}
|
|
66
30
|
|
|
67
31
|
export function validateServerUrl(value, translate = createTranslator()) {
|
|
@@ -86,80 +50,6 @@ export function validateServerUrl(value, translate = createTranslator()) {
|
|
|
86
50
|
return url.origin;
|
|
87
51
|
}
|
|
88
52
|
|
|
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
53
|
function nonEmptyString(value) {
|
|
164
54
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
165
55
|
}
|
package/src/i18n/en-US.mjs
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
const configurationHelp = `
|
|
2
|
-
MAR-launched terminals and Workspace Agent Runs
|
|
3
|
-
scheduled runs use the task owner.
|
|
4
|
-
|
|
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
|
|
@@ -13,6 +11,7 @@ Usage:
|
|
|
13
11
|
marctl help [command]
|
|
14
12
|
|
|
15
13
|
Commands:
|
|
14
|
+
sessions Inspect and coordinate Sessions in the current Workspace.
|
|
16
15
|
nodes List accessible Nodes and their connection status.
|
|
17
16
|
ssh Open a Node terminal or execute a remote command.
|
|
18
17
|
scp Copy a regular file between this machine and Nodes, or between Nodes.
|
|
@@ -20,7 +19,6 @@ Commands:
|
|
|
20
19
|
help Show help for the CLI or a command.
|
|
21
20
|
|
|
22
21
|
Options:
|
|
23
|
-
--env-file PATH Use the specified management configuration file.
|
|
24
22
|
-h, --help Show help and exit.
|
|
25
23
|
-V, --version Print the CLI package version and exit.
|
|
26
24
|
|
|
@@ -40,23 +38,23 @@ ${configurationHelp}
|
|
|
40
38
|
|
|
41
39
|
Notes:
|
|
42
40
|
Help and version work offline without configuration or authorization.
|
|
43
|
-
Put local help/version options before a Node or file endpoint
|
|
41
|
+
Put local help/version options before a Node or file endpoint.
|
|
44
42
|
Each alias shows only its own command help. Use marctl help <command> for details.
|
|
45
43
|
Remote access uses authorized Node Terminal transport, not system SSH or SCP.`,
|
|
46
44
|
commandHelp: Object.freeze({
|
|
45
|
+
sessions:
|
|
46
|
+
'{program} — Coordinate Sessions in the current MAR Workspace\n\nUsage:\n {program} current [--json]\n {program} list [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} show <session-id> [--json]\n {program} history <session-id> [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} start --runner <runner> [-m <text> | --message-file <file|->] [--image <path> ...] [--model <id>] [--title <name>] [--client-message-id <id>] [--json]\n {program} send <session-id> [-m <text> | --message-file <file|->] [--image <path> ...] [--delivery send|queue|replace-current] [--client-message-id <id>] [--json]\n {program} stop <session-id> [--run <run-id>] [--json]\n {program} goal get|clear <session-id> [--json]\n {program} goal set <session-id> (-m <text> | --message-file <file|->) [--json]\n {program} wait <session-id> --run <run-id> --timeout <30s|5m> [--json]\n\nRunners: mar-agent, codex, claude-code, opencode; actual capabilities apply.\n-m / --message supplies text; it is mutually exclusive with --message-file.\nFiles use UTF-8; - reads stdin. Repeat --image for PNG/JPEG/GIF/WebP files.\nStart/send allow image-only input: up to 5 images, 5 MiB each, 10 MiB total.\nGoal set accepts text only. Start/send return a clientMessageId.\nCredentials expire 24 hours after issue without automatic renewal. Scope is bound\nby MAR to the current Node/Workspace; there are no scope override options.\nSend defaults to non-interrupting delivery. Queued runId may identify another\nactive input: correlate queueItemId/clientMessageId with visible history.\nWait observes the specified Run through Watch. Timeout does not stop it;\nRun success is not proof of task success. Completion never wakes a Coordinator.\nOn an unknown delivery outcome, inspect state/history; do not blindly resend.\nCreate workers as needed, e.g. --title "fixer#42 Handle empty totals" or\n--title "reviewer#42 Review empty totals". Titles are not routing identities;\nrecord returned Session IDs. Put task/role instructions in the first message.\nSessions share files and the Git index; no automatic worktree or worker pool.\n\nExit codes: 1 failure, 2 input, 3 authorization, 4 unsupported, 5 timeout,\n6 unknown outcome, 130 cancelled wait. --json prints one result/error object.',
|
|
47
47
|
nodes: `{program} — List accessible Nodes
|
|
48
48
|
|
|
49
49
|
Usage:
|
|
50
|
-
{program} [
|
|
50
|
+
{program} [options]
|
|
51
51
|
|
|
52
52
|
Options:
|
|
53
|
-
--env-file PATH Use the specified management configuration file.
|
|
54
53
|
-h, --help Show help and exit.
|
|
55
54
|
-V, --version Print the CLI package version and exit.
|
|
56
55
|
|
|
57
56
|
Examples:
|
|
58
57
|
{program}
|
|
59
|
-
{program} --env-file ~/.marctl/production.env
|
|
60
58
|
|
|
61
59
|
Output:
|
|
62
60
|
Tab-separated columns: ID, NAME, STATUS, PLATFORM.
|
|
@@ -66,14 +64,13 @@ ${configurationHelp}`,
|
|
|
66
64
|
ssh: `{program} — Open a terminal or execute a remote command
|
|
67
65
|
|
|
68
66
|
Usage:
|
|
69
|
-
{program} [
|
|
67
|
+
{program} [options] <node> [command [arg...]]
|
|
70
68
|
|
|
71
69
|
Arguments:
|
|
72
70
|
<node> An authorized Node ID or an exact, unique Node name.
|
|
73
71
|
[command ...] A command for the remote shell; omit it for an interactive shell.
|
|
74
72
|
|
|
75
73
|
Options (before <node>):
|
|
76
|
-
--env-file PATH Use the specified management configuration file.
|
|
77
74
|
-h, --help Show help and exit.
|
|
78
75
|
-V, --version Print the CLI package version and exit.
|
|
79
76
|
|
|
@@ -96,7 +93,7 @@ ${configurationHelp}`,
|
|
|
96
93
|
scp: `{program} — Copy a regular file
|
|
97
94
|
|
|
98
95
|
Usage:
|
|
99
|
-
{program} [
|
|
96
|
+
{program} [options] <source> <destination>
|
|
100
97
|
|
|
101
98
|
Arguments:
|
|
102
99
|
<source> A local file or an authorized Node file endpoint.
|
|
@@ -108,7 +105,6 @@ Arguments:
|
|
|
108
105
|
Options:
|
|
109
106
|
-p Preserve source permissions and access/modification times.
|
|
110
107
|
-q Suppress progress output (already the default).
|
|
111
|
-
--env-file PATH Use the specified management configuration file.
|
|
112
108
|
-h, --help Show help and exit; place before the first endpoint.
|
|
113
109
|
-V, --version Print the CLI package version and exit; place before the first endpoint.
|
|
114
110
|
-- Treat remaining arguments as literal paths.
|
|
@@ -155,22 +151,20 @@ or Server connection is required.`
|
|
|
155
151
|
connected: 'connected',
|
|
156
152
|
disconnected: 'disconnected',
|
|
157
153
|
errors: Object.freeze({
|
|
154
|
+
sessionOutcomeUnknown:
|
|
155
|
+
'The delivery outcome is unknown. Inspect Session state/history before retrying.',
|
|
156
|
+
sessionMessageInvalid:
|
|
157
|
+
'The message must be non-empty and within the length limit; input files must be readable UTF-8.',
|
|
158
|
+
sessionImageInvalid:
|
|
159
|
+
'Images must be readable PNG, JPEG, GIF or WebP files: at most 5 images, 5 MiB each and 10 MiB total.',
|
|
160
|
+
sessionWaitUnknown: 'The specified Run could not be confirmed. Inspect Session state/history.',
|
|
161
|
+
sessionWaitCancelled: 'Waiting was cancelled; the target Run was not stopped.',
|
|
162
|
+
sessionWaitTimeout: 'Waiting timed out; the target Run was not stopped.',
|
|
163
|
+
|
|
158
164
|
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
165
|
serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
|
|
163
166
|
serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTPS or loopback HTTP.',
|
|
164
167
|
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
168
|
fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
|
|
175
169
|
serverInvalidNodes: 'The server returned an invalid node list.',
|
|
176
170
|
serverInvalidManifest: 'The server returned an invalid node manifest.',
|
|
@@ -212,7 +206,6 @@ or Server connection is required.`
|
|
|
212
206
|
terminalDirectUnsafeFallback:
|
|
213
207
|
'Direct terminal setup could not be safely canceled after OPEN; relay fallback was skipped.',
|
|
214
208
|
invalidArguments: 'Invalid command arguments.',
|
|
215
|
-
envFileValueRequired: 'A value is required for --env-file.',
|
|
216
209
|
sshOptionsUnsupported: 'SSH options are not supported; use marssh <host>.',
|
|
217
210
|
scpOptionsUnsupported: 'This SCP option is not supported by the Node Terminal transport.',
|
|
218
211
|
commandFailed: 'The command failed.'
|
package/src/i18n/zh-CN.mjs
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
const configurationHelp =
|
|
2
|
-
MAR 打开的终端和普通 Workspace Agent Run
|
|
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 命令行工具
|
|
@@ -13,6 +10,7 @@ export default Object.freeze({
|
|
|
13
10
|
marctl help [子命令]
|
|
14
11
|
|
|
15
12
|
子命令:
|
|
13
|
+
sessions 查看和协调当前工作区中的会话。
|
|
16
14
|
nodes 列出有权限的节点及连接状态。
|
|
17
15
|
ssh 打开节点终端或执行远程命令。
|
|
18
16
|
scp 在当前机器与节点之间、节点之间复制普通文件。
|
|
@@ -20,7 +18,6 @@ export default Object.freeze({
|
|
|
20
18
|
help 显示工具或指定子命令的帮助。
|
|
21
19
|
|
|
22
20
|
选项:
|
|
23
|
-
--env-file PATH 使用指定的管理配置文件。
|
|
24
21
|
-h, --help 显示帮助后退出。
|
|
25
22
|
-V, --version 显示 CLI 包版本后退出。
|
|
26
23
|
|
|
@@ -40,23 +37,23 @@ ${configurationHelp}
|
|
|
40
37
|
|
|
41
38
|
说明:
|
|
42
39
|
帮助和版本查询离线可用,无需配置或授权。
|
|
43
|
-
|
|
40
|
+
本机帮助、版本选项须放在节点或文件端点前。
|
|
44
41
|
快捷命令仅显示对应操作的帮助。用 marctl help <子命令> 查看详细说明。
|
|
45
42
|
远程访问使用授权的 Node Terminal 传输,不是系统 SSH 或 SCP 协议。`,
|
|
46
43
|
commandHelp: Object.freeze({
|
|
44
|
+
sessions:
|
|
45
|
+
'{program} — 协调当前 MAR 工作区中的会话\n\n用法:\n {program} current [--json]\n {program} list [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} show <session-id> [--json]\n {program} history <session-id> [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} start --runner <runner> [-m <text> | --message-file <file|->] [--image <path> ...] [--model <id>] [--title <name>] [--client-message-id <id>] [--json]\n {program} send <session-id> [-m <text> | --message-file <file|->] [--image <path> ...] [--delivery send|queue|replace-current] [--client-message-id <id>] [--json]\n {program} stop <session-id> [--run <run-id>] [--json]\n {program} goal get|clear <session-id> [--json]\n {program} goal set <session-id> (-m <text> | --message-file <file|->) [--json]\n {program} wait <session-id> --run <run-id> --timeout <30s|5m> [--json]\n\nRunner:mar-agent、codex、claude-code、opencode,操作以实际能力为准。\n-m / --message 直接传文本,与 --message-file 互斥;文件用 UTF-8,- 从 stdin 读取。\nstart/send 可重复 --image 传 PNG/JPEG/GIF/WebP 图片,也可只发送图片。\n最多 5 张,单张 5 MiB、合计 10 MiB;goal set 只接受文本。\nstart/send 返回 clientMessageId。\n凭据自签发起 24 小时到期,不自动续签。MAR 绑定当前 Node/Workspace,\n不提供覆盖范围的参数。send 默认不打断;排队回执的 runId 可能属于\n其他活动输入,需按 queueItemId/clientMessageId 与可见历史核对。\nwait 通过 Watch 等待指定 Run;超时不会停止任务,Run 成功不等于业务完成。\nWorker 完成不自动唤醒 Coordinator。投递结果未知时先查状态/历史,不盲目重发。\n按任务需要创建会话,例如 --title "fixer#42 Handle empty totals" 或\n--title "reviewer#42 Review empty totals";标题不是寻址身份,保存返回的会话 ID。\n角色和任务要求放入首条普通消息。会话共享文件与 Git 索引,不自动隔离工作目录或维护池。\n\n退出码:1 失败,2 参数,3 授权,4 不支持,5 超时,6 结果未知,130 取消等待。\n--json 只输出一个结果或错误对象。',
|
|
47
46
|
nodes: `{program} — 列出有权限的节点
|
|
48
47
|
|
|
49
48
|
用法:
|
|
50
|
-
{program} [
|
|
49
|
+
{program} [选项]
|
|
51
50
|
|
|
52
51
|
选项:
|
|
53
|
-
--env-file PATH 使用指定的管理配置文件。
|
|
54
52
|
-h, --help 显示帮助后退出。
|
|
55
53
|
-V, --version 显示 CLI 包版本后退出。
|
|
56
54
|
|
|
57
55
|
示例:
|
|
58
56
|
{program}
|
|
59
|
-
{program} --env-file ~/.marctl/production.env
|
|
60
57
|
|
|
61
58
|
输出:
|
|
62
59
|
以制表符分隔的节点 ID、名称、状态、平台列。
|
|
@@ -66,14 +63,13 @@ ${configurationHelp}`,
|
|
|
66
63
|
ssh: `{program} — 打开终端或执行远程命令
|
|
67
64
|
|
|
68
65
|
用法:
|
|
69
|
-
{program} [
|
|
66
|
+
{program} [选项] <节点> [command [arg...]]
|
|
70
67
|
|
|
71
68
|
参数:
|
|
72
69
|
<节点> 有权限的节点 ID,或精确、唯一的节点名称。
|
|
73
70
|
[command ...] 交给远端 Shell 的命令;省略时打开交互 Shell。
|
|
74
71
|
|
|
75
72
|
选项(放在节点前):
|
|
76
|
-
--env-file PATH 使用指定的管理配置文件。
|
|
77
73
|
-h, --help 显示帮助后退出。
|
|
78
74
|
-V, --version 显示 CLI 包版本后退出。
|
|
79
75
|
|
|
@@ -94,7 +90,7 @@ ${configurationHelp}`,
|
|
|
94
90
|
scp: `{program} — 复制普通文件
|
|
95
91
|
|
|
96
92
|
用法:
|
|
97
|
-
{program} [
|
|
93
|
+
{program} [选项] <源> <目标>
|
|
98
94
|
|
|
99
95
|
参数:
|
|
100
96
|
<源> 本机文件或有权限的节点文件端点。
|
|
@@ -106,7 +102,6 @@ ${configurationHelp}`,
|
|
|
106
102
|
选项:
|
|
107
103
|
-p 保留源文件权限及访问、修改时间。
|
|
108
104
|
-q 不显示进度(默认已不显示)。
|
|
109
|
-
--env-file PATH 使用指定的管理配置文件。
|
|
110
105
|
-h, --help 显示帮助后退出,须放在第一个文件端点前。
|
|
111
106
|
-V, --version 显示 CLI 包版本后退出,须放在第一个文件端点前。
|
|
112
107
|
-- 后续参数按字面路径处理。
|
|
@@ -150,21 +145,18 @@ ${configurationHelp}`,
|
|
|
150
145
|
connected: '在线',
|
|
151
146
|
disconnected: '离线',
|
|
152
147
|
errors: Object.freeze({
|
|
148
|
+
sessionOutcomeUnknown: '投递结果未知,请先查看会话状态和历史,不要盲目重试。',
|
|
149
|
+
sessionMessageInvalid: '消息不能为空或超过长度限制;输入文件必须是可读取的 UTF-8 文本。',
|
|
150
|
+
sessionImageInvalid:
|
|
151
|
+
'图片必须是可读取的 PNG、JPEG、GIF 或 WebP 文件:最多 5 张,单张 5 MiB,合计 10 MiB。',
|
|
152
|
+
sessionWaitUnknown: '无法确认指定 Run,请查看会话状态和历史。',
|
|
153
|
+
sessionWaitCancelled: '已取消等待,未停止目标 Run。',
|
|
154
|
+
sessionWaitTimeout: '等待超时,未停止目标 Run。',
|
|
155
|
+
|
|
153
156
|
userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
|
|
154
|
-
configServerUrlMissing: '未配置 MAR_CTL_SERVER_URL。',
|
|
155
|
-
configKeyMissing: '未配置 MAR_CTL_KEY。',
|
|
156
|
-
configKeyInvalid: 'MAR_CTL_KEY 包含无效字符。',
|
|
157
157
|
serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
|
|
158
158
|
serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
|
|
159
159
|
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
160
|
fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
|
|
169
161
|
serverInvalidNodes: 'Server 返回的节点列表无效。',
|
|
170
162
|
serverInvalidManifest: 'Server 返回的节点清单无效。',
|
|
@@ -203,7 +195,6 @@ ${configurationHelp}`,
|
|
|
203
195
|
terminalSessionFailed: '终端会话失败。',
|
|
204
196
|
terminalDirectUnsafeFallback: 'OPEN 后无法安全取消直连终端,已跳过 relay 回退。',
|
|
205
197
|
invalidArguments: '命令参数无效。',
|
|
206
|
-
envFileValueRequired: '--env-file 必须提供值。',
|
|
207
198
|
sshOptionsUnsupported: '不支持 SSH 选项,请使用 marssh <节点>。',
|
|
208
199
|
scpOptionsUnsupported: 'Node Terminal 传输不支持此 SCP 选项。',
|
|
209
200
|
terminalBinaryUnavailable: '二进制复制需要支持 pipe 终端的 Server 与 Node。',
|
package/src/sessions.mjs
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { MarctlError } from './config.mjs';
|
|
5
|
+
import { createTranslator } from './i18n/index.mjs';
|
|
6
|
+
|
|
7
|
+
const terminal = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED']);
|
|
8
|
+
const commands = {
|
|
9
|
+
current: [],
|
|
10
|
+
list: ['cursor', 'limit'],
|
|
11
|
+
show: [],
|
|
12
|
+
history: ['cursor', 'limit'],
|
|
13
|
+
start: ['runner', 'model', 'title', 'message', 'message-file', 'image', 'client-message-id'],
|
|
14
|
+
send: ['message', 'message-file', 'image', 'delivery', 'client-message-id'],
|
|
15
|
+
stop: ['run'],
|
|
16
|
+
'goal.get': [],
|
|
17
|
+
'goal.set': ['message', 'message-file'],
|
|
18
|
+
'goal.clear': [],
|
|
19
|
+
wait: ['run', 'timeout']
|
|
20
|
+
};
|
|
21
|
+
const fieldNames = {
|
|
22
|
+
'message-file': 'messageFile',
|
|
23
|
+
'client-message-id': 'clientMessageId',
|
|
24
|
+
run: 'runId',
|
|
25
|
+
timeout: 'timeoutMs',
|
|
26
|
+
image: 'images'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function parseSessionArgs(argv, translate = createTranslator()) {
|
|
30
|
+
const invalid = () =>
|
|
31
|
+
new MarctlError(translate('errors.invalidArguments'), { code: 'MARCTL_SESSION_INPUT_INVALID' });
|
|
32
|
+
if (
|
|
33
|
+
argv.length === 0 ||
|
|
34
|
+
argv.some(
|
|
35
|
+
(value, index) =>
|
|
36
|
+
(value === '--help' || value === '-h') && !['-m', '--message'].includes(argv[index - 1])
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return { command: 'sessions', help: true };
|
|
40
|
+
const input = [...argv];
|
|
41
|
+
let action = input.shift();
|
|
42
|
+
if (action === 'goal') action += '.' + input.shift();
|
|
43
|
+
if (!Object.hasOwn(commands, action)) throw invalid();
|
|
44
|
+
const args = { command: 'sessions', action };
|
|
45
|
+
if (!['current', 'list', 'start'].includes(action)) {
|
|
46
|
+
const sessionId = input.shift();
|
|
47
|
+
if (!sessionId || sessionId.startsWith('-') || sessionId.length > 512) throw invalid();
|
|
48
|
+
args.sessionId = sessionId;
|
|
49
|
+
}
|
|
50
|
+
while (input.length > 0) {
|
|
51
|
+
const rawFlag = input.shift();
|
|
52
|
+
const flag = rawFlag === '-m' ? '--message' : rawFlag;
|
|
53
|
+
if (flag === '--json' && args.json === undefined) {
|
|
54
|
+
args.json = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const name = flag.startsWith('--') ? flag.slice(2) : '';
|
|
58
|
+
const key = fieldNames[name] ?? name;
|
|
59
|
+
if (!commands[action].includes(name) || (name !== 'image' && Object.hasOwn(args, key)))
|
|
60
|
+
throw invalid();
|
|
61
|
+
const value = input.shift();
|
|
62
|
+
if (value === undefined || value === '' || (name !== 'message' && value.startsWith('--')))
|
|
63
|
+
throw invalid();
|
|
64
|
+
if (name === 'image') {
|
|
65
|
+
(args.images ??= []).push(value);
|
|
66
|
+
if (args.images.length > 5) throw invalid();
|
|
67
|
+
} else if (name === 'limit') {
|
|
68
|
+
if (!/^[1-9][0-9]*$/u.test(value) || Number(value) > 100) throw invalid();
|
|
69
|
+
args[key] = Number(value);
|
|
70
|
+
} else if (name === 'timeout') {
|
|
71
|
+
const match = /^([1-9][0-9]*)(ms|s|m)$/u.exec(value);
|
|
72
|
+
const ms = match === null ? 0 : Number(match[1]) * { ms: 1, s: 1000, m: 60000 }[match[2]];
|
|
73
|
+
if (!Number.isSafeInteger(ms) || ms < 1 || ms > 3600000) throw invalid();
|
|
74
|
+
args[key] = ms;
|
|
75
|
+
} else args[key] = value;
|
|
76
|
+
}
|
|
77
|
+
if (args.message !== undefined && args.messageFile !== undefined) throw invalid();
|
|
78
|
+
if (
|
|
79
|
+
['start', 'send', 'goal.set'].includes(action) &&
|
|
80
|
+
args.messageFile === undefined &&
|
|
81
|
+
args.message === undefined &&
|
|
82
|
+
!args.images?.length
|
|
83
|
+
)
|
|
84
|
+
throw invalid();
|
|
85
|
+
if (
|
|
86
|
+
action === 'start' &&
|
|
87
|
+
!['mar-agent', 'codex', 'claude-code', 'opencode'].includes(args.runner)
|
|
88
|
+
)
|
|
89
|
+
throw invalid();
|
|
90
|
+
if (action === 'wait' && (args.runId === undefined || args.timeoutMs === undefined))
|
|
91
|
+
throw invalid();
|
|
92
|
+
if (args.delivery !== undefined && !['send', 'queue', 'replace-current'].includes(args.delivery))
|
|
93
|
+
throw invalid();
|
|
94
|
+
if (args.title !== undefined && args.title.length > 160) throw invalid();
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runSessionCommand({ args, client, env, stdin, WebSocketClass, translate }) {
|
|
99
|
+
if (args.action === 'wait') {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const cancel = () => controller.abort();
|
|
102
|
+
process.once('SIGINT', cancel);
|
|
103
|
+
process.once('SIGTERM', cancel);
|
|
104
|
+
try {
|
|
105
|
+
return await waitForSessionRun({
|
|
106
|
+
client,
|
|
107
|
+
sessionId: args.sessionId,
|
|
108
|
+
runId: args.runId,
|
|
109
|
+
timeoutMs: args.timeoutMs,
|
|
110
|
+
WebSocketClass,
|
|
111
|
+
translate,
|
|
112
|
+
signal: controller.signal
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
process.off('SIGINT', cancel);
|
|
116
|
+
process.off('SIGTERM', cancel);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const body = { action: args.action };
|
|
120
|
+
for (const name of ['sessionId', 'runner', 'model', 'title', 'cursor', 'limit', 'runId'])
|
|
121
|
+
if (args[name] !== undefined) body[name] = args[name];
|
|
122
|
+
if (args.action === 'current' && env.MAR_CTL_SESSION_ID) body.sessionId = env.MAR_CTL_SESSION_ID;
|
|
123
|
+
if (args.messageFile !== undefined || args.message !== undefined)
|
|
124
|
+
body.content = await messageContent(
|
|
125
|
+
args,
|
|
126
|
+
stdin,
|
|
127
|
+
args.action === 'goal.set' ? 20000 : 100000,
|
|
128
|
+
translate
|
|
129
|
+
);
|
|
130
|
+
if (args.images !== undefined) {
|
|
131
|
+
body.attachments = await imageAttachments(args.images, translate);
|
|
132
|
+
body.content ??= '';
|
|
133
|
+
}
|
|
134
|
+
if (args.action === 'start' || args.action === 'send')
|
|
135
|
+
body.clientMessageId = args.clientMessageId ?? randomUUID();
|
|
136
|
+
if (args.action === 'send')
|
|
137
|
+
body.deliveryIntent = { send: 'SEND', queue: 'QUEUE', 'replace-current': 'REPLACE_CURRENT' }[
|
|
138
|
+
args.delivery ?? 'send'
|
|
139
|
+
];
|
|
140
|
+
try {
|
|
141
|
+
const result = await client.sessionCommand(body);
|
|
142
|
+
return {
|
|
143
|
+
...result,
|
|
144
|
+
...(typeof result?.session?.id === 'string' ? { sessionId: result.session.id } : {}),
|
|
145
|
+
...(typeof result?.run?.id === 'string' ? { runId: result.run.id } : {}),
|
|
146
|
+
...(body.clientMessageId === undefined ? {} : { clientMessageId: body.clientMessageId })
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (body.clientMessageId !== undefined) error.clientMessageId = body.clientMessageId;
|
|
150
|
+
if (
|
|
151
|
+
['start', 'send', 'stop', 'goal.set', 'goal.clear'].includes(args.action) &&
|
|
152
|
+
['MARCTL_REQUEST_TIMEOUT', 'MARCTL_SERVER_UNREACHABLE', 'MARCTL_RESPONSE_INVALID'].includes(
|
|
153
|
+
error.code
|
|
154
|
+
)
|
|
155
|
+
) {
|
|
156
|
+
error.code = 'MARCTL_OUTCOME_UNKNOWN';
|
|
157
|
+
error.message = translate('errors.sessionOutcomeUnknown');
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function messageContent({ message, messageFile: path }, stdin, limit, translate) {
|
|
164
|
+
let file;
|
|
165
|
+
try {
|
|
166
|
+
let content = message;
|
|
167
|
+
if (content === undefined) {
|
|
168
|
+
const input =
|
|
169
|
+
path === '-'
|
|
170
|
+
? stdin
|
|
171
|
+
: (file = await open(path, 'r')).createReadStream({ autoClose: false });
|
|
172
|
+
const parts = [];
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
for await (const part of input) {
|
|
175
|
+
const chunk = Buffer.from(part);
|
|
176
|
+
bytes += chunk.length;
|
|
177
|
+
if (bytes > limit * 4) throw new Error('MESSAGE_TOO_LONG');
|
|
178
|
+
parts.push(chunk);
|
|
179
|
+
}
|
|
180
|
+
content = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
|
|
181
|
+
}
|
|
182
|
+
if (content.trim().length === 0 || content.length > limit) throw new Error('MESSAGE_INVALID');
|
|
183
|
+
return content;
|
|
184
|
+
} catch {
|
|
185
|
+
throw new MarctlError(translate('errors.sessionMessageInvalid'), {
|
|
186
|
+
code: 'MARCTL_SESSION_INPUT_INVALID'
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
await file?.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function imageAttachments(paths, translate) {
|
|
194
|
+
const attachments = [];
|
|
195
|
+
let total = 0;
|
|
196
|
+
for (const path of paths) {
|
|
197
|
+
let file;
|
|
198
|
+
try {
|
|
199
|
+
file = await open(path, 'r');
|
|
200
|
+
const info = await file.stat();
|
|
201
|
+
if (!info.isFile() || info.size === 0 || info.size > 5 * 1024 * 1024)
|
|
202
|
+
throw new Error('IMAGE_INVALID');
|
|
203
|
+
const parts = [];
|
|
204
|
+
let size = 0;
|
|
205
|
+
for await (const part of file.createReadStream({ autoClose: false })) {
|
|
206
|
+
size += part.length;
|
|
207
|
+
total += part.length;
|
|
208
|
+
if (size > 5 * 1024 * 1024 || total > 10 * 1024 * 1024) throw new Error('IMAGE_TOO_LARGE');
|
|
209
|
+
parts.push(part);
|
|
210
|
+
}
|
|
211
|
+
const data = Buffer.concat(parts);
|
|
212
|
+
const mime = imageMime(data);
|
|
213
|
+
if (mime === undefined) throw new Error('IMAGE_INVALID');
|
|
214
|
+
attachments.push({ name: basename(path), mime, dataBase64: data.toString('base64') });
|
|
215
|
+
} catch {
|
|
216
|
+
throw new MarctlError(translate('errors.sessionImageInvalid'), {
|
|
217
|
+
code: 'MARCTL_SESSION_INPUT_INVALID'
|
|
218
|
+
});
|
|
219
|
+
} finally {
|
|
220
|
+
await file?.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return attachments;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function imageMime(data) {
|
|
227
|
+
if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
|
|
228
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg';
|
|
229
|
+
if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii'))) return 'image/gif';
|
|
230
|
+
if (
|
|
231
|
+
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
232
|
+
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
233
|
+
)
|
|
234
|
+
return 'image/webp';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One CLI-owned Workbench connection and one existing Session Watch, never history polling. */
|
|
238
|
+
export async function waitForSessionRun({
|
|
239
|
+
client,
|
|
240
|
+
sessionId,
|
|
241
|
+
runId,
|
|
242
|
+
timeoutMs,
|
|
243
|
+
WebSocketClass,
|
|
244
|
+
translate = createTranslator(),
|
|
245
|
+
signal
|
|
246
|
+
}) {
|
|
247
|
+
const socket = client.openWorkbenchWebSocket(WebSocketClass ?? (await import('ws')).default);
|
|
248
|
+
const watchId = randomUUID();
|
|
249
|
+
let connectionId;
|
|
250
|
+
let renewal;
|
|
251
|
+
let settled = false;
|
|
252
|
+
let subscribed = false;
|
|
253
|
+
let accept;
|
|
254
|
+
let rejectResult;
|
|
255
|
+
const result = new Promise((resolve, fail) => {
|
|
256
|
+
accept = resolve;
|
|
257
|
+
rejectResult = fail;
|
|
258
|
+
});
|
|
259
|
+
const reject = (error) => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
rejectResult(error);
|
|
263
|
+
};
|
|
264
|
+
const fail = (code, key, options = {}) =>
|
|
265
|
+
reject(new MarctlError(translate(key), { ...options, code }));
|
|
266
|
+
const command = (mode) =>
|
|
267
|
+
client.sessionCommand({
|
|
268
|
+
action: 'watch',
|
|
269
|
+
sessionId,
|
|
270
|
+
workbenchConnectionId: connectionId,
|
|
271
|
+
watchId,
|
|
272
|
+
mode
|
|
273
|
+
});
|
|
274
|
+
const scheduleRenewal = (expiresAt) => {
|
|
275
|
+
clearTimeout(renewal);
|
|
276
|
+
const remaining = expiresAt - Date.now();
|
|
277
|
+
if (!Number.isFinite(remaining) || remaining <= 0) {
|
|
278
|
+
fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
renewal = setTimeout(
|
|
282
|
+
() => {
|
|
283
|
+
command('renew')
|
|
284
|
+
.then((ack) => {
|
|
285
|
+
if (!settled) scheduleRenewal(ack?.expiresAt);
|
|
286
|
+
})
|
|
287
|
+
.catch((error) => {
|
|
288
|
+
if ([401, 403].includes(error.statusCode)) reject(error);
|
|
289
|
+
else fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
Math.max(50, Math.floor(remaining / 2))
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
const check = (run) => {
|
|
296
|
+
if (run?.id === runId && run.sessionId === sessionId && terminal.has(run.status) && !settled) {
|
|
297
|
+
settled = true;
|
|
298
|
+
accept({ run });
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const onMessage = (raw) => {
|
|
302
|
+
if (settled) return;
|
|
303
|
+
let message;
|
|
304
|
+
try {
|
|
305
|
+
message = JSON.parse(String(raw));
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (message?.type === 'watch.event') message = message.event;
|
|
310
|
+
if (!message || typeof message !== 'object') return;
|
|
311
|
+
if (
|
|
312
|
+
message.type === 'workbench.ready' &&
|
|
313
|
+
connectionId === undefined &&
|
|
314
|
+
typeof message.workbenchConnectionId === 'string'
|
|
315
|
+
) {
|
|
316
|
+
connectionId = message.workbenchConnectionId;
|
|
317
|
+
subscribed = true;
|
|
318
|
+
command('initial').catch(reject);
|
|
319
|
+
}
|
|
320
|
+
if (message.watchId !== watchId) return;
|
|
321
|
+
if (message.type === 'watch.failed') {
|
|
322
|
+
fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (message.type === 'watch.session.snapshot') {
|
|
326
|
+
const runs = message.payload?.runs;
|
|
327
|
+
if (!Array.isArray(runs)) {
|
|
328
|
+
fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const run = runs.find((value) => value.id === runId && value.sessionId === sessionId);
|
|
332
|
+
if (run === undefined) {
|
|
333
|
+
fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
check(run);
|
|
337
|
+
} else if (message.type === 'watch.session.event') check(message.event?.payload?.run);
|
|
338
|
+
if (!settled && message.expiresAt !== undefined) scheduleRenewal(message.expiresAt);
|
|
339
|
+
};
|
|
340
|
+
const onClose = (code, reason) => {
|
|
341
|
+
if (code === 1008 && String(reason) === 'UNAUTHENTICATED')
|
|
342
|
+
fail('UNAUTHENTICATED', 'errors.userContextUnavailable', { statusCode: 401 });
|
|
343
|
+
else fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
|
|
344
|
+
};
|
|
345
|
+
const onOpen = () => {
|
|
346
|
+
try {
|
|
347
|
+
socket.send(JSON.stringify({ type: 'resume' }));
|
|
348
|
+
} catch {
|
|
349
|
+
onClose();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const onAbort = () => fail('MARCTL_WAIT_CANCELLED', 'errors.sessionWaitCancelled');
|
|
353
|
+
const timer = setTimeout(
|
|
354
|
+
() => fail('MARCTL_WAIT_TIMEOUT', 'errors.sessionWaitTimeout'),
|
|
355
|
+
timeoutMs
|
|
356
|
+
);
|
|
357
|
+
socket.on('open', onOpen);
|
|
358
|
+
socket.on('message', onMessage);
|
|
359
|
+
socket.on('error', onClose);
|
|
360
|
+
socket.on('close', onClose);
|
|
361
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
362
|
+
if (signal?.aborted) onAbort();
|
|
363
|
+
try {
|
|
364
|
+
return await result;
|
|
365
|
+
} finally {
|
|
366
|
+
settled = true;
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
clearTimeout(renewal);
|
|
369
|
+
signal?.removeEventListener('abort', onAbort);
|
|
370
|
+
if (subscribed) await command('stop').catch(() => undefined);
|
|
371
|
+
socket.off('open', onOpen);
|
|
372
|
+
socket.off('message', onMessage);
|
|
373
|
+
socket.off('close', onClose);
|
|
374
|
+
socket.close();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function sessionExitCode(error) {
|
|
379
|
+
if (error.code === 'MARCTL_SESSION_INPUT_INVALID') return 2;
|
|
380
|
+
if ([401, 403].includes(error.statusCode)) return 3;
|
|
381
|
+
if (error.code?.includes('UNSUPPORTED')) return 4;
|
|
382
|
+
if (error.code === 'MARCTL_WAIT_TIMEOUT' || error.code === 'MARCTL_REQUEST_TIMEOUT') return 5;
|
|
383
|
+
if (['MARCTL_OUTCOME_UNKNOWN', 'MARCTL_WAIT_UNKNOWN'].includes(error.code)) return 6;
|
|
384
|
+
if (error.code === 'MARCTL_WAIT_CANCELLED') return 130;
|
|
385
|
+
return 1;
|
|
386
|
+
}
|