oomi-ai 0.2.5 → 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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/bin/oomi-ai.js +322 -11
  3. 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,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,109 @@ 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
+ 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
+
676
782
  const existingScopes = Array.isArray(params.scopes)
677
783
  ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
678
784
  : [];
@@ -684,17 +790,63 @@ function injectGatewayAuth(frameText, gatewayAuth) {
684
790
  }
685
791
  params.scopes = existingScopes;
686
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
+
687
803
  const auth = {};
688
- if (gatewayAuth.token) auth.token = gatewayAuth.token;
689
- else if (gatewayAuth.password) auth.password = gatewayAuth.password;
804
+ if (gatewayAuth.token) {
805
+ auth.token = gatewayAuth.token;
806
+ } else if (gatewayAuth.password) {
807
+ auth.password = gatewayAuth.password;
808
+ }
690
809
  if (Object.keys(auth).length > 0) {
691
810
  params.auth = auth;
692
- frame.params = params;
693
- return JSON.stringify(frame);
694
811
  }
695
- return frameText;
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 };
696
848
  } catch {
697
- return frameText;
849
+ return { frameText, waitForChallenge: false };
698
850
  }
699
851
  }
700
852
 
@@ -952,6 +1104,12 @@ async function startOpenclawBridge(flags) {
952
1104
  if (!gateway.token && !gateway.password) {
953
1105
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
954
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
+ }
955
1113
 
956
1114
  if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
957
1115
  console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
@@ -1099,21 +1257,157 @@ async function startOpenclawBridge(flags) {
1099
1257
 
1100
1258
  const brokerSocket = new WebSocket(wsUrl.toString());
1101
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
+
1102
1338
  const setupGatewaySession = (sessionId, sessionBridge) => {
1103
1339
  if (!sessionBridge || !sessionBridge.socket) return;
1104
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);
1105
1365
 
1106
1366
  gatewaySocket.on('open', () => {
1367
+ if (connectTimeout) {
1368
+ clearTimeout(connectTimeout);
1369
+ connectTimeout = null;
1370
+ }
1107
1371
  console.log(`[bridge] gateway.open ${sessionId}`);
1108
1372
  flushSessionQueue(sessionBridge);
1109
1373
  });
1110
1374
 
1111
1375
  gatewaySocket.on('message', (gatewayRaw) => {
1112
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
+ }
1113
1402
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1114
1403
  });
1115
1404
 
1116
1405
  gatewaySocket.on('close', (code, reason) => {
1406
+ if (connectTimeout) {
1407
+ clearTimeout(connectTimeout);
1408
+ connectTimeout = null;
1409
+ }
1410
+ clearChallengeTimer(sessionBridge);
1117
1411
  const reasonText = reason ? reason.toString() : '';
1118
1412
  console.log(
1119
1413
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
@@ -1129,6 +1423,10 @@ async function startOpenclawBridge(flags) {
1129
1423
  });
1130
1424
 
1131
1425
  gatewaySocket.on('error', (err) => {
1426
+ if (connectTimeout && gatewaySocket.readyState !== WebSocket.CONNECTING) {
1427
+ clearTimeout(connectTimeout);
1428
+ connectTimeout = null;
1429
+ }
1132
1430
  console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
1133
1431
  sendBrokerPayload(brokerSocket, {
1134
1432
  action: 'log',
@@ -1270,8 +1568,19 @@ async function startOpenclawBridge(flags) {
1270
1568
  console.log(`[bridge] client.frame ${sessionId}`);
1271
1569
  const sessionBridge = getOrCreateGatewaySession(sessionId);
1272
1570
  if (!sessionBridge) return;
1273
- const frameWithAuth = injectGatewayAuth(frame, gateway);
1274
- const result = forwardFrameToSession(sessionBridge, frameWithAuth);
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);
1275
1584
  if (result === 'queued') {
1276
1585
  console.log(`[bridge] client.frame queued ${sessionId}`);
1277
1586
  } else if (result === 'dropped') {
@@ -1285,6 +1594,7 @@ async function startOpenclawBridge(flags) {
1285
1594
  console.log(`[bridge] client.close ${sessionId}`);
1286
1595
  const sessionBridge = activeGatewaySockets.get(sessionId);
1287
1596
  if (sessionBridge && sessionBridge.socket) {
1597
+ clearChallengeTimer(sessionBridge);
1288
1598
  activeGatewaySockets.delete(sessionId);
1289
1599
  sessionBridge.socket.close(1000, 'client_closed');
1290
1600
  }
@@ -1300,6 +1610,7 @@ async function startOpenclawBridge(flags) {
1300
1610
  const reasonText = reason ? reason.toString() : '';
1301
1611
  console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
1302
1612
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1613
+ clearChallengeTimer(sessionBridge);
1303
1614
  activeGatewaySockets.delete(sessionId);
1304
1615
  try {
1305
1616
  sessionBridge.socket.close(1001, 'broker_disconnected');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"