oomi-ai 0.2.7 → 0.2.12
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 +26 -0
- package/agent_instructions.md +6 -2
- package/bin/oomi-ai.js +1212 -41
- package/openclaw.extension.js +62 -2
- package/package.json +2 -2
- package/skills/oomi/agent_instructions.md +6 -2
package/bin/oomi-ai.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { spawn } from 'child_process';
|
|
5
|
+
import { spawn, spawnSync } from 'child_process';
|
|
6
6
|
import { createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign } from 'crypto';
|
|
7
7
|
import net from 'net';
|
|
8
8
|
import { lookup as dnsLookup } from 'dns/promises';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
9
10
|
import WebSocket from 'ws';
|
|
10
11
|
import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
|
|
11
12
|
|
|
@@ -26,6 +27,11 @@ const BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS = parsePositiveInteger(
|
|
|
26
27
|
process.env.OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS,
|
|
27
28
|
3000
|
|
28
29
|
);
|
|
30
|
+
const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
|
|
31
|
+
process.env.OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
|
|
32
|
+
30000
|
|
33
|
+
);
|
|
34
|
+
const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
|
|
29
35
|
const DEVICE_IDENTITY_PATH = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
|
|
30
36
|
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
31
37
|
|
|
@@ -151,8 +157,10 @@ Commands:
|
|
|
151
157
|
openclaw install
|
|
152
158
|
Install agent instructions and the Oomi skill into OpenClaw.
|
|
153
159
|
|
|
154
|
-
openclaw bridge
|
|
155
|
-
|
|
160
|
+
openclaw bridge [start|ensure|stop|restart|ps]
|
|
161
|
+
Manage local OpenClaw-to-Oomi bridge lifecycle (singleton).
|
|
162
|
+
openclaw bridge service [install|start|stop|restart|status|uninstall]
|
|
163
|
+
Manage macOS launchd bridge supervision.
|
|
156
164
|
|
|
157
165
|
openclaw pair
|
|
158
166
|
Pair this OpenClaw host with Oomi and start bridge (single command).
|
|
@@ -204,8 +212,17 @@ function readFile(filePath) {
|
|
|
204
212
|
return fs.readFileSync(filePath, 'utf-8');
|
|
205
213
|
}
|
|
206
214
|
|
|
207
|
-
function writeFile(filePath, content) {
|
|
208
|
-
fs.writeFileSync(filePath, content);
|
|
215
|
+
function writeFile(filePath, content, options = undefined) {
|
|
216
|
+
fs.writeFileSync(filePath, content, options);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function xmlEscape(value) {
|
|
220
|
+
return String(value)
|
|
221
|
+
.replaceAll('&', '&')
|
|
222
|
+
.replaceAll('<', '<')
|
|
223
|
+
.replaceAll('>', '>')
|
|
224
|
+
.replaceAll('"', '"')
|
|
225
|
+
.replaceAll("'", ''');
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
function resolveWorkspace() {
|
|
@@ -558,6 +575,18 @@ function resolveBridgeStatusPath() {
|
|
|
558
575
|
return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
|
|
559
576
|
}
|
|
560
577
|
|
|
578
|
+
function resolveBridgeLockPath() {
|
|
579
|
+
return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resolveBridgeLiveLogPath() {
|
|
583
|
+
return path.join(os.homedir(), '.openclaw', 'logs', 'oomi-bridge-live.log');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function resolveBridgeLaunchAgentPlistPath() {
|
|
587
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${BRIDGE_LAUNCHD_LABEL}.plist`);
|
|
588
|
+
}
|
|
589
|
+
|
|
561
590
|
function defaultDeviceId() {
|
|
562
591
|
const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
|
|
563
592
|
return `oomi-${host}-${randomUUID().slice(0, 8)}`;
|
|
@@ -608,6 +637,158 @@ function updateBridgeStatus(partial) {
|
|
|
608
637
|
return next;
|
|
609
638
|
}
|
|
610
639
|
|
|
640
|
+
function normalizeBridgeMetrics(value) {
|
|
641
|
+
if (!value || typeof value !== 'object') return {};
|
|
642
|
+
const next = {};
|
|
643
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
644
|
+
const parsed = Number(raw);
|
|
645
|
+
next[key] = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
|
|
646
|
+
}
|
|
647
|
+
return next;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function incrementBridgeMetric(metricKey, amount = 1) {
|
|
651
|
+
const normalizedKey = String(metricKey || '').trim();
|
|
652
|
+
if (!normalizedKey) return;
|
|
653
|
+
const delta = Number(amount);
|
|
654
|
+
if (!Number.isFinite(delta) || delta <= 0) return;
|
|
655
|
+
|
|
656
|
+
const current = readBridgeStatus();
|
|
657
|
+
const metrics = normalizeBridgeMetrics(current.metrics);
|
|
658
|
+
metrics[normalizedKey] = (metrics[normalizedKey] || 0) + Math.floor(delta);
|
|
659
|
+
updateBridgeStatus({ metrics });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function normalizePid(value) {
|
|
663
|
+
const pid = Number(value);
|
|
664
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
665
|
+
return pid;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function isPidAlive(pid) {
|
|
669
|
+
const normalized = normalizePid(pid);
|
|
670
|
+
if (!normalized) return false;
|
|
671
|
+
try {
|
|
672
|
+
process.kill(normalized, 0);
|
|
673
|
+
return true;
|
|
674
|
+
} catch {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function isBridgeWorkerCommand(command) {
|
|
680
|
+
const text = String(command || '').trim().toLowerCase();
|
|
681
|
+
if (!text.includes('openclaw bridge')) return false;
|
|
682
|
+
if (/\bopenclaw\s+bridge\s+(ps|stop|restart|ensure)\b/.test(text)) return false;
|
|
683
|
+
if (/\bopenclaw\s+bridge\s+start\b/.test(text)) return true;
|
|
684
|
+
if (/\bopenclaw\s+bridge(\s+--|$)/.test(text)) return true;
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function isBridgeProcess(pid) {
|
|
689
|
+
const normalized = normalizePid(pid);
|
|
690
|
+
if (!normalized) return false;
|
|
691
|
+
if (!isPidAlive(normalized)) return false;
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
const result = spawnSync('ps', ['-p', String(normalized), '-o', 'command='], {
|
|
695
|
+
encoding: 'utf8',
|
|
696
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
697
|
+
});
|
|
698
|
+
const command = String(result.stdout || '').trim();
|
|
699
|
+
if (!command) return true;
|
|
700
|
+
return isBridgeWorkerCommand(command);
|
|
701
|
+
} catch {
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function readBridgeLock() {
|
|
707
|
+
return readJsonSafe(resolveBridgeLockPath()) || {};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function clearStaleBridgeLock() {
|
|
711
|
+
const lockPath = resolveBridgeLockPath();
|
|
712
|
+
if (!fs.existsSync(lockPath)) return;
|
|
713
|
+
const lock = readBridgeLock();
|
|
714
|
+
const lockPid = normalizePid(lock.pid);
|
|
715
|
+
if (lockPid && isBridgeProcess(lockPid)) return;
|
|
716
|
+
try {
|
|
717
|
+
fs.unlinkSync(lockPath);
|
|
718
|
+
} catch {
|
|
719
|
+
// no-op
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function findRunningBridgeProcess() {
|
|
724
|
+
clearStaleBridgeLock();
|
|
725
|
+
|
|
726
|
+
const lock = readBridgeLock();
|
|
727
|
+
const lockPid = normalizePid(lock.pid);
|
|
728
|
+
if (lockPid && isBridgeProcess(lockPid)) {
|
|
729
|
+
return {
|
|
730
|
+
pid: lockPid,
|
|
731
|
+
source: 'lock',
|
|
732
|
+
deviceId: typeof lock.deviceId === 'string' ? lock.deviceId : '',
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const status = readBridgeStatus();
|
|
737
|
+
const statusPid = normalizePid(status.pid);
|
|
738
|
+
if (statusPid && isBridgeProcess(statusPid)) {
|
|
739
|
+
return {
|
|
740
|
+
pid: statusPid,
|
|
741
|
+
source: 'status',
|
|
742
|
+
deviceId: typeof status.deviceId === 'string' ? status.deviceId : '',
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function acquireBridgeLock(deviceId) {
|
|
750
|
+
const lockPath = resolveBridgeLockPath();
|
|
751
|
+
ensureDir(path.dirname(lockPath));
|
|
752
|
+
const payload = {
|
|
753
|
+
pid: process.pid,
|
|
754
|
+
deviceId,
|
|
755
|
+
acquiredAt: bridgeNowIso(),
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const writeLock = () => writeFile(lockPath, JSON.stringify(payload, null, 2) + '\n', { flag: 'wx' });
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
writeLock();
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const code = err && typeof err === 'object' ? err.code : '';
|
|
764
|
+
if (code !== 'EEXIST') {
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
clearStaleBridgeLock();
|
|
768
|
+
const existing = findRunningBridgeProcess();
|
|
769
|
+
if (existing && existing.pid !== process.pid) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Bridge already running (pid ${existing.pid})${existing.deviceId ? ` for device ${existing.deviceId}` : ''}.`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
writeLock();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const release = () => {
|
|
778
|
+
const current = readBridgeLock();
|
|
779
|
+
const currentPid = normalizePid(current.pid);
|
|
780
|
+
if (currentPid && currentPid !== process.pid) return;
|
|
781
|
+
try {
|
|
782
|
+
fs.unlinkSync(lockPath);
|
|
783
|
+
} catch {
|
|
784
|
+
// no-op
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
process.once('exit', release);
|
|
789
|
+
return release;
|
|
790
|
+
}
|
|
791
|
+
|
|
611
792
|
async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
|
|
612
793
|
const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
|
|
613
794
|
method: 'POST',
|
|
@@ -768,11 +949,20 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
768
949
|
return { frameText, waitForChallenge: false };
|
|
769
950
|
}
|
|
770
951
|
|
|
771
|
-
const
|
|
952
|
+
const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
953
|
+
const params = {};
|
|
772
954
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
955
|
+
params.minProtocol = Number.isInteger(rawParams.minProtocol) && rawParams.minProtocol >= 1
|
|
956
|
+
? rawParams.minProtocol
|
|
957
|
+
: 3;
|
|
958
|
+
params.maxProtocol = Number.isInteger(rawParams.maxProtocol) && rawParams.maxProtocol >= 1
|
|
959
|
+
? rawParams.maxProtocol
|
|
960
|
+
: 3;
|
|
961
|
+
|
|
962
|
+
const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
|
|
963
|
+
const client = {};
|
|
964
|
+
const incomingClientId = typeof clientInput.id === 'string' ? clientInput.id.trim().toLowerCase() : '';
|
|
965
|
+
const incomingClientMode = typeof clientInput.mode === 'string' ? clientInput.mode.trim().toLowerCase() : '';
|
|
776
966
|
const proxiedBrowserClient =
|
|
777
967
|
incomingClientMode === 'webchat' ||
|
|
778
968
|
incomingClientId === 'webchat-ui' ||
|
|
@@ -784,18 +974,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
784
974
|
// so Control UI/webchat Origin checks don't reject proxied sessions.
|
|
785
975
|
client.id = proxiedBrowserClient
|
|
786
976
|
? 'node-host'
|
|
787
|
-
: (typeof
|
|
788
|
-
client.version = typeof
|
|
789
|
-
client.platform =
|
|
977
|
+
: (typeof clientInput.id === 'string' && clientInput.id.trim() ? clientInput.id.trim() : 'node-host');
|
|
978
|
+
client.version = typeof clientInput.version === 'string' && clientInput.version.trim() ? clientInput.version.trim() : '0.1.0';
|
|
979
|
+
client.platform = proxiedBrowserClient
|
|
980
|
+
? process.platform
|
|
981
|
+
: (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
|
|
790
982
|
client.mode = proxiedBrowserClient
|
|
791
983
|
? 'backend'
|
|
792
|
-
: (typeof
|
|
984
|
+
: (typeof clientInput.mode === 'string' && clientInput.mode.trim() ? clientInput.mode.trim() : 'backend');
|
|
985
|
+
if (typeof clientInput.displayName === 'string' && clientInput.displayName.trim()) {
|
|
986
|
+
client.displayName = clientInput.displayName.trim();
|
|
987
|
+
}
|
|
988
|
+
if (typeof clientInput.deviceFamily === 'string' && clientInput.deviceFamily.trim()) {
|
|
989
|
+
client.deviceFamily = clientInput.deviceFamily.trim();
|
|
990
|
+
}
|
|
991
|
+
if (typeof clientInput.modelIdentifier === 'string' && clientInput.modelIdentifier.trim()) {
|
|
992
|
+
client.modelIdentifier = clientInput.modelIdentifier.trim();
|
|
993
|
+
}
|
|
994
|
+
if (typeof clientInput.instanceId === 'string' && clientInput.instanceId.trim()) {
|
|
995
|
+
client.instanceId = clientInput.instanceId.trim();
|
|
996
|
+
}
|
|
793
997
|
params.client = client;
|
|
794
998
|
|
|
795
|
-
params.role = typeof
|
|
999
|
+
params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
|
|
796
1000
|
|
|
797
|
-
const existingScopes = Array.isArray(
|
|
798
|
-
?
|
|
1001
|
+
const existingScopes = Array.isArray(rawParams.scopes)
|
|
1002
|
+
? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
|
|
799
1003
|
: [];
|
|
800
1004
|
const requiredScopes = ['operator.read', 'operator.write'];
|
|
801
1005
|
for (const scope of requiredScopes) {
|
|
@@ -805,14 +1009,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
805
1009
|
}
|
|
806
1010
|
params.scopes = existingScopes;
|
|
807
1011
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1012
|
+
params.caps = Array.isArray(rawParams.caps)
|
|
1013
|
+
? rawParams.caps.filter((value) => typeof value === 'string' && value.trim())
|
|
1014
|
+
: [];
|
|
1015
|
+
|
|
1016
|
+
params.commands = Array.isArray(rawParams.commands)
|
|
1017
|
+
? rawParams.commands.filter((value) => typeof value === 'string' && value.trim())
|
|
1018
|
+
: [];
|
|
1019
|
+
|
|
1020
|
+
if (rawParams.permissions && typeof rawParams.permissions === 'object') {
|
|
1021
|
+
const permissions = {};
|
|
1022
|
+
for (const [key, value] of Object.entries(rawParams.permissions)) {
|
|
1023
|
+
const normalizedKey = typeof key === 'string' ? key.trim() : '';
|
|
1024
|
+
if (!normalizedKey || typeof value !== 'boolean') continue;
|
|
1025
|
+
permissions[normalizedKey] = value;
|
|
1026
|
+
}
|
|
1027
|
+
if (Object.keys(permissions).length > 0) {
|
|
1028
|
+
params.permissions = permissions;
|
|
1029
|
+
}
|
|
813
1030
|
}
|
|
814
|
-
|
|
815
|
-
|
|
1031
|
+
|
|
1032
|
+
if (typeof rawParams.pathEnv === 'string') {
|
|
1033
|
+
params.pathEnv = rawParams.pathEnv;
|
|
816
1034
|
}
|
|
817
1035
|
|
|
818
1036
|
const auth = {};
|
|
@@ -825,16 +1043,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
825
1043
|
params.auth = auth;
|
|
826
1044
|
}
|
|
827
1045
|
|
|
1046
|
+
if (typeof rawParams.locale === 'string' && rawParams.locale.trim()) {
|
|
1047
|
+
params.locale = rawParams.locale;
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof rawParams.userAgent === 'string' && rawParams.userAgent.trim()) {
|
|
1050
|
+
params.userAgent = rawParams.userAgent;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
828
1053
|
if (deviceIdentity) {
|
|
829
1054
|
if (!connectNonce) {
|
|
830
|
-
return { frameText
|
|
1055
|
+
return { frameText, waitForChallenge: true };
|
|
831
1056
|
}
|
|
832
|
-
|
|
833
1057
|
const signedAtMs = Date.now();
|
|
834
1058
|
const tokenForSignature =
|
|
835
1059
|
typeof auth.token === 'string' && auth.token.trim()
|
|
836
1060
|
? auth.token.trim()
|
|
837
1061
|
: (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
|
|
1062
|
+
const nonceForSignature = connectNonce;
|
|
838
1063
|
|
|
839
1064
|
const payload = buildDeviceAuthPayloadV3({
|
|
840
1065
|
deviceId: deviceIdentity.deviceId,
|
|
@@ -844,7 +1069,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
844
1069
|
scopes: existingScopes,
|
|
845
1070
|
signedAtMs,
|
|
846
1071
|
token: tokenForSignature,
|
|
847
|
-
nonce:
|
|
1072
|
+
nonce: nonceForSignature,
|
|
848
1073
|
platform: client.platform,
|
|
849
1074
|
deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
|
|
850
1075
|
});
|
|
@@ -854,7 +1079,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
854
1079
|
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
|
|
855
1080
|
signature,
|
|
856
1081
|
signedAt: signedAtMs,
|
|
857
|
-
nonce:
|
|
1082
|
+
nonce: nonceForSignature,
|
|
858
1083
|
};
|
|
859
1084
|
}
|
|
860
1085
|
|
|
@@ -873,6 +1098,53 @@ function parseJsonPayload(raw) {
|
|
|
873
1098
|
}
|
|
874
1099
|
}
|
|
875
1100
|
|
|
1101
|
+
function extractCorrelationId(params) {
|
|
1102
|
+
if (!params || typeof params !== 'object') return '';
|
|
1103
|
+
if (typeof params.correlationId === 'string' && params.correlationId.trim()) {
|
|
1104
|
+
return params.correlationId.trim();
|
|
1105
|
+
}
|
|
1106
|
+
const metadata = params.metadata;
|
|
1107
|
+
if (metadata && typeof metadata === 'object' && typeof metadata.correlationId === 'string' && metadata.correlationId.trim()) {
|
|
1108
|
+
return metadata.correlationId.trim();
|
|
1109
|
+
}
|
|
1110
|
+
return '';
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function extractGatewayRequestMeta(frameText) {
|
|
1114
|
+
const payload = parseJsonPayload(frameText);
|
|
1115
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
1116
|
+
if (payload.type !== 'req') return null;
|
|
1117
|
+
const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
|
|
1118
|
+
const method = typeof payload.method === 'string' ? payload.method.trim() : '';
|
|
1119
|
+
if (!requestId || !method) return null;
|
|
1120
|
+
|
|
1121
|
+
const params = payload.params && typeof payload.params === 'object' ? payload.params : {};
|
|
1122
|
+
const correlationId = extractCorrelationId(params);
|
|
1123
|
+
return { requestId, method, correlationId };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function extractGatewayResponseMeta(frameText) {
|
|
1127
|
+
const payload = parseJsonPayload(frameText);
|
|
1128
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
1129
|
+
if (payload.type !== 'res') return null;
|
|
1130
|
+
const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
|
|
1131
|
+
if (!requestId) return null;
|
|
1132
|
+
return {
|
|
1133
|
+
requestId,
|
|
1134
|
+
ok: payload.ok === true,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function isGatewayRunStartedFrame(frameText) {
|
|
1139
|
+
const payload = parseJsonPayload(frameText);
|
|
1140
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
1141
|
+
if (payload.type !== 'event' || payload.event !== 'agent') return false;
|
|
1142
|
+
const body = payload.payload;
|
|
1143
|
+
if (!body || typeof body !== 'object') return false;
|
|
1144
|
+
if (body.stream !== 'lifecycle') return false;
|
|
1145
|
+
return body.data && typeof body.data === 'object' && body.data.phase === 'start';
|
|
1146
|
+
}
|
|
1147
|
+
|
|
876
1148
|
function bridgeNowIso() {
|
|
877
1149
|
return new Date().toISOString();
|
|
878
1150
|
}
|
|
@@ -1030,13 +1302,260 @@ function buildBridgeDetachArgs(rawFlags = {}) {
|
|
|
1030
1302
|
}
|
|
1031
1303
|
|
|
1032
1304
|
function startBridgeDetachedProcess(rawFlags = {}) {
|
|
1305
|
+
const existing = findRunningBridgeProcess();
|
|
1306
|
+
if (existing) {
|
|
1307
|
+
return {
|
|
1308
|
+
pid: existing.pid,
|
|
1309
|
+
alreadyRunning: true,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1033
1313
|
const args = buildBridgeDetachArgs(rawFlags);
|
|
1314
|
+
const logPath = resolveBridgeLiveLogPath();
|
|
1315
|
+
ensureDir(path.dirname(logPath));
|
|
1316
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] [bridge-supervisor] starting detached bridge\n`);
|
|
1317
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1034
1318
|
const child = spawn(process.execPath, args, {
|
|
1035
1319
|
detached: true,
|
|
1036
|
-
stdio: 'ignore',
|
|
1320
|
+
stdio: ['ignore', logFd, logFd],
|
|
1037
1321
|
});
|
|
1038
1322
|
child.unref();
|
|
1039
|
-
|
|
1323
|
+
try {
|
|
1324
|
+
fs.closeSync(logFd);
|
|
1325
|
+
} catch {
|
|
1326
|
+
// no-op
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
pid: child.pid,
|
|
1330
|
+
alreadyRunning: false,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function listBridgeProcessPids() {
|
|
1335
|
+
const pids = new Set();
|
|
1336
|
+
const addPid = (value) => {
|
|
1337
|
+
const pid = normalizePid(value);
|
|
1338
|
+
if (!pid || pid === process.pid) return;
|
|
1339
|
+
if (!isBridgeProcess(pid)) return;
|
|
1340
|
+
pids.add(pid);
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
const lock = readBridgeLock();
|
|
1344
|
+
addPid(lock.pid);
|
|
1345
|
+
|
|
1346
|
+
const status = readBridgeStatus();
|
|
1347
|
+
addPid(status.pid);
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
|
|
1351
|
+
encoding: 'utf8',
|
|
1352
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1353
|
+
});
|
|
1354
|
+
const output = String(result.stdout || '');
|
|
1355
|
+
for (const rawLine of output.split('\n')) {
|
|
1356
|
+
const line = rawLine.trim();
|
|
1357
|
+
if (!line) continue;
|
|
1358
|
+
const match = line.match(/^(\d+)\s+(.+)$/);
|
|
1359
|
+
if (!match) continue;
|
|
1360
|
+
const pid = Number(match[1]);
|
|
1361
|
+
const command = String(match[2] || '');
|
|
1362
|
+
if (!isBridgeWorkerCommand(command)) continue;
|
|
1363
|
+
addPid(pid);
|
|
1364
|
+
}
|
|
1365
|
+
} catch {
|
|
1366
|
+
// best-effort process scan
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return Array.from(pids).sort((a, b) => a - b);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function waitForBridgePidsToExit(pids, timeoutMs) {
|
|
1373
|
+
const deadline = Date.now() + timeoutMs;
|
|
1374
|
+
while (Date.now() < deadline) {
|
|
1375
|
+
const alive = pids.filter((pid) => isBridgeProcess(pid));
|
|
1376
|
+
if (alive.length === 0) return [];
|
|
1377
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1378
|
+
}
|
|
1379
|
+
return pids.filter((pid) => isBridgeProcess(pid));
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function stopBridgeProcesses() {
|
|
1383
|
+
const targets = listBridgeProcessPids();
|
|
1384
|
+
if (targets.length === 0) {
|
|
1385
|
+
clearStaleBridgeLock();
|
|
1386
|
+
updateBridgeStatus({
|
|
1387
|
+
status: 'stopped',
|
|
1388
|
+
stopSignal: 'none',
|
|
1389
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1390
|
+
pid: null,
|
|
1391
|
+
});
|
|
1392
|
+
return {
|
|
1393
|
+
stopped: [],
|
|
1394
|
+
forceKilled: [],
|
|
1395
|
+
found: [],
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
for (const pid of targets) {
|
|
1400
|
+
try {
|
|
1401
|
+
process.kill(pid, 'SIGTERM');
|
|
1402
|
+
} catch {
|
|
1403
|
+
// no-op
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
let remaining = await waitForBridgePidsToExit(targets, 2500);
|
|
1408
|
+
const forceKilled = [];
|
|
1409
|
+
if (remaining.length > 0) {
|
|
1410
|
+
for (const pid of remaining) {
|
|
1411
|
+
try {
|
|
1412
|
+
process.kill(pid, 'SIGKILL');
|
|
1413
|
+
forceKilled.push(pid);
|
|
1414
|
+
} catch {
|
|
1415
|
+
// no-op
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
remaining = await waitForBridgePidsToExit(remaining, 1000);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
clearStaleBridgeLock();
|
|
1422
|
+
|
|
1423
|
+
const stopped = targets.filter((pid) => !remaining.includes(pid));
|
|
1424
|
+
updateBridgeStatus({
|
|
1425
|
+
status: 'stopped',
|
|
1426
|
+
stopSignal: forceKilled.length > 0 ? 'SIGKILL' : 'SIGTERM',
|
|
1427
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1428
|
+
pid: null,
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
return {
|
|
1432
|
+
stopped,
|
|
1433
|
+
forceKilled,
|
|
1434
|
+
found: targets,
|
|
1435
|
+
stillAlive: remaining,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function assertMacOSLaunchdAvailable() {
|
|
1440
|
+
if (process.platform !== 'darwin') {
|
|
1441
|
+
throw new Error('Bridge service manager is only supported on macOS (launchd).');
|
|
1442
|
+
}
|
|
1443
|
+
if (typeof process.getuid !== 'function') {
|
|
1444
|
+
throw new Error('Cannot resolve current UID for launchd domain.');
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function launchctlDomain() {
|
|
1449
|
+
assertMacOSLaunchdAvailable();
|
|
1450
|
+
return `gui/${String(process.getuid())}`;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function launchctlServiceTarget() {
|
|
1454
|
+
return `${launchctlDomain()}/${BRIDGE_LAUNCHD_LABEL}`;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function runLaunchctl(args, { allowFailure = false } = {}) {
|
|
1458
|
+
const result = spawnSync('launchctl', args, {
|
|
1459
|
+
encoding: 'utf8',
|
|
1460
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1461
|
+
});
|
|
1462
|
+
const status = Number.isInteger(result.status) ? result.status : 1;
|
|
1463
|
+
const stdout = String(result.stdout || '').trim();
|
|
1464
|
+
const stderr = String(result.stderr || '').trim();
|
|
1465
|
+
if (status !== 0 && !allowFailure) {
|
|
1466
|
+
throw new Error(
|
|
1467
|
+
`launchctl ${args.join(' ')} failed (${status}): ${stderr || stdout || 'unknown launchctl error'}`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
return { status, stdout, stderr };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function buildBridgeLaunchAgentPlist() {
|
|
1474
|
+
const scriptPath = (() => {
|
|
1475
|
+
try {
|
|
1476
|
+
return fs.realpathSync(process.argv[1]);
|
|
1477
|
+
} catch {
|
|
1478
|
+
return process.argv[1];
|
|
1479
|
+
}
|
|
1480
|
+
})();
|
|
1481
|
+
const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start'];
|
|
1482
|
+
const bridgeLogPath = resolveBridgeLiveLogPath();
|
|
1483
|
+
const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
|
|
1484
|
+
|
|
1485
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1486
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1487
|
+
<plist version="1.0">
|
|
1488
|
+
<dict>
|
|
1489
|
+
<key>Label</key>
|
|
1490
|
+
<string>${xmlEscape(BRIDGE_LAUNCHD_LABEL)}</string>
|
|
1491
|
+
<key>ProgramArguments</key>
|
|
1492
|
+
<array>
|
|
1493
|
+
${argsXml}
|
|
1494
|
+
</array>
|
|
1495
|
+
<key>WorkingDirectory</key>
|
|
1496
|
+
<string>${xmlEscape(os.homedir())}</string>
|
|
1497
|
+
<key>RunAtLoad</key>
|
|
1498
|
+
<true/>
|
|
1499
|
+
<key>KeepAlive</key>
|
|
1500
|
+
<true/>
|
|
1501
|
+
<key>ThrottleInterval</key>
|
|
1502
|
+
<integer>5</integer>
|
|
1503
|
+
<key>EnvironmentVariables</key>
|
|
1504
|
+
<dict>
|
|
1505
|
+
<key>OOMI_SKIP_UPDATE_CHECK</key>
|
|
1506
|
+
<string>1</string>
|
|
1507
|
+
</dict>
|
|
1508
|
+
<key>StandardOutPath</key>
|
|
1509
|
+
<string>${xmlEscape(bridgeLogPath)}</string>
|
|
1510
|
+
<key>StandardErrorPath</key>
|
|
1511
|
+
<string>${xmlEscape(bridgeLogPath)}</string>
|
|
1512
|
+
</dict>
|
|
1513
|
+
</plist>
|
|
1514
|
+
`;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function readBridgeLaunchdStatus() {
|
|
1518
|
+
assertMacOSLaunchdAvailable();
|
|
1519
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1520
|
+
const target = launchctlServiceTarget();
|
|
1521
|
+
const printResult = runLaunchctl(['print', target], { allowFailure: true });
|
|
1522
|
+
const loaded = printResult.status === 0;
|
|
1523
|
+
const output = [printResult.stdout, printResult.stderr].filter(Boolean).join('\n');
|
|
1524
|
+
const pidMatch = output.match(/\bpid\s*=\s*(\d+)/);
|
|
1525
|
+
const lastExitMatch = output.match(/\blast exit code\s*=\s*(-?\d+)/i);
|
|
1526
|
+
|
|
1527
|
+
return {
|
|
1528
|
+
plistPath,
|
|
1529
|
+
target,
|
|
1530
|
+
installed: fs.existsSync(plistPath),
|
|
1531
|
+
loaded,
|
|
1532
|
+
pid: pidMatch ? Number(pidMatch[1]) : null,
|
|
1533
|
+
running: Boolean(pidMatch && Number(pidMatch[1]) > 0),
|
|
1534
|
+
lastExitCode: lastExitMatch ? Number(lastExitMatch[1]) : null,
|
|
1535
|
+
printOutput: output,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function startBridgeLaunchdService() {
|
|
1540
|
+
assertMacOSLaunchdAvailable();
|
|
1541
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1542
|
+
if (!fs.existsSync(plistPath)) {
|
|
1543
|
+
throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
|
|
1544
|
+
}
|
|
1545
|
+
const domain = launchctlDomain();
|
|
1546
|
+
const target = launchctlServiceTarget();
|
|
1547
|
+
runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
|
|
1548
|
+
runLaunchctl(['bootstrap', domain, plistPath]);
|
|
1549
|
+
runLaunchctl(['enable', target], { allowFailure: true });
|
|
1550
|
+
runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
async function stopBridgeLaunchdService() {
|
|
1554
|
+
assertMacOSLaunchdAvailable();
|
|
1555
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1556
|
+
const domain = launchctlDomain();
|
|
1557
|
+
runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
|
|
1558
|
+
return stopBridgeProcesses();
|
|
1040
1559
|
}
|
|
1041
1560
|
|
|
1042
1561
|
async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
@@ -1077,6 +1596,13 @@ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
|
1077
1596
|
}
|
|
1078
1597
|
|
|
1079
1598
|
async function startOpenclawBridge(flags) {
|
|
1599
|
+
const runningBridge = findRunningBridgeProcess();
|
|
1600
|
+
if (runningBridge && runningBridge.pid !== process.pid) {
|
|
1601
|
+
throw new Error(
|
|
1602
|
+
`Bridge already running (pid ${runningBridge.pid})${runningBridge.deviceId ? ` for device ${runningBridge.deviceId}` : ''}.`
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1080
1606
|
const bridgeState = readBridgeState();
|
|
1081
1607
|
const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
|
|
1082
1608
|
const brokerHttp = runtimeConfig.brokerHttp;
|
|
@@ -1084,6 +1610,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1084
1610
|
const deviceId = resolveDeviceId(flags, bridgeState);
|
|
1085
1611
|
const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
|
|
1086
1612
|
const explicitDeviceToken = String(flags['device-token'] || '').trim();
|
|
1613
|
+
const releaseBridgeLock = acquireBridgeLock(deviceId);
|
|
1087
1614
|
let deviceToken = explicitDeviceToken;
|
|
1088
1615
|
if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
|
|
1089
1616
|
deviceToken = String(bridgeState.deviceToken || '').trim();
|
|
@@ -1209,6 +1736,188 @@ async function startOpenclawBridge(flags) {
|
|
|
1209
1736
|
);
|
|
1210
1737
|
};
|
|
1211
1738
|
|
|
1739
|
+
const sendGatewayAck = (brokerSocket, {
|
|
1740
|
+
sessionId,
|
|
1741
|
+
requestId = '',
|
|
1742
|
+
method = '',
|
|
1743
|
+
correlationId = '',
|
|
1744
|
+
stage = 'unknown',
|
|
1745
|
+
}) => {
|
|
1746
|
+
if (!sessionId) return;
|
|
1747
|
+
if (requestId) {
|
|
1748
|
+
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
1749
|
+
if (sessionBridge && sessionBridge.pendingRequests instanceof Map) {
|
|
1750
|
+
const pending = sessionBridge.pendingRequests.get(requestId);
|
|
1751
|
+
if (pending) {
|
|
1752
|
+
pending.lastSuccessfulHop = stage;
|
|
1753
|
+
sessionBridge.pendingRequests.set(requestId, pending);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
sendBrokerPayload(brokerSocket, {
|
|
1758
|
+
action: 'gateway_ack',
|
|
1759
|
+
type: 'gateway.ack',
|
|
1760
|
+
sessionId,
|
|
1761
|
+
requestId,
|
|
1762
|
+
method,
|
|
1763
|
+
correlationId,
|
|
1764
|
+
stage,
|
|
1765
|
+
ts: bridgeNowIso(),
|
|
1766
|
+
});
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
const sendGatewayErrorResponse = (
|
|
1770
|
+
brokerSocket,
|
|
1771
|
+
{
|
|
1772
|
+
sessionId,
|
|
1773
|
+
requestMeta,
|
|
1774
|
+
code = 'gateway_error',
|
|
1775
|
+
message = 'Gateway request failed',
|
|
1776
|
+
lastSuccessfulHop = '',
|
|
1777
|
+
retryable = false,
|
|
1778
|
+
details = null,
|
|
1779
|
+
}
|
|
1780
|
+
) => {
|
|
1781
|
+
if (!sessionId || !requestMeta || !requestMeta.requestId) return;
|
|
1782
|
+
const errorPayload = {
|
|
1783
|
+
code,
|
|
1784
|
+
message,
|
|
1785
|
+
correlationId: requestMeta.correlationId || '',
|
|
1786
|
+
};
|
|
1787
|
+
if (lastSuccessfulHop) {
|
|
1788
|
+
errorPayload.lastSuccessfulHop = lastSuccessfulHop;
|
|
1789
|
+
}
|
|
1790
|
+
if (retryable === true) {
|
|
1791
|
+
errorPayload.retryable = true;
|
|
1792
|
+
}
|
|
1793
|
+
if (details && typeof details === 'object') {
|
|
1794
|
+
errorPayload.details = details;
|
|
1795
|
+
}
|
|
1796
|
+
const responseFrame = {
|
|
1797
|
+
type: 'res',
|
|
1798
|
+
id: requestMeta.requestId,
|
|
1799
|
+
ok: false,
|
|
1800
|
+
error: errorPayload,
|
|
1801
|
+
};
|
|
1802
|
+
sendBrokerPayload(brokerSocket, {
|
|
1803
|
+
action: 'gateway_frame',
|
|
1804
|
+
type: 'gateway.frame',
|
|
1805
|
+
sessionId,
|
|
1806
|
+
frame: JSON.stringify(responseFrame),
|
|
1807
|
+
});
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const classifyGatewayClose = (code, reasonText) => {
|
|
1811
|
+
const reasonLower = String(reasonText || '').toLowerCase();
|
|
1812
|
+
if (code === 1008 && reasonLower.includes('invalid connect params')) {
|
|
1813
|
+
return {
|
|
1814
|
+
errorCode: 'gateway_invalid_connect_params',
|
|
1815
|
+
retryable: false,
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
if (code === 1008) {
|
|
1819
|
+
return {
|
|
1820
|
+
errorCode: 'gateway_policy_violation',
|
|
1821
|
+
retryable: false,
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
if (code === 1003 || code === 1002) {
|
|
1825
|
+
return {
|
|
1826
|
+
errorCode: 'gateway_protocol_error',
|
|
1827
|
+
retryable: false,
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
if (code === 1006) {
|
|
1831
|
+
return {
|
|
1832
|
+
errorCode: 'gateway_abnormal_close',
|
|
1833
|
+
retryable: true,
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return {
|
|
1837
|
+
errorCode: 'gateway_closed',
|
|
1838
|
+
retryable: true,
|
|
1839
|
+
};
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
const clearPendingRequestTimeout = (sessionBridge, requestId) => {
|
|
1843
|
+
if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
|
|
1844
|
+
const existingTimer = sessionBridge.pendingRequestTimers.get(requestId);
|
|
1845
|
+
if (existingTimer) {
|
|
1846
|
+
clearTimeout(existingTimer);
|
|
1847
|
+
sessionBridge.pendingRequestTimers.delete(requestId);
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
const clearAllPendingRequestTimeouts = (sessionBridge) => {
|
|
1852
|
+
if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
|
|
1853
|
+
for (const timer of sessionBridge.pendingRequestTimers.values()) {
|
|
1854
|
+
clearTimeout(timer);
|
|
1855
|
+
}
|
|
1856
|
+
sessionBridge.pendingRequestTimers.clear();
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
const startPendingRequestTimeout = (brokerSocket, sessionId, sessionBridge, requestMeta) => {
|
|
1860
|
+
if (!sessionBridge || !requestMeta || !requestMeta.requestId) return;
|
|
1861
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
1862
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
1863
|
+
}
|
|
1864
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
1865
|
+
const timer = setTimeout(() => {
|
|
1866
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
1867
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
1868
|
+
: null;
|
|
1869
|
+
if (!pending) {
|
|
1870
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (requestMeta.method === 'connect') {
|
|
1875
|
+
incrementBridgeMetric('connect_timeout_count');
|
|
1876
|
+
} else if (requestMeta.method === 'chat.send') {
|
|
1877
|
+
incrementBridgeMetric('chat_send_timeout_count');
|
|
1878
|
+
} else {
|
|
1879
|
+
incrementBridgeMetric('gateway_request_timeout_count');
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const lastSuccessfulHop = typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
1883
|
+
? pending.lastSuccessfulHop
|
|
1884
|
+
: 'bridge.forwarded';
|
|
1885
|
+
sendGatewayAck(brokerSocket, {
|
|
1886
|
+
sessionId,
|
|
1887
|
+
requestId: pending.requestId,
|
|
1888
|
+
method: pending.method,
|
|
1889
|
+
correlationId: pending.correlationId,
|
|
1890
|
+
stage: 'gateway.timeout',
|
|
1891
|
+
});
|
|
1892
|
+
sendBrokerPayload(brokerSocket, {
|
|
1893
|
+
action: 'log',
|
|
1894
|
+
type: 'log',
|
|
1895
|
+
sessionId,
|
|
1896
|
+
level: 'warn',
|
|
1897
|
+
message: `Gateway request timeout (${pending.method} ${pending.requestId}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
|
|
1898
|
+
});
|
|
1899
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
1900
|
+
sessionId,
|
|
1901
|
+
requestMeta: pending,
|
|
1902
|
+
code: 'gateway_timeout',
|
|
1903
|
+
message: `Gateway request timeout (${pending.method}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
|
|
1904
|
+
lastSuccessfulHop,
|
|
1905
|
+
retryable: true,
|
|
1906
|
+
details: {
|
|
1907
|
+
method: pending.method,
|
|
1908
|
+
timeoutMs: BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
|
|
1909
|
+
},
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
1913
|
+
sessionBridge.pendingRequests.delete(pending.requestId);
|
|
1914
|
+
}
|
|
1915
|
+
clearPendingRequestTimeout(sessionBridge, pending.requestId);
|
|
1916
|
+
}, BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS);
|
|
1917
|
+
|
|
1918
|
+
sessionBridge.pendingRequestTimers.set(requestMeta.requestId, timer);
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1212
1921
|
const parseBrokerEnvelope = (raw) => {
|
|
1213
1922
|
const payload = parseJsonPayload(raw);
|
|
1214
1923
|
if (!payload) return null;
|
|
@@ -1236,6 +1945,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1236
1945
|
const scheduleReconnect = () => {
|
|
1237
1946
|
if (reconnectState.stopped || reconnectState.timer) return;
|
|
1238
1947
|
reconnectState.attempt += 1;
|
|
1948
|
+
incrementBridgeMetric('bridge_reconnect_scheduled_count');
|
|
1239
1949
|
const failure =
|
|
1240
1950
|
reconnectState.lastFailure ||
|
|
1241
1951
|
classifyBridgeFailure({ reason: 'connection closed without classified error' });
|
|
@@ -1332,20 +2042,102 @@ async function startOpenclawBridge(flags) {
|
|
|
1332
2042
|
|
|
1333
2043
|
clearChallengeTimer(sessionBridge);
|
|
1334
2044
|
for (const pendingFrame of pending) {
|
|
2045
|
+
const requestMeta = extractGatewayRequestMeta(pendingFrame);
|
|
1335
2046
|
const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
|
|
1336
2047
|
connectNonce: sessionBridge.connectNonce,
|
|
1337
2048
|
deviceIdentity: gatewayDeviceIdentity,
|
|
1338
2049
|
});
|
|
1339
2050
|
if (!prepared.frameText || prepared.waitForChallenge) {
|
|
2051
|
+
if (requestMeta) {
|
|
2052
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2053
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2054
|
+
: null;
|
|
2055
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2056
|
+
? pending.lastSuccessfulHop
|
|
2057
|
+
: 'bridge.waiting_for_challenge';
|
|
2058
|
+
sendGatewayAck(brokerSocket, {
|
|
2059
|
+
sessionId,
|
|
2060
|
+
requestId: requestMeta.requestId,
|
|
2061
|
+
method: requestMeta.method,
|
|
2062
|
+
correlationId: requestMeta.correlationId,
|
|
2063
|
+
stage: 'bridge.dropped',
|
|
2064
|
+
});
|
|
2065
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2066
|
+
sessionId,
|
|
2067
|
+
requestMeta,
|
|
2068
|
+
code: 'bridge_dropped',
|
|
2069
|
+
message: 'Bridge dropped connect request after challenge handling.',
|
|
2070
|
+
lastSuccessfulHop,
|
|
2071
|
+
retryable: true,
|
|
2072
|
+
});
|
|
2073
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2074
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2075
|
+
}
|
|
2076
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2077
|
+
}
|
|
1340
2078
|
continue;
|
|
1341
2079
|
}
|
|
1342
2080
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1343
2081
|
if (result === 'queued') {
|
|
1344
2082
|
console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
|
|
2083
|
+
if (requestMeta) {
|
|
2084
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2085
|
+
}
|
|
2086
|
+
if (requestMeta) {
|
|
2087
|
+
sendGatewayAck(brokerSocket, {
|
|
2088
|
+
sessionId,
|
|
2089
|
+
requestId: requestMeta.requestId,
|
|
2090
|
+
method: requestMeta.method,
|
|
2091
|
+
correlationId: requestMeta.correlationId,
|
|
2092
|
+
stage: 'bridge.queued',
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
1345
2095
|
} else if (result === 'dropped') {
|
|
1346
2096
|
console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
|
|
2097
|
+
incrementBridgeMetric('bridge_drop_count');
|
|
2098
|
+
if (requestMeta) {
|
|
2099
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2100
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2101
|
+
: null;
|
|
2102
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2103
|
+
? pending.lastSuccessfulHop
|
|
2104
|
+
: 'bridge.waiting_for_challenge';
|
|
2105
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2106
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2107
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2108
|
+
}
|
|
2109
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2110
|
+
sessionId,
|
|
2111
|
+
requestMeta,
|
|
2112
|
+
code: 'bridge_dropped',
|
|
2113
|
+
message: 'Bridge dropped request because gateway socket is not open.',
|
|
2114
|
+
lastSuccessfulHop,
|
|
2115
|
+
retryable: true,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
if (requestMeta) {
|
|
2119
|
+
sendGatewayAck(brokerSocket, {
|
|
2120
|
+
sessionId,
|
|
2121
|
+
requestId: requestMeta.requestId,
|
|
2122
|
+
method: requestMeta.method,
|
|
2123
|
+
correlationId: requestMeta.correlationId,
|
|
2124
|
+
stage: 'bridge.dropped',
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
1347
2127
|
} else {
|
|
1348
2128
|
console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
|
|
2129
|
+
if (requestMeta) {
|
|
2130
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2131
|
+
}
|
|
2132
|
+
if (requestMeta) {
|
|
2133
|
+
sendGatewayAck(brokerSocket, {
|
|
2134
|
+
sessionId,
|
|
2135
|
+
requestId: requestMeta.requestId,
|
|
2136
|
+
method: requestMeta.method,
|
|
2137
|
+
correlationId: requestMeta.correlationId,
|
|
2138
|
+
stage: 'bridge.forwarded',
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
1349
2141
|
}
|
|
1350
2142
|
}
|
|
1351
2143
|
};
|
|
@@ -1359,11 +2151,21 @@ async function startOpenclawBridge(flags) {
|
|
|
1359
2151
|
if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
|
|
1360
2152
|
sessionBridge.pendingConnectFrames = [];
|
|
1361
2153
|
}
|
|
2154
|
+
if (!(sessionBridge.pendingRequests instanceof Map)) {
|
|
2155
|
+
sessionBridge.pendingRequests = new Map();
|
|
2156
|
+
}
|
|
2157
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
2158
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
2159
|
+
}
|
|
2160
|
+
if (typeof sessionBridge.lastChatCorrelationId !== 'string') {
|
|
2161
|
+
sessionBridge.lastChatCorrelationId = '';
|
|
2162
|
+
}
|
|
1362
2163
|
let connectTimeout = setTimeout(() => {
|
|
1363
2164
|
if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
|
|
1364
2165
|
console.error(
|
|
1365
2166
|
`[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
|
|
1366
2167
|
);
|
|
2168
|
+
incrementBridgeMetric('gateway_connect_timeout_count');
|
|
1367
2169
|
sendBrokerPayload(brokerSocket, {
|
|
1368
2170
|
action: 'log',
|
|
1369
2171
|
type: 'log',
|
|
@@ -1391,6 +2193,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1391
2193
|
const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
|
|
1392
2194
|
const gatewayPayload = parseJsonPayload(frame);
|
|
1393
2195
|
if (gatewayPayload?.event === 'connect.challenge') {
|
|
2196
|
+
console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
|
|
1394
2197
|
const nonce =
|
|
1395
2198
|
gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
|
|
1396
2199
|
? gatewayPayload.payload.nonce.trim()
|
|
@@ -1414,6 +2217,35 @@ async function startOpenclawBridge(flags) {
|
|
|
1414
2217
|
flushPendingConnectFrames(sessionId, sessionBridge);
|
|
1415
2218
|
}
|
|
1416
2219
|
}
|
|
2220
|
+
|
|
2221
|
+
const responseMeta = extractGatewayResponseMeta(frame);
|
|
2222
|
+
if (responseMeta && sessionBridge.pendingRequests instanceof Map) {
|
|
2223
|
+
const requestMeta = sessionBridge.pendingRequests.get(responseMeta.requestId);
|
|
2224
|
+
if (requestMeta) {
|
|
2225
|
+
clearPendingRequestTimeout(sessionBridge, responseMeta.requestId);
|
|
2226
|
+
sendGatewayAck(brokerSocket, {
|
|
2227
|
+
sessionId,
|
|
2228
|
+
requestId: requestMeta.requestId,
|
|
2229
|
+
method: requestMeta.method,
|
|
2230
|
+
correlationId: requestMeta.correlationId,
|
|
2231
|
+
stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
|
|
2232
|
+
});
|
|
2233
|
+
if (!responseMeta.ok) {
|
|
2234
|
+
incrementBridgeMetric('gateway_rejected_count');
|
|
2235
|
+
}
|
|
2236
|
+
sessionBridge.pendingRequests.delete(responseMeta.requestId);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if (isGatewayRunStartedFrame(frame)) {
|
|
2241
|
+
sendGatewayAck(brokerSocket, {
|
|
2242
|
+
sessionId,
|
|
2243
|
+
method: 'chat.send',
|
|
2244
|
+
correlationId: sessionBridge.lastChatCorrelationId || '',
|
|
2245
|
+
stage: 'run.started',
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1417
2249
|
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
|
|
1418
2250
|
});
|
|
1419
2251
|
|
|
@@ -1424,9 +2256,41 @@ async function startOpenclawBridge(flags) {
|
|
|
1424
2256
|
}
|
|
1425
2257
|
clearChallengeTimer(sessionBridge);
|
|
1426
2258
|
const reasonText = reason ? reason.toString() : '';
|
|
2259
|
+
const closeMeta = classifyGatewayClose(code, reasonText);
|
|
1427
2260
|
console.log(
|
|
1428
2261
|
`[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
|
|
1429
2262
|
);
|
|
2263
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2264
|
+
for (const requestMeta of sessionBridge.pendingRequests.values()) {
|
|
2265
|
+
if (!requestMeta || typeof requestMeta !== 'object') continue;
|
|
2266
|
+
const lastSuccessfulHop = typeof requestMeta.lastSuccessfulHop === 'string' && requestMeta.lastSuccessfulHop
|
|
2267
|
+
? requestMeta.lastSuccessfulHop
|
|
2268
|
+
: 'bridge.forwarded';
|
|
2269
|
+
sendGatewayAck(brokerSocket, {
|
|
2270
|
+
sessionId,
|
|
2271
|
+
requestId: requestMeta.requestId || '',
|
|
2272
|
+
method: requestMeta.method || '',
|
|
2273
|
+
correlationId: requestMeta.correlationId || '',
|
|
2274
|
+
stage: 'gateway.closed',
|
|
2275
|
+
});
|
|
2276
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2277
|
+
sessionId,
|
|
2278
|
+
requestMeta,
|
|
2279
|
+
code: closeMeta.errorCode,
|
|
2280
|
+
message: reasonText
|
|
2281
|
+
? `Gateway closed (${String(code)}): ${reasonText}`
|
|
2282
|
+
: `Gateway closed (${String(code)})`,
|
|
2283
|
+
lastSuccessfulHop,
|
|
2284
|
+
retryable: closeMeta.retryable,
|
|
2285
|
+
details: {
|
|
2286
|
+
closeCode: code,
|
|
2287
|
+
closeReason: reasonText,
|
|
2288
|
+
},
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
sessionBridge.pendingRequests.clear();
|
|
2292
|
+
}
|
|
2293
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1430
2294
|
activeGatewaySockets.delete(sessionId);
|
|
1431
2295
|
sendBrokerPayload(brokerSocket, {
|
|
1432
2296
|
action: 'gateway_closed',
|
|
@@ -1583,6 +2447,22 @@ async function startOpenclawBridge(flags) {
|
|
|
1583
2447
|
console.log(`[bridge] client.frame ${sessionId}`);
|
|
1584
2448
|
const sessionBridge = getOrCreateGatewaySession(sessionId);
|
|
1585
2449
|
if (!sessionBridge) return;
|
|
2450
|
+
const requestMeta = extractGatewayRequestMeta(frame);
|
|
2451
|
+
if (requestMeta) {
|
|
2452
|
+
if (!(sessionBridge.pendingRequests instanceof Map)) {
|
|
2453
|
+
sessionBridge.pendingRequests = new Map();
|
|
2454
|
+
}
|
|
2455
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
2456
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
2457
|
+
}
|
|
2458
|
+
sessionBridge.pendingRequests.set(requestMeta.requestId, {
|
|
2459
|
+
...requestMeta,
|
|
2460
|
+
lastSuccessfulHop: 'broker.accepted',
|
|
2461
|
+
});
|
|
2462
|
+
if (requestMeta.method === 'chat.send' && requestMeta.correlationId) {
|
|
2463
|
+
sessionBridge.lastChatCorrelationId = requestMeta.correlationId;
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
1586
2466
|
const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
|
|
1587
2467
|
connectNonce: sessionBridge.connectNonce,
|
|
1588
2468
|
deviceIdentity: gatewayDeviceIdentity,
|
|
@@ -1590,16 +2470,103 @@ async function startOpenclawBridge(flags) {
|
|
|
1590
2470
|
if (prepared.waitForChallenge) {
|
|
1591
2471
|
queueConnectUntilChallenge(sessionId, sessionBridge, frame);
|
|
1592
2472
|
console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
|
|
2473
|
+
if (requestMeta) {
|
|
2474
|
+
sendGatewayAck(brokerSocket, {
|
|
2475
|
+
sessionId,
|
|
2476
|
+
requestId: requestMeta.requestId,
|
|
2477
|
+
method: requestMeta.method,
|
|
2478
|
+
correlationId: requestMeta.correlationId,
|
|
2479
|
+
stage: 'bridge.waiting_for_challenge',
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
1593
2482
|
return;
|
|
1594
2483
|
}
|
|
1595
2484
|
if (!prepared.frameText) {
|
|
2485
|
+
if (requestMeta) {
|
|
2486
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2487
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2488
|
+
: null;
|
|
2489
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2490
|
+
? pending.lastSuccessfulHop
|
|
2491
|
+
: 'broker.accepted';
|
|
2492
|
+
sendGatewayAck(brokerSocket, {
|
|
2493
|
+
sessionId,
|
|
2494
|
+
requestId: requestMeta.requestId,
|
|
2495
|
+
method: requestMeta.method,
|
|
2496
|
+
correlationId: requestMeta.correlationId,
|
|
2497
|
+
stage: 'bridge.dropped',
|
|
2498
|
+
});
|
|
2499
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2500
|
+
sessionId,
|
|
2501
|
+
requestMeta,
|
|
2502
|
+
code: 'bridge_dropped',
|
|
2503
|
+
message: 'Bridge dropped request before forwarding to gateway.',
|
|
2504
|
+
lastSuccessfulHop,
|
|
2505
|
+
retryable: true,
|
|
2506
|
+
});
|
|
2507
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2508
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2509
|
+
}
|
|
2510
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2511
|
+
}
|
|
1596
2512
|
return;
|
|
1597
2513
|
}
|
|
1598
2514
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1599
2515
|
if (result === 'queued') {
|
|
1600
2516
|
console.log(`[bridge] client.frame queued ${sessionId}`);
|
|
2517
|
+
if (requestMeta) {
|
|
2518
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2519
|
+
}
|
|
2520
|
+
if (requestMeta) {
|
|
2521
|
+
sendGatewayAck(brokerSocket, {
|
|
2522
|
+
sessionId,
|
|
2523
|
+
requestId: requestMeta.requestId,
|
|
2524
|
+
method: requestMeta.method,
|
|
2525
|
+
correlationId: requestMeta.correlationId,
|
|
2526
|
+
stage: 'bridge.queued',
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
1601
2529
|
} else if (result === 'dropped') {
|
|
1602
2530
|
console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
|
|
2531
|
+
incrementBridgeMetric('bridge_drop_count');
|
|
2532
|
+
if (requestMeta) {
|
|
2533
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2534
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2535
|
+
: null;
|
|
2536
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2537
|
+
? pending.lastSuccessfulHop
|
|
2538
|
+
: 'broker.accepted';
|
|
2539
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2540
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2541
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2542
|
+
}
|
|
2543
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2544
|
+
sessionId,
|
|
2545
|
+
requestMeta,
|
|
2546
|
+
code: 'bridge_dropped',
|
|
2547
|
+
message: 'Bridge dropped request because gateway socket is not open.',
|
|
2548
|
+
lastSuccessfulHop,
|
|
2549
|
+
retryable: true,
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
if (requestMeta) {
|
|
2553
|
+
sendGatewayAck(brokerSocket, {
|
|
2554
|
+
sessionId,
|
|
2555
|
+
requestId: requestMeta.requestId,
|
|
2556
|
+
method: requestMeta.method,
|
|
2557
|
+
correlationId: requestMeta.correlationId,
|
|
2558
|
+
stage: 'bridge.dropped',
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
} else if (requestMeta) {
|
|
2562
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2563
|
+
sendGatewayAck(brokerSocket, {
|
|
2564
|
+
sessionId,
|
|
2565
|
+
requestId: requestMeta.requestId,
|
|
2566
|
+
method: requestMeta.method,
|
|
2567
|
+
correlationId: requestMeta.correlationId,
|
|
2568
|
+
stage: 'bridge.forwarded',
|
|
2569
|
+
});
|
|
1603
2570
|
}
|
|
1604
2571
|
return;
|
|
1605
2572
|
}
|
|
@@ -1610,6 +2577,10 @@ async function startOpenclawBridge(flags) {
|
|
|
1610
2577
|
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
1611
2578
|
if (sessionBridge && sessionBridge.socket) {
|
|
1612
2579
|
clearChallengeTimer(sessionBridge);
|
|
2580
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2581
|
+
sessionBridge.pendingRequests.clear();
|
|
2582
|
+
}
|
|
2583
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1613
2584
|
activeGatewaySockets.delete(sessionId);
|
|
1614
2585
|
sessionBridge.socket.close(1000, 'client_closed');
|
|
1615
2586
|
}
|
|
@@ -1624,8 +2595,13 @@ async function startOpenclawBridge(flags) {
|
|
|
1624
2595
|
}
|
|
1625
2596
|
const reasonText = reason ? reason.toString() : '';
|
|
1626
2597
|
console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
|
|
2598
|
+
incrementBridgeMetric('bridge_disconnect_count');
|
|
1627
2599
|
for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
|
|
1628
2600
|
clearChallengeTimer(sessionBridge);
|
|
2601
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2602
|
+
sessionBridge.pendingRequests.clear();
|
|
2603
|
+
}
|
|
2604
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1629
2605
|
activeGatewaySockets.delete(sessionId);
|
|
1630
2606
|
try {
|
|
1631
2607
|
sessionBridge.socket.close(1001, 'broker_disconnected');
|
|
@@ -1647,6 +2623,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1647
2623
|
});
|
|
1648
2624
|
|
|
1649
2625
|
brokerSocket.on('error', (err) => {
|
|
2626
|
+
incrementBridgeMetric('bridge_socket_error_count');
|
|
1650
2627
|
reconnectState.lastFailure = classifyBridgeFailure({ err });
|
|
1651
2628
|
console.error(
|
|
1652
2629
|
`[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
|
|
@@ -1671,6 +2648,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1671
2648
|
stopSignal: signal,
|
|
1672
2649
|
pid: process.pid,
|
|
1673
2650
|
});
|
|
2651
|
+
releaseBridgeLock();
|
|
1674
2652
|
process.exit(0);
|
|
1675
2653
|
};
|
|
1676
2654
|
process.once('SIGINT', () => markStopped('SIGINT'));
|
|
@@ -1765,13 +2743,17 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
1765
2743
|
}
|
|
1766
2744
|
|
|
1767
2745
|
if (detach) {
|
|
1768
|
-
const
|
|
2746
|
+
const result = startBridgeDetachedProcess({
|
|
1769
2747
|
'broker-http': managedConfig.brokerHttpUrl,
|
|
1770
2748
|
'broker-ws': brokerWs,
|
|
1771
2749
|
'device-id': deviceId,
|
|
1772
2750
|
'device-token': deviceToken,
|
|
1773
2751
|
});
|
|
1774
|
-
|
|
2752
|
+
if (result.alreadyRunning) {
|
|
2753
|
+
console.log(`Bridge already running (pid: ${result.pid}).`);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
console.log(`Bridge started in background (pid: ${result.pid}).`);
|
|
1775
2757
|
return;
|
|
1776
2758
|
}
|
|
1777
2759
|
|
|
@@ -1895,6 +2877,16 @@ function printOpenclawBridgeStatus(flags) {
|
|
|
1895
2877
|
if (payload.runtime.hint) {
|
|
1896
2878
|
console.log(`Hint: ${payload.runtime.hint}`);
|
|
1897
2879
|
}
|
|
2880
|
+
if (payload.runtime.metrics && typeof payload.runtime.metrics === 'object') {
|
|
2881
|
+
const metrics = normalizeBridgeMetrics(payload.runtime.metrics);
|
|
2882
|
+
const metricPairs = Object.entries(metrics);
|
|
2883
|
+
if (metricPairs.length > 0) {
|
|
2884
|
+
console.log('Metrics:');
|
|
2885
|
+
for (const [name, value] of metricPairs) {
|
|
2886
|
+
console.log(` ${name}: ${value}`);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
1898
2890
|
return;
|
|
1899
2891
|
}
|
|
1900
2892
|
|
|
@@ -1969,6 +2961,169 @@ function printOpenclawPluginSetup(flags) {
|
|
|
1969
2961
|
}
|
|
1970
2962
|
}
|
|
1971
2963
|
|
|
2964
|
+
async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
|
|
2965
|
+
assertMacOSLaunchdAvailable();
|
|
2966
|
+
const action = String(actionRaw || 'status').trim().toLowerCase();
|
|
2967
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
2968
|
+
|
|
2969
|
+
if (action === 'install') {
|
|
2970
|
+
ensureDir(path.dirname(plistPath));
|
|
2971
|
+
writeFile(plistPath, buildBridgeLaunchAgentPlist());
|
|
2972
|
+
console.log(`Installed bridge launchd plist: ${plistPath}`);
|
|
2973
|
+
if (isTruthyFlag(flags['no-start'])) {
|
|
2974
|
+
console.log('Service install complete. Start with: oomi openclaw bridge service start');
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
startBridgeLaunchdService();
|
|
2978
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2979
|
+
console.log(`Bridge service started: ${launchctlServiceTarget()}`);
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
if (action === 'uninstall') {
|
|
2984
|
+
await stopBridgeLaunchdService();
|
|
2985
|
+
if (fs.existsSync(plistPath)) {
|
|
2986
|
+
fs.unlinkSync(plistPath);
|
|
2987
|
+
}
|
|
2988
|
+
console.log(`Removed bridge launchd plist: ${plistPath}`);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
if (action === 'start') {
|
|
2993
|
+
startBridgeLaunchdService();
|
|
2994
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2995
|
+
console.log(`Bridge service started: ${launchctlServiceTarget()}`);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
if (action === 'stop') {
|
|
3000
|
+
const stopped = await stopBridgeLaunchdService();
|
|
3001
|
+
if (Array.isArray(stopped.found) && stopped.found.length > 0) {
|
|
3002
|
+
console.log(`Stopped bridge workers: ${stopped.stopped.join(', ') || 'none'}.`);
|
|
3003
|
+
} else {
|
|
3004
|
+
console.log('No bridge workers running.');
|
|
3005
|
+
}
|
|
3006
|
+
console.log(`Bridge service stopped: ${launchctlServiceTarget()}`);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
if (action === 'restart') {
|
|
3011
|
+
await stopBridgeLaunchdService();
|
|
3012
|
+
startBridgeLaunchdService();
|
|
3013
|
+
incrementBridgeMetric('bridge_restart_count');
|
|
3014
|
+
console.log(`Bridge service restarted: ${launchctlServiceTarget()}`);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
if (action === 'status') {
|
|
3019
|
+
const status = readBridgeLaunchdStatus();
|
|
3020
|
+
console.log('Bridge Service Status');
|
|
3021
|
+
console.log('---------------------');
|
|
3022
|
+
console.log(`Label: ${BRIDGE_LAUNCHD_LABEL}`);
|
|
3023
|
+
console.log(`Target: ${status.target}`);
|
|
3024
|
+
console.log(`Plist: ${status.plistPath}`);
|
|
3025
|
+
console.log(`Installed: ${status.installed ? 'yes' : 'no'}`);
|
|
3026
|
+
console.log(`Loaded: ${status.loaded ? 'yes' : 'no'}`);
|
|
3027
|
+
console.log(`Running: ${status.running ? 'yes' : 'no'}`);
|
|
3028
|
+
if (status.pid) {
|
|
3029
|
+
console.log(`PID: ${status.pid}`);
|
|
3030
|
+
}
|
|
3031
|
+
if (status.lastExitCode !== null) {
|
|
3032
|
+
console.log(`Last exit code: ${status.lastExitCode}`);
|
|
3033
|
+
}
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
throw new Error(
|
|
3038
|
+
`Unknown bridge service action: ${action}. Use: oomi openclaw bridge service [install|start|stop|restart|status|uninstall]`
|
|
3039
|
+
);
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
async function startBridgeLifecycle(flags = {}) {
|
|
3043
|
+
if (Boolean(flags.detach)) {
|
|
3044
|
+
const detachedFlags = { ...flags };
|
|
3045
|
+
delete detachedFlags.detach;
|
|
3046
|
+
const result = startBridgeDetachedProcess(detachedFlags);
|
|
3047
|
+
if (result.alreadyRunning) {
|
|
3048
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
3049
|
+
console.log(`Bridge already running (pid: ${result.pid}).`);
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
incrementBridgeMetric('bridge_start_count');
|
|
3053
|
+
console.log(`Bridge started in background (pid: ${result.pid}).`);
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const running = findRunningBridgeProcess();
|
|
3058
|
+
if (running) {
|
|
3059
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
3060
|
+
console.log(
|
|
3061
|
+
`Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
|
|
3062
|
+
);
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
incrementBridgeMetric('bridge_start_count');
|
|
3067
|
+
await startOpenclawBridge(flags);
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
|
|
3071
|
+
const action = String(actionRaw || 'start').trim().toLowerCase();
|
|
3072
|
+
|
|
3073
|
+
if (action === 'start' || action === 'ensure') {
|
|
3074
|
+
await startBridgeLifecycle(flags);
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
if (action === 'ps') {
|
|
3079
|
+
const pids = listBridgeProcessPids();
|
|
3080
|
+
if (pids.length === 0) {
|
|
3081
|
+
console.log('No bridge processes running.');
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
console.log(`Bridge processes: ${pids.join(', ')}`);
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (action === 'stop') {
|
|
3089
|
+
const result = await stopBridgeProcesses();
|
|
3090
|
+
if (result.found.length === 0) {
|
|
3091
|
+
console.log('No bridge processes running.');
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
|
|
3095
|
+
if (result.forceKilled.length > 0) {
|
|
3096
|
+
console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
|
|
3097
|
+
}
|
|
3098
|
+
if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
|
|
3099
|
+
throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
|
|
3100
|
+
}
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
if (action === 'restart') {
|
|
3105
|
+
incrementBridgeMetric('bridge_restart_count');
|
|
3106
|
+
const result = await stopBridgeProcesses();
|
|
3107
|
+
if (result.found.length > 0) {
|
|
3108
|
+
console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
|
|
3109
|
+
if (result.forceKilled.length > 0) {
|
|
3110
|
+
console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
|
|
3111
|
+
}
|
|
3112
|
+
} else {
|
|
3113
|
+
console.log('No existing bridge process found; starting fresh bridge.');
|
|
3114
|
+
}
|
|
3115
|
+
if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
|
|
3116
|
+
throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
|
|
3117
|
+
}
|
|
3118
|
+
await startBridgeLifecycle(flags);
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
throw new Error(
|
|
3123
|
+
`Unknown bridge action: ${action}. Use: oomi openclaw bridge [start|ensure|stop|restart|ps]`
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
1972
3127
|
async function main() {
|
|
1973
3128
|
const args = parseArgs(process.argv);
|
|
1974
3129
|
const command = args.command;
|
|
@@ -2001,14 +3156,13 @@ async function main() {
|
|
|
2001
3156
|
}
|
|
2002
3157
|
|
|
2003
3158
|
if (command === 'openclaw' && subcommand === 'bridge') {
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
console.log(`Bridge started in background (pid: ${pid}).`);
|
|
3159
|
+
const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
|
|
3160
|
+
if (bridgeAction === 'service') {
|
|
3161
|
+
const serviceAction = args.positionals[1] || 'status';
|
|
3162
|
+
await handleBridgeServiceCommand(serviceAction, args.flags);
|
|
2009
3163
|
return;
|
|
2010
3164
|
}
|
|
2011
|
-
await
|
|
3165
|
+
await handleBridgeLifecycleCommand(args.flags, bridgeAction);
|
|
2012
3166
|
return;
|
|
2013
3167
|
}
|
|
2014
3168
|
|
|
@@ -2051,7 +3205,24 @@ async function main() {
|
|
|
2051
3205
|
process.exit(1);
|
|
2052
3206
|
}
|
|
2053
3207
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
3208
|
+
const __currentFilePath = fileURLToPath(import.meta.url);
|
|
3209
|
+
const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
3210
|
+
const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
|
|
3211
|
+
|
|
3212
|
+
if (__isDirectExecution) {
|
|
3213
|
+
main().catch((err) => {
|
|
3214
|
+
console.error(err instanceof Error ? err.message : err);
|
|
3215
|
+
process.exit(1);
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
export {
|
|
3220
|
+
prepareGatewayFrameForLocalGateway,
|
|
3221
|
+
classifyBridgeFailure,
|
|
3222
|
+
computeReconnectDelayMs,
|
|
3223
|
+
extractGatewayRequestMeta,
|
|
3224
|
+
extractGatewayResponseMeta,
|
|
3225
|
+
isGatewayRunStartedFrame,
|
|
3226
|
+
isBridgeWorkerCommand,
|
|
3227
|
+
parsePositiveInteger,
|
|
3228
|
+
};
|