ocuclaw 1.2.4 → 1.3.1
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.
- package/README.md +21 -6
- package/dist/config/runtime-config.js +84 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +56 -182
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +754 -83
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1293 -225
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +281 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1004 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +638 -27
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +581 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1111 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +14 -5
- package/skills/glasses-ui/SKILL.md +156 -0
- package/dist/runtime/downstream-server.js +0 -1891
|
@@ -0,0 +1,581 @@
|
|
|
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
|
+
import { totalmem, freemem, loadavg, cpus } from "node:os";
|
|
12
|
+
import * as dns from "node:dns";
|
|
13
|
+
import { Agent } from "undici";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
16
|
+
const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
const SYSTEM_STATS_WINDOW_DEFAULT_MS = 200;
|
|
19
|
+
const SYSTEM_STATS_WINDOW_MIN_MS = 50;
|
|
20
|
+
const SYSTEM_STATS_WINDOW_MAX_MS = 1000;
|
|
21
|
+
|
|
22
|
+
function parseJsonIfPossible(text) {
|
|
23
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
24
|
+
const trimmed = text.trim();
|
|
25
|
+
if (
|
|
26
|
+
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
|
27
|
+
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
28
|
+
) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(trimmed);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkIpv4Tuple(a, b) {
|
|
39
|
+
if (a === 127) return "loopback IPv4 blocked";
|
|
40
|
+
if (a === 10) return "RFC1918 IPv4 blocked";
|
|
41
|
+
if (a === 172 && b >= 16 && b <= 31) return "RFC1918 IPv4 blocked";
|
|
42
|
+
if (a === 192 && b === 168) return "RFC1918 IPv4 blocked";
|
|
43
|
+
if (a === 169 && b === 254) return "link-local / cloud-metadata IPv4 blocked";
|
|
44
|
+
if (a === 0) return "zero-network IPv4 blocked";
|
|
45
|
+
if (a >= 224) return "multicast/reserved IPv4 blocked";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
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
|
+
function checkResolvedIp(address, family) {
|
|
55
|
+
if (typeof address !== "string") return null;
|
|
56
|
+
if (family === 4) {
|
|
57
|
+
const m = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
return checkIpv4Tuple(Number(m[1]), Number(m[2]));
|
|
60
|
+
}
|
|
61
|
+
if (family !== 6) return null;
|
|
62
|
+
const addr = address.toLowerCase();
|
|
63
|
+
if (addr === "::" || addr === "::1") return "IPv6 loopback/unspecified blocked";
|
|
64
|
+
const mappedDotted = addr.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
65
|
+
if (mappedDotted) {
|
|
66
|
+
const r = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
|
|
67
|
+
return r ? `IPv4-mapped IPv6 (${r})` : null;
|
|
68
|
+
}
|
|
69
|
+
const mappedHex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
70
|
+
if (mappedHex) {
|
|
71
|
+
const high = parseInt(mappedHex[1], 16);
|
|
72
|
+
const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
|
|
73
|
+
return r ? `IPv4-mapped IPv6 (${r})` : null;
|
|
74
|
+
}
|
|
75
|
+
const compatHex = addr.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
76
|
+
if (compatHex) {
|
|
77
|
+
const high = parseInt(compatHex[1], 16);
|
|
78
|
+
const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
|
|
79
|
+
return r ? `IPv4-compatible IPv6 (${r})` : null;
|
|
80
|
+
}
|
|
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::*.
|
|
86
|
+
if (/^fe[89ab][0-9a-f]:/.test(addr)) return "IPv6 link-local blocked";
|
|
87
|
+
if (/^f[cd][0-9a-f]{2}:/.test(addr)) return "IPv6 ULA blocked";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
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
|
+
export function makeSafeLookup(dnsLookup) {
|
|
100
|
+
return function safeLookup(hostname, opts, cb) {
|
|
101
|
+
const family = opts && typeof opts.family === "number" ? opts.family : 0;
|
|
102
|
+
Promise.resolve()
|
|
103
|
+
.then(() => dnsLookup(hostname, { all: true, family: 0 }))
|
|
104
|
+
.then((records) => {
|
|
105
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
106
|
+
cb(new Error(`SSRF guard: no DNS records for ${hostname}`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const r of records) {
|
|
110
|
+
const reason = checkResolvedIp(r.address, r.family);
|
|
111
|
+
if (reason) {
|
|
112
|
+
cb(new Error(`SSRF guard: ${hostname} resolves to ${r.address} (${reason})`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const picked =
|
|
117
|
+
family === 4 || family === 6
|
|
118
|
+
? records.find((r) => r.family === family) || records[0]
|
|
119
|
+
: records[0];
|
|
120
|
+
cb(null, picked.address, picked.family);
|
|
121
|
+
})
|
|
122
|
+
.catch((err) => cb(err));
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ssrfSafeDispatcher = new Agent({
|
|
127
|
+
connect: { lookup: makeSafeLookup(dns.promises.lookup) },
|
|
128
|
+
});
|
|
129
|
+
|
|
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
|
+
function isForbiddenHttpDestination(urlString) {
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = new URL(urlString);
|
|
154
|
+
} catch (_) {
|
|
155
|
+
return "invalid url";
|
|
156
|
+
}
|
|
157
|
+
const proto = parsed.protocol;
|
|
158
|
+
if (proto !== "http:" && proto !== "https:") {
|
|
159
|
+
return `disallowed scheme: ${proto}`;
|
|
160
|
+
}
|
|
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.
|
|
164
|
+
const rawHost = parsed.hostname.toLowerCase();
|
|
165
|
+
const host = rawHost.startsWith("[") && rawHost.endsWith("]")
|
|
166
|
+
? rawHost.slice(1, -1)
|
|
167
|
+
: rawHost;
|
|
168
|
+
// Common loopback aliases.
|
|
169
|
+
if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") {
|
|
170
|
+
return "loopback hostname blocked";
|
|
171
|
+
}
|
|
172
|
+
// IPv4 dotted quad?
|
|
173
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
174
|
+
if (v4) {
|
|
175
|
+
return checkIpv4Tuple(Number(v4[1]), Number(v4[2]));
|
|
176
|
+
}
|
|
177
|
+
// Bare hex IPv4 like 0x7f000001 — refuse anything that looks numeric.
|
|
178
|
+
if (/^[0-9a-f.x]+$/.test(host) && /^\d/.test(host) && !host.includes(":")) {
|
|
179
|
+
return "ambiguous numeric host blocked";
|
|
180
|
+
}
|
|
181
|
+
// IPv6? URL.hostname strips brackets but keeps colons.
|
|
182
|
+
if (host.includes(":")) {
|
|
183
|
+
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.
|
|
188
|
+
const mappedDotted = host.match(/^::(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
189
|
+
if (mappedDotted) {
|
|
190
|
+
const reason = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
|
|
191
|
+
if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
195
|
+
if (mappedHex) {
|
|
196
|
+
const high = parseInt(mappedHex[1], 16);
|
|
197
|
+
const a = (high >> 8) & 0xff;
|
|
198
|
+
const b = high & 0xff;
|
|
199
|
+
const reason = checkIpv4Tuple(a, b);
|
|
200
|
+
if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
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).
|
|
208
|
+
const compatHex = host.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
209
|
+
if (compatHex) {
|
|
210
|
+
const high = parseInt(compatHex[1], 16);
|
|
211
|
+
const a = (high >> 8) & 0xff;
|
|
212
|
+
const b = high & 0xff;
|
|
213
|
+
const reason = checkIpv4Tuple(a, b);
|
|
214
|
+
if (reason) return `IPv4-compatible IPv6 blocked (${reason})`;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
// fe80::/10 spans fe80:: through febf:: — see comment in checkResolvedIp.
|
|
218
|
+
if (/^fe[89ab][0-9a-f]:/.test(host)) return "IPv6 link-local blocked";
|
|
219
|
+
if (/^f[cd][0-9a-f]{2}:/.test(host)) return "IPv6 ULA blocked";
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveJsonPath(value, jsonPath) {
|
|
226
|
+
if (!jsonPath || typeof jsonPath !== "string") return value;
|
|
227
|
+
// Minimal JSONPath: only "$.a.b.c" and "$.a[0].b" shapes.
|
|
228
|
+
const expr = jsonPath.trim();
|
|
229
|
+
if (!expr.startsWith("$")) return value;
|
|
230
|
+
const rest = expr.slice(1).replace(/\[(\d+)\]/g, ".$1");
|
|
231
|
+
const segments = rest.split(".").filter(Boolean);
|
|
232
|
+
let cursor = value;
|
|
233
|
+
for (const seg of segments) {
|
|
234
|
+
if (cursor === null || cursor === undefined) return undefined;
|
|
235
|
+
if (Array.isArray(cursor)) {
|
|
236
|
+
const idx = Number(seg);
|
|
237
|
+
cursor = Number.isInteger(idx) ? cursor[idx] : undefined;
|
|
238
|
+
} else if (typeof cursor === "object") {
|
|
239
|
+
cursor = cursor[seg];
|
|
240
|
+
} else {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return cursor;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function executeHttpRecipe(params, opts) {
|
|
248
|
+
const url = params && typeof params.url === "string" ? params.url : "";
|
|
249
|
+
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.
|
|
255
|
+
if (!(opts && opts.allowPrivateNetworks === true)) {
|
|
256
|
+
const forbidden = isForbiddenHttpDestination(url);
|
|
257
|
+
if (forbidden) {
|
|
258
|
+
return { error: `http recipe destination blocked: ${forbidden}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const method = params && typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
262
|
+
const headers = params && params.headers && typeof params.headers === "object" ? params.headers : {};
|
|
263
|
+
const body = method !== "GET" && method !== "HEAD" ? params && params.body : undefined;
|
|
264
|
+
const timeoutMs = Number.isFinite(params && params.timeoutMs)
|
|
265
|
+
? params.timeoutMs
|
|
266
|
+
: DEFAULT_TIMEOUT_MS;
|
|
267
|
+
const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
|
|
268
|
+
? params.outputCapBytes
|
|
269
|
+
: DEFAULT_OUTPUT_CAP_BYTES;
|
|
270
|
+
const jsonPath = params && typeof params.jsonPath === "string" ? params.jsonPath : "";
|
|
271
|
+
|
|
272
|
+
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.
|
|
279
|
+
const dispatcher =
|
|
280
|
+
opts && opts.dispatcher !== undefined ? opts.dispatcher : ssrfSafeDispatcher;
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
283
|
+
const allowPrivate = opts && opts.allowPrivateNetworks === true;
|
|
284
|
+
const MAX_REDIRECTS = 3;
|
|
285
|
+
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.
|
|
290
|
+
let currentUrl = url;
|
|
291
|
+
let response = null;
|
|
292
|
+
let hop = 0;
|
|
293
|
+
while (true) {
|
|
294
|
+
const fetchInit = {
|
|
295
|
+
method,
|
|
296
|
+
headers,
|
|
297
|
+
body,
|
|
298
|
+
signal: controller.signal,
|
|
299
|
+
redirect: "manual",
|
|
300
|
+
};
|
|
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).
|
|
304
|
+
if (dispatcher) fetchInit.dispatcher = dispatcher;
|
|
305
|
+
response = await fetchFn(currentUrl, fetchInit);
|
|
306
|
+
if (response.status >= 300 && response.status < 400) {
|
|
307
|
+
const location = response.headers.get("location");
|
|
308
|
+
if (!location) {
|
|
309
|
+
return { error: `http recipe got ${response.status} with no Location header` };
|
|
310
|
+
}
|
|
311
|
+
if (hop >= MAX_REDIRECTS) {
|
|
312
|
+
return { error: `http recipe exceeded ${MAX_REDIRECTS} redirects` };
|
|
313
|
+
}
|
|
314
|
+
let nextUrl;
|
|
315
|
+
try {
|
|
316
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
317
|
+
} catch (_) {
|
|
318
|
+
return { error: `http recipe got invalid redirect Location: ${location}` };
|
|
319
|
+
}
|
|
320
|
+
if (!allowPrivate) {
|
|
321
|
+
const forbidden = isForbiddenHttpDestination(nextUrl);
|
|
322
|
+
if (forbidden) {
|
|
323
|
+
return { error: `http recipe redirect destination blocked: ${forbidden}` };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Drain the redirect body to free the socket before the next hop.
|
|
327
|
+
try { await response.arrayBuffer(); } catch (_) {}
|
|
328
|
+
currentUrl = nextUrl;
|
|
329
|
+
hop += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
if (response.status < 200 || response.status >= 300) {
|
|
335
|
+
return { error: `http recipe got status ${response.status}` };
|
|
336
|
+
}
|
|
337
|
+
const reader = response.body && response.body.getReader ? response.body.getReader() : null;
|
|
338
|
+
let bytes = 0;
|
|
339
|
+
const chunks = [];
|
|
340
|
+
if (reader) {
|
|
341
|
+
while (true) {
|
|
342
|
+
const { done, value } = await reader.read();
|
|
343
|
+
if (done) break;
|
|
344
|
+
if (bytes + value.length > outputCapBytes) {
|
|
345
|
+
chunks.push(value.subarray(0, outputCapBytes - bytes));
|
|
346
|
+
bytes = outputCapBytes;
|
|
347
|
+
try { await reader.cancel(); } catch (_) {}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
chunks.push(value);
|
|
351
|
+
bytes += value.length;
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
const text = await response.text();
|
|
355
|
+
chunks.push(Buffer.from(text.slice(0, outputCapBytes), "utf8"));
|
|
356
|
+
}
|
|
357
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
358
|
+
const merged = Buffer.alloc(totalLength);
|
|
359
|
+
let offset = 0;
|
|
360
|
+
for (const c of chunks) {
|
|
361
|
+
merged.set(c, offset);
|
|
362
|
+
offset += c.length;
|
|
363
|
+
}
|
|
364
|
+
const text = merged.toString("utf8");
|
|
365
|
+
const ct = response.headers.get("content-type") || "";
|
|
366
|
+
let parsed;
|
|
367
|
+
if (ct.toLowerCase().includes("json")) {
|
|
368
|
+
try { parsed = JSON.parse(text); } catch (_) { parsed = text; }
|
|
369
|
+
} else {
|
|
370
|
+
parsed = parseJsonIfPossible(text);
|
|
371
|
+
}
|
|
372
|
+
const extracted = jsonPath ? resolveJsonPath(parsed, jsonPath) : parsed;
|
|
373
|
+
return { output: extracted };
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err && err.name === "AbortError") {
|
|
376
|
+
return { error: `http recipe timeout after ${timeoutMs}ms` };
|
|
377
|
+
}
|
|
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
|
+
const msg = err && err.message ? err.message : String(err);
|
|
383
|
+
const causeMsg = err && err.cause && err.cause.message ? err.cause.message : "";
|
|
384
|
+
const full = causeMsg ? `${msg}: ${causeMsg}` : msg;
|
|
385
|
+
return { error: `http recipe error: ${full}` };
|
|
386
|
+
} finally {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const DEFAULT_SYSTEM_PROMPT = (maxChars, previousBody) =>
|
|
392
|
+
`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 || "")}.`;
|
|
393
|
+
|
|
394
|
+
function stripModelProviderPrefix(modelRef) {
|
|
395
|
+
if (typeof modelRef !== "string") return "";
|
|
396
|
+
const idx = modelRef.indexOf("/");
|
|
397
|
+
return idx === -1 ? modelRef : modelRef.slice(idx + 1);
|
|
398
|
+
}
|
|
399
|
+
|
|
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
|
+
async function runAnthropicApi(params, deps) {
|
|
407
|
+
const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
|
|
408
|
+
if (!params.apiKey) return { error: "anthropic-api: missing api key" };
|
|
409
|
+
const model = stripModelProviderPrefix(params.model);
|
|
410
|
+
const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
|
|
411
|
+
const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
|
|
412
|
+
const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
|
|
413
|
+
const controller = new AbortController();
|
|
414
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
415
|
+
try {
|
|
416
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
417
|
+
method: "POST",
|
|
418
|
+
headers: {
|
|
419
|
+
"x-api-key": params.apiKey,
|
|
420
|
+
"anthropic-version": "2023-06-01",
|
|
421
|
+
"Content-Type": "application/json",
|
|
422
|
+
},
|
|
423
|
+
body: JSON.stringify({
|
|
424
|
+
model,
|
|
425
|
+
max_tokens,
|
|
426
|
+
system: systemPrompt,
|
|
427
|
+
messages: [{ role: "user", content: params.prompt }],
|
|
428
|
+
}),
|
|
429
|
+
signal: controller.signal,
|
|
430
|
+
});
|
|
431
|
+
if (response.status < 200 || response.status >= 300) {
|
|
432
|
+
return { error: `anthropic-api status ${response.status}` };
|
|
433
|
+
}
|
|
434
|
+
const json = await response.json();
|
|
435
|
+
const text = Array.isArray(json.content)
|
|
436
|
+
? json.content.filter((b) => b && b.type === "text").map((b) => b.text).join("")
|
|
437
|
+
: "";
|
|
438
|
+
return { output: text };
|
|
439
|
+
} catch (err) {
|
|
440
|
+
if (err && err.name === "AbortError") return { error: `anthropic-api timeout after ${timeoutMs}ms` };
|
|
441
|
+
return { error: `anthropic-api error: ${err && err.message ? err.message : String(err)}` };
|
|
442
|
+
} finally {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function runOpenAiCompat(params, deps) {
|
|
448
|
+
const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
|
|
449
|
+
if (!params.apiKey) return { error: "openai-compat: missing api key" };
|
|
450
|
+
if (!params.baseUrl) return { error: "openai-compat: missing baseUrl" };
|
|
451
|
+
const model = stripModelProviderPrefix(params.model);
|
|
452
|
+
const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
|
|
453
|
+
const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
|
|
454
|
+
const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
|
|
455
|
+
const controller = new AbortController();
|
|
456
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
457
|
+
try {
|
|
458
|
+
const response = await fetchFn(`${params.baseUrl}/v1/chat/completions`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: {
|
|
461
|
+
Authorization: `Bearer ${params.apiKey}`,
|
|
462
|
+
"Content-Type": "application/json",
|
|
463
|
+
},
|
|
464
|
+
body: JSON.stringify({
|
|
465
|
+
model,
|
|
466
|
+
max_tokens,
|
|
467
|
+
messages: [
|
|
468
|
+
{ role: "system", content: systemPrompt },
|
|
469
|
+
{ role: "user", content: params.prompt },
|
|
470
|
+
],
|
|
471
|
+
}),
|
|
472
|
+
signal: controller.signal,
|
|
473
|
+
});
|
|
474
|
+
if (response.status < 200 || response.status >= 300) {
|
|
475
|
+
return { error: `openai-compat status ${response.status}` };
|
|
476
|
+
}
|
|
477
|
+
const json = await response.json();
|
|
478
|
+
const text =
|
|
479
|
+
json && json.choices && json.choices[0] && json.choices[0].message
|
|
480
|
+
? json.choices[0].message.content || ""
|
|
481
|
+
: "";
|
|
482
|
+
return { output: text };
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (err && err.name === "AbortError") return { error: `openai-compat timeout after ${timeoutMs}ms` };
|
|
485
|
+
return { error: `openai-compat error: ${err && err.message ? err.message : String(err)}` };
|
|
486
|
+
} finally {
|
|
487
|
+
clearTimeout(timer);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function executeLlmRecipeWithDeps(recipe, ctx, deps) {
|
|
492
|
+
const backend = ctx && typeof ctx.backend === "string" ? ctx.backend : "";
|
|
493
|
+
const baseParams = {
|
|
494
|
+
prompt: recipe && typeof recipe.prompt === "string" ? recipe.prompt : "",
|
|
495
|
+
systemPrompt: recipe && typeof recipe.systemPrompt === "string" ? recipe.systemPrompt : undefined,
|
|
496
|
+
model: ctx && typeof ctx.model === "string" ? ctx.model : "",
|
|
497
|
+
maxOutputTokens:
|
|
498
|
+
ctx && Number.isFinite(ctx.maxOutputTokens) ? ctx.maxOutputTokens : undefined,
|
|
499
|
+
apiKey: ctx && typeof ctx.apiKey === "string" ? ctx.apiKey : "",
|
|
500
|
+
baseUrl: ctx && typeof ctx.baseUrl === "string" ? ctx.baseUrl : "",
|
|
501
|
+
previousBody: ctx && typeof ctx.previousBody === "string" ? ctx.previousBody : "",
|
|
502
|
+
timeoutMs: ctx && Number.isFinite(ctx.timeoutMs) ? ctx.timeoutMs : undefined,
|
|
503
|
+
};
|
|
504
|
+
if (!baseParams.prompt) return { error: "llm recipe missing prompt" };
|
|
505
|
+
switch (backend) {
|
|
506
|
+
case "anthropic-api":
|
|
507
|
+
return runAnthropicApi(baseParams, deps || {});
|
|
508
|
+
case "openai-compat":
|
|
509
|
+
return runOpenAiCompat(baseParams, deps || {});
|
|
510
|
+
default:
|
|
511
|
+
return { error: `unknown backend: ${JSON.stringify(backend)}` };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function executeLlmRecipe(recipe, ctx) {
|
|
516
|
+
return executeLlmRecipeWithDeps(recipe, ctx, {});
|
|
517
|
+
}
|
|
518
|
+
|
|
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
|
+
export function computeCpuPct(t0, t1) {
|
|
524
|
+
let idleDelta = 0;
|
|
525
|
+
let totalDelta = 0;
|
|
526
|
+
const n = Math.min(Array.isArray(t0) ? t0.length : 0, Array.isArray(t1) ? t1.length : 0);
|
|
527
|
+
for (let i = 0; i < n; i += 1) {
|
|
528
|
+
const a = (t0[i] && t0[i].times) || {};
|
|
529
|
+
const b = (t1[i] && t1[i].times) || {};
|
|
530
|
+
const totA = (a.user || 0) + (a.nice || 0) + (a.sys || 0) + (a.idle || 0) + (a.irq || 0);
|
|
531
|
+
const totB = (b.user || 0) + (b.nice || 0) + (b.sys || 0) + (b.idle || 0) + (b.irq || 0);
|
|
532
|
+
idleDelta += (b.idle || 0) - (a.idle || 0);
|
|
533
|
+
totalDelta += totB - totA;
|
|
534
|
+
}
|
|
535
|
+
if (totalDelta <= 0) return 0;
|
|
536
|
+
return Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function executeSystemStatsRecipe(params, opts) {
|
|
540
|
+
const o = opts || {};
|
|
541
|
+
const totalmemFn = typeof o.totalmem === "function" ? o.totalmem : totalmem;
|
|
542
|
+
const freememFn = typeof o.freemem === "function" ? o.freemem : freemem;
|
|
543
|
+
const loadavgFn = typeof o.loadavg === "function" ? o.loadavg : loadavg;
|
|
544
|
+
const cpusFn = typeof o.cpus === "function" ? o.cpus : cpus;
|
|
545
|
+
const sleep =
|
|
546
|
+
typeof o.sleep === "function" ? o.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
547
|
+
const requested =
|
|
548
|
+
params && Number.isFinite(params.sampleWindowMs)
|
|
549
|
+
? params.sampleWindowMs
|
|
550
|
+
: SYSTEM_STATS_WINDOW_DEFAULT_MS;
|
|
551
|
+
const windowMs = Math.max(
|
|
552
|
+
SYSTEM_STATS_WINDOW_MIN_MS,
|
|
553
|
+
Math.min(SYSTEM_STATS_WINDOW_MAX_MS, Math.floor(requested)),
|
|
554
|
+
);
|
|
555
|
+
try {
|
|
556
|
+
const t0 = cpusFn();
|
|
557
|
+
await sleep(windowMs);
|
|
558
|
+
const t1 = cpusFn();
|
|
559
|
+
const cpuPct = computeCpuPct(t0, t1);
|
|
560
|
+
const total = totalmemFn();
|
|
561
|
+
const free = freememFn();
|
|
562
|
+
const used = total - free;
|
|
563
|
+
const load = loadavgFn();
|
|
564
|
+
const toMb = (b) => Math.round(b / (1024 * 1024));
|
|
565
|
+
const round1 = (x) => Math.round(x * 10) / 10;
|
|
566
|
+
return {
|
|
567
|
+
output: {
|
|
568
|
+
memTotalMb: toMb(total),
|
|
569
|
+
memUsedMb: toMb(used),
|
|
570
|
+
memFreeMb: toMb(free),
|
|
571
|
+
memUsedPct: total > 0 ? round1((used / total) * 100) : 0,
|
|
572
|
+
cpuPct: round1(cpuPct),
|
|
573
|
+
loadAvg1: Array.isArray(load) && load.length > 0 ? Math.round(load[0] * 100) / 100 : 0,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
} catch (err) {
|
|
577
|
+
return { error: `system-stats read failed: ${err && err.message ? err.message : err}` };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export default { executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };
|