ocuclaw 0.1.0 → 1.3.0

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 (59) hide show
  1. package/README.md +63 -8
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +41 -184
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +909 -68
  27. package/dist/runtime/downstream-server.js +1004 -512
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1357 -210
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +656 -38
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -0,0 +1,746 @@
1
+ // Recipe executors for glasses_ui_refresh. Three kinds: shell (spawn bash),
2
+ // http (in-process fetch), and llm (added in Task 3 — four backends behind
3
+ // one dispatcher). All return either { output } or { error: <string> }.
4
+ //
5
+ // output is `string` for plain text and `object` for JSON. The cron engine
6
+ // hands `output` to the template engine which handles both shapes.
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { tmpdir, totalmem, freemem, loadavg, cpus } from "node:os";
10
+ import * as dns from "node:dns";
11
+ import { Agent } from "undici";
12
+
13
+ // Env vars allowed to cross into the spawned Claude CLI subprocess.
14
+ // Everything else is stripped — Claude reads its own auth from ~/.claude/
15
+ // (reachable via HOME), so cloud-provider keys (AWS_*, GOOGLE_*,
16
+ // ANTHROPIC_API_KEY, OPENAI_API_KEY, *_SECRET, *_TOKEN) and database URLs
17
+ // must never reach the agent the CLI is running. Operators who want an
18
+ // API key in the LLM-tick path use the *-api backends, which read the key
19
+ // via the host's modelAuth resolver — not from spawn env.
20
+ const CLI_SPAWN_ENV_ALLOWLIST = [
21
+ "PATH",
22
+ "HOME",
23
+ "USER",
24
+ "LOGNAME",
25
+ "SHELL",
26
+ "TERM",
27
+ "LANG",
28
+ "TZ",
29
+ "XDG_CONFIG_HOME",
30
+ "XDG_CACHE_HOME",
31
+ "XDG_DATA_HOME",
32
+ "XDG_RUNTIME_DIR",
33
+ "NODE_OPTIONS",
34
+ ];
35
+ const CLI_SPAWN_ENV_ALLOWLIST_PREFIXES = ["LC_"];
36
+
37
+ function buildScopedSpawnEnv() {
38
+ const sourceEnv = process.env || {};
39
+ const out = {};
40
+ for (const k of CLI_SPAWN_ENV_ALLOWLIST) {
41
+ if (typeof sourceEnv[k] === "string") out[k] = sourceEnv[k];
42
+ }
43
+ for (const k of Object.keys(sourceEnv)) {
44
+ if (CLI_SPAWN_ENV_ALLOWLIST_PREFIXES.some((p) => k.startsWith(p))) {
45
+ out[k] = sourceEnv[k];
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const DEFAULT_TIMEOUT_MS = 10_000;
52
+ const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
53
+
54
+ const SYSTEM_STATS_WINDOW_DEFAULT_MS = 200;
55
+ const SYSTEM_STATS_WINDOW_MIN_MS = 50;
56
+ const SYSTEM_STATS_WINDOW_MAX_MS = 1000;
57
+
58
+ function parseJsonIfPossible(text) {
59
+ if (typeof text !== "string" || text.length === 0) return text;
60
+ const trimmed = text.trim();
61
+ if (
62
+ !(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
63
+ !(trimmed.startsWith("[") && trimmed.endsWith("]"))
64
+ ) {
65
+ return text;
66
+ }
67
+ try {
68
+ return JSON.parse(trimmed);
69
+ } catch (_) {
70
+ return text;
71
+ }
72
+ }
73
+
74
+ export async function executeShellRecipe(params) {
75
+ const command = params && typeof params.command === "string" ? params.command : "";
76
+ if (!command) {
77
+ return { error: "shell recipe missing command" };
78
+ }
79
+ const timeoutMs = Number.isFinite(params && params.timeoutMs)
80
+ ? params.timeoutMs
81
+ : DEFAULT_TIMEOUT_MS;
82
+ const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
83
+ ? params.outputCapBytes
84
+ : DEFAULT_OUTPUT_CAP_BYTES;
85
+
86
+ return new Promise((resolve) => {
87
+ const child = spawn("bash", ["-c", command], {
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ });
90
+ const chunks = [];
91
+ const errChunks = [];
92
+ let bytes = 0;
93
+ let truncated = false;
94
+ let done = false;
95
+
96
+ const finish = (result) => {
97
+ if (done) return;
98
+ done = true;
99
+ clearTimeout(timer);
100
+ try { child.kill("SIGKILL"); } catch (_) { /* already exited */ }
101
+ resolve(result);
102
+ };
103
+
104
+ const timer = setTimeout(() => {
105
+ finish({ error: `shell recipe timeout after ${timeoutMs}ms` });
106
+ }, timeoutMs);
107
+
108
+ child.stdout.on("data", (chunk) => {
109
+ if (bytes + chunk.length > outputCapBytes) {
110
+ const remaining = outputCapBytes - bytes;
111
+ if (remaining > 0) chunks.push(chunk.slice(0, remaining));
112
+ bytes = outputCapBytes;
113
+ truncated = true;
114
+ try { child.kill("SIGTERM"); } catch (_) {}
115
+ } else {
116
+ chunks.push(chunk);
117
+ bytes += chunk.length;
118
+ }
119
+ });
120
+
121
+ child.stderr.on("data", (chunk) => {
122
+ if (errChunks.length < 32) errChunks.push(chunk);
123
+ });
124
+
125
+ child.on("error", (err) => {
126
+ finish({ error: `shell recipe spawn error: ${err && err.message ? err.message : err}` });
127
+ });
128
+
129
+ child.on("close", (code, signal) => {
130
+ const stdout = Buffer.concat(chunks).toString("utf8");
131
+ if (code !== 0 && !truncated) {
132
+ const stderr = Buffer.concat(errChunks).toString("utf8").trim();
133
+ finish({
134
+ error: signal
135
+ ? `shell recipe killed by ${signal}`
136
+ : `shell recipe exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}`,
137
+ });
138
+ return;
139
+ }
140
+ finish({ output: parseJsonIfPossible(stdout) });
141
+ });
142
+ });
143
+ }
144
+
145
+ function checkIpv4Tuple(a, b) {
146
+ if (a === 127) return "loopback IPv4 blocked";
147
+ if (a === 10) return "RFC1918 IPv4 blocked";
148
+ if (a === 172 && b >= 16 && b <= 31) return "RFC1918 IPv4 blocked";
149
+ if (a === 192 && b === 168) return "RFC1918 IPv4 blocked";
150
+ if (a === 169 && b === 254) return "link-local / cloud-metadata IPv4 blocked";
151
+ if (a === 0) return "zero-network IPv4 blocked";
152
+ if (a >= 224) return "multicast/reserved IPv4 blocked";
153
+ return null;
154
+ }
155
+
156
+ // Classify a resolved IP literal (as returned by dns.lookup). Reused both
157
+ // by the URL-form check below and by the per-connection lookup that fires
158
+ // when undici opens the socket — see safeLookup. For IPv4 we route through
159
+ // checkIpv4Tuple; for IPv6 we additionally peel IPv4-mapped (::ffff:a.b.c.d)
160
+ // and the hex-compressed IPv4-compat form that Node sometimes returns.
161
+ function checkResolvedIp(address, family) {
162
+ if (typeof address !== "string") return null;
163
+ if (family === 4) {
164
+ const m = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
165
+ if (!m) return null;
166
+ return checkIpv4Tuple(Number(m[1]), Number(m[2]));
167
+ }
168
+ if (family !== 6) return null;
169
+ const addr = address.toLowerCase();
170
+ if (addr === "::" || addr === "::1") return "IPv6 loopback/unspecified blocked";
171
+ const mappedDotted = addr.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
172
+ if (mappedDotted) {
173
+ const r = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
174
+ return r ? `IPv4-mapped IPv6 (${r})` : null;
175
+ }
176
+ const mappedHex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
177
+ if (mappedHex) {
178
+ const high = parseInt(mappedHex[1], 16);
179
+ const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
180
+ return r ? `IPv4-mapped IPv6 (${r})` : null;
181
+ }
182
+ const compatHex = addr.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
183
+ if (compatHex) {
184
+ const high = parseInt(compatHex[1], 16);
185
+ const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
186
+ return r ? `IPv4-compatible IPv6 (${r})` : null;
187
+ }
188
+ // fe80::/10 spans fe80:: through febf:: — the top 10 bits are 1111111010,
189
+ // so the first byte is always 0xfe and the second byte ranges 0x80-0xbf.
190
+ // The canonical first hextet is therefore fe[89ab][0-9a-f] (the leading
191
+ // 'fe' is fixed, the third hex char is 8/9/a/b for the high two bits 10).
192
+ // The old startsWith("fe80:") check missed fe81::* through febf::*.
193
+ if (/^fe[89ab][0-9a-f]:/.test(addr)) return "IPv6 link-local blocked";
194
+ if (/^f[cd][0-9a-f]{2}:/.test(addr)) return "IPv6 ULA blocked";
195
+ return null;
196
+ }
197
+
198
+ // Per-connection DNS lookup that undici calls just before opening the socket.
199
+ // We resolve the hostname via the same resolver fetch would use, then check
200
+ // EVERY returned address: if any resolves into a private/loopback/link-local
201
+ // range, reject the whole hostname. Strict — split-horizon DNS that returns
202
+ // public IPs at one moment and private at another can't slip past, because
203
+ // we only ever connect via this lookup's returned address (no TOCTOU). TLS
204
+ // SNI and certificate verification keep using the URL hostname downstream,
205
+ // so https://public.example still validates against its public cert.
206
+ export function makeSafeLookup(dnsLookup) {
207
+ return function safeLookup(hostname, opts, cb) {
208
+ const family = opts && typeof opts.family === "number" ? opts.family : 0;
209
+ Promise.resolve()
210
+ .then(() => dnsLookup(hostname, { all: true, family: 0 }))
211
+ .then((records) => {
212
+ if (!Array.isArray(records) || records.length === 0) {
213
+ cb(new Error(`SSRF guard: no DNS records for ${hostname}`));
214
+ return;
215
+ }
216
+ for (const r of records) {
217
+ const reason = checkResolvedIp(r.address, r.family);
218
+ if (reason) {
219
+ cb(new Error(`SSRF guard: ${hostname} resolves to ${r.address} (${reason})`));
220
+ return;
221
+ }
222
+ }
223
+ const picked =
224
+ family === 4 || family === 6
225
+ ? records.find((r) => r.family === family) || records[0]
226
+ : records[0];
227
+ cb(null, picked.address, picked.family);
228
+ })
229
+ .catch((err) => cb(err));
230
+ };
231
+ }
232
+
233
+ const ssrfSafeDispatcher = new Agent({
234
+ connect: { lookup: makeSafeLookup(dns.promises.lookup) },
235
+ });
236
+
237
+ // Block direct-IP requests to loopback, link-local (incl. 169.254.169.254
238
+ // cloud-metadata IPs), RFC1918 private space, IPv6 ULA/link-local. This is
239
+ // the cheap URL-form check that runs before any DNS — it rejects literal IPs
240
+ // (including IPv4-mapped/compat IPv6) without a round-trip.
241
+ //
242
+ // Hostnames pass through this URL-form check by design; the second layer is
243
+ // safeLookup() above, which undici invokes just before opening the socket.
244
+ // That layer closes the real attack surface here:
245
+ // 1. Static A record. evil.example with `A 127.0.0.1` (or 169.254.169.254,
246
+ // or an RFC1918 IP) — no DNS rebinding required, the agent just owns
247
+ // a domain.
248
+ // 2. Operator's resolver context. evenclaw deployments may resolve names
249
+ // like grafana.internal or vault to private services the operator
250
+ // didn't realize the plugin could reach.
251
+ // 3. Cloud-metadata uniformity. evil.example → 169.254.169.254 works on
252
+ // every AWS/GCP/Azure VM running OcuClaw.
253
+ // safeLookup rejects the hostname if ANY resolved IP is private — strict, so
254
+ // split-horizon DNS can't slip past either.
255
+ //
256
+ // Non-http(s) schemes (file:, gopher:, ftp:, data:) are rejected outright.
257
+ function isForbiddenHttpDestination(urlString) {
258
+ let parsed;
259
+ try {
260
+ parsed = new URL(urlString);
261
+ } catch (_) {
262
+ return "invalid url";
263
+ }
264
+ const proto = parsed.protocol;
265
+ if (proto !== "http:" && proto !== "https:") {
266
+ return `disallowed scheme: ${proto}`;
267
+ }
268
+ // Node's WHATWG URL.hostname may or may not strip the IPv6 brackets across
269
+ // versions; normalize by stripping them ourselves so the IPv6 checks below
270
+ // work regardless.
271
+ const rawHost = parsed.hostname.toLowerCase();
272
+ const host = rawHost.startsWith("[") && rawHost.endsWith("]")
273
+ ? rawHost.slice(1, -1)
274
+ : rawHost;
275
+ // Common loopback aliases.
276
+ if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") {
277
+ return "loopback hostname blocked";
278
+ }
279
+ // IPv4 dotted quad?
280
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
281
+ if (v4) {
282
+ return checkIpv4Tuple(Number(v4[1]), Number(v4[2]));
283
+ }
284
+ // Bare hex IPv4 like 0x7f000001 — refuse anything that looks numeric.
285
+ if (/^[0-9a-f.x]+$/.test(host) && /^\d/.test(host) && !host.includes(":")) {
286
+ return "ambiguous numeric host blocked";
287
+ }
288
+ // IPv6? URL.hostname strips brackets but keeps colons.
289
+ if (host.includes(":")) {
290
+ if (host === "::" || host === "::1") return "IPv6 loopback/unspecified blocked";
291
+ // IPv4-mapped IPv6 (RFC 4291 §2.5.5.2): ::ffff:a.b.c.d (dotted) or
292
+ // ::ffff:XXXX:YYYY (hex). Also IPv4-compatible (deprecated): ::a.b.c.d.
293
+ // All of these resolve to an IPv4 destination — extract the embedded
294
+ // IPv4 and run it through the IPv4 rules so the bypass closes.
295
+ const mappedDotted = host.match(/^::(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
296
+ if (mappedDotted) {
297
+ const reason = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
298
+ if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
299
+ return null;
300
+ }
301
+ const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
302
+ if (mappedHex) {
303
+ const high = parseInt(mappedHex[1], 16);
304
+ const a = (high >> 8) & 0xff;
305
+ const b = high & 0xff;
306
+ const reason = checkIpv4Tuple(a, b);
307
+ if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
308
+ return null;
309
+ }
310
+ // IPv4-compatible IPv6 (deprecated, RFC 4291 §2.5.5.1): ::XXXX:YYYY
311
+ // Node's URL parser normalizes ::a.b.c.d to this compressed-hex form, so
312
+ // the dotted regex above never fires for compat addresses — handle it
313
+ // here. Excludes the IPv4-mapped case (caught above) and the IPv6
314
+ // loopback ::1 (caught earlier as an exact match).
315
+ const compatHex = host.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
316
+ if (compatHex) {
317
+ const high = parseInt(compatHex[1], 16);
318
+ const a = (high >> 8) & 0xff;
319
+ const b = high & 0xff;
320
+ const reason = checkIpv4Tuple(a, b);
321
+ if (reason) return `IPv4-compatible IPv6 blocked (${reason})`;
322
+ return null;
323
+ }
324
+ // fe80::/10 spans fe80:: through febf:: — see comment in checkResolvedIp.
325
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return "IPv6 link-local blocked";
326
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return "IPv6 ULA blocked";
327
+ return null;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ function resolveJsonPath(value, jsonPath) {
333
+ if (!jsonPath || typeof jsonPath !== "string") return value;
334
+ // Minimal JSONPath: only "$.a.b.c" and "$.a[0].b" shapes.
335
+ const expr = jsonPath.trim();
336
+ if (!expr.startsWith("$")) return value;
337
+ const rest = expr.slice(1).replace(/\[(\d+)\]/g, ".$1");
338
+ const segments = rest.split(".").filter(Boolean);
339
+ let cursor = value;
340
+ for (const seg of segments) {
341
+ if (cursor === null || cursor === undefined) return undefined;
342
+ if (Array.isArray(cursor)) {
343
+ const idx = Number(seg);
344
+ cursor = Number.isInteger(idx) ? cursor[idx] : undefined;
345
+ } else if (typeof cursor === "object") {
346
+ cursor = cursor[seg];
347
+ } else {
348
+ return undefined;
349
+ }
350
+ }
351
+ return cursor;
352
+ }
353
+
354
+ export async function executeHttpRecipe(params, opts) {
355
+ const url = params && typeof params.url === "string" ? params.url : "";
356
+ if (!url) return { error: "http recipe missing url" };
357
+ // The SSRF guard is enabled by default for all production callers (the cron
358
+ // engine invokes executeHttpRecipe(recipe) with no second argument). Tests
359
+ // that spin up their own loopback HTTP server opt out via
360
+ // executeHttpRecipe(recipe, { allowPrivateNetworks: true }). The recipe
361
+ // schema deliberately does NOT include this flag, so agents can't bypass.
362
+ if (!(opts && opts.allowPrivateNetworks === true)) {
363
+ const forbidden = isForbiddenHttpDestination(url);
364
+ if (forbidden) {
365
+ return { error: `http recipe destination blocked: ${forbidden}` };
366
+ }
367
+ }
368
+ const method = params && typeof params.method === "string" ? params.method.toUpperCase() : "GET";
369
+ const headers = params && params.headers && typeof params.headers === "object" ? params.headers : {};
370
+ const body = method !== "GET" && method !== "HEAD" ? params && params.body : undefined;
371
+ const timeoutMs = Number.isFinite(params && params.timeoutMs)
372
+ ? params.timeoutMs
373
+ : DEFAULT_TIMEOUT_MS;
374
+ const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
375
+ ? params.outputCapBytes
376
+ : DEFAULT_OUTPUT_CAP_BYTES;
377
+ const jsonPath = params && typeof params.jsonPath === "string" ? params.jsonPath : "";
378
+
379
+ const fetchFn = opts && typeof opts.fetch === "function" ? opts.fetch : fetch;
380
+ // The dispatcher carries the per-connection DNS lookup (safeLookup) that
381
+ // resolves hostnames and blocks any that point at private IPs — see the
382
+ // comment block on isForbiddenHttpDestination. Production callers default
383
+ // to the module-level ssrfSafeDispatcher; the test seam accepts an
384
+ // injected dispatcher (e.g. one built with a canned lookup) so the lookup
385
+ // check can be exercised without real DNS.
386
+ const dispatcher =
387
+ opts && opts.dispatcher !== undefined ? opts.dispatcher : ssrfSafeDispatcher;
388
+ const controller = new AbortController();
389
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
390
+ const allowPrivate = opts && opts.allowPrivateNetworks === true;
391
+ const MAX_REDIRECTS = 3;
392
+ try {
393
+ // Manual redirect handling — fetch's default redirect: "follow" lets a
394
+ // public attacker-controlled domain return a 302 → http://169.254.169.254/
395
+ // after the one-shot URL check. Validate every hop. Capped at 3 to
396
+ // bound effort and prevent loops.
397
+ let currentUrl = url;
398
+ let response = null;
399
+ let hop = 0;
400
+ while (true) {
401
+ const fetchInit = {
402
+ method,
403
+ headers,
404
+ body,
405
+ signal: controller.signal,
406
+ redirect: "manual",
407
+ };
408
+ // Only attach a dispatcher when it's defined and non-null. Injected
409
+ // tests that pass `dispatcher: null` explicitly opt out (e.g. to use
410
+ // a mock fetch with no networking at all).
411
+ if (dispatcher) fetchInit.dispatcher = dispatcher;
412
+ response = await fetchFn(currentUrl, fetchInit);
413
+ if (response.status >= 300 && response.status < 400) {
414
+ const location = response.headers.get("location");
415
+ if (!location) {
416
+ return { error: `http recipe got ${response.status} with no Location header` };
417
+ }
418
+ if (hop >= MAX_REDIRECTS) {
419
+ return { error: `http recipe exceeded ${MAX_REDIRECTS} redirects` };
420
+ }
421
+ let nextUrl;
422
+ try {
423
+ nextUrl = new URL(location, currentUrl).toString();
424
+ } catch (_) {
425
+ return { error: `http recipe got invalid redirect Location: ${location}` };
426
+ }
427
+ if (!allowPrivate) {
428
+ const forbidden = isForbiddenHttpDestination(nextUrl);
429
+ if (forbidden) {
430
+ return { error: `http recipe redirect destination blocked: ${forbidden}` };
431
+ }
432
+ }
433
+ // Drain the redirect body to free the socket before the next hop.
434
+ try { await response.arrayBuffer(); } catch (_) {}
435
+ currentUrl = nextUrl;
436
+ hop += 1;
437
+ continue;
438
+ }
439
+ break;
440
+ }
441
+ if (response.status < 200 || response.status >= 300) {
442
+ return { error: `http recipe got status ${response.status}` };
443
+ }
444
+ const reader = response.body && response.body.getReader ? response.body.getReader() : null;
445
+ let bytes = 0;
446
+ const chunks = [];
447
+ if (reader) {
448
+ while (true) {
449
+ const { done, value } = await reader.read();
450
+ if (done) break;
451
+ if (bytes + value.length > outputCapBytes) {
452
+ chunks.push(value.subarray(0, outputCapBytes - bytes));
453
+ bytes = outputCapBytes;
454
+ try { await reader.cancel(); } catch (_) {}
455
+ break;
456
+ }
457
+ chunks.push(value);
458
+ bytes += value.length;
459
+ }
460
+ } else {
461
+ const text = await response.text();
462
+ chunks.push(Buffer.from(text.slice(0, outputCapBytes), "utf8"));
463
+ }
464
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
465
+ const merged = Buffer.alloc(totalLength);
466
+ let offset = 0;
467
+ for (const c of chunks) {
468
+ merged.set(c, offset);
469
+ offset += c.length;
470
+ }
471
+ const text = merged.toString("utf8");
472
+ const ct = response.headers.get("content-type") || "";
473
+ let parsed;
474
+ if (ct.toLowerCase().includes("json")) {
475
+ try { parsed = JSON.parse(text); } catch (_) { parsed = text; }
476
+ } else {
477
+ parsed = parseJsonIfPossible(text);
478
+ }
479
+ const extracted = jsonPath ? resolveJsonPath(parsed, jsonPath) : parsed;
480
+ return { output: extracted };
481
+ } catch (err) {
482
+ if (err && err.name === "AbortError") {
483
+ return { error: `http recipe timeout after ${timeoutMs}ms` };
484
+ }
485
+ // undici wraps connect-time errors (including our safeLookup rejections)
486
+ // as `TypeError: fetch failed` with the real reason on err.cause. Surface
487
+ // the cause so SSRF-guard rejections are visible to the operator and
488
+ // testable.
489
+ const msg = err && err.message ? err.message : String(err);
490
+ const causeMsg = err && err.cause && err.cause.message ? err.cause.message : "";
491
+ const full = causeMsg ? `${msg}: ${causeMsg}` : msg;
492
+ return { error: `http recipe error: ${full}` };
493
+ } finally {
494
+ clearTimeout(timer);
495
+ }
496
+ }
497
+
498
+ const DEFAULT_SYSTEM_PROMPT = (maxChars, previousBody) =>
499
+ `You are a tick worker producing a single short line of text for a head-mounted display surface. Reply with ONLY the new value to display, no preamble, no quotes, no JSON. Maximum ${maxChars} characters. Previous value: ${JSON.stringify(previousBody || "")}.`;
500
+
501
+ function stripModelProviderPrefix(modelRef) {
502
+ if (typeof modelRef !== "string") return "";
503
+ const idx = modelRef.indexOf("/");
504
+ return idx === -1 ? modelRef : modelRef.slice(idx + 1);
505
+ }
506
+
507
+ async function runClaudeCli(params, deps) {
508
+ const spawnFn = deps && deps.spawn ? deps.spawn : spawn;
509
+ const promptText =
510
+ (params.systemPrompt ? params.systemPrompt + "\n\n" : "") + params.prompt;
511
+ const model = stripModelProviderPrefix(params.model);
512
+ // Lock the spawned Claude CLI to plan-mode with an empty toolset so the
513
+ // tick prompt can't drive file/shell/web tools to exfil. --bare disables
514
+ // hooks, plugin sync, CLAUDE.md auto-discovery, and keychain reads — the
515
+ // CLI runs as a pure text-in/text-out worker.
516
+ const args = [
517
+ "-p", promptText,
518
+ "--output-format", "text",
519
+ "--permission-mode", "plan",
520
+ "--tools", "",
521
+ "--bare",
522
+ ];
523
+ if (model) args.push("--model", model);
524
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 60_000;
525
+
526
+ return new Promise((resolve) => {
527
+ const child = spawnFn("claude", args, {
528
+ stdio: ["ignore", "pipe", "pipe"],
529
+ cwd: tmpdir(),
530
+ env: buildScopedSpawnEnv(),
531
+ });
532
+ const chunks = [];
533
+ const errChunks = [];
534
+ let done = false;
535
+ const finish = (result) => {
536
+ if (done) return;
537
+ done = true;
538
+ clearTimeout(timer);
539
+ try { child.kill && child.kill("SIGKILL"); } catch (_) {}
540
+ resolve(result);
541
+ };
542
+ const timer = setTimeout(
543
+ () => finish({ error: `claude-cli timeout after ${timeoutMs}ms` }),
544
+ timeoutMs,
545
+ );
546
+ child.stdout.on("data", (c) => chunks.push(c));
547
+ child.stderr.on("data", (c) => errChunks.push(c));
548
+ child.on("error", (err) => finish({ error: `claude-cli spawn error: ${err.message}` }));
549
+ child.on("close", (code) => {
550
+ if (code !== 0) {
551
+ const stderr = Buffer.concat(errChunks).toString("utf8").trim();
552
+ finish({ error: `claude-cli exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}` });
553
+ return;
554
+ }
555
+ finish({ output: Buffer.concat(chunks).toString("utf8") });
556
+ });
557
+ });
558
+ }
559
+
560
+ // codex-cli backend removed (round-5 autoreview): Codex's `read-only`
561
+ // sandbox blocks writes and exec but still permits filesystem reads, so
562
+ // an agent prompt could drive the spawned process to read ~/.aws/credentials,
563
+ // ~/.ssh/*, etc. and emit them through stdout → glasses body. Claude CLI
564
+ // is structurally safe because --tools "" disables every tool including
565
+ // Read; Codex has no equivalent flag. Operators who want Codex point an
566
+ // openai-compat backend at https://api.openai.com (or wherever Codex is
567
+ // served) — that path has no tool surface at all.
568
+
569
+ async function runAnthropicApi(params, deps) {
570
+ const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
571
+ if (!params.apiKey) return { error: "anthropic-api: missing api key" };
572
+ const model = stripModelProviderPrefix(params.model);
573
+ const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
574
+ const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
575
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
576
+ const controller = new AbortController();
577
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
578
+ try {
579
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
580
+ method: "POST",
581
+ headers: {
582
+ "x-api-key": params.apiKey,
583
+ "anthropic-version": "2023-06-01",
584
+ "Content-Type": "application/json",
585
+ },
586
+ body: JSON.stringify({
587
+ model,
588
+ max_tokens,
589
+ system: systemPrompt,
590
+ messages: [{ role: "user", content: params.prompt }],
591
+ }),
592
+ signal: controller.signal,
593
+ });
594
+ if (response.status < 200 || response.status >= 300) {
595
+ return { error: `anthropic-api status ${response.status}` };
596
+ }
597
+ const json = await response.json();
598
+ const text = Array.isArray(json.content)
599
+ ? json.content.filter((b) => b && b.type === "text").map((b) => b.text).join("")
600
+ : "";
601
+ return { output: text };
602
+ } catch (err) {
603
+ if (err && err.name === "AbortError") return { error: `anthropic-api timeout after ${timeoutMs}ms` };
604
+ return { error: `anthropic-api error: ${err && err.message ? err.message : String(err)}` };
605
+ } finally {
606
+ clearTimeout(timer);
607
+ }
608
+ }
609
+
610
+ async function runOpenAiCompat(params, deps) {
611
+ const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
612
+ if (!params.apiKey) return { error: "openai-compat: missing api key" };
613
+ if (!params.baseUrl) return { error: "openai-compat: missing baseUrl" };
614
+ const model = stripModelProviderPrefix(params.model);
615
+ const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
616
+ const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
617
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
618
+ const controller = new AbortController();
619
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
620
+ try {
621
+ const response = await fetchFn(`${params.baseUrl}/v1/chat/completions`, {
622
+ method: "POST",
623
+ headers: {
624
+ Authorization: `Bearer ${params.apiKey}`,
625
+ "Content-Type": "application/json",
626
+ },
627
+ body: JSON.stringify({
628
+ model,
629
+ max_tokens,
630
+ messages: [
631
+ { role: "system", content: systemPrompt },
632
+ { role: "user", content: params.prompt },
633
+ ],
634
+ }),
635
+ signal: controller.signal,
636
+ });
637
+ if (response.status < 200 || response.status >= 300) {
638
+ return { error: `openai-compat status ${response.status}` };
639
+ }
640
+ const json = await response.json();
641
+ const text =
642
+ json && json.choices && json.choices[0] && json.choices[0].message
643
+ ? json.choices[0].message.content || ""
644
+ : "";
645
+ return { output: text };
646
+ } catch (err) {
647
+ if (err && err.name === "AbortError") return { error: `openai-compat timeout after ${timeoutMs}ms` };
648
+ return { error: `openai-compat error: ${err && err.message ? err.message : String(err)}` };
649
+ } finally {
650
+ clearTimeout(timer);
651
+ }
652
+ }
653
+
654
+ export async function executeLlmRecipeWithDeps(recipe, ctx, deps) {
655
+ const backend = ctx && typeof ctx.backend === "string" ? ctx.backend : "";
656
+ const baseParams = {
657
+ prompt: recipe && typeof recipe.prompt === "string" ? recipe.prompt : "",
658
+ systemPrompt: recipe && typeof recipe.systemPrompt === "string" ? recipe.systemPrompt : undefined,
659
+ model: ctx && typeof ctx.model === "string" ? ctx.model : "",
660
+ maxOutputTokens:
661
+ ctx && Number.isFinite(ctx.maxOutputTokens) ? ctx.maxOutputTokens : undefined,
662
+ apiKey: ctx && typeof ctx.apiKey === "string" ? ctx.apiKey : "",
663
+ baseUrl: ctx && typeof ctx.baseUrl === "string" ? ctx.baseUrl : "",
664
+ previousBody: ctx && typeof ctx.previousBody === "string" ? ctx.previousBody : "",
665
+ timeoutMs: ctx && Number.isFinite(ctx.timeoutMs) ? ctx.timeoutMs : undefined,
666
+ };
667
+ if (!baseParams.prompt) return { error: "llm recipe missing prompt" };
668
+ switch (backend) {
669
+ case "claude-cli":
670
+ return runClaudeCli(baseParams, deps || {});
671
+ case "anthropic-api":
672
+ return runAnthropicApi(baseParams, deps || {});
673
+ case "openai-compat":
674
+ return runOpenAiCompat(baseParams, deps || {});
675
+ default:
676
+ return { error: `unknown backend: ${JSON.stringify(backend)}` };
677
+ }
678
+ }
679
+
680
+ export function executeLlmRecipe(recipe, ctx) {
681
+ return executeLlmRecipeWithDeps(recipe, ctx, {});
682
+ }
683
+
684
+ // L0' tier: host RAM/CPU via in-process node:os reads. No shell (a sandboxed
685
+ // `free -m` would report the container's view, not the host's). CPU% is a delta
686
+ // over a short window — os.loadavg() is run-queue length, not utilization. Deps
687
+ // (totalmem/freemem/loadavg/cpus/sleep) are injectable for deterministic tests.
688
+ export function computeCpuPct(t0, t1) {
689
+ let idleDelta = 0;
690
+ let totalDelta = 0;
691
+ const n = Math.min(Array.isArray(t0) ? t0.length : 0, Array.isArray(t1) ? t1.length : 0);
692
+ for (let i = 0; i < n; i += 1) {
693
+ const a = (t0[i] && t0[i].times) || {};
694
+ const b = (t1[i] && t1[i].times) || {};
695
+ const totA = (a.user || 0) + (a.nice || 0) + (a.sys || 0) + (a.idle || 0) + (a.irq || 0);
696
+ const totB = (b.user || 0) + (b.nice || 0) + (b.sys || 0) + (b.idle || 0) + (b.irq || 0);
697
+ idleDelta += (b.idle || 0) - (a.idle || 0);
698
+ totalDelta += totB - totA;
699
+ }
700
+ if (totalDelta <= 0) return 0;
701
+ return Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)));
702
+ }
703
+
704
+ export async function executeSystemStatsRecipe(params, opts) {
705
+ const o = opts || {};
706
+ const totalmemFn = typeof o.totalmem === "function" ? o.totalmem : totalmem;
707
+ const freememFn = typeof o.freemem === "function" ? o.freemem : freemem;
708
+ const loadavgFn = typeof o.loadavg === "function" ? o.loadavg : loadavg;
709
+ const cpusFn = typeof o.cpus === "function" ? o.cpus : cpus;
710
+ const sleep =
711
+ typeof o.sleep === "function" ? o.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
712
+ const requested =
713
+ params && Number.isFinite(params.sampleWindowMs)
714
+ ? params.sampleWindowMs
715
+ : SYSTEM_STATS_WINDOW_DEFAULT_MS;
716
+ const windowMs = Math.max(
717
+ SYSTEM_STATS_WINDOW_MIN_MS,
718
+ Math.min(SYSTEM_STATS_WINDOW_MAX_MS, Math.floor(requested)),
719
+ );
720
+ try {
721
+ const t0 = cpusFn();
722
+ await sleep(windowMs);
723
+ const t1 = cpusFn();
724
+ const cpuPct = computeCpuPct(t0, t1);
725
+ const total = totalmemFn();
726
+ const free = freememFn();
727
+ const used = total - free;
728
+ const load = loadavgFn();
729
+ const toMb = (b) => Math.round(b / (1024 * 1024));
730
+ const round1 = (x) => Math.round(x * 10) / 10;
731
+ return {
732
+ output: {
733
+ memTotalMb: toMb(total),
734
+ memUsedMb: toMb(used),
735
+ memFreeMb: toMb(free),
736
+ memUsedPct: total > 0 ? round1((used / total) * 100) : 0,
737
+ cpuPct: round1(cpuPct),
738
+ loadAvg1: Array.isArray(load) && load.length > 0 ? Math.round(load[0] * 100) / 100 : 0,
739
+ },
740
+ };
741
+ } catch (err) {
742
+ return { error: `system-stats read failed: ${err && err.message ? err.message : err}` };
743
+ }
744
+ }
745
+
746
+ export default { executeShellRecipe, executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };