@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
package/src/direct.mjs
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
|
|
4
|
+
export const DIRECT_PREPARE_TIMEOUT_MS = 5000;
|
|
5
|
+
export const DIRECT_SETUP_TIMEOUT_MS = 15000;
|
|
6
|
+
export const DIRECT_HEARTBEAT_MS = 10000;
|
|
7
|
+
|
|
8
|
+
export class DirectFallbackError extends Error {
|
|
9
|
+
constructor(code = 'DIRECT_UNAVAILABLE') {
|
|
10
|
+
super(code);
|
|
11
|
+
this.name = 'DirectFallbackError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class DirectUnsafeError extends Error {
|
|
17
|
+
constructor(code = 'DIRECT_CANCEL_UNCONFIRMED') {
|
|
18
|
+
super(code);
|
|
19
|
+
this.name = 'DirectUnsafeError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function openDirectTerminal({
|
|
25
|
+
client,
|
|
26
|
+
WebSocketClass,
|
|
27
|
+
nodeId,
|
|
28
|
+
terminalId,
|
|
29
|
+
afterSequence = 0,
|
|
30
|
+
timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
|
|
31
|
+
heartbeatMs = DIRECT_HEARTBEAT_MS,
|
|
32
|
+
peerConnectionFactory
|
|
33
|
+
}) {
|
|
34
|
+
if (typeof client.getTerminalManifest !== 'function')
|
|
35
|
+
throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
|
|
36
|
+
|
|
37
|
+
let live;
|
|
38
|
+
let peer;
|
|
39
|
+
let bootstrapChannel;
|
|
40
|
+
let terminalChannel;
|
|
41
|
+
let transportSessionId;
|
|
42
|
+
let terminalSessionId;
|
|
43
|
+
let transport;
|
|
44
|
+
let unsafeFallback = false;
|
|
45
|
+
let transportControlCleanup;
|
|
46
|
+
let transportNegotiationActive = true;
|
|
47
|
+
let cleanupNative;
|
|
48
|
+
try {
|
|
49
|
+
const manifest = await client.getTerminalManifest(nodeId);
|
|
50
|
+
if (
|
|
51
|
+
typeof manifest?.nodeGeneration !== 'string' ||
|
|
52
|
+
manifest.nodeGeneration.length < 8 ||
|
|
53
|
+
manifest.terminalDirect?.apiVersion !== 1 ||
|
|
54
|
+
manifest.terminalDirect.transport !== true
|
|
55
|
+
)
|
|
56
|
+
throw new DirectFallbackError('DIRECT_UNSUPPORTED');
|
|
57
|
+
|
|
58
|
+
live = new LiveControl(client.openWorkbenchWebSocket(WebSocketClass), timeoutMs);
|
|
59
|
+
await live.openAndResume();
|
|
60
|
+
|
|
61
|
+
const transportPrepared = await live.request(
|
|
62
|
+
{
|
|
63
|
+
type: 'direct.prepare',
|
|
64
|
+
requestId: requestId(),
|
|
65
|
+
nodeId,
|
|
66
|
+
nodeGeneration: manifest.nodeGeneration,
|
|
67
|
+
purpose: 'TRANSPORT'
|
|
68
|
+
},
|
|
69
|
+
(message, request) =>
|
|
70
|
+
message.type === 'direct.prepared' &&
|
|
71
|
+
message.requestId === request.requestId &&
|
|
72
|
+
message.purpose === 'TRANSPORT',
|
|
73
|
+
'DIRECT_TRANSPORT_PREPARE_FAILED'
|
|
74
|
+
);
|
|
75
|
+
transportSessionId = transportPrepared.directSessionId;
|
|
76
|
+
|
|
77
|
+
const native =
|
|
78
|
+
peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
|
|
79
|
+
cleanupNative = native?.cleanup;
|
|
80
|
+
const createPeer =
|
|
81
|
+
peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
|
|
82
|
+
peer = createPeer(`marmgr-${transportSessionId}`, { iceServers: [] });
|
|
83
|
+
|
|
84
|
+
const transportReady = new Promise((resolve, reject) => {
|
|
85
|
+
let connected = false;
|
|
86
|
+
let serverOpened = false;
|
|
87
|
+
let settled = false;
|
|
88
|
+
const finish = (error) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
transportNegotiationActive = false;
|
|
92
|
+
if (error === undefined) resolve();
|
|
93
|
+
else reject(error);
|
|
94
|
+
};
|
|
95
|
+
const maybeReady = () => {
|
|
96
|
+
if (connected && serverOpened) finish();
|
|
97
|
+
};
|
|
98
|
+
transportControlCleanup = live.onControl((message) => {
|
|
99
|
+
if (!transportNegotiationActive || settled) return;
|
|
100
|
+
if (message.type === 'direct.signal' && message.directSessionId === transportSessionId) {
|
|
101
|
+
try {
|
|
102
|
+
applyRemoteSignal(peer, message.signal);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
finish(
|
|
105
|
+
error instanceof DirectFallbackError
|
|
106
|
+
? error
|
|
107
|
+
: new DirectFallbackError('DIRECT_SIGNAL_INVALID')
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (message.type === 'direct.opened' && message.directSessionId === transportSessionId) {
|
|
113
|
+
serverOpened = true;
|
|
114
|
+
maybeReady();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
(message.type === 'direct.error' || message.type === 'direct.closed') &&
|
|
119
|
+
message.directSessionId === transportSessionId
|
|
120
|
+
)
|
|
121
|
+
finish(new DirectFallbackError(message.code));
|
|
122
|
+
});
|
|
123
|
+
peer.onLocalDescription((sdp, type) => {
|
|
124
|
+
if (!transportNegotiationActive || settled) return;
|
|
125
|
+
if (String(type).toLowerCase() !== 'offer') return;
|
|
126
|
+
let signal;
|
|
127
|
+
try {
|
|
128
|
+
signal = localOfferSignal(sdp);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
finish(error instanceof DirectFallbackError ? error : new DirectFallbackError());
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!live.send({ type: 'direct.signal', directSessionId: transportSessionId, signal }))
|
|
134
|
+
finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
135
|
+
});
|
|
136
|
+
peer.onLocalCandidate((candidate, mid) => {
|
|
137
|
+
if (!transportNegotiationActive || settled || typeof candidate !== 'string') return;
|
|
138
|
+
if (candidate === '') {
|
|
139
|
+
if (
|
|
140
|
+
!live.send({
|
|
141
|
+
type: 'direct.signal',
|
|
142
|
+
directSessionId: transportSessionId,
|
|
143
|
+
signal: { kind: 'END_OF_CANDIDATES' }
|
|
144
|
+
})
|
|
145
|
+
)
|
|
146
|
+
finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!safeHostCandidate(candidate)) return;
|
|
150
|
+
if (
|
|
151
|
+
!live.send({
|
|
152
|
+
type: 'direct.signal',
|
|
153
|
+
directSessionId: transportSessionId,
|
|
154
|
+
signal: {
|
|
155
|
+
kind: 'CANDIDATE',
|
|
156
|
+
candidate: { candidate, sdpMid: mid || null, sdpMLineIndex: null }
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
)
|
|
160
|
+
finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
161
|
+
});
|
|
162
|
+
peer.onStateChange((state) => {
|
|
163
|
+
if (!transportNegotiationActive || settled) return;
|
|
164
|
+
const normalizedState = String(state).toLowerCase();
|
|
165
|
+
if (normalizedState === 'connected') {
|
|
166
|
+
if (!connected) {
|
|
167
|
+
connected = true;
|
|
168
|
+
if (!live.send({ type: 'direct.connected', directSessionId: transportSessionId }))
|
|
169
|
+
finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
170
|
+
}
|
|
171
|
+
maybeReady();
|
|
172
|
+
} else if (normalizedState === 'failed' || normalizedState === 'closed') {
|
|
173
|
+
finish(new DirectFallbackError('DIRECT_CONNECTION_FAILED'));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
bootstrapChannel = peer.createDataChannel('mar-direct-transport', {
|
|
178
|
+
unordered: false
|
|
179
|
+
});
|
|
180
|
+
if (peer.localDescription?.() == null) peer.setLocalDescription('Offer');
|
|
181
|
+
} catch {
|
|
182
|
+
finish(new DirectFallbackError('DIRECT_OFFER_FAILED'));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
await withTimeout(transportReady, timeoutMs, 'DIRECT_SETUP_TIMEOUT');
|
|
187
|
+
} finally {
|
|
188
|
+
transportNegotiationActive = false;
|
|
189
|
+
transportControlCleanup?.();
|
|
190
|
+
transportControlCleanup = undefined;
|
|
191
|
+
}
|
|
192
|
+
bootstrapChannel?.close?.();
|
|
193
|
+
|
|
194
|
+
const terminalPrepared = await live.request(
|
|
195
|
+
{
|
|
196
|
+
type: 'terminal.direct.prepare',
|
|
197
|
+
requestId: requestId(),
|
|
198
|
+
nodeId,
|
|
199
|
+
nodeGeneration: manifest.nodeGeneration,
|
|
200
|
+
transportSessionId,
|
|
201
|
+
terminalId,
|
|
202
|
+
afterSequence,
|
|
203
|
+
readonly: false
|
|
204
|
+
},
|
|
205
|
+
(message, request) =>
|
|
206
|
+
message.type === 'terminal.direct.prepared' &&
|
|
207
|
+
message.requestId === request.requestId &&
|
|
208
|
+
typeof message.directSessionId === 'string' &&
|
|
209
|
+
typeof message.grant === 'string',
|
|
210
|
+
'TERMINAL_DIRECT_PREPARE_FAILED'
|
|
211
|
+
);
|
|
212
|
+
terminalSessionId = terminalPrepared.directSessionId;
|
|
213
|
+
terminalChannel = peer.createDataChannel(`mar-terminal-${terminalSessionId}`, {
|
|
214
|
+
unordered: false
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
transport = new DirectTerminalTransport({
|
|
218
|
+
channel: terminalChannel,
|
|
219
|
+
live,
|
|
220
|
+
peer,
|
|
221
|
+
transportSessionId,
|
|
222
|
+
terminalSessionId,
|
|
223
|
+
terminalId,
|
|
224
|
+
heartbeatMs,
|
|
225
|
+
cleanupNative
|
|
226
|
+
});
|
|
227
|
+
await withTimeout(
|
|
228
|
+
transport.waitForAttachment(terminalPrepared.grant),
|
|
229
|
+
timeoutMs,
|
|
230
|
+
'TERMINAL_DIRECT_TIMEOUT'
|
|
231
|
+
);
|
|
232
|
+
return transport;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
transportControlCleanup?.();
|
|
235
|
+
transportControlCleanup = undefined;
|
|
236
|
+
unsafeFallback = transport?.openSent === true;
|
|
237
|
+
if (transport !== undefined) {
|
|
238
|
+
await transport.cancelAndDispose().catch(() => undefined);
|
|
239
|
+
transport.dispose({ skipCancel: true });
|
|
240
|
+
}
|
|
241
|
+
bootstrapChannel?.close?.();
|
|
242
|
+
terminalChannel?.close?.();
|
|
243
|
+
try {
|
|
244
|
+
if (transport === undefined && terminalSessionId !== undefined)
|
|
245
|
+
await cancelSession(live, 'terminal.direct.cancel', terminalSessionId);
|
|
246
|
+
if (transport === undefined && transportSessionId !== undefined)
|
|
247
|
+
await cancelSession(live, 'direct.cancel', transportSessionId);
|
|
248
|
+
} catch {
|
|
249
|
+
// Direct setup cleanup is best effort before relay fallback.
|
|
250
|
+
}
|
|
251
|
+
live?.close();
|
|
252
|
+
if (transport === undefined) peer?.close?.();
|
|
253
|
+
if (transport === undefined) cleanupNative?.();
|
|
254
|
+
if (unsafeFallback) throw new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED');
|
|
255
|
+
if (error instanceof DirectFallbackError || error instanceof DirectUnsafeError) throw error;
|
|
256
|
+
throw new DirectFallbackError(errorCode(error));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class LiveControl {
|
|
261
|
+
#socket;
|
|
262
|
+
#timeoutMs;
|
|
263
|
+
#opened = false;
|
|
264
|
+
#ready = false;
|
|
265
|
+
#closed = false;
|
|
266
|
+
#waiters = new Set();
|
|
267
|
+
#listeners = new Set();
|
|
268
|
+
#onMessage;
|
|
269
|
+
#onError;
|
|
270
|
+
#onClose;
|
|
271
|
+
|
|
272
|
+
constructor(socket, timeoutMs) {
|
|
273
|
+
this.#socket = socket;
|
|
274
|
+
this.#timeoutMs = timeoutMs;
|
|
275
|
+
this.#onMessage = (raw, isBinary) => {
|
|
276
|
+
if (isBinary) return;
|
|
277
|
+
let message;
|
|
278
|
+
try {
|
|
279
|
+
message = JSON.parse(rawToString(raw));
|
|
280
|
+
} catch {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!isRecord(message) || typeof message.type !== 'string') return;
|
|
284
|
+
for (const listener of this.#listeners) listener(message);
|
|
285
|
+
for (const waiter of [...this.#waiters]) {
|
|
286
|
+
if (!waiter.predicate(message, waiter.request)) continue;
|
|
287
|
+
this.#waiters.delete(waiter);
|
|
288
|
+
clearTimeout(waiter.timer);
|
|
289
|
+
waiter.resolve(message);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
this.#onError = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
|
|
293
|
+
this.#onClose = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
|
|
294
|
+
socket.on?.('message', this.#onMessage);
|
|
295
|
+
socket.on?.('error', this.#onError);
|
|
296
|
+
socket.on?.('close', this.#onClose);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async openAndResume() {
|
|
300
|
+
await this.#waitForSocket('open', 'DIRECT_CONTROL_TIMEOUT');
|
|
301
|
+
const ready = this.waitFor(
|
|
302
|
+
(message) =>
|
|
303
|
+
message.type === 'workbench.ready' && typeof message.workbenchConnectionId === 'string',
|
|
304
|
+
undefined,
|
|
305
|
+
'DIRECT_RESUME_TIMEOUT'
|
|
306
|
+
);
|
|
307
|
+
if (!this.send({ type: 'resume' })) {
|
|
308
|
+
ready.catch(() => undefined);
|
|
309
|
+
throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
|
|
310
|
+
}
|
|
311
|
+
await ready;
|
|
312
|
+
this.#ready = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
request(message, predicate, fallbackCode) {
|
|
316
|
+
if (!this.#ready) throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
|
|
317
|
+
const response = this.waitFor(predicate, message, fallbackCode);
|
|
318
|
+
if (!this.send(message)) {
|
|
319
|
+
response.catch(() => undefined);
|
|
320
|
+
throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
|
|
321
|
+
}
|
|
322
|
+
return response;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
waitFor(predicate, request, fallbackCode, timeoutMs = this.#timeoutMs) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
if (this.#closed) {
|
|
328
|
+
reject(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const waiter = {
|
|
332
|
+
predicate,
|
|
333
|
+
request,
|
|
334
|
+
resolve,
|
|
335
|
+
reject,
|
|
336
|
+
timer: setTimeout(() => {
|
|
337
|
+
this.#waiters.delete(waiter);
|
|
338
|
+
reject(new DirectFallbackError(fallbackCode ?? 'DIRECT_SETUP_TIMEOUT'));
|
|
339
|
+
}, timeoutMs)
|
|
340
|
+
};
|
|
341
|
+
this.#waiters.add(waiter);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
onControl(listener) {
|
|
346
|
+
if (this.#closed) return () => {};
|
|
347
|
+
this.#listeners.add(listener);
|
|
348
|
+
return () => this.#listeners.delete(listener);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
send(message) {
|
|
352
|
+
if (this.#closed || (this.#socket.readyState !== undefined && this.#socket.readyState !== 1))
|
|
353
|
+
return false;
|
|
354
|
+
try {
|
|
355
|
+
this.#socket.send(JSON.stringify(message));
|
|
356
|
+
return true;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
close() {
|
|
363
|
+
if (this.#closed) return;
|
|
364
|
+
this.#closed = true;
|
|
365
|
+
this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
|
|
366
|
+
this.#listeners.clear();
|
|
367
|
+
this.#socket.removeListener?.('message', this.#onMessage);
|
|
368
|
+
this.#socket.removeListener?.('error', this.#onError);
|
|
369
|
+
this.#socket.removeListener?.('close', this.#onClose);
|
|
370
|
+
try {
|
|
371
|
+
this.#socket.close();
|
|
372
|
+
} catch {
|
|
373
|
+
// The control socket is already closed.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#waitForSocket(event, code) {
|
|
378
|
+
if (this.#socket.readyState === 1) {
|
|
379
|
+
this.#opened = true;
|
|
380
|
+
return Promise.resolve();
|
|
381
|
+
}
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
const onEvent = () => {
|
|
384
|
+
cleanup();
|
|
385
|
+
this.#opened = true;
|
|
386
|
+
resolve();
|
|
387
|
+
};
|
|
388
|
+
const onError = () => {
|
|
389
|
+
cleanup();
|
|
390
|
+
reject(new DirectFallbackError(code));
|
|
391
|
+
};
|
|
392
|
+
const onClose = () => {
|
|
393
|
+
cleanup();
|
|
394
|
+
reject(new DirectFallbackError(code));
|
|
395
|
+
};
|
|
396
|
+
const timer = setTimeout(() => {
|
|
397
|
+
cleanup();
|
|
398
|
+
reject(new DirectFallbackError(code));
|
|
399
|
+
}, this.#timeoutMs);
|
|
400
|
+
const cleanup = () => {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
this.#socket.removeListener?.(event, onEvent);
|
|
403
|
+
this.#socket.removeListener?.('error', onError);
|
|
404
|
+
this.#socket.removeListener?.('close', onClose);
|
|
405
|
+
};
|
|
406
|
+
this.#socket.once?.(event, onEvent);
|
|
407
|
+
this.#socket.once?.('error', onError);
|
|
408
|
+
this.#socket.once?.('close', onClose);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#fail(error) {
|
|
413
|
+
if (this.#closed && this.#waiters.size === 0) return;
|
|
414
|
+
for (const waiter of [...this.#waiters]) {
|
|
415
|
+
this.#waiters.delete(waiter);
|
|
416
|
+
clearTimeout(waiter.timer);
|
|
417
|
+
waiter.reject(error);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
class DirectTerminalTransport extends EventEmitter {
|
|
423
|
+
#channel;
|
|
424
|
+
#live;
|
|
425
|
+
#peer;
|
|
426
|
+
#transportSessionId;
|
|
427
|
+
#terminalSessionId;
|
|
428
|
+
#terminalId;
|
|
429
|
+
#cleanupNative;
|
|
430
|
+
#heartbeatTimer;
|
|
431
|
+
#unsubscribeLive;
|
|
432
|
+
#closed = false;
|
|
433
|
+
#attached = false;
|
|
434
|
+
#active = false;
|
|
435
|
+
#pendingEvents = [];
|
|
436
|
+
#attachmentResolve;
|
|
437
|
+
#attachmentReject;
|
|
438
|
+
#openSent = false;
|
|
439
|
+
#cancelSent = false;
|
|
440
|
+
|
|
441
|
+
constructor({
|
|
442
|
+
channel,
|
|
443
|
+
live,
|
|
444
|
+
peer,
|
|
445
|
+
transportSessionId,
|
|
446
|
+
terminalSessionId,
|
|
447
|
+
terminalId,
|
|
448
|
+
heartbeatMs,
|
|
449
|
+
cleanupNative
|
|
450
|
+
}) {
|
|
451
|
+
super();
|
|
452
|
+
this.#channel = channel;
|
|
453
|
+
this.#live = live;
|
|
454
|
+
this.#peer = peer;
|
|
455
|
+
this.#transportSessionId = transportSessionId;
|
|
456
|
+
this.#terminalSessionId = terminalSessionId;
|
|
457
|
+
this.#terminalId = terminalId;
|
|
458
|
+
this.#cleanupNative = cleanupNative;
|
|
459
|
+
this.#channel.onMessage?.((raw) => this.#handleMessage(raw));
|
|
460
|
+
this.#channel.onClosed?.(() => this.#fail('TERMINAL_DIRECT_CLOSED'));
|
|
461
|
+
this.#channel.onError?.(() => this.#fail('TERMINAL_CONNECTION_FAILED'));
|
|
462
|
+
this.#unsubscribeLive = this.#live.onControl((message) => this.#handleControl(message));
|
|
463
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
464
|
+
if (
|
|
465
|
+
!this.#live.send({
|
|
466
|
+
type: 'direct.heartbeat',
|
|
467
|
+
directSessionId: this.#transportSessionId
|
|
468
|
+
}) ||
|
|
469
|
+
!this.#live.send({
|
|
470
|
+
type: 'terminal.direct.heartbeat',
|
|
471
|
+
directSessionId: this.#terminalSessionId
|
|
472
|
+
})
|
|
473
|
+
)
|
|
474
|
+
this.#fail('TERMINAL_CONNECTION_LOST');
|
|
475
|
+
}, heartbeatMs);
|
|
476
|
+
this.#heartbeatTimer.unref?.();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
get openSent() {
|
|
480
|
+
return this.#openSent;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
activate() {
|
|
484
|
+
if (this.#active) return;
|
|
485
|
+
this.#active = true;
|
|
486
|
+
const pendingEvents = this.#pendingEvents;
|
|
487
|
+
this.#pendingEvents = [];
|
|
488
|
+
for (const { type, args } of pendingEvents) this.#emit(type, ...args);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
waitForAttachment(grant) {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
if (this.#closed) {
|
|
494
|
+
reject(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.#attachmentResolve = resolve;
|
|
498
|
+
this.#attachmentReject = reject;
|
|
499
|
+
let opened = false;
|
|
500
|
+
const onOpen = () => {
|
|
501
|
+
if (opened || this.#closed) return;
|
|
502
|
+
opened = true;
|
|
503
|
+
try {
|
|
504
|
+
this.#openSent = true;
|
|
505
|
+
if (
|
|
506
|
+
!this.#channel.sendMessage(
|
|
507
|
+
JSON.stringify({
|
|
508
|
+
type: 'OPEN',
|
|
509
|
+
directSessionId: this.#terminalSessionId,
|
|
510
|
+
grant
|
|
511
|
+
})
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
throw new Error('TERMINAL_ATTACH_FAILED');
|
|
515
|
+
} catch {
|
|
516
|
+
this.#fail('TERMINAL_ATTACH_FAILED');
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
this.#channel.onOpen?.(onOpen);
|
|
520
|
+
if (this.#channel.isOpen?.() === true) onOpen();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
send(frame) {
|
|
525
|
+
if (this.#closed || this.#channel.isOpen?.() !== true)
|
|
526
|
+
throw new Error('TERMINAL_CONNECTION_LOST');
|
|
527
|
+
const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
|
|
528
|
+
if (!this.#channel.sendMessage(value)) throw new Error('TERMINAL_INPUT_FAILED');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
close() {
|
|
532
|
+
this.dispose();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
dispose({ skipCancel = false } = {}) {
|
|
536
|
+
if (this.#closed) return;
|
|
537
|
+
this.#closed = true;
|
|
538
|
+
clearInterval(this.#heartbeatTimer);
|
|
539
|
+
if (!skipCancel) this.#sendCancel();
|
|
540
|
+
this.#attachmentReject?.(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
|
|
541
|
+
this.#attachmentResolve = undefined;
|
|
542
|
+
this.#attachmentReject = undefined;
|
|
543
|
+
this.#unsubscribeLive?.();
|
|
544
|
+
this.#unsubscribeLive = undefined;
|
|
545
|
+
this.#channel.close?.();
|
|
546
|
+
this.#peer.close?.();
|
|
547
|
+
this.#live.close();
|
|
548
|
+
this.#cleanupNative?.();
|
|
549
|
+
this.#emit('close');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async cancelAndDispose() {
|
|
553
|
+
const terminalClosed = this.#live.waitFor(
|
|
554
|
+
(message) =>
|
|
555
|
+
message.type === 'terminal.direct.closed' &&
|
|
556
|
+
message.directSessionId === this.#terminalSessionId,
|
|
557
|
+
undefined,
|
|
558
|
+
'DIRECT_CANCEL_TIMEOUT',
|
|
559
|
+
1000
|
|
560
|
+
);
|
|
561
|
+
const transportClosed = this.#live.waitFor(
|
|
562
|
+
(message) =>
|
|
563
|
+
message.type === 'direct.closed' && message.directSessionId === this.#transportSessionId,
|
|
564
|
+
undefined,
|
|
565
|
+
'DIRECT_CANCEL_TIMEOUT',
|
|
566
|
+
1000
|
|
567
|
+
);
|
|
568
|
+
this.#sendCancel();
|
|
569
|
+
await Promise.allSettled([terminalClosed, transportClosed]);
|
|
570
|
+
this.dispose({ skipCancel: true });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
#handleMessage(raw) {
|
|
574
|
+
if (this.#closed) return;
|
|
575
|
+
if (!this.#attached) {
|
|
576
|
+
let frame;
|
|
577
|
+
try {
|
|
578
|
+
frame = JSON.parse(rawToString(raw));
|
|
579
|
+
} catch {
|
|
580
|
+
this.#fail('TERMINAL_FRAME_INVALID');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (
|
|
584
|
+
!isRecord(frame) ||
|
|
585
|
+
frame.type !== 'ATTACHED' ||
|
|
586
|
+
frame.terminalId !== this.#terminalId ||
|
|
587
|
+
!Number.isSafeInteger(frame.afterSequence) ||
|
|
588
|
+
frame.afterSequence < 0
|
|
589
|
+
) {
|
|
590
|
+
this.#fail('TERMINAL_ATTACH_FAILED');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.#attached = true;
|
|
594
|
+
this.#attachmentResolve?.();
|
|
595
|
+
this.#attachmentResolve = undefined;
|
|
596
|
+
this.#attachmentReject = undefined;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
this.#emit(
|
|
600
|
+
'message',
|
|
601
|
+
raw,
|
|
602
|
+
typeof raw === 'string'
|
|
603
|
+
? false
|
|
604
|
+
: Buffer.isBuffer(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#handleControl(message) {
|
|
609
|
+
if (message.type === 'terminal.direct.error' || message.type === 'terminal.direct.closed') {
|
|
610
|
+
if (!this.#attached) this.#fail(message.code);
|
|
611
|
+
else this.#fail(message.code);
|
|
612
|
+
}
|
|
613
|
+
if (message.type === 'direct.closed' || message.type === 'direct.error')
|
|
614
|
+
this.#fail(message.code);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
#fail(code) {
|
|
618
|
+
if (this.#closed) return;
|
|
619
|
+
const error = new Error(code);
|
|
620
|
+
if (!this.#attached) this.#attachmentReject?.(new DirectFallbackError(code));
|
|
621
|
+
this.#attachmentResolve = undefined;
|
|
622
|
+
this.#attachmentReject = undefined;
|
|
623
|
+
this.#emit('error', error);
|
|
624
|
+
this.dispose();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#emit(type, ...args) {
|
|
628
|
+
if (!this.#active) {
|
|
629
|
+
this.#pendingEvents.push({ type, args });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#sendCancel() {
|
|
636
|
+
if (this.#cancelSent) return;
|
|
637
|
+
this.#cancelSent = true;
|
|
638
|
+
this.#live.send({
|
|
639
|
+
type: 'terminal.direct.cancel',
|
|
640
|
+
directSessionId: this.#terminalSessionId
|
|
641
|
+
});
|
|
642
|
+
this.#live.send({
|
|
643
|
+
type: 'direct.cancel',
|
|
644
|
+
directSessionId: this.#transportSessionId,
|
|
645
|
+
reason: 'CLI_CLOSED'
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function cancelSession(live, type, directSessionId) {
|
|
651
|
+
if (live === undefined) return;
|
|
652
|
+
const closed = live.waitFor(
|
|
653
|
+
(message) =>
|
|
654
|
+
message.type === `${type.replace('.cancel', '.closed')}` &&
|
|
655
|
+
message.directSessionId === directSessionId,
|
|
656
|
+
undefined,
|
|
657
|
+
'DIRECT_CANCEL_TIMEOUT',
|
|
658
|
+
1000
|
|
659
|
+
);
|
|
660
|
+
live.send({
|
|
661
|
+
type,
|
|
662
|
+
directSessionId,
|
|
663
|
+
...(type === 'direct.cancel' ? { reason: 'CLI_FALLBACK' } : {})
|
|
664
|
+
});
|
|
665
|
+
await closed.catch(() => undefined);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function withTimeout(promise, timeoutMs, code) {
|
|
669
|
+
let timer;
|
|
670
|
+
return Promise.race([
|
|
671
|
+
promise,
|
|
672
|
+
new Promise((_, reject) => {
|
|
673
|
+
timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
|
|
674
|
+
})
|
|
675
|
+
]).finally(() => clearTimeout(timer));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function requestId() {
|
|
679
|
+
return randomUUID().replaceAll('-', '');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function rawToString(raw) {
|
|
683
|
+
if (typeof raw === 'string') return raw;
|
|
684
|
+
if (Buffer.isBuffer(raw)) return raw.toString('utf8');
|
|
685
|
+
if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
|
|
686
|
+
if (ArrayBuffer.isView(raw))
|
|
687
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
|
688
|
+
return String(raw);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isRecord(value) {
|
|
692
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function localOfferSignal(sdp) {
|
|
696
|
+
const fingerprint = extractFingerprint(sdp);
|
|
697
|
+
if (fingerprint === undefined) throw new DirectFallbackError('DIRECT_FINGERPRINT_INVALID');
|
|
698
|
+
return { kind: 'OFFER', sdp: stripHostCandidates(sdp), fingerprint };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function applyRemoteSignal(peer, signal) {
|
|
702
|
+
if (signal.kind === 'CANDIDATE') {
|
|
703
|
+
if (
|
|
704
|
+
!isRecord(signal.candidate) ||
|
|
705
|
+
typeof signal.candidate.candidate !== 'string' ||
|
|
706
|
+
!safeHostCandidate(signal.candidate.candidate)
|
|
707
|
+
)
|
|
708
|
+
throw new DirectFallbackError('DIRECT_CANDIDATE_REJECTED');
|
|
709
|
+
peer.addRemoteCandidate(
|
|
710
|
+
signal.candidate.candidate,
|
|
711
|
+
signal.candidate.sdpMid ?? String(signal.candidate.sdpMLineIndex ?? 0)
|
|
712
|
+
);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (signal.kind === 'END_OF_CANDIDATES') return;
|
|
716
|
+
if (
|
|
717
|
+
!hostOnlySdp(signal.sdp) ||
|
|
718
|
+
!fingerprintMatches(signal.sdp, signal.fingerprint) ||
|
|
719
|
+
signal.kind !== 'ANSWER'
|
|
720
|
+
)
|
|
721
|
+
throw new DirectFallbackError('DIRECT_FINGERPRINT_INVALID');
|
|
722
|
+
peer.setRemoteDescription(signal.sdp, 'Answer');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function extractFingerprint(sdp) {
|
|
726
|
+
if (typeof sdp !== 'string') return undefined;
|
|
727
|
+
const match = sdp.match(/^a=fingerprint:sha-256 ([A-F0-9:]+)\r?$/imu);
|
|
728
|
+
if (match === null) return undefined;
|
|
729
|
+
const value = match[1].toUpperCase();
|
|
730
|
+
return /^([A-F0-9]{2}:){31}[A-F0-9]{2}$/u.test(value) ? `sha-256 ${value}` : undefined;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function safeHostCandidate(candidate) {
|
|
734
|
+
return typeof candidate === 'string' && /\styp host(?:\s|$)/u.test(candidate);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function hostOnlySdp(sdp) {
|
|
738
|
+
if (typeof sdp !== 'string') return false;
|
|
739
|
+
return sdp
|
|
740
|
+
.split(/\r?\n/u)
|
|
741
|
+
.filter((line) => line.startsWith('a=candidate:'))
|
|
742
|
+
.every((line) => safeHostCandidate(line.slice(2)));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function stripHostCandidates(sdp) {
|
|
746
|
+
if (typeof sdp !== 'string') throw new DirectFallbackError('DIRECT_SDP_INVALID');
|
|
747
|
+
return sdp
|
|
748
|
+
.split(/\r?\n/u)
|
|
749
|
+
.filter((line) => !line.startsWith('a=candidate:') || safeHostCandidate(line.slice(2)))
|
|
750
|
+
.join('\r\n');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function fingerprintMatches(sdp, fingerprint) {
|
|
754
|
+
return extractFingerprint(sdp) === normalizeFingerprint(fingerprint);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function normalizeFingerprint(value) {
|
|
758
|
+
if (typeof value !== 'string') return undefined;
|
|
759
|
+
const match = /^sha-256 ([A-F0-9:]+)$/iu.exec(value);
|
|
760
|
+
if (match === null) return undefined;
|
|
761
|
+
const normalized = match[1].toUpperCase();
|
|
762
|
+
return /^([A-F0-9]{2}:){31}[A-F0-9]{2}$/u.test(normalized) ? `sha-256 ${normalized}` : undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function errorCode(error) {
|
|
766
|
+
return error instanceof Error && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.message)
|
|
767
|
+
? error.message
|
|
768
|
+
: 'DIRECT_CONNECTION_FAILED';
|
|
769
|
+
}
|