oomi-ai 0.2.4 → 0.2.5
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/bin/oomi-ai.js +511 -39
- package/package.json +1 -1
package/bin/oomi-ai.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from 'os';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import { randomUUID } 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,8 @@ 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;
|
|
17
21
|
|
|
18
22
|
function parsePositiveInteger(value, fallback) {
|
|
19
23
|
const num = Number(value);
|
|
@@ -149,6 +153,9 @@ Commands:
|
|
|
149
153
|
openclaw plugin
|
|
150
154
|
Print OpenClaw extension install/config guidance for Oomi channel plugin.
|
|
151
155
|
|
|
156
|
+
openclaw status
|
|
157
|
+
Show bridge state + runtime health from local status files.
|
|
158
|
+
|
|
152
159
|
personas sync
|
|
153
160
|
Sync personas from the repo into the Oomi backend registry.
|
|
154
161
|
|
|
@@ -162,7 +169,7 @@ Common flags:
|
|
|
162
169
|
--broker-http URL Managed broker HTTPS URL (for pair claim)
|
|
163
170
|
--broker-ws URL Managed broker device WS URL (wss://.../cable)
|
|
164
171
|
--pair-code CODE One-time pairing code from Oomi
|
|
165
|
-
--app-url URL Oomi app URL used for pairing APIs
|
|
172
|
+
--app-url URL Oomi app URL used for pairing APIs; bridge can also refresh managed broker URLs from it
|
|
166
173
|
--label TEXT Pairing label shown in broker logs
|
|
167
174
|
--session-key KEY Session key used in generated connect URL
|
|
168
175
|
--detach Start bridge in background and exit
|
|
@@ -537,6 +544,10 @@ function resolveBridgeStatePath() {
|
|
|
537
544
|
return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
|
|
538
545
|
}
|
|
539
546
|
|
|
547
|
+
function resolveBridgeStatusPath() {
|
|
548
|
+
return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
|
|
549
|
+
}
|
|
550
|
+
|
|
540
551
|
function defaultDeviceId() {
|
|
541
552
|
const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
|
|
542
553
|
return `oomi-${host}-${randomUUID().slice(0, 8)}`;
|
|
@@ -566,6 +577,27 @@ function writeBridgeState(nextState) {
|
|
|
566
577
|
writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
|
|
567
578
|
}
|
|
568
579
|
|
|
580
|
+
function readBridgeStatus() {
|
|
581
|
+
return readJsonSafe(resolveBridgeStatusPath()) || {};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function writeBridgeStatus(nextStatus) {
|
|
585
|
+
const statusPath = resolveBridgeStatusPath();
|
|
586
|
+
ensureDir(path.dirname(statusPath));
|
|
587
|
+
writeFile(statusPath, JSON.stringify(nextStatus, null, 2) + '\n');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function updateBridgeStatus(partial) {
|
|
591
|
+
const current = readBridgeStatus();
|
|
592
|
+
const next = {
|
|
593
|
+
...current,
|
|
594
|
+
...partial,
|
|
595
|
+
updatedAt: new Date().toISOString(),
|
|
596
|
+
};
|
|
597
|
+
writeBridgeStatus(next);
|
|
598
|
+
return next;
|
|
599
|
+
}
|
|
600
|
+
|
|
569
601
|
async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
|
|
570
602
|
const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
|
|
571
603
|
method: 'POST',
|
|
@@ -674,29 +706,219 @@ function parseJsonPayload(raw) {
|
|
|
674
706
|
}
|
|
675
707
|
}
|
|
676
708
|
|
|
709
|
+
function bridgeNowIso() {
|
|
710
|
+
return new Date().toISOString();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function extractErrorCode(err) {
|
|
714
|
+
if (!err || typeof err !== 'object') return '';
|
|
715
|
+
const value = err.code;
|
|
716
|
+
return typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function extractErrorMessage(err) {
|
|
720
|
+
if (!err) return '';
|
|
721
|
+
if (typeof err === 'string') return err.trim();
|
|
722
|
+
if (err instanceof Error) return err.message.trim();
|
|
723
|
+
return String(err).trim();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function classifyBridgeFailure({ err, reason = '', code = '', forceClass = '' } = {}) {
|
|
727
|
+
const errorCode = (code || extractErrorCode(err)).toUpperCase();
|
|
728
|
+
const message = [extractErrorMessage(err), String(reason || '').trim()].filter(Boolean).join(' | ');
|
|
729
|
+
const text = `${errorCode} ${message}`.toLowerCase();
|
|
730
|
+
|
|
731
|
+
let failureClass = forceClass || 'unknown';
|
|
732
|
+
let retryable = true;
|
|
733
|
+
let hint = 'Check broker URL, network path, and bridge token.';
|
|
734
|
+
let baseDelayMs = BRIDGE_RECONNECT_BASE_MS;
|
|
735
|
+
|
|
736
|
+
if (
|
|
737
|
+
errorCode === 'ENOTFOUND' ||
|
|
738
|
+
errorCode === 'EAI_AGAIN' ||
|
|
739
|
+
text.includes('enotfound') ||
|
|
740
|
+
text.includes('name resolution') ||
|
|
741
|
+
text.includes('dns')
|
|
742
|
+
) {
|
|
743
|
+
failureClass = 'dns_resolution';
|
|
744
|
+
hint = 'Host resolution failed. Verify DNS/network access to the broker host.';
|
|
745
|
+
baseDelayMs = 5000;
|
|
746
|
+
} else if (
|
|
747
|
+
text.includes('unauthorized') ||
|
|
748
|
+
text.includes('forbidden') ||
|
|
749
|
+
text.includes('invalid token') ||
|
|
750
|
+
text.includes('token expired') ||
|
|
751
|
+
text.includes('broker rejected')
|
|
752
|
+
) {
|
|
753
|
+
failureClass = 'auth_rejected';
|
|
754
|
+
retryable = false;
|
|
755
|
+
hint = 'Bridge token is invalid/expired. Re-run: oomi openclaw pair --app-url <url>.';
|
|
756
|
+
baseDelayMs = BRIDGE_RECONNECT_MAX_MS;
|
|
757
|
+
} else if (
|
|
758
|
+
errorCode === 'ECONNREFUSED' ||
|
|
759
|
+
errorCode === 'ENETUNREACH' ||
|
|
760
|
+
errorCode === 'EHOSTUNREACH' ||
|
|
761
|
+
errorCode === 'ETIMEDOUT' ||
|
|
762
|
+
text.includes('socket hang up') ||
|
|
763
|
+
text.includes('abnormal closure')
|
|
764
|
+
) {
|
|
765
|
+
failureClass = 'network';
|
|
766
|
+
hint = 'Broker network path is unavailable. Check connectivity/firewall/proxy settings.';
|
|
767
|
+
baseDelayMs = 3000;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
errorCode: errorCode || 'UNKNOWN',
|
|
772
|
+
message: message || 'unknown bridge error',
|
|
773
|
+
failureClass,
|
|
774
|
+
retryable,
|
|
775
|
+
hint,
|
|
776
|
+
baseDelayMs,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function computeReconnectDelayMs(attempt, baseDelayMs) {
|
|
781
|
+
const growth = Math.min(BRIDGE_RECONNECT_MAX_MS, Math.round(baseDelayMs * (2 ** Math.max(0, attempt - 1))));
|
|
782
|
+
const jitter = Math.floor(growth * (Math.random() * 0.25));
|
|
783
|
+
return Math.min(BRIDGE_RECONNECT_MAX_MS, growth + jitter);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function assertTcpReachable(urlValue, timeoutMs = 1500) {
|
|
787
|
+
const url = new URL(urlValue);
|
|
788
|
+
const host = url.hostname || '127.0.0.1';
|
|
789
|
+
const port = Number(url.port || (url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80));
|
|
790
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
791
|
+
throw new Error(`Invalid port in URL: ${urlValue}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await new Promise((resolve, reject) => {
|
|
795
|
+
const socket = net.connect({ host, port });
|
|
796
|
+
let finished = false;
|
|
797
|
+
const done = (fn) => (value) => {
|
|
798
|
+
if (finished) return;
|
|
799
|
+
finished = true;
|
|
800
|
+
clearTimeout(timer);
|
|
801
|
+
socket.destroy();
|
|
802
|
+
fn(value);
|
|
803
|
+
};
|
|
804
|
+
const timer = setTimeout(done(reject), timeoutMs, new Error(`TCP connect timeout (${host}:${port})`));
|
|
805
|
+
socket.once('connect', done(resolve));
|
|
806
|
+
socket.once('error', done(reject));
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
|
|
811
|
+
let brokerUrl;
|
|
812
|
+
try {
|
|
813
|
+
brokerUrl = new URL(brokerWs);
|
|
814
|
+
} catch {
|
|
815
|
+
throw new Error(`Invalid broker WS URL: ${brokerWs}`);
|
|
816
|
+
}
|
|
817
|
+
if (brokerUrl.protocol !== 'ws:' && brokerUrl.protocol !== 'wss:') {
|
|
818
|
+
throw new Error(`Broker WS URL must use ws:// or wss:// (received ${brokerUrl.protocol})`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const brokerHost = brokerUrl.hostname;
|
|
822
|
+
if (!brokerHost) {
|
|
823
|
+
throw new Error('Broker WS URL is missing hostname.');
|
|
824
|
+
}
|
|
825
|
+
await dnsLookup(brokerHost);
|
|
826
|
+
|
|
827
|
+
let parsedGatewayUrl;
|
|
828
|
+
try {
|
|
829
|
+
parsedGatewayUrl = new URL(gatewayUrl);
|
|
830
|
+
} catch {
|
|
831
|
+
throw new Error(`Invalid local gateway URL (${gatewayUrl}) from ${gatewayConfigPath}`);
|
|
832
|
+
}
|
|
833
|
+
if (parsedGatewayUrl.protocol !== 'ws:') {
|
|
834
|
+
throw new Error(`Local gateway URL must use ws:// (received ${parsedGatewayUrl.protocol})`);
|
|
835
|
+
}
|
|
836
|
+
await assertTcpReachable(parsedGatewayUrl.toString());
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function buildBridgeDetachArgs(rawFlags = {}) {
|
|
840
|
+
const orderedKeys = [
|
|
841
|
+
'broker-http',
|
|
842
|
+
'broker-ws',
|
|
843
|
+
'pair-code',
|
|
844
|
+
'app-url',
|
|
845
|
+
'device-id',
|
|
846
|
+
'device-token',
|
|
847
|
+
];
|
|
848
|
+
const args = [process.argv[1], 'openclaw', 'bridge'];
|
|
849
|
+
|
|
850
|
+
for (const key of orderedKeys) {
|
|
851
|
+
const value = rawFlags[key];
|
|
852
|
+
if (value === undefined || value === null || value === false) continue;
|
|
853
|
+
if (value === true) {
|
|
854
|
+
args.push(`--${key}`);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const text = String(value).trim();
|
|
858
|
+
if (!text) continue;
|
|
859
|
+
args.push(`--${key}`, text);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return args;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function startBridgeDetachedProcess(rawFlags = {}) {
|
|
866
|
+
const args = buildBridgeDetachArgs(rawFlags);
|
|
867
|
+
const child = spawn(process.execPath, args, {
|
|
868
|
+
detached: true,
|
|
869
|
+
stdio: 'ignore',
|
|
870
|
+
});
|
|
871
|
+
child.unref();
|
|
872
|
+
return child.pid;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function resolveBridgeRuntimeConfig(flags, bridgeState) {
|
|
876
|
+
const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
|
|
877
|
+
const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
|
|
878
|
+
const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
|
|
879
|
+
|
|
880
|
+
let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
|
|
881
|
+
let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
|
|
882
|
+
let managedConfigUsed = false;
|
|
883
|
+
let managedConfigError = '';
|
|
884
|
+
|
|
885
|
+
if (appUrl && (!explicitBrokerHttp || !explicitBrokerWs)) {
|
|
886
|
+
try {
|
|
887
|
+
const managedConfig = await fetchManagedGatewayConfig({ appUrl });
|
|
888
|
+
managedConfigUsed = true;
|
|
889
|
+
if (!explicitBrokerHttp) {
|
|
890
|
+
brokerHttp = String(managedConfig.brokerHttpUrl || '').trim();
|
|
891
|
+
}
|
|
892
|
+
if (!explicitBrokerWs) {
|
|
893
|
+
brokerWs = String(managedConfig.brokerDeviceWsUrl || '').trim();
|
|
894
|
+
}
|
|
895
|
+
} catch (err) {
|
|
896
|
+
managedConfigError = extractErrorMessage(err);
|
|
897
|
+
if (!brokerWs) {
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
appUrl,
|
|
905
|
+
brokerHttp,
|
|
906
|
+
brokerWs,
|
|
907
|
+
managedConfigUsed,
|
|
908
|
+
managedConfigError,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
677
912
|
async function startOpenclawBridge(flags) {
|
|
678
913
|
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();
|
|
914
|
+
const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
|
|
915
|
+
const brokerHttp = runtimeConfig.brokerHttp;
|
|
916
|
+
const brokerWs = runtimeConfig.brokerWs;
|
|
691
917
|
const deviceId = resolveDeviceId(flags, bridgeState);
|
|
692
918
|
const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
|
|
693
919
|
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
920
|
let deviceToken = explicitDeviceToken;
|
|
699
|
-
if (!deviceToken &&
|
|
921
|
+
if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
|
|
700
922
|
deviceToken = String(bridgeState.deviceToken || '').trim();
|
|
701
923
|
}
|
|
702
924
|
|
|
@@ -731,11 +953,64 @@ async function startOpenclawBridge(flags) {
|
|
|
731
953
|
throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
|
|
732
954
|
}
|
|
733
955
|
|
|
956
|
+
if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
|
|
957
|
+
console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
|
|
958
|
+
} else if (runtimeConfig.managedConfigError) {
|
|
959
|
+
console.warn(
|
|
960
|
+
`[bridge] failed to refresh broker URLs from app URL; using local/state broker config (${runtimeConfig.managedConfigError})`
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
await runBridgePreflight({
|
|
966
|
+
brokerWs,
|
|
967
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
968
|
+
gatewayConfigPath: gateway.configPath,
|
|
969
|
+
});
|
|
970
|
+
} catch (err) {
|
|
971
|
+
const failure = classifyBridgeFailure({ err });
|
|
972
|
+
updateBridgeStatus({
|
|
973
|
+
status: 'error',
|
|
974
|
+
deviceId,
|
|
975
|
+
brokerWs,
|
|
976
|
+
brokerHttp,
|
|
977
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
978
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
979
|
+
lastErrorCode: failure.errorCode,
|
|
980
|
+
lastErrorClass: failure.failureClass,
|
|
981
|
+
lastErrorMessage: failure.message,
|
|
982
|
+
hint: failure.hint,
|
|
983
|
+
consecutiveFailures: 0,
|
|
984
|
+
pid: process.pid,
|
|
985
|
+
});
|
|
986
|
+
throw new Error(`Bridge preflight failed (${failure.failureClass}): ${failure.message}. ${failure.hint}`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
updateBridgeStatus({
|
|
990
|
+
status: 'starting',
|
|
991
|
+
deviceId,
|
|
992
|
+
brokerWs,
|
|
993
|
+
brokerHttp,
|
|
994
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
995
|
+
lastErrorCode: '',
|
|
996
|
+
lastErrorClass: '',
|
|
997
|
+
lastErrorMessage: '',
|
|
998
|
+
consecutiveFailures: 0,
|
|
999
|
+
pid: process.pid,
|
|
1000
|
+
startedAt: bridgeNowIso(),
|
|
1001
|
+
});
|
|
1002
|
+
|
|
734
1003
|
console.log(`Starting OpenClaw bridge: device=${deviceId}`);
|
|
735
1004
|
console.log(`Local gateway: ${gateway.gatewayUrl}`);
|
|
736
1005
|
console.log(`Broker WS: ${brokerWs}`);
|
|
737
1006
|
|
|
738
1007
|
const activeGatewaySockets = new Map();
|
|
1008
|
+
const reconnectState = {
|
|
1009
|
+
attempt: 0,
|
|
1010
|
+
timer: null,
|
|
1011
|
+
stopped: false,
|
|
1012
|
+
lastFailure: null,
|
|
1013
|
+
};
|
|
739
1014
|
const brokerPath = (() => {
|
|
740
1015
|
try {
|
|
741
1016
|
return new URL(brokerWs).pathname || '';
|
|
@@ -785,6 +1060,39 @@ async function startOpenclawBridge(flags) {
|
|
|
785
1060
|
return null;
|
|
786
1061
|
};
|
|
787
1062
|
|
|
1063
|
+
const scheduleReconnect = () => {
|
|
1064
|
+
if (reconnectState.stopped || reconnectState.timer) return;
|
|
1065
|
+
reconnectState.attempt += 1;
|
|
1066
|
+
const failure =
|
|
1067
|
+
reconnectState.lastFailure ||
|
|
1068
|
+
classifyBridgeFailure({ reason: 'connection closed without classified error' });
|
|
1069
|
+
const delayMs = computeReconnectDelayMs(reconnectState.attempt, failure.baseDelayMs);
|
|
1070
|
+
|
|
1071
|
+
console.warn(
|
|
1072
|
+
`[bridge] reconnect scheduled in ${delayMs}ms (attempt ${reconnectState.attempt}, class=${failure.failureClass}, code=${failure.errorCode})`
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
updateBridgeStatus({
|
|
1076
|
+
status: 'reconnecting',
|
|
1077
|
+
deviceId,
|
|
1078
|
+
brokerWs,
|
|
1079
|
+
brokerHttp,
|
|
1080
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1081
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1082
|
+
lastErrorCode: failure.errorCode,
|
|
1083
|
+
lastErrorClass: failure.failureClass,
|
|
1084
|
+
lastErrorMessage: failure.message,
|
|
1085
|
+
hint: failure.hint,
|
|
1086
|
+
consecutiveFailures: reconnectState.attempt,
|
|
1087
|
+
pid: process.pid,
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
reconnectState.timer = setTimeout(() => {
|
|
1091
|
+
reconnectState.timer = null;
|
|
1092
|
+
connectBroker();
|
|
1093
|
+
}, delayMs);
|
|
1094
|
+
};
|
|
1095
|
+
|
|
788
1096
|
const connectBroker = () => {
|
|
789
1097
|
const wsUrl = new URL(brokerWs);
|
|
790
1098
|
wsUrl.searchParams.set('token', deviceToken);
|
|
@@ -846,6 +1154,8 @@ async function startOpenclawBridge(flags) {
|
|
|
846
1154
|
|
|
847
1155
|
brokerSocket.on('open', () => {
|
|
848
1156
|
console.log('[bridge] Connected to managed broker.');
|
|
1157
|
+
reconnectState.attempt = 0;
|
|
1158
|
+
reconnectState.lastFailure = null;
|
|
849
1159
|
if (!actionCableMode) return;
|
|
850
1160
|
brokerSocket.send(
|
|
851
1161
|
JSON.stringify({
|
|
@@ -856,6 +1166,20 @@ async function startOpenclawBridge(flags) {
|
|
|
856
1166
|
actionCableHeartbeat = setInterval(() => {
|
|
857
1167
|
sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
|
|
858
1168
|
}, 15000);
|
|
1169
|
+
updateBridgeStatus({
|
|
1170
|
+
status: 'connected',
|
|
1171
|
+
deviceId,
|
|
1172
|
+
brokerWs,
|
|
1173
|
+
brokerHttp,
|
|
1174
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1175
|
+
lastConnectedAt: bridgeNowIso(),
|
|
1176
|
+
lastErrorCode: '',
|
|
1177
|
+
lastErrorClass: '',
|
|
1178
|
+
lastErrorMessage: '',
|
|
1179
|
+
hint: '',
|
|
1180
|
+
consecutiveFailures: 0,
|
|
1181
|
+
pid: process.pid,
|
|
1182
|
+
});
|
|
859
1183
|
});
|
|
860
1184
|
|
|
861
1185
|
brokerSocket.on('message', (rawData) => {
|
|
@@ -868,12 +1192,61 @@ async function startOpenclawBridge(flags) {
|
|
|
868
1192
|
}
|
|
869
1193
|
|
|
870
1194
|
if (payload.type === 'broker.disconnect') {
|
|
1195
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1196
|
+
reason: String(payload.reason || 'unauthorized'),
|
|
1197
|
+
forceClass: 'auth_rejected',
|
|
1198
|
+
});
|
|
1199
|
+
reconnectState.stopped = true;
|
|
871
1200
|
console.error(`[bridge] Broker rejected connection: ${String(payload.reason || 'unauthorized')}`);
|
|
1201
|
+
console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
|
|
1202
|
+
updateBridgeStatus({
|
|
1203
|
+
status: 'error',
|
|
1204
|
+
deviceId,
|
|
1205
|
+
brokerWs,
|
|
1206
|
+
brokerHttp,
|
|
1207
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1208
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1209
|
+
lastErrorCode: reconnectState.lastFailure.errorCode,
|
|
1210
|
+
lastErrorClass: reconnectState.lastFailure.failureClass,
|
|
1211
|
+
lastErrorMessage: reconnectState.lastFailure.message,
|
|
1212
|
+
hint: reconnectState.lastFailure.hint,
|
|
1213
|
+
consecutiveFailures: reconnectState.attempt + 1,
|
|
1214
|
+
pid: process.pid,
|
|
1215
|
+
});
|
|
1216
|
+
try {
|
|
1217
|
+
brokerSocket.close(4001, 'auth_rejected');
|
|
1218
|
+
} catch {
|
|
1219
|
+
// no-op
|
|
1220
|
+
}
|
|
872
1221
|
return;
|
|
873
1222
|
}
|
|
874
1223
|
|
|
875
1224
|
if (payload.type === 'broker.reject_subscription') {
|
|
876
1225
|
console.error('[bridge] Broker rejected DeviceChannel subscription.');
|
|
1226
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1227
|
+
reason: 'broker rejected DeviceChannel subscription',
|
|
1228
|
+
forceClass: 'auth_rejected',
|
|
1229
|
+
});
|
|
1230
|
+
reconnectState.stopped = true;
|
|
1231
|
+
updateBridgeStatus({
|
|
1232
|
+
status: 'error',
|
|
1233
|
+
deviceId,
|
|
1234
|
+
brokerWs,
|
|
1235
|
+
brokerHttp,
|
|
1236
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1237
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1238
|
+
lastErrorCode: reconnectState.lastFailure.errorCode,
|
|
1239
|
+
lastErrorClass: reconnectState.lastFailure.failureClass,
|
|
1240
|
+
lastErrorMessage: reconnectState.lastFailure.message,
|
|
1241
|
+
hint: reconnectState.lastFailure.hint,
|
|
1242
|
+
consecutiveFailures: reconnectState.attempt + 1,
|
|
1243
|
+
pid: process.pid,
|
|
1244
|
+
});
|
|
1245
|
+
try {
|
|
1246
|
+
brokerSocket.close(4002, 'subscription_rejected');
|
|
1247
|
+
} catch {
|
|
1248
|
+
// no-op
|
|
1249
|
+
}
|
|
877
1250
|
return;
|
|
878
1251
|
}
|
|
879
1252
|
|
|
@@ -924,7 +1297,8 @@ async function startOpenclawBridge(flags) {
|
|
|
924
1297
|
clearInterval(actionCableHeartbeat);
|
|
925
1298
|
actionCableHeartbeat = null;
|
|
926
1299
|
}
|
|
927
|
-
|
|
1300
|
+
const reasonText = reason ? reason.toString() : '';
|
|
1301
|
+
console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
|
|
928
1302
|
for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
|
|
929
1303
|
activeGatewaySockets.delete(sessionId);
|
|
930
1304
|
try {
|
|
@@ -933,13 +1307,48 @@ async function startOpenclawBridge(flags) {
|
|
|
933
1307
|
// no-op
|
|
934
1308
|
}
|
|
935
1309
|
}
|
|
936
|
-
|
|
1310
|
+
|
|
1311
|
+
if (reconnectState.stopped) {
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (!reconnectState.lastFailure) {
|
|
1316
|
+
reconnectState.lastFailure = classifyBridgeFailure({
|
|
1317
|
+
reason: `socket closed code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`,
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
scheduleReconnect();
|
|
937
1321
|
});
|
|
938
1322
|
|
|
939
1323
|
brokerSocket.on('error', (err) => {
|
|
940
|
-
|
|
1324
|
+
reconnectState.lastFailure = classifyBridgeFailure({ err });
|
|
1325
|
+
console.error(
|
|
1326
|
+
`[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
|
|
1327
|
+
);
|
|
1328
|
+
console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
|
|
1329
|
+
});
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
const markStopped = (signal) => {
|
|
1333
|
+
reconnectState.stopped = true;
|
|
1334
|
+
if (reconnectState.timer) {
|
|
1335
|
+
clearTimeout(reconnectState.timer);
|
|
1336
|
+
reconnectState.timer = null;
|
|
1337
|
+
}
|
|
1338
|
+
updateBridgeStatus({
|
|
1339
|
+
status: 'stopped',
|
|
1340
|
+
deviceId,
|
|
1341
|
+
brokerWs,
|
|
1342
|
+
brokerHttp,
|
|
1343
|
+
gatewayUrl: gateway.gatewayUrl,
|
|
1344
|
+
lastDisconnectAt: bridgeNowIso(),
|
|
1345
|
+
stopSignal: signal,
|
|
1346
|
+
pid: process.pid,
|
|
941
1347
|
});
|
|
1348
|
+
process.exit(0);
|
|
942
1349
|
};
|
|
1350
|
+
process.once('SIGINT', () => markStopped('SIGINT'));
|
|
1351
|
+
process.once('SIGTERM', () => markStopped('SIGTERM'));
|
|
943
1352
|
|
|
944
1353
|
connectBroker();
|
|
945
1354
|
await new Promise(() => {});
|
|
@@ -1030,25 +1439,13 @@ async function pairAndStartOpenclawBridge(flags) {
|
|
|
1030
1439
|
}
|
|
1031
1440
|
|
|
1032
1441
|
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',
|
|
1442
|
+
const pid = startBridgeDetachedProcess({
|
|
1443
|
+
'broker-http': managedConfig.brokerHttpUrl,
|
|
1444
|
+
'broker-ws': brokerWs,
|
|
1445
|
+
'device-id': deviceId,
|
|
1446
|
+
'device-token': deviceToken,
|
|
1049
1447
|
});
|
|
1050
|
-
|
|
1051
|
-
console.log(`Bridge started in background (pid: ${child.pid}).`);
|
|
1448
|
+
console.log(`Bridge started in background (pid: ${pid}).`);
|
|
1052
1449
|
return;
|
|
1053
1450
|
}
|
|
1054
1451
|
|
|
@@ -1116,6 +1513,69 @@ async function createOpenclawInviteLink(flags) {
|
|
|
1116
1513
|
}
|
|
1117
1514
|
}
|
|
1118
1515
|
|
|
1516
|
+
function printOpenclawBridgeStatus(flags) {
|
|
1517
|
+
const bridgeState = readBridgeState();
|
|
1518
|
+
const runtimeStatus = readBridgeStatus();
|
|
1519
|
+
const jsonOutput = isTruthyFlag(flags.json);
|
|
1520
|
+
const redactToken = (value) => {
|
|
1521
|
+
const text = String(value || '').trim();
|
|
1522
|
+
if (!text) return '';
|
|
1523
|
+
if (text.length <= 12) return '***';
|
|
1524
|
+
return `${text.slice(0, 6)}...${text.slice(-6)}`;
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
const payload = {
|
|
1528
|
+
bridgeStatePath: resolveBridgeStatePath(),
|
|
1529
|
+
bridgeStatusPath: resolveBridgeStatusPath(),
|
|
1530
|
+
bridgeState: {
|
|
1531
|
+
brokerHttp: String(bridgeState.brokerHttp || ''),
|
|
1532
|
+
brokerWs: String(bridgeState.brokerWs || ''),
|
|
1533
|
+
deviceId: String(bridgeState.deviceId || ''),
|
|
1534
|
+
deviceToken: redactToken(bridgeState.deviceToken),
|
|
1535
|
+
claimedAt: bridgeState.claimedAt || null,
|
|
1536
|
+
expiresAt: bridgeState.expiresAt || null,
|
|
1537
|
+
},
|
|
1538
|
+
runtime: runtimeStatus,
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
if (jsonOutput) {
|
|
1542
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
console.log('Oomi Bridge Status');
|
|
1547
|
+
console.log('------------------');
|
|
1548
|
+
console.log(`Bridge state: ${payload.bridgeStatePath}`);
|
|
1549
|
+
console.log(`Runtime status: ${payload.bridgeStatusPath}`);
|
|
1550
|
+
console.log(`Device: ${payload.bridgeState.deviceId || 'not paired'}`);
|
|
1551
|
+
console.log(`Broker HTTP: ${payload.bridgeState.brokerHttp || 'not configured'}`);
|
|
1552
|
+
console.log(`Broker WS: ${payload.bridgeState.brokerWs || 'not configured'}`);
|
|
1553
|
+
if (payload.bridgeState.deviceToken) {
|
|
1554
|
+
console.log(`Device token: ${payload.bridgeState.deviceToken}`);
|
|
1555
|
+
}
|
|
1556
|
+
if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
|
|
1557
|
+
console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
|
|
1558
|
+
if (payload.runtime.lastConnectedAt) {
|
|
1559
|
+
console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
|
|
1560
|
+
}
|
|
1561
|
+
if (payload.runtime.lastDisconnectAt) {
|
|
1562
|
+
console.log(`Last disconnected: ${payload.runtime.lastDisconnectAt}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (payload.runtime.lastErrorClass || payload.runtime.lastErrorCode || payload.runtime.lastErrorMessage) {
|
|
1565
|
+
console.log(
|
|
1566
|
+
`Last error: ${String(payload.runtime.lastErrorClass || 'unknown')}/${String(payload.runtime.lastErrorCode || 'UNKNOWN')} ${String(payload.runtime.lastErrorMessage || '').trim()}`
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
if (payload.runtime.hint) {
|
|
1570
|
+
console.log(`Hint: ${payload.runtime.hint}`);
|
|
1571
|
+
}
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
console.log('Runtime state: no bridge runtime status recorded yet.');
|
|
1576
|
+
console.log('Run: oomi openclaw bridge --app-url https://www.oomi.ai');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1119
1579
|
function printOpenclawPluginSetup(flags) {
|
|
1120
1580
|
const bridgeState = readBridgeState();
|
|
1121
1581
|
const backendUrl = String(
|
|
@@ -1215,6 +1675,13 @@ async function main() {
|
|
|
1215
1675
|
}
|
|
1216
1676
|
|
|
1217
1677
|
if (command === 'openclaw' && subcommand === 'bridge') {
|
|
1678
|
+
if (Boolean(args.flags.detach)) {
|
|
1679
|
+
const detachedFlags = { ...args.flags };
|
|
1680
|
+
delete detachedFlags.detach;
|
|
1681
|
+
const pid = startBridgeDetachedProcess(detachedFlags);
|
|
1682
|
+
console.log(`Bridge started in background (pid: ${pid}).`);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1218
1685
|
await startOpenclawBridge(args.flags);
|
|
1219
1686
|
return;
|
|
1220
1687
|
}
|
|
@@ -1229,6 +1696,11 @@ async function main() {
|
|
|
1229
1696
|
return;
|
|
1230
1697
|
}
|
|
1231
1698
|
|
|
1699
|
+
if (command === 'openclaw' && subcommand === 'status') {
|
|
1700
|
+
printOpenclawBridgeStatus(args.flags);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1232
1704
|
if (command === 'openclaw' && subcommand === 'plugin') {
|
|
1233
1705
|
printOpenclawPluginSetup(args.flags);
|
|
1234
1706
|
return;
|