oomi-ai 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/oomi-ai.js +833 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ Agent-intent mapping (recommended):
|
|
|
72
72
|
Important distinction:
|
|
73
73
|
- `pairCode` is one-time and used internally by the pair/bootstrap flow.
|
|
74
74
|
- Invite auth links are the required user flow.
|
|
75
|
+
- Managed chat connect now uses OpenClaw challenge auth (`connect.challenge` nonce + signed device payload) in the local bridge path.
|
|
75
76
|
|
|
76
77
|
Sync personas from the repo into the backend registry:
|
|
77
78
|
```
|
|
@@ -109,6 +110,8 @@ Restart OpenClaw after running `oomi init` or `oomi openclaw install`.
|
|
|
109
110
|
- `OOMI_SKIP_UPDATE_CHECK=1` disables checks
|
|
110
111
|
- `OOMI_UPDATE_CHECK_INTERVAL_MS=<ms>` changes check interval
|
|
111
112
|
- `OOMI_UPDATE_CHECK_TIMEOUT_MS=<ms>` changes network timeout
|
|
113
|
+
- `OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS=<ms>` changes local gateway socket connect timeout
|
|
114
|
+
- `OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS=<ms>` changes wait timeout for gateway `connect.challenge` nonce
|
|
112
115
|
|
|
113
116
|
## Package Audit + Publish (pnpm)
|
|
114
117
|
```
|
package/bin/oomi-ai.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from 'fs';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
|
-
import { randomUUID } from 'crypto';
|
|
6
|
+
import { createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign } from 'crypto';
|
|
7
|
+
import net from 'net';
|
|
8
|
+
import { lookup as dnsLookup } from 'dns/promises';
|
|
7
9
|
import WebSocket from 'ws';
|
|
8
10
|
import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
|
|
9
11
|
|
|
@@ -14,6 +16,18 @@ const PACKAGE_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname
|
|
|
14
16
|
const UPDATE_STATE_FILE = path.join(os.homedir(), '.openclaw', 'oomi-ai-update-check.json');
|
|
15
17
|
const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
16
18
|
const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
|
|
19
|
+
const BRIDGE_RECONNECT_BASE_MS = 2000;
|
|
20
|
+
const BRIDGE_RECONNECT_MAX_MS = 60000;
|
|
21
|
+
const BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS = parsePositiveInteger(
|
|
22
|
+
process.env.OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS,
|
|
23
|
+
10000
|
|
24
|
+
);
|
|
25
|
+
const BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS = parsePositiveInteger(
|
|
26
|
+
process.env.OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS,
|
|
27
|
+
3000
|
|
28
|
+
);
|
|
29
|
+
const DEVICE_IDENTITY_PATH = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
|
|
30
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
17
31
|
|
|
18
32
|
function parsePositiveInteger(value, fallback) {
|
|
19
33
|
const num = Number(value);
|
|
@@ -149,6 +163,9 @@ Commands:
|
|
|
149
163
|
openclaw plugin
|
|
150
164
|
Print OpenClaw extension install/config guidance for Oomi channel plugin.
|
|
151
165
|
|
|
166
|
+
openclaw status
|
|
167
|
+
Show bridge state + runtime health from local status files.
|
|
168
|
+
|
|
152
169
|
personas sync
|
|
153
170
|
Sync personas from the repo into the Oomi backend registry.
|
|
154
171
|
|
|
@@ -162,7 +179,7 @@ Common flags:
|
|
|
162
179
|
--broker-http URL Managed broker HTTPS URL (for pair claim)
|
|
163
180
|
--broker-ws URL Managed broker device WS URL (wss://.../cable)
|
|
164
181
|
--pair-code CODE One-time pairing code from Oomi
|
|
165
|
-
--app-url URL Oomi app URL used for pairing APIs
|
|
182
|
+
--app-url URL Oomi app URL used for pairing APIs; bridge can also refresh managed broker URLs from it
|
|
166
183
|
--label TEXT Pairing label shown in broker logs
|
|
167
184
|
--session-key KEY Session key used in generated connect URL
|
|
168
185
|
--detach Start bridge in background and exit
|
|
@@ -537,6 +554,10 @@ function resolveBridgeStatePath() {
|
|
|
537
554
|
return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
|
|
538
555
|
}
|
|
539
556
|
|
|
557
|
+
function resolveBridgeStatusPath() {
|
|
558
|
+
return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
|
|
559
|
+
}
|
|
560
|
+
|
|
540
561
|
function defaultDeviceId() {
|
|
541
562
|
const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
|
|
542
563
|
return `oomi-${host}-${randomUUID().slice(0, 8)}`;
|
|
@@ -566,6 +587,27 @@ function writeBridgeState(nextState) {
|
|
|
566
587
|
writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
|
|
567
588
|
}
|
|
568
589
|
|
|
590
|
+
function readBridgeStatus() {
|
|
591
|
+
return readJsonSafe(resolveBridgeStatusPath()) || {};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function writeBridgeStatus(nextStatus) {
|
|
595
|
+
const statusPath = resolveBridgeStatusPath();
|
|
596
|
+
ensureDir(path.dirname(statusPath));
|
|
597
|
+
writeFile(statusPath, JSON.stringify(nextStatus, null, 2) + '\n');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function updateBridgeStatus(partial) {
|
|
601
|
+
const current = readBridgeStatus();
|
|
602
|
+
const next = {
|
|
603
|
+
...current,
|
|
604
|
+
...partial,
|
|
605
|
+
updatedAt: new Date().toISOString(),
|
|
606
|
+
};
|
|
607
|
+
writeBridgeStatus(next);
|
|
608
|
+
return next;
|
|
609
|
+
}
|
|
610
|
+
|
|
569
611
|
async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
|
|
570
612
|
const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
|
|
571
613
|
method: 'POST',
|
|
@@ -634,13 +676,109 @@ async function fetchManagedGatewayConfig({ appUrl }) {
|
|
|
634
676
|
return payload;
|
|
635
677
|
}
|
|
636
678
|
|
|
637
|
-
function
|
|
679
|
+
function base64UrlEncode(value) {
|
|
680
|
+
return Buffer.from(value)
|
|
681
|
+
.toString('base64')
|
|
682
|
+
.replaceAll('+', '-')
|
|
683
|
+
.replaceAll('/', '_')
|
|
684
|
+
.replace(/=+$/g, '');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function normalizeDeviceMetadataForAuth(value) {
|
|
688
|
+
if (typeof value !== 'string') return '';
|
|
689
|
+
const trimmed = value.trim();
|
|
690
|
+
if (!trimmed) return '';
|
|
691
|
+
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function publicKeyRawBase64UrlFromPem(publicKeyPem) {
|
|
695
|
+
const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
696
|
+
const raw =
|
|
697
|
+
der.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
698
|
+
der.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
699
|
+
? der.subarray(ED25519_SPKI_PREFIX.length)
|
|
700
|
+
: der;
|
|
701
|
+
return base64UrlEncode(raw);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function signDevicePayload(privateKeyPem, payload) {
|
|
705
|
+
const key = createPrivateKey(privateKeyPem);
|
|
706
|
+
return base64UrlEncode(cryptoSign(null, Buffer.from(payload, 'utf8'), key));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function buildDeviceAuthPayloadV3({
|
|
710
|
+
deviceId,
|
|
711
|
+
clientId,
|
|
712
|
+
clientMode,
|
|
713
|
+
role,
|
|
714
|
+
scopes,
|
|
715
|
+
signedAtMs,
|
|
716
|
+
token,
|
|
717
|
+
nonce,
|
|
718
|
+
platform,
|
|
719
|
+
deviceFamily,
|
|
720
|
+
}) {
|
|
721
|
+
return [
|
|
722
|
+
'v3',
|
|
723
|
+
deviceId,
|
|
724
|
+
clientId,
|
|
725
|
+
clientMode,
|
|
726
|
+
role,
|
|
727
|
+
scopes.join(','),
|
|
728
|
+
String(signedAtMs),
|
|
729
|
+
token || '',
|
|
730
|
+
nonce,
|
|
731
|
+
normalizeDeviceMetadataForAuth(platform),
|
|
732
|
+
normalizeDeviceMetadataForAuth(deviceFamily),
|
|
733
|
+
].join('|');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function loadGatewayDeviceIdentity() {
|
|
737
|
+
if (!fs.existsSync(DEVICE_IDENTITY_PATH)) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(readFile(DEVICE_IDENTITY_PATH));
|
|
742
|
+
if (
|
|
743
|
+
parsed &&
|
|
744
|
+
parsed.version === 1 &&
|
|
745
|
+
typeof parsed.deviceId === 'string' &&
|
|
746
|
+
typeof parsed.publicKeyPem === 'string' &&
|
|
747
|
+
typeof parsed.privateKeyPem === 'string'
|
|
748
|
+
) {
|
|
749
|
+
return {
|
|
750
|
+
deviceId: parsed.deviceId.trim(),
|
|
751
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
752
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
} catch {
|
|
756
|
+
// no-op
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}) {
|
|
762
|
+
const connectNonce = typeof options.connectNonce === 'string' ? options.connectNonce.trim() : '';
|
|
763
|
+
const deviceIdentity = options.deviceIdentity || null;
|
|
764
|
+
|
|
638
765
|
try {
|
|
639
766
|
const frame = JSON.parse(frameText);
|
|
640
767
|
if (frame?.type !== 'req' || frame?.method !== 'connect') {
|
|
641
|
-
return frameText;
|
|
768
|
+
return { frameText, waitForChallenge: false };
|
|
642
769
|
}
|
|
770
|
+
|
|
643
771
|
const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
|
|
772
|
+
|
|
773
|
+
const client = params.client && typeof params.client === 'object' ? params.client : {};
|
|
774
|
+
client.id = typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'webchat-ui';
|
|
775
|
+
client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
|
|
776
|
+
client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
|
|
777
|
+
client.mode = typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'webchat';
|
|
778
|
+
params.client = client;
|
|
779
|
+
|
|
780
|
+
params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
|
|
781
|
+
|
|
644
782
|
const existingScopes = Array.isArray(params.scopes)
|
|
645
783
|
? params.scopes.filter((value) => typeof value === 'string' && value.trim())
|
|
646
784
|
: [];
|
|
@@ -652,17 +790,63 @@ function injectGatewayAuth(frameText, gatewayAuth) {
|
|
|
652
790
|
}
|
|
653
791
|
params.scopes = existingScopes;
|
|
654
792
|
|
|
793
|
+
if (!Array.isArray(params.caps)) {
|
|
794
|
+
params.caps = [];
|
|
795
|
+
}
|
|
796
|
+
if (!Array.isArray(params.commands)) {
|
|
797
|
+
params.commands = [];
|
|
798
|
+
}
|
|
799
|
+
if (!params.permissions || typeof params.permissions !== 'object') {
|
|
800
|
+
params.permissions = {};
|
|
801
|
+
}
|
|
802
|
+
|
|
655
803
|
const auth = {};
|
|
656
|
-
if (gatewayAuth.token)
|
|
657
|
-
|
|
804
|
+
if (gatewayAuth.token) {
|
|
805
|
+
auth.token = gatewayAuth.token;
|
|
806
|
+
} else if (gatewayAuth.password) {
|
|
807
|
+
auth.password = gatewayAuth.password;
|
|
808
|
+
}
|
|
658
809
|
if (Object.keys(auth).length > 0) {
|
|
659
810
|
params.auth = auth;
|
|
660
|
-
frame.params = params;
|
|
661
|
-
return JSON.stringify(frame);
|
|
662
811
|
}
|
|
663
|
-
|
|
812
|
+
|
|
813
|
+
if (deviceIdentity) {
|
|
814
|
+
if (!connectNonce) {
|
|
815
|
+
return { frameText: null, waitForChallenge: true };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const signedAtMs = Date.now();
|
|
819
|
+
const tokenForSignature =
|
|
820
|
+
typeof auth.token === 'string' && auth.token.trim()
|
|
821
|
+
? auth.token.trim()
|
|
822
|
+
: (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
|
|
823
|
+
|
|
824
|
+
const payload = buildDeviceAuthPayloadV3({
|
|
825
|
+
deviceId: deviceIdentity.deviceId,
|
|
826
|
+
clientId: client.id,
|
|
827
|
+
clientMode: client.mode,
|
|
828
|
+
role: params.role,
|
|
829
|
+
scopes: existingScopes,
|
|
830
|
+
signedAtMs,
|
|
831
|
+
token: tokenForSignature,
|
|
832
|
+
nonce: connectNonce,
|
|
833
|
+
platform: client.platform,
|
|
834
|
+
deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
|
|
835
|
+
});
|
|
836
|
+
const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
|
|
837
|
+
params.device = {
|
|
838
|
+
id: deviceIdentity.deviceId,
|
|
839
|
+
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
|
|
840
|
+
signature,
|
|
841
|
+
signedAt: signedAtMs,
|
|
842
|
+
nonce: connectNonce,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
frame.params = params;
|
|
847
|
+
return { frameText: JSON.stringify(frame), waitForChallenge: false };
|
|
664
848
|
} catch {
|
|
665
|
-
return frameText;
|
|
849
|
+
return { frameText, waitForChallenge: false };
|
|
666
850
|
}
|
|
667
851
|
}
|
|
668
852
|
|
|
@@ -674,29 +858,219 @@ function parseJsonPayload(raw) {
|
|
|
674
858
|
}
|
|
675
859
|
}
|
|
676
860
|
|
|
861
|
+
function bridgeNowIso() {
|
|
862
|
+
return new Date().toISOString();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function extractErrorCode(err) {
|
|
866
|
+
if (!err || typeof err !== 'object') return '';
|
|
867
|
+
const value = err.code;
|
|
868
|
+
return typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function extractErrorMessage(err) {
|
|
872
|
+
if (!err) return '';
|
|
873
|
+
if (typeof err === 'string') return err.trim();
|
|
874
|
+
if (err instanceof Error) return err.message.trim();
|
|
875
|
+
return String(err).trim();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function classifyBridgeFailure({ err, reason = '', code = '', forceClass = '' } = {}) {
|
|
879
|
+
const errorCode = (code || extractErrorCode(err)).toUpperCase();
|
|
880
|
+
const message = [extractErrorMessage(err), String(reason || '').trim()].filter(Boolean).join(' | ');
|
|
881
|
+
const text = `${errorCode} ${message}`.toLowerCase();
|
|
882
|
+
|
|
883
|
+
let failureClass = forceClass || 'unknown';
|
|
884
|
+
let retryable = true;
|
|
885
|
+
let hint = 'Check broker URL, network path, and bridge token.';
|
|
886
|
+
let baseDelayMs = BRIDGE_RECONNECT_BASE_MS;
|
|
887
|
+
|
|
888
|
+
if (
|
|
889
|
+
errorCode === 'ENOTFOUND' ||
|
|
890
|
+
errorCode === 'EAI_AGAIN' ||
|
|
891
|
+
text.includes('enotfound') ||
|
|
892
|
+
text.includes('name resolution') ||
|
|
893
|
+
text.includes('dns')
|
|
894
|
+
) {
|
|
895
|
+
failureClass = 'dns_resolution';
|
|
896
|
+
hint = 'Host resolution failed. Verify DNS/network access to the broker host.';
|
|
897
|
+
baseDelayMs = 5000;
|
|
898
|
+
} else if (
|
|
899
|
+
text.includes('unauthorized') ||
|
|
900
|
+
text.includes('forbidden') ||
|
|
901
|
+
text.includes('invalid token') ||
|
|
902
|
+
text.includes('token expired') ||
|
|
903
|
+
text.includes('broker rejected')
|
|
904
|
+
) {
|
|
905
|
+
failureClass = 'auth_rejected';
|
|
906
|
+
retryable = false;
|
|
907
|
+
hint = 'Bridge token is invalid/expired. Re-run: oomi openclaw pair --app-url <url>.';
|
|
908
|
+
baseDelayMs = BRIDGE_RECONNECT_MAX_MS;
|
|
909
|
+
} else if (
|
|
910
|
+
errorCode === 'ECONNREFUSED' ||
|
|
911
|
+
errorCode === 'ENETUNREACH' ||
|
|
912
|
+
errorCode === 'EHOSTUNREACH' ||
|
|
913
|
+
errorCode === 'ETIMEDOUT' ||
|
|
914
|
+
text.includes('socket hang up') ||
|
|
915
|
+
text.includes('abnormal closure')
|
|
916
|
+
) {
|
|
917
|
+
failureClass = 'network';
|
|
918
|
+
hint = 'Broker network path is unavailable. Check connectivity/firewall/proxy settings.';
|
|
919
|
+
baseDelayMs = 3000;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
errorCode: errorCode || 'UNKNOWN',
|
|
924
|
+
message: message || 'unknown bridge error',
|
|
925
|
+
failureClass,
|
|
926
|
+
retryable,
|
|
927
|
+
hint,
|
|
928
|
+
baseDelayMs,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function computeReconnectDelayMs(attempt, baseDelayMs) {
|
|
933
|
+
const growth = Math.min(BRIDGE_RECONNECT_MAX_MS, Math.round(baseDelayMs * (2 ** Math.max(0, attempt - 1))));
|
|
934
|
+
const jitter = Math.floor(growth * (Math.random() * 0.25));
|
|
935
|
+
return Math.min(BRIDGE_RECONNECT_MAX_MS, growth + jitter);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function assertTcpReachable(urlValue, timeoutMs = 1500) {
|
|
939
|
+
const url = new URL(urlValue);
|
|
940
|
+
const host = url.hostname || '127.0.0.1';
|
|
941
|
+
const port = Number(url.port || (url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80));
|
|
942
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
943
|
+
throw new Error(`Invalid port in URL: ${urlValue}`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
await new Promise((resolve, reject) => {
|
|
947
|
+
const socket = net.connect({ host, port });
|
|
948
|
+
let finished = false;
|
|
949
|
+
const done = (fn) => (value) => {
|
|
950
|
+
if (finished) return;
|
|
951
|
+
finished = true;
|
|
952
|
+
clearTimeout(timer);
|
|
953
|
+
socket.destroy();
|
|
954
|
+
fn(value);
|
|
955
|
+
};
|
|
956
|
+
const timer = setTimeout(done(reject), timeoutMs, new Error(`TCP connect timeout (${host}:${port})`));
|
|
957
|
+
socket.once('connect', done(resolve));
|
|
958
|
+
socket.once('error', done(reject));
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
|
|
963
|
+
let brokerUrl;
|
|
964
|
+
try {
|
|
965
|
+
brokerUrl = new URL(brokerWs);
|
|
966
|
+
} catch {
|
|
967
|
+
throw new Error(`Invalid broker WS URL: ${brokerWs}`);
|
|
968
|
+
}
|
|
969
|
+
if (brokerUrl.protocol !== 'ws:' && brokerUrl.protocol !== 'wss:') {
|
|
970
|
+
throw new Error(`Broker WS URL must use ws:// or wss:// (received ${brokerUrl.protocol})`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const brokerHost = brokerUrl.hostname;
|
|
974
|
+
if (!brokerHost) {
|
|
975
|
+
throw new Error('Broker WS URL is missing hostname.');
|
|
976
|
+
}
|
|
977
|
+
await dnsLookup(brokerHost);
|
|
978
|
+
|
|
979
|
+
let parsedGatewayUrl;
|
|
980
|
+
try {
|
|
981
|
+
parsedGatewayUrl = new URL(gatewayUrl);
|
|
982
|
+
} catch {
|
|
983
|
+
throw new Error(`Invalid local gateway URL (${gatewayUrl}) from ${gatewayConfigPath}`);
|
|
984
|
+
}
|
|
985
|
+
if (parsedGatewayUrl.protocol !== 'ws:') {
|
|
986
|
+
throw new Error(`Local gateway URL must use ws:// (received ${parsedGatewayUrl.protocol})`);
|
|
987
|
+
}
|
|
988
|
+
await assertTcpReachable(parsedGatewayUrl.toString());
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function buildBridgeDetachArgs(rawFlags = {}) {
|
|
992
|
+
const orderedKeys = [
|
|
993
|
+
'broker-http',
|
|
994
|
+
'broker-ws',
|
|
995
|
+
'pair-code',
|
|
996
|
+
'app-url',
|
|
997
|
+
'device-id',
|
|
998
|
+
'device-token',
|
|
999
|
+
];
|
|
1000
|
+
const args = [process.argv[1], 'openclaw', 'bridge'];
|
|
1001
|
+
|
|
1002
|
+
for (const key of orderedKeys) {
|
|
1003
|
+
const value = rawFlags[key];
|
|
1004
|
+
if (value === undefined || value === null || value === false) continue;
|
|
1005
|
+
if (value === true) {
|
|
1006
|
+
args.push(`--${key}`);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const text = String(value).trim();
|
|
1010
|
+
if (!text) continue;
|
|
1011
|
+
args.push(`--${key}`, text);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return args;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function startBridgeDetachedProcess(rawFlags = {}) {
|
|
1018
|
+
const args = buildBridgeDetachArgs(rawFlags);
|
|
1019
|
+
const child = spawn(process.execPath, args, {
|
|
1020
|
+
detached: true,
|
|
1021
|
+
stdio: 'ignore',
|
|
1022
|
+
});
|
|
1023
|
+
child.unref();
|
|
1024
|
+
return child.pid;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
1028
|
+
const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
|
|
1029
|
+
const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
|
|
1030
|
+
const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
|
|
1031
|
+
|
|
1032
|
+
let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
|
|
1033
|
+
let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
|
|
1034
|
+
let managedConfigUsed = false;
|
|
1035
|
+
let managedConfigError = '';
|
|
1036
|
+
|
|
1037
|
+
if (appUrl && (!explicitBrokerHttp || !explicitBrokerWs)) {
|
|
1038
|
+
try {
|
|
1039
|
+
const managedConfig = await fetchManagedGatewayConfig({ appUrl });
|
|
1040
|
+
managedConfigUsed = true;
|
|
1041
|
+
if (!explicitBrokerHttp) {
|
|
1042
|
+
brokerHttp = String(managedConfig.brokerHttpUrl || '').trim();
|
|
1043
|
+
}
|
|
1044
|
+
if (!explicitBrokerWs) {
|
|
1045
|
+
brokerWs = String(managedConfig.brokerDeviceWsUrl || '').trim();
|
|
1046
|
+
}
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
managedConfigError = extractErrorMessage(err);
|
|
1049
|
+
if (!brokerWs) {
|
|
1050
|
+
throw err;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return {
|
|
1056
|
+
appUrl,
|
|
1057
|
+
brokerHttp,
|
|
1058
|
+
brokerWs,
|
|
1059
|
+
managedConfigUsed,
|
|
1060
|
+
managedConfigError,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
677
1064
|
async function startOpenclawBridge(flags) {
|
|
678
1065
|
const bridgeState = readBridgeState();
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
bridgeState.brokerHttp ||
|
|
683
|
-
''
|
|
684
|
-
).trim();
|
|
685
|
-
const brokerWs = String(
|
|
686
|
-
flags['broker-ws'] ||
|
|
687
|
-
process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL ||
|
|
688
|
-
bridgeState.brokerWs ||
|
|
689
|
-
''
|
|
690
|
-
).trim();
|
|
1066
|
+
const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
|
|
1067
|
+
const brokerHttp = runtimeConfig.brokerHttp;
|
|
1068
|
+
const brokerWs = runtimeConfig.brokerWs;
|
|
691
1069
|
const deviceId = resolveDeviceId(flags, bridgeState);
|
|
692
1070
|
const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
|
|
693
1071
|
const explicitDeviceToken = String(flags['device-token'] || '').trim();
|
|
694
|
-
const canReuseStateToken =
|
|
695
|
-
String(bridgeState.deviceId || '').trim() === deviceId &&
|
|
696
|
-
String(bridgeState.brokerWs || '').trim() === brokerWs &&
|
|
697
|
-
String(bridgeState.brokerHttp || '').trim() === brokerHttp;
|
|
698
1072
|
let deviceToken = explicitDeviceToken;
|
|
699
|
-
if (!deviceToken &&
|
|
1073
|
+
if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
|
|
700
1074
|
deviceToken = String(bridgeState.deviceToken || '').trim();
|
|
701
1075
|
}
|
|
702
1076
|
|
|
@@ -730,12 +1104,71 @@ async function startOpenclawBridge(flags) {
|
|
|
730
1104
|
if (!gateway.token && !gateway.password) {
|
|
731
1105
|
throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
|
|
732
1106
|
}
|
|
1107
|
+
const gatewayDeviceIdentity = loadGatewayDeviceIdentity();
|
|
1108
|
+
if (!gatewayDeviceIdentity) {
|
|
1109
|
+
console.warn(
|
|
1110
|
+
`[bridge] OpenClaw device identity not found at ${DEVICE_IDENTITY_PATH}; device-signed connect may fail on newer gateways.`
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
|
|
1115
|
+
console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
|
|
1116
|
+
} else if (runtimeConfig.managedConfigError) {
|
|
1117
|
+
console.warn(
|
|
1118
|
+
`[bridge] failed to refresh broker URLs from app URL; using local/state broker config (${runtimeConfig.managedConfigError})`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
await runBridgePreflight({
|
|
1124
|
+
brokerWs,
|
|
1125
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1126
|
+
gatewayConfigPath: gateway.configPath,
|
|
1127
|
+
});
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
const failure = classifyBridgeFailure({ err });
|
|
1130
|
+
updateBridgeStatus({
|
|
1131
|
+
status: 'error',
|
|
1132
|
+
deviceId,
|
|
1133
|
+
brokerWs,
|
|
1134
|
+
brokerHttp,
|
|
1135
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1136
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1137
|
+
lastErrorCode: failure.errorCode,
|
|
1138
|
+
lastErrorClass: failure.failureClass,
|
|
1139
|
+
lastErrorMessage: failure.message,
|
|
1140
|
+
hint: failure.hint,
|
|
1141
|
+
consecutiveFailures: 0,
|
|
1142
|
+
pid: process.pid,
|
|
1143
|
+
});
|
|
1144
|
+
throw new Error(`Bridge preflight failed (${failure.failureClass}): ${failure.message}. ${failure.hint}`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
updateBridgeStatus({
|
|
1148
|
+
status: 'starting',
|
|
1149
|
+
deviceId,
|
|
1150
|
+
brokerWs,
|
|
1151
|
+
brokerHttp,
|
|
1152
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1153
|
+
lastErrorCode: '',
|
|
1154
|
+
lastErrorClass: '',
|
|
1155
|
+
lastErrorMessage: '',
|
|
1156
|
+
consecutiveFailures: 0,
|
|
1157
|
+
pid: process.pid,
|
|
1158
|
+
startedAt: bridgeNowIso(),
|
|
1159
|
+
});
|
|
733
1160
|
|
|
734
1161
|
console.log(`Starting OpenClaw bridge: device=${deviceId}`);
|
|
735
1162
|
console.log(`Local gateway: ${gateway.gatewayUrl}`);
|
|
736
1163
|
console.log(`Broker WS: ${brokerWs}`);
|
|
737
1164
|
|
|
738
1165
|
const activeGatewaySockets = new Map();
|
|
1166
|
+
const reconnectState = {
|
|
1167
|
+
attempt: 0,
|
|
1168
|
+
timer: null,
|
|
1169
|
+
stopped: false,
|
|
1170
|
+
lastFailure: null,
|
|
1171
|
+
};
|
|
739
1172
|
const brokerPath = (() => {
|
|
740
1173
|
try {
|
|
741
1174
|
return new URL(brokerWs).pathname || '';
|
|
@@ -785,27 +1218,196 @@ async function startOpenclawBridge(flags) {
|
|
|
785
1218
|
return null;
|
|
786
1219
|
};
|
|
787
1220
|
|
|
1221
|
+
const scheduleReconnect = () => {
|
|
1222
|
+
if (reconnectState.stopped || reconnectState.timer) return;
|
|
1223
|
+
reconnectState.attempt += 1;
|
|
1224
|
+
const failure =
|
|
1225
|
+
reconnectState.lastFailure ||
|
|
1226
|
+
classifyBridgeFailure({ reason: 'connection closed without classified error' });
|
|
1227
|
+
const delayMs = computeReconnectDelayMs(reconnectState.attempt, failure.baseDelayMs);
|
|
1228
|
+
|
|
1229
|
+
console.warn(
|
|
1230
|
+
`[bridge] reconnect scheduled in ${delayMs}ms (attempt ${reconnectState.attempt}, class=${failure.failureClass}, code=${failure.errorCode})`
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
updateBridgeStatus({
|
|
1234
|
+
status: 'reconnecting',
|
|
1235
|
+
deviceId,
|
|
1236
|
+
brokerWs,
|
|
1237
|
+
brokerHttp,
|
|
1238
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1239
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1240
|
+
lastErrorCode: failure.errorCode,
|
|
1241
|
+
lastErrorClass: failure.failureClass,
|
|
1242
|
+
lastErrorMessage: failure.message,
|
|
1243
|
+
hint: failure.hint,
|
|
1244
|
+
consecutiveFailures: reconnectState.attempt,
|
|
1245
|
+
pid: process.pid,
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
reconnectState.timer = setTimeout(() => {
|
|
1249
|
+
reconnectState.timer = null;
|
|
1250
|
+
connectBroker();
|
|
1251
|
+
}, delayMs);
|
|
1252
|
+
};
|
|
1253
|
+
|
|
788
1254
|
const connectBroker = () => {
|
|
789
1255
|
const wsUrl = new URL(brokerWs);
|
|
790
1256
|
wsUrl.searchParams.set('token', deviceToken);
|
|
791
1257
|
|
|
792
1258
|
const brokerSocket = new WebSocket(wsUrl.toString());
|
|
793
1259
|
let actionCableHeartbeat = null;
|
|
1260
|
+
|
|
1261
|
+
const clearChallengeTimer = (sessionBridge) => {
|
|
1262
|
+
if (sessionBridge && sessionBridge.connectChallengeTimer) {
|
|
1263
|
+
clearTimeout(sessionBridge.connectChallengeTimer);
|
|
1264
|
+
sessionBridge.connectChallengeTimer = null;
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const queueConnectUntilChallenge = (sessionId, sessionBridge, frame) => {
|
|
1269
|
+
if (!sessionBridge || typeof frame !== 'string' || !frame) return;
|
|
1270
|
+
if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
|
|
1271
|
+
sessionBridge.pendingConnectFrames = [];
|
|
1272
|
+
}
|
|
1273
|
+
if (sessionBridge.pendingConnectFrames.includes(frame)) {
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
sessionBridge.pendingConnectFrames.push(frame);
|
|
1277
|
+
|
|
1278
|
+
if (sessionBridge.connectChallengeTimer) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
sessionBridge.connectChallengeTimer = setTimeout(() => {
|
|
1283
|
+
sessionBridge.connectChallengeTimer = null;
|
|
1284
|
+
const hasPending = Array.isArray(sessionBridge.pendingConnectFrames)
|
|
1285
|
+
? sessionBridge.pendingConnectFrames.length > 0
|
|
1286
|
+
: false;
|
|
1287
|
+
if (!hasPending || sessionBridge.connectNonce) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
console.error(
|
|
1291
|
+
`[bridge] gateway.connect_challenge_timeout ${sessionId} (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms)`
|
|
1292
|
+
);
|
|
1293
|
+
sendBrokerPayload(brokerSocket, {
|
|
1294
|
+
action: 'log',
|
|
1295
|
+
type: 'log',
|
|
1296
|
+
sessionId,
|
|
1297
|
+
level: 'error',
|
|
1298
|
+
message: `Gateway challenge timeout (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms) for session ${sessionId}`,
|
|
1299
|
+
});
|
|
1300
|
+
try {
|
|
1301
|
+
sessionBridge.socket?.close(4009, 'connect_challenge_timeout');
|
|
1302
|
+
} catch {
|
|
1303
|
+
// no-op
|
|
1304
|
+
}
|
|
1305
|
+
}, BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS);
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const flushPendingConnectFrames = (sessionId, sessionBridge) => {
|
|
1309
|
+
if (!sessionBridge || !sessionBridge.connectNonce) return;
|
|
1310
|
+
const pending = Array.isArray(sessionBridge.pendingConnectFrames)
|
|
1311
|
+
? sessionBridge.pendingConnectFrames.splice(0, sessionBridge.pendingConnectFrames.length)
|
|
1312
|
+
: [];
|
|
1313
|
+
if (pending.length === 0) {
|
|
1314
|
+
clearChallengeTimer(sessionBridge);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
clearChallengeTimer(sessionBridge);
|
|
1319
|
+
for (const pendingFrame of pending) {
|
|
1320
|
+
const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
|
|
1321
|
+
connectNonce: sessionBridge.connectNonce,
|
|
1322
|
+
deviceIdentity: gatewayDeviceIdentity,
|
|
1323
|
+
});
|
|
1324
|
+
if (!prepared.frameText || prepared.waitForChallenge) {
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
1328
|
+
if (result === 'queued') {
|
|
1329
|
+
console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
|
|
1330
|
+
} else if (result === 'dropped') {
|
|
1331
|
+
console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
|
|
1332
|
+
} else {
|
|
1333
|
+
console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
|
|
794
1338
|
const setupGatewaySession = (sessionId, sessionBridge) => {
|
|
795
1339
|
if (!sessionBridge || !sessionBridge.socket) return;
|
|
796
1340
|
const gatewaySocket = sessionBridge.socket;
|
|
1341
|
+
if (typeof sessionBridge.connectNonce !== 'string') {
|
|
1342
|
+
sessionBridge.connectNonce = '';
|
|
1343
|
+
}
|
|
1344
|
+
if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
|
|
1345
|
+
sessionBridge.pendingConnectFrames = [];
|
|
1346
|
+
}
|
|
1347
|
+
let connectTimeout = setTimeout(() => {
|
|
1348
|
+
if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
|
|
1349
|
+
console.error(
|
|
1350
|
+
`[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
|
|
1351
|
+
);
|
|
1352
|
+
sendBrokerPayload(brokerSocket, {
|
|
1353
|
+
action: 'log',
|
|
1354
|
+
type: 'log',
|
|
1355
|
+
sessionId,
|
|
1356
|
+
level: 'error',
|
|
1357
|
+
message: `Gateway connect timeout (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms) for session ${sessionId}`,
|
|
1358
|
+
});
|
|
1359
|
+
try {
|
|
1360
|
+
gatewaySocket.close(4008, 'gateway_connect_timeout');
|
|
1361
|
+
} catch {
|
|
1362
|
+
// no-op
|
|
1363
|
+
}
|
|
1364
|
+
}, BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS);
|
|
797
1365
|
|
|
798
1366
|
gatewaySocket.on('open', () => {
|
|
1367
|
+
if (connectTimeout) {
|
|
1368
|
+
clearTimeout(connectTimeout);
|
|
1369
|
+
connectTimeout = null;
|
|
1370
|
+
}
|
|
799
1371
|
console.log(`[bridge] gateway.open ${sessionId}`);
|
|
800
1372
|
flushSessionQueue(sessionBridge);
|
|
801
1373
|
});
|
|
802
1374
|
|
|
803
1375
|
gatewaySocket.on('message', (gatewayRaw) => {
|
|
804
1376
|
const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
|
|
1377
|
+
const gatewayPayload = parseJsonPayload(frame);
|
|
1378
|
+
if (gatewayPayload?.event === 'connect.challenge') {
|
|
1379
|
+
const nonce =
|
|
1380
|
+
gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
|
|
1381
|
+
? gatewayPayload.payload.nonce.trim()
|
|
1382
|
+
: '';
|
|
1383
|
+
if (!nonce) {
|
|
1384
|
+
console.error(`[bridge] gateway.connect.challenge missing nonce for ${sessionId}`);
|
|
1385
|
+
sendBrokerPayload(brokerSocket, {
|
|
1386
|
+
action: 'log',
|
|
1387
|
+
type: 'log',
|
|
1388
|
+
sessionId,
|
|
1389
|
+
level: 'error',
|
|
1390
|
+
message: `Gateway connect challenge missing nonce for session ${sessionId}`,
|
|
1391
|
+
});
|
|
1392
|
+
try {
|
|
1393
|
+
gatewaySocket.close(1008, 'connect_challenge_missing_nonce');
|
|
1394
|
+
} catch {
|
|
1395
|
+
// no-op
|
|
1396
|
+
}
|
|
1397
|
+
} else {
|
|
1398
|
+
sessionBridge.connectNonce = nonce;
|
|
1399
|
+
flushPendingConnectFrames(sessionId, sessionBridge);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
805
1402
|
sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
|
|
806
1403
|
});
|
|
807
1404
|
|
|
808
1405
|
gatewaySocket.on('close', (code, reason) => {
|
|
1406
|
+
if (connectTimeout) {
|
|
1407
|
+
clearTimeout(connectTimeout);
|
|
1408
|
+
connectTimeout = null;
|
|
1409
|
+
}
|
|
1410
|
+
clearChallengeTimer(sessionBridge);
|
|
809
1411
|
const reasonText = reason ? reason.toString() : '';
|
|
810
1412
|
console.log(
|
|
811
1413
|
`[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
|
|
@@ -821,6 +1423,10 @@ async function startOpenclawBridge(flags) {
|
|
|
821
1423
|
});
|
|
822
1424
|
|
|
823
1425
|
gatewaySocket.on('error', (err) => {
|
|
1426
|
+
if (connectTimeout && gatewaySocket.readyState !== WebSocket.CONNECTING) {
|
|
1427
|
+
clearTimeout(connectTimeout);
|
|
1428
|
+
connectTimeout = null;
|
|
1429
|
+
}
|
|
824
1430
|
console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
|
|
825
1431
|
sendBrokerPayload(brokerSocket, {
|
|
826
1432
|
action: 'log',
|
|
@@ -846,6 +1452,8 @@ async function startOpenclawBridge(flags) {
|
|
|
846
1452
|
|
|
847
1453
|
brokerSocket.on('open', () => {
|
|
848
1454
|
console.log('[bridge] Connected to managed broker.');
|
|
1455
|
+
reconnectState.attempt = 0;
|
|
1456
|
+
reconnectState.lastFailure = null;
|
|
849
1457
|
if (!actionCableMode) return;
|
|
850
1458
|
brokerSocket.send(
|
|
851
1459
|
JSON.stringify({
|
|
@@ -856,6 +1464,20 @@ async function startOpenclawBridge(flags) {
|
|
|
856
1464
|
actionCableHeartbeat = setInterval(() => {
|
|
857
1465
|
sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
|
|
858
1466
|
}, 15000);
|
|
1467
|
+
updateBridgeStatus({
|
|
1468
|
+
status: 'connected',
|
|
1469
|
+
deviceId,
|
|
1470
|
+
brokerWs,
|
|
1471
|
+
brokerHttp,
|
|
1472
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1473
|
+
lastConnectedAt: bridgeNowIso(),
|
|
1474
|
+
lastErrorCode: '',
|
|
1475
|
+
lastErrorClass: '',
|
|
1476
|
+
lastErrorMessage: '',
|
|
1477
|
+
hint: '',
|
|
1478
|
+
consecutiveFailures: 0,
|
|
1479
|
+
pid: process.pid,
|
|
1480
|
+
});
|
|
859
1481
|
});
|
|
860
1482
|
|
|
861
1483
|
brokerSocket.on('message', (rawData) => {
|
|
@@ -868,12 +1490,61 @@ async function startOpenclawBridge(flags) {
|
|
|
868
1490
|
}
|
|
869
1491
|
|
|
870
1492
|
if (payload.type === 'broker.disconnect') {
|
|
1493
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1494
|
+
reason: String(payload.reason || 'unauthorized'),
|
|
1495
|
+
forceClass: 'auth_rejected',
|
|
1496
|
+
});
|
|
1497
|
+
reconnectState.stopped = true;
|
|
871
1498
|
console.error(`[bridge] Broker rejected connection: ${String(payload.reason || 'unauthorized')}`);
|
|
1499
|
+
console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
|
|
1500
|
+
updateBridgeStatus({
|
|
1501
|
+
status: 'error',
|
|
1502
|
+
deviceId,
|
|
1503
|
+
brokerWs,
|
|
1504
|
+
brokerHttp,
|
|
1505
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1506
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1507
|
+
lastErrorCode: reconnectState.lastFailure.errorCode,
|
|
1508
|
+
lastErrorClass: reconnectState.lastFailure.failureClass,
|
|
1509
|
+
lastErrorMessage: reconnectState.lastFailure.message,
|
|
1510
|
+
hint: reconnectState.lastFailure.hint,
|
|
1511
|
+
consecutiveFailures: reconnectState.attempt + 1,
|
|
1512
|
+
pid: process.pid,
|
|
1513
|
+
});
|
|
1514
|
+
try {
|
|
1515
|
+
brokerSocket.close(4001, 'auth_rejected');
|
|
1516
|
+
} catch {
|
|
1517
|
+
// no-op
|
|
1518
|
+
}
|
|
872
1519
|
return;
|
|
873
1520
|
}
|
|
874
1521
|
|
|
875
1522
|
if (payload.type === 'broker.reject_subscription') {
|
|
876
1523
|
console.error('[bridge] Broker rejected DeviceChannel subscription.');
|
|
1524
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1525
|
+
reason: 'broker rejected DeviceChannel subscription',
|
|
1526
|
+
forceClass: 'auth_rejected',
|
|
1527
|
+
});
|
|
1528
|
+
reconnectState.stopped = true;
|
|
1529
|
+
updateBridgeStatus({
|
|
1530
|
+
status: 'error',
|
|
1531
|
+
deviceId,
|
|
1532
|
+
brokerWs,
|
|
1533
|
+
brokerHttp,
|
|
1534
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1535
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1536
|
+
lastErrorCode: reconnectState.lastFailure.errorCode,
|
|
1537
|
+
lastErrorClass: reconnectState.lastFailure.failureClass,
|
|
1538
|
+
lastErrorMessage: reconnectState.lastFailure.message,
|
|
1539
|
+
hint: reconnectState.lastFailure.hint,
|
|
1540
|
+
consecutiveFailures: reconnectState.attempt + 1,
|
|
1541
|
+
pid: process.pid,
|
|
1542
|
+
});
|
|
1543
|
+
try {
|
|
1544
|
+
brokerSocket.close(4002, 'subscription_rejected');
|
|
1545
|
+
} catch {
|
|
1546
|
+
// no-op
|
|
1547
|
+
}
|
|
877
1548
|
return;
|
|
878
1549
|
}
|
|
879
1550
|
|
|
@@ -897,8 +1568,19 @@ async function startOpenclawBridge(flags) {
|
|
|
897
1568
|
console.log(`[bridge] client.frame ${sessionId}`);
|
|
898
1569
|
const sessionBridge = getOrCreateGatewaySession(sessionId);
|
|
899
1570
|
if (!sessionBridge) return;
|
|
900
|
-
const
|
|
901
|
-
|
|
1571
|
+
const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
|
|
1572
|
+
connectNonce: sessionBridge.connectNonce,
|
|
1573
|
+
deviceIdentity: gatewayDeviceIdentity,
|
|
1574
|
+
});
|
|
1575
|
+
if (prepared.waitForChallenge) {
|
|
1576
|
+
queueConnectUntilChallenge(sessionId, sessionBridge, frame);
|
|
1577
|
+
console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (!prepared.frameText) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
const result = forwardFrameToSession(sessionBridge, prepared.frameText);
|
|
902
1584
|
if (result === 'queued') {
|
|
903
1585
|
console.log(`[bridge] client.frame queued ${sessionId}`);
|
|
904
1586
|
} else if (result === 'dropped') {
|
|
@@ -912,6 +1594,7 @@ async function startOpenclawBridge(flags) {
|
|
|
912
1594
|
console.log(`[bridge] client.close ${sessionId}`);
|
|
913
1595
|
const sessionBridge = activeGatewaySockets.get(sessionId);
|
|
914
1596
|
if (sessionBridge && sessionBridge.socket) {
|
|
1597
|
+
clearChallengeTimer(sessionBridge);
|
|
915
1598
|
activeGatewaySockets.delete(sessionId);
|
|
916
1599
|
sessionBridge.socket.close(1000, 'client_closed');
|
|
917
1600
|
}
|
|
@@ -924,8 +1607,10 @@ async function startOpenclawBridge(flags) {
|
|
|
924
1607
|
clearInterval(actionCableHeartbeat);
|
|
925
1608
|
actionCableHeartbeat = null;
|
|
926
1609
|
}
|
|
927
|
-
|
|
1610
|
+
const reasonText = reason ? reason.toString() : '';
|
|
1611
|
+
console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
|
|
928
1612
|
for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
|
|
1613
|
+
clearChallengeTimer(sessionBridge);
|
|
929
1614
|
activeGatewaySockets.delete(sessionId);
|
|
930
1615
|
try {
|
|
931
1616
|
sessionBridge.socket.close(1001, 'broker_disconnected');
|
|
@@ -933,13 +1618,48 @@ async function startOpenclawBridge(flags) {
|
|
|
933
1618
|
// no-op
|
|
934
1619
|
}
|
|
935
1620
|
}
|
|
936
|
-
|
|
1621
|
+
|
|
1622
|
+
if (reconnectState.stopped) {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
if (!reconnectState.lastFailure) {
|
|
1627
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1628
|
+
reason: `socket closed code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`,
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
scheduleReconnect();
|
|
937
1632
|
});
|
|
938
1633
|
|
|
939
1634
|
brokerSocket.on('error', (err) => {
|
|
940
|
-
|
|
1635
|
+
reconnectState.lastFailure = classifyBridgeFailure({ err });
|
|
1636
|
+
console.error(
|
|
1637
|
+
`[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
|
|
1638
|
+
);
|
|
1639
|
+
console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
|
|
1640
|
+
});
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
const markStopped = (signal) => {
|
|
1644
|
+
reconnectState.stopped = true;
|
|
1645
|
+
if (reconnectState.timer) {
|
|
1646
|
+
clearTimeout(reconnectState.timer);
|
|
1647
|
+
reconnectState.timer = null;
|
|
1648
|
+
}
|
|
1649
|
+
updateBridgeStatus({
|
|
1650
|
+
status: 'stopped',
|
|
1651
|
+
deviceId,
|
|
1652
|
+
brokerWs,
|
|
1653
|
+
brokerHttp,
|
|
1654
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1655
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1656
|
+
stopSignal: signal,
|
|
1657
|
+
pid: process.pid,
|
|
941
1658
|
});
|
|
1659
|
+
process.exit(0);
|
|
942
1660
|
};
|
|
1661
|
+
process.once('SIGINT', () => markStopped('SIGINT'));
|
|
1662
|
+
process.once('SIGTERM', () => markStopped('SIGTERM'));
|
|
943
1663
|
|
|
944
1664
|
connectBroker();
|
|
945
1665
|
await new Promise(() => {});
|
|
@@ -1030,25 +1750,13 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
1030
1750
|
}
|
|
1031
1751
|
|
|
1032
1752
|
if (detach) {
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
'
|
|
1036
|
-
'
|
|
1037
|
-
'
|
|
1038
|
-
managedConfig.brokerHttpUrl,
|
|
1039
|
-
'--broker-ws',
|
|
1040
|
-
brokerWs,
|
|
1041
|
-
'--device-id',
|
|
1042
|
-
deviceId,
|
|
1043
|
-
'--device-token',
|
|
1044
|
-
deviceToken,
|
|
1045
|
-
];
|
|
1046
|
-
const child = spawn(process.execPath, args, {
|
|
1047
|
-
detached: true,
|
|
1048
|
-
stdio: 'ignore',
|
|
1753
|
+
const pid = startBridgeDetachedProcess({
|
|
1754
|
+
'broker-http': managedConfig.brokerHttpUrl,
|
|
1755
|
+
'broker-ws': brokerWs,
|
|
1756
|
+
'device-id': deviceId,
|
|
1757
|
+
'device-token': deviceToken,
|
|
1049
1758
|
});
|
|
1050
|
-
|
|
1051
|
-
console.log(`Bridge started in background (pid: ${child.pid}).`);
|
|
1759
|
+
console.log(`Bridge started in background (pid: ${pid}).`);
|
|
1052
1760
|
return;
|
|
1053
1761
|
}
|
|
1054
1762
|
|
|
@@ -1116,6 +1824,69 @@ async function createOpenclawInviteLink(flags) {
|
|
|
1116
1824
|
}
|
|
1117
1825
|
}
|
|
1118
1826
|
|
|
1827
|
+
function printOpenclawBridgeStatus(flags) {
|
|
1828
|
+
const bridgeState = readBridgeState();
|
|
1829
|
+
const runtimeStatus = readBridgeStatus();
|
|
1830
|
+
const jsonOutput = isTruthyFlag(flags.json);
|
|
1831
|
+
const redactToken = (value) => {
|
|
1832
|
+
const text = String(value || '').trim();
|
|
1833
|
+
if (!text) return '';
|
|
1834
|
+
if (text.length <= 12) return '***';
|
|
1835
|
+
return `${text.slice(0, 6)}...${text.slice(-6)}`;
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
const payload = {
|
|
1839
|
+
bridgeStatePath: resolveBridgeStatePath(),
|
|
1840
|
+
bridgeStatusPath: resolveBridgeStatusPath(),
|
|
1841
|
+
bridgeState: {
|
|
1842
|
+
brokerHttp: String(bridgeState.brokerHttp || ''),
|
|
1843
|
+
brokerWs: String(bridgeState.brokerWs || ''),
|
|
1844
|
+
deviceId: String(bridgeState.deviceId || ''),
|
|
1845
|
+
deviceToken: redactToken(bridgeState.deviceToken),
|
|
1846
|
+
claimedAt: bridgeState.claimedAt || null,
|
|
1847
|
+
expiresAt: bridgeState.expiresAt || null,
|
|
1848
|
+
},
|
|
1849
|
+
runtime: runtimeStatus,
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
if (jsonOutput) {
|
|
1853
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
console.log('Oomi Bridge Status');
|
|
1858
|
+
console.log('------------------');
|
|
1859
|
+
console.log(`Bridge state: ${payload.bridgeStatePath}`);
|
|
1860
|
+
console.log(`Runtime status: ${payload.bridgeStatusPath}`);
|
|
1861
|
+
console.log(`Device: ${payload.bridgeState.deviceId || 'not paired'}`);
|
|
1862
|
+
console.log(`Broker HTTP: ${payload.bridgeState.brokerHttp || 'not configured'}`);
|
|
1863
|
+
console.log(`Broker WS: ${payload.bridgeState.brokerWs || 'not configured'}`);
|
|
1864
|
+
if (payload.bridgeState.deviceToken) {
|
|
1865
|
+
console.log(`Device token: ${payload.bridgeState.deviceToken}`);
|
|
1866
|
+
}
|
|
1867
|
+
if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
|
|
1868
|
+
console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
|
|
1869
|
+
if (payload.runtime.lastConnectedAt) {
|
|
1870
|
+
console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
|
|
1871
|
+
}
|
|
1872
|
+
if (payload.runtime.lastDisconnectAt) {
|
|
1873
|
+
console.log(`Last disconnected: ${payload.runtime.lastDisconnectAt}`);
|
|
1874
|
+
}
|
|
1875
|
+
if (payload.runtime.lastErrorClass || payload.runtime.lastErrorCode || payload.runtime.lastErrorMessage) {
|
|
1876
|
+
console.log(
|
|
1877
|
+
`Last error: ${String(payload.runtime.lastErrorClass || 'unknown')}/${String(payload.runtime.lastErrorCode || 'UNKNOWN')} ${String(payload.runtime.lastErrorMessage || '').trim()}`
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
if (payload.runtime.hint) {
|
|
1881
|
+
console.log(`Hint: ${payload.runtime.hint}`);
|
|
1882
|
+
}
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
console.log('Runtime state: no bridge runtime status recorded yet.');
|
|
1887
|
+
console.log('Run: oomi openclaw bridge --app-url https://www.oomi.ai');
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1119
1890
|
function printOpenclawPluginSetup(flags) {
|
|
1120
1891
|
const bridgeState = readBridgeState();
|
|
1121
1892
|
const backendUrl = String(
|
|
@@ -1215,6 +1986,13 @@ async function main() {
|
|
|
1215
1986
|
}
|
|
1216
1987
|
|
|
1217
1988
|
if (command === 'openclaw' && subcommand === 'bridge') {
|
|
1989
|
+
if (Boolean(args.flags.detach)) {
|
|
1990
|
+
const detachedFlags = { ...args.flags };
|
|
1991
|
+
delete detachedFlags.detach;
|
|
1992
|
+
const pid = startBridgeDetachedProcess(detachedFlags);
|
|
1993
|
+
console.log(`Bridge started in background (pid: ${pid}).`);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1218
1996
|
await startOpenclawBridge(args.flags);
|
|
1219
1997
|
return;
|
|
1220
1998
|
}
|
|
@@ -1229,6 +2007,11 @@ async function main() {
|
|
|
1229
2007
|
return;
|
|
1230
2008
|
}
|
|
1231
2009
|
|
|
2010
|
+
if (command === 'openclaw' && subcommand === 'status') {
|
|
2011
|
+
printOpenclawBridgeStatus(args.flags);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1232
2015
|
if (command === 'openclaw' && subcommand === 'plugin') {
|
|
1233
2016
|
printOpenclawPluginSetup(args.flags);
|
|
1234
2017
|
return;
|