oomi-ai 0.2.10 → 0.2.13
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 +888 -29
- 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;
|
|
@@ -898,15 +945,51 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
898
945
|
|
|
899
946
|
try {
|
|
900
947
|
const frame = JSON.parse(frameText);
|
|
901
|
-
if (frame?.type !== 'req'
|
|
948
|
+
if (frame?.type !== 'req') {
|
|
949
|
+
return { frameText, waitForChallenge: false };
|
|
950
|
+
}
|
|
951
|
+
const method = typeof frame.method === 'string' ? frame.method.trim() : '';
|
|
952
|
+
if (!method) {
|
|
953
|
+
return { frameText, waitForChallenge: false };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (method !== 'connect') {
|
|
957
|
+
const shouldStripBridgeOnlyParams = method === 'chat.send' || method === 'chat.history';
|
|
958
|
+
if (!shouldStripBridgeOnlyParams) {
|
|
959
|
+
return { frameText, waitForChallenge: false };
|
|
960
|
+
}
|
|
961
|
+
const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
962
|
+
const sanitized = { ...rawParams };
|
|
963
|
+
let changed = false;
|
|
964
|
+
if (Object.prototype.hasOwnProperty.call(sanitized, 'correlationId')) {
|
|
965
|
+
delete sanitized.correlationId;
|
|
966
|
+
changed = true;
|
|
967
|
+
}
|
|
968
|
+
if (Object.prototype.hasOwnProperty.call(sanitized, 'metadata')) {
|
|
969
|
+
delete sanitized.metadata;
|
|
970
|
+
changed = true;
|
|
971
|
+
}
|
|
972
|
+
if (changed) {
|
|
973
|
+
frame.params = sanitized;
|
|
974
|
+
return { frameText: JSON.stringify(frame), waitForChallenge: false };
|
|
975
|
+
}
|
|
902
976
|
return { frameText, waitForChallenge: false };
|
|
903
977
|
}
|
|
904
978
|
|
|
905
|
-
const
|
|
979
|
+
const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
980
|
+
const params = {};
|
|
906
981
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
982
|
+
params.minProtocol = Number.isInteger(rawParams.minProtocol) && rawParams.minProtocol >= 1
|
|
983
|
+
? rawParams.minProtocol
|
|
984
|
+
: 3;
|
|
985
|
+
params.maxProtocol = Number.isInteger(rawParams.maxProtocol) && rawParams.maxProtocol >= 1
|
|
986
|
+
? rawParams.maxProtocol
|
|
987
|
+
: 3;
|
|
988
|
+
|
|
989
|
+
const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
|
|
990
|
+
const client = {};
|
|
991
|
+
const incomingClientId = typeof clientInput.id === 'string' ? clientInput.id.trim().toLowerCase() : '';
|
|
992
|
+
const incomingClientMode = typeof clientInput.mode === 'string' ? clientInput.mode.trim().toLowerCase() : '';
|
|
910
993
|
const proxiedBrowserClient =
|
|
911
994
|
incomingClientMode === 'webchat' ||
|
|
912
995
|
incomingClientId === 'webchat-ui' ||
|
|
@@ -918,20 +1001,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
918
1001
|
// so Control UI/webchat Origin checks don't reject proxied sessions.
|
|
919
1002
|
client.id = proxiedBrowserClient
|
|
920
1003
|
? 'node-host'
|
|
921
|
-
: (typeof
|
|
922
|
-
client.version = typeof
|
|
1004
|
+
: (typeof clientInput.id === 'string' && clientInput.id.trim() ? clientInput.id.trim() : 'node-host');
|
|
1005
|
+
client.version = typeof clientInput.version === 'string' && clientInput.version.trim() ? clientInput.version.trim() : '0.1.0';
|
|
923
1006
|
client.platform = proxiedBrowserClient
|
|
924
1007
|
? process.platform
|
|
925
|
-
: (typeof
|
|
1008
|
+
: (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
|
|
926
1009
|
client.mode = proxiedBrowserClient
|
|
927
1010
|
? 'backend'
|
|
928
|
-
: (typeof
|
|
1011
|
+
: (typeof clientInput.mode === 'string' && clientInput.mode.trim() ? clientInput.mode.trim() : 'backend');
|
|
1012
|
+
if (typeof clientInput.displayName === 'string' && clientInput.displayName.trim()) {
|
|
1013
|
+
client.displayName = clientInput.displayName.trim();
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof clientInput.deviceFamily === 'string' && clientInput.deviceFamily.trim()) {
|
|
1016
|
+
client.deviceFamily = clientInput.deviceFamily.trim();
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof clientInput.modelIdentifier === 'string' && clientInput.modelIdentifier.trim()) {
|
|
1019
|
+
client.modelIdentifier = clientInput.modelIdentifier.trim();
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof clientInput.instanceId === 'string' && clientInput.instanceId.trim()) {
|
|
1022
|
+
client.instanceId = clientInput.instanceId.trim();
|
|
1023
|
+
}
|
|
929
1024
|
params.client = client;
|
|
930
1025
|
|
|
931
|
-
params.role = typeof
|
|
1026
|
+
params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
|
|
932
1027
|
|
|
933
|
-
const existingScopes = Array.isArray(
|
|
934
|
-
?
|
|
1028
|
+
const existingScopes = Array.isArray(rawParams.scopes)
|
|
1029
|
+
? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
|
|
935
1030
|
: [];
|
|
936
1031
|
const requiredScopes = ['operator.read', 'operator.write'];
|
|
937
1032
|
for (const scope of requiredScopes) {
|
|
@@ -941,14 +1036,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
941
1036
|
}
|
|
942
1037
|
params.scopes = existingScopes;
|
|
943
1038
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1039
|
+
params.caps = Array.isArray(rawParams.caps)
|
|
1040
|
+
? rawParams.caps.filter((value) => typeof value === 'string' && value.trim())
|
|
1041
|
+
: [];
|
|
1042
|
+
|
|
1043
|
+
params.commands = Array.isArray(rawParams.commands)
|
|
1044
|
+
? rawParams.commands.filter((value) => typeof value === 'string' && value.trim())
|
|
1045
|
+
: [];
|
|
1046
|
+
|
|
1047
|
+
if (rawParams.permissions && typeof rawParams.permissions === 'object') {
|
|
1048
|
+
const permissions = {};
|
|
1049
|
+
for (const [key, value] of Object.entries(rawParams.permissions)) {
|
|
1050
|
+
const normalizedKey = typeof key === 'string' ? key.trim() : '';
|
|
1051
|
+
if (!normalizedKey || typeof value !== 'boolean') continue;
|
|
1052
|
+
permissions[normalizedKey] = value;
|
|
1053
|
+
}
|
|
1054
|
+
if (Object.keys(permissions).length > 0) {
|
|
1055
|
+
params.permissions = permissions;
|
|
1056
|
+
}
|
|
949
1057
|
}
|
|
950
|
-
|
|
951
|
-
|
|
1058
|
+
|
|
1059
|
+
if (typeof rawParams.pathEnv === 'string') {
|
|
1060
|
+
params.pathEnv = rawParams.pathEnv;
|
|
952
1061
|
}
|
|
953
1062
|
|
|
954
1063
|
const auth = {};
|
|
@@ -961,16 +1070,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
961
1070
|
params.auth = auth;
|
|
962
1071
|
}
|
|
963
1072
|
|
|
1073
|
+
if (typeof rawParams.locale === 'string' && rawParams.locale.trim()) {
|
|
1074
|
+
params.locale = rawParams.locale;
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof rawParams.userAgent === 'string' && rawParams.userAgent.trim()) {
|
|
1077
|
+
params.userAgent = rawParams.userAgent;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
964
1080
|
if (deviceIdentity) {
|
|
965
1081
|
if (!connectNonce) {
|
|
966
|
-
return { frameText
|
|
1082
|
+
return { frameText, waitForChallenge: true };
|
|
967
1083
|
}
|
|
968
|
-
|
|
969
1084
|
const signedAtMs = Date.now();
|
|
970
1085
|
const tokenForSignature =
|
|
971
1086
|
typeof auth.token === 'string' && auth.token.trim()
|
|
972
1087
|
? auth.token.trim()
|
|
973
1088
|
: (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
|
|
1089
|
+
const nonceForSignature = connectNonce;
|
|
974
1090
|
|
|
975
1091
|
const payload = buildDeviceAuthPayloadV3({
|
|
976
1092
|
deviceId: deviceIdentity.deviceId,
|
|
@@ -980,7 +1096,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
980
1096
|
scopes: existingScopes,
|
|
981
1097
|
signedAtMs,
|
|
982
1098
|
token: tokenForSignature,
|
|
983
|
-
nonce:
|
|
1099
|
+
nonce: nonceForSignature,
|
|
984
1100
|
platform: client.platform,
|
|
985
1101
|
deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
|
|
986
1102
|
});
|
|
@@ -990,7 +1106,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
|
|
|
990
1106
|
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
|
|
991
1107
|
signature,
|
|
992
1108
|
signedAt: signedAtMs,
|
|
993
|
-
nonce:
|
|
1109
|
+
nonce: nonceForSignature,
|
|
994
1110
|
};
|
|
995
1111
|
}
|
|
996
1112
|
|
|
@@ -1009,6 +1125,53 @@ function parseJsonPayload(raw) {
|
|
|
1009
1125
|
}
|
|
1010
1126
|
}
|
|
1011
1127
|
|
|
1128
|
+
function extractCorrelationId(params) {
|
|
1129
|
+
if (!params || typeof params !== 'object') return '';
|
|
1130
|
+
if (typeof params.correlationId === 'string' && params.correlationId.trim()) {
|
|
1131
|
+
return params.correlationId.trim();
|
|
1132
|
+
}
|
|
1133
|
+
const metadata = params.metadata;
|
|
1134
|
+
if (metadata && typeof metadata === 'object' && typeof metadata.correlationId === 'string' && metadata.correlationId.trim()) {
|
|
1135
|
+
return metadata.correlationId.trim();
|
|
1136
|
+
}
|
|
1137
|
+
return '';
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function extractGatewayRequestMeta(frameText) {
|
|
1141
|
+
const payload = parseJsonPayload(frameText);
|
|
1142
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
1143
|
+
if (payload.type !== 'req') return null;
|
|
1144
|
+
const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
|
|
1145
|
+
const method = typeof payload.method === 'string' ? payload.method.trim() : '';
|
|
1146
|
+
if (!requestId || !method) return null;
|
|
1147
|
+
|
|
1148
|
+
const params = payload.params && typeof payload.params === 'object' ? payload.params : {};
|
|
1149
|
+
const correlationId = extractCorrelationId(params);
|
|
1150
|
+
return { requestId, method, correlationId };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function extractGatewayResponseMeta(frameText) {
|
|
1154
|
+
const payload = parseJsonPayload(frameText);
|
|
1155
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
1156
|
+
if (payload.type !== 'res') return null;
|
|
1157
|
+
const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
|
|
1158
|
+
if (!requestId) return null;
|
|
1159
|
+
return {
|
|
1160
|
+
requestId,
|
|
1161
|
+
ok: payload.ok === true,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function isGatewayRunStartedFrame(frameText) {
|
|
1166
|
+
const payload = parseJsonPayload(frameText);
|
|
1167
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
1168
|
+
if (payload.type !== 'event' || payload.event !== 'agent') return false;
|
|
1169
|
+
const body = payload.payload;
|
|
1170
|
+
if (!body || typeof body !== 'object') return false;
|
|
1171
|
+
if (body.stream !== 'lifecycle') return false;
|
|
1172
|
+
return body.data && typeof body.data === 'object' && body.data.phase === 'start';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1012
1175
|
function bridgeNowIso() {
|
|
1013
1176
|
return new Date().toISOString();
|
|
1014
1177
|
}
|
|
@@ -1175,11 +1338,20 @@ function startBridgeDetachedProcess(rawFlags = {}) {
|
|
|
1175
1338
|
}
|
|
1176
1339
|
|
|
1177
1340
|
const args = buildBridgeDetachArgs(rawFlags);
|
|
1341
|
+
const logPath = resolveBridgeLiveLogPath();
|
|
1342
|
+
ensureDir(path.dirname(logPath));
|
|
1343
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] [bridge-supervisor] starting detached bridge\n`);
|
|
1344
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1178
1345
|
const child = spawn(process.execPath, args, {
|
|
1179
1346
|
detached: true,
|
|
1180
|
-
stdio: 'ignore',
|
|
1347
|
+
stdio: ['ignore', logFd, logFd],
|
|
1181
1348
|
});
|
|
1182
1349
|
child.unref();
|
|
1350
|
+
try {
|
|
1351
|
+
fs.closeSync(logFd);
|
|
1352
|
+
} catch {
|
|
1353
|
+
// no-op
|
|
1354
|
+
}
|
|
1183
1355
|
return {
|
|
1184
1356
|
pid: child.pid,
|
|
1185
1357
|
alreadyRunning: false,
|
|
@@ -1291,6 +1463,128 @@ async function stopBridgeProcesses() {
|
|
|
1291
1463
|
};
|
|
1292
1464
|
}
|
|
1293
1465
|
|
|
1466
|
+
function assertMacOSLaunchdAvailable() {
|
|
1467
|
+
if (process.platform !== 'darwin') {
|
|
1468
|
+
throw new Error('Bridge service manager is only supported on macOS (launchd).');
|
|
1469
|
+
}
|
|
1470
|
+
if (typeof process.getuid !== 'function') {
|
|
1471
|
+
throw new Error('Cannot resolve current UID for launchd domain.');
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function launchctlDomain() {
|
|
1476
|
+
assertMacOSLaunchdAvailable();
|
|
1477
|
+
return `gui/${String(process.getuid())}`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function launchctlServiceTarget() {
|
|
1481
|
+
return `${launchctlDomain()}/${BRIDGE_LAUNCHD_LABEL}`;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function runLaunchctl(args, { allowFailure = false } = {}) {
|
|
1485
|
+
const result = spawnSync('launchctl', args, {
|
|
1486
|
+
encoding: 'utf8',
|
|
1487
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1488
|
+
});
|
|
1489
|
+
const status = Number.isInteger(result.status) ? result.status : 1;
|
|
1490
|
+
const stdout = String(result.stdout || '').trim();
|
|
1491
|
+
const stderr = String(result.stderr || '').trim();
|
|
1492
|
+
if (status !== 0 && !allowFailure) {
|
|
1493
|
+
throw new Error(
|
|
1494
|
+
`launchctl ${args.join(' ')} failed (${status}): ${stderr || stdout || 'unknown launchctl error'}`
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
return { status, stdout, stderr };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function buildBridgeLaunchAgentPlist() {
|
|
1501
|
+
const scriptPath = (() => {
|
|
1502
|
+
try {
|
|
1503
|
+
return fs.realpathSync(process.argv[1]);
|
|
1504
|
+
} catch {
|
|
1505
|
+
return process.argv[1];
|
|
1506
|
+
}
|
|
1507
|
+
})();
|
|
1508
|
+
const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start'];
|
|
1509
|
+
const bridgeLogPath = resolveBridgeLiveLogPath();
|
|
1510
|
+
const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
|
|
1511
|
+
|
|
1512
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1513
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1514
|
+
<plist version="1.0">
|
|
1515
|
+
<dict>
|
|
1516
|
+
<key>Label</key>
|
|
1517
|
+
<string>${xmlEscape(BRIDGE_LAUNCHD_LABEL)}</string>
|
|
1518
|
+
<key>ProgramArguments</key>
|
|
1519
|
+
<array>
|
|
1520
|
+
${argsXml}
|
|
1521
|
+
</array>
|
|
1522
|
+
<key>WorkingDirectory</key>
|
|
1523
|
+
<string>${xmlEscape(os.homedir())}</string>
|
|
1524
|
+
<key>RunAtLoad</key>
|
|
1525
|
+
<true/>
|
|
1526
|
+
<key>KeepAlive</key>
|
|
1527
|
+
<true/>
|
|
1528
|
+
<key>ThrottleInterval</key>
|
|
1529
|
+
<integer>5</integer>
|
|
1530
|
+
<key>EnvironmentVariables</key>
|
|
1531
|
+
<dict>
|
|
1532
|
+
<key>OOMI_SKIP_UPDATE_CHECK</key>
|
|
1533
|
+
<string>1</string>
|
|
1534
|
+
</dict>
|
|
1535
|
+
<key>StandardOutPath</key>
|
|
1536
|
+
<string>${xmlEscape(bridgeLogPath)}</string>
|
|
1537
|
+
<key>StandardErrorPath</key>
|
|
1538
|
+
<string>${xmlEscape(bridgeLogPath)}</string>
|
|
1539
|
+
</dict>
|
|
1540
|
+
</plist>
|
|
1541
|
+
`;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function readBridgeLaunchdStatus() {
|
|
1545
|
+
assertMacOSLaunchdAvailable();
|
|
1546
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1547
|
+
const target = launchctlServiceTarget();
|
|
1548
|
+
const printResult = runLaunchctl(['print', target], { allowFailure: true });
|
|
1549
|
+
const loaded = printResult.status === 0;
|
|
1550
|
+
const output = [printResult.stdout, printResult.stderr].filter(Boolean).join('\n');
|
|
1551
|
+
const pidMatch = output.match(/\bpid\s*=\s*(\d+)/);
|
|
1552
|
+
const lastExitMatch = output.match(/\blast exit code\s*=\s*(-?\d+)/i);
|
|
1553
|
+
|
|
1554
|
+
return {
|
|
1555
|
+
plistPath,
|
|
1556
|
+
target,
|
|
1557
|
+
installed: fs.existsSync(plistPath),
|
|
1558
|
+
loaded,
|
|
1559
|
+
pid: pidMatch ? Number(pidMatch[1]) : null,
|
|
1560
|
+
running: Boolean(pidMatch && Number(pidMatch[1]) > 0),
|
|
1561
|
+
lastExitCode: lastExitMatch ? Number(lastExitMatch[1]) : null,
|
|
1562
|
+
printOutput: output,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function startBridgeLaunchdService() {
|
|
1567
|
+
assertMacOSLaunchdAvailable();
|
|
1568
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1569
|
+
if (!fs.existsSync(plistPath)) {
|
|
1570
|
+
throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
|
|
1571
|
+
}
|
|
1572
|
+
const domain = launchctlDomain();
|
|
1573
|
+
const target = launchctlServiceTarget();
|
|
1574
|
+
runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
|
|
1575
|
+
runLaunchctl(['bootstrap', domain, plistPath]);
|
|
1576
|
+
runLaunchctl(['enable', target], { allowFailure: true });
|
|
1577
|
+
runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async function stopBridgeLaunchdService() {
|
|
1581
|
+
assertMacOSLaunchdAvailable();
|
|
1582
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
1583
|
+
const domain = launchctlDomain();
|
|
1584
|
+
runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
|
|
1585
|
+
return stopBridgeProcesses();
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1294
1588
|
async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
1295
1589
|
const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
|
|
1296
1590
|
const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
|
|
@@ -1469,6 +1763,188 @@ async function startOpenclawBridge(flags) {
|
|
|
1469
1763
|
);
|
|
1470
1764
|
};
|
|
1471
1765
|
|
|
1766
|
+
const sendGatewayAck = (brokerSocket, {
|
|
1767
|
+
sessionId,
|
|
1768
|
+
requestId = '',
|
|
1769
|
+
method = '',
|
|
1770
|
+
correlationId = '',
|
|
1771
|
+
stage = 'unknown',
|
|
1772
|
+
}) => {
|
|
1773
|
+
if (!sessionId) return;
|
|
1774
|
+
if (requestId) {
|
|
1775
|
+
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
1776
|
+
if (sessionBridge && sessionBridge.pendingRequests instanceof Map) {
|
|
1777
|
+
const pending = sessionBridge.pendingRequests.get(requestId);
|
|
1778
|
+
if (pending) {
|
|
1779
|
+
pending.lastSuccessfulHop = stage;
|
|
1780
|
+
sessionBridge.pendingRequests.set(requestId, pending);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
sendBrokerPayload(brokerSocket, {
|
|
1785
|
+
action: 'gateway_ack',
|
|
1786
|
+
type: 'gateway.ack',
|
|
1787
|
+
sessionId,
|
|
1788
|
+
requestId,
|
|
1789
|
+
method,
|
|
1790
|
+
correlationId,
|
|
1791
|
+
stage,
|
|
1792
|
+
ts: bridgeNowIso(),
|
|
1793
|
+
});
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
const sendGatewayErrorResponse = (
|
|
1797
|
+
brokerSocket,
|
|
1798
|
+
{
|
|
1799
|
+
sessionId,
|
|
1800
|
+
requestMeta,
|
|
1801
|
+
code = 'gateway_error',
|
|
1802
|
+
message = 'Gateway request failed',
|
|
1803
|
+
lastSuccessfulHop = '',
|
|
1804
|
+
retryable = false,
|
|
1805
|
+
details = null,
|
|
1806
|
+
}
|
|
1807
|
+
) => {
|
|
1808
|
+
if (!sessionId || !requestMeta || !requestMeta.requestId) return;
|
|
1809
|
+
const errorPayload = {
|
|
1810
|
+
code,
|
|
1811
|
+
message,
|
|
1812
|
+
correlationId: requestMeta.correlationId || '',
|
|
1813
|
+
};
|
|
1814
|
+
if (lastSuccessfulHop) {
|
|
1815
|
+
errorPayload.lastSuccessfulHop = lastSuccessfulHop;
|
|
1816
|
+
}
|
|
1817
|
+
if (retryable === true) {
|
|
1818
|
+
errorPayload.retryable = true;
|
|
1819
|
+
}
|
|
1820
|
+
if (details && typeof details === 'object') {
|
|
1821
|
+
errorPayload.details = details;
|
|
1822
|
+
}
|
|
1823
|
+
const responseFrame = {
|
|
1824
|
+
type: 'res',
|
|
1825
|
+
id: requestMeta.requestId,
|
|
1826
|
+
ok: false,
|
|
1827
|
+
error: errorPayload,
|
|
1828
|
+
};
|
|
1829
|
+
sendBrokerPayload(brokerSocket, {
|
|
1830
|
+
action: 'gateway_frame',
|
|
1831
|
+
type: 'gateway.frame',
|
|
1832
|
+
sessionId,
|
|
1833
|
+
frame: JSON.stringify(responseFrame),
|
|
1834
|
+
});
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
const classifyGatewayClose = (code, reasonText) => {
|
|
1838
|
+
const reasonLower = String(reasonText || '').toLowerCase();
|
|
1839
|
+
if (code === 1008 && reasonLower.includes('invalid connect params')) {
|
|
1840
|
+
return {
|
|
1841
|
+
errorCode: 'gateway_invalid_connect_params',
|
|
1842
|
+
retryable: false,
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
if (code === 1008) {
|
|
1846
|
+
return {
|
|
1847
|
+
errorCode: 'gateway_policy_violation',
|
|
1848
|
+
retryable: false,
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
if (code === 1003 || code === 1002) {
|
|
1852
|
+
return {
|
|
1853
|
+
errorCode: 'gateway_protocol_error',
|
|
1854
|
+
retryable: false,
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
if (code === 1006) {
|
|
1858
|
+
return {
|
|
1859
|
+
errorCode: 'gateway_abnormal_close',
|
|
1860
|
+
retryable: true,
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
return {
|
|
1864
|
+
errorCode: 'gateway_closed',
|
|
1865
|
+
retryable: true,
|
|
1866
|
+
};
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
const clearPendingRequestTimeout = (sessionBridge, requestId) => {
|
|
1870
|
+
if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
|
|
1871
|
+
const existingTimer = sessionBridge.pendingRequestTimers.get(requestId);
|
|
1872
|
+
if (existingTimer) {
|
|
1873
|
+
clearTimeout(existingTimer);
|
|
1874
|
+
sessionBridge.pendingRequestTimers.delete(requestId);
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
const clearAllPendingRequestTimeouts = (sessionBridge) => {
|
|
1879
|
+
if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
|
|
1880
|
+
for (const timer of sessionBridge.pendingRequestTimers.values()) {
|
|
1881
|
+
clearTimeout(timer);
|
|
1882
|
+
}
|
|
1883
|
+
sessionBridge.pendingRequestTimers.clear();
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const startPendingRequestTimeout = (brokerSocket, sessionId, sessionBridge, requestMeta) => {
|
|
1887
|
+
if (!sessionBridge || !requestMeta || !requestMeta.requestId) return;
|
|
1888
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
1889
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
1890
|
+
}
|
|
1891
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
1892
|
+
const timer = setTimeout(() => {
|
|
1893
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
1894
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
1895
|
+
: null;
|
|
1896
|
+
if (!pending) {
|
|
1897
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
if (requestMeta.method === 'connect') {
|
|
1902
|
+
incrementBridgeMetric('connect_timeout_count');
|
|
1903
|
+
} else if (requestMeta.method === 'chat.send') {
|
|
1904
|
+
incrementBridgeMetric('chat_send_timeout_count');
|
|
1905
|
+
} else {
|
|
1906
|
+
incrementBridgeMetric('gateway_request_timeout_count');
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const lastSuccessfulHop = typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
1910
|
+
? pending.lastSuccessfulHop
|
|
1911
|
+
: 'bridge.forwarded';
|
|
1912
|
+
sendGatewayAck(brokerSocket, {
|
|
1913
|
+
sessionId,
|
|
1914
|
+
requestId: pending.requestId,
|
|
1915
|
+
method: pending.method,
|
|
1916
|
+
correlationId: pending.correlationId,
|
|
1917
|
+
stage: 'gateway.timeout',
|
|
1918
|
+
});
|
|
1919
|
+
sendBrokerPayload(brokerSocket, {
|
|
1920
|
+
action: 'log',
|
|
1921
|
+
type: 'log',
|
|
1922
|
+
sessionId,
|
|
1923
|
+
level: 'warn',
|
|
1924
|
+
message: `Gateway request timeout (${pending.method} ${pending.requestId}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
|
|
1925
|
+
});
|
|
1926
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
1927
|
+
sessionId,
|
|
1928
|
+
requestMeta: pending,
|
|
1929
|
+
code: 'gateway_timeout',
|
|
1930
|
+
message: `Gateway request timeout (${pending.method}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
|
|
1931
|
+
lastSuccessfulHop,
|
|
1932
|
+
retryable: true,
|
|
1933
|
+
details: {
|
|
1934
|
+
method: pending.method,
|
|
1935
|
+
timeoutMs: BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
|
|
1936
|
+
},
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
1940
|
+
sessionBridge.pendingRequests.delete(pending.requestId);
|
|
1941
|
+
}
|
|
1942
|
+
clearPendingRequestTimeout(sessionBridge, pending.requestId);
|
|
1943
|
+
}, BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS);
|
|
1944
|
+
|
|
1945
|
+
sessionBridge.pendingRequestTimers.set(requestMeta.requestId, timer);
|
|
1946
|
+
};
|
|
1947
|
+
|
|
1472
1948
|
const parseBrokerEnvelope = (raw) => {
|
|
1473
1949
|
const payload = parseJsonPayload(raw);
|
|
1474
1950
|
if (!payload) return null;
|
|
@@ -1496,6 +1972,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1496
1972
|
const scheduleReconnect = () => {
|
|
1497
1973
|
if (reconnectState.stopped || reconnectState.timer) return;
|
|
1498
1974
|
reconnectState.attempt += 1;
|
|
1975
|
+
incrementBridgeMetric('bridge_reconnect_scheduled_count');
|
|
1499
1976
|
const failure =
|
|
1500
1977
|
reconnectState.lastFailure ||
|
|
1501
1978
|
classifyBridgeFailure({ reason: 'connection closed without classified error' });
|
|
@@ -1592,20 +2069,102 @@ async function startOpenclawBridge(flags) {
|
|
|
1592
2069
|
|
|
1593
2070
|
clearChallengeTimer(sessionBridge);
|
|
1594
2071
|
for (const pendingFrame of pending) {
|
|
2072
|
+
const requestMeta = extractGatewayRequestMeta(pendingFrame);
|
|
1595
2073
|
const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
|
|
1596
2074
|
connectNonce: sessionBridge.connectNonce,
|
|
1597
2075
|
deviceIdentity: gatewayDeviceIdentity,
|
|
1598
2076
|
});
|
|
1599
2077
|
if (!prepared.frameText || prepared.waitForChallenge) {
|
|
2078
|
+
if (requestMeta) {
|
|
2079
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2080
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2081
|
+
: null;
|
|
2082
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2083
|
+
? pending.lastSuccessfulHop
|
|
2084
|
+
: 'bridge.waiting_for_challenge';
|
|
2085
|
+
sendGatewayAck(brokerSocket, {
|
|
2086
|
+
sessionId,
|
|
2087
|
+
requestId: requestMeta.requestId,
|
|
2088
|
+
method: requestMeta.method,
|
|
2089
|
+
correlationId: requestMeta.correlationId,
|
|
2090
|
+
stage: 'bridge.dropped',
|
|
2091
|
+
});
|
|
2092
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2093
|
+
sessionId,
|
|
2094
|
+
requestMeta,
|
|
2095
|
+
code: 'bridge_dropped',
|
|
2096
|
+
message: 'Bridge dropped connect request after challenge handling.',
|
|
2097
|
+
lastSuccessfulHop,
|
|
2098
|
+
retryable: true,
|
|
2099
|
+
});
|
|
2100
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2101
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2102
|
+
}
|
|
2103
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2104
|
+
}
|
|
1600
2105
|
continue;
|
|
1601
2106
|
}
|
|
1602
2107
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1603
2108
|
if (result === 'queued') {
|
|
1604
2109
|
console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
|
|
2110
|
+
if (requestMeta) {
|
|
2111
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2112
|
+
}
|
|
2113
|
+
if (requestMeta) {
|
|
2114
|
+
sendGatewayAck(brokerSocket, {
|
|
2115
|
+
sessionId,
|
|
2116
|
+
requestId: requestMeta.requestId,
|
|
2117
|
+
method: requestMeta.method,
|
|
2118
|
+
correlationId: requestMeta.correlationId,
|
|
2119
|
+
stage: 'bridge.queued',
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
1605
2122
|
} else if (result === 'dropped') {
|
|
1606
2123
|
console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
|
|
2124
|
+
incrementBridgeMetric('bridge_drop_count');
|
|
2125
|
+
if (requestMeta) {
|
|
2126
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2127
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2128
|
+
: null;
|
|
2129
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2130
|
+
? pending.lastSuccessfulHop
|
|
2131
|
+
: 'bridge.waiting_for_challenge';
|
|
2132
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2133
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2134
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2135
|
+
}
|
|
2136
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2137
|
+
sessionId,
|
|
2138
|
+
requestMeta,
|
|
2139
|
+
code: 'bridge_dropped',
|
|
2140
|
+
message: 'Bridge dropped request because gateway socket is not open.',
|
|
2141
|
+
lastSuccessfulHop,
|
|
2142
|
+
retryable: true,
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
if (requestMeta) {
|
|
2146
|
+
sendGatewayAck(brokerSocket, {
|
|
2147
|
+
sessionId,
|
|
2148
|
+
requestId: requestMeta.requestId,
|
|
2149
|
+
method: requestMeta.method,
|
|
2150
|
+
correlationId: requestMeta.correlationId,
|
|
2151
|
+
stage: 'bridge.dropped',
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
1607
2154
|
} else {
|
|
1608
2155
|
console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
|
|
2156
|
+
if (requestMeta) {
|
|
2157
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2158
|
+
}
|
|
2159
|
+
if (requestMeta) {
|
|
2160
|
+
sendGatewayAck(brokerSocket, {
|
|
2161
|
+
sessionId,
|
|
2162
|
+
requestId: requestMeta.requestId,
|
|
2163
|
+
method: requestMeta.method,
|
|
2164
|
+
correlationId: requestMeta.correlationId,
|
|
2165
|
+
stage: 'bridge.forwarded',
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
1609
2168
|
}
|
|
1610
2169
|
}
|
|
1611
2170
|
};
|
|
@@ -1619,11 +2178,21 @@ async function startOpenclawBridge(flags) {
|
|
|
1619
2178
|
if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
|
|
1620
2179
|
sessionBridge.pendingConnectFrames = [];
|
|
1621
2180
|
}
|
|
2181
|
+
if (!(sessionBridge.pendingRequests instanceof Map)) {
|
|
2182
|
+
sessionBridge.pendingRequests = new Map();
|
|
2183
|
+
}
|
|
2184
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
2185
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
2186
|
+
}
|
|
2187
|
+
if (typeof sessionBridge.lastChatCorrelationId !== 'string') {
|
|
2188
|
+
sessionBridge.lastChatCorrelationId = '';
|
|
2189
|
+
}
|
|
1622
2190
|
let connectTimeout = setTimeout(() => {
|
|
1623
2191
|
if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
|
|
1624
2192
|
console.error(
|
|
1625
2193
|
`[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
|
|
1626
2194
|
);
|
|
2195
|
+
incrementBridgeMetric('gateway_connect_timeout_count');
|
|
1627
2196
|
sendBrokerPayload(brokerSocket, {
|
|
1628
2197
|
action: 'log',
|
|
1629
2198
|
type: 'log',
|
|
@@ -1651,6 +2220,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1651
2220
|
const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
|
|
1652
2221
|
const gatewayPayload = parseJsonPayload(frame);
|
|
1653
2222
|
if (gatewayPayload?.event === 'connect.challenge') {
|
|
2223
|
+
console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
|
|
1654
2224
|
const nonce =
|
|
1655
2225
|
gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
|
|
1656
2226
|
? gatewayPayload.payload.nonce.trim()
|
|
@@ -1674,6 +2244,35 @@ async function startOpenclawBridge(flags) {
|
|
|
1674
2244
|
flushPendingConnectFrames(sessionId, sessionBridge);
|
|
1675
2245
|
}
|
|
1676
2246
|
}
|
|
2247
|
+
|
|
2248
|
+
const responseMeta = extractGatewayResponseMeta(frame);
|
|
2249
|
+
if (responseMeta && sessionBridge.pendingRequests instanceof Map) {
|
|
2250
|
+
const requestMeta = sessionBridge.pendingRequests.get(responseMeta.requestId);
|
|
2251
|
+
if (requestMeta) {
|
|
2252
|
+
clearPendingRequestTimeout(sessionBridge, responseMeta.requestId);
|
|
2253
|
+
sendGatewayAck(brokerSocket, {
|
|
2254
|
+
sessionId,
|
|
2255
|
+
requestId: requestMeta.requestId,
|
|
2256
|
+
method: requestMeta.method,
|
|
2257
|
+
correlationId: requestMeta.correlationId,
|
|
2258
|
+
stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
|
|
2259
|
+
});
|
|
2260
|
+
if (!responseMeta.ok) {
|
|
2261
|
+
incrementBridgeMetric('gateway_rejected_count');
|
|
2262
|
+
}
|
|
2263
|
+
sessionBridge.pendingRequests.delete(responseMeta.requestId);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
if (isGatewayRunStartedFrame(frame)) {
|
|
2268
|
+
sendGatewayAck(brokerSocket, {
|
|
2269
|
+
sessionId,
|
|
2270
|
+
method: 'chat.send',
|
|
2271
|
+
correlationId: sessionBridge.lastChatCorrelationId || '',
|
|
2272
|
+
stage: 'run.started',
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1677
2276
|
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
|
|
1678
2277
|
});
|
|
1679
2278
|
|
|
@@ -1684,9 +2283,41 @@ async function startOpenclawBridge(flags) {
|
|
|
1684
2283
|
}
|
|
1685
2284
|
clearChallengeTimer(sessionBridge);
|
|
1686
2285
|
const reasonText = reason ? reason.toString() : '';
|
|
2286
|
+
const closeMeta = classifyGatewayClose(code, reasonText);
|
|
1687
2287
|
console.log(
|
|
1688
2288
|
`[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
|
|
1689
2289
|
);
|
|
2290
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2291
|
+
for (const requestMeta of sessionBridge.pendingRequests.values()) {
|
|
2292
|
+
if (!requestMeta || typeof requestMeta !== 'object') continue;
|
|
2293
|
+
const lastSuccessfulHop = typeof requestMeta.lastSuccessfulHop === 'string' && requestMeta.lastSuccessfulHop
|
|
2294
|
+
? requestMeta.lastSuccessfulHop
|
|
2295
|
+
: 'bridge.forwarded';
|
|
2296
|
+
sendGatewayAck(brokerSocket, {
|
|
2297
|
+
sessionId,
|
|
2298
|
+
requestId: requestMeta.requestId || '',
|
|
2299
|
+
method: requestMeta.method || '',
|
|
2300
|
+
correlationId: requestMeta.correlationId || '',
|
|
2301
|
+
stage: 'gateway.closed',
|
|
2302
|
+
});
|
|
2303
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2304
|
+
sessionId,
|
|
2305
|
+
requestMeta,
|
|
2306
|
+
code: closeMeta.errorCode,
|
|
2307
|
+
message: reasonText
|
|
2308
|
+
? `Gateway closed (${String(code)}): ${reasonText}`
|
|
2309
|
+
: `Gateway closed (${String(code)})`,
|
|
2310
|
+
lastSuccessfulHop,
|
|
2311
|
+
retryable: closeMeta.retryable,
|
|
2312
|
+
details: {
|
|
2313
|
+
closeCode: code,
|
|
2314
|
+
closeReason: reasonText,
|
|
2315
|
+
},
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
sessionBridge.pendingRequests.clear();
|
|
2319
|
+
}
|
|
2320
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1690
2321
|
activeGatewaySockets.delete(sessionId);
|
|
1691
2322
|
sendBrokerPayload(brokerSocket, {
|
|
1692
2323
|
action: 'gateway_closed',
|
|
@@ -1843,6 +2474,22 @@ async function startOpenclawBridge(flags) {
|
|
|
1843
2474
|
console.log(`[bridge] client.frame ${sessionId}`);
|
|
1844
2475
|
const sessionBridge = getOrCreateGatewaySession(sessionId);
|
|
1845
2476
|
if (!sessionBridge) return;
|
|
2477
|
+
const requestMeta = extractGatewayRequestMeta(frame);
|
|
2478
|
+
if (requestMeta) {
|
|
2479
|
+
if (!(sessionBridge.pendingRequests instanceof Map)) {
|
|
2480
|
+
sessionBridge.pendingRequests = new Map();
|
|
2481
|
+
}
|
|
2482
|
+
if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
|
|
2483
|
+
sessionBridge.pendingRequestTimers = new Map();
|
|
2484
|
+
}
|
|
2485
|
+
sessionBridge.pendingRequests.set(requestMeta.requestId, {
|
|
2486
|
+
...requestMeta,
|
|
2487
|
+
lastSuccessfulHop: 'broker.accepted',
|
|
2488
|
+
});
|
|
2489
|
+
if (requestMeta.method === 'chat.send' && requestMeta.correlationId) {
|
|
2490
|
+
sessionBridge.lastChatCorrelationId = requestMeta.correlationId;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
1846
2493
|
const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
|
|
1847
2494
|
connectNonce: sessionBridge.connectNonce,
|
|
1848
2495
|
deviceIdentity: gatewayDeviceIdentity,
|
|
@@ -1850,16 +2497,103 @@ async function startOpenclawBridge(flags) {
|
|
|
1850
2497
|
if (prepared.waitForChallenge) {
|
|
1851
2498
|
queueConnectUntilChallenge(sessionId, sessionBridge, frame);
|
|
1852
2499
|
console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
|
|
2500
|
+
if (requestMeta) {
|
|
2501
|
+
sendGatewayAck(brokerSocket, {
|
|
2502
|
+
sessionId,
|
|
2503
|
+
requestId: requestMeta.requestId,
|
|
2504
|
+
method: requestMeta.method,
|
|
2505
|
+
correlationId: requestMeta.correlationId,
|
|
2506
|
+
stage: 'bridge.waiting_for_challenge',
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
1853
2509
|
return;
|
|
1854
2510
|
}
|
|
1855
2511
|
if (!prepared.frameText) {
|
|
2512
|
+
if (requestMeta) {
|
|
2513
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2514
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2515
|
+
: null;
|
|
2516
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2517
|
+
? pending.lastSuccessfulHop
|
|
2518
|
+
: 'broker.accepted';
|
|
2519
|
+
sendGatewayAck(brokerSocket, {
|
|
2520
|
+
sessionId,
|
|
2521
|
+
requestId: requestMeta.requestId,
|
|
2522
|
+
method: requestMeta.method,
|
|
2523
|
+
correlationId: requestMeta.correlationId,
|
|
2524
|
+
stage: 'bridge.dropped',
|
|
2525
|
+
});
|
|
2526
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2527
|
+
sessionId,
|
|
2528
|
+
requestMeta,
|
|
2529
|
+
code: 'bridge_dropped',
|
|
2530
|
+
message: 'Bridge dropped request before forwarding to gateway.',
|
|
2531
|
+
lastSuccessfulHop,
|
|
2532
|
+
retryable: true,
|
|
2533
|
+
});
|
|
2534
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2535
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2536
|
+
}
|
|
2537
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2538
|
+
}
|
|
1856
2539
|
return;
|
|
1857
2540
|
}
|
|
1858
2541
|
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1859
2542
|
if (result === 'queued') {
|
|
1860
2543
|
console.log(`[bridge] client.frame queued ${sessionId}`);
|
|
2544
|
+
if (requestMeta) {
|
|
2545
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2546
|
+
}
|
|
2547
|
+
if (requestMeta) {
|
|
2548
|
+
sendGatewayAck(brokerSocket, {
|
|
2549
|
+
sessionId,
|
|
2550
|
+
requestId: requestMeta.requestId,
|
|
2551
|
+
method: requestMeta.method,
|
|
2552
|
+
correlationId: requestMeta.correlationId,
|
|
2553
|
+
stage: 'bridge.queued',
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
1861
2556
|
} else if (result === 'dropped') {
|
|
1862
2557
|
console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
|
|
2558
|
+
incrementBridgeMetric('bridge_drop_count');
|
|
2559
|
+
if (requestMeta) {
|
|
2560
|
+
const pending = sessionBridge.pendingRequests instanceof Map
|
|
2561
|
+
? sessionBridge.pendingRequests.get(requestMeta.requestId)
|
|
2562
|
+
: null;
|
|
2563
|
+
const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
|
|
2564
|
+
? pending.lastSuccessfulHop
|
|
2565
|
+
: 'broker.accepted';
|
|
2566
|
+
clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
|
|
2567
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2568
|
+
sessionBridge.pendingRequests.delete(requestMeta.requestId);
|
|
2569
|
+
}
|
|
2570
|
+
sendGatewayErrorResponse(brokerSocket, {
|
|
2571
|
+
sessionId,
|
|
2572
|
+
requestMeta,
|
|
2573
|
+
code: 'bridge_dropped',
|
|
2574
|
+
message: 'Bridge dropped request because gateway socket is not open.',
|
|
2575
|
+
lastSuccessfulHop,
|
|
2576
|
+
retryable: true,
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
if (requestMeta) {
|
|
2580
|
+
sendGatewayAck(brokerSocket, {
|
|
2581
|
+
sessionId,
|
|
2582
|
+
requestId: requestMeta.requestId,
|
|
2583
|
+
method: requestMeta.method,
|
|
2584
|
+
correlationId: requestMeta.correlationId,
|
|
2585
|
+
stage: 'bridge.dropped',
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
} else if (requestMeta) {
|
|
2589
|
+
startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
|
|
2590
|
+
sendGatewayAck(brokerSocket, {
|
|
2591
|
+
sessionId,
|
|
2592
|
+
requestId: requestMeta.requestId,
|
|
2593
|
+
method: requestMeta.method,
|
|
2594
|
+
correlationId: requestMeta.correlationId,
|
|
2595
|
+
stage: 'bridge.forwarded',
|
|
2596
|
+
});
|
|
1863
2597
|
}
|
|
1864
2598
|
return;
|
|
1865
2599
|
}
|
|
@@ -1870,6 +2604,10 @@ async function startOpenclawBridge(flags) {
|
|
|
1870
2604
|
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
1871
2605
|
if (sessionBridge && sessionBridge.socket) {
|
|
1872
2606
|
clearChallengeTimer(sessionBridge);
|
|
2607
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2608
|
+
sessionBridge.pendingRequests.clear();
|
|
2609
|
+
}
|
|
2610
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1873
2611
|
activeGatewaySockets.delete(sessionId);
|
|
1874
2612
|
sessionBridge.socket.close(1000, 'client_closed');
|
|
1875
2613
|
}
|
|
@@ -1884,8 +2622,13 @@ async function startOpenclawBridge(flags) {
|
|
|
1884
2622
|
}
|
|
1885
2623
|
const reasonText = reason ? reason.toString() : '';
|
|
1886
2624
|
console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
|
|
2625
|
+
incrementBridgeMetric('bridge_disconnect_count');
|
|
1887
2626
|
for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
|
|
1888
2627
|
clearChallengeTimer(sessionBridge);
|
|
2628
|
+
if (sessionBridge.pendingRequests instanceof Map) {
|
|
2629
|
+
sessionBridge.pendingRequests.clear();
|
|
2630
|
+
}
|
|
2631
|
+
clearAllPendingRequestTimeouts(sessionBridge);
|
|
1889
2632
|
activeGatewaySockets.delete(sessionId);
|
|
1890
2633
|
try {
|
|
1891
2634
|
sessionBridge.socket.close(1001, 'broker_disconnected');
|
|
@@ -1907,6 +2650,7 @@ async function startOpenclawBridge(flags) {
|
|
|
1907
2650
|
});
|
|
1908
2651
|
|
|
1909
2652
|
brokerSocket.on('error', (err) => {
|
|
2653
|
+
incrementBridgeMetric('bridge_socket_error_count');
|
|
1910
2654
|
reconnectState.lastFailure = classifyBridgeFailure({ err });
|
|
1911
2655
|
console.error(
|
|
1912
2656
|
`[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
|
|
@@ -2160,6 +2904,16 @@ function printOpenclawBridgeStatus(flags) {
|
|
|
2160
2904
|
if (payload.runtime.hint) {
|
|
2161
2905
|
console.log(`Hint: ${payload.runtime.hint}`);
|
|
2162
2906
|
}
|
|
2907
|
+
if (payload.runtime.metrics && typeof payload.runtime.metrics === 'object') {
|
|
2908
|
+
const metrics = normalizeBridgeMetrics(payload.runtime.metrics);
|
|
2909
|
+
const metricPairs = Object.entries(metrics);
|
|
2910
|
+
if (metricPairs.length > 0) {
|
|
2911
|
+
console.log('Metrics:');
|
|
2912
|
+
for (const [name, value] of metricPairs) {
|
|
2913
|
+
console.log(` ${name}: ${value}`);
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2163
2917
|
return;
|
|
2164
2918
|
}
|
|
2165
2919
|
|
|
@@ -2234,27 +2988,109 @@ function printOpenclawPluginSetup(flags) {
|
|
|
2234
2988
|
}
|
|
2235
2989
|
}
|
|
2236
2990
|
|
|
2991
|
+
async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
|
|
2992
|
+
assertMacOSLaunchdAvailable();
|
|
2993
|
+
const action = String(actionRaw || 'status').trim().toLowerCase();
|
|
2994
|
+
const plistPath = resolveBridgeLaunchAgentPlistPath();
|
|
2995
|
+
|
|
2996
|
+
if (action === 'install') {
|
|
2997
|
+
ensureDir(path.dirname(plistPath));
|
|
2998
|
+
writeFile(plistPath, buildBridgeLaunchAgentPlist());
|
|
2999
|
+
console.log(`Installed bridge launchd plist: ${plistPath}`);
|
|
3000
|
+
if (isTruthyFlag(flags['no-start'])) {
|
|
3001
|
+
console.log('Service install complete. Start with: oomi openclaw bridge service start');
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
startBridgeLaunchdService();
|
|
3005
|
+
incrementBridgeMetric('bridge_start_count');
|
|
3006
|
+
console.log(`Bridge service started: ${launchctlServiceTarget()}`);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
if (action === 'uninstall') {
|
|
3011
|
+
await stopBridgeLaunchdService();
|
|
3012
|
+
if (fs.existsSync(plistPath)) {
|
|
3013
|
+
fs.unlinkSync(plistPath);
|
|
3014
|
+
}
|
|
3015
|
+
console.log(`Removed bridge launchd plist: ${plistPath}`);
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
if (action === 'start') {
|
|
3020
|
+
startBridgeLaunchdService();
|
|
3021
|
+
incrementBridgeMetric('bridge_start_count');
|
|
3022
|
+
console.log(`Bridge service started: ${launchctlServiceTarget()}`);
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
if (action === 'stop') {
|
|
3027
|
+
const stopped = await stopBridgeLaunchdService();
|
|
3028
|
+
if (Array.isArray(stopped.found) && stopped.found.length > 0) {
|
|
3029
|
+
console.log(`Stopped bridge workers: ${stopped.stopped.join(', ') || 'none'}.`);
|
|
3030
|
+
} else {
|
|
3031
|
+
console.log('No bridge workers running.');
|
|
3032
|
+
}
|
|
3033
|
+
console.log(`Bridge service stopped: ${launchctlServiceTarget()}`);
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
if (action === 'restart') {
|
|
3038
|
+
await stopBridgeLaunchdService();
|
|
3039
|
+
startBridgeLaunchdService();
|
|
3040
|
+
incrementBridgeMetric('bridge_restart_count');
|
|
3041
|
+
console.log(`Bridge service restarted: ${launchctlServiceTarget()}`);
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
if (action === 'status') {
|
|
3046
|
+
const status = readBridgeLaunchdStatus();
|
|
3047
|
+
console.log('Bridge Service Status');
|
|
3048
|
+
console.log('---------------------');
|
|
3049
|
+
console.log(`Label: ${BRIDGE_LAUNCHD_LABEL}`);
|
|
3050
|
+
console.log(`Target: ${status.target}`);
|
|
3051
|
+
console.log(`Plist: ${status.plistPath}`);
|
|
3052
|
+
console.log(`Installed: ${status.installed ? 'yes' : 'no'}`);
|
|
3053
|
+
console.log(`Loaded: ${status.loaded ? 'yes' : 'no'}`);
|
|
3054
|
+
console.log(`Running: ${status.running ? 'yes' : 'no'}`);
|
|
3055
|
+
if (status.pid) {
|
|
3056
|
+
console.log(`PID: ${status.pid}`);
|
|
3057
|
+
}
|
|
3058
|
+
if (status.lastExitCode !== null) {
|
|
3059
|
+
console.log(`Last exit code: ${status.lastExitCode}`);
|
|
3060
|
+
}
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
throw new Error(
|
|
3065
|
+
`Unknown bridge service action: ${action}. Use: oomi openclaw bridge service [install|start|stop|restart|status|uninstall]`
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2237
3069
|
async function startBridgeLifecycle(flags = {}) {
|
|
2238
3070
|
if (Boolean(flags.detach)) {
|
|
2239
3071
|
const detachedFlags = { ...flags };
|
|
2240
3072
|
delete detachedFlags.detach;
|
|
2241
3073
|
const result = startBridgeDetachedProcess(detachedFlags);
|
|
2242
3074
|
if (result.alreadyRunning) {
|
|
3075
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
2243
3076
|
console.log(`Bridge already running (pid: ${result.pid}).`);
|
|
2244
3077
|
return;
|
|
2245
3078
|
}
|
|
3079
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2246
3080
|
console.log(`Bridge started in background (pid: ${result.pid}).`);
|
|
2247
3081
|
return;
|
|
2248
3082
|
}
|
|
2249
3083
|
|
|
2250
3084
|
const running = findRunningBridgeProcess();
|
|
2251
3085
|
if (running) {
|
|
3086
|
+
incrementBridgeMetric('duplicate_start_attempt_count');
|
|
2252
3087
|
console.log(
|
|
2253
3088
|
`Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
|
|
2254
3089
|
);
|
|
2255
3090
|
return;
|
|
2256
3091
|
}
|
|
2257
3092
|
|
|
3093
|
+
incrementBridgeMetric('bridge_start_count');
|
|
2258
3094
|
await startOpenclawBridge(flags);
|
|
2259
3095
|
}
|
|
2260
3096
|
|
|
@@ -2293,6 +3129,7 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
|
|
|
2293
3129
|
}
|
|
2294
3130
|
|
|
2295
3131
|
if (action === 'restart') {
|
|
3132
|
+
incrementBridgeMetric('bridge_restart_count');
|
|
2296
3133
|
const result = await stopBridgeProcesses();
|
|
2297
3134
|
if (result.found.length > 0) {
|
|
2298
3135
|
console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
|
|
@@ -2346,7 +3183,12 @@ async function main() {
|
|
|
2346
3183
|
}
|
|
2347
3184
|
|
|
2348
3185
|
if (command === 'openclaw' && subcommand === 'bridge') {
|
|
2349
|
-
const bridgeAction = args.positionals[0] || 'start';
|
|
3186
|
+
const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
|
|
3187
|
+
if (bridgeAction === 'service') {
|
|
3188
|
+
const serviceAction = args.positionals[1] || 'status';
|
|
3189
|
+
await handleBridgeServiceCommand(serviceAction, args.flags);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
2350
3192
|
await handleBridgeLifecycleCommand(args.flags, bridgeAction);
|
|
2351
3193
|
return;
|
|
2352
3194
|
}
|
|
@@ -2390,7 +3232,24 @@ async function main() {
|
|
|
2390
3232
|
process.exit(1);
|
|
2391
3233
|
}
|
|
2392
3234
|
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
3235
|
+
const __currentFilePath = fileURLToPath(import.meta.url);
|
|
3236
|
+
const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
3237
|
+
const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
|
|
3238
|
+
|
|
3239
|
+
if (__isDirectExecution) {
|
|
3240
|
+
main().catch((err) => {
|
|
3241
|
+
console.error(err instanceof Error ? err.message : err);
|
|
3242
|
+
process.exit(1);
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
export {
|
|
3247
|
+
prepareGatewayFrameForLocalGateway,
|
|
3248
|
+
classifyBridgeFailure,
|
|
3249
|
+
computeReconnectDelayMs,
|
|
3250
|
+
extractGatewayRequestMeta,
|
|
3251
|
+
extractGatewayResponseMeta,
|
|
3252
|
+
isGatewayRunStartedFrame,
|
|
3253
|
+
isBridgeWorkerCommand,
|
|
3254
|
+
parsePositiveInteger,
|
|
3255
|
+
};
|
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.13",
|
|
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`
|