@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.0
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/CHANGELOG.md +59 -0
- package/cli.js +392 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +415 -17
- package/install.js +156 -26
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/protocol/http-auth.js +36 -1
- package/src/submit-gate.js +130 -5
- package/src/transport/broker-client.js +498 -0
- package/src/transport/broker-protocol.js +155 -0
- package/src/transport/broker-server.js +505 -0
- package/src/win-resolve-executable.js +6 -1
package/src/submit-gate.js
CHANGED
|
@@ -198,6 +198,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
198
198
|
|
|
199
199
|
let lastVisibility = null;
|
|
200
200
|
let everVisible = false;
|
|
201
|
+
let everObservedOutput = false;
|
|
201
202
|
|
|
202
203
|
while (true) {
|
|
203
204
|
const state = getState ? getState() : null;
|
|
@@ -214,6 +215,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
214
215
|
|
|
215
216
|
const visibility = observeBodyVisibility(session, bodyText, opts);
|
|
216
217
|
lastVisibility = visibility;
|
|
218
|
+
if (visibility.observable && visibility.empty === false) everObservedOutput = true;
|
|
217
219
|
if (visibility.reason === 'empty_body') {
|
|
218
220
|
return {
|
|
219
221
|
accepted: true,
|
|
@@ -235,22 +237,67 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
235
237
|
}
|
|
236
238
|
if (visibility.visible) {
|
|
237
239
|
everVisible = true;
|
|
238
|
-
} else {
|
|
240
|
+
} else if (everVisible) {
|
|
241
|
+
// Body was visible then disappeared — the CR consumed the input line.
|
|
242
|
+
return {
|
|
243
|
+
accepted: true,
|
|
244
|
+
retryable: false,
|
|
245
|
+
waited_ms: now() - start,
|
|
246
|
+
reason: 'body_consumed',
|
|
247
|
+
visibility,
|
|
248
|
+
};
|
|
249
|
+
} else if (!getState) {
|
|
250
|
+
// No state probe → preserve the optimistic body-absent accept so callers
|
|
251
|
+
// without a sessionStateManager keep the prior screen-free behavior.
|
|
239
252
|
return {
|
|
240
253
|
accepted: true,
|
|
241
254
|
retryable: false,
|
|
242
255
|
waited_ms: now() - start,
|
|
243
|
-
reason:
|
|
256
|
+
reason: 'body_absent',
|
|
244
257
|
visibility,
|
|
245
258
|
};
|
|
246
259
|
}
|
|
260
|
+
// else: body never observably present AND a state probe IS available — do NOT
|
|
261
|
+
// optimistically accept on absence. A dropped CR on codex alt-screen renders
|
|
262
|
+
// the body OFF the outputRing tail, so absence is not positive submit evidence
|
|
263
|
+
// (#568 FM3). Keep polling within the window for the primary signal — a state
|
|
264
|
+
// transition idle→working/thinking. If none arrives, fall through to no_land.
|
|
247
265
|
|
|
248
266
|
if (now() - start >= timeoutMs) {
|
|
267
|
+
if (visibility.visible) {
|
|
268
|
+
// Body stayed in the input box the whole window — the CR was not consumed.
|
|
269
|
+
return {
|
|
270
|
+
accepted: false,
|
|
271
|
+
retryable: true,
|
|
272
|
+
waited_ms: now() - start,
|
|
273
|
+
reason: 'body_still_visible',
|
|
274
|
+
visibility,
|
|
275
|
+
state: state || undefined,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (everObservedOutput) {
|
|
279
|
+
// The terminal produced output (it is alive) but the body was never consumed
|
|
280
|
+
// AND no state transition occurred — the CR did not land (#568 FM3, e.g. a
|
|
281
|
+
// dropped CR on codex alt-screen). Truthful retryable failure, never a false
|
|
282
|
+
// success on an unsent CR.
|
|
283
|
+
return {
|
|
284
|
+
accepted: false,
|
|
285
|
+
retryable: true,
|
|
286
|
+
waited_ms: now() - start,
|
|
287
|
+
reason: 'no_land',
|
|
288
|
+
visibility,
|
|
289
|
+
state: state || undefined,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// No observable terminal output at all for the whole window — we have zero
|
|
293
|
+
// screen evidence either way. Preserve the long-standing optimistic accept
|
|
294
|
+
// (back-compat: a wrapped session that never echoes), but mark it ambiguous.
|
|
249
295
|
return {
|
|
250
|
-
accepted:
|
|
251
|
-
retryable:
|
|
296
|
+
accepted: true,
|
|
297
|
+
retryable: false,
|
|
252
298
|
waited_ms: now() - start,
|
|
253
|
-
reason: '
|
|
299
|
+
reason: 'no_observable',
|
|
300
|
+
ambiguous: true,
|
|
254
301
|
visibility,
|
|
255
302
|
state: state || undefined,
|
|
256
303
|
};
|
|
@@ -260,6 +307,78 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
260
307
|
}
|
|
261
308
|
}
|
|
262
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Render-gate the submit CR (#568). Resolve `ready` only when the input is
|
|
312
|
+
* settled enough to safely receive a bare 0x0D:
|
|
313
|
+
* - the injected body is echoed in the outputRing tail (the input is present), AND
|
|
314
|
+
* - the render has gone quiet — the tail is unchanged for ≥ quietWindowMs.
|
|
315
|
+
*
|
|
316
|
+
* This closes the FM1 busy-render race: the pre-#568 submit fired the CR with no
|
|
317
|
+
* readiness gate, so under load the CR landed mid-render and the TUI dropped it.
|
|
318
|
+
* The daemon applies the same gate before each retry CR (FM2).
|
|
319
|
+
*
|
|
320
|
+
* Bounded + best-effort: if the render never goes quiet within timeoutMs (e.g. a
|
|
321
|
+
* continuous spinner), resolve { ready:false, reason:'timeout' } so the caller
|
|
322
|
+
* STILL writes the CR — never worse than the pre-gate behavior. When the body
|
|
323
|
+
* never echoes into the tail (alt-screen / non-echoing TUI), settle on the quiet
|
|
324
|
+
* window alone once echoGraceMs has elapsed (reason:'settled_no_echo').
|
|
325
|
+
*
|
|
326
|
+
* Pure: outputRing-only, DI now/sleep — no I/O, no daemon coupling.
|
|
327
|
+
*
|
|
328
|
+
* @param {{ outputRing?: string[] }} session
|
|
329
|
+
* @param {string} bodyText
|
|
330
|
+
* @param {{ timeoutMs?: number, quietWindowMs?: number, echoGraceMs?: number, pollIntervalMs?: number, tailBytes?: number, stripAnsi?: Function, now?: Function, sleep?: Function }} [opts]
|
|
331
|
+
* @returns {Promise<{ ready: boolean, reason: string, echoed: boolean, settled: boolean, waited_ms: number }>}
|
|
332
|
+
*/
|
|
333
|
+
async function awaitInputSettled(session, bodyText, opts = {}) {
|
|
334
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
|
|
335
|
+
const quietWindowMs = Number.isFinite(opts.quietWindowMs) ? opts.quietWindowMs : 100;
|
|
336
|
+
const echoGraceMs = Number.isFinite(opts.echoGraceMs) ? opts.echoGraceMs : 400;
|
|
337
|
+
const pollIntervalMs = Number.isFinite(opts.pollIntervalMs) ? opts.pollIntervalMs : 30;
|
|
338
|
+
const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
|
|
339
|
+
const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
|
|
340
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
341
|
+
const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
342
|
+
|
|
343
|
+
const needle = normalize(bodyText);
|
|
344
|
+
if (!needle) {
|
|
345
|
+
return { ready: true, reason: 'empty_body', echoed: false, settled: true, waited_ms: 0 };
|
|
346
|
+
}
|
|
347
|
+
if (!session || !Array.isArray(session.outputRing)) {
|
|
348
|
+
// No ring to observe — cannot gate; stay optimistic and never block the CR.
|
|
349
|
+
return { ready: true, reason: 'no_ring', echoed: false, settled: false, waited_ms: 0 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const start = now();
|
|
353
|
+
let lastTail = null;
|
|
354
|
+
let lastChangeAt = start;
|
|
355
|
+
let everEchoed = false;
|
|
356
|
+
|
|
357
|
+
while (true) {
|
|
358
|
+
const tail = normalize(stripAnsi(readTail(session, tailBytes)));
|
|
359
|
+
if (tail.indexOf(needle) !== -1) everEchoed = true;
|
|
360
|
+
|
|
361
|
+
if (lastTail === null || tail !== lastTail) {
|
|
362
|
+
// Render still active — reset the quiet-window timer.
|
|
363
|
+
lastTail = tail;
|
|
364
|
+
lastChangeAt = now();
|
|
365
|
+
} else if (now() - lastChangeAt >= quietWindowMs) {
|
|
366
|
+
// Tail unchanged for the full quiet window → render settled.
|
|
367
|
+
if (everEchoed) {
|
|
368
|
+
return { ready: true, reason: 'settled', echoed: true, settled: true, waited_ms: now() - start };
|
|
369
|
+
}
|
|
370
|
+
if (now() - start >= echoGraceMs) {
|
|
371
|
+
return { ready: true, reason: 'settled_no_echo', echoed: false, settled: true, waited_ms: now() - start };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (now() - start >= timeoutMs) {
|
|
376
|
+
return { ready: false, reason: 'timeout', echoed: everEchoed, settled: false, waited_ms: now() - start };
|
|
377
|
+
}
|
|
378
|
+
await sleep(pollIntervalMs);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
263
382
|
function isAcceptedSubmitState(state, submittedAtMs) {
|
|
264
383
|
if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
|
|
265
384
|
if (!Number.isFinite(submittedAtMs)) {
|
|
@@ -292,6 +411,7 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
|
|
|
292
411
|
observable: true,
|
|
293
412
|
visible: haystack.indexOf(needle) !== -1,
|
|
294
413
|
source: 'screen',
|
|
414
|
+
empty: haystack.length === 0,
|
|
295
415
|
};
|
|
296
416
|
}
|
|
297
417
|
|
|
@@ -305,6 +425,10 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
|
|
|
305
425
|
observable: true,
|
|
306
426
|
visible: haystack.indexOf(needle) !== -1,
|
|
307
427
|
source: 'output_ring',
|
|
428
|
+
// #568: distinguish "terminal alive but body off-screen" (empty=false → a
|
|
429
|
+
// dropped CR is no_land) from "no screen evidence at all" (empty=true → stay
|
|
430
|
+
// optimistic). Used by confirmSubmitAccepted's bounded timeout fallback.
|
|
431
|
+
empty: haystack.length === 0,
|
|
308
432
|
};
|
|
309
433
|
}
|
|
310
434
|
|
|
@@ -425,6 +549,7 @@ function defaultReadScreen(workspaceId, lines) {
|
|
|
425
549
|
|
|
426
550
|
module.exports = {
|
|
427
551
|
awaitReplReady,
|
|
552
|
+
awaitInputSettled,
|
|
428
553
|
verifyBodyConsumed,
|
|
429
554
|
confirmSubmitAccepted,
|
|
430
555
|
observeBodyVisibility,
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const https = require('node:https');
|
|
5
|
+
const net = require('node:net');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
buildAck,
|
|
9
|
+
buildInjectEnvelope,
|
|
10
|
+
createMessageIdDeduper,
|
|
11
|
+
parseInjectEnvelope,
|
|
12
|
+
parseSseFrame,
|
|
13
|
+
} = require('./broker-protocol');
|
|
14
|
+
|
|
15
|
+
function createBrokerClient(options) {
|
|
16
|
+
return new BrokerClient(options);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class BrokerClient {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.url = new URL(requireString(options.url, 'url'));
|
|
22
|
+
if (this.url.protocol !== 'https:') {
|
|
23
|
+
throw new Error('Broker URL must use https');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.node = requireString(options.node || options.nodeName, 'node');
|
|
27
|
+
this.nodeJwt = requireString(options.nodeJwt || options.jwt, 'nodeJwt');
|
|
28
|
+
this.pin = normalizePin(options.pin || null);
|
|
29
|
+
this.deliver = requireFunction(options.deliver, 'deliver');
|
|
30
|
+
this.getSession = typeof options.getSession === 'function'
|
|
31
|
+
? options.getSession
|
|
32
|
+
: createSessionGetter(options.sessions);
|
|
33
|
+
this.getSessions = typeof options.getSessions === 'function'
|
|
34
|
+
? options.getSessions
|
|
35
|
+
: createSessionsGetter(options.sessions);
|
|
36
|
+
this.acceptFrom = options.acceptFrom === undefined ? options.accept_from : options.acceptFrom;
|
|
37
|
+
|
|
38
|
+
this.heartbeatMs = numberOr(options.heartbeatMs, 23000);
|
|
39
|
+
this.reconnect = options.reconnect !== false;
|
|
40
|
+
this.reconnectInitialMs = numberOr(options.reconnectInitialMs, 500);
|
|
41
|
+
this.reconnectMaxMs = numberOr(options.reconnectMaxMs, 10000);
|
|
42
|
+
this.reconnectJitterMs = numberOr(options.reconnectJitterMs, 250);
|
|
43
|
+
this.requestTimeoutMs = numberOr(options.requestTimeoutMs, 10000);
|
|
44
|
+
this.random = typeof options.random === 'function' ? options.random : Math.random;
|
|
45
|
+
this.agent = options.agent || new https.Agent({ keepAlive: false, maxCachedSessions: 0 });
|
|
46
|
+
|
|
47
|
+
this.lastEventId = options.lastEventId === undefined || options.lastEventId === null
|
|
48
|
+
? null
|
|
49
|
+
: String(options.lastEventId);
|
|
50
|
+
this.deduper = options.deduper || createMessageIdDeduper(options.deduperOptions);
|
|
51
|
+
this.processing = Promise.resolve();
|
|
52
|
+
this.started = false;
|
|
53
|
+
this.stopped = true;
|
|
54
|
+
this.currentRequest = null;
|
|
55
|
+
this.currentStream = null;
|
|
56
|
+
this.reconnectTimer = null;
|
|
57
|
+
this.heartbeatTimer = null;
|
|
58
|
+
this.reconnectDelayMs = this.reconnectInitialMs;
|
|
59
|
+
this.lastError = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start() {
|
|
63
|
+
if (this.started) return this;
|
|
64
|
+
this.started = true;
|
|
65
|
+
this.stopped = false;
|
|
66
|
+
this.startHeartbeat();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await this.connectStream();
|
|
70
|
+
this.reconnectDelayMs = this.reconnectInitialMs;
|
|
71
|
+
return this;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.lastError = error;
|
|
74
|
+
this.scheduleReconnect();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stop() {
|
|
80
|
+
this.started = false;
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
83
|
+
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
|
|
84
|
+
this.reconnectTimer = null;
|
|
85
|
+
this.heartbeatTimer = null;
|
|
86
|
+
if (this.currentRequest) this.currentRequest.destroy();
|
|
87
|
+
if (this.currentStream) this.currentStream.destroy();
|
|
88
|
+
this.currentRequest = null;
|
|
89
|
+
this.currentStream = null;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async inject(input) {
|
|
94
|
+
const envelope = buildInjectEnvelope(input);
|
|
95
|
+
const response = await this.postJson('/broker/inject', envelope);
|
|
96
|
+
return response.body;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async register() {
|
|
100
|
+
return this.postJson('/broker/register', {
|
|
101
|
+
node: this.node,
|
|
102
|
+
sessions: normalizeSessions(this.getSessions()),
|
|
103
|
+
last_event_id: this.lastEventId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async heartbeat() {
|
|
108
|
+
return this.postJson('/broker/heartbeat', {
|
|
109
|
+
node: this.node,
|
|
110
|
+
sessions: normalizeSessions(this.getSessions()),
|
|
111
|
+
last_event_id: this.lastEventId,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
startHeartbeat() {
|
|
116
|
+
if (!this.heartbeatMs || this.heartbeatMs < 1) return;
|
|
117
|
+
const tick = async () => {
|
|
118
|
+
if (this.stopped) return;
|
|
119
|
+
try {
|
|
120
|
+
await this.heartbeat();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.lastError = error;
|
|
123
|
+
}
|
|
124
|
+
this.startHeartbeat();
|
|
125
|
+
};
|
|
126
|
+
this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
|
|
127
|
+
this.heartbeatTimer.unref?.();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async connectStream() {
|
|
131
|
+
await this.register();
|
|
132
|
+
if (this.stopped) return;
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
let disconnected = false;
|
|
137
|
+
let buffer = '';
|
|
138
|
+
const headers = this.authHeaders({ accept: 'text/event-stream' });
|
|
139
|
+
if (this.lastEventId !== null) {
|
|
140
|
+
headers['last-event-id'] = this.lastEventId;
|
|
141
|
+
}
|
|
142
|
+
const req = this.createRequest('GET', '/broker/stream', headers, (res) => {
|
|
143
|
+
if (res.statusCode !== 200) {
|
|
144
|
+
collectResponse(res).then((body) => {
|
|
145
|
+
rejectOnce(httpError('Broker stream failed', res.statusCode, body));
|
|
146
|
+
}, rejectOnce);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.currentStream = res;
|
|
151
|
+
resolveOnce();
|
|
152
|
+
res.setEncoding('utf8');
|
|
153
|
+
res.on('data', (chunk) => {
|
|
154
|
+
buffer += chunk;
|
|
155
|
+
buffer = this.consumeSseBuffer(buffer);
|
|
156
|
+
});
|
|
157
|
+
res.on('end', () => disconnectOnce());
|
|
158
|
+
res.on('close', () => disconnectOnce());
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.currentRequest = req;
|
|
162
|
+
req.on('error', (error) => {
|
|
163
|
+
if (settled) {
|
|
164
|
+
disconnectOnce(error);
|
|
165
|
+
} else {
|
|
166
|
+
rejectOnce(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
req.end();
|
|
170
|
+
|
|
171
|
+
const resolveOnce = () => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
const rejectOnce = (error) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
this.currentRequest = null;
|
|
180
|
+
reject(error);
|
|
181
|
+
};
|
|
182
|
+
const disconnectOnce = (error) => {
|
|
183
|
+
if (disconnected || this.stopped) return;
|
|
184
|
+
disconnected = true;
|
|
185
|
+
this.currentRequest = null;
|
|
186
|
+
this.currentStream = null;
|
|
187
|
+
if (error) this.lastError = error;
|
|
188
|
+
this.scheduleReconnect();
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
consumeSseBuffer(buffer) {
|
|
194
|
+
let remaining = buffer;
|
|
195
|
+
for (;;) {
|
|
196
|
+
const boundary = findFrameBoundary(remaining);
|
|
197
|
+
if (!boundary) return remaining;
|
|
198
|
+
const rawFrame = remaining.slice(0, boundary.index);
|
|
199
|
+
remaining = remaining.slice(boundary.index + boundary.length);
|
|
200
|
+
if (!rawFrame.trim()) continue;
|
|
201
|
+
const frame = parseSseFrame(rawFrame);
|
|
202
|
+
if (frame.id !== null && frame.id !== undefined && frame.id !== '') {
|
|
203
|
+
this.lastEventId = String(frame.id);
|
|
204
|
+
}
|
|
205
|
+
this.enqueueFrame(frame);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
enqueueFrame(frame) {
|
|
210
|
+
this.processing = this.processing
|
|
211
|
+
.then(() => this.handleFrame(frame))
|
|
212
|
+
.catch((error) => {
|
|
213
|
+
this.lastError = error;
|
|
214
|
+
});
|
|
215
|
+
return this.processing;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async handleFrame(frame) {
|
|
219
|
+
if (frame.event !== 'inject') return;
|
|
220
|
+
const envelope = parseInjectEnvelope(frame.data);
|
|
221
|
+
await this.handleInjectEnvelope(envelope);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async handleInjectEnvelope(envelope) {
|
|
225
|
+
if (!this.deduper.accept(envelope)) {
|
|
226
|
+
await this.ack({ inject_id: envelope.inject_id, success: true });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!acceptsFrom(this.acceptFrom, envelope.from_node, envelope)) {
|
|
231
|
+
await this.ack({
|
|
232
|
+
inject_id: envelope.inject_id,
|
|
233
|
+
success: false,
|
|
234
|
+
code: 'ACCEPT_FROM_DENIED',
|
|
235
|
+
error: `Broker inject from ${envelope.from_node} denied by accept_from`,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const session = this.getSession(envelope.to_session, envelope);
|
|
241
|
+
if (!session) {
|
|
242
|
+
await this.ack({
|
|
243
|
+
inject_id: envelope.inject_id,
|
|
244
|
+
success: false,
|
|
245
|
+
code: 'SESSION_NOT_FOUND',
|
|
246
|
+
error: `Session not found: ${envelope.to_session}`,
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const payload = envelope.payload || {};
|
|
252
|
+
if (typeof payload.prompt !== 'string') {
|
|
253
|
+
await this.ack({
|
|
254
|
+
inject_id: envelope.inject_id,
|
|
255
|
+
success: false,
|
|
256
|
+
code: 'INVALID_REQUEST',
|
|
257
|
+
error: 'prompt is required',
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const delivery = await this.callDeliver(envelope, session, payload);
|
|
263
|
+
await this.ack({
|
|
264
|
+
inject_id: envelope.inject_id,
|
|
265
|
+
success: delivery.success === true,
|
|
266
|
+
code: delivery.success === true ? null : delivery.code || 'DELIVERY_FAILED',
|
|
267
|
+
error: delivery.success === true ? null : delivery.error || 'Broker inject delivery failed',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async callDeliver(envelope, session, payload) {
|
|
272
|
+
const options = {
|
|
273
|
+
noEnter: payload.no_enter === true,
|
|
274
|
+
source: 'broker',
|
|
275
|
+
from: payload.from || envelope.from_node || 'broker',
|
|
276
|
+
};
|
|
277
|
+
if (payload.reply_to) options.replyTo = payload.reply_to;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
return await this.deliver(envelope.to_session, session, payload.prompt, options);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return {
|
|
283
|
+
success: false,
|
|
284
|
+
code: 'DELIVERY_ERROR',
|
|
285
|
+
error: error && error.message ? error.message : String(error),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async ack(input) {
|
|
291
|
+
try {
|
|
292
|
+
await this.postJson('/broker/ack', buildAck(input));
|
|
293
|
+
} catch (error) {
|
|
294
|
+
this.lastError = error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
scheduleReconnect() {
|
|
299
|
+
if (this.stopped || !this.reconnect || this.reconnectTimer) return;
|
|
300
|
+
const delay = this.nextReconnectDelay();
|
|
301
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
302
|
+
this.reconnectTimer = null;
|
|
303
|
+
if (this.stopped) return;
|
|
304
|
+
try {
|
|
305
|
+
await this.connectStream();
|
|
306
|
+
this.reconnectDelayMs = this.reconnectInitialMs;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this.lastError = error;
|
|
309
|
+
this.scheduleReconnect();
|
|
310
|
+
}
|
|
311
|
+
}, delay);
|
|
312
|
+
this.reconnectTimer.unref?.();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
nextReconnectDelay() {
|
|
316
|
+
const jitter = this.reconnectJitterMs > 0 ? Math.floor(this.random() * this.reconnectJitterMs) : 0;
|
|
317
|
+
const delay = Math.min(this.reconnectDelayMs, this.reconnectMaxMs) + jitter;
|
|
318
|
+
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
|
|
319
|
+
return delay;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async postJson(path, body) {
|
|
323
|
+
const payload = JSON.stringify(body || {});
|
|
324
|
+
const headers = this.authHeaders({
|
|
325
|
+
accept: 'application/json',
|
|
326
|
+
'content-type': 'application/json',
|
|
327
|
+
'content-length': Buffer.byteLength(payload),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
const req = this.createRequest('POST', path, headers, (res) => {
|
|
332
|
+
collectResponse(res).then((rawBody) => {
|
|
333
|
+
const parsedBody = parseJsonBody(rawBody);
|
|
334
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
335
|
+
reject(httpError('Broker request failed', res.statusCode, parsedBody));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body: parsedBody });
|
|
339
|
+
}, reject);
|
|
340
|
+
});
|
|
341
|
+
req.setTimeout(this.requestTimeoutMs, () => {
|
|
342
|
+
req.destroy(new Error('Broker request timed out'));
|
|
343
|
+
});
|
|
344
|
+
req.on('error', reject);
|
|
345
|
+
req.end(payload);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
createRequest(method, path, headers, onResponse) {
|
|
350
|
+
const requestUrl = new URL(path, this.url);
|
|
351
|
+
const req = https.request({
|
|
352
|
+
protocol: requestUrl.protocol,
|
|
353
|
+
hostname: requestUrl.hostname,
|
|
354
|
+
port: requestUrl.port,
|
|
355
|
+
path: `${requestUrl.pathname}${requestUrl.search}`,
|
|
356
|
+
method,
|
|
357
|
+
headers,
|
|
358
|
+
servername: net.isIP(requestUrl.hostname) ? undefined : requestUrl.hostname,
|
|
359
|
+
rejectUnauthorized: this.pin ? false : true,
|
|
360
|
+
agent: this.agent,
|
|
361
|
+
}, onResponse);
|
|
362
|
+
|
|
363
|
+
if (this.pin) {
|
|
364
|
+
req.on('socket', (socket) => {
|
|
365
|
+
socket.once('secureConnect', () => {
|
|
366
|
+
const cert = socket.getPeerCertificate(true);
|
|
367
|
+
const actualPin = certificateFingerprint(cert);
|
|
368
|
+
if (actualPin !== this.pin) {
|
|
369
|
+
req.destroy(new Error('TLS pin mismatch'));
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return req;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
authHeaders(extra = {}) {
|
|
379
|
+
return {
|
|
380
|
+
...extra,
|
|
381
|
+
authorization: `Bearer ${this.nodeJwt}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function createSessionGetter(sessions) {
|
|
387
|
+
return (id) => {
|
|
388
|
+
if (!sessions) return null;
|
|
389
|
+
if (sessions instanceof Map) return sessions.get(id) || null;
|
|
390
|
+
return sessions[id] || null;
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function createSessionsGetter(sessions) {
|
|
395
|
+
return () => sessions || [];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeSessions(value) {
|
|
399
|
+
if (!value) return [];
|
|
400
|
+
if (Array.isArray(value)) return value;
|
|
401
|
+
if (value instanceof Map) return [...value.values()];
|
|
402
|
+
if (typeof value === 'object') return Object.values(value);
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function acceptsFrom(acceptFrom, fromNode, envelope) {
|
|
407
|
+
if (acceptFrom === null || acceptFrom === undefined) return true;
|
|
408
|
+
if (typeof acceptFrom === 'function') return acceptFrom(fromNode, envelope) === true;
|
|
409
|
+
if (Array.isArray(acceptFrom) || acceptFrom instanceof Set) {
|
|
410
|
+
return listHas(acceptFrom, fromNode);
|
|
411
|
+
}
|
|
412
|
+
if (typeof acceptFrom !== 'object') return true;
|
|
413
|
+
|
|
414
|
+
const deny = acceptFrom.deny || acceptFrom.deny_list || acceptFrom.denylist;
|
|
415
|
+
if (deny && listHas(deny, fromNode)) return false;
|
|
416
|
+
const allow = acceptFrom.allow || acceptFrom.allow_list || acceptFrom.allowlist;
|
|
417
|
+
if (allow) return listHas(allow, fromNode);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function listHas(list, value) {
|
|
422
|
+
if (list instanceof Set) return list.has(value) || list.has('*');
|
|
423
|
+
if (!Array.isArray(list)) return false;
|
|
424
|
+
return list.includes(value) || list.includes('*');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function findFrameBoundary(buffer) {
|
|
428
|
+
const lf = buffer.indexOf('\n\n');
|
|
429
|
+
const crlf = buffer.indexOf('\r\n\r\n');
|
|
430
|
+
if (lf === -1 && crlf === -1) return null;
|
|
431
|
+
if (lf !== -1 && (crlf === -1 || lf < crlf)) return { index: lf, length: 2 };
|
|
432
|
+
return { index: crlf, length: 4 };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function collectResponse(res) {
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
const chunks = [];
|
|
438
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
439
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
440
|
+
res.on('error', reject);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function parseJsonBody(rawBody) {
|
|
445
|
+
if (!rawBody) return null;
|
|
446
|
+
try {
|
|
447
|
+
return JSON.parse(rawBody);
|
|
448
|
+
} catch {
|
|
449
|
+
return rawBody;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function normalizePin(pin) {
|
|
454
|
+
if (!pin) return null;
|
|
455
|
+
let value = String(pin).trim().toLowerCase();
|
|
456
|
+
if (value.startsWith('sha256:')) value = value.slice('sha256:'.length);
|
|
457
|
+
value = value.replace(/:/g, '');
|
|
458
|
+
if (!/^[0-9a-f]{64}$/.test(value)) {
|
|
459
|
+
throw new Error('Invalid broker TLS pin');
|
|
460
|
+
}
|
|
461
|
+
return value;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function certificateFingerprint(cert) {
|
|
465
|
+
if (!cert) return false;
|
|
466
|
+
return cert.fingerprint256
|
|
467
|
+
? cert.fingerprint256.toLowerCase().replace(/:/g, '')
|
|
468
|
+
: cert.raw && crypto.createHash('sha256').update(cert.raw).digest('hex');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function httpError(message, statusCode, body) {
|
|
472
|
+
const error = new Error(`${message}: ${statusCode}`);
|
|
473
|
+
error.statusCode = statusCode;
|
|
474
|
+
error.body = body;
|
|
475
|
+
return error;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function numberOr(value, fallback) {
|
|
479
|
+
return Number.isFinite(value) ? value : fallback;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requireString(value, name) {
|
|
483
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
484
|
+
throw new Error(`Missing ${name}`);
|
|
485
|
+
}
|
|
486
|
+
return value;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function requireFunction(value, name) {
|
|
490
|
+
if (typeof value !== 'function') {
|
|
491
|
+
throw new Error(`Missing ${name}`);
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
module.exports = {
|
|
497
|
+
createBrokerClient,
|
|
498
|
+
};
|