squad-openclaw 2026.3.1301 → 2026.3.1401

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 (2) hide show
  1. package/dist/index.js +864 -96
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -140,21 +140,26 @@ function updatePlugin(pluginDirName, configDir) {
140
140
  registryDelete(`plugin:${pluginDirName}`);
141
141
  }
142
142
  }
143
- function startWatcher(configDir, onFsChange) {
144
- const watcher = chokidar.watch(configDir, {
143
+ function startWatcher(configDir, onFsChange, options = {}) {
144
+ const watcherOptions = {
145
145
  persistent: true,
146
146
  usePolling: false,
147
147
  ignoreInitial: true,
148
148
  awaitWriteFinish: { stabilityThreshold: 300 },
149
149
  depth: 4,
150
- ignored: [
151
- // Ignore heavy directories that aren't relevant
152
- "**/node_modules/**",
153
- "**/dist/**",
154
- "**/.git/**",
155
- "**/data/**"
156
- ]
150
+ ignored: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/data/**"]
151
+ };
152
+ const generalWatcher = chokidar.watch(configDir, {
153
+ ...watcherOptions,
154
+ ignored: [...watcherOptions.ignored, "**/extensions/**"]
157
155
  });
156
+ const pluginManifestWatcher = chokidar.watch(
157
+ path.join(configDir, "extensions", "*", "openclaw.plugin.json"),
158
+ watcherOptions
159
+ );
160
+ const watchers = [generalWatcher, pluginManifestWatcher];
161
+ let stopped = false;
162
+ let fatalErrorReported = false;
158
163
  const emitFsChange = (action, filePath) => {
159
164
  if (!onFsChange) return;
160
165
  const rel = path.relative(configDir, filePath);
@@ -237,12 +242,31 @@ function startWatcher(configDir, onFsChange) {
237
242
  return;
238
243
  }
239
244
  };
240
- watcher.on("add", (fp) => handleChange(fp, "add"));
241
- watcher.on("change", (fp) => handleChange(fp, "change"));
242
- watcher.on("unlink", (fp) => handleChange(fp, "unlink"));
243
- watcher.on("addDir", handleAddDir);
244
- watcher.on("unlinkDir", handleUnlinkDir);
245
- return () => {
245
+ const onWatcherError = (error) => {
246
+ const normalized = error instanceof Error ? error : new Error(String(error));
247
+ console.error(`[squad-openclaw] watcher error: ${normalized.message}`);
248
+ stop();
249
+ try {
250
+ if (!fatalErrorReported) {
251
+ fatalErrorReported = true;
252
+ options.onFatalError?.(normalized);
253
+ }
254
+ } catch (callbackError) {
255
+ const callbackMessage = callbackError instanceof Error ? callbackError.message : String(callbackError);
256
+ console.warn(`[squad-openclaw] watcher fatal-error callback failed: ${callbackMessage}`);
257
+ }
258
+ };
259
+ for (const watcher of watchers) {
260
+ watcher.on("add", (fp) => handleChange(fp, "add"));
261
+ watcher.on("change", (fp) => handleChange(fp, "change"));
262
+ watcher.on("unlink", (fp) => handleChange(fp, "unlink"));
263
+ watcher.on("addDir", handleAddDir);
264
+ watcher.on("unlinkDir", handleUnlinkDir);
265
+ watcher.on("error", onWatcherError);
266
+ }
267
+ const stop = () => {
268
+ if (stopped) return;
269
+ stopped = true;
246
270
  for (const timer of debounceTimers.values()) {
247
271
  clearTimeout(timer);
248
272
  }
@@ -251,8 +275,11 @@ function startWatcher(configDir, onFsChange) {
251
275
  clearTimeout(timer);
252
276
  }
253
277
  fsDebounceTimers.clear();
254
- watcher.close();
278
+ for (const watcher of watchers) {
279
+ void watcher.close();
280
+ }
255
281
  };
282
+ return stop;
256
283
  }
257
284
 
258
285
  // src/filesystem.ts
@@ -779,6 +806,110 @@ function registryList(type) {
779
806
  if (!type) return all;
780
807
  return all.filter((e) => e.type === type);
781
808
  }
809
+ var DEFAULT_MEDIA_SCAN_MAX_ENTRIES = 5e3;
810
+ var DEFAULT_MEDIA_SCAN_MAX_DEPTH = 6;
811
+ var DEFAULT_MEDIA_SCAN_MAX_DURATION_MS = 2500;
812
+ var ENTITY_RUNTIME_STATE_KEY = "__squadOpenclawEntityRuntimeState_v1";
813
+ function readPositiveIntEnv(envName, fallback, min, max) {
814
+ const raw = process.env[envName];
815
+ if (!raw) return fallback;
816
+ const parsed = Number(raw);
817
+ if (!Number.isFinite(parsed)) return fallback;
818
+ const rounded = Math.floor(parsed);
819
+ if (rounded < min) return min;
820
+ if (rounded > max) return max;
821
+ return rounded;
822
+ }
823
+ function readMediaScanLimits() {
824
+ return {
825
+ maxEntries: readPositiveIntEnv(
826
+ "SQUAD_MEDIA_SCAN_MAX_ENTRIES",
827
+ DEFAULT_MEDIA_SCAN_MAX_ENTRIES,
828
+ 1,
829
+ 1e6
830
+ ),
831
+ maxDepth: readPositiveIntEnv(
832
+ "SQUAD_MEDIA_SCAN_MAX_DEPTH",
833
+ DEFAULT_MEDIA_SCAN_MAX_DEPTH,
834
+ 1,
835
+ 64
836
+ ),
837
+ maxDurationMs: readPositiveIntEnv(
838
+ "SQUAD_MEDIA_SCAN_MAX_DURATION_MS",
839
+ DEFAULT_MEDIA_SCAN_MAX_DURATION_MS,
840
+ 200,
841
+ 6e5
842
+ )
843
+ };
844
+ }
845
+ function getEntityRuntimeState() {
846
+ const globalState = globalThis;
847
+ if (!globalState[ENTITY_RUNTIME_STATE_KEY]) {
848
+ globalState[ENTITY_RUNTIME_STATE_KEY] = {
849
+ stopWatcher: null,
850
+ watcherConfigDir: null,
851
+ processSignalCleanup: null,
852
+ shutdownHookApis: /* @__PURE__ */ new WeakSet()
853
+ };
854
+ }
855
+ return globalState[ENTITY_RUNTIME_STATE_KEY];
856
+ }
857
+ function stopEntityWatcher() {
858
+ const runtime = getEntityRuntimeState();
859
+ if (!runtime.stopWatcher) return;
860
+ try {
861
+ runtime.stopWatcher();
862
+ } catch (error) {
863
+ const message = error instanceof Error ? error.message : String(error);
864
+ console.warn(`[squad-openclaw] watcher stop failed: ${message}`);
865
+ } finally {
866
+ runtime.stopWatcher = null;
867
+ runtime.watcherConfigDir = null;
868
+ }
869
+ }
870
+ function ensureProcessSignalHandlers() {
871
+ const runtime = getEntityRuntimeState();
872
+ runtime.processSignalCleanup?.();
873
+ const onProcessSignal = () => {
874
+ stopEntityWatcher();
875
+ };
876
+ process.on("SIGTERM", onProcessSignal);
877
+ process.on("SIGINT", onProcessSignal);
878
+ runtime.processSignalCleanup = () => {
879
+ process.off("SIGTERM", onProcessSignal);
880
+ process.off("SIGINT", onProcessSignal);
881
+ };
882
+ }
883
+ function registerShutdownHook(api) {
884
+ if (!api || typeof api !== "object") return;
885
+ const runtime = getEntityRuntimeState();
886
+ if (runtime.shutdownHookApis.has(api)) return;
887
+ const maybeApi = api;
888
+ if (typeof maybeApi.onShutdown !== "function") return;
889
+ maybeApi.onShutdown(() => {
890
+ stopEntityWatcher();
891
+ });
892
+ runtime.shutdownHookApis.add(api);
893
+ }
894
+ function startOrRestartWatcher(configDir, onFsChange, onWatcherError) {
895
+ const runtime = getEntityRuntimeState();
896
+ stopEntityWatcher();
897
+ runtime.stopWatcher = startWatcher(configDir, onFsChange, {
898
+ onFatalError: (error) => {
899
+ const message = error instanceof Error ? error.message : String(error);
900
+ console.error(`[squad-openclaw] file watcher stopped after fatal error: ${message}`);
901
+ runtime.stopWatcher = null;
902
+ runtime.watcherConfigDir = null;
903
+ try {
904
+ onWatcherError?.(error);
905
+ } catch (callbackError) {
906
+ const callbackMessage = callbackError instanceof Error ? callbackError.message : String(callbackError);
907
+ console.warn(`[squad-openclaw] watcher error callback failed: ${callbackMessage}`);
908
+ }
909
+ }
910
+ });
911
+ runtime.watcherConfigDir = configDir;
912
+ }
782
913
  var IDENTITY_NAME_RE = /\*\*Name:\*\*\s*\n?\s*(.+?)(?=\n|$)/;
783
914
  function parseIdentityName(content) {
784
915
  const match = content.match(IDENTITY_NAME_RE);
@@ -990,9 +1121,48 @@ function getMimeType(filename) {
990
1121
  function scanMedia(configDir) {
991
1122
  const now = Date.now();
992
1123
  const mediaDir = path5.join(configDir, "media");
993
- scanMediaDir(mediaDir, now);
1124
+ const limits = readMediaScanLimits();
1125
+ const budget = {
1126
+ visitedEntries: 0,
1127
+ maxEntries: limits.maxEntries,
1128
+ maxDepth: limits.maxDepth,
1129
+ deadlineMs: Date.now() + limits.maxDurationMs,
1130
+ truncatedByEntries: false,
1131
+ truncatedByDepth: false,
1132
+ truncatedByTimeout: false
1133
+ };
1134
+ scanMediaDir(mediaDir, now, 0, budget);
1135
+ if (budget.truncatedByEntries || budget.truncatedByDepth || budget.truncatedByTimeout) {
1136
+ const reasons = [];
1137
+ if (budget.truncatedByEntries) reasons.push(`max entries (${budget.maxEntries})`);
1138
+ if (budget.truncatedByDepth) reasons.push(`max depth (${budget.maxDepth})`);
1139
+ if (budget.truncatedByTimeout) reasons.push(`time budget (${limits.maxDurationMs}ms)`);
1140
+ console.warn(
1141
+ `[squad-openclaw] media scan truncated after ${budget.visitedEntries} entries: ${reasons.join(", ")}. Tune SQUAD_MEDIA_SCAN_MAX_ENTRIES/SQUAD_MEDIA_SCAN_MAX_DEPTH/SQUAD_MEDIA_SCAN_MAX_DURATION_MS if needed.`
1142
+ );
1143
+ }
1144
+ }
1145
+ function shouldStopMediaScan(budget) {
1146
+ if (Date.now() > budget.deadlineMs) {
1147
+ budget.truncatedByTimeout = true;
1148
+ return true;
1149
+ }
1150
+ return false;
1151
+ }
1152
+ function claimMediaEntry(budget) {
1153
+ if (budget.visitedEntries >= budget.maxEntries) {
1154
+ budget.truncatedByEntries = true;
1155
+ return false;
1156
+ }
1157
+ budget.visitedEntries += 1;
1158
+ return true;
994
1159
  }
995
- function scanMediaDir(dirPath, now) {
1160
+ function scanMediaDir(dirPath, now, depth, budget) {
1161
+ if (shouldStopMediaScan(budget)) return;
1162
+ if (depth > budget.maxDepth) {
1163
+ budget.truncatedByDepth = true;
1164
+ return;
1165
+ }
996
1166
  let entries;
997
1167
  try {
998
1168
  entries = fs5.readdirSync(dirPath, { withFileTypes: true });
@@ -1000,10 +1170,12 @@ function scanMediaDir(dirPath, now) {
1000
1170
  return;
1001
1171
  }
1002
1172
  for (const entry of entries) {
1173
+ if (shouldStopMediaScan(budget)) return;
1003
1174
  if (entry.name.startsWith(".")) continue;
1004
1175
  const entryPath = path5.join(dirPath, entry.name);
1005
1176
  if (isSensitivePath(entryPath)) continue;
1006
1177
  if (entry.isDirectory()) {
1178
+ if (!claimMediaEntry(budget)) return;
1007
1179
  registrySet({
1008
1180
  id: entryPath,
1009
1181
  type: "directory",
@@ -1016,8 +1188,13 @@ function scanMediaDir(dirPath, now) {
1016
1188
  created_at: now,
1017
1189
  updated_at: now
1018
1190
  });
1019
- scanMediaDir(entryPath, now);
1191
+ if (depth >= budget.maxDepth) {
1192
+ budget.truncatedByDepth = true;
1193
+ continue;
1194
+ }
1195
+ scanMediaDir(entryPath, now, depth + 1, budget);
1020
1196
  } else if (entry.isFile()) {
1197
+ if (!claimMediaEntry(budget)) return;
1021
1198
  const mimeType = getMimeType(entry.name);
1022
1199
  let size;
1023
1200
  let mtime = now;
@@ -1050,7 +1227,7 @@ function fullScan(configDir) {
1050
1227
  scanTools(configDir);
1051
1228
  scanMedia(configDir);
1052
1229
  }
1053
- function registerEntityTools(api, onFsChange) {
1230
+ function registerEntityTools(api, onFsChange, options = {}) {
1054
1231
  const configDir = getOpenclawStateDir();
1055
1232
  api.registerTool({
1056
1233
  name: "entity_list",
@@ -1119,22 +1296,19 @@ function registerEntityTools(api, onFsChange) {
1119
1296
  };
1120
1297
  }
1121
1298
  });
1299
+ stopEntityWatcher();
1122
1300
  try {
1123
1301
  fullScan(configDir);
1124
1302
  } catch (err2) {
1125
1303
  console.error("[squad-openclaw] Initial scan failed:", err2);
1126
1304
  }
1127
- let stopWatcher = null;
1128
1305
  try {
1129
- stopWatcher = startWatcher(configDir, onFsChange);
1306
+ startOrRestartWatcher(configDir, onFsChange, options.onWatcherError);
1130
1307
  } catch (err2) {
1131
1308
  console.error("[squad-openclaw] Watcher failed to start:", err2);
1132
1309
  }
1133
- const cleanup = () => {
1134
- stopWatcher?.();
1135
- };
1136
- process.on("SIGTERM", cleanup);
1137
- process.on("SIGINT", cleanup);
1310
+ ensureProcessSignalHandlers();
1311
+ registerShutdownHook(api);
1138
1312
  }
1139
1313
 
1140
1314
  // src/version.ts
@@ -2390,7 +2564,23 @@ function registerSquadSharedApi(api, onFsChange) {
2390
2564
  console.warn(`[squad-openclaw] ${label} registration failed: ${errorMessage(err2)}`);
2391
2565
  }
2392
2566
  };
2393
- registerStep("entity tools", () => registerEntityTools(api, onFsChange));
2567
+ const handleEntityWatcherError = (error) => {
2568
+ const snapshot = recordPluginFailure(
2569
+ "WATCHER_FATAL_ERROR",
2570
+ errorMessage(error),
2571
+ "Check inotify/file descriptor limits and run squad.plugin.recover after the host is stable."
2572
+ );
2573
+ console.error(`[squad-openclaw] entity watcher fatal error: ${errorMessage(error)}`);
2574
+ if (isPluginExecutionBlocked(snapshot)) {
2575
+ console.warn(
2576
+ `[squad-openclaw] plugin moved to ${snapshot.state} after watcher failure (${snapshot.reasonCode ?? "NO_REASON"})`
2577
+ );
2578
+ }
2579
+ };
2580
+ registerStep(
2581
+ "entity tools",
2582
+ () => registerEntityTools(api, onFsChange, { onWatcherError: handleEntityWatcherError })
2583
+ );
2394
2584
  registerStep("filesystem tools", () => registerFilesystemTools(api));
2395
2585
  registerStep("version methods", () => registerVersionMethods(api));
2396
2586
  registerStep("question methods", () => registerQuestionMethods(api));
@@ -2866,7 +3056,79 @@ async function runStartupMigrations(api) {
2866
3056
  }
2867
3057
 
2868
3058
  // src/http-routes.ts
3059
+ import crypto5 from "crypto";
3060
+ import fs15 from "fs";
3061
+ import path15 from "path";
3062
+ import { WebSocket as NodeWebSocket2 } from "ws";
3063
+
3064
+ // src/relay-client.ts
3065
+ import { WebSocket as NodeWebSocket } from "ws";
3066
+ import crypto4 from "crypto";
3067
+ import fs14 from "fs";
3068
+ import path14 from "path";
3069
+
3070
+ // src/e2e-crypto.ts
2869
3071
  import crypto2 from "crypto";
3072
+
3073
+ // src/device-keys.ts
3074
+ import crypto3 from "crypto";
3075
+ import fs13 from "fs";
3076
+ import path13 from "path";
3077
+ var RELAY_DATA_DIR = path13.join(getOpenclawStateDir(), "squad-ceo-data", "relay");
3078
+ var RELAY_STATE_PATH = path13.join(RELAY_DATA_DIR, "squad-relay.json");
3079
+ var PENDING_APPROVAL_PATH = path13.join(RELAY_DATA_DIR, "pending-approval.json");
3080
+ function toBase64Url(buf) {
3081
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3082
+ }
3083
+
3084
+ // src/relay-client.ts
3085
+ function readOperatorToken() {
3086
+ const stateDir = getOpenclawStateDir();
3087
+ const configPath = path14.join(stateDir, "openclaw.json");
3088
+ try {
3089
+ const raw = fs14.readFileSync(configPath, "utf-8");
3090
+ const config = JSON.parse(raw);
3091
+ return config?.gateway?.auth?.token ?? config?.gateway?.remote?.token ?? config?.gateway?.token ?? null;
3092
+ } catch {
3093
+ return null;
3094
+ }
3095
+ }
3096
+ function readGatewayLocalWsConfig() {
3097
+ const defaults = {
3098
+ port: 18789,
3099
+ // Try IPv4, hostname, then IPv6 loopback.
3100
+ hosts: ["127.0.0.1", "localhost", "[::1]"]
3101
+ };
3102
+ const stateDir = getOpenclawStateDir();
3103
+ const configPath = path14.join(stateDir, "openclaw.json");
3104
+ try {
3105
+ const raw = fs14.readFileSync(configPath, "utf-8");
3106
+ const config = JSON.parse(raw);
3107
+ const parsedPort = Number(config?.gateway?.port);
3108
+ if (Number.isFinite(parsedPort) && parsedPort > 0) {
3109
+ defaults.port = parsedPort;
3110
+ }
3111
+ } catch {
3112
+ }
3113
+ return defaults;
3114
+ }
3115
+ function signDeviceIdentity(keys, clientId, clientMode, role, scopes, token, challengeNonce) {
3116
+ const signedAtMs = Date.now();
3117
+ const nonce = challengeNonce || crypto4.randomBytes(16).toString("hex");
3118
+ const scopeStr = scopes.join(",");
3119
+ const payload = `v2|${keys.deviceId}|${clientId}|${clientMode}|${role}|${scopeStr}|${signedAtMs}|${token ?? ""}|${nonce}`;
3120
+ const privateKey = crypto4.createPrivateKey(keys.privateKeyPem);
3121
+ const signature = crypto4.sign(null, Buffer.from(payload), privateKey);
3122
+ return {
3123
+ id: keys.deviceId,
3124
+ publicKey: keys.publicKey,
3125
+ signature: toBase64Url(signature),
3126
+ signedAt: signedAtMs,
3127
+ nonce
3128
+ };
3129
+ }
3130
+
3131
+ // src/http-routes.ts
2870
3132
  var DEFAULT_ALLOWED_ORIGINS = [
2871
3133
  "https://squad.ceo",
2872
3134
  "https://www.squad.ceo",
@@ -2888,9 +3150,13 @@ var PAIRING_STATUS_METHODS = [
2888
3150
  var PROOF_MAX_SKEW_MS = 5 * 60 * 1e3;
2889
3151
  var DEFAULT_PAIRING_TTL_MS = 15 * 60 * 1e3;
2890
3152
  var NONCE_TTL_MS = 10 * 60 * 1e3;
3153
+ var BROWSER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
3154
+ var BROWSER_SESSION_COOKIE_NAME = "squad_browser_session";
2891
3155
  var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
2892
3156
  var INTERNAL_ROUTE_TIMEOUT_MS = readTimeoutMs("SQUAD_INTERNAL_ROUTE_TIMEOUT_MS", 1e4);
2893
3157
  var PAIRING_GATEWAY_CALL_TIMEOUT_MS = readTimeoutMs("SQUAD_PAIRING_GATEWAY_CALL_TIMEOUT_MS", 4e3);
3158
+ var BROWSER_SESSION_DIR = path15.join(getOpenclawStateDir(), "squad-ceo-data", "browser-sessions");
3159
+ var BROWSER_SESSION_STORE_PATH = path15.join(BROWSER_SESSION_DIR, "sessions.json");
2894
3160
  function errorMessage2(error) {
2895
3161
  return error instanceof Error ? error.message : String(error);
2896
3162
  }
@@ -2922,6 +3188,7 @@ function withCors(request, response, allowMethods = "GET, POST, OPTIONS") {
2922
3188
  const allowedOrigin = resolveAllowedOrigin(origin);
2923
3189
  if (allowedOrigin) {
2924
3190
  headers.set("Access-Control-Allow-Origin", allowedOrigin);
3191
+ headers.set("Access-Control-Allow-Credentials", "true");
2925
3192
  headers.set("Access-Control-Allow-Methods", allowMethods);
2926
3193
  headers.set("Access-Control-Allow-Headers", "Content-Type");
2927
3194
  headers.set("Vary", "Origin");
@@ -2937,8 +3204,16 @@ function jsonError(request, code, message, status = 500, extra = {}) {
2937
3204
  }
2938
3205
  function resolveAllowedOrigin(origin) {
2939
3206
  if (!origin) return null;
3207
+ try {
3208
+ const url = new URL(origin);
3209
+ const hostname = url.hostname.toLowerCase();
3210
+ if (url.protocol === "http:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]")) {
3211
+ return origin;
3212
+ }
3213
+ } catch {
3214
+ }
2940
3215
  const configured = process.env.SQUAD_ALLOWED_ORIGINS ? process.env.SQUAD_ALLOWED_ORIGINS.split(",").map((item) => item.trim()).filter(Boolean) : [];
2941
- const allowed = configured.length > 0 ? configured : DEFAULT_ALLOWED_ORIGINS;
3216
+ const allowed = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_ORIGINS, ...configured])];
2942
3217
  return allowed.includes(origin) ? origin : null;
2943
3218
  }
2944
3219
  function isTailnetContext(request) {
@@ -2970,6 +3245,71 @@ function firstHeaderToken(value) {
2970
3245
  const first = value.split(",")[0]?.trim();
2971
3246
  return first ? first : null;
2972
3247
  }
3248
+ function parseCookieHeader(cookieHeader) {
3249
+ const cookies = /* @__PURE__ */ new Map();
3250
+ if (!cookieHeader) return cookies;
3251
+ for (const segment of cookieHeader.split(";")) {
3252
+ const trimmed = segment.trim();
3253
+ if (!trimmed) continue;
3254
+ const separator = trimmed.indexOf("=");
3255
+ if (separator <= 0) continue;
3256
+ const name = trimmed.slice(0, separator).trim();
3257
+ const value = trimmed.slice(separator + 1).trim();
3258
+ if (!name) continue;
3259
+ try {
3260
+ cookies.set(name, decodeURIComponent(value));
3261
+ } catch {
3262
+ cookies.set(name, value);
3263
+ }
3264
+ }
3265
+ return cookies;
3266
+ }
3267
+ function isSecureRequest(request) {
3268
+ const protocol = firstHeaderToken(request.headers.get("x-forwarded-proto"))?.toLowerCase();
3269
+ if (protocol === "https" || protocol === "wss") return true;
3270
+ try {
3271
+ return new URL(request.url).protocol === "https:";
3272
+ } catch {
3273
+ return false;
3274
+ }
3275
+ }
3276
+ function buildBrowserSessionCookie(request, sessionId) {
3277
+ const secure = isSecureRequest(request);
3278
+ const sameSite = secure ? "SameSite=None" : "SameSite=Lax";
3279
+ const parts = [
3280
+ `${BROWSER_SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}`,
3281
+ "Path=/",
3282
+ "HttpOnly",
3283
+ sameSite,
3284
+ `Max-Age=${Math.floor(BROWSER_SESSION_TTL_MS / 1e3)}`
3285
+ ];
3286
+ if (secure) {
3287
+ parts.push("Secure");
3288
+ }
3289
+ return parts.join("; ");
3290
+ }
3291
+ function browserSessionStoreToObject(store) {
3292
+ return Object.fromEntries(store.entries());
3293
+ }
3294
+ function loadBrowserSessions() {
3295
+ try {
3296
+ const raw = fs15.readFileSync(BROWSER_SESSION_STORE_PATH, "utf8");
3297
+ const parsed = JSON.parse(raw);
3298
+ const now = Date.now();
3299
+ const entries = Object.entries(parsed).filter(([, record]) => typeof record?.sessionId === "string" && typeof record?.deviceId === "string" && typeof record?.publicKey === "string" && typeof record?.privateKeyPem === "string" && typeof record?.createdAt === "number" && typeof record?.lastSeenAt === "number" && typeof record?.expiresAt === "number" && record.expiresAt > now);
3300
+ return new Map(entries);
3301
+ } catch {
3302
+ return /* @__PURE__ */ new Map();
3303
+ }
3304
+ }
3305
+ function persistBrowserSessions(store) {
3306
+ fs15.mkdirSync(BROWSER_SESSION_DIR, { recursive: true });
3307
+ fs15.writeFileSync(
3308
+ BROWSER_SESSION_STORE_PATH,
3309
+ JSON.stringify(browserSessionStoreToObject(store), null, 2),
3310
+ { mode: 384 }
3311
+ );
3312
+ }
2973
3313
  function readObjectHeader(request, name) {
2974
3314
  const headers = request.headers;
2975
3315
  if (!headers || typeof headers !== "object") return null;
@@ -2980,6 +3320,29 @@ function readObjectHeader(request, name) {
2980
3320
  if (typeof value === "string") return value;
2981
3321
  return value == null ? null : String(value);
2982
3322
  }
3323
+ async function readRequestBodyRecord(request) {
3324
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
3325
+ if (contentType.includes("application/json")) {
3326
+ return await request.json();
3327
+ }
3328
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
3329
+ const form = await request.formData();
3330
+ const record = {};
3331
+ for (const [key, value] of form.entries()) {
3332
+ const normalized = typeof value === "string" ? value : value.name;
3333
+ const existing = record[key];
3334
+ if (existing === void 0) {
3335
+ record[key] = normalized;
3336
+ } else if (Array.isArray(existing)) {
3337
+ existing.push(normalized);
3338
+ } else {
3339
+ record[key] = [existing, normalized];
3340
+ }
3341
+ }
3342
+ return record;
3343
+ }
3344
+ return {};
3345
+ }
2983
3346
  async function toFetchRequest(incoming) {
2984
3347
  if (incoming && typeof incoming.method === "string" && typeof incoming.url === "string" && typeof incoming.headers?.get === "function") {
2985
3348
  return incoming;
@@ -3022,6 +3385,66 @@ async function toFetchRequest(incoming) {
3022
3385
  }
3023
3386
  return new Request(absoluteUrl, { method, headers, body });
3024
3387
  }
3388
+ async function readIncomingBody(incoming) {
3389
+ const method = typeof incoming.method === "string" ? incoming.method.toUpperCase() : "GET";
3390
+ if (method === "GET" || method === "HEAD") return void 0;
3391
+ const bodyValue = incoming.body;
3392
+ if (typeof bodyValue === "string") return bodyValue;
3393
+ if (bodyValue instanceof Uint8Array) return Buffer.from(bodyValue).toString("utf8");
3394
+ if (bodyValue instanceof ArrayBuffer) return Buffer.from(bodyValue).toString("utf8");
3395
+ const on = incoming.on;
3396
+ if (typeof on !== "function") return void 0;
3397
+ return await new Promise((resolve, reject) => {
3398
+ const chunks = [];
3399
+ const addChunk = (chunk) => {
3400
+ if (typeof chunk === "string") {
3401
+ chunks.push(Buffer.from(chunk));
3402
+ return;
3403
+ }
3404
+ if (chunk instanceof Uint8Array) {
3405
+ chunks.push(Buffer.from(chunk));
3406
+ return;
3407
+ }
3408
+ if (chunk instanceof ArrayBuffer) {
3409
+ chunks.push(Buffer.from(chunk));
3410
+ }
3411
+ };
3412
+ on.call(incoming, "data", addChunk);
3413
+ on.call(incoming, "end", () => resolve(Buffer.concat(chunks).toString("utf8")));
3414
+ on.call(incoming, "error", (error) => reject(error));
3415
+ });
3416
+ }
3417
+ async function toRouteRequest(incoming) {
3418
+ if (incoming && typeof incoming.method === "string" && typeof incoming.url === "string" && typeof incoming.headers?.get === "function") {
3419
+ return incoming;
3420
+ }
3421
+ const requestLike = incoming;
3422
+ const body = await readIncomingBody(requestLike);
3423
+ if (body === void 0) {
3424
+ return toFetchRequest(requestLike);
3425
+ }
3426
+ return toFetchRequest({
3427
+ method: typeof requestLike.method === "string" ? requestLike.method : "POST",
3428
+ url: typeof requestLike.url === "string" ? requestLike.url : typeof requestLike.originalUrl === "string" ? requestLike.originalUrl : "/",
3429
+ originalUrl: typeof requestLike.originalUrl === "string" ? requestLike.originalUrl : void 0,
3430
+ headers: requestLike.headers,
3431
+ body
3432
+ });
3433
+ }
3434
+ async function writeNodeResponse(outgoing, response) {
3435
+ outgoing.statusCode = response.status;
3436
+ const setHeader = typeof outgoing.setHeader === "function" ? outgoing.setHeader.bind(outgoing) : null;
3437
+ if (setHeader) {
3438
+ for (const [key, value] of response.headers.entries()) {
3439
+ setHeader(key, value);
3440
+ }
3441
+ }
3442
+ const body = response.body ? Buffer.from(await response.arrayBuffer()) : Buffer.alloc(0);
3443
+ const end = typeof outgoing.end === "function" ? outgoing.end.bind(outgoing) : null;
3444
+ if (end) {
3445
+ end(body.length > 0 ? body : void 0);
3446
+ }
3447
+ }
3025
3448
  function getRequestUrl(request) {
3026
3449
  try {
3027
3450
  return new URL(request.url);
@@ -3051,10 +3474,25 @@ function base64UrlEncode(bytes) {
3051
3474
  function encodeUtf8(value) {
3052
3475
  return new TextEncoder().encode(value);
3053
3476
  }
3477
+ function generateBrowserSessionDevice() {
3478
+ const { publicKey, privateKey } = crypto5.generateKeyPairSync("ed25519");
3479
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
3480
+ const rawPub = pubDer.subarray(pubDer.length - 32);
3481
+ return {
3482
+ deviceId: crypto5.createHash("sha256").update(rawPub).digest("hex"),
3483
+ publicKey: base64UrlEncode(rawPub),
3484
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" })
3485
+ };
3486
+ }
3487
+ function signBrowserSessionPayload(privateKeyPem, payload) {
3488
+ const privateKey = crypto5.createPrivateKey(privateKeyPem);
3489
+ const signature = crypto5.sign(null, Buffer.from(payload, "utf8"), privateKey);
3490
+ return base64UrlEncode(signature);
3491
+ }
3054
3492
  function normalizeDevicePublicKeyBase64Url(publicKey) {
3055
3493
  try {
3056
3494
  if (publicKey.includes("BEGIN")) {
3057
- const spki = crypto2.createPublicKey(publicKey).export({
3495
+ const spki = crypto5.createPublicKey(publicKey).export({
3058
3496
  type: "spki",
3059
3497
  format: "der"
3060
3498
  });
@@ -3074,7 +3512,7 @@ function deriveDeviceIdFromPublicKey(publicKey) {
3074
3512
  if (!normalized) return null;
3075
3513
  const raw = decodeBase64Url(normalized);
3076
3514
  if (raw.length !== 32) return null;
3077
- return crypto2.createHash("sha256").update(raw).digest("hex");
3515
+ return crypto5.createHash("sha256").update(raw).digest("hex");
3078
3516
  } catch {
3079
3517
  return null;
3080
3518
  }
@@ -3083,7 +3521,7 @@ function verifyEd25519Signature(publicKey, payload, signatureBase64Url) {
3083
3521
  try {
3084
3522
  const normalized = normalizeDevicePublicKeyBase64Url(publicKey);
3085
3523
  if (!normalized) return false;
3086
- const key = crypto2.createPublicKey({
3524
+ const key = crypto5.createPublicKey({
3087
3525
  key: Buffer.concat([ED25519_SPKI_PREFIX, decodeBase64Url(normalized)]),
3088
3526
  type: "spki",
3089
3527
  format: "der"
@@ -3095,7 +3533,7 @@ function verifyEd25519Signature(publicKey, payload, signatureBase64Url) {
3095
3533
  return Buffer.from(signatureBase64Url, "base64");
3096
3534
  }
3097
3535
  })();
3098
- return crypto2.verify(null, Buffer.from(payload, "utf8"), key, signature);
3536
+ return crypto5.verify(null, Buffer.from(payload, "utf8"), key, signature);
3099
3537
  } catch {
3100
3538
  return false;
3101
3539
  }
@@ -3113,11 +3551,24 @@ function canonicalizeP256Jwk(value) {
3113
3551
  }
3114
3552
  function computeDeviceIdFromJwk(jwk) {
3115
3553
  const canonical = JSON.stringify(jwk);
3116
- return crypto2.createHash("sha256").update(canonical).digest("hex");
3554
+ return crypto5.createHash("sha256").update(canonical).digest("hex");
3117
3555
  }
3118
3556
  function buildProofPayload(action, deviceId, nonce, signedAt, origin) {
3119
3557
  return `squad.${action}|${deviceId}|${nonce}|${signedAt}|${origin}`;
3120
3558
  }
3559
+ function buildGatewayConnectPayload(params) {
3560
+ return [
3561
+ "v2",
3562
+ params.deviceId,
3563
+ params.clientId,
3564
+ params.clientMode,
3565
+ params.role,
3566
+ params.scopes.join(","),
3567
+ String(params.signedAtMs),
3568
+ "",
3569
+ params.nonce
3570
+ ].join("|");
3571
+ }
3121
3572
  async function verifyBrowserProof(payload, origin, action, usedProofNonces) {
3122
3573
  const deviceId = pickString(payload.deviceId);
3123
3574
  const signature = pickString(payload.signature);
@@ -3194,14 +3645,14 @@ async function verifyBrowserProof(payload, origin, action, usedProofNonces) {
3194
3645
  };
3195
3646
  }
3196
3647
  try {
3197
- const key = await crypto2.webcrypto.subtle.importKey(
3648
+ const key = await crypto5.webcrypto.subtle.importKey(
3198
3649
  "jwk",
3199
3650
  jwk,
3200
3651
  { name: "ECDSA", namedCurve: "P-256" },
3201
3652
  false,
3202
3653
  ["verify"]
3203
3654
  );
3204
- verified = await crypto2.webcrypto.subtle.verify(
3655
+ verified = await crypto5.webcrypto.subtle.verify(
3205
3656
  { name: "ECDSA", hash: "SHA-256" },
3206
3657
  key,
3207
3658
  decodeBase64Url(signature),
@@ -3299,8 +3750,50 @@ function registerTailnetInternalRoutes(api) {
3299
3750
  const pendingPairings = /* @__PURE__ */ new Map();
3300
3751
  const proofNonces = /* @__PURE__ */ new Map();
3301
3752
  const rateLimitBucket = /* @__PURE__ */ new Map();
3753
+ const browserSessions = loadBrowserSessions();
3302
3754
  let preferredPairingRequestMethod = null;
3303
3755
  let preferredPairingStatusMethod = null;
3756
+ const persistBrowserSessionsSafe = () => {
3757
+ try {
3758
+ persistBrowserSessions(browserSessions);
3759
+ } catch (error) {
3760
+ console.warn(`[squad-openclaw] failed to persist browser sessions: ${errorMessage2(error)}`);
3761
+ }
3762
+ };
3763
+ const getOrCreateBrowserSession = (request) => {
3764
+ const now = Date.now();
3765
+ const cookies = parseCookieHeader(request.headers.get("cookie"));
3766
+ const sessionId = cookies.get(BROWSER_SESSION_COOKIE_NAME) ?? null;
3767
+ const existing = sessionId ? browserSessions.get(sessionId) ?? null : null;
3768
+ if (existing && existing.expiresAt > now) {
3769
+ const next = {
3770
+ ...existing,
3771
+ lastSeenAt: now,
3772
+ expiresAt: now + BROWSER_SESSION_TTL_MS
3773
+ };
3774
+ browserSessions.set(existing.sessionId, next);
3775
+ persistBrowserSessionsSafe();
3776
+ return {
3777
+ record: next,
3778
+ setCookie: buildBrowserSessionCookie(request, next.sessionId)
3779
+ };
3780
+ }
3781
+ const createdSessionId = crypto5.randomBytes(32).toString("hex");
3782
+ const device = generateBrowserSessionDevice();
3783
+ const created = {
3784
+ sessionId: createdSessionId,
3785
+ ...device,
3786
+ createdAt: now,
3787
+ lastSeenAt: now,
3788
+ expiresAt: now + BROWSER_SESSION_TTL_MS
3789
+ };
3790
+ browserSessions.set(createdSessionId, created);
3791
+ persistBrowserSessionsSafe();
3792
+ return {
3793
+ record: created,
3794
+ setCookie: buildBrowserSessionCookie(request, createdSessionId)
3795
+ };
3796
+ };
3304
3797
  const cleanupCaches = () => {
3305
3798
  const now = Date.now();
3306
3799
  for (const [key, value] of pendingPairings) {
@@ -3312,13 +3805,171 @@ function registerTailnetInternalRoutes(api) {
3312
3805
  for (const [key, bucket] of rateLimitBucket) {
3313
3806
  if (bucket.resetAt <= now) rateLimitBucket.delete(key);
3314
3807
  }
3808
+ let sessionsDirty = false;
3809
+ for (const [key, record] of browserSessions) {
3810
+ if (record.expiresAt <= now) {
3811
+ browserSessions.delete(key);
3812
+ sessionsDirty = true;
3813
+ }
3814
+ }
3815
+ if (sessionsDirty) {
3816
+ persistBrowserSessionsSafe();
3817
+ }
3818
+ };
3819
+ const callGatewayMethodViaLocalWs = async (method, params) => {
3820
+ const { port, hosts } = readGatewayLocalWsConfig();
3821
+ const operatorToken = readOperatorToken();
3822
+ if (!operatorToken) {
3823
+ throw new Error("Gateway auth token missing");
3824
+ }
3825
+ const relayStatePath = path15.join(getOpenclawStateDir(), "squad-ceo-data", "relay", "squad-relay.json");
3826
+ let deviceKeys = loadBrowserSessions().get("__relay_fallback__");
3827
+ if (!deviceKeys) {
3828
+ const relayDevice = (() => {
3829
+ try {
3830
+ const raw = fs15.readFileSync(relayStatePath, "utf8");
3831
+ const parsed = JSON.parse(raw);
3832
+ const keys = parsed.deviceKeys;
3833
+ if (typeof keys?.deviceId === "string" && typeof keys?.publicKey === "string" && typeof keys?.privateKeyPem === "string") {
3834
+ return keys;
3835
+ }
3836
+ } catch {
3837
+ }
3838
+ return generateBrowserSessionDevice();
3839
+ })();
3840
+ deviceKeys = {
3841
+ sessionId: "__relay_fallback__",
3842
+ deviceId: relayDevice.deviceId,
3843
+ publicKey: relayDevice.publicKey,
3844
+ privateKeyPem: relayDevice.privateKeyPem,
3845
+ createdAt: Date.now(),
3846
+ lastSeenAt: Date.now(),
3847
+ expiresAt: Date.now() + BROWSER_SESSION_TTL_MS
3848
+ };
3849
+ browserSessions.set(deviceKeys.sessionId, deviceKeys);
3850
+ }
3851
+ let lastError = null;
3852
+ for (const host of hosts) {
3853
+ try {
3854
+ const result = await new Promise((resolve, reject) => {
3855
+ const ws = new NodeWebSocket2(`ws://${host}:${port}`);
3856
+ const connectId = `connect-${crypto5.randomUUID()}`;
3857
+ const requestId = `request-${crypto5.randomUUID()}`;
3858
+ const scopes = ["operator.admin", "operator.read", "operator.write"];
3859
+ let settled = false;
3860
+ const finish = (fn) => {
3861
+ if (settled) return;
3862
+ settled = true;
3863
+ clearTimeout(timer);
3864
+ fn();
3865
+ };
3866
+ const timer = setTimeout(() => {
3867
+ finish(() => {
3868
+ try {
3869
+ ws.close();
3870
+ } catch {
3871
+ }
3872
+ reject(new Error(`gateway local call timed out for ${method}`));
3873
+ });
3874
+ }, PAIRING_GATEWAY_CALL_TIMEOUT_MS);
3875
+ ws.on("message", (data) => {
3876
+ let msg;
3877
+ try {
3878
+ msg = JSON.parse(data.toString());
3879
+ } catch {
3880
+ return;
3881
+ }
3882
+ if (msg.type === "event" && msg.event === "connect.challenge") {
3883
+ const payload = asRecord4(msg.payload);
3884
+ const nonce = pickString(payload?.nonce);
3885
+ const connectMsg = {
3886
+ type: "req",
3887
+ id: connectId,
3888
+ method: "connect",
3889
+ params: {
3890
+ minProtocol: 3,
3891
+ maxProtocol: 3,
3892
+ client: {
3893
+ id: "cli",
3894
+ version: "0.1.0",
3895
+ platform: "plugin",
3896
+ mode: "gateway"
3897
+ },
3898
+ role: "operator",
3899
+ scopes,
3900
+ auth: { token: operatorToken },
3901
+ device: signDeviceIdentity(
3902
+ {
3903
+ deviceId: deviceKeys.deviceId,
3904
+ publicKey: deviceKeys.publicKey,
3905
+ privateKeyPem: deviceKeys.privateKeyPem
3906
+ },
3907
+ "cli",
3908
+ "gateway",
3909
+ "operator",
3910
+ scopes,
3911
+ operatorToken,
3912
+ nonce
3913
+ )
3914
+ }
3915
+ };
3916
+ ws.send(JSON.stringify(connectMsg));
3917
+ return;
3918
+ }
3919
+ if (msg.type === "res" && msg.id === connectId) {
3920
+ if (msg.ok) {
3921
+ ws.send(JSON.stringify({ type: "req", id: requestId, method, params }));
3922
+ } else {
3923
+ const errorPayload = asRecord4(msg.error);
3924
+ const message = pickString(errorPayload?.message) ?? pickString(errorPayload?.error) ?? pickString(msg.error) ?? "Gateway connect rejected";
3925
+ finish(() => reject(new Error(message)));
3926
+ }
3927
+ return;
3928
+ }
3929
+ if (msg.type === "res" && msg.id === requestId) {
3930
+ if (msg.ok) {
3931
+ finish(() => {
3932
+ try {
3933
+ ws.close();
3934
+ } catch {
3935
+ }
3936
+ resolve(msg.payload);
3937
+ });
3938
+ } else {
3939
+ const errorPayload = asRecord4(msg.error);
3940
+ const message = pickString(errorPayload?.message) ?? pickString(errorPayload?.error) ?? pickString(msg.error) ?? "Gateway request failed";
3941
+ finish(() => reject(new Error(message)));
3942
+ }
3943
+ }
3944
+ });
3945
+ ws.on("error", (error) => {
3946
+ finish(() => reject(error));
3947
+ });
3948
+ ws.on("close", (code, reason) => {
3949
+ if (settled) return;
3950
+ finish(() => reject(new Error(`gateway closed (${code}): ${reason.toString()}`)));
3951
+ });
3952
+ });
3953
+ return result;
3954
+ } catch (error) {
3955
+ lastError = error;
3956
+ }
3957
+ }
3958
+ throw lastError instanceof Error ? lastError : new Error(errorMessage2(lastError));
3315
3959
  };
3316
3960
  const callGatewayMethod = async (method, params) => {
3317
- return withTimeout(
3318
- callGatewayAny({}, api, method, params),
3319
- PAIRING_GATEWAY_CALL_TIMEOUT_MS,
3320
- `pairing gateway call ${method}`
3321
- );
3961
+ try {
3962
+ return await withTimeout(
3963
+ callGatewayAny({}, api, method, params),
3964
+ PAIRING_GATEWAY_CALL_TIMEOUT_MS,
3965
+ `pairing gateway call ${method}`
3966
+ );
3967
+ } catch (error) {
3968
+ if (error instanceof Error && error.message === "Gateway method invocation API unavailable in plugin context") {
3969
+ return callGatewayMethodViaLocalWs(method, params);
3970
+ }
3971
+ throw error;
3972
+ }
3322
3973
  };
3323
3974
  const requestPairingFromGateway = async () => {
3324
3975
  const methods = preferredPairingRequestMethod ? [preferredPairingRequestMethod, ...PAIRING_REQUEST_METHODS.filter((m) => m !== preferredPairingRequestMethod)] : [...PAIRING_REQUEST_METHODS];
@@ -3348,6 +3999,45 @@ function registerTailnetInternalRoutes(api) {
3348
3999
  details ? `PAIRING_REQUEST_UNAVAILABLE: ${details}` : "PAIRING_REQUEST_UNAVAILABLE: No pairing request method supported"
3349
4000
  );
3350
4001
  };
4002
+ const issuePairingRequestResponse = async (request, deviceId, setCookie = null) => {
4003
+ const forwardedFor = request.headers.get("x-forwarded-for") ?? "unknown";
4004
+ const ipKey = `ip:${forwardedFor}`;
4005
+ const deviceKey = `device:${deviceId}`;
4006
+ if (isRateLimited(rateLimitBucket, ipKey, 20, 6e4) || isRateLimited(rateLimitBucket, deviceKey, 8, 6e4)) {
4007
+ return jsonError(request, "RATE_LIMITED", "Too many pairing requests", 429);
4008
+ }
4009
+ try {
4010
+ const pairing = await requestPairingFromGateway();
4011
+ pendingPairings.set(pairing.requestId, {
4012
+ requestId: pairing.requestId,
4013
+ deviceId,
4014
+ createdAt: Date.now(),
4015
+ expiresAt: pairing.expiresAt
4016
+ });
4017
+ const response = withCors(
4018
+ request,
4019
+ json({
4020
+ ok: true,
4021
+ requestId: pairing.requestId,
4022
+ approveCommand: `openclaw devices approve ${pairing.requestId}`,
4023
+ expiresAt: pairing.expiresAt
4024
+ })
4025
+ );
4026
+ if (setCookie) {
4027
+ response.headers.append("Set-Cookie", setCookie);
4028
+ }
4029
+ return response;
4030
+ } catch (error) {
4031
+ const message = error instanceof Error ? error.message : String(error);
4032
+ return jsonError(
4033
+ request,
4034
+ "PAIRING_REQUEST_UNAVAILABLE",
4035
+ message.replace(/^PAIRING_REQUEST_UNAVAILABLE:\s*/i, ""),
4036
+ 501,
4037
+ { nextStep: "openclaw devices list --json" }
4038
+ );
4039
+ }
4040
+ };
3351
4041
  const readPairingStatusFromGateway = async (requestId) => {
3352
4042
  const methods = preferredPairingStatusMethod ? [preferredPairingStatusMethod, ...PAIRING_STATUS_METHODS.filter((m) => m !== preferredPairingStatusMethod)] : [...PAIRING_STATUS_METHODS];
3353
4043
  for (const method of methods) {
@@ -3365,10 +4055,10 @@ function registerTailnetInternalRoutes(api) {
3365
4055
  };
3366
4056
  const handleRequest = async (request) => {
3367
4057
  const url = getRequestUrl(request);
3368
- const path13 = url.pathname;
4058
+ const path16 = url.pathname;
3369
4059
  cleanupCaches();
3370
4060
  let pluginState = getPluginSafetySnapshot();
3371
- if (request.method === "OPTIONS" && path13.startsWith("/squad-internal/")) {
4061
+ if (request.method === "OPTIONS" && path16.startsWith("/squad-internal/")) {
3372
4062
  const origin = ensureOriginAllowed(request);
3373
4063
  if (!origin) {
3374
4064
  return new Response(null, { status: 403 });
@@ -3386,7 +4076,7 @@ function registerTailnetInternalRoutes(api) {
3386
4076
  })
3387
4077
  );
3388
4078
  }
3389
- if (request.method === "GET" && path13 === "/squad-internal/health") {
4079
+ if (request.method === "GET" && path16 === "/squad-internal/health") {
3390
4080
  if (!isTailnetContext(request)) {
3391
4081
  return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
3392
4082
  }
@@ -3403,7 +4093,7 @@ function registerTailnetInternalRoutes(api) {
3403
4093
  })
3404
4094
  );
3405
4095
  }
3406
- if (request.method === "GET" && path13 === "/squad-internal/plugin/status") {
4096
+ if (request.method === "GET" && path16 === "/squad-internal/plugin/status") {
3407
4097
  if (!isTailnetContext(request)) {
3408
4098
  return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
3409
4099
  }
@@ -3418,7 +4108,7 @@ function registerTailnetInternalRoutes(api) {
3418
4108
  })
3419
4109
  );
3420
4110
  }
3421
- if (request.method === "POST" && path13 === "/squad-internal/plugin/recover") {
4111
+ if (request.method === "POST" && path16 === "/squad-internal/plugin/recover") {
3422
4112
  if (!isTailnetContext(request)) {
3423
4113
  return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
3424
4114
  }
@@ -3451,7 +4141,7 @@ function registerTailnetInternalRoutes(api) {
3451
4141
  pluginState = result.snapshot;
3452
4142
  return withCors(request, json({ ok: true, plugin: pluginState, message: result.message }));
3453
4143
  }
3454
- if (request.method === "POST" && path13 === "/squad-internal/plugin/disable") {
4144
+ if (request.method === "POST" && path16 === "/squad-internal/plugin/disable") {
3455
4145
  if (!isTailnetContext(request)) {
3456
4146
  return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
3457
4147
  }
@@ -3477,7 +4167,7 @@ function registerTailnetInternalRoutes(api) {
3477
4167
  pluginState = setPluginManualDisabled(reasonCode, reasonMessage, remediation);
3478
4168
  return withCors(request, json({ ok: true, plugin: pluginState }));
3479
4169
  }
3480
- if (path13.startsWith("/squad-internal/") && isPluginExecutionBlocked(pluginState)) {
4170
+ if (path16.startsWith("/squad-internal/") && isPluginExecutionBlocked(pluginState)) {
3481
4171
  const code = pluginBlockedCode(pluginState);
3482
4172
  return jsonError(
3483
4173
  request,
@@ -3487,7 +4177,75 @@ function registerTailnetInternalRoutes(api) {
3487
4177
  { plugin: pluginState }
3488
4178
  );
3489
4179
  }
3490
- if (request.method === "POST" && path13 === "/squad-internal/pairing/request") {
4180
+ if (request.method === "POST" && path16 === "/squad-internal/browser-session/connect-proof") {
4181
+ const origin = ensureOriginAllowed(request);
4182
+ if (!origin) {
4183
+ return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
4184
+ }
4185
+ if (!isTailnetContext(request)) {
4186
+ return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
4187
+ }
4188
+ const forwardedFor = request.headers.get("x-forwarded-for") ?? "unknown";
4189
+ if (isRateLimited(rateLimitBucket, `connect-proof:${forwardedFor}`, 120, 6e4)) {
4190
+ return jsonError(request, "RATE_LIMITED", "Too many connect proof requests", 429);
4191
+ }
4192
+ let body;
4193
+ try {
4194
+ body = await readRequestBodyRecord(request);
4195
+ } catch {
4196
+ return jsonError(request, "INVALID_REQUEST", "Invalid request body", 400);
4197
+ }
4198
+ const nonce = pickString(body.nonce);
4199
+ const clientId = pickString(body.clientId) ?? "cli";
4200
+ const clientMode = pickString(body.clientMode) ?? "ui";
4201
+ const role = pickString(body.role) ?? "operator";
4202
+ const scopesRaw = Array.isArray(body.scopes) ? body.scopes : typeof body.scopes === "string" ? [body.scopes] : [];
4203
+ const scopes = scopesRaw.filter((value) => typeof value === "string" && value.trim().length > 0);
4204
+ if (!nonce) {
4205
+ return jsonError(request, "INVALID_REQUEST", "nonce is required", 400);
4206
+ }
4207
+ const session = getOrCreateBrowserSession(request);
4208
+ const signedAt = Date.now();
4209
+ const payload = buildGatewayConnectPayload({
4210
+ deviceId: session.record.deviceId,
4211
+ clientId,
4212
+ clientMode,
4213
+ role,
4214
+ scopes,
4215
+ signedAtMs: signedAt,
4216
+ nonce
4217
+ });
4218
+ const signature = signBrowserSessionPayload(session.record.privateKeyPem, payload);
4219
+ const response = withCors(
4220
+ request,
4221
+ json({
4222
+ ok: true,
4223
+ device: {
4224
+ id: session.record.deviceId,
4225
+ publicKey: session.record.publicKey,
4226
+ signature,
4227
+ nonce,
4228
+ signedAt
4229
+ }
4230
+ })
4231
+ );
4232
+ if (session.setCookie) {
4233
+ response.headers.append("Set-Cookie", session.setCookie);
4234
+ }
4235
+ return response;
4236
+ }
4237
+ if (request.method === "POST" && path16 === "/squad-internal/browser-session/pairing/request") {
4238
+ const origin = ensureOriginAllowed(request);
4239
+ if (!origin) {
4240
+ return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
4241
+ }
4242
+ if (!isTailnetContext(request)) {
4243
+ return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
4244
+ }
4245
+ const session = getOrCreateBrowserSession(request);
4246
+ return issuePairingRequestResponse(request, session.record.deviceId, session.setCookie);
4247
+ }
4248
+ if (request.method === "POST" && path16 === "/squad-internal/pairing/request") {
3491
4249
  const origin = ensureOriginAllowed(request);
3492
4250
  if (!origin) {
3493
4251
  return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
@@ -3505,41 +4263,9 @@ function registerTailnetInternalRoutes(api) {
3505
4263
  if (!proofCheck.ok) {
3506
4264
  return jsonError(request, proofCheck.code, proofCheck.message, proofCheck.status);
3507
4265
  }
3508
- const forwardedFor = request.headers.get("x-forwarded-for") ?? "unknown";
3509
- const ipKey = `ip:${forwardedFor}`;
3510
- const deviceKey = `device:${proofCheck.deviceId}`;
3511
- if (isRateLimited(rateLimitBucket, ipKey, 20, 6e4) || isRateLimited(rateLimitBucket, deviceKey, 8, 6e4)) {
3512
- return jsonError(request, "RATE_LIMITED", "Too many pairing requests", 429);
3513
- }
3514
- try {
3515
- const pairing = await requestPairingFromGateway();
3516
- pendingPairings.set(pairing.requestId, {
3517
- requestId: pairing.requestId,
3518
- deviceId: proofCheck.deviceId,
3519
- createdAt: Date.now(),
3520
- expiresAt: pairing.expiresAt
3521
- });
3522
- return withCors(
3523
- request,
3524
- json({
3525
- ok: true,
3526
- requestId: pairing.requestId,
3527
- approveCommand: `openclaw devices approve ${pairing.requestId}`,
3528
- expiresAt: pairing.expiresAt
3529
- })
3530
- );
3531
- } catch (error) {
3532
- const message = error instanceof Error ? error.message : String(error);
3533
- return jsonError(
3534
- request,
3535
- "PAIRING_REQUEST_UNAVAILABLE",
3536
- message.replace(/^PAIRING_REQUEST_UNAVAILABLE:\s*/i, ""),
3537
- 501,
3538
- { nextStep: "openclaw devices list --json" }
3539
- );
3540
- }
4266
+ return issuePairingRequestResponse(request, proofCheck.deviceId);
3541
4267
  }
3542
- if (request.method === "GET" && path13 === "/squad-internal/pairing/status") {
4268
+ if (request.method === "GET" && path16 === "/squad-internal/pairing/status") {
3543
4269
  const origin = ensureOriginAllowed(request);
3544
4270
  if (!origin) {
3545
4271
  return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
@@ -3572,20 +4298,30 @@ function registerTailnetInternalRoutes(api) {
3572
4298
  }
3573
4299
  return new Response("Not Found", { status: 404 });
3574
4300
  };
3575
- const handle = async (incoming) => {
4301
+ const handle = async (incoming, outgoing) => {
3576
4302
  let request;
3577
4303
  try {
3578
- request = await toFetchRequest(incoming);
4304
+ request = await toRouteRequest(incoming);
3579
4305
  } catch (error) {
3580
4306
  console.warn(`[squad-openclaw] failed to normalize internal request: ${errorMessage2(error)}`);
3581
- return new Response("Bad Request", { status: 400 });
4307
+ const response = new Response("Bad Request", { status: 400 });
4308
+ if (outgoing) {
4309
+ await writeNodeResponse(outgoing, response);
4310
+ return true;
4311
+ }
4312
+ return response;
3582
4313
  }
3583
4314
  try {
3584
- return await withTimeout(
4315
+ const response = await withTimeout(
3585
4316
  handleRequest(request),
3586
4317
  INTERNAL_ROUTE_TIMEOUT_MS,
3587
4318
  `internal route ${request.method} ${getRequestUrl(request).pathname}`
3588
4319
  );
4320
+ if (outgoing) {
4321
+ await writeNodeResponse(outgoing, response);
4322
+ return true;
4323
+ }
4324
+ return response;
3589
4325
  } catch (error) {
3590
4326
  if (error instanceof TimeoutError) {
3591
4327
  const snapshot2 = recordPluginFailure(
@@ -3594,13 +4330,18 @@ function registerTailnetInternalRoutes(api) {
3594
4330
  "Investigate internal route hangs and run squad.plugin.recover after remediation."
3595
4331
  );
3596
4332
  console.warn(`[squad-openclaw] ${error.operation}`);
3597
- return jsonError(
4333
+ const response2 = jsonError(
3598
4334
  request,
3599
4335
  snapshot2.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "ROUTE_TIMEOUT",
3600
4336
  `Internal route timed out after ${error.timeoutMs}ms`,
3601
4337
  snapshot2.state === "QUARANTINED_AUTO" ? 503 : 504,
3602
4338
  snapshot2.state === "QUARANTINED_AUTO" ? { plugin: snapshot2 } : {}
3603
4339
  );
4340
+ if (outgoing) {
4341
+ await writeNodeResponse(outgoing, response2);
4342
+ return true;
4343
+ }
4344
+ return response2;
3604
4345
  }
3605
4346
  const snapshot = recordPluginFailure(
3606
4347
  "INTERNAL_ROUTE_ERROR",
@@ -3608,25 +4349,52 @@ function registerTailnetInternalRoutes(api) {
3608
4349
  "Inspect internal route failures and run squad.plugin.recover after remediation."
3609
4350
  );
3610
4351
  console.warn(`[squad-openclaw] internal route failure: ${errorMessage2(error)}`);
3611
- return jsonError(
4352
+ const response = jsonError(
3612
4353
  request,
3613
4354
  snapshot.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "INTERNAL_ROUTE_ERROR",
3614
4355
  "Internal route failed unexpectedly",
3615
4356
  snapshot.state === "QUARANTINED_AUTO" ? 503 : 500,
3616
4357
  snapshot.state === "QUARANTINED_AUTO" ? { plugin: snapshot } : {}
3617
4358
  );
4359
+ if (outgoing) {
4360
+ await writeNodeResponse(outgoing, response);
4361
+ return true;
4362
+ }
4363
+ return response;
3618
4364
  }
3619
4365
  };
3620
4366
  if (typeof api.registerHttpHandler === "function") {
3621
4367
  api.registerHttpHandler(handle);
3622
4368
  } else if (typeof api.registerHttpRoute === "function") {
3623
- api.registerHttpRoute("/squad-internal/health", handle);
3624
- api.registerHttpRoute("/squad-internal/plugin/status", handle);
3625
- api.registerHttpRoute("/squad-internal/plugin/recover", handle);
3626
- api.registerHttpRoute("/squad-internal/plugin/disable", handle);
3627
- api.registerHttpRoute("/squad-internal/pairing/request", handle);
3628
- api.registerHttpRoute("/squad-internal/pairing/status", handle);
3629
- api.registerHttpRoute("/squad-internal/*", handle);
4369
+ if (api.registerHttpRoute.length <= 1) {
4370
+ const registerRoute = (path16, match = "exact") => {
4371
+ api.registerHttpRoute({
4372
+ path: path16,
4373
+ handler: handle,
4374
+ auth: "plugin",
4375
+ match
4376
+ });
4377
+ };
4378
+ registerRoute("/squad-internal/health");
4379
+ registerRoute("/squad-internal/plugin/status");
4380
+ registerRoute("/squad-internal/plugin/recover");
4381
+ registerRoute("/squad-internal/plugin/disable");
4382
+ registerRoute("/squad-internal/browser-session/connect-proof");
4383
+ registerRoute("/squad-internal/browser-session/pairing/request");
4384
+ registerRoute("/squad-internal/pairing/request");
4385
+ registerRoute("/squad-internal/pairing/status");
4386
+ registerRoute("/squad-internal/*", "prefix");
4387
+ } else {
4388
+ api.registerHttpRoute("/squad-internal/health", handle);
4389
+ api.registerHttpRoute("/squad-internal/plugin/status", handle);
4390
+ api.registerHttpRoute("/squad-internal/plugin/recover", handle);
4391
+ api.registerHttpRoute("/squad-internal/plugin/disable", handle);
4392
+ api.registerHttpRoute("/squad-internal/browser-session/connect-proof", handle);
4393
+ api.registerHttpRoute("/squad-internal/browser-session/pairing/request", handle);
4394
+ api.registerHttpRoute("/squad-internal/pairing/request", handle);
4395
+ api.registerHttpRoute("/squad-internal/pairing/status", handle);
4396
+ api.registerHttpRoute("/squad-internal/*", handle);
4397
+ }
3630
4398
  } else if (typeof api.registerHttpMiddleware === "function") {
3631
4399
  api.registerHttpMiddleware(handle);
3632
4400
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squad-openclaw",
3
- "version": "2026.3.1301",
3
+ "version": "2026.3.1401",
4
4
  "description": "Entity registry, filesystem tools, and version management plugin for OpenClaw gateway",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",