ocuclaw 1.3.0 → 1.3.2
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/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-surfaces.js +8 -1
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +98 -60
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- package/dist/runtime/plugin-update-service.js +0 -216
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { createRuntimeConfig } from "../config/runtime-config.js";
|
|
2
2
|
import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
|
|
3
|
+
import {
|
|
4
|
+
composeContainerLoopbackWarning,
|
|
5
|
+
isContainerEnvironment,
|
|
6
|
+
isLoopbackBindAddress,
|
|
7
|
+
} from "./container-env.js";
|
|
3
8
|
import { createRelay as createPluginOwnedRelay } from "./relay-core.js";
|
|
4
9
|
|
|
5
10
|
function normalizeLogger(logger) {
|
|
@@ -158,6 +163,13 @@ export function createOcuClawRelayService(opts = {}) {
|
|
|
158
163
|
logger.info(
|
|
159
164
|
`[ocuclaw] relay service started on ws://${config.wsBind}:${config.wsPort}`,
|
|
160
165
|
);
|
|
166
|
+
const containerEnvProbe =
|
|
167
|
+
typeof opts.isContainerEnvironment === "function"
|
|
168
|
+
? opts.isContainerEnvironment
|
|
169
|
+
: isContainerEnvironment;
|
|
170
|
+
if (isLoopbackBindAddress(config.wsBind) && containerEnvProbe()) {
|
|
171
|
+
logger.warn(composeContainerLoopbackWarning(config.wsBind, config.wsPort));
|
|
172
|
+
}
|
|
161
173
|
return nextRelay;
|
|
162
174
|
} catch (err) {
|
|
163
175
|
clearSharedRelay(nextRelay);
|
|
@@ -291,34 +303,128 @@ export function createOcuClawRelayService(opts = {}) {
|
|
|
291
303
|
trackedThrowawayKeys: [],
|
|
292
304
|
};
|
|
293
305
|
},
|
|
306
|
+
// These reads back the session-title tool, the distiller gates, and the
|
|
307
|
+
// Channel-2 before_prompt_build hook — all of which can run in a sibling
|
|
308
|
+
// plugin-register context whose own `relay` is null. Resolve the live
|
|
309
|
+
// (possibly shared) relay so they reach the running instance, mirroring
|
|
310
|
+
// hasConnectedAppClient(); otherwise the distiller skips every run and the
|
|
311
|
+
// Channel-2 stop-notices never fire.
|
|
294
312
|
getSessionTitle(sessionKey) {
|
|
295
|
-
|
|
296
|
-
|
|
313
|
+
const liveRelay = resolveLiveRelay();
|
|
314
|
+
if (liveRelay && typeof liveRelay.getSessionTitle === "function") {
|
|
315
|
+
return liveRelay.getSessionTitle(sessionKey);
|
|
297
316
|
}
|
|
298
317
|
return null;
|
|
299
318
|
},
|
|
300
319
|
hasRecordedUserMessage(sessionKey) {
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
const liveRelay = resolveLiveRelay();
|
|
321
|
+
if (liveRelay && typeof liveRelay.hasRecordedUserMessage === "function") {
|
|
322
|
+
return liveRelay.hasRecordedUserMessage(sessionKey);
|
|
303
323
|
}
|
|
304
|
-
// Fail-closed when
|
|
324
|
+
// Fail-closed when no relay is running: block titling.
|
|
305
325
|
return false;
|
|
306
326
|
},
|
|
307
327
|
isNeuralSessionNamesEnabled(sessionKey) {
|
|
308
|
-
|
|
309
|
-
|
|
328
|
+
const liveRelay = resolveLiveRelay();
|
|
329
|
+
if (liveRelay && typeof liveRelay.isNeuralSessionNamesEnabled === "function") {
|
|
330
|
+
return liveRelay.isNeuralSessionNamesEnabled(sessionKey);
|
|
310
331
|
}
|
|
311
332
|
return true;
|
|
312
333
|
},
|
|
313
334
|
isSessionUserLocked(sessionKey) {
|
|
314
|
-
|
|
315
|
-
|
|
335
|
+
const liveRelay = resolveLiveRelay();
|
|
336
|
+
if (liveRelay && typeof liveRelay.isSessionUserLocked === "function") {
|
|
337
|
+
return liveRelay.isSessionUserLocked(sessionKey);
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
},
|
|
341
|
+
getDisplayStartStates(sessionKey) {
|
|
342
|
+
const liveRelay = resolveLiveRelay();
|
|
343
|
+
if (liveRelay && typeof liveRelay.getDisplayStartStates === "function") {
|
|
344
|
+
return liveRelay.getDisplayStartStates(sessionKey);
|
|
345
|
+
}
|
|
346
|
+
return { emoji: false, pace: false };
|
|
347
|
+
},
|
|
348
|
+
getDisplayCurrentStates(sessionKey) {
|
|
349
|
+
const liveRelay = resolveLiveRelay();
|
|
350
|
+
if (liveRelay && typeof liveRelay.getDisplayCurrentStates === "function") {
|
|
351
|
+
return liveRelay.getDisplayCurrentStates(sessionKey);
|
|
352
|
+
}
|
|
353
|
+
return { emoji: false, pace: false };
|
|
354
|
+
},
|
|
355
|
+
// Distiller-sidecar passthroughs. Use the live relay (possibly a sibling
|
|
356
|
+
// register-context's shared relay) since the distiller may fire from a
|
|
357
|
+
// context whose own `relay` is null.
|
|
358
|
+
getSessionTitleRecord(sessionKey) {
|
|
359
|
+
const liveRelay = resolveLiveRelay();
|
|
360
|
+
if (liveRelay && typeof liveRelay.getSessionTitleRecord === "function") {
|
|
361
|
+
return liveRelay.getSessionTitleRecord(sessionKey);
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
},
|
|
365
|
+
isEvenAiSessionKey(sessionKey) {
|
|
366
|
+
const liveRelay = resolveLiveRelay();
|
|
367
|
+
if (liveRelay && typeof liveRelay.isEvenAiSessionKey === "function") {
|
|
368
|
+
return liveRelay.isEvenAiSessionKey(sessionKey);
|
|
316
369
|
}
|
|
317
370
|
return false;
|
|
318
371
|
},
|
|
372
|
+
getRawMessages() {
|
|
373
|
+
const liveRelay = resolveLiveRelay();
|
|
374
|
+
if (liveRelay && typeof liveRelay.getRawMessages === "function") {
|
|
375
|
+
return liveRelay.getRawMessages();
|
|
376
|
+
}
|
|
377
|
+
return [];
|
|
378
|
+
},
|
|
379
|
+
getDistillerBudget() {
|
|
380
|
+
const liveRelay = resolveLiveRelay();
|
|
381
|
+
if (liveRelay && typeof liveRelay.getDistillerBudget === "function") {
|
|
382
|
+
return liveRelay.getDistillerBudget();
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
},
|
|
386
|
+
deleteDistillerSession(sessionKey) {
|
|
387
|
+
const liveRelay = resolveLiveRelay();
|
|
388
|
+
if (liveRelay && typeof liveRelay.deleteDistillerSession === "function") {
|
|
389
|
+
return liveRelay.deleteDistillerSession(sessionKey);
|
|
390
|
+
}
|
|
391
|
+
return Promise.resolve(null);
|
|
392
|
+
},
|
|
393
|
+
getStateDir() {
|
|
394
|
+
const liveRelay = resolveLiveRelay();
|
|
395
|
+
if (liveRelay && typeof liveRelay.getStateDir === "function") {
|
|
396
|
+
return liveRelay.getStateDir();
|
|
397
|
+
}
|
|
398
|
+
return opts.stateDir;
|
|
399
|
+
},
|
|
400
|
+
emitDebug(...args) {
|
|
401
|
+
const liveRelay = resolveLiveRelay();
|
|
402
|
+
if (liveRelay && typeof liveRelay.emitDebug === "function") {
|
|
403
|
+
return liveRelay.emitDebug(...args);
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
},
|
|
407
|
+
gatewayRequest(method, params, requestOpts) {
|
|
408
|
+
const liveRelay = resolveLiveRelay();
|
|
409
|
+
if (liveRelay && typeof liveRelay.gatewayRequest === "function") {
|
|
410
|
+
return liveRelay.gatewayRequest(method, params, requestOpts);
|
|
411
|
+
}
|
|
412
|
+
return Promise.reject(new Error("relay_not_running"));
|
|
413
|
+
},
|
|
414
|
+
onGatewayEvent(eventName, listener) {
|
|
415
|
+
const liveRelay = resolveLiveRelay();
|
|
416
|
+
if (liveRelay && typeof liveRelay.onGatewayEvent === "function") {
|
|
417
|
+
return liveRelay.onGatewayEvent(eventName, listener);
|
|
418
|
+
}
|
|
419
|
+
return () => {};
|
|
420
|
+
},
|
|
319
421
|
peekSessionKey() {
|
|
320
|
-
|
|
321
|
-
|
|
422
|
+
// The set_session_title tool reads this before writing; like the adjacent
|
|
423
|
+
// session-title accessors it must reach the live (possibly shared) relay,
|
|
424
|
+
// or an explicit rename from a sibling tool context fails no_active_session.
|
|
425
|
+
const liveRelay = resolveLiveRelay();
|
|
426
|
+
if (liveRelay && typeof liveRelay.peekSessionKey === "function") {
|
|
427
|
+
return liveRelay.peekSessionKey();
|
|
322
428
|
}
|
|
323
429
|
return null;
|
|
324
430
|
},
|
|
@@ -328,8 +434,9 @@ export function createOcuClawRelayService(opts = {}) {
|
|
|
328
434
|
}
|
|
329
435
|
},
|
|
330
436
|
setSessionTitle(sessionKey, title, opts) {
|
|
331
|
-
|
|
332
|
-
|
|
437
|
+
const liveRelay = resolveLiveRelay();
|
|
438
|
+
if (liveRelay && typeof liveRelay.setSessionTitle === "function") {
|
|
439
|
+
return liveRelay.setSessionTitle(sessionKey, title, opts);
|
|
333
440
|
}
|
|
334
441
|
return { ok: false, code: "relay_not_running" };
|
|
335
442
|
},
|
|
@@ -5,10 +5,36 @@ if (!parentPort) {
|
|
|
5
5
|
throw new Error("relay worker entry requires parentPort");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function formatLogArgs(args) {
|
|
9
|
+
return args
|
|
10
|
+
.map((arg) => {
|
|
11
|
+
if (typeof arg === "string") return arg;
|
|
12
|
+
if (arg instanceof Error) return arg.message;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.stringify(arg);
|
|
15
|
+
} catch {
|
|
16
|
+
return String(arg);
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
.join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function postWorkerLog(level, args) {
|
|
23
|
+
parentPort.postMessage({ kind: "worker.log", level, message: formatLogArgs(args) });
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
const transport = createRelayWorkerTransport({
|
|
9
27
|
postToMain(message) {
|
|
10
28
|
parentPort.postMessage(message);
|
|
11
29
|
},
|
|
30
|
+
// Worker-thread console output never reaches the gateway's log file;
|
|
31
|
+
// forward log lines to the supervisor, which owns the real plugin logger.
|
|
32
|
+
logger: {
|
|
33
|
+
info(...args) { postWorkerLog("info", args); },
|
|
34
|
+
warn(...args) { postWorkerLog("warn", args); },
|
|
35
|
+
error(...args) { postWorkerLog("error", args); },
|
|
36
|
+
debug(...args) { postWorkerLog("debug", args); },
|
|
37
|
+
},
|
|
12
38
|
});
|
|
13
39
|
|
|
14
40
|
parentPort.on("message", async (message) => {
|
|
@@ -16,10 +16,6 @@ export const APP_PROTOCOL = Object.freeze({
|
|
|
16
16
|
readinessProbeAck: "ocuclaw.readiness.probe.ack",
|
|
17
17
|
automationStateGet: "ocuclaw.automation.state.get",
|
|
18
18
|
automationStateSnapshot: "ocuclaw.automation.state.snapshot",
|
|
19
|
-
restartGateway: "ocuclaw.restartGateway",
|
|
20
|
-
restartGatewayAck: "ocuclaw.restartGatewayAck",
|
|
21
|
-
updatePlugin: "ocuclaw.updatePlugin",
|
|
22
|
-
updatePluginResult: "ocuclaw.updatePluginResult",
|
|
23
19
|
approvalRequest: "ocuclaw.approval.request",
|
|
24
20
|
approvalResolved: "ocuclaw.approval.resolved",
|
|
25
21
|
sessionContextSnapshot: "ocuclaw.session.context.snapshot",
|
|
@@ -92,33 +92,6 @@ function parseRequestIdFromRaw(raw) {
|
|
|
92
92
|
return normalizeRequestId((parseFrame(raw) || {}).requestId);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function formatUpdatePluginResult(requestId, result) {
|
|
96
|
-
const payload = { type: APP_PROTOCOL.updatePluginResult };
|
|
97
|
-
if (requestId) payload.requestId = requestId;
|
|
98
|
-
if (result && result.ok === true) {
|
|
99
|
-
payload.ok = true;
|
|
100
|
-
} else {
|
|
101
|
-
payload.ok = false;
|
|
102
|
-
if (result && typeof result.reason === "string") payload.reason = result.reason;
|
|
103
|
-
if (result && typeof result.exitCode === "number") payload.exitCode = result.exitCode;
|
|
104
|
-
if (result && typeof result.stderrTail === "string" && result.stderrTail.length > 0) {
|
|
105
|
-
payload.stderrTail = result.stderrTail;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return JSON.stringify(payload);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function formatRestartGatewayAck(requestId, result) {
|
|
112
|
-
const payload = {
|
|
113
|
-
type: APP_PROTOCOL.restartGatewayAck,
|
|
114
|
-
ok: !!(result && result.ok),
|
|
115
|
-
started: !!(result && result.started),
|
|
116
|
-
};
|
|
117
|
-
if (requestId) payload.requestId = requestId;
|
|
118
|
-
if (result && typeof result.reason === "string") payload.reason = result.reason;
|
|
119
|
-
return JSON.stringify(payload);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
95
|
export function createRelayWorkerSupervisor(options = {}) {
|
|
123
96
|
const logger = normalizeLogger(options.logger);
|
|
124
97
|
const handler = options.handler || options.downstreamHandler || null;
|
|
@@ -546,57 +519,17 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
546
519
|
logger.warn(`[relay-worker] ${message.message || "worker error"}`);
|
|
547
520
|
return;
|
|
548
521
|
}
|
|
522
|
+
if (message.kind === "worker.log") {
|
|
523
|
+
// Worker-thread console output never reaches the gateway's structured
|
|
524
|
+
// log file, so the worker forwards its log lines here instead.
|
|
525
|
+
const level =
|
|
526
|
+
message.level === "warn" || message.level === "error" || message.level === "debug"
|
|
527
|
+
? message.level
|
|
528
|
+
: "info";
|
|
529
|
+
logger[level](typeof message.message === "string" ? message.message : String(message.message));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
549
532
|
if (message.kind === "app.message") {
|
|
550
|
-
if (message.operation === APP_PROTOCOL.updatePlugin) {
|
|
551
|
-
if (typeof options.runPluginUpdate !== "function") {
|
|
552
|
-
logger.warn("[relay-worker] updatePlugin requested but no runPluginUpdate is configured");
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
const requestId = parseRequestIdFromRaw(message.raw);
|
|
556
|
-
(async () => {
|
|
557
|
-
try {
|
|
558
|
-
const result = await Promise.resolve(options.runPluginUpdate());
|
|
559
|
-
postMainFrame("unicast", formatUpdatePluginResult(requestId, result), message.clientId);
|
|
560
|
-
} catch (err) {
|
|
561
|
-
logger.error(
|
|
562
|
-
`[relay-worker] updatePlugin threw: ${err && err.message ? err.message : err}`,
|
|
563
|
-
);
|
|
564
|
-
postMainFrame(
|
|
565
|
-
"unicast",
|
|
566
|
-
formatUpdatePluginResult(requestId, { ok: false, reason: "spawn_failed" }),
|
|
567
|
-
message.clientId,
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
})();
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
if (message.operation === APP_PROTOCOL.restartGateway) {
|
|
574
|
-
if (typeof options.runGatewayRestart !== "function") {
|
|
575
|
-
logger.warn("[relay-worker] restartGateway requested but no runGatewayRestart is configured");
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
const requestId = parseRequestIdFromRaw(message.raw);
|
|
579
|
-
(async () => {
|
|
580
|
-
try {
|
|
581
|
-
const result = await Promise.resolve(options.runGatewayRestart());
|
|
582
|
-
postMainFrame("unicast", formatRestartGatewayAck(requestId, result), message.clientId);
|
|
583
|
-
} catch (err) {
|
|
584
|
-
logger.error(
|
|
585
|
-
`[relay-worker] restartGateway threw: ${err && err.message ? err.message : err}`,
|
|
586
|
-
);
|
|
587
|
-
postMainFrame(
|
|
588
|
-
"unicast",
|
|
589
|
-
formatRestartGatewayAck(requestId, {
|
|
590
|
-
ok: false,
|
|
591
|
-
started: false,
|
|
592
|
-
reason: "spawn_failed",
|
|
593
|
-
}),
|
|
594
|
-
message.clientId,
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
})();
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
533
|
if (!handler || typeof handler.handleMessage !== "function") return;
|
|
601
534
|
const processOptions = {};
|
|
602
535
|
if (message.operation === "message.send" && message.requestId) {
|
|
@@ -658,7 +591,7 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
658
591
|
clientName: message.clientName || null,
|
|
659
592
|
clientVersion: message.clientVersion || null,
|
|
660
593
|
sessionKey: message.sessionKey || null,
|
|
661
|
-
readinessSnapshot: message.readinessSnapshot
|
|
594
|
+
readinessSnapshot: normalizeIngestedReadinessSnapshot(message.readinessSnapshot),
|
|
662
595
|
connectedAtMs: Number.isFinite(message.connectedAtMs)
|
|
663
596
|
? message.connectedAtMs
|
|
664
597
|
: Date.now(),
|
|
@@ -758,7 +691,7 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
758
691
|
if (message.kind === "client.readinessSnapshot") {
|
|
759
692
|
const entry = clients.get(message.clientId);
|
|
760
693
|
if (entry) {
|
|
761
|
-
entry.readinessSnapshot = message.readinessSnapshot
|
|
694
|
+
entry.readinessSnapshot = normalizeIngestedReadinessSnapshot(message.readinessSnapshot);
|
|
762
695
|
entry.updatedAtMs = Number.isFinite(message.updatedAtMs)
|
|
763
696
|
? message.updatedAtMs
|
|
764
697
|
: Date.now();
|
|
@@ -772,6 +705,21 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
772
705
|
const pending = requestId ? pendingReadinessProbeRequests.get(requestId) : null;
|
|
773
706
|
if (!pending || pending.targetClientId !== message.clientId) return;
|
|
774
707
|
pendingReadinessProbeRequests.delete(requestId);
|
|
708
|
+
// Probe acks carry the app's CURRENT activeSessionKey — ground truth.
|
|
709
|
+
// Refresh the registry entry with it: the app's standalone snapshot
|
|
710
|
+
// republication can race a reconnecting socket and get lost, leaving the
|
|
711
|
+
// hello-frozen key here until the next app boot (quirk 8's second layer).
|
|
712
|
+
if (ack && ack.ok !== false && typeof ack.activeSessionKey === "string" && ack.activeSessionKey) {
|
|
713
|
+
const ackEntry = clients.get(message.clientId);
|
|
714
|
+
if (ackEntry && ackEntry.readinessSnapshot) {
|
|
715
|
+
ackEntry.readinessSnapshot = {
|
|
716
|
+
...ackEntry.readinessSnapshot,
|
|
717
|
+
activeSessionKey: ack.activeSessionKey,
|
|
718
|
+
emittedAtMs: Number.isFinite(ack.emittedAtMs) ? ack.emittedAtMs : Date.now(),
|
|
719
|
+
};
|
|
720
|
+
ackEntry.updatedAtMs = Date.now();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
775
723
|
const protocol = clients.get(message.clientId) || {};
|
|
776
724
|
const frame =
|
|
777
725
|
handler && typeof handler.formatReadinessProbeAck === "function"
|
|
@@ -988,6 +936,22 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
988
936
|
});
|
|
989
937
|
}
|
|
990
938
|
|
|
939
|
+
// The WebUI's reconnect hello embeds its readiness snapshot WITHOUT
|
|
940
|
+
// emittedAtMs (toProtocolHelloReadinessSnapshotJson omits it), but the
|
|
941
|
+
// automation-state gate requires a finite emittedAtMs to count the client
|
|
942
|
+
// as published. Stamp ingest time so a reconnect hello counts as a
|
|
943
|
+
// publication — otherwise every relay restart leaves `inspect state`
|
|
944
|
+
// returning snapshot_unavailable until the sim/app client is cycled.
|
|
945
|
+
function normalizeIngestedReadinessSnapshot(snapshot) {
|
|
946
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
if (Number.isFinite(snapshot.emittedAtMs)) {
|
|
950
|
+
return snapshot;
|
|
951
|
+
}
|
|
952
|
+
return { ...snapshot, emittedAtMs: Date.now() };
|
|
953
|
+
}
|
|
954
|
+
|
|
991
955
|
function getReadinessSnapshot() {
|
|
992
956
|
const appClients = getConnectedAppEntries();
|
|
993
957
|
const updatedAtMs = appClients.reduce((latest, entry) => {
|
|
@@ -56,6 +56,36 @@ export function createRelayWorkerTransport(options = {}) {
|
|
|
56
56
|
let httpServer = null;
|
|
57
57
|
let wss = null;
|
|
58
58
|
let nextClientId = 1;
|
|
59
|
+
// Invalid-token rejects are the one connection log an internet-facing
|
|
60
|
+
// misconfiguration could flood, so they are rate-limited per remote address.
|
|
61
|
+
const TOKEN_REJECT_LOG_WINDOW_MS = 60000;
|
|
62
|
+
const TOKEN_REJECT_LOG_MAX_ADDRESSES = 100;
|
|
63
|
+
const tokenRejectLogState = new Map();
|
|
64
|
+
|
|
65
|
+
function logTokenReject(remoteAddress) {
|
|
66
|
+
const at = now();
|
|
67
|
+
let state = tokenRejectLogState.get(remoteAddress);
|
|
68
|
+
if (!state) {
|
|
69
|
+
if (tokenRejectLogState.size >= TOKEN_REJECT_LOG_MAX_ADDRESSES) {
|
|
70
|
+
tokenRejectLogState.clear();
|
|
71
|
+
}
|
|
72
|
+
state = { lastLogAtMs: null, suppressedCount: 0 };
|
|
73
|
+
tokenRejectLogState.set(remoteAddress, state);
|
|
74
|
+
}
|
|
75
|
+
if (state.lastLogAtMs !== null && at - state.lastLogAtMs < TOKEN_REJECT_LOG_WINDOW_MS) {
|
|
76
|
+
state.suppressedCount += 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const suffix =
|
|
80
|
+
state.suppressedCount > 0
|
|
81
|
+
? ` (+${state.suppressedCount} more rejected from this address since last log)`
|
|
82
|
+
: "";
|
|
83
|
+
state.lastLogAtMs = at;
|
|
84
|
+
state.suppressedCount = 0;
|
|
85
|
+
logger.warn(
|
|
86
|
+
`[ocuclaw] relay rejected connection: invalid token remote=${remoteAddress}${suffix}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
59
89
|
let expireTimer = null;
|
|
60
90
|
let healthTimer = null;
|
|
61
91
|
let loopDelayMonitor = null;
|
|
@@ -894,11 +924,17 @@ export function createRelayWorkerTransport(options = {}) {
|
|
|
894
924
|
});
|
|
895
925
|
wss.on("connection", (ws, req) => {
|
|
896
926
|
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
927
|
+
const remoteAddress = (req.socket && req.socket.remoteAddress) || "unknown";
|
|
897
928
|
if (requestUrl.searchParams.get("token") !== manifest.relayToken) {
|
|
929
|
+
logTokenReject(remoteAddress);
|
|
898
930
|
ws.close(4001, "invalid_token");
|
|
899
931
|
return;
|
|
900
932
|
}
|
|
901
933
|
const clientId = `worker-client-${nextClientId++}`;
|
|
934
|
+
const connectedAtMs = now();
|
|
935
|
+
logger.info(
|
|
936
|
+
`[ocuclaw] relay client connected clientId=${clientId} remote=${remoteAddress}`,
|
|
937
|
+
);
|
|
902
938
|
clients.set(clientId, ws);
|
|
903
939
|
protocolState.set(clientId, {
|
|
904
940
|
protocolVersion: null,
|
|
@@ -925,6 +961,11 @@ export function createRelayWorkerTransport(options = {}) {
|
|
|
925
961
|
: Buffer.isBuffer(reason)
|
|
926
962
|
? reason.toString("utf8")
|
|
927
963
|
: String(reason);
|
|
964
|
+
logger.info(
|
|
965
|
+
`[ocuclaw] relay client disconnected clientId=${clientId} remote=${remoteAddress} code=${
|
|
966
|
+
Number.isFinite(code) ? code : "none"
|
|
967
|
+
} lifetimeMs=${Math.max(0, now() - connectedAtMs)}`,
|
|
968
|
+
);
|
|
928
969
|
postToMain({
|
|
929
970
|
kind: "client.disconnected",
|
|
930
971
|
clientId,
|