@myagentroam/marmgr 0.9.112
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/marmgr.mjs +6 -0
- package/package.json +29 -0
- package/src/cli.mjs +137 -0
- package/src/client.mjs +212 -0
- package/src/config.mjs +150 -0
- package/src/direct.mjs +769 -0
- package/src/i18n/en-US.mjs +72 -0
- package/src/i18n/index.mjs +20 -0
- package/src/i18n/zh-CN.mjs +70 -0
- package/src/terminal.mjs +479 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
help: `Usage:
|
|
3
|
+
marmgr [--env-file PATH] nodes
|
|
4
|
+
marmgr [--env-file PATH] ssh [-t] <node-id> [command [arg...]]
|
|
5
|
+
|
|
6
|
+
Remote command arguments are joined with spaces like SSH. Quote commands containing spaces.
|
|
7
|
+
Use -- before remote command flags. Only -t is supported; network and identity SSH options are rejected.
|
|
8
|
+
Default config: ~/.marmgr/default.env. On Windows, use %HOME%\\.marmgr\\default.env when HOME is set;
|
|
9
|
+
otherwise marmgr uses the system home directory.
|
|
10
|
+
Connection setup has a bounded timeout. Remote commands have no total or idle timeout.
|
|
11
|
+
If the relay disconnects, marmgr fails without retrying or re-running the command.`,
|
|
12
|
+
connected: 'connected',
|
|
13
|
+
disconnected: 'disconnected',
|
|
14
|
+
errors: Object.freeze({
|
|
15
|
+
configServerUrlMissing: 'MAR_MGR_SERVER_URL is not configured.',
|
|
16
|
+
configKeyMissing: 'MAR_MGR_KEY is not configured.',
|
|
17
|
+
configKeyInvalid: 'MAR_MGR_KEY contains invalid characters.',
|
|
18
|
+
serverUrlInvalid: 'MAR_MGR_SERVER_URL is invalid.',
|
|
19
|
+
serverUrlTransport: 'MAR_MGR_SERVER_URL must use HTTPS or loopback HTTP.',
|
|
20
|
+
serverUrlOrigin: 'MAR_MGR_SERVER_URL must be an origin without credentials or query data.',
|
|
21
|
+
envFileMissing: 'The requested environment file was not found.',
|
|
22
|
+
envFileRead: 'The environment file could not be read.',
|
|
23
|
+
envFileSymlink: 'The environment file must not be a symbolic link. Use chmod 600.',
|
|
24
|
+
envFileType: 'The environment file must be a regular file. Use chmod 600.',
|
|
25
|
+
envFilePermissions:
|
|
26
|
+
'The environment file must be owned by the current user and have mode 0600 or 0400. Use chmod 600.',
|
|
27
|
+
envFileEntry: 'Invalid environment entry.',
|
|
28
|
+
envFileUnsupported: 'Unsupported environment variable.',
|
|
29
|
+
envFileQuoted: 'Invalid quoted environment value.',
|
|
30
|
+
fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
|
|
31
|
+
serverInvalidNodes: 'The server returned an invalid node list.',
|
|
32
|
+
serverInvalidManifest: 'The server returned an invalid node manifest.',
|
|
33
|
+
terminalCommandInvalid: 'The terminal command is invalid.',
|
|
34
|
+
terminalSequenceInvalid: 'The terminal sequence is invalid.',
|
|
35
|
+
terminalTicketInvalid: 'The terminal ticket is invalid.',
|
|
36
|
+
terminalIdInvalid: 'The terminal identifier is invalid.',
|
|
37
|
+
nodeIdInvalid: 'The node identifier is invalid.',
|
|
38
|
+
terminalSizeInvalid: 'The terminal size is invalid.',
|
|
39
|
+
serverInvalidTerminalId: 'The server returned an invalid terminal identifier.',
|
|
40
|
+
serverInvalidTicket: 'The server returned an invalid terminal ticket.',
|
|
41
|
+
serverInvalidJson: 'The server returned invalid JSON.',
|
|
42
|
+
serverTimeout: 'The marmgr server did not respond before the control request timeout.',
|
|
43
|
+
serverUnreachable: 'The marmgr server could not be reached.',
|
|
44
|
+
serverRejected: 'The marmgr server rejected the request.',
|
|
45
|
+
terminalConnectionStart: 'The terminal connection could not be started.',
|
|
46
|
+
terminalConnectionOpen: 'The terminal connection could not be opened.',
|
|
47
|
+
terminalAttachTimeout: 'The terminal did not attach before the connection setup timeout.',
|
|
48
|
+
terminalAttachConnection: 'The terminal connection failed during attachment.',
|
|
49
|
+
terminalAttachLost: 'The terminal connection was lost during attachment.',
|
|
50
|
+
terminalRemoteError: 'The remote terminal reported an error.',
|
|
51
|
+
terminalClosedBeforeAttach: 'The remote terminal closed before attachment.',
|
|
52
|
+
terminalFrameInvalid: 'The terminal frame was invalid.',
|
|
53
|
+
terminalInvalidJson: 'The terminal sent invalid JSON.',
|
|
54
|
+
terminalBinaryFrame: 'The terminal sent an invalid binary frame.',
|
|
55
|
+
terminalConnectionFailed: 'The terminal connection failed.',
|
|
56
|
+
terminalConnectionLost: 'The terminal connection was lost.',
|
|
57
|
+
terminalUnexpectedFrame: 'The remote terminal sent an unexpected frame.',
|
|
58
|
+
terminalInputFailed: 'The terminal input could not be sent.',
|
|
59
|
+
terminalResizeFailed: 'The terminal resize could not be sent.',
|
|
60
|
+
terminalInteractiveRequired: 'An interactive terminal is required.',
|
|
61
|
+
terminalInteractiveInit: 'The interactive terminal could not be initialized.',
|
|
62
|
+
terminalCloseFailed: 'The remote terminal could not be closed.',
|
|
63
|
+
terminalSessionFailed: 'The terminal session failed.',
|
|
64
|
+
terminalDirectUnsafeFallback:
|
|
65
|
+
'Direct terminal setup could not be safely canceled after OPEN; relay fallback was skipped.',
|
|
66
|
+
invalidArguments: 'Invalid command arguments.',
|
|
67
|
+
envFileValueRequired: 'A value is required for --env-file.',
|
|
68
|
+
sshTUnsupported: 'SSH option -T is not supported; marmgr always uses a PTY.',
|
|
69
|
+
sshOnlyT: 'Only SSH option -t is supported; network and identity options are unavailable.',
|
|
70
|
+
commandFailed: 'The command failed.'
|
|
71
|
+
})
|
|
72
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import enUS from './en-US.mjs';
|
|
2
|
+
import zhCN from './zh-CN.mjs';
|
|
3
|
+
|
|
4
|
+
export function localeFromEnv(env = process.env) {
|
|
5
|
+
const value = String(env.LC_ALL || env.LANG || '').toLowerCase();
|
|
6
|
+
return value.startsWith('zh_') || value.startsWith('zh-') || value === 'zh' ? 'zh-CN' : 'en-US';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTranslator(env = process.env) {
|
|
10
|
+
const locale = localeFromEnv(env);
|
|
11
|
+
const resource = locale === 'zh-CN' ? zhCN : enUS;
|
|
12
|
+
const translate = (key, params = {}) => {
|
|
13
|
+
const value = key.split('.').reduce((current, part) => current?.[part], resource);
|
|
14
|
+
if (typeof value === 'function') return value(params);
|
|
15
|
+
if (typeof value !== 'string') return key;
|
|
16
|
+
return value.replace(/\{(\w+)\}/gu, (_match, name) => String(params[name] ?? ''));
|
|
17
|
+
};
|
|
18
|
+
translate.locale = locale;
|
|
19
|
+
return translate;
|
|
20
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
help: `用法:
|
|
3
|
+
marmgr [--env-file PATH] nodes
|
|
4
|
+
marmgr [--env-file PATH] ssh [-t] <node-id> [command [arg...]]
|
|
5
|
+
|
|
6
|
+
远程命令参数会按 SSH 方式用空格连接。包含空格的命令请按 SSH 方式引用。
|
|
7
|
+
远程命令含有 flags 时请使用 --。仅支持 -t;不支持网络和身份 SSH 参数。
|
|
8
|
+
默认配置为 ~/.marmgr/default.env。Windows 在 HOME 有值时使用 %HOME%\\.marmgr\\default.env,
|
|
9
|
+
否则使用系统主目录。
|
|
10
|
+
连接建立阶段有明确超时;远程命令没有总时长或空闲超时。
|
|
11
|
+
relay 断开时 marmgr 会失败,不会重试或重新执行命令。`,
|
|
12
|
+
connected: '在线',
|
|
13
|
+
disconnected: '离线',
|
|
14
|
+
errors: Object.freeze({
|
|
15
|
+
configServerUrlMissing: '未配置 MAR_MGR_SERVER_URL。',
|
|
16
|
+
configKeyMissing: '未配置 MAR_MGR_KEY。',
|
|
17
|
+
configKeyInvalid: 'MAR_MGR_KEY 包含无效字符。',
|
|
18
|
+
serverUrlInvalid: 'MAR_MGR_SERVER_URL 无效。',
|
|
19
|
+
serverUrlTransport: 'MAR_MGR_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
|
|
20
|
+
serverUrlOrigin: 'MAR_MGR_SERVER_URL 必须是不含凭据或查询参数的 Origin。',
|
|
21
|
+
envFileMissing: '指定的环境文件不存在。',
|
|
22
|
+
envFileRead: '无法读取环境文件。',
|
|
23
|
+
envFileSymlink: '环境文件不能是符号链接。请使用 chmod 600。',
|
|
24
|
+
envFileType: '环境文件必须是普通文件。请使用 chmod 600。',
|
|
25
|
+
envFilePermissions: '环境文件必须归当前用户所有,权限必须为 0600 或 0400。请使用 chmod 600。',
|
|
26
|
+
envFileEntry: '环境文件条目无效。',
|
|
27
|
+
envFileUnsupported: '环境变量不受支持。',
|
|
28
|
+
envFileQuoted: '带引号的环境变量值无效。',
|
|
29
|
+
fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
|
|
30
|
+
serverInvalidNodes: 'Server 返回的节点列表无效。',
|
|
31
|
+
serverInvalidManifest: 'Server 返回的节点清单无效。',
|
|
32
|
+
terminalCommandInvalid: '终端命令无效。',
|
|
33
|
+
terminalSequenceInvalid: '终端序列无效。',
|
|
34
|
+
terminalTicketInvalid: '终端票据无效。',
|
|
35
|
+
terminalIdInvalid: '终端标识无效。',
|
|
36
|
+
nodeIdInvalid: '节点标识无效。',
|
|
37
|
+
terminalSizeInvalid: '终端尺寸无效。',
|
|
38
|
+
serverInvalidTerminalId: 'Server 返回的终端标识无效。',
|
|
39
|
+
serverInvalidTicket: 'Server 返回的终端票据无效。',
|
|
40
|
+
serverInvalidJson: 'Server 返回的 JSON 无效。',
|
|
41
|
+
serverTimeout: 'marmgr Server 未能在控制面请求超时前响应。',
|
|
42
|
+
serverUnreachable: '无法连接 marmgr Server。',
|
|
43
|
+
serverRejected: 'marmgr Server 拒绝了请求。',
|
|
44
|
+
terminalConnectionStart: '无法启动终端连接。',
|
|
45
|
+
terminalConnectionOpen: '无法打开终端连接。',
|
|
46
|
+
terminalAttachTimeout: '终端未能在连接建立超时前完成附着。',
|
|
47
|
+
terminalAttachConnection: '终端附着期间连接失败。',
|
|
48
|
+
terminalAttachLost: '终端附着期间连接断开。',
|
|
49
|
+
terminalRemoteError: '远程终端返回错误。',
|
|
50
|
+
terminalClosedBeforeAttach: '远程终端在附着前关闭。',
|
|
51
|
+
terminalFrameInvalid: '终端帧无效。',
|
|
52
|
+
terminalInvalidJson: '终端返回了无效 JSON。',
|
|
53
|
+
terminalBinaryFrame: '终端返回了无效二进制帧。',
|
|
54
|
+
terminalConnectionFailed: '终端连接失败。',
|
|
55
|
+
terminalConnectionLost: '终端连接断开。',
|
|
56
|
+
terminalUnexpectedFrame: '远程终端返回了意外帧。',
|
|
57
|
+
terminalInputFailed: '无法发送终端输入。',
|
|
58
|
+
terminalResizeFailed: '无法发送终端尺寸。',
|
|
59
|
+
terminalInteractiveRequired: '需要交互式终端。',
|
|
60
|
+
terminalInteractiveInit: '无法初始化交互式终端。',
|
|
61
|
+
terminalCloseFailed: '无法关闭远程终端。',
|
|
62
|
+
terminalSessionFailed: '终端会话失败。',
|
|
63
|
+
terminalDirectUnsafeFallback: 'OPEN 后无法安全取消直连终端,已跳过 relay 回退。',
|
|
64
|
+
invalidArguments: '命令参数无效。',
|
|
65
|
+
envFileValueRequired: '--env-file 必须提供值。',
|
|
66
|
+
sshTUnsupported: '不支持 SSH 选项 -T;marmgr 始终使用 PTY。',
|
|
67
|
+
sshOnlyT: '仅支持 SSH 选项 -t;不支持网络和身份参数。',
|
|
68
|
+
commandFailed: '命令执行失败。'
|
|
69
|
+
})
|
|
70
|
+
});
|
package/src/terminal.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { MarmgrError } from './config.mjs';
|
|
3
|
+
import { createTranslator } from './i18n/index.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_INPUT_BYTES = 65536;
|
|
6
|
+
const DEFAULT_ROWS = 24;
|
|
7
|
+
const DEFAULT_COLS = 80;
|
|
8
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
export async function runTerminalSession({
|
|
11
|
+
client,
|
|
12
|
+
WebSocketClass,
|
|
13
|
+
nodeId,
|
|
14
|
+
command,
|
|
15
|
+
stdin = process.stdin,
|
|
16
|
+
stdout = process.stdout,
|
|
17
|
+
signals = process,
|
|
18
|
+
translate = createTranslator(),
|
|
19
|
+
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
20
|
+
openDirectTerminal: openDirectTerminalImpl
|
|
21
|
+
}) {
|
|
22
|
+
const dimensions = getDimensions(stdout);
|
|
23
|
+
let terminalId;
|
|
24
|
+
let transport;
|
|
25
|
+
let result;
|
|
26
|
+
let primaryError;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
terminalId = await client.createTerminal({
|
|
30
|
+
nodeId,
|
|
31
|
+
...dimensions,
|
|
32
|
+
...(command === undefined ? {} : { command })
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const openDirectTerminal =
|
|
36
|
+
openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
|
|
37
|
+
transport = await openDirectTerminal({
|
|
38
|
+
client,
|
|
39
|
+
WebSocketClass,
|
|
40
|
+
nodeId,
|
|
41
|
+
terminalId,
|
|
42
|
+
afterSequence: 0,
|
|
43
|
+
timeoutMs: connectionTimeoutMs
|
|
44
|
+
});
|
|
45
|
+
result = await runAttachedTerminal({
|
|
46
|
+
transport,
|
|
47
|
+
terminalId,
|
|
48
|
+
commandMode: command !== undefined,
|
|
49
|
+
stdin,
|
|
50
|
+
stdout,
|
|
51
|
+
signals,
|
|
52
|
+
translate
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.name === 'DirectUnsafeError')
|
|
56
|
+
throw new MarmgrError(translate('errors.terminalDirectUnsafeFallback'));
|
|
57
|
+
if (error?.name !== 'DirectFallbackError') throw error;
|
|
58
|
+
transport = await openRelayTransport({
|
|
59
|
+
client,
|
|
60
|
+
WebSocketClass,
|
|
61
|
+
nodeId,
|
|
62
|
+
terminalId,
|
|
63
|
+
translate,
|
|
64
|
+
connectionTimeoutMs
|
|
65
|
+
});
|
|
66
|
+
result = await runAttachedTerminal({
|
|
67
|
+
transport,
|
|
68
|
+
terminalId,
|
|
69
|
+
commandMode: command !== undefined,
|
|
70
|
+
stdin,
|
|
71
|
+
stdout,
|
|
72
|
+
signals,
|
|
73
|
+
translate
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
primaryError = toMarmgrError(error, translate);
|
|
78
|
+
} finally {
|
|
79
|
+
transport?.close?.();
|
|
80
|
+
if (terminalId !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
await client.deleteTerminal({ nodeId, terminalId });
|
|
83
|
+
} catch {
|
|
84
|
+
if (primaryError === undefined && result === undefined)
|
|
85
|
+
primaryError = new MarmgrError(translate('errors.terminalCloseFailed'));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (primaryError !== undefined) throw primaryError;
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function splitInput(text, maxBytes = MAX_INPUT_BYTES) {
|
|
95
|
+
const value = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let chunk = '';
|
|
98
|
+
let chunkBytes = 0;
|
|
99
|
+
|
|
100
|
+
for (const character of value) {
|
|
101
|
+
const characterBytes = Buffer.byteLength(character);
|
|
102
|
+
if (chunk !== '' && chunkBytes + characterBytes > maxBytes) {
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
chunk = '';
|
|
105
|
+
chunkBytes = 0;
|
|
106
|
+
}
|
|
107
|
+
if (characterBytes > maxBytes) continue;
|
|
108
|
+
chunk += character;
|
|
109
|
+
chunkBytes += characterBytes;
|
|
110
|
+
}
|
|
111
|
+
if (chunk !== '') chunks.push(chunk);
|
|
112
|
+
return chunks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getDimensions(stdout) {
|
|
116
|
+
const rows = Number.isSafeInteger(stdout.rows) && stdout.rows > 0 ? stdout.rows : DEFAULT_ROWS;
|
|
117
|
+
const cols =
|
|
118
|
+
Number.isSafeInteger(stdout.columns) && stdout.columns > 0 ? stdout.columns : DEFAULT_COLS;
|
|
119
|
+
return { rows: Math.min(rows, 1000), cols: Math.min(cols, 1000) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function openRelayTransport({
|
|
123
|
+
client,
|
|
124
|
+
WebSocketClass,
|
|
125
|
+
nodeId,
|
|
126
|
+
terminalId,
|
|
127
|
+
translate,
|
|
128
|
+
connectionTimeoutMs
|
|
129
|
+
}) {
|
|
130
|
+
const ticket = await client.createAttachTicket({ nodeId, terminalId });
|
|
131
|
+
const socket = client.openTerminalWebSocket(WebSocketClass, { terminalId, ticket });
|
|
132
|
+
const transport = new BufferedRelayTransport({
|
|
133
|
+
socket,
|
|
134
|
+
terminalId,
|
|
135
|
+
translate,
|
|
136
|
+
connectionTimeoutMs
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await transport.waitForAttachment();
|
|
140
|
+
return transport;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
transport.close();
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class BufferedRelayTransport extends EventEmitter {
|
|
148
|
+
#socket;
|
|
149
|
+
#terminalId;
|
|
150
|
+
#translate;
|
|
151
|
+
#attached = false;
|
|
152
|
+
#active = false;
|
|
153
|
+
#pendingEvents = [];
|
|
154
|
+
#attachmentResolve;
|
|
155
|
+
#attachmentReject;
|
|
156
|
+
#openTimer;
|
|
157
|
+
#attachTimer;
|
|
158
|
+
#closed = false;
|
|
159
|
+
#onOpen;
|
|
160
|
+
#onMessage;
|
|
161
|
+
#onError;
|
|
162
|
+
#onClose;
|
|
163
|
+
|
|
164
|
+
constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
|
|
165
|
+
super();
|
|
166
|
+
this.#socket = socket;
|
|
167
|
+
this.#terminalId = terminalId;
|
|
168
|
+
this.#translate = translate;
|
|
169
|
+
this.#onOpen = () => {
|
|
170
|
+
clearTimeout(this.#openTimer);
|
|
171
|
+
this.#armAttachTimer(connectionTimeoutMs);
|
|
172
|
+
};
|
|
173
|
+
this.#onMessage = (raw, isBinary) => {
|
|
174
|
+
try {
|
|
175
|
+
const frame = parseFrame(raw, isBinary, this.#terminalId, this.#translate);
|
|
176
|
+
if (!this.#attached) {
|
|
177
|
+
if (frame.type === 'attach') {
|
|
178
|
+
this.#attached = true;
|
|
179
|
+
clearTimeout(this.#attachTimer);
|
|
180
|
+
this.#attachmentResolve?.();
|
|
181
|
+
this.#attachmentResolve = undefined;
|
|
182
|
+
this.#attachmentReject = undefined;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (frame.type === 'error')
|
|
186
|
+
throw new MarmgrError(this.#translate('errors.terminalRemoteError'));
|
|
187
|
+
this.#queueOrEmit('message', raw, isBinary);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.#queueOrEmit('message', raw, isBinary);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.#fail(
|
|
193
|
+
error instanceof MarmgrError
|
|
194
|
+
? error
|
|
195
|
+
: new MarmgrError(this.#translate('errors.terminalFrameInvalid'))
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
this.#onError = () => {
|
|
200
|
+
const error = new MarmgrError(
|
|
201
|
+
this.#translate(
|
|
202
|
+
this.#attached ? 'errors.terminalConnectionFailed' : 'errors.terminalAttachConnection'
|
|
203
|
+
)
|
|
204
|
+
);
|
|
205
|
+
if (this.#attached) this.#queueOrEmit('error', error);
|
|
206
|
+
else this.#fail(error);
|
|
207
|
+
};
|
|
208
|
+
this.#onClose = () => {
|
|
209
|
+
const error = new MarmgrError(
|
|
210
|
+
this.#translate(
|
|
211
|
+
this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
if (this.#attached) this.#queueOrEmit('close');
|
|
215
|
+
else this.#fail(error);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
socket.on?.('open', this.#onOpen);
|
|
219
|
+
socket.on?.('message', this.#onMessage);
|
|
220
|
+
socket.on?.('error', this.#onError);
|
|
221
|
+
socket.on?.('close', this.#onClose);
|
|
222
|
+
if (socket.readyState === 1) this.#onOpen();
|
|
223
|
+
else
|
|
224
|
+
this.#openTimer = setTimeout(
|
|
225
|
+
() => this.#fail(new MarmgrError(this.#translate('errors.terminalConnectionOpen'))),
|
|
226
|
+
connectionTimeoutMs
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
waitForAttachment() {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
if (this.#closed) {
|
|
233
|
+
reject(new MarmgrError(this.#translate('errors.terminalAttachLost')));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.#attachmentResolve = resolve;
|
|
237
|
+
this.#attachmentReject = reject;
|
|
238
|
+
if (this.#attached) {
|
|
239
|
+
this.#attachmentResolve = undefined;
|
|
240
|
+
this.#attachmentReject = undefined;
|
|
241
|
+
resolve();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
activate() {
|
|
247
|
+
if (this.#active) return;
|
|
248
|
+
this.#active = true;
|
|
249
|
+
const pendingEvents = this.#pendingEvents;
|
|
250
|
+
this.#pendingEvents = [];
|
|
251
|
+
for (const { type, args } of pendingEvents) this.emit(type, ...args);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
get readyState() {
|
|
255
|
+
return this.#socket.readyState;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
send(value) {
|
|
259
|
+
if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
|
|
260
|
+
this.#socket.send(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
close() {
|
|
264
|
+
if (this.#closed) return;
|
|
265
|
+
this.#closed = true;
|
|
266
|
+
clearTimeout(this.#openTimer);
|
|
267
|
+
clearTimeout(this.#attachTimer);
|
|
268
|
+
this.#socket.removeListener?.('open', this.#onOpen);
|
|
269
|
+
this.#socket.removeListener?.('message', this.#onMessage);
|
|
270
|
+
this.#socket.removeListener?.('error', this.#onError);
|
|
271
|
+
this.#socket.removeListener?.('close', this.#onClose);
|
|
272
|
+
try {
|
|
273
|
+
this.#socket.close();
|
|
274
|
+
} catch {
|
|
275
|
+
// The connection is already unavailable.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#armAttachTimer(connectionTimeoutMs) {
|
|
280
|
+
if (this.#attached) return;
|
|
281
|
+
this.#attachTimer = setTimeout(
|
|
282
|
+
() => this.#fail(new MarmgrError(this.#translate('errors.terminalAttachTimeout'))),
|
|
283
|
+
connectionTimeoutMs
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#fail(error) {
|
|
288
|
+
if (this.#closed) return;
|
|
289
|
+
clearTimeout(this.#openTimer);
|
|
290
|
+
clearTimeout(this.#attachTimer);
|
|
291
|
+
this.#attachmentResolve = undefined;
|
|
292
|
+
const reject = this.#attachmentReject;
|
|
293
|
+
this.#attachmentReject = undefined;
|
|
294
|
+
if (!this.#attached) reject?.(error);
|
|
295
|
+
else this.#queueOrEmit('error', error);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#queueOrEmit(type, ...args) {
|
|
299
|
+
if (this.#active) {
|
|
300
|
+
if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.#pendingEvents.push({ type, args });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function runAttachedTerminal({
|
|
308
|
+
transport,
|
|
309
|
+
terminalId,
|
|
310
|
+
commandMode,
|
|
311
|
+
stdin,
|
|
312
|
+
stdout,
|
|
313
|
+
signals,
|
|
314
|
+
translate
|
|
315
|
+
}) {
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
let settled = false;
|
|
318
|
+
let inputEnabled = false;
|
|
319
|
+
let rawModeEnabled = false;
|
|
320
|
+
|
|
321
|
+
const cleanup = () => {
|
|
322
|
+
transport.removeListener?.('message', onMessage);
|
|
323
|
+
transport.removeListener?.('error', onError);
|
|
324
|
+
transport.removeListener?.('close', onClose);
|
|
325
|
+
stdin.removeListener?.('data', onInput);
|
|
326
|
+
stdout.removeListener?.('resize', onResize);
|
|
327
|
+
signals.removeListener?.('SIGINT', onSigint);
|
|
328
|
+
signals.removeListener?.('SIGTERM', onSigterm);
|
|
329
|
+
if (rawModeEnabled) {
|
|
330
|
+
try {
|
|
331
|
+
stdin.setRawMode(false);
|
|
332
|
+
} catch {
|
|
333
|
+
// The terminal may already be gone.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
stdin.pause?.();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const finish = (error, exitCode) => {
|
|
340
|
+
if (settled) return;
|
|
341
|
+
settled = true;
|
|
342
|
+
cleanup();
|
|
343
|
+
sendCloseFrame(transport, terminalId);
|
|
344
|
+
transport.close?.();
|
|
345
|
+
if (error === undefined) resolve(exitCode);
|
|
346
|
+
else reject(error);
|
|
347
|
+
};
|
|
348
|
+
const fail = (key) => finish(new MarmgrError(translate(key)));
|
|
349
|
+
const send = (frame) => {
|
|
350
|
+
transport.send(JSON.stringify(frame));
|
|
351
|
+
};
|
|
352
|
+
const onInput = (data) => {
|
|
353
|
+
try {
|
|
354
|
+
for (const chunk of splitInput(data)) send({ type: 'input', terminalId, data: chunk });
|
|
355
|
+
} catch {
|
|
356
|
+
fail('errors.terminalInputFailed');
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const onResize = () => {
|
|
360
|
+
try {
|
|
361
|
+
const { rows, cols } = getDimensions(stdout);
|
|
362
|
+
send({ type: 'resize', terminalId, rows, cols });
|
|
363
|
+
} catch {
|
|
364
|
+
fail('errors.terminalResizeFailed');
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const onMessage = (raw, isBinary) => {
|
|
368
|
+
try {
|
|
369
|
+
const frame = parseFrame(raw, isBinary, terminalId, translate);
|
|
370
|
+
if (frame.type === 'output') stdout.write(frame.data);
|
|
371
|
+
else if (frame.type === 'exit') {
|
|
372
|
+
finish(undefined, frame.exitCode === null ? 1 : frame.exitCode);
|
|
373
|
+
} else if (frame.type === 'error') fail('errors.terminalRemoteError');
|
|
374
|
+
else fail('errors.terminalUnexpectedFrame');
|
|
375
|
+
} catch (error) {
|
|
376
|
+
finish(
|
|
377
|
+
error instanceof MarmgrError
|
|
378
|
+
? error
|
|
379
|
+
: new MarmgrError(translate('errors.terminalFrameInvalid'))
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const onError = () => finish(new MarmgrError(translate('errors.terminalConnectionFailed')));
|
|
384
|
+
const onClose = () => finish(new MarmgrError(translate('errors.terminalConnectionLost')));
|
|
385
|
+
const onSigint = () => finish(undefined, 130);
|
|
386
|
+
const onSigterm = () => finish(undefined, 143);
|
|
387
|
+
const startInput = () => {
|
|
388
|
+
if (settled || inputEnabled) return;
|
|
389
|
+
inputEnabled = true;
|
|
390
|
+
try {
|
|
391
|
+
if (!commandMode) {
|
|
392
|
+
if (typeof stdin.setRawMode !== 'function') {
|
|
393
|
+
fail('errors.terminalInteractiveRequired');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
stdin.setRawMode(true);
|
|
397
|
+
rawModeEnabled = true;
|
|
398
|
+
}
|
|
399
|
+
stdin.resume?.();
|
|
400
|
+
stdin.on?.('data', onInput);
|
|
401
|
+
stdout.on?.('resize', onResize);
|
|
402
|
+
onResize();
|
|
403
|
+
} catch {
|
|
404
|
+
fail(commandMode ? 'errors.terminalSessionFailed' : 'errors.terminalInteractiveInit');
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
transport.on?.('message', onMessage);
|
|
408
|
+
transport.on?.('error', onError);
|
|
409
|
+
transport.on?.('close', onClose);
|
|
410
|
+
signals.on?.('SIGINT', onSigint);
|
|
411
|
+
signals.on?.('SIGTERM', onSigterm);
|
|
412
|
+
transport.activate?.();
|
|
413
|
+
startInput();
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseFrame(raw, isBinary, terminalId, translate) {
|
|
418
|
+
if (isBinary) throw new MarmgrError(translate('errors.terminalBinaryFrame'));
|
|
419
|
+
let frame;
|
|
420
|
+
try {
|
|
421
|
+
frame = JSON.parse(rawToString(raw));
|
|
422
|
+
} catch {
|
|
423
|
+
throw new MarmgrError(translate('errors.terminalInvalidJson'));
|
|
424
|
+
}
|
|
425
|
+
if (frame === null || typeof frame !== 'object' || frame.terminalId !== terminalId)
|
|
426
|
+
throw new MarmgrError(translate('errors.terminalFrameInvalid'));
|
|
427
|
+
|
|
428
|
+
if (frame.type === 'attach') {
|
|
429
|
+
if (!Number.isSafeInteger(frame.afterSequence) || frame.afterSequence < 0)
|
|
430
|
+
throw new MarmgrError(translate('errors.terminalFrameInvalid'));
|
|
431
|
+
return frame;
|
|
432
|
+
}
|
|
433
|
+
if (frame.type === 'output') {
|
|
434
|
+
if (
|
|
435
|
+
!Number.isSafeInteger(frame.sequence) ||
|
|
436
|
+
frame.sequence < 1 ||
|
|
437
|
+
typeof frame.data !== 'string' ||
|
|
438
|
+
Buffer.byteLength(frame.data) > MAX_INPUT_BYTES
|
|
439
|
+
)
|
|
440
|
+
throw new MarmgrError(translate('errors.terminalFrameInvalid'));
|
|
441
|
+
return frame;
|
|
442
|
+
}
|
|
443
|
+
if (frame.type === 'exit') {
|
|
444
|
+
if (frame.exitCode !== null && !Number.isSafeInteger(frame.exitCode))
|
|
445
|
+
throw new MarmgrError(translate('errors.terminalFrameInvalid'));
|
|
446
|
+
return frame;
|
|
447
|
+
}
|
|
448
|
+
if (frame.type === 'error' && typeof frame.code === 'string' && frame.code.length > 0)
|
|
449
|
+
return frame;
|
|
450
|
+
throw new MarmgrError(translate('errors.terminalFrameInvalid'));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function rawToString(raw) {
|
|
454
|
+
if (typeof raw === 'string') return raw;
|
|
455
|
+
if (Buffer.isBuffer(raw)) return raw.toString('utf8');
|
|
456
|
+
if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
|
|
457
|
+
if (ArrayBuffer.isView(raw))
|
|
458
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
|
459
|
+
return String(raw);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function sendCloseFrame(socket, terminalId) {
|
|
463
|
+
if (!terminalId || !isSocketOpen(socket)) return;
|
|
464
|
+
try {
|
|
465
|
+
socket.send(JSON.stringify({ type: 'close', terminalId }));
|
|
466
|
+
} catch {
|
|
467
|
+
// The DELETE request below remains the authoritative cleanup path.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isSocketOpen(socket) {
|
|
472
|
+
return socket.readyState === undefined || socket.readyState === 1;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function toMarmgrError(error, translate) {
|
|
476
|
+
return error instanceof MarmgrError
|
|
477
|
+
? error
|
|
478
|
+
: new MarmgrError(translate('errors.terminalSessionFailed'));
|
|
479
|
+
}
|