@myagentroam/marctl 0.9.120
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/bin/marctl.mjs +6 -0
- package/bin/marnodes.mjs +6 -0
- package/bin/marscp.mjs +6 -0
- package/bin/marssh.mjs +6 -0
- package/package.json +32 -0
- package/src/cli.mjs +219 -0
- package/src/client.mjs +220 -0
- package/src/config.mjs +164 -0
- package/src/direct.mjs +791 -0
- package/src/i18n/en-US.mjs +220 -0
- package/src/i18n/index.mjs +20 -0
- package/src/i18n/zh-CN.mjs +212 -0
- package/src/scp-worker.cjs +254 -0
- package/src/scp.mjs +321 -0
- package/src/terminal.mjs +661 -0
package/src/client.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { MarctlError } from './config.mjs';
|
|
2
|
+
import { createTranslator } from './i18n/index.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
5
|
+
|
|
6
|
+
export class MarctlClient {
|
|
7
|
+
#serverUrl;
|
|
8
|
+
#key;
|
|
9
|
+
#fetch;
|
|
10
|
+
#translate;
|
|
11
|
+
#requestTimeoutMs;
|
|
12
|
+
|
|
13
|
+
constructor({
|
|
14
|
+
serverUrl,
|
|
15
|
+
key,
|
|
16
|
+
fetchImpl = globalThis.fetch,
|
|
17
|
+
translate = createTranslator(),
|
|
18
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS
|
|
19
|
+
}) {
|
|
20
|
+
this.#serverUrl = serverUrl;
|
|
21
|
+
this.#key = key;
|
|
22
|
+
this.#fetch = fetchImpl;
|
|
23
|
+
this.#translate = translate;
|
|
24
|
+
this.#requestTimeoutMs = requestTimeoutMs;
|
|
25
|
+
if (typeof this.#fetch !== 'function')
|
|
26
|
+
throw new MarctlError(this.#translate('errors.fetchUnavailable'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async listNodes() {
|
|
30
|
+
const payload = await this.#requestJson('/api/marctl/nodes');
|
|
31
|
+
if (!isRecord(payload) || !Array.isArray(payload.nodes))
|
|
32
|
+
throw new MarctlError(this.#translate('errors.serverInvalidNodes'));
|
|
33
|
+
|
|
34
|
+
return payload.nodes.map((node) => {
|
|
35
|
+
if (
|
|
36
|
+
!isRecord(node) ||
|
|
37
|
+
!isNonEmptyString(node.id) ||
|
|
38
|
+
typeof node.connected !== 'boolean' ||
|
|
39
|
+
(node.displayName !== undefined && typeof node.displayName !== 'string') ||
|
|
40
|
+
(node.platform !== undefined && typeof node.platform !== 'string')
|
|
41
|
+
)
|
|
42
|
+
throw new MarctlError(this.#translate('errors.serverInvalidNodes'));
|
|
43
|
+
return {
|
|
44
|
+
id: node.id,
|
|
45
|
+
...(node.displayName === undefined ? {} : { displayName: node.displayName }),
|
|
46
|
+
connected: node.connected,
|
|
47
|
+
...(node.platform === undefined ? {} : { platform: node.platform })
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async createTerminal({ nodeId, rows, cols, command, binary = false }) {
|
|
53
|
+
validateNodeId(nodeId, this.#translate);
|
|
54
|
+
validateDimensions(rows, cols, this.#translate);
|
|
55
|
+
if (command !== undefined && typeof command !== 'string')
|
|
56
|
+
throw new MarctlError(this.#translate('errors.terminalCommandInvalid'));
|
|
57
|
+
const body = { nodeId, rows, cols };
|
|
58
|
+
if (command !== undefined) body.command = command;
|
|
59
|
+
if (binary) body.binary = true;
|
|
60
|
+
const payload = await this.#requestJson('/api/marctl/terminals', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
body
|
|
63
|
+
});
|
|
64
|
+
if (!isRecord(payload) || !isNonEmptyString(payload.terminalId))
|
|
65
|
+
throw new MarctlError(this.#translate('errors.serverInvalidTerminalId'));
|
|
66
|
+
return binary
|
|
67
|
+
? { terminalId: payload.terminalId, binary: payload.binary === true }
|
|
68
|
+
: payload.terminalId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getTerminalManifest(nodeId) {
|
|
72
|
+
validateNodeId(nodeId, this.#translate);
|
|
73
|
+
const payload = await this.#requestJson(
|
|
74
|
+
`/api/marctl/nodes/${encodeURIComponent(nodeId)}/manifest`
|
|
75
|
+
);
|
|
76
|
+
if (
|
|
77
|
+
!isRecord(payload) ||
|
|
78
|
+
!isRecord(payload.manifest) ||
|
|
79
|
+
!isNonEmptyString(payload.manifest.nodeGeneration)
|
|
80
|
+
)
|
|
81
|
+
throw new MarctlError(this.#translate('errors.serverInvalidManifest'));
|
|
82
|
+
return {
|
|
83
|
+
...payload.manifest,
|
|
84
|
+
...(payload.deferredRelay === true ? { deferredRelay: true } : {}),
|
|
85
|
+
...(payload.binaryTerminal === true ? { binaryTerminal: true } : {})
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async createAttachTicket({ nodeId, terminalId, afterSequence }) {
|
|
90
|
+
validateNodeId(nodeId, this.#translate);
|
|
91
|
+
validateTerminalId(terminalId, this.#translate);
|
|
92
|
+
if (afterSequence !== undefined && (!Number.isSafeInteger(afterSequence) || afterSequence < 0))
|
|
93
|
+
throw new MarctlError(this.#translate('errors.terminalSequenceInvalid'));
|
|
94
|
+
|
|
95
|
+
const body = { nodeId };
|
|
96
|
+
if (afterSequence !== undefined) body.afterSequence = afterSequence;
|
|
97
|
+
const payload = await this.#requestJson(
|
|
98
|
+
`/api/marctl/terminals/${encodeURIComponent(terminalId)}/attach-ticket`,
|
|
99
|
+
{ method: 'POST', body }
|
|
100
|
+
);
|
|
101
|
+
if (!isRecord(payload) || !isNonEmptyString(payload.ticket))
|
|
102
|
+
throw new MarctlError(this.#translate('errors.serverInvalidTicket'));
|
|
103
|
+
return payload.ticket;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async deleteTerminal({ nodeId, terminalId }) {
|
|
107
|
+
validateNodeId(nodeId, this.#translate);
|
|
108
|
+
validateTerminalId(terminalId, this.#translate);
|
|
109
|
+
await this.#request('/api/marctl/terminals/' + encodeURIComponent(terminalId), {
|
|
110
|
+
method: 'DELETE',
|
|
111
|
+
body: { nodeId }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
openTerminalWebSocket(WebSocketClass, { terminalId, ticket, deferred = false }) {
|
|
116
|
+
validateTerminalId(terminalId, this.#translate);
|
|
117
|
+
if (!isNonEmptyString(ticket))
|
|
118
|
+
throw new MarctlError(this.#translate('errors.terminalTicketInvalid'));
|
|
119
|
+
|
|
120
|
+
const url = new URL(
|
|
121
|
+
`/api/marctl/terminals/${encodeURIComponent(terminalId)}/connect`,
|
|
122
|
+
`${this.#serverUrl}/`
|
|
123
|
+
);
|
|
124
|
+
url.searchParams.set('ticket', ticket);
|
|
125
|
+
if (deferred) url.searchParams.set('deferred', '1');
|
|
126
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return new WebSocketClass(url.toString(), {
|
|
130
|
+
headers: { Authorization: `Bearer ${this.#key}` }
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
throw new MarctlError(this.#translate('errors.terminalConnectionStart'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
openWorkbenchWebSocket(WebSocketClass) {
|
|
138
|
+
const url = new URL('/api/marctl/live', `${this.#serverUrl}/`);
|
|
139
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
140
|
+
try {
|
|
141
|
+
return new WebSocketClass(url.toString(), {
|
|
142
|
+
headers: { Authorization: `Bearer ${this.#key}` }
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
throw new MarctlError(this.#translate('errors.workbenchConnectionStart'));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async #requestJson(pathname, options = {}) {
|
|
150
|
+
return this.#request(pathname, { ...options, json: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #request(pathname, { method = 'GET', body, json = false } = {}) {
|
|
154
|
+
const headers = {
|
|
155
|
+
Accept: 'application/json',
|
|
156
|
+
Authorization: `Bearer ${this.#key}`
|
|
157
|
+
};
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const request = { method, headers, signal: controller.signal };
|
|
160
|
+
if (body !== undefined) {
|
|
161
|
+
headers['Content-Type'] = 'application/json';
|
|
162
|
+
request.body = JSON.stringify(body);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const timer = setTimeout(() => controller.abort(), this.#requestTimeoutMs);
|
|
166
|
+
try {
|
|
167
|
+
const response = await this.#fetch(new URL(pathname, `${this.#serverUrl}/`), request);
|
|
168
|
+
if (!response?.ok) throw new MarctlError(this.#translate('errors.serverRejected'));
|
|
169
|
+
if (!json) return response;
|
|
170
|
+
try {
|
|
171
|
+
return await response.json();
|
|
172
|
+
} catch {
|
|
173
|
+
throw new MarctlError(
|
|
174
|
+
this.#translate(
|
|
175
|
+
controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverInvalidJson'
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof MarctlError) throw error;
|
|
181
|
+
throw new MarctlError(
|
|
182
|
+
this.#translate(
|
|
183
|
+
controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverUnreachable'
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateNodeId(nodeId, translate) {
|
|
193
|
+
if (!isNonEmptyString(nodeId) || nodeId.length > 128)
|
|
194
|
+
throw new MarctlError(translate('errors.nodeIdInvalid'));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateTerminalId(terminalId, translate) {
|
|
198
|
+
if (!isNonEmptyString(terminalId) || terminalId.length > 128)
|
|
199
|
+
throw new MarctlError(translate('errors.terminalIdInvalid'));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function validateDimensions(rows, cols, translate) {
|
|
203
|
+
if (
|
|
204
|
+
!Number.isSafeInteger(rows) ||
|
|
205
|
+
!Number.isSafeInteger(cols) ||
|
|
206
|
+
rows < 1 ||
|
|
207
|
+
cols < 1 ||
|
|
208
|
+
rows > 1000 ||
|
|
209
|
+
cols > 1000
|
|
210
|
+
)
|
|
211
|
+
throw new MarctlError(translate('errors.terminalSizeInvalid'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isRecord(value) {
|
|
215
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isNonEmptyString(value) {
|
|
219
|
+
return typeof value === 'string' && value.length > 0;
|
|
220
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createTranslator } from './i18n/index.mjs';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_KEYS = Object.freeze(['MAR_CTL_SERVER_URL', 'MAR_CTL_KEY']);
|
|
7
|
+
|
|
8
|
+
export class MarctlError extends Error {
|
|
9
|
+
constructor(message, options = {}) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'MarctlError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
if (/[\u0000-\u001f\u007f]/u.test(keyValue))
|
|
58
|
+
throw new MarctlError(translate('errors.configKeyInvalid'));
|
|
59
|
+
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
serverUrl: validateServerUrl(serverUrlValue, translate),
|
|
62
|
+
key: keyValue
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateServerUrl(value, translate = createTranslator()) {
|
|
67
|
+
let url;
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(value);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new MarctlError(translate('errors.serverUrlInvalid'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isLoopbackHttp =
|
|
75
|
+
url.protocol === 'http:' &&
|
|
76
|
+
(url.hostname === 'localhost' ||
|
|
77
|
+
url.hostname === '127.0.0.1' ||
|
|
78
|
+
url.hostname === '::1' ||
|
|
79
|
+
url.hostname === '[::1]');
|
|
80
|
+
if (url.protocol !== 'https:' && !isLoopbackHttp)
|
|
81
|
+
throw new MarctlError(translate('errors.serverUrlTransport'));
|
|
82
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
83
|
+
throw new MarctlError(translate('errors.serverUrlOrigin'));
|
|
84
|
+
|
|
85
|
+
return url.origin;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readConfigFile(filePath, { platform, translate }) {
|
|
89
|
+
let before;
|
|
90
|
+
try {
|
|
91
|
+
before = await lstat(filePath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.code === 'ENOENT') throw new MarctlError(translate('errors.envFileMissing'));
|
|
94
|
+
throw new MarctlError(translate('errors.envFileRead'));
|
|
95
|
+
}
|
|
96
|
+
assertSecureConfigFile(before, platform, translate);
|
|
97
|
+
|
|
98
|
+
let content;
|
|
99
|
+
try {
|
|
100
|
+
content = await readFile(filePath, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
throw new MarctlError(translate('errors.envFileRead'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let after;
|
|
106
|
+
try {
|
|
107
|
+
after = await lstat(filePath);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new MarctlError(translate('errors.envFileRead'));
|
|
110
|
+
}
|
|
111
|
+
assertSecureConfigFile(after, platform, translate);
|
|
112
|
+
if (before.dev !== after.dev || before.ino !== after.ino)
|
|
113
|
+
throw new MarctlError(translate('errors.envFileRead'));
|
|
114
|
+
|
|
115
|
+
const values = {};
|
|
116
|
+
for (const rawLine of content.replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
|
|
117
|
+
const line = rawLine.trim();
|
|
118
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
119
|
+
|
|
120
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
|
|
121
|
+
if (match === null) throw new MarctlError(translate('errors.envFileEntry'));
|
|
122
|
+
|
|
123
|
+
const [, name, rawValue] = match;
|
|
124
|
+
if (!CONFIG_KEYS.includes(name)) throw new MarctlError(translate('errors.envFileUnsupported'));
|
|
125
|
+
values[name] = parseEnvValue(rawValue, translate);
|
|
126
|
+
}
|
|
127
|
+
return values;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseEnvValue(rawValue, translate) {
|
|
131
|
+
const value = rawValue.trim();
|
|
132
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
133
|
+
const quote = value[0];
|
|
134
|
+
if (!value.endsWith(quote) || value.length < 2)
|
|
135
|
+
throw new MarctlError(translate('errors.envFileQuoted'));
|
|
136
|
+
const body = value.slice(1, -1);
|
|
137
|
+
if (quote === "'") return body;
|
|
138
|
+
return body.replace(/\\(["\\nrt])/gu, (_match, escaped) => {
|
|
139
|
+
if (escaped === 'n') return '\n';
|
|
140
|
+
if (escaped === 'r') return '\r';
|
|
141
|
+
if (escaped === 't') return '\t';
|
|
142
|
+
return escaped;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertSecureConfigFile(stats, platform, translate) {
|
|
149
|
+
if (stats.isSymbolicLink()) throw new MarctlError(translate('errors.envFileSymlink'));
|
|
150
|
+
if (!stats.isFile()) throw new MarctlError(translate('errors.envFileType'));
|
|
151
|
+
if (platform === 'win32') return;
|
|
152
|
+
|
|
153
|
+
const permissions = stats.mode & 0o7777;
|
|
154
|
+
const ownerUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
155
|
+
if (
|
|
156
|
+
(ownerUid !== undefined && stats.uid !== ownerUid) ||
|
|
157
|
+
(permissions !== 0o600 && permissions !== 0o400)
|
|
158
|
+
)
|
|
159
|
+
throw new MarctlError(translate('errors.envFilePermissions'));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nonEmptyString(value) {
|
|
163
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
164
|
+
}
|