oomi-ai 0.2.5 → 0.2.7

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 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,7 @@ 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
7
  import net from 'net';
8
8
  import { lookup as dnsLookup } from 'dns/promises';
9
9
  import WebSocket from 'ws';
@@ -18,6 +18,16 @@ const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
18
18
  const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
19
19
  const BRIDGE_RECONNECT_BASE_MS = 2000;
20
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');
21
31
 
22
32
  function parsePositiveInteger(value, fallback) {
23
33
  const num = Number(value);
@@ -666,13 +676,124 @@ async function fetchManagedGatewayConfig({ appUrl }) {
666
676
  return payload;
667
677
  }
668
678
 
669
- function injectGatewayAuth(frameText, gatewayAuth) {
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
+
670
765
  try {
671
766
  const frame = JSON.parse(frameText);
672
767
  if (frame?.type !== 'req' || frame?.method !== 'connect') {
673
- return frameText;
768
+ return { frameText, waitForChallenge: false };
674
769
  }
770
+
675
771
  const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
772
+
773
+ const client = params.client && typeof params.client === 'object' ? params.client : {};
774
+ const incomingClientId = typeof client.id === 'string' ? client.id.trim().toLowerCase() : '';
775
+ const incomingClientMode = typeof client.mode === 'string' ? client.mode.trim().toLowerCase() : '';
776
+ const proxiedBrowserClient =
777
+ incomingClientMode === 'webchat' ||
778
+ incomingClientId === 'webchat-ui' ||
779
+ incomingClientId === 'webchat' ||
780
+ incomingClientId === 'clawdbot-control-ui';
781
+
782
+ // Frames relayed by this bridge originate from a local Node websocket, not a browser.
783
+ // Keep gateway auth/nonce flow, but normalize browser-mode connects to backend identity
784
+ // so Control UI/webchat Origin checks don't reject proxied sessions.
785
+ client.id = proxiedBrowserClient
786
+ ? 'node-host'
787
+ : (typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'node-host');
788
+ client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
789
+ client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
790
+ client.mode = proxiedBrowserClient
791
+ ? 'backend'
792
+ : (typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'backend');
793
+ params.client = client;
794
+
795
+ params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
796
+
676
797
  const existingScopes = Array.isArray(params.scopes)
677
798
  ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
678
799
  : [];
@@ -684,17 +805,63 @@ function injectGatewayAuth(frameText, gatewayAuth) {
684
805
  }
685
806
  params.scopes = existingScopes;
686
807
 
808
+ if (!Array.isArray(params.caps)) {
809
+ params.caps = [];
810
+ }
811
+ if (!Array.isArray(params.commands)) {
812
+ params.commands = [];
813
+ }
814
+ if (!params.permissions || typeof params.permissions !== 'object') {
815
+ params.permissions = {};
816
+ }
817
+
687
818
  const auth = {};
688
- if (gatewayAuth.token) auth.token = gatewayAuth.token;
689
- else if (gatewayAuth.password) auth.password = gatewayAuth.password;
819
+ if (gatewayAuth.token) {
820
+ auth.token = gatewayAuth.token;
821
+ } else if (gatewayAuth.password) {
822
+ auth.password = gatewayAuth.password;
823
+ }
690
824
  if (Object.keys(auth).length > 0) {
691
825
  params.auth = auth;
692
- frame.params = params;
693
- return JSON.stringify(frame);
694
826
  }
695
- return frameText;
827
+
828
+ if (deviceIdentity) {
829
+ if (!connectNonce) {
830
+ return { frameText: null, waitForChallenge: true };
831
+ }
832
+
833
+ const signedAtMs = Date.now();
834
+ const tokenForSignature =
835
+ typeof auth.token === 'string' && auth.token.trim()
836
+ ? auth.token.trim()
837
+ : (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
838
+
839
+ const payload = buildDeviceAuthPayloadV3({
840
+ deviceId: deviceIdentity.deviceId,
841
+ clientId: client.id,
842
+ clientMode: client.mode,
843
+ role: params.role,
844
+ scopes: existingScopes,
845
+ signedAtMs,
846
+ token: tokenForSignature,
847
+ nonce: connectNonce,
848
+ platform: client.platform,
849
+ deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
850
+ });
851
+ const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
852
+ params.device = {
853
+ id: deviceIdentity.deviceId,
854
+ publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
855
+ signature,
856
+ signedAt: signedAtMs,
857
+ nonce: connectNonce,
858
+ };
859
+ }
860
+
861
+ frame.params = params;
862
+ return { frameText: JSON.stringify(frame), waitForChallenge: false };
696
863
  } catch {
697
- return frameText;
864
+ return { frameText, waitForChallenge: false };
698
865
  }
699
866
  }
700
867
 
@@ -952,6 +1119,12 @@ async function startOpenclawBridge(flags) {
952
1119
  if (!gateway.token && !gateway.password) {
953
1120
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
954
1121
  }
1122
+ const gatewayDeviceIdentity = loadGatewayDeviceIdentity();
1123
+ if (!gatewayDeviceIdentity) {
1124
+ console.warn(
1125
+ `[bridge] OpenClaw device identity not found at ${DEVICE_IDENTITY_PATH}; device-signed connect may fail on newer gateways.`
1126
+ );
1127
+ }
955
1128
 
956
1129
  if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
957
1130
  console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
@@ -1099,21 +1272,157 @@ async function startOpenclawBridge(flags) {
1099
1272
 
1100
1273
  const brokerSocket = new WebSocket(wsUrl.toString());
1101
1274
  let actionCableHeartbeat = null;
1275
+
1276
+ const clearChallengeTimer = (sessionBridge) => {
1277
+ if (sessionBridge && sessionBridge.connectChallengeTimer) {
1278
+ clearTimeout(sessionBridge.connectChallengeTimer);
1279
+ sessionBridge.connectChallengeTimer = null;
1280
+ }
1281
+ };
1282
+
1283
+ const queueConnectUntilChallenge = (sessionId, sessionBridge, frame) => {
1284
+ if (!sessionBridge || typeof frame !== 'string' || !frame) return;
1285
+ if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1286
+ sessionBridge.pendingConnectFrames = [];
1287
+ }
1288
+ if (sessionBridge.pendingConnectFrames.includes(frame)) {
1289
+ return;
1290
+ }
1291
+ sessionBridge.pendingConnectFrames.push(frame);
1292
+
1293
+ if (sessionBridge.connectChallengeTimer) {
1294
+ return;
1295
+ }
1296
+
1297
+ sessionBridge.connectChallengeTimer = setTimeout(() => {
1298
+ sessionBridge.connectChallengeTimer = null;
1299
+ const hasPending = Array.isArray(sessionBridge.pendingConnectFrames)
1300
+ ? sessionBridge.pendingConnectFrames.length > 0
1301
+ : false;
1302
+ if (!hasPending || sessionBridge.connectNonce) {
1303
+ return;
1304
+ }
1305
+ console.error(
1306
+ `[bridge] gateway.connect_challenge_timeout ${sessionId} (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms)`
1307
+ );
1308
+ sendBrokerPayload(brokerSocket, {
1309
+ action: 'log',
1310
+ type: 'log',
1311
+ sessionId,
1312
+ level: 'error',
1313
+ message: `Gateway challenge timeout (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms) for session ${sessionId}`,
1314
+ });
1315
+ try {
1316
+ sessionBridge.socket?.close(4009, 'connect_challenge_timeout');
1317
+ } catch {
1318
+ // no-op
1319
+ }
1320
+ }, BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS);
1321
+ };
1322
+
1323
+ const flushPendingConnectFrames = (sessionId, sessionBridge) => {
1324
+ if (!sessionBridge || !sessionBridge.connectNonce) return;
1325
+ const pending = Array.isArray(sessionBridge.pendingConnectFrames)
1326
+ ? sessionBridge.pendingConnectFrames.splice(0, sessionBridge.pendingConnectFrames.length)
1327
+ : [];
1328
+ if (pending.length === 0) {
1329
+ clearChallengeTimer(sessionBridge);
1330
+ return;
1331
+ }
1332
+
1333
+ clearChallengeTimer(sessionBridge);
1334
+ for (const pendingFrame of pending) {
1335
+ const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
1336
+ connectNonce: sessionBridge.connectNonce,
1337
+ deviceIdentity: gatewayDeviceIdentity,
1338
+ });
1339
+ if (!prepared.frameText || prepared.waitForChallenge) {
1340
+ continue;
1341
+ }
1342
+ const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1343
+ if (result === 'queued') {
1344
+ console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
1345
+ } else if (result === 'dropped') {
1346
+ console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
1347
+ } else {
1348
+ console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
1349
+ }
1350
+ }
1351
+ };
1352
+
1102
1353
  const setupGatewaySession = (sessionId, sessionBridge) => {
1103
1354
  if (!sessionBridge || !sessionBridge.socket) return;
1104
1355
  const gatewaySocket = sessionBridge.socket;
1356
+ if (typeof sessionBridge.connectNonce !== 'string') {
1357
+ sessionBridge.connectNonce = '';
1358
+ }
1359
+ if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1360
+ sessionBridge.pendingConnectFrames = [];
1361
+ }
1362
+ let connectTimeout = setTimeout(() => {
1363
+ if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
1364
+ console.error(
1365
+ `[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
1366
+ );
1367
+ sendBrokerPayload(brokerSocket, {
1368
+ action: 'log',
1369
+ type: 'log',
1370
+ sessionId,
1371
+ level: 'error',
1372
+ message: `Gateway connect timeout (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms) for session ${sessionId}`,
1373
+ });
1374
+ try {
1375
+ gatewaySocket.close(4008, 'gateway_connect_timeout');
1376
+ } catch {
1377
+ // no-op
1378
+ }
1379
+ }, BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS);
1105
1380
 
1106
1381
  gatewaySocket.on('open', () => {
1382
+ if (connectTimeout) {
1383
+ clearTimeout(connectTimeout);
1384
+ connectTimeout = null;
1385
+ }
1107
1386
  console.log(`[bridge] gateway.open ${sessionId}`);
1108
1387
  flushSessionQueue(sessionBridge);
1109
1388
  });
1110
1389
 
1111
1390
  gatewaySocket.on('message', (gatewayRaw) => {
1112
1391
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1392
+ const gatewayPayload = parseJsonPayload(frame);
1393
+ if (gatewayPayload?.event === 'connect.challenge') {
1394
+ const nonce =
1395
+ gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
1396
+ ? gatewayPayload.payload.nonce.trim()
1397
+ : '';
1398
+ if (!nonce) {
1399
+ console.error(`[bridge] gateway.connect.challenge missing nonce for ${sessionId}`);
1400
+ sendBrokerPayload(brokerSocket, {
1401
+ action: 'log',
1402
+ type: 'log',
1403
+ sessionId,
1404
+ level: 'error',
1405
+ message: `Gateway connect challenge missing nonce for session ${sessionId}`,
1406
+ });
1407
+ try {
1408
+ gatewaySocket.close(1008, 'connect_challenge_missing_nonce');
1409
+ } catch {
1410
+ // no-op
1411
+ }
1412
+ } else {
1413
+ sessionBridge.connectNonce = nonce;
1414
+ flushPendingConnectFrames(sessionId, sessionBridge);
1415
+ }
1416
+ }
1113
1417
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1114
1418
  });
1115
1419
 
1116
1420
  gatewaySocket.on('close', (code, reason) => {
1421
+ if (connectTimeout) {
1422
+ clearTimeout(connectTimeout);
1423
+ connectTimeout = null;
1424
+ }
1425
+ clearChallengeTimer(sessionBridge);
1117
1426
  const reasonText = reason ? reason.toString() : '';
1118
1427
  console.log(
1119
1428
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
@@ -1129,6 +1438,10 @@ async function startOpenclawBridge(flags) {
1129
1438
  });
1130
1439
 
1131
1440
  gatewaySocket.on('error', (err) => {
1441
+ if (connectTimeout && gatewaySocket.readyState !== WebSocket.CONNECTING) {
1442
+ clearTimeout(connectTimeout);
1443
+ connectTimeout = null;
1444
+ }
1132
1445
  console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
1133
1446
  sendBrokerPayload(brokerSocket, {
1134
1447
  action: 'log',
@@ -1270,8 +1583,19 @@ async function startOpenclawBridge(flags) {
1270
1583
  console.log(`[bridge] client.frame ${sessionId}`);
1271
1584
  const sessionBridge = getOrCreateGatewaySession(sessionId);
1272
1585
  if (!sessionBridge) return;
1273
- const frameWithAuth = injectGatewayAuth(frame, gateway);
1274
- const result = forwardFrameToSession(sessionBridge, frameWithAuth);
1586
+ const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
1587
+ connectNonce: sessionBridge.connectNonce,
1588
+ deviceIdentity: gatewayDeviceIdentity,
1589
+ });
1590
+ if (prepared.waitForChallenge) {
1591
+ queueConnectUntilChallenge(sessionId, sessionBridge, frame);
1592
+ console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
1593
+ return;
1594
+ }
1595
+ if (!prepared.frameText) {
1596
+ return;
1597
+ }
1598
+ const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1275
1599
  if (result === 'queued') {
1276
1600
  console.log(`[bridge] client.frame queued ${sessionId}`);
1277
1601
  } else if (result === 'dropped') {
@@ -1285,6 +1609,7 @@ async function startOpenclawBridge(flags) {
1285
1609
  console.log(`[bridge] client.close ${sessionId}`);
1286
1610
  const sessionBridge = activeGatewaySockets.get(sessionId);
1287
1611
  if (sessionBridge && sessionBridge.socket) {
1612
+ clearChallengeTimer(sessionBridge);
1288
1613
  activeGatewaySockets.delete(sessionId);
1289
1614
  sessionBridge.socket.close(1000, 'client_closed');
1290
1615
  }
@@ -1300,6 +1625,7 @@ async function startOpenclawBridge(flags) {
1300
1625
  const reasonText = reason ? reason.toString() : '';
1301
1626
  console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
1302
1627
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1628
+ clearChallengeTimer(sessionBridge);
1303
1629
  activeGatewaySockets.delete(sessionId);
1304
1630
  try {
1305
1631
  sessionBridge.socket.close(1001, 'broker_disconnected');
@@ -2,7 +2,7 @@
2
2
  "id": "oomi-ai",
3
3
  "name": "Oomi Channel Plugin",
4
4
  "description": "Managed Oomi channel integration for OpenClaw.",
5
- "version": "0.2.4",
5
+ "version": "0.2.6",
6
6
  "author": "Oomi",
7
7
  "license": "MIT",
8
8
  "openclawVersion": ">=0.5.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"