ocuclaw 1.3.3 → 1.3.4

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 +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/dist/runtime/protocol-adapter.js +0 -387
@@ -1,13 +1,3 @@
1
- // Recipe executors for glasses_ui_refresh. Kinds: http (in-process fetch),
2
- // llm (two HTTP API backends behind one dispatcher), and system-stats
3
- // (in-process node:os reads). All return either { output } or
4
- // { error: <string> }. The shell (spawn bash) and llm claude-cli
5
- // (spawn claude) backends were removed to drop the plugin's last
6
- // child_process spawn — see the backends comment in config/runtime-config.ts.
7
- //
8
- // output is `string` for plain text and `object` for JSON. The cron engine
9
- // hands `output` to the template engine which handles both shapes.
10
-
11
1
  import { totalmem, freemem, loadavg, cpus } from "node:os";
12
2
  import * as dns from "node:dns";
13
3
  import { Agent } from "undici";
@@ -46,11 +36,6 @@ function checkIpv4Tuple(a, b) {
46
36
  return null;
47
37
  }
48
38
 
49
- // Classify a resolved IP literal (as returned by dns.lookup). Reused both
50
- // by the URL-form check below and by the per-connection lookup that fires
51
- // when undici opens the socket — see safeLookup. For IPv4 we route through
52
- // checkIpv4Tuple; for IPv6 we additionally peel IPv4-mapped (::ffff:a.b.c.d)
53
- // and the hex-compressed IPv4-compat form that Node sometimes returns.
54
39
  function checkResolvedIp(address, family) {
55
40
  if (typeof address !== "string") return null;
56
41
  if (family === 4) {
@@ -78,24 +63,12 @@ function checkResolvedIp(address, family) {
78
63
  const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
79
64
  return r ? `IPv4-compatible IPv6 (${r})` : null;
80
65
  }
81
- // fe80::/10 spans fe80:: through febf:: — the top 10 bits are 1111111010,
82
- // so the first byte is always 0xfe and the second byte ranges 0x80-0xbf.
83
- // The canonical first hextet is therefore fe[89ab][0-9a-f] (the leading
84
- // 'fe' is fixed, the third hex char is 8/9/a/b for the high two bits 10).
85
- // The old startsWith("fe80:") check missed fe81::* through febf::*.
66
+
86
67
  if (/^fe[89ab][0-9a-f]:/.test(addr)) return "IPv6 link-local blocked";
87
68
  if (/^f[cd][0-9a-f]{2}:/.test(addr)) return "IPv6 ULA blocked";
88
69
  return null;
89
70
  }
90
71
 
91
- // Per-connection DNS lookup that undici calls just before opening the socket.
92
- // We resolve the hostname via the same resolver fetch would use, then check
93
- // EVERY returned address: if any resolves into a private/loopback/link-local
94
- // range, reject the whole hostname. Strict — split-horizon DNS that returns
95
- // public IPs at one moment and private at another can't slip past, because
96
- // we only ever connect via this lookup's returned address (no TOCTOU). TLS
97
- // SNI and certificate verification keep using the URL hostname downstream,
98
- // so https://public.example still validates against its public cert.
99
72
  export function makeSafeLookup(dnsLookup) {
100
73
  return function safeLookup(hostname, opts, cb) {
101
74
  const family = opts && typeof opts.family === "number" ? opts.family : 0;
@@ -127,26 +100,6 @@ const ssrfSafeDispatcher = new Agent({
127
100
  connect: { lookup: makeSafeLookup(dns.promises.lookup) },
128
101
  });
129
102
 
130
- // Block direct-IP requests to loopback, link-local (incl. 169.254.169.254
131
- // cloud-metadata IPs), RFC1918 private space, IPv6 ULA/link-local. This is
132
- // the cheap URL-form check that runs before any DNS — it rejects literal IPs
133
- // (including IPv4-mapped/compat IPv6) without a round-trip.
134
- //
135
- // Hostnames pass through this URL-form check by design; the second layer is
136
- // safeLookup() above, which undici invokes just before opening the socket.
137
- // That layer closes the real attack surface here:
138
- // 1. Static A record. evil.example with `A 127.0.0.1` (or 169.254.169.254,
139
- // or an RFC1918 IP) — no DNS rebinding required, the agent just owns
140
- // a domain.
141
- // 2. Operator's resolver context. evenclaw deployments may resolve names
142
- // like grafana.internal or vault to private services the operator
143
- // didn't realize the plugin could reach.
144
- // 3. Cloud-metadata uniformity. evil.example → 169.254.169.254 works on
145
- // every AWS/GCP/Azure VM running OcuClaw.
146
- // safeLookup rejects the hostname if ANY resolved IP is private — strict, so
147
- // split-horizon DNS can't slip past either.
148
- //
149
- // Non-http(s) schemes (file:, gopher:, ftp:, data:) are rejected outright.
150
103
  function isForbiddenHttpDestination(urlString) {
151
104
  let parsed;
152
105
  try {
@@ -158,33 +111,28 @@ function isForbiddenHttpDestination(urlString) {
158
111
  if (proto !== "http:" && proto !== "https:") {
159
112
  return `disallowed scheme: ${proto}`;
160
113
  }
161
- // Node's WHATWG URL.hostname may or may not strip the IPv6 brackets across
162
- // versions; normalize by stripping them ourselves so the IPv6 checks below
163
- // work regardless.
114
+
164
115
  const rawHost = parsed.hostname.toLowerCase();
165
116
  const host = rawHost.startsWith("[") && rawHost.endsWith("]")
166
117
  ? rawHost.slice(1, -1)
167
118
  : rawHost;
168
- // Common loopback aliases.
119
+
169
120
  if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") {
170
121
  return "loopback hostname blocked";
171
122
  }
172
- // IPv4 dotted quad?
123
+
173
124
  const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
174
125
  if (v4) {
175
126
  return checkIpv4Tuple(Number(v4[1]), Number(v4[2]));
176
127
  }
177
- // Bare hex IPv4 like 0x7f000001 — refuse anything that looks numeric.
128
+
178
129
  if (/^[0-9a-f.x]+$/.test(host) && /^\d/.test(host) && !host.includes(":")) {
179
130
  return "ambiguous numeric host blocked";
180
131
  }
181
- // IPv6? URL.hostname strips brackets but keeps colons.
132
+
182
133
  if (host.includes(":")) {
183
134
  if (host === "::" || host === "::1") return "IPv6 loopback/unspecified blocked";
184
- // IPv4-mapped IPv6 (RFC 4291 §2.5.5.2): ::ffff:a.b.c.d (dotted) or
185
- // ::ffff:XXXX:YYYY (hex). Also IPv4-compatible (deprecated): ::a.b.c.d.
186
- // All of these resolve to an IPv4 destination — extract the embedded
187
- // IPv4 and run it through the IPv4 rules so the bypass closes.
135
+
188
136
  const mappedDotted = host.match(/^::(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
189
137
  if (mappedDotted) {
190
138
  const reason = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
@@ -200,11 +148,7 @@ function isForbiddenHttpDestination(urlString) {
200
148
  if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
201
149
  return null;
202
150
  }
203
- // IPv4-compatible IPv6 (deprecated, RFC 4291 §2.5.5.1): ::XXXX:YYYY
204
- // Node's URL parser normalizes ::a.b.c.d to this compressed-hex form, so
205
- // the dotted regex above never fires for compat addresses — handle it
206
- // here. Excludes the IPv4-mapped case (caught above) and the IPv6
207
- // loopback ::1 (caught earlier as an exact match).
151
+
208
152
  const compatHex = host.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
209
153
  if (compatHex) {
210
154
  const high = parseInt(compatHex[1], 16);
@@ -214,7 +158,7 @@ function isForbiddenHttpDestination(urlString) {
214
158
  if (reason) return `IPv4-compatible IPv6 blocked (${reason})`;
215
159
  return null;
216
160
  }
217
- // fe80::/10 spans fe80:: through febf:: — see comment in checkResolvedIp.
161
+
218
162
  if (/^fe[89ab][0-9a-f]:/.test(host)) return "IPv6 link-local blocked";
219
163
  if (/^f[cd][0-9a-f]{2}:/.test(host)) return "IPv6 ULA blocked";
220
164
  return null;
@@ -224,7 +168,7 @@ function isForbiddenHttpDestination(urlString) {
224
168
 
225
169
  function resolveJsonPath(value, jsonPath) {
226
170
  if (!jsonPath || typeof jsonPath !== "string") return value;
227
- // Minimal JSONPath: only "$.a.b.c" and "$.a[0].b" shapes.
171
+
228
172
  const expr = jsonPath.trim();
229
173
  if (!expr.startsWith("$")) return value;
230
174
  const rest = expr.slice(1).replace(/\[(\d+)\]/g, ".$1");
@@ -244,23 +188,80 @@ function resolveJsonPath(value, jsonPath) {
244
188
  return cursor;
245
189
  }
246
190
 
191
+ export function normalizeHttpAllowHosts(list) {
192
+ if (!Array.isArray(list)) return [];
193
+ return list
194
+ .filter((p) => typeof p === "string")
195
+ .map((p) => p.trim().toLowerCase().replace(/\.+$/, ""))
196
+ .filter((p) => p.length > 0);
197
+ }
198
+
199
+ export function isHttpHostAllowed(hostname, allowList) {
200
+ if (!Array.isArray(allowList) || allowList.length === 0) return false;
201
+ if (typeof hostname !== "string" || !hostname) return false;
202
+ const host = hostname.trim().toLowerCase().replace(/\.+$/, "");
203
+ if (!host) return false;
204
+ return allowList.some((p) =>
205
+ p.startsWith(".") ? host === p.slice(1) || host.endsWith(p) : host === p,
206
+ );
207
+ }
208
+
209
+ const CROSS_ORIGIN_STRIP_HEADERS = new Set([
210
+ "authorization",
211
+ "cookie",
212
+ "proxy-authorization",
213
+ "x-api-key",
214
+ "api-key",
215
+ "x-auth-token",
216
+ "x-access-token",
217
+ "x-amz-security-token",
218
+ ]);
219
+ const CREDENTIALISH_HEADER_RE = /(^|-)(api|auth|access|secret|session)(-|key|token|$)/i;
220
+
221
+ function stripCrossOriginHeaders(headers) {
222
+ const out = {};
223
+ for (const key of Object.keys(headers || {})) {
224
+ const lower = key.toLowerCase();
225
+ if (CROSS_ORIGIN_STRIP_HEADERS.has(lower)) continue;
226
+ if (CREDENTIALISH_HEADER_RE.test(lower)) continue;
227
+ out[key] = headers[key];
228
+ }
229
+ return out;
230
+ }
231
+
232
+ function sameOrigin(a, b) {
233
+ try {
234
+ return new URL(a).origin === new URL(b).origin;
235
+ } catch (_) {
236
+ return false;
237
+ }
238
+ }
239
+
247
240
  export async function executeHttpRecipe(params, opts) {
248
241
  const url = params && typeof params.url === "string" ? params.url : "";
249
242
  if (!url) return { error: "http recipe missing url" };
250
- // The SSRF guard is enabled by default for all production callers (the cron
251
- // engine invokes executeHttpRecipe(recipe) with no second argument). Tests
252
- // that spin up their own loopback HTTP server opt out via
253
- // executeHttpRecipe(recipe, { allowPrivateNetworks: true }). The recipe
254
- // schema deliberately does NOT include this flag, so agents can't bypass.
243
+
255
244
  if (!(opts && opts.allowPrivateNetworks === true)) {
256
245
  const forbidden = isForbiddenHttpDestination(url);
257
246
  if (forbidden) {
258
247
  return { error: `http recipe destination blocked: ${forbidden}` };
259
248
  }
260
249
  }
250
+
251
+ const allowHosts = opts && Array.isArray(opts.allowHosts) ? opts.allowHosts : null;
252
+ if (allowHosts) {
253
+ let initialHost = "";
254
+ try { initialHost = new URL(url).hostname; } catch (_) {}
255
+ if (!isHttpHostAllowed(initialHost, allowHosts)) {
256
+ return { error: `http recipe destination not in allowlist: ${initialHost || url}` };
257
+ }
258
+ }
261
259
  const method = params && typeof params.method === "string" ? params.method.toUpperCase() : "GET";
262
260
  const headers = params && params.headers && typeof params.headers === "object" ? params.headers : {};
263
261
  const body = method !== "GET" && method !== "HEAD" ? params && params.body : undefined;
262
+
263
+ let requestHeaders = headers;
264
+ let requestBody = body;
264
265
  const timeoutMs = Number.isFinite(params && params.timeoutMs)
265
266
  ? params.timeoutMs
266
267
  : DEFAULT_TIMEOUT_MS;
@@ -270,12 +271,7 @@ export async function executeHttpRecipe(params, opts) {
270
271
  const jsonPath = params && typeof params.jsonPath === "string" ? params.jsonPath : "";
271
272
 
272
273
  const fetchFn = opts && typeof opts.fetch === "function" ? opts.fetch : fetch;
273
- // The dispatcher carries the per-connection DNS lookup (safeLookup) that
274
- // resolves hostnames and blocks any that point at private IPs — see the
275
- // comment block on isForbiddenHttpDestination. Production callers default
276
- // to the module-level ssrfSafeDispatcher; the test seam accepts an
277
- // injected dispatcher (e.g. one built with a canned lookup) so the lookup
278
- // check can be exercised without real DNS.
274
+
279
275
  const dispatcher =
280
276
  opts && opts.dispatcher !== undefined ? opts.dispatcher : ssrfSafeDispatcher;
281
277
  const controller = new AbortController();
@@ -283,24 +279,19 @@ export async function executeHttpRecipe(params, opts) {
283
279
  const allowPrivate = opts && opts.allowPrivateNetworks === true;
284
280
  const MAX_REDIRECTS = 3;
285
281
  try {
286
- // Manual redirect handling — fetch's default redirect: "follow" lets a
287
- // public attacker-controlled domain return a 302 → http://169.254.169.254/
288
- // after the one-shot URL check. Validate every hop. Capped at 3 to
289
- // bound effort and prevent loops.
282
+
290
283
  let currentUrl = url;
291
284
  let response = null;
292
285
  let hop = 0;
293
286
  while (true) {
294
287
  const fetchInit = {
295
288
  method,
296
- headers,
297
- body,
289
+ headers: requestHeaders,
290
+ body: requestBody,
298
291
  signal: controller.signal,
299
292
  redirect: "manual",
300
293
  };
301
- // Only attach a dispatcher when it's defined and non-null. Injected
302
- // tests that pass `dispatcher: null` explicitly opt out (e.g. to use
303
- // a mock fetch with no networking at all).
294
+
304
295
  if (dispatcher) fetchInit.dispatcher = dispatcher;
305
296
  response = await fetchFn(currentUrl, fetchInit);
306
297
  if (response.status >= 300 && response.status < 400) {
@@ -323,7 +314,20 @@ export async function executeHttpRecipe(params, opts) {
323
314
  return { error: `http recipe redirect destination blocked: ${forbidden}` };
324
315
  }
325
316
  }
326
- // Drain the redirect body to free the socket before the next hop.
317
+
318
+ if (allowHosts) {
319
+ let nextHost = "";
320
+ try { nextHost = new URL(nextUrl).hostname; } catch (_) {}
321
+ if (!isHttpHostAllowed(nextHost, allowHosts)) {
322
+ return { error: `http recipe redirect destination not in allowlist: ${nextHost || nextUrl}` };
323
+ }
324
+ }
325
+
326
+ if (!sameOrigin(currentUrl, nextUrl)) {
327
+ requestHeaders = stripCrossOriginHeaders(requestHeaders);
328
+ requestBody = undefined;
329
+ }
330
+
327
331
  try { await response.arrayBuffer(); } catch (_) {}
328
332
  currentUrl = nextUrl;
329
333
  hop += 1;
@@ -375,10 +379,7 @@ export async function executeHttpRecipe(params, opts) {
375
379
  if (err && err.name === "AbortError") {
376
380
  return { error: `http recipe timeout after ${timeoutMs}ms` };
377
381
  }
378
- // undici wraps connect-time errors (including our safeLookup rejections)
379
- // as `TypeError: fetch failed` with the real reason on err.cause. Surface
380
- // the cause so SSRF-guard rejections are visible to the operator and
381
- // testable.
382
+
382
383
  const msg = err && err.message ? err.message : String(err);
383
384
  const causeMsg = err && err.cause && err.cause.message ? err.cause.message : "";
384
385
  const full = causeMsg ? `${msg}: ${causeMsg}` : msg;
@@ -397,12 +398,6 @@ function stripModelProviderPrefix(modelRef) {
397
398
  return idx === -1 ? modelRef : modelRef.slice(idx + 1);
398
399
  }
399
400
 
400
- // Both CLI-spawn backends (codex-cli, claude-cli) were removed to eliminate
401
- // the plugin's last child_process spawn — see the backends comment in
402
- // config/runtime-config.ts for the rationale and the deferred native-delegation
403
- // track. Operators who want those providers point an *-api backend at the
404
- // provider endpoint (key resolved via the host modelAuth, tool-less).
405
-
406
401
  async function runAnthropicApi(params, deps) {
407
402
  const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
408
403
  if (!params.apiKey) return { error: "anthropic-api: missing api key" };
@@ -516,10 +511,6 @@ export function executeLlmRecipe(recipe, ctx) {
516
511
  return executeLlmRecipeWithDeps(recipe, ctx, {});
517
512
  }
518
513
 
519
- // L0' tier: host RAM/CPU via in-process node:os reads. No shell (a sandboxed
520
- // `free -m` would report the container's view, not the host's). CPU% is a delta
521
- // over a short window — os.loadavg() is run-queue length, not utilization. Deps
522
- // (totalmem/freemem/loadavg/cpus/sleep) are injectable for deterministic tests.
523
514
  export function computeCpuPct(t0, t1) {
524
515
  let idleDelta = 0;
525
516
  let totalDelta = 0;