oomi-ai 0.2.10 → 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 +17 -0
- package/agent_instructions.md +1 -0
- package/bin/oomi-ai.js +860 -28
- package/openclaw.extension.js +62 -2
- package/package.json +2 -2
- package/skills/oomi/agent_instructions.md +1 -0
package/README.md
CHANGED
|
@@ -65,9 +65,20 @@ oomi openclaw bridge ensure --detach # start if needed; no-op if already runni
|
|
|
65
65
|
oomi openclaw bridge ps # list bridge pids
|
|
66
66
|
oomi openclaw bridge stop # stop all bridge workers
|
|
67
67
|
oomi openclaw bridge restart --detach # clean restart as background worker
|
|
68
|
+
tail -f ~/.openclaw/logs/oomi-bridge-live.log # detached bridge logs
|
|
68
69
|
```
|
|
69
70
|
`oomi openclaw bridge --detach` is equivalent to `oomi openclaw bridge start --detach`.
|
|
70
71
|
|
|
72
|
+
macOS launchd supervision (recommended for durability):
|
|
73
|
+
```
|
|
74
|
+
oomi openclaw bridge service install # install + start service
|
|
75
|
+
oomi openclaw bridge service status
|
|
76
|
+
oomi openclaw bridge service restart
|
|
77
|
+
oomi openclaw bridge service stop
|
|
78
|
+
oomi openclaw bridge service uninstall
|
|
79
|
+
```
|
|
80
|
+
Optional: `oomi openclaw bridge service install --no-start` to install without starting.
|
|
81
|
+
|
|
71
82
|
Agent-intent mapping (recommended):
|
|
72
83
|
- If user says `Connect yourself to Oomi. Use app URL https://www.oomi.ai.`
|
|
73
84
|
- Run:
|
|
@@ -121,6 +132,12 @@ Restart OpenClaw after running `oomi init` or `oomi openclaw install`.
|
|
|
121
132
|
- `OOMI_UPDATE_CHECK_TIMEOUT_MS=<ms>` changes network timeout
|
|
122
133
|
- `OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS=<ms>` changes local gateway socket connect timeout
|
|
123
134
|
- `OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS=<ms>` changes wait timeout for gateway `connect.challenge` nonce
|
|
135
|
+
- `OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS=<ms>` changes timeout for forwarded gateway `connect`/`chat.send` request responses
|
|
136
|
+
|
|
137
|
+
Bridge alert helper (reads `~/.openclaw/oomi-bridge-status.json` counters):
|
|
138
|
+
```
|
|
139
|
+
node <repo-root>/scripts/openclaw/bridge-alert-check.mjs
|
|
140
|
+
```
|
|
124
141
|
|
|
125
142
|
## Package Audit + Publish (pnpm)
|
|
126
143
|
```
|
package/agent_instructions.md
CHANGED
|
@@ -29,6 +29,7 @@ If the user asks to connect OpenClaw to Oomi voice/web:
|
|
|
29
29
|
7. Ensure exactly one bridge worker is running (singleton handler):
|
|
30
30
|
- `oomi openclaw bridge ensure --detach`
|
|
31
31
|
- If stale/disconnected: `oomi openclaw bridge restart --detach`
|
|
32
|
+
- On macOS, prefer supervised mode: `oomi openclaw bridge service install`
|
|
32
33
|
8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
|
|
33
34
|
- Use that URL directly in the pair command.
|
|
34
35
|
- Example: `oomi openclaw pair --app-url <URL> --no-start`
|
package/bin/oomi-ai.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
|
|
|
@@ -153,6 +159,8 @@ Commands:
|
|
|
153
159
|
|
|
154
160
|
openclaw bridge [start|ensure|stop|restart|ps]
|
|
155
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).
|
|
@@ -208,6 +216,15 @@ function writeFile(filePath, content, options = undefined) {
|
|
|
208
216
|
fs.writeFileSync(filePath, content, options);
|
|
209
217
|
}
|
|
210
218
|
|
|
219
|
+
function xmlEscape(value) {
|
|
220
|
+
return String(value)
|
|
221
|
+
.replaceAll('&', '&')
|
|
222
|
+
.replaceAll('<', '<')
|
|
223
|
+
.replaceAll('>', '>')
|
|
224
|
+
.replaceAll('"', '"')
|
|
225
|
+
.replaceAll("'", ''');
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
function resolveWorkspace() {
|
|
212
229
|
const envWorkspace = process.env.OPENCLAW_WORKSPACE || process.env.OPENCLAW_HOME;
|
|
213
230
|
if (envWorkspace) return envWorkspace;
|
|
@@ -562,6 +579,14 @@ function resolveBridgeLockPath() {
|
|
|
562
579
|
return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
|
|
563
580
|
}
|
|
564
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
|
+
|
|
565
590
|
function defaultDeviceId() {
|
|
566
591
|
const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
|
|
567
592
|
return `oomi-${host}-${randomUUID().slice(0, 8)}`;
|
|
@@ -612,6 +637,28 @@ function updateBridgeStatus(partial) {
|
|
|
612
637
|
return next;
|
|
613
638
|
}
|
|
614
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
|
+
|
|
615
662
|
function normalizePid(value) {
|
|
616
663
|
const pid = Number(value);
|
|
617
664
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
@@ -902,11 +949,20 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
902
949
|
return { frameText, waitForChallenge: false };
|
|
903
950
|
}
|
|
904
951
|
|
|
905
|
-
const
|
|
952
|
+
const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
953
|
+
const params = {};
|
|
954
|
+
|
|
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;
|
|
906
961
|
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
const
|
|
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() : '';
|
|
910
966
|
const proxiedBrowserClient =
|
|
911
967
|
incomingClientMode === 'webchat' ||
|
|
912
968
|
incomingClientId === 'webchat-ui' ||
|
|
@@ -918,20 +974,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
918
974
|
// so Control UI/webchat Origin checks don't reject proxied sessions.
|
|
919
975
|
client.id = proxiedBrowserClient
|
|
920
976
|
? 'node-host'
|
|
921
|
-
: (typeof
|
|
922
|
-
client.version = typeof
|
|
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';
|
|
923
979
|
client.platform = proxiedBrowserClient
|
|
924
980
|
? process.platform
|
|
925
|
-
: (typeof
|
|
981
|
+
: (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
|
|
926
982
|
client.mode = proxiedBrowserClient
|
|
927
983
|
? 'backend'
|
|
928
|
-
: (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
|
+
}
|
|
929
997
|
params.client = client;
|
|
930
998
|
|
|
931
|
-
params.role = typeof
|
|
999
|
+
params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
|
|
932
1000
|
|
|
933
|
-
const existingScopes = Array.isArray(
|
|
934
|
-
?
|
|
1001
|
+
const existingScopes = Array.isArray(rawParams.scopes)
|
|
1002
|
+
? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
|
|
935
1003
|
: [];
|
|
936
1004
|
const requiredScopes = ['operator.read', 'operator.write'];
|
|
937
1005
|
for (const scope of requiredScopes) {
|
|
@@ -941,14 +1009,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
941
1009
|
}
|
|
942
1010
|
params.scopes = existingScopes;
|
|
943
1011
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
+
}
|
|
949
1030
|
}
|
|
950
|
-
|
|
951
|
-
|
|
1031
|
+
|
|
1032
|
+
if (typeof rawParams.pathEnv === 'string') {
|
|
1033
|
+
params.pathEnv = rawParams.pathEnv;
|
|
952
1034
|
}
|
|
953
1035
|
|
|
954
1036
|
const auth = {};
|
|
@@ -961,16 +1043,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
961
1043
|
params.auth = auth;
|
|
962
1044
|
}
|
|
963
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
|
+
|
|
964
1053
|
if (deviceIdentity) {
|
|
965
1054
|
if (!connectNonce) {
|
|
966
|
-
return { frameText
|
|
1055
|
+
return { frameText, waitForChallenge: true };
|
|
967
1056
|
}
|
|
968
|
-
|
|
969
1057
|
const signedAtMs = Date.now();
|
|
970
1058
|
const tokenForSignature =
|
|
971
1059
|
typeof auth.token === 'string' && auth.token.trim()
|
|
972
1060
|
? auth.token.trim()
|
|
973
1061
|
: (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
|
|
1062
|
+
const nonceForSignature = connectNonce;
|
|
974
1063
|
|
|
975
1064
|
const payload = buildDeviceAuthPayloadV3({
|
|
976
1065
|
deviceId: deviceIdentity.deviceId,
|
|
@@ -980,7 +1069,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
980
1069
|
scopes: existingScopes,
|
|
981
1070
|
signedAtMs,
|
|
982
1071
|
token: tokenForSignature,
|
|
983
|
-
nonce:
|
|
1072
|
+
nonce: nonceForSignature,
|
|
984
1073
|
platform: client.platform,
|
|
985
1074
|
deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
|
|
986
1075
|
});
|
|
@@ -990,7 +1079,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
990
1079
|
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
|
|
991
1080
|
signature,
|
|
992
1081
|
signedAt: signedAtMs,
|
|
993
|
-
nonce:
|
|
1082
|
+
nonce: nonceForSignature,
|
|
994
1083
|
};
|
|
995
1084
|
}
|
|
996
1085
|
|
|
@@ -1009,6 +1098,53 @@ function parseJsonPayload(raw) {
|
|
|
1009
1098
|
}
|
|
1010
1099
|
}
|
|
1011
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
|
+
|
|
1012
1148
|
function bridgeNowIso() {
|
|
1013
1149
|
return new Date().toISOString();
|
|
1014
1150
|
}
|
|
@@ -1175,11 +1311,20 @@ function startBridgeDetachedProcess(rawFlags = {}) {
|
|
|
1175
1311
|
}
|
|
1176
1312
|
|
|
1177
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');
|
|
1178
1318
|
const child = spawn(process.execPath, args, {
|
|
1179
1319
|
detached: true,
|
|
1180
|
-
stdio: 'ignore',
|
|
1320
|
+
stdio: ['ignore', logFd, logFd],
|
|
1181
1321
|
});
|
|
1182
1322
|
child.unref();
|
|
1323
|
+
try {
|
|
1324
|
+
fs.closeSync(logFd);
|
|
1325
|
+
} catch {
|
|
1326
|
+
// no-op
|
|
1327
|
+
}
|
|
1183
1328
|
return {
|
|
1184
1329
|
pid: child.pid,
|
|
1185
1330
|
alreadyRunning: false,
|
|
@@ -1291,6 +1436,128 @@ async function stopBridgeProcesses() {
|
|
|
1291
1436
|
};
|
|
1292
1437
|
}
|
|
1293
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();
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1294
1561
|
async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
1295
1562
|
const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
|
|
1296
1563
|
const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
|
|
@@ -1469,6 +1736,188 @@ async function startOpenclawBridge(flags) {
|
|
|
1469
1736
|
);
|
|
1470
1737
|
};
|
|
1471
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
|
+
|
|
1472
1921
|
const parseBrokerEnvelope = (raw) => {
|
|
1473
1922
|
const payload = parseJsonPayload(raw);
|
|
1474
1923
|
if (!payload) return null;
|
|
@@ -1496,6 +1945,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1496
1945
|
const scheduleReconnect = () => {
|
|
1497
1946
|
if (reconnectState.stopped || reconnectState.timer) return;
|
|
1498
1947
|
reconnectState.attempt += 1;
|
|
1948
|
+
incrementBridgeMetric('bridge_reconnect_scheduled_count');
|
|
1499
1949
|
const failure =
|
|
1500
1950
|
reconnectState.lastFailure ||
|
|
1501
1951
|
classifyBridgeFailure({ reason: 'connection closed without classified error' });
|
|
@@ -1592,20 +2042,102 @@ async function startOpenclawBridge(flags) {
|
|
|
1592
2042
|
|
|
1593
2043
|
clearChallengeTimer(sessionBridge);
|
|
1594
2044
|
for (const pendingFrame of pending) {
|
|
2045
|
+
const requestMeta = extractGatewayRequestMeta(pendingFrame);
|
|
1595
2046
|
const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
|
|
1596
2047
|
connectNonce: sessionBridge.connectNonce,
|
|
1597
2048
|
deviceIdentity: gatewayDeviceIdentity,
|
|
1598
2049
|
});
|
|
1599
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
|
+
}
|
|
1600
2078
|
continue;
|
|
1601
2079
|
}
|
|
1602
2080
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1603
2081
|
if (result === 'queued') {
|
|
1604
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
|
+
}
|
|
1605
2095
|
} else if (result === 'dropped') {
|
|
1606
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
|
+
}
|
|
1607
2127
|
} else {
|
|
1608
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
|
+
}
|
|
1609
2141
|
}
|
|
1610
2142
|
}
|
|
1611
2143
|
};
|
|
@@ -1619,11 +2151,21 @@ async function startOpenclawBridge(flags) {
|
|
|
1619
2151
|
if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
|
|
1620
2152
|
sessionBridge.pendingConnectFrames = [];
|
|
1621
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
|
+
}
|
|
1622
2163
|
let connectTimeout = setTimeout(() => {
|
|
1623
2164
|
if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
|
|
1624
2165
|
console.error(
|
|
1625
2166
|
`[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
|
|
1626
2167
|
);
|
|
2168
|
+
incrementBridgeMetric('gateway_connect_timeout_count');
|
|
1627
2169
|
sendBrokerPayload(brokerSocket, {
|
|
1628
2170
|
action: 'log',
|
|
1629
2171
|
type: 'log',
|
|
@@ -1651,6 +2193,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1651
2193
|
const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
|
|
1652
2194
|
const gatewayPayload = parseJsonPayload(frame);
|
|
1653
2195
|
if (gatewayPayload?.event === 'connect.challenge') {
|
|
2196
|
+
console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
|
|
1654
2197
|
const nonce =
|
|
1655
2198
|
gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
|
|
1656
2199
|
? gatewayPayload.payload.nonce.trim()
|
|
@@ -1674,6 +2217,35 @@ async function startOpenclawBridge(flags) {
|
|
|
1674
2217
|
flushPendingConnectFrames(sessionId, sessionBridge);
|
|
1675
2218
|
}
|
|
1676
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
|
+
|
|
1677
2249
|
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
|
|
1678
2250
|
});
|
|
1679
2251
|
|
|
@@ -1684,9 +2256,41 @@ async function startOpenclawBridge(flags) {
|
|
|
1684
2256
|
}
|
|
1685
2257
|
clearChallengeTimer(sessionBridge);
|
|
1686
2258
|
const reasonText = reason ? reason.toString() : '';
|
|
2259
|
+
const closeMeta = classifyGatewayClose(code, reasonText);
|
|
1687
2260
|
console.log(
|
|
1688
2261
|
`[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
|
|
1689
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);
|
|
1690
2294
|
activeGatewaySockets.delete(sessionId);
|
|
1691
2295
|
sendBrokerPayload(brokerSocket, {
|
|
1692
2296
|
action: 'gateway_closed',
|
|
@@ -1843,6 +2447,22 @@ async function startOpenclawBridge(flags) {
|
|
|
1843
2447
|
console.log(`[bridge] client.frame ${sessionId}`);
|
|
1844
2448
|
const sessionBridge = getOrCreateGatewaySession(sessionId);
|
|
1845
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
|
+
}
|
|
1846
2466
|
const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
|
|
1847
2467
|
connectNonce: sessionBridge.connectNonce,
|
|
1848
2468
|
deviceIdentity: gatewayDeviceIdentity,
|
|
@@ -1850,16 +2470,103 @@ async function startOpenclawBridge(flags) {
|
|
|
1850
2470
|
if (prepared.waitForChallenge) {
|
|
1851
2471
|
queueConnectUntilChallenge(sessionId, sessionBridge, frame);
|
|
1852
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
|
+
}
|
|
1853
2482
|
return;
|
|
1854
2483
|
}
|
|
1855
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
|
+
}
|
|
1856
2512
|
return;
|
|
1857
2513
|
}
|
|
1858
2514
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1859
2515
|
if (result === 'queued') {
|
|
1860
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
|
+
}
|
|
1861
2529
|
} else if (result === 'dropped') {
|
|
1862
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
|
+
});
|
|
1863
2570
|
}
|
|
1864
2571
|
return;
|
|
1865
2572
|
}
|
|
@@ -1870,6 +2577,10 @@ async function startOpenclawBridge(flags) {
|
|
|
1870
2577
|
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
1871
2578
|
if (sessionBridge && sessionBridge.socket) {
|
|
1872
2579
|
clearChallengeTimer(sessionBridge);
|
|
2580
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2581
|
+
sessionBridge.pendingRequests.clear();
|
|
2582
|
+
}
|
|
2583
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1873
2584
|
activeGatewaySockets.delete(sessionId);
|
|
1874
2585
|
sessionBridge.socket.close(1000, 'client_closed');
|
|
1875
2586
|
}
|
|
@@ -1884,8 +2595,13 @@ async function startOpenclawBridge(flags) {
|
|
|
1884
2595
|
}
|
|
1885
2596
|
const reasonText = reason ? reason.toString() : '';
|
|
1886
2597
|
console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
|
|
2598
|
+
incrementBridgeMetric('bridge_disconnect_count');
|
|
1887
2599
|
for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
|
|
1888
2600
|
clearChallengeTimer(sessionBridge);
|
|
2601
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2602
|
+
sessionBridge.pendingRequests.clear();
|
|
2603
|
+
}
|
|
2604
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1889
2605
|
activeGatewaySockets.delete(sessionId);
|
|
1890
2606
|
try {
|
|
1891
2607
|
sessionBridge.socket.close(1001, 'broker_disconnected');
|
|
@@ -1907,6 +2623,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1907
2623
|
});
|
|
1908
2624
|
|
|
1909
2625
|
brokerSocket.on('error', (err) => {
|
|
2626
|
+
incrementBridgeMetric('bridge_socket_error_count');
|
|
1910
2627
|
reconnectState.lastFailure = classifyBridgeFailure({ err });
|
|
1911
2628
|
console.error(
|
|
1912
2629
|
`[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
|
|
@@ -2160,6 +2877,16 @@ function printOpenclawBridgeStatus(flags) {
|
|
|
2160
2877
|
if (payload.runtime.hint) {
|
|
2161
2878
|
console.log(`Hint: ${payload.runtime.hint}`);
|
|
2162
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
|
+
}
|
|
2163
2890
|
return;
|
|
2164
2891
|
}
|
|
2165
2892
|
|
|
@@ -2234,27 +2961,109 @@ function printOpenclawPluginSetup(flags) {
|
|
|
2234
2961
|
}
|
|
2235
2962
|
}
|
|
2236
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
|
+
|
|
2237
3042
|
async function startBridgeLifecycle(flags = {}) {
|
|
2238
3043
|
if (Boolean(flags.detach)) {
|
|
2239
3044
|
const detachedFlags = { ...flags };
|
|
2240
3045
|
delete detachedFlags.detach;
|
|
2241
3046
|
const result = startBridgeDetachedProcess(detachedFlags);
|
|
2242
3047
|
if (result.alreadyRunning) {
|
|
3048
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
2243
3049
|
console.log(`Bridge already running (pid: ${result.pid}).`);
|
|
2244
3050
|
return;
|
|
2245
3051
|
}
|
|
3052
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2246
3053
|
console.log(`Bridge started in background (pid: ${result.pid}).`);
|
|
2247
3054
|
return;
|
|
2248
3055
|
}
|
|
2249
3056
|
|
|
2250
3057
|
const running = findRunningBridgeProcess();
|
|
2251
3058
|
if (running) {
|
|
3059
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
2252
3060
|
console.log(
|
|
2253
3061
|
`Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
|
|
2254
3062
|
);
|
|
2255
3063
|
return;
|
|
2256
3064
|
}
|
|
2257
3065
|
|
|
3066
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2258
3067
|
await startOpenclawBridge(flags);
|
|
2259
3068
|
}
|
|
2260
3069
|
|
|
@@ -2293,6 +3102,7 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
|
|
|
2293
3102
|
}
|
|
2294
3103
|
|
|
2295
3104
|
if (action === 'restart') {
|
|
3105
|
+
incrementBridgeMetric('bridge_restart_count');
|
|
2296
3106
|
const result = await stopBridgeProcesses();
|
|
2297
3107
|
if (result.found.length > 0) {
|
|
2298
3108
|
console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
|
|
@@ -2346,7 +3156,12 @@ async function main() {
|
|
|
2346
3156
|
}
|
|
2347
3157
|
|
|
2348
3158
|
if (command === 'openclaw' && subcommand === 'bridge') {
|
|
2349
|
-
const bridgeAction = args.positionals[0] || 'start';
|
|
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);
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
2350
3165
|
await handleBridgeLifecycleCommand(args.flags, bridgeAction);
|
|
2351
3166
|
return;
|
|
2352
3167
|
}
|
|
@@ -2390,7 +3205,24 @@ async function main() {
|
|
|
2390
3205
|
process.exit(1);
|
|
2391
3206
|
}
|
|
2392
3207
|
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
+
};
|
package/openclaw.extension.js
CHANGED
|
@@ -32,8 +32,22 @@ function parseAccounts(rawAccounts) {
|
|
|
32
32
|
return accounts;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function extractChannelConfig(cfg = {}) {
|
|
36
|
+
if (!cfg || typeof cfg !== 'object') return {};
|
|
37
|
+
if (cfg.channels && typeof cfg.channels === 'object' && cfg.channels[CHANNEL_ID] && typeof cfg.channels[CHANNEL_ID] === 'object') {
|
|
38
|
+
return cfg.channels[CHANNEL_ID];
|
|
39
|
+
}
|
|
40
|
+
if (cfg[CHANNEL_ID] && typeof cfg[CHANNEL_ID] === 'object') {
|
|
41
|
+
return cfg[CHANNEL_ID];
|
|
42
|
+
}
|
|
43
|
+
if (cfg.accounts && typeof cfg.accounts === 'object') {
|
|
44
|
+
return cfg;
|
|
45
|
+
}
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
function normalizeConfig(cfg = {}) {
|
|
36
|
-
const channelConfig = cfg
|
|
50
|
+
const channelConfig = extractChannelConfig(cfg);
|
|
37
51
|
const configuredAccounts = parseAccounts(channelConfig.accounts);
|
|
38
52
|
const accountIds = Object.keys(configuredAccounts);
|
|
39
53
|
const defaultAccountId = toString(channelConfig.defaultAccountId, accountIds[0] || 'default');
|
|
@@ -125,6 +139,45 @@ function extractUserId(payload) {
|
|
|
125
139
|
return '';
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
function nextMessageId() {
|
|
143
|
+
return `oomi_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractMessageId(payload) {
|
|
147
|
+
const candidates = [
|
|
148
|
+
payload?.messageId,
|
|
149
|
+
payload?.id,
|
|
150
|
+
payload?.requestId,
|
|
151
|
+
payload?.idempotencyKey,
|
|
152
|
+
payload?.metadata?.messageId,
|
|
153
|
+
payload?.metadata?.idempotencyKey,
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const value = toString(candidate);
|
|
158
|
+
if (value) return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return nextMessageId();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractCorrelationId(payload) {
|
|
165
|
+
const candidates = [
|
|
166
|
+
payload?.correlationId,
|
|
167
|
+
payload?.metadata?.correlationId,
|
|
168
|
+
payload?.requestId,
|
|
169
|
+
payload?.messageId,
|
|
170
|
+
payload?.id,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const candidate of candidates) {
|
|
174
|
+
const value = toString(candidate);
|
|
175
|
+
if (value) return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
|
|
128
181
|
async function postJson({ url, token, body, timeoutMs }) {
|
|
129
182
|
const controller = new AbortController();
|
|
130
183
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -221,12 +274,16 @@ const oomiChannelPlugin = {
|
|
|
221
274
|
const conversationKey = extractConversationKey(payload);
|
|
222
275
|
const userId = extractUserId(payload);
|
|
223
276
|
const sessionKey = toString(payload?.sessionKey || payload?.metadata?.sessionKey, account.defaultSessionKey);
|
|
277
|
+
const messageId = extractMessageId(payload);
|
|
278
|
+
const correlationId = extractCorrelationId(payload);
|
|
224
279
|
|
|
225
280
|
const response = await postJson({
|
|
226
281
|
url: `${account.backendUrl}/v1/channel/plugin/messages`,
|
|
227
282
|
token: account.deviceToken,
|
|
228
283
|
timeoutMs: account.requestTimeoutMs,
|
|
229
284
|
body: {
|
|
285
|
+
messageId,
|
|
286
|
+
correlationId,
|
|
230
287
|
conversationKey,
|
|
231
288
|
userId,
|
|
232
289
|
sessionKey,
|
|
@@ -234,15 +291,18 @@ const oomiChannelPlugin = {
|
|
|
234
291
|
source: 'openclaw.channel',
|
|
235
292
|
metadata: {
|
|
236
293
|
accountId: resolvedAccountId,
|
|
294
|
+
correlationId,
|
|
237
295
|
},
|
|
238
296
|
},
|
|
239
297
|
});
|
|
240
298
|
|
|
241
299
|
if (!response.ok) {
|
|
242
300
|
const reason = toString(response.payload?.error, `status ${response.status}`);
|
|
301
|
+
const code = toString(response.payload?.errorCode);
|
|
243
302
|
return {
|
|
244
303
|
ok: false,
|
|
245
|
-
error: `oomi plugin message publish failed: ${reason}`,
|
|
304
|
+
error: `oomi plugin message publish failed: ${reason}${code ? ` (code=${code})` : ''}`,
|
|
305
|
+
code,
|
|
246
306
|
};
|
|
247
307
|
}
|
|
248
308
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Oomi CLI for OpenClaw setup",
|
|
5
5
|
"bin": {
|
|
6
6
|
"oomi": "bin/oomi-ai.js"
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"check": "node --check bin/oomi-ai.js",
|
|
50
|
-
"test": "node --test test
|
|
50
|
+
"test": "node --test test/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"ws": "^8.19.0"
|
|
@@ -29,6 +29,7 @@ If the user asks to connect OpenClaw to Oomi voice/web:
|
|
|
29
29
|
7. Ensure exactly one bridge worker is running (singleton handler):
|
|
30
30
|
- `oomi openclaw bridge ensure --detach`
|
|
31
31
|
- If stale/disconnected: `oomi openclaw bridge restart --detach`
|
|
32
|
+
- On macOS, prefer supervised mode: `oomi openclaw bridge service install`
|
|
32
33
|
8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
|
|
33
34
|
- Use that URL directly in the pair command.
|
|
34
35
|
- Example: `oomi openclaw pair --app-url <URL> --no-start`
|