opencami 1.8.2 → 1.8.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.
Files changed (83) hide show
  1. package/README.md +139 -14
  2. package/bin/opencami.js +15 -6
  3. package/dist/client/assets/{CSPContext-DeJH85nm.js → CSPContext-6t3O1emU.js} +1 -1
  4. package/dist/client/assets/{DirectionContext-CxhRpXkm.js → DirectionContext-C6goXEY_.js} +1 -1
  5. package/dist/client/assets/_sessionKey-B5Viv43f.js +23 -0
  6. package/dist/client/assets/agents-BmE6QOwl.js +2 -0
  7. package/dist/client/assets/agents-screen-pHUzJxX5.js +1 -0
  8. package/dist/client/assets/bots-BeOkwrXr.js +2 -0
  9. package/dist/client/assets/{bots-screen-Be3cfGgq.js → bots-screen-B79bAYvf.js} +1 -1
  10. package/dist/client/assets/{button-D9Plv7hu.js → button-CK8tKQ-Z.js} +1 -1
  11. package/dist/client/assets/{composite-B2KCZKKL.js → composite-feK0c-xF.js} +1 -1
  12. package/dist/client/assets/{connect-DuJfnyNK.js → connect-02tmQV_v.js} +1 -1
  13. package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
  14. package/dist/client/assets/{dashboard-00GpXm5V.js → dashboard-DQ0zDQKd.js} +1 -1
  15. package/dist/client/assets/event-BsD1rqGT.js +1 -0
  16. package/dist/client/assets/file-explorer-screen-Ds7LeJTd.js +1 -0
  17. package/dist/client/assets/files-e40B1zFy.js +2 -0
  18. package/dist/client/assets/go-CxLEBnE3.js +1 -0
  19. package/dist/client/assets/{index-Yo5UhdZV.js → index-lK3yGoTI.js} +1 -1
  20. package/dist/client/assets/{index-DtGzE-ea.js → index-rljDU_1M.js} +2 -2
  21. package/dist/client/assets/keyboard-shortcuts-dialog-Bb_GOr9L.js +1 -0
  22. package/dist/client/assets/main-Dq6jpr6-.js +210 -0
  23. package/dist/client/assets/{markdown-DtWnt4NA.js → markdown-C7_Aipwd.js} +37 -37
  24. package/dist/client/assets/memory-C7UG-1le.js +2 -0
  25. package/dist/client/assets/memory-screen-CUFBWsq5.js +1 -0
  26. package/dist/client/assets/menu-n6L--M9R.js +1 -0
  27. package/dist/client/assets/{opencami-logo-Bmge6-FB.js → opencami-logo-zuSBm5Br.js} +1 -1
  28. package/dist/client/assets/php-Dhbhpdrm.js +1 -0
  29. package/dist/client/assets/proxy-BU8Bw1Vt.js +9 -0
  30. package/dist/client/assets/{react-DODKNyyU.js → react-BLyCEWpN.js} +1 -1
  31. package/dist/client/assets/ruby-NiQIzKut.js +1 -0
  32. package/dist/client/assets/search-dialog-yB4w5ajo.js +1 -0
  33. package/dist/client/assets/session-export-dialog-qbZgd2Zo.js +1 -0
  34. package/dist/client/assets/settings-dialog-CHJbvpgk.js +1 -0
  35. package/dist/client/assets/skills-DoKPPhNY.js +2 -0
  36. package/dist/client/assets/{skills-panel-DSiH-DLs.js → skills-panel-BH27r3nC.js} +1 -1
  37. package/dist/client/assets/styles-CXV5jZiD.css +1 -0
  38. package/dist/client/assets/{swift-Dg5xB15N.js → swift-D82vCrfD.js} +1 -1
  39. package/dist/client/assets/switch-BD3a0LRm.js +1 -0
  40. package/dist/client/assets/tabs-DI1e-kzz.js +1 -0
  41. package/dist/client/assets/tooltip-BbH3QWvK.js +1 -0
  42. package/dist/client/assets/use-file-explorer-state-DBfLeAyz.js +12 -0
  43. package/dist/client/assets/{useBaseUiId-KQTzRPLp.js → useBaseUiId-MgM4ouhx.js} +1 -1
  44. package/dist/client/assets/{useCompositeItem-BPY2_hF_.js → useCompositeItem-OhltNFdZ.js} +1 -1
  45. package/dist/client/assets/{useControlled-B5pEEz2V.js → useControlled-BQxTgsOd.js} +1 -1
  46. package/dist/client/assets/{useMutation-BsQD6FKe.js → useMutation-12DyB3Ox.js} +1 -1
  47. package/dist/client/assets/useOnFirstRender-7qoaK5sI.js +1 -0
  48. package/dist/client/assets/{useQuery-CmAJuY2W.js → useQuery-Ctiljcrr.js} +1 -1
  49. package/dist/server/assets/{_sessionKey-Bq_fl7uv.js → _sessionKey-CH8wIyED.js} +3 -3
  50. package/dist/server/assets/{_tanstack-start-manifest_v-BMCAWon2.js → _tanstack-start-manifest_v-C5HBDfQB.js} +1 -1
  51. package/dist/server/assets/{index-C2hVqxBl.js → index-NcNCVGTL.js} +4 -3
  52. package/dist/server/assets/{router-bN_iTo0B.js → router-Brzpnz55.js} +449 -70
  53. package/dist/server/assets/{search-dialog-DReM5ZD2.js → search-dialog-BNhjVvKc.js} +5 -4
  54. package/dist/server/assets/{settings-dialog-BUOrQN3Z.js → settings-dialog-CWcmfDiV.js} +5 -4
  55. package/dist/server/server.js +195 -38
  56. package/package.json +4 -1
  57. package/dist/client/assets/_sessionKey-CQE0brGK.js +0 -23
  58. package/dist/client/assets/agents-CMTFd_sG.js +0 -2
  59. package/dist/client/assets/agents-screen-BNQGEqcW.js +0 -1
  60. package/dist/client/assets/bots-B6oGzCxP.js +0 -2
  61. package/dist/client/assets/csharp-K5feNrxe.js +0 -1
  62. package/dist/client/assets/event-DD8Cz4O9.js +0 -1
  63. package/dist/client/assets/file-explorer-screen-CxwemBES.js +0 -1
  64. package/dist/client/assets/files-DyBJVXBu.js +0 -2
  65. package/dist/client/assets/go-Dn2_MT6a.js +0 -1
  66. package/dist/client/assets/keyboard-shortcuts-dialog-BZwd-iyV.js +0 -1
  67. package/dist/client/assets/main-CgwdHc9W.js +0 -210
  68. package/dist/client/assets/memory-l756yiNq.js +0 -2
  69. package/dist/client/assets/memory-screen-BQtVRuzE.js +0 -1
  70. package/dist/client/assets/menu-BsS6CDf_.js +0 -1
  71. package/dist/client/assets/php-CDn_0X-4.js +0 -1
  72. package/dist/client/assets/popupStateMapping-D0ZbJR_o.js +0 -1
  73. package/dist/client/assets/proxy-CYZeDXoy.js +0 -9
  74. package/dist/client/assets/ruby-FDmvQDUv.js +0 -1
  75. package/dist/client/assets/search-dialog-DW91SK30.js +0 -1
  76. package/dist/client/assets/session-export-dialog-CliO9Ob-.js +0 -1
  77. package/dist/client/assets/settings-dialog-C1u52aju.js +0 -1
  78. package/dist/client/assets/skills-8T_avaVb.js +0 -2
  79. package/dist/client/assets/styles-DvaLh0o1.css +0 -1
  80. package/dist/client/assets/switch-DbgQPO6i.js +0 -1
  81. package/dist/client/assets/tabs-BsAvZnlD.js +0 -1
  82. package/dist/client/assets/tooltip-DLmutB5C.js +0 -1
  83. package/dist/client/assets/use-file-explorer-state-Cg_yDYJl.js +0 -12
@@ -1,17 +1,18 @@
1
1
  import { createRootRoute, Outlet, HeadContent, Scripts, createFileRoute, lazyRouteComponent, redirect, createRouter } from "@tanstack/react-router";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
4
- import { randomUUID } from "node:crypto";
5
- import WebSocket from "ws";
6
- import { readFileSync, existsSync } from "node:fs";
7
- import path, { join, resolve, relative, extname } from "node:path";
4
+ import crypto, { randomUUID } from "node:crypto";
5
+ import fs, { readFileSync, existsSync } from "node:fs";
8
6
  import os, { homedir } from "node:os";
7
+ import path, { join, resolve, relative, extname } from "node:path";
8
+ import WebSocket from "ws";
9
9
  import { json } from "@tanstack/router-core/ssr/client";
10
10
  import { PassThrough, Readable } from "node:stream";
11
- import { execSync } from "node:child_process";
11
+ import { execFile, execSync } from "node:child_process";
12
+ import { promisify } from "node:util";
12
13
  import { readFile, mkdir, writeFile, rename, stat, readdir, rm, realpath, lstat } from "node:fs/promises";
13
14
  import { posix } from "path";
14
- const appCss = "/assets/styles-DvaLh0o1.css";
15
+ const appCss = "/assets/styles-CXV5jZiD.css";
15
16
  const swRegisterScript = `
16
17
  (() => {
17
18
  // Skip PWA service worker inside Capacitor native shell — they conflict
@@ -369,11 +370,11 @@ const $$splitComponentImporter$2 = () => import("./agents-CmQ4vvXm.js");
369
370
  const Route$t = createFileRoute("/agents")({
370
371
  component: lazyRouteComponent($$splitComponentImporter$2, "component")
371
372
  });
372
- const $$splitComponentImporter$1 = () => import("./index-C2hVqxBl.js");
373
+ const $$splitComponentImporter$1 = () => import("./index-NcNCVGTL.js");
373
374
  const Route$s = createFileRoute("/")({
374
375
  component: lazyRouteComponent($$splitComponentImporter$1, "component")
375
376
  });
376
- const $$splitComponentImporter = () => import("./_sessionKey-Bq_fl7uv.js").then((n) => n.$);
377
+ const $$splitComponentImporter = () => import("./_sessionKey-CH8wIyED.js").then((n) => n.$);
377
378
  const Route$r = createFileRoute("/chat/$sessionKey")({
378
379
  component: lazyRouteComponent($$splitComponentImporter, "component")
379
380
  });
@@ -388,26 +389,202 @@ function getGatewayConfig() {
388
389
  }
389
390
  return { url, token, password };
390
391
  }
391
- function buildConnectParams(token, password) {
392
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
393
+ function base64UrlEncode(buf) {
394
+ return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
395
+ }
396
+ function derivePublicKeyRaw(publicKeyPem) {
397
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
398
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
399
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
400
+ }
401
+ return spki;
402
+ }
403
+ function fingerprintPublicKey(publicKeyPem) {
404
+ const raw = derivePublicKeyRaw(publicKeyPem);
405
+ return crypto.createHash("sha256").update(raw).digest("hex");
406
+ }
407
+ function ensureDir(filePath) {
408
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
409
+ }
410
+ function resolveDeviceIdentityPath() {
411
+ return path.join(os.homedir(), ".opencami", "identity", "device.json");
412
+ }
413
+ function resolveDeviceTokensPath() {
414
+ return path.join(os.homedir(), ".opencami", "identity", "device-tokens.json");
415
+ }
416
+ function hashGatewayUrl(url) {
417
+ return crypto.createHash("sha256").update(url.trim()).digest("hex");
418
+ }
419
+ function loadDeviceToken(deviceId, gatewayUrl) {
420
+ const filePath = resolveDeviceTokensPath();
421
+ try {
422
+ if (!fs.existsSync(filePath)) return "";
423
+ const raw = fs.readFileSync(filePath, "utf8");
424
+ const parsed = JSON.parse(raw);
425
+ const key = `${deviceId}:${hashGatewayUrl(gatewayUrl)}`;
426
+ const token = parsed[key];
427
+ return typeof token === "string" ? token : "";
428
+ } catch {
429
+ return "";
430
+ }
431
+ }
432
+ function storeDeviceToken(deviceId, gatewayUrl, token) {
433
+ if (!token) return;
434
+ const filePath = resolveDeviceTokensPath();
435
+ const key = `${deviceId}:${hashGatewayUrl(gatewayUrl)}`;
436
+ try {
437
+ let parsed = {};
438
+ if (fs.existsSync(filePath)) {
439
+ const raw = fs.readFileSync(filePath, "utf8");
440
+ parsed = JSON.parse(raw);
441
+ }
442
+ parsed[key] = token;
443
+ ensureDir(filePath);
444
+ fs.writeFileSync(filePath, `${JSON.stringify(parsed, null, 2)}
445
+ `, { mode: 384 });
446
+ try {
447
+ fs.chmodSync(filePath, 384);
448
+ } catch {
449
+ }
450
+ } catch {
451
+ }
452
+ }
453
+ function loadOrCreateDeviceIdentity(filePath = resolveDeviceIdentityPath()) {
454
+ try {
455
+ if (fs.existsSync(filePath)) {
456
+ const raw = fs.readFileSync(filePath, "utf8");
457
+ const parsed = JSON.parse(raw);
458
+ if (parsed?.version === 1 && typeof parsed.deviceId === "string" && typeof parsed.publicKeyPem === "string" && typeof parsed.privateKeyPem === "string") {
459
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
460
+ if (derivedId && derivedId !== parsed.deviceId) {
461
+ const updated = { ...parsed, deviceId: derivedId };
462
+ fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}
463
+ `, { mode: 384 });
464
+ try {
465
+ fs.chmodSync(filePath, 384);
466
+ } catch {
467
+ }
468
+ return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
469
+ }
470
+ return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
471
+ }
472
+ }
473
+ } catch {
474
+ }
475
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
476
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
477
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
478
+ const deviceId = fingerprintPublicKey(publicKeyPem);
479
+ ensureDir(filePath);
480
+ const stored = {
481
+ version: 1,
482
+ deviceId,
483
+ publicKeyPem,
484
+ privateKeyPem,
485
+ createdAtMs: Date.now()
486
+ };
487
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}
488
+ `, { mode: 384 });
489
+ try {
490
+ fs.chmodSync(filePath, 384);
491
+ } catch {
492
+ }
493
+ return { deviceId, publicKeyPem, privateKeyPem };
494
+ }
495
+ function signDevicePayload(privateKeyPem, payload) {
496
+ const key = crypto.createPrivateKey(privateKeyPem);
497
+ return base64UrlEncode(crypto.sign(null, Buffer.from(payload, "utf8"), key));
498
+ }
499
+ function publicKeyRawBase64UrlFromPem(publicKeyPem) {
500
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
501
+ }
502
+ function buildDeviceAuthPayload(args) {
503
+ const scopes = args.scopes.join(",");
504
+ const token = args.token ?? "";
505
+ return ["v2", args.deviceId, args.clientId, args.clientMode, args.role, scopes, String(args.signedAtMs), token, args.nonce].join("|");
506
+ }
507
+ function loadOrCreateInstanceId() {
508
+ const filePath = path.join(os.homedir(), ".opencami", "identity", "instance-id.txt");
509
+ try {
510
+ if (fs.existsSync(filePath)) {
511
+ const v2 = fs.readFileSync(filePath, "utf8").trim();
512
+ if (v2) return v2;
513
+ }
514
+ } catch {
515
+ }
516
+ const v = randomUUID();
517
+ ensureDir(filePath);
518
+ fs.writeFileSync(filePath, `${v}
519
+ `, { mode: 384 });
520
+ try {
521
+ fs.chmodSync(filePath, 384);
522
+ } catch {
523
+ }
524
+ return v;
525
+ }
526
+ function buildConnectParams(url, token, password, nonce) {
527
+ const clientId = "openclaw-control-ui";
528
+ const clientMode = "webchat";
529
+ const role = "operator";
530
+ const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
531
+ if (!nonce) {
532
+ throw new Error(
533
+ "OpenClaw did not send connect.challenge nonce in time. If you are connecting cross-origin, ensure your origin is allowed (gateway.controlUi.allowedOrigins)."
534
+ );
535
+ }
536
+ const identity = loadOrCreateDeviceIdentity();
537
+ const storedToken = loadDeviceToken(identity.deviceId, url);
538
+ const signedAtMs = Date.now();
539
+ const payload = buildDeviceAuthPayload({
540
+ deviceId: identity.deviceId,
541
+ clientId,
542
+ clientMode,
543
+ role,
544
+ scopes,
545
+ signedAtMs,
546
+ token: token || null,
547
+ nonce
548
+ });
549
+ const signature = signDevicePayload(identity.privateKeyPem, payload);
392
550
  return {
393
551
  minProtocol: 3,
394
552
  maxProtocol: 3,
395
553
  client: {
396
- id: "gateway-client",
554
+ id: clientId,
397
555
  displayName: "OpenCami",
398
556
  version: "dev",
399
557
  platform: process.platform,
400
- mode: "ui",
401
- instanceId: randomUUID()
558
+ mode: clientMode,
559
+ instanceId: loadOrCreateInstanceId()
402
560
  },
561
+ caps: [],
403
562
  auth: {
404
563
  token: token || void 0,
405
- password: password || void 0
564
+ password: password || void 0,
565
+ deviceToken: storedToken || void 0
406
566
  },
407
567
  role: "operator",
408
- scopes: ["operator.read", "operator.write", "operator.admin"]
568
+ scopes,
569
+ device: {
570
+ id: identity.deviceId,
571
+ publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
572
+ signature,
573
+ signedAt: signedAtMs,
574
+ nonce
575
+ },
576
+ userAgent: `opencami/${process.env.npm_package_version ?? "dev"} (node ${process.version})`,
577
+ locale: process.env.LANG || "en"
409
578
  };
410
579
  }
580
+ class GatewayResponseError extends Error {
581
+ code;
582
+ constructor(message, code) {
583
+ super(message);
584
+ this.name = "GatewayResponseError";
585
+ this.code = code;
586
+ }
587
+ }
411
588
  class PersistentGatewayConnection {
412
589
  ws = null;
413
590
  connected = false;
@@ -417,6 +594,8 @@ class PersistentGatewayConnection {
417
594
  reconnectDelay = 1e3;
418
595
  maxReconnectDelay = 3e4;
419
596
  destroyed = false;
597
+ _devicePending = false;
598
+ _deviceId = "";
420
599
  // Event listeners keyed by sessionKey — each sessionKey can have multiple listeners
421
600
  sessionListeners = /* @__PURE__ */ new Map();
422
601
  // Listeners that receive ALL events (for debugging or global subscriptions)
@@ -426,6 +605,12 @@ class PersistentGatewayConnection {
426
605
  get isConnected() {
427
606
  return this.connected && this.ws?.readyState === WebSocket.OPEN;
428
607
  }
608
+ getDeviceStatus() {
609
+ return {
610
+ deviceId: this._deviceId,
611
+ isPending: this._devicePending
612
+ };
613
+ }
429
614
  /** Ensure the persistent connection is up and authenticated. */
430
615
  async ensureConnected() {
431
616
  if (this.isConnected) return;
@@ -440,7 +625,8 @@ class PersistentGatewayConnection {
440
625
  async _connect() {
441
626
  if (this.destroyed) throw new Error("Connection destroyed");
442
627
  const { url, token, password } = getGatewayConfig();
443
- const ws = new WebSocket(url);
628
+ const origin = process.env.OPENCAMI_ORIGIN?.trim();
629
+ const ws = origin ? new WebSocket(url, { headers: { Origin: origin } }) : new WebSocket(url);
444
630
  this.ws = ws;
445
631
  await new Promise((resolve2, reject) => {
446
632
  const onOpen = () => {
@@ -458,19 +644,105 @@ class PersistentGatewayConnection {
458
644
  ws.on("open", onOpen);
459
645
  ws.on("error", onError);
460
646
  });
647
+ const nonce = await new Promise((resolve2) => {
648
+ let done = false;
649
+ const timer = setTimeout(() => {
650
+ if (done) return;
651
+ done = true;
652
+ resolve2("");
653
+ }, 3e3);
654
+ const onMessage = (data) => {
655
+ try {
656
+ const str = typeof data === "string" ? data : data.toString();
657
+ const parsed = JSON.parse(str);
658
+ if (parsed.type === "event" && parsed.event === "connect.challenge") {
659
+ const n = parsed.payload?.nonce;
660
+ if (typeof n === "string" && n.length > 0) {
661
+ clearTimeout(timer);
662
+ ws.off("message", onMessage);
663
+ if (done) return;
664
+ done = true;
665
+ resolve2(n);
666
+ }
667
+ }
668
+ } catch {
669
+ }
670
+ };
671
+ ws.on("message", onMessage);
672
+ });
461
673
  ws.on("message", (data) => this._onMessage(data));
462
674
  ws.on("close", () => this._onClose());
463
675
  ws.on("error", () => {
464
676
  });
465
677
  const connectId = randomUUID();
466
- const connectParams = buildConnectParams(token, password);
467
- ws.send(JSON.stringify({
468
- type: "req",
469
- id: connectId,
470
- method: "connect",
471
- params: connectParams
472
- }));
473
- await this._waitForRes(connectId, 1e4);
678
+ const shouldFallback = process.env.OPENCAMI_DEVICE_AUTH_FALLBACK === "1" || process.env.OPENCAMI_DEVICE_AUTH_FALLBACK === "true";
679
+ try {
680
+ const connectParams = buildConnectParams(url, token, password, nonce);
681
+ this._deviceId = connectParams.device?.id ?? "";
682
+ ws.send(
683
+ JSON.stringify({
684
+ type: "req",
685
+ id: connectId,
686
+ method: "connect",
687
+ params: connectParams
688
+ })
689
+ );
690
+ const hello = await this._waitForRes(connectId, 1e4);
691
+ if (hello?.auth?.deviceToken) {
692
+ const identity = loadOrCreateDeviceIdentity();
693
+ storeDeviceToken(identity.deviceId, url, hello.auth.deviceToken);
694
+ }
695
+ const grantedScopes = hello?.auth?.scopes;
696
+ if (Array.isArray(grantedScopes) && !grantedScopes.includes("operator.read")) {
697
+ throw new Error(
698
+ `Gateway connected but missing required scope: operator.read (granted: ${grantedScopes.join(", ")})`
699
+ );
700
+ }
701
+ this._devicePending = false;
702
+ } catch (err) {
703
+ const code = err instanceof GatewayResponseError ? err.code : "";
704
+ const message = err instanceof Error ? err.message : String(err);
705
+ if (code === "device_pending" || message.toLowerCase().includes("pending")) {
706
+ this._devicePending = true;
707
+ }
708
+ if (!shouldFallback) throw err;
709
+ console.warn(
710
+ "[gateway-ws] Device auth connect failed; retrying without device identity (fallback enabled):",
711
+ err instanceof Error ? err.message : err
712
+ );
713
+ const fallbackId = randomUUID();
714
+ const fallbackParams = {
715
+ minProtocol: 3,
716
+ maxProtocol: 3,
717
+ client: {
718
+ id: "openclaw-control-ui",
719
+ displayName: "OpenCami",
720
+ version: "dev",
721
+ platform: process.platform,
722
+ mode: "webchat",
723
+ instanceId: loadOrCreateInstanceId()
724
+ },
725
+ caps: [],
726
+ auth: {
727
+ token: token || void 0,
728
+ password: password || void 0
729
+ },
730
+ role: "operator",
731
+ scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
732
+ userAgent: `opencami/${process.env.npm_package_version ?? "dev"} (node ${process.version})`,
733
+ locale: process.env.LANG || "en"
734
+ };
735
+ ws.send(
736
+ JSON.stringify({
737
+ type: "req",
738
+ id: fallbackId,
739
+ method: "connect",
740
+ params: fallbackParams
741
+ })
742
+ );
743
+ await this._waitForRes(fallbackId, 1e4);
744
+ this._devicePending = false;
745
+ }
474
746
  this.connected = true;
475
747
  this.reconnectDelay = 1e3;
476
748
  console.log("[gateway-ws] Persistent connection established");
@@ -487,7 +759,7 @@ class PersistentGatewayConnection {
487
759
  if (parsed.ok) {
488
760
  pending.resolve(parsed.payload);
489
761
  } else {
490
- pending.reject(new Error(parsed.error?.message ?? "gateway error"));
762
+ pending.reject(new GatewayResponseError(parsed.error?.message ?? "gateway error", parsed.error?.code));
491
763
  }
492
764
  }
493
765
  return;
@@ -532,9 +804,9 @@ class PersistentGatewayConnection {
532
804
  }
533
805
  _extractSessionKey(event) {
534
806
  const payload = event.payload;
535
- if (typeof payload?.sessionKey === "string") return payload.sessionKey;
536
- if (typeof payload?.session === "string") return payload.session;
537
- if (payload?.data && typeof payload.data?.sessionKey === "string") {
807
+ if (typeof payload.sessionKey === "string") return payload.sessionKey;
808
+ if (typeof payload.session === "string") return payload.session;
809
+ if (payload.data && typeof payload.data?.sessionKey === "string") {
538
810
  return payload.data.sessionKey;
539
811
  }
540
812
  return null;
@@ -652,6 +924,10 @@ function subscribeGatewayEvents(sessionKey, listener) {
652
924
  const conn = getPersistentConnection();
653
925
  return conn.subscribe(sessionKey, listener);
654
926
  }
927
+ function getDeviceStatus() {
928
+ const conn = getPersistentConnection();
929
+ return conn.getDeviceStatus();
930
+ }
655
931
  async function gatewayConnectCheck() {
656
932
  const conn = getPersistentConnection();
657
933
  await conn.ensureConnected();
@@ -1116,9 +1392,34 @@ const RECOMMENDED_SKILLS = [
1116
1392
  "clawd-docs-v2",
1117
1393
  "therapy-mode"
1118
1394
  ];
1119
- function runCmd(cmd) {
1395
+ const execFileAsync = promisify(execFile);
1396
+ const ALLOWED_SORTS = /* @__PURE__ */ new Set(["trending", "downloads", "installs", "newest"]);
1397
+ const SLUG_PATTERN = /^[a-zA-Z0-9/_-]+$/;
1398
+ function parsePositiveInt(input, fallback, max) {
1399
+ if (!input) return fallback;
1400
+ const parsed = Number.parseInt(input, 10);
1401
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
1402
+ return Math.min(parsed, max);
1403
+ }
1404
+ function parseSort(input) {
1405
+ if (!input) return "trending";
1406
+ const normalized = input.trim().toLowerCase();
1407
+ return ALLOWED_SORTS.has(normalized) ? normalized : "trending";
1408
+ }
1409
+ function parseSlug(input) {
1410
+ if (typeof input !== "string") return null;
1411
+ const slug = input.trim();
1412
+ if (!slug || !SLUG_PATTERN.test(slug)) return null;
1413
+ return slug;
1414
+ }
1415
+ async function runCmd(args) {
1120
1416
  try {
1121
- return execSync(cmd, { encoding: "utf-8", timeout: 3e4 }).trim();
1417
+ const { stdout } = await execFileAsync("clawhub", args, {
1418
+ encoding: "utf-8",
1419
+ timeout: 3e4,
1420
+ maxBuffer: 1024 * 1024 * 8
1421
+ });
1422
+ return stdout.trim();
1122
1423
  } catch (err) {
1123
1424
  const msg = err instanceof Error ? err.message : String(err);
1124
1425
  throw new Error(`Command failed: ${msg}`);
@@ -1165,15 +1466,13 @@ const Route$n = createFileRoute("/api/skills")({
1165
1466
  const url = new URL(request.url);
1166
1467
  const action = url.searchParams.get("action") || "installed";
1167
1468
  if (action === "installed") {
1168
- const output = runCmd("clawhub list");
1469
+ const output = await runCmd(["list"]);
1169
1470
  return json({ ok: true, skills: parseInstalledSkills(output) });
1170
1471
  }
1171
1472
  if (action === "explore") {
1172
- const sort = url.searchParams.get("sort") || "trending";
1173
- const limit = url.searchParams.get("limit") || "25";
1174
- const safeSort = sort.replace(/[^a-z-]/gi, "");
1175
- const safeLimit = String(parseInt(limit, 10) || 25);
1176
- const raw = runCmd(`clawhub explore --json --limit ${safeLimit} --sort ${safeSort}`);
1473
+ const sort = parseSort(url.searchParams.get("sort"));
1474
+ const limit = parsePositiveInt(url.searchParams.get("limit"), 25, 200);
1475
+ const raw = await runCmd(["explore", "--json", "--limit", String(limit), "--sort", sort]);
1177
1476
  const output = raw.substring(raw.indexOf("{"));
1178
1477
  try {
1179
1478
  const data = JSON.parse(output);
@@ -1184,11 +1483,9 @@ const Route$n = createFileRoute("/api/skills")({
1184
1483
  }
1185
1484
  if (action === "search") {
1186
1485
  const q = url.searchParams.get("q") || "";
1187
- const limit = url.searchParams.get("limit") || "10";
1486
+ const limit = parsePositiveInt(url.searchParams.get("limit"), 10, 200);
1188
1487
  if (!q.trim()) return json({ ok: true, skills: [] });
1189
- const safeLimit = String(parseInt(limit, 10) || 10);
1190
- const safeQ = q.replace(/"/g, '\\"');
1191
- const raw = runCmd(`clawhub search "${safeQ}" --limit ${safeLimit}`);
1488
+ const raw = await runCmd(["search", q, "--limit", String(limit)]);
1192
1489
  const output = raw.replace(/^- Searching\n?/, "");
1193
1490
  return json({ ok: true, skills: parseSearchResults(output) });
1194
1491
  }
@@ -1202,7 +1499,7 @@ const Route$n = createFileRoute("/api/skills")({
1202
1499
  for (const sort of ["downloads", "installs", "newest", "trending"]) {
1203
1500
  if (found.size >= slugSet.size) break;
1204
1501
  try {
1205
- const raw = runCmd(`clawhub explore --json --limit 200 --sort ${sort}`);
1502
+ const raw = await runCmd(["explore", "--json", "--limit", "200", "--sort", sort]);
1206
1503
  const output = raw.substring(raw.indexOf("{"));
1207
1504
  const data = JSON.parse(output);
1208
1505
  const items = Array.isArray(data) ? data : data.items || [];
@@ -1230,15 +1527,14 @@ const Route$n = createFileRoute("/api/skills")({
1230
1527
  try {
1231
1528
  const body = await request.json().catch(() => ({}));
1232
1529
  const action = typeof body.action === "string" ? body.action : "";
1233
- const slug = typeof body.slug === "string" ? body.slug.trim() : "";
1234
- if (!slug) return json({ ok: false, error: "slug is required" }, { status: 400 });
1235
- const safeSlug = slug.replace(/[^a-zA-Z0-9/_-]/g, "");
1530
+ const slug = parseSlug(body.slug);
1531
+ if (!slug) return json({ ok: false, error: "Valid slug is required" }, { status: 400 });
1236
1532
  if (action === "install") {
1237
- const output = runCmd(`clawhub install ${safeSlug} --no-input`);
1533
+ const output = await runCmd(["install", slug, "--no-input"]);
1238
1534
  return json({ ok: true, output });
1239
1535
  }
1240
1536
  if (action === "update") {
1241
- const output = runCmd(`clawhub update ${safeSlug} --no-input`);
1537
+ const output = await runCmd(["update", slug, "--no-input"]);
1242
1538
  return json({ ok: true, output });
1243
1539
  }
1244
1540
  return json({ ok: false, error: "Unknown action" }, { status: 400 });
@@ -1501,12 +1797,38 @@ const Route$k = createFileRoute("/api/ping")({
1501
1797
  GET: async () => {
1502
1798
  try {
1503
1799
  await gatewayConnectCheck();
1504
- return json({ ok: true });
1800
+ const status = getDeviceStatus();
1801
+ if (status.isPending) {
1802
+ return json(
1803
+ {
1804
+ ok: false,
1805
+ error: "device pending approval",
1806
+ deviceId: status.deviceId,
1807
+ approveCommand: `openclaw devices approve ${status.deviceId}`
1808
+ },
1809
+ { status: 503 }
1810
+ );
1811
+ }
1812
+ return json({ ok: true, deviceId: status.deviceId, isPending: false });
1505
1813
  } catch (err) {
1814
+ const status = getDeviceStatus();
1815
+ if (status.isPending) {
1816
+ return json(
1817
+ {
1818
+ ok: false,
1819
+ error: "device pending approval",
1820
+ deviceId: status.deviceId,
1821
+ approveCommand: `openclaw devices approve ${status.deviceId}`
1822
+ },
1823
+ { status: 503 }
1824
+ );
1825
+ }
1506
1826
  return json(
1507
1827
  {
1508
1828
  ok: false,
1509
- error: err instanceof Error ? err.message : String(err)
1829
+ error: err instanceof Error ? err.message : String(err),
1830
+ deviceId: status.deviceId,
1831
+ isPending: status.isPending
1510
1832
  },
1511
1833
  { status: 503 }
1512
1834
  );
@@ -1755,27 +2077,73 @@ Rules:
1755
2077
  }
1756
2078
  return [];
1757
2079
  }
1758
- async function testApiKey(apiKey) {
2080
+ async function testApiKey(options) {
2081
+ const llmOptions = typeof options === "string" ? { apiKey: options } : options;
1759
2082
  try {
1760
2083
  await chatCompletion(
1761
2084
  [{ role: "user", content: "Hi" }],
1762
- { apiKey, maxTokens: 1, timeoutMs: 5e3 }
2085
+ { ...llmOptions, maxTokens: 1, timeoutMs: 5e3 }
1763
2086
  );
1764
2087
  return true;
1765
2088
  } catch {
1766
2089
  return false;
1767
2090
  }
1768
2091
  }
2092
+ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
2093
+ const PRESET_BASE_URL_ORIGINS = /* @__PURE__ */ new Set([
2094
+ "https://api.openai.com",
2095
+ "https://openrouter.ai",
2096
+ "https://api.kilo.ai",
2097
+ "http://localhost:11434",
2098
+ "http://127.0.0.1:11434"
2099
+ ]);
2100
+ function getOrigin(rawBaseUrl) {
2101
+ try {
2102
+ return new URL(rawBaseUrl).origin;
2103
+ } catch {
2104
+ return null;
2105
+ }
2106
+ }
2107
+ function isAllowedClientBaseUrl(rawBaseUrl) {
2108
+ const parsed = new URL(rawBaseUrl);
2109
+ if (!["http:", "https:"].includes(parsed.protocol)) return false;
2110
+ if (parsed.username || parsed.password) return false;
2111
+ const hostname = parsed.hostname.toLowerCase();
2112
+ const isLocalHost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
2113
+ if (!isLocalHost && parsed.protocol !== "https:") return false;
2114
+ const origin = parsed.origin;
2115
+ if (PRESET_BASE_URL_ORIGINS.has(origin)) return true;
2116
+ const envBaseUrl = process.env.LLM_BASE_URL?.trim();
2117
+ const envOrigin = envBaseUrl ? getOrigin(envBaseUrl) : null;
2118
+ return Boolean(envOrigin && envOrigin === origin);
2119
+ }
2120
+ function detectProvider(rawBaseUrl) {
2121
+ const baseUrl = rawBaseUrl?.toLowerCase() || "";
2122
+ if (baseUrl.includes("openrouter.ai")) return "openrouter";
2123
+ if (baseUrl.includes("kilo.ai")) return "kilocode";
2124
+ return "openai";
2125
+ }
1769
2126
  function getLlmConfig(request) {
1770
2127
  const headerKey = request.headers.get("X-OpenAI-API-Key");
1771
2128
  const headerBaseUrl = request.headers.get("X-LLM-Base-URL")?.trim() || null;
1772
- const baseUrl = headerBaseUrl || process.env.LLM_BASE_URL?.trim() || null;
1773
- const isOpenRouter = baseUrl?.includes("openrouter.ai");
1774
- const isKilocode = baseUrl?.includes("kilo.ai");
1775
- const envKey = isOpenRouter ? process.env.OPENROUTER_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() : isKilocode ? process.env.KILOCODE_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() : process.env.OPENAI_API_KEY?.trim();
2129
+ const envBaseUrl = process.env.LLM_BASE_URL?.trim() || null;
2130
+ if (headerBaseUrl) {
2131
+ const origin = getOrigin(headerBaseUrl);
2132
+ if (!origin || !isAllowedClientBaseUrl(headerBaseUrl)) {
2133
+ return {
2134
+ apiKey: null,
2135
+ baseUrl: null,
2136
+ model: null,
2137
+ error: "Disallowed X-LLM-Base-URL value"
2138
+ };
2139
+ }
2140
+ }
2141
+ const baseUrl = headerBaseUrl || envBaseUrl || DEFAULT_OPENAI_BASE_URL;
2142
+ const provider = detectProvider(baseUrl);
2143
+ const envKey = provider === "openrouter" ? process.env.OPENROUTER_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() : provider === "kilocode" ? process.env.KILOCODE_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() : process.env.OPENAI_API_KEY?.trim();
1776
2144
  const apiKey = headerKey?.trim() || envKey || null;
1777
2145
  const model = request.headers.get("X-LLM-Model")?.trim() || process.env.LLM_MODEL?.trim() || null;
1778
- return { apiKey, baseUrl, model };
2146
+ return { apiKey, baseUrl, model, error: null };
1779
2147
  }
1780
2148
  function generateHeuristicTitle(message) {
1781
2149
  let text = message.replace(/```[\s\S]*?```/g, " ");
@@ -1844,6 +2212,12 @@ const Route$g = createFileRoute("/api/llm-features")({
1844
2212
  });
1845
2213
  }
1846
2214
  const llmConfig = getLlmConfig(request);
2215
+ if (llmConfig.error) {
2216
+ return json({
2217
+ ok: false,
2218
+ error: llmConfig.error
2219
+ }, { status: 400 });
2220
+ }
1847
2221
  if (!llmConfig.apiKey && !llmConfig.baseUrl?.includes("localhost")) {
1848
2222
  const title = generateHeuristicTitle(message);
1849
2223
  return json({
@@ -1884,6 +2258,12 @@ const Route$g = createFileRoute("/api/llm-features")({
1884
2258
  });
1885
2259
  }
1886
2260
  const llmConfig = getLlmConfig(request);
2261
+ if (llmConfig.error) {
2262
+ return json({
2263
+ ok: false,
2264
+ error: llmConfig.error
2265
+ }, { status: 400 });
2266
+ }
1887
2267
  if (!llmConfig.apiKey && !llmConfig.baseUrl?.includes("localhost")) {
1888
2268
  return json({
1889
2269
  ok: true,
@@ -1914,6 +2294,12 @@ const Route$g = createFileRoute("/api/llm-features")({
1914
2294
  }
1915
2295
  case "test": {
1916
2296
  const llmConfig = getLlmConfig(request);
2297
+ if (llmConfig.error) {
2298
+ return json({
2299
+ ok: false,
2300
+ error: llmConfig.error
2301
+ }, { status: 400 });
2302
+ }
1917
2303
  if (!llmConfig.apiKey && !llmConfig.baseUrl?.includes("localhost")) {
1918
2304
  return json({
1919
2305
  ok: false,
@@ -1921,7 +2307,11 @@ const Route$g = createFileRoute("/api/llm-features")({
1921
2307
  });
1922
2308
  }
1923
2309
  try {
1924
- const valid = await testApiKey(llmConfig.apiKey || "");
2310
+ const valid = await testApiKey({
2311
+ apiKey: llmConfig.apiKey || "",
2312
+ ...llmConfig.baseUrl ? { baseUrl: llmConfig.baseUrl } : {},
2313
+ ...llmConfig.model ? { model: llmConfig.model } : {}
2314
+ });
1925
2315
  return json({
1926
2316
  ok: true,
1927
2317
  valid
@@ -3095,10 +3485,8 @@ const Route$5 = createFileRoute("/api/files/info")({
3095
3485
  );
3096
3486
  }
3097
3487
  const path2 = validatePath(rawPath, "Path parameter");
3098
- const root = process.env.FILES_ROOT?.trim() ? resolve(process.env.FILES_ROOT) : process.env.HOME || "/home";
3099
- const absolutePath = resolve(root, path2.replace(/^\/+/, ""));
3100
- const stats = await stat(absolutePath);
3101
- return json({ size: stats.size });
3488
+ const fileInfo = await getFileInfo(path2);
3489
+ return json({ size: fileInfo.size });
3102
3490
  } catch (err) {
3103
3491
  const error = err;
3104
3492
  if (error.message.includes("invalid characters") || error.message.includes("traversal attempts")) {
@@ -3194,11 +3582,6 @@ function encodeFilename(filename) {
3194
3582
  const encodedFilename = encodeURIComponent(filename);
3195
3583
  return `filename*=UTF-8''${encodedFilename}`;
3196
3584
  }
3197
- function isStaticAsset(filename) {
3198
- const ext = filename.split(".").pop()?.toLowerCase() || "";
3199
- const staticExts = ["png", "jpg", "jpeg", "gif", "webp", "svg", "css", "js", "woff", "woff2", "ttf", "otf"];
3200
- return staticExts.includes(ext);
3201
- }
3202
3585
  const Route$4 = createFileRoute("/api/files/download")({
3203
3586
  server: {
3204
3587
  handlers: {
@@ -3226,13 +3609,9 @@ const Route$4 = createFileRoute("/api/files/download")({
3226
3609
  const headers = {
3227
3610
  "Content-Type": contentType,
3228
3611
  "Content-Disposition": contentDisposition,
3229
- "Content-Length": content.byteLength.toString()
3612
+ "Content-Length": content.byteLength.toString(),
3613
+ "Cache-Control": "private, no-store"
3230
3614
  };
3231
- if (isStaticAsset(fileInfo.name)) {
3232
- headers["Cache-Control"] = "public, max-age=31536000";
3233
- } else {
3234
- headers["Cache-Control"] = "no-cache";
3235
- }
3236
3615
  return new Response(content, { headers });
3237
3616
  } catch (err) {
3238
3617
  const error = err;