@useorgx/openclaw-plugin 0.3.0 → 0.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.
Files changed (62) hide show
  1. package/README.md +48 -1
  2. package/dashboard/dist/assets/index-BjqNjHpY.css +1 -0
  3. package/dashboard/dist/assets/index-DCLkU4AM.js +57 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/adapters/outbox.d.ts +8 -0
  6. package/dist/adapters/outbox.d.ts.map +1 -0
  7. package/dist/adapters/outbox.js +6 -0
  8. package/dist/adapters/outbox.js.map +1 -0
  9. package/dist/agent-context-store.d.ts +24 -0
  10. package/dist/agent-context-store.d.ts.map +1 -0
  11. package/dist/agent-context-store.js +110 -0
  12. package/dist/agent-context-store.js.map +1 -0
  13. package/dist/agent-run-store.d.ts +31 -0
  14. package/dist/agent-run-store.d.ts.map +1 -0
  15. package/dist/agent-run-store.js +158 -0
  16. package/dist/agent-run-store.js.map +1 -0
  17. package/dist/api.d.ts +4 -139
  18. package/dist/api.d.ts.map +1 -1
  19. package/dist/api.js +4 -347
  20. package/dist/api.js.map +1 -1
  21. package/dist/auth-store.d.ts.map +1 -1
  22. package/dist/auth-store.js +27 -1
  23. package/dist/auth-store.js.map +1 -1
  24. package/dist/byok-store.d.ts +11 -0
  25. package/dist/byok-store.d.ts.map +1 -0
  26. package/dist/byok-store.js +94 -0
  27. package/dist/byok-store.js.map +1 -0
  28. package/dist/contracts/client.d.ts +154 -0
  29. package/dist/contracts/client.d.ts.map +1 -0
  30. package/dist/contracts/client.js +422 -0
  31. package/dist/contracts/client.js.map +1 -0
  32. package/dist/contracts/types.d.ts +430 -0
  33. package/dist/contracts/types.d.ts.map +1 -0
  34. package/dist/contracts/types.js +8 -0
  35. package/dist/contracts/types.js.map +1 -0
  36. package/dist/http-handler.d.ts +10 -1
  37. package/dist/http-handler.d.ts.map +1 -1
  38. package/dist/http-handler.js +2256 -98
  39. package/dist/http-handler.js.map +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +348 -24
  42. package/dist/index.js.map +1 -1
  43. package/dist/local-openclaw.d.ts.map +1 -1
  44. package/dist/local-openclaw.js +57 -15
  45. package/dist/local-openclaw.js.map +1 -1
  46. package/dist/openclaw.plugin.json +3 -3
  47. package/dist/outbox.d.ts +7 -0
  48. package/dist/outbox.d.ts.map +1 -1
  49. package/dist/outbox.js +94 -6
  50. package/dist/outbox.js.map +1 -1
  51. package/dist/snapshot-store.d.ts +10 -0
  52. package/dist/snapshot-store.d.ts.map +1 -0
  53. package/dist/snapshot-store.js +64 -0
  54. package/dist/snapshot-store.js.map +1 -0
  55. package/dist/types.d.ts +5 -410
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js +5 -4
  58. package/dist/types.js.map +1 -1
  59. package/openclaw.plugin.json +3 -3
  60. package/package.json +13 -3
  61. package/dashboard/dist/assets/index-BrAP-X_H.css +0 -1
  62. package/dashboard/dist/assets/index-cOk6qwh-.js +0 -56
@@ -8,6 +8,7 @@
8
8
  * /orgx/api/agents → agent states
9
9
  * /orgx/api/activity → activity feed
10
10
  * /orgx/api/initiatives → initiative data
11
+ * /orgx/api/health → plugin diagnostics + outbox/sync status
11
12
  * /orgx/api/onboarding → onboarding / config state
12
13
  * /orgx/api/delegation/preflight → delegation preflight
13
14
  * /orgx/api/runs/:id/checkpoints → list/create checkpoints
@@ -15,13 +16,17 @@
15
16
  * /orgx/api/runs/:id/actions/:action → run control action
16
17
  */
17
18
  import { readFileSync, existsSync } from "node:fs";
18
- import { join, extname } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
19
  import { homedir } from "node:os";
21
- import { createHash } from "node:crypto";
20
+ import { join, extname, normalize, resolve, relative, sep } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { spawn } from "node:child_process";
23
+ import { createHash, randomUUID } from "node:crypto";
22
24
  import { formatStatus, formatAgents, formatActivity, formatInitiatives, getOnboardingState, } from "./dashboard-api.js";
23
25
  import { loadLocalOpenClawSnapshot, loadLocalTurnDetail, toLocalLiveActivity, toLocalLiveAgents, toLocalLiveInitiatives, toLocalSessionTree, } from "./local-openclaw.js";
24
- import { readAllOutboxItems } from "./outbox.js";
26
+ import { defaultOutboxAdapter } from "./adapters/outbox.js";
27
+ import { readAgentContexts, upsertAgentContext } from "./agent-context-store.js";
28
+ import { getAgentRun, markAgentRunStopped, readAgentRuns, upsertAgentRun, } from "./agent-run-store.js";
29
+ import { readByokKeys, writeByokKeys } from "./byok-store.js";
25
30
  // =============================================================================
26
31
  // Helpers
27
32
  // =============================================================================
@@ -32,6 +37,445 @@ function safeErrorMessage(err) {
32
37
  return err;
33
38
  return "Unexpected error";
34
39
  }
40
+ function isUserScopedApiKey(apiKey) {
41
+ return apiKey.trim().toLowerCase().startsWith("oxk_");
42
+ }
43
+ function parseJsonSafe(value) {
44
+ try {
45
+ return JSON.parse(value);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function maskSecret(value) {
52
+ if (!value)
53
+ return null;
54
+ const trimmed = value.trim();
55
+ if (!trimmed)
56
+ return null;
57
+ if (trimmed.length <= 8)
58
+ return `${trimmed[0]}…${trimmed.slice(-1)}`;
59
+ return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
60
+ }
61
+ function modelImpliesByok(model) {
62
+ const lower = (model ?? "").trim().toLowerCase();
63
+ if (!lower)
64
+ return false;
65
+ return (lower.includes("openrouter") ||
66
+ lower.includes("anthropic") ||
67
+ lower.includes("openai"));
68
+ }
69
+ async function fetchBillingStatusSafe(client) {
70
+ try {
71
+ return await client.getBillingStatus();
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function resolveByokEnvOverrides() {
78
+ const stored = readByokKeys();
79
+ const env = {};
80
+ const openai = stored?.openaiApiKey?.trim() ?? "";
81
+ const anthropic = stored?.anthropicApiKey?.trim() ?? "";
82
+ const openrouter = stored?.openrouterApiKey?.trim() ?? "";
83
+ if (openai)
84
+ env.OPENAI_API_KEY = openai;
85
+ if (anthropic)
86
+ env.ANTHROPIC_API_KEY = anthropic;
87
+ if (openrouter)
88
+ env.OPENROUTER_API_KEY = openrouter;
89
+ return env;
90
+ }
91
+ async function runCommandCollect(input) {
92
+ const timeoutMs = input.timeoutMs ?? 10_000;
93
+ return await new Promise((resolve, reject) => {
94
+ const child = spawn(input.command, input.args, {
95
+ env: input.env ? { ...process.env, ...input.env } : process.env,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+ let stdout = "";
99
+ let stderr = "";
100
+ const timer = timeoutMs
101
+ ? setTimeout(() => {
102
+ try {
103
+ child.kill("SIGKILL");
104
+ }
105
+ catch {
106
+ // best effort
107
+ }
108
+ reject(new Error(`Command timed out after ${timeoutMs}ms`));
109
+ }, timeoutMs)
110
+ : null;
111
+ child.stdout?.on("data", (chunk) => {
112
+ stdout += chunk.toString("utf8");
113
+ });
114
+ child.stderr?.on("data", (chunk) => {
115
+ stderr += chunk.toString("utf8");
116
+ });
117
+ child.on("error", (err) => {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ reject(err);
121
+ });
122
+ child.on("close", (code) => {
123
+ if (timer)
124
+ clearTimeout(timer);
125
+ resolve({ stdout, stderr, exitCode: typeof code === "number" ? code : null });
126
+ });
127
+ });
128
+ }
129
+ async function listOpenClawAgents() {
130
+ const result = await runCommandCollect({
131
+ command: "openclaw",
132
+ args: ["agents", "list", "--json"],
133
+ timeoutMs: 5_000,
134
+ env: resolveByokEnvOverrides(),
135
+ });
136
+ if (result.exitCode !== 0) {
137
+ throw new Error(result.stderr.trim() || "openclaw agents list failed");
138
+ }
139
+ const parsed = parseJsonSafe(result.stdout);
140
+ if (!Array.isArray(parsed)) {
141
+ throw new Error("openclaw agents list returned invalid JSON");
142
+ }
143
+ return parsed.filter((entry) => Boolean(entry && typeof entry === "object"));
144
+ }
145
+ function spawnOpenClawAgentTurn(input) {
146
+ const args = [
147
+ "agent",
148
+ "--agent",
149
+ input.agentId,
150
+ "--session-id",
151
+ input.sessionId,
152
+ "--message",
153
+ input.message,
154
+ ];
155
+ if (input.thinking) {
156
+ args.push("--thinking", input.thinking);
157
+ }
158
+ const child = spawn("openclaw", args, {
159
+ env: { ...process.env, ...resolveByokEnvOverrides() },
160
+ stdio: "ignore",
161
+ detached: true,
162
+ });
163
+ child.unref();
164
+ return { pid: child.pid ?? null };
165
+ }
166
+ function normalizeOpenClawProvider(value) {
167
+ const raw = (value ?? "").trim().toLowerCase();
168
+ if (!raw)
169
+ return null;
170
+ if (raw === "auto")
171
+ return null;
172
+ if (raw === "claude")
173
+ return "anthropic";
174
+ if (raw === "anthropic")
175
+ return "anthropic";
176
+ if (raw === "openrouter" || raw === "open-router")
177
+ return "openrouter";
178
+ if (raw === "openai")
179
+ return "openai";
180
+ return null;
181
+ }
182
+ async function setOpenClawAgentModel(input) {
183
+ const agentId = input.agentId.trim();
184
+ const model = input.model.trim();
185
+ if (!agentId || !model) {
186
+ throw new Error("agentId and model are required");
187
+ }
188
+ const result = await runCommandCollect({
189
+ command: "openclaw",
190
+ args: ["models", "--agent", agentId, "set", model],
191
+ timeoutMs: 10_000,
192
+ env: resolveByokEnvOverrides(),
193
+ });
194
+ if (result.exitCode !== 0) {
195
+ throw new Error(result.stderr.trim() || `openclaw models set failed for ${agentId}`);
196
+ }
197
+ }
198
+ async function listOpenClawProviderModels(input) {
199
+ const result = await runCommandCollect({
200
+ command: "openclaw",
201
+ args: [
202
+ "models",
203
+ "--agent",
204
+ input.agentId,
205
+ "list",
206
+ "--provider",
207
+ input.provider,
208
+ "--json",
209
+ ],
210
+ timeoutMs: 10_000,
211
+ env: resolveByokEnvOverrides(),
212
+ });
213
+ if (result.exitCode !== 0) {
214
+ throw new Error(result.stderr.trim() || "openclaw models list failed");
215
+ }
216
+ const parsed = parseJsonSafe(result.stdout);
217
+ if (!parsed || typeof parsed !== "object") {
218
+ const trimmed = result.stdout.trim();
219
+ if (!trimmed || /no models found/i.test(trimmed)) {
220
+ return [];
221
+ }
222
+ throw new Error("openclaw models list returned invalid JSON");
223
+ }
224
+ const modelsRaw = "models" in parsed && Array.isArray(parsed.models)
225
+ ? parsed.models
226
+ : [];
227
+ return modelsRaw
228
+ .map((entry) => {
229
+ if (!entry || typeof entry !== "object")
230
+ return null;
231
+ const row = entry;
232
+ const key = typeof row.key === "string" ? row.key.trim() : "";
233
+ const tags = Array.isArray(row.tags)
234
+ ? row.tags.filter((t) => typeof t === "string")
235
+ : [];
236
+ if (!key)
237
+ return null;
238
+ return { key, tags };
239
+ })
240
+ .filter((entry) => Boolean(entry));
241
+ }
242
+ function pickPreferredModel(models) {
243
+ if (models.length === 0)
244
+ return null;
245
+ const preferred = models.find((m) => m.tags.some((t) => t === "default"));
246
+ return preferred?.key ?? models[0]?.key ?? null;
247
+ }
248
+ async function configureOpenClawProviderRouting(input) {
249
+ const requestedModel = (input.requestedModel ?? "").trim() || null;
250
+ // Fast path: use known aliases where possible.
251
+ const aliasByProvider = {
252
+ anthropic: "opus",
253
+ openrouter: "sonnet",
254
+ openai: null,
255
+ };
256
+ const candidate = requestedModel ?? aliasByProvider[input.provider];
257
+ if (candidate) {
258
+ try {
259
+ await setOpenClawAgentModel({ agentId: input.agentId, model: candidate });
260
+ return { provider: input.provider, model: candidate };
261
+ }
262
+ catch {
263
+ // Fall through to discovery-based selection.
264
+ }
265
+ }
266
+ const models = await listOpenClawProviderModels({
267
+ agentId: input.agentId,
268
+ provider: input.provider,
269
+ });
270
+ const selected = pickPreferredModel(models);
271
+ if (!selected) {
272
+ throw new Error(`No ${input.provider} models configured for agent ${input.agentId}. Add a model in OpenClaw and retry.`);
273
+ }
274
+ await setOpenClawAgentModel({ agentId: input.agentId, model: selected });
275
+ return { provider: input.provider, model: selected };
276
+ }
277
+ function isPidAlive(pid) {
278
+ if (!Number.isFinite(pid) || pid <= 0)
279
+ return false;
280
+ try {
281
+ process.kill(pid, 0);
282
+ return true;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ async function stopDetachedProcess(pid) {
289
+ const alive = isPidAlive(pid);
290
+ if (!alive) {
291
+ return { stopped: true, wasRunning: false };
292
+ }
293
+ const tryKill = (signal) => {
294
+ try {
295
+ // Detached child becomes its own process group (pgid = pid) on Unix.
296
+ process.kill(-pid, signal);
297
+ return;
298
+ }
299
+ catch {
300
+ // Fall back to direct pid kill.
301
+ }
302
+ try {
303
+ process.kill(pid, signal);
304
+ }
305
+ catch {
306
+ // ignore
307
+ }
308
+ };
309
+ tryKill("SIGTERM");
310
+ await new Promise((resolve) => setTimeout(resolve, 450));
311
+ if (isPidAlive(pid)) {
312
+ tryKill("SIGKILL");
313
+ }
314
+ return { stopped: !isPidAlive(pid), wasRunning: true };
315
+ }
316
+ function getScopedAgentIds(contexts) {
317
+ const scoped = new Set();
318
+ for (const [key, ctx] of Object.entries(contexts)) {
319
+ if (!ctx || typeof ctx !== "object")
320
+ continue;
321
+ const agentId = (ctx.agentId ?? key).trim();
322
+ if (!agentId)
323
+ continue;
324
+ const initiativeId = ctx.initiativeId?.trim() ?? "";
325
+ if (initiativeId) {
326
+ scoped.add(agentId);
327
+ }
328
+ }
329
+ return scoped;
330
+ }
331
+ function applyAgentContextsToSessionTree(input, contexts) {
332
+ if (!input || !Array.isArray(input.nodes))
333
+ return input;
334
+ const groupsById = new Map();
335
+ for (const group of input.groups ?? []) {
336
+ if (!group)
337
+ continue;
338
+ groupsById.set(group.id, {
339
+ id: group.id,
340
+ label: group.label,
341
+ status: group.status ?? null,
342
+ });
343
+ }
344
+ const nodes = input.nodes.map((node) => {
345
+ const agentId = node.agentId?.trim() ?? "";
346
+ if (!agentId)
347
+ return node;
348
+ const ctx = contexts[agentId];
349
+ const initiativeId = ctx?.initiativeId?.trim() ?? "";
350
+ if (!initiativeId)
351
+ return node;
352
+ const groupId = initiativeId;
353
+ const ctxTitle = ctx.initiativeTitle?.trim() ?? "";
354
+ const groupLabel = ctxTitle || node.groupLabel || initiativeId;
355
+ const existing = groupsById.get(groupId);
356
+ if (!existing) {
357
+ groupsById.set(groupId, {
358
+ id: groupId,
359
+ label: groupLabel,
360
+ status: node.status ?? null,
361
+ });
362
+ }
363
+ else if (ctxTitle && (existing.label === groupId || existing.label.startsWith("Agent "))) {
364
+ groupsById.set(groupId, { ...existing, label: groupLabel });
365
+ }
366
+ return {
367
+ ...node,
368
+ initiativeId,
369
+ workstreamId: ctx.workstreamId ?? node.workstreamId ?? null,
370
+ groupId,
371
+ groupLabel,
372
+ };
373
+ });
374
+ // Ensure every node's group exists.
375
+ for (const node of nodes) {
376
+ if (!groupsById.has(node.groupId)) {
377
+ groupsById.set(node.groupId, {
378
+ id: node.groupId,
379
+ label: node.groupLabel || node.groupId,
380
+ status: node.status ?? null,
381
+ });
382
+ }
383
+ }
384
+ return {
385
+ ...input,
386
+ nodes,
387
+ groups: Array.from(groupsById.values()),
388
+ };
389
+ }
390
+ function applyAgentContextsToActivity(input, contexts) {
391
+ if (!Array.isArray(input))
392
+ return [];
393
+ return input.map((item) => {
394
+ const agentId = item.agentId?.trim() ?? "";
395
+ if (!agentId)
396
+ return item;
397
+ const ctx = contexts[agentId];
398
+ const initiativeId = ctx?.initiativeId?.trim() ?? "";
399
+ if (!initiativeId)
400
+ return item;
401
+ const metadata = item.metadata && typeof item.metadata === "object"
402
+ ? { ...item.metadata }
403
+ : {};
404
+ metadata.orgx_context = {
405
+ initiativeId,
406
+ workstreamId: ctx.workstreamId ?? null,
407
+ taskId: ctx.taskId ?? null,
408
+ updatedAt: ctx.updatedAt,
409
+ };
410
+ return {
411
+ ...item,
412
+ initiativeId,
413
+ metadata,
414
+ };
415
+ });
416
+ }
417
+ function mergeSessionTrees(base, extra) {
418
+ const seenNodes = new Set();
419
+ const nodes = [];
420
+ for (const node of base.nodes ?? []) {
421
+ seenNodes.add(node.id);
422
+ nodes.push(node);
423
+ }
424
+ for (const node of extra.nodes ?? []) {
425
+ if (seenNodes.has(node.id))
426
+ continue;
427
+ seenNodes.add(node.id);
428
+ nodes.push(node);
429
+ }
430
+ const seenEdges = new Set();
431
+ const edges = [];
432
+ for (const edge of base.edges ?? []) {
433
+ const key = `${edge.parentId}→${edge.childId}`;
434
+ seenEdges.add(key);
435
+ edges.push(edge);
436
+ }
437
+ for (const edge of extra.edges ?? []) {
438
+ const key = `${edge.parentId}→${edge.childId}`;
439
+ if (seenEdges.has(key))
440
+ continue;
441
+ seenEdges.add(key);
442
+ edges.push(edge);
443
+ }
444
+ const groupsById = new Map();
445
+ for (const group of base.groups ?? []) {
446
+ groupsById.set(group.id, group);
447
+ }
448
+ for (const group of extra.groups ?? []) {
449
+ const existing = groupsById.get(group.id);
450
+ if (!existing) {
451
+ groupsById.set(group.id, group);
452
+ continue;
453
+ }
454
+ const nextLabel = existing.label === existing.id && group.label && group.label !== group.id
455
+ ? group.label
456
+ : existing.label;
457
+ groupsById.set(group.id, { ...existing, label: nextLabel });
458
+ }
459
+ return {
460
+ nodes,
461
+ edges,
462
+ groups: Array.from(groupsById.values()),
463
+ };
464
+ }
465
+ function mergeActivities(base, extra, limit) {
466
+ const merged = [...(base ?? []), ...(extra ?? [])].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
467
+ const deduped = [];
468
+ const seen = new Set();
469
+ for (const item of merged) {
470
+ if (seen.has(item.id))
471
+ continue;
472
+ seen.add(item.id);
473
+ deduped.push(item);
474
+ if (deduped.length >= limit)
475
+ break;
476
+ }
477
+ return deduped;
478
+ }
35
479
  const ACTIVITY_HEADLINE_TIMEOUT_MS = 4_000;
36
480
  const ACTIVITY_HEADLINE_CACHE_TTL_MS = 12 * 60 * 60_000;
37
481
  const ACTIVITY_HEADLINE_CACHE_MAX = 1_000;
@@ -78,53 +522,6 @@ function heuristicActivityHeadline(text, title) {
78
522
  return cleanedTitle;
79
523
  return "Activity update";
80
524
  }
81
- function readDotEnvValue(pattern) {
82
- try {
83
- const envPath = join(homedir(), "Code", "orgx", "orgx", ".env.local");
84
- const envContent = readFileSync(envPath, "utf-8");
85
- const match = envContent.match(pattern);
86
- return match?.[1]?.trim() ?? "";
87
- }
88
- catch {
89
- return "";
90
- }
91
- }
92
- function findOpenRouterApiKeyInConfig(input, trail = []) {
93
- if (!input || typeof input !== "object")
94
- return null;
95
- if (Array.isArray(input)) {
96
- for (const value of input) {
97
- const nested = findOpenRouterApiKeyInConfig(value, trail);
98
- if (nested)
99
- return nested;
100
- }
101
- return null;
102
- }
103
- const record = input;
104
- for (const [key, value] of Object.entries(record)) {
105
- const nextTrail = [...trail, key.toLowerCase()];
106
- if (typeof value === "string" &&
107
- key.toLowerCase() === "apikey" &&
108
- nextTrail.join(".").includes("openrouter") &&
109
- value.trim().length > 0) {
110
- return value.trim();
111
- }
112
- const nested = findOpenRouterApiKeyInConfig(value, nextTrail);
113
- if (nested)
114
- return nested;
115
- }
116
- return null;
117
- }
118
- function readOpenRouterApiKeyFromConfig() {
119
- try {
120
- const raw = readFileSync(join(homedir(), ".openclaw", "openclaw.json"), "utf8");
121
- const parsed = JSON.parse(raw);
122
- return findOpenRouterApiKeyInConfig(parsed) ?? "";
123
- }
124
- catch {
125
- return "";
126
- }
127
- }
128
525
  function resolveActivitySummaryApiKey() {
129
526
  if (resolvedActivitySummaryApiKey !== undefined) {
130
527
  return resolvedActivitySummaryApiKey;
@@ -132,9 +529,6 @@ function resolveActivitySummaryApiKey() {
132
529
  const candidates = [
133
530
  process.env.ORGX_ACTIVITY_SUMMARY_API_KEY ?? "",
134
531
  process.env.OPENROUTER_API_KEY ?? "",
135
- readDotEnvValue(/^ORGX_ACTIVITY_SUMMARY_API_KEY=["']?([^"'\n]+)["']?$/m),
136
- readDotEnvValue(/^OPENROUTER_API_KEY=["']?([^"'\n]+)["']?$/m),
137
- readOpenRouterApiKeyFromConfig(),
138
532
  ];
139
533
  const key = candidates.find((candidate) => candidate.trim().length > 0)?.trim() ?? "";
140
534
  resolvedActivitySummaryApiKey = key || null;
@@ -296,13 +690,63 @@ function contentType(filePath) {
296
690
  return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
297
691
  }
298
692
  // =============================================================================
299
- // CORS headers (for local dev)
693
+ // CORS + response hardening
300
694
  // =============================================================================
301
695
  const CORS_HEADERS = {
302
- "Access-Control-Allow-Origin": "*",
303
696
  "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
304
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
697
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OrgX-Api-Key, X-API-Key, X-OrgX-User-Id",
698
+ Vary: "Origin",
699
+ };
700
+ const SECURITY_HEADERS = {
701
+ "X-Content-Type-Options": "nosniff",
702
+ "X-Frame-Options": "DENY",
703
+ "Referrer-Policy": "same-origin",
704
+ "Cross-Origin-Resource-Policy": "same-origin",
305
705
  };
706
+ function normalizeHost(value) {
707
+ return value.trim().toLowerCase().replace(/^\[|\]$/g, "");
708
+ }
709
+ function isLoopbackHost(hostname) {
710
+ const host = normalizeHost(hostname);
711
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
712
+ }
713
+ function isTrustedOrigin(origin) {
714
+ try {
715
+ const parsed = new URL(origin);
716
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
717
+ return false;
718
+ }
719
+ return isLoopbackHost(parsed.hostname);
720
+ }
721
+ catch {
722
+ return false;
723
+ }
724
+ }
725
+ function isTrustedRequestSource(headers) {
726
+ const fetchSite = pickHeaderString(headers, ["sec-fetch-site"]);
727
+ if (fetchSite) {
728
+ const normalizedFetchSite = fetchSite.trim().toLowerCase();
729
+ if (normalizedFetchSite !== "same-origin" &&
730
+ normalizedFetchSite !== "same-site" &&
731
+ normalizedFetchSite !== "none") {
732
+ return false;
733
+ }
734
+ }
735
+ const origin = pickHeaderString(headers, ["origin"]);
736
+ if (origin) {
737
+ return isTrustedOrigin(origin);
738
+ }
739
+ const referer = pickHeaderString(headers, ["referer"]);
740
+ if (referer) {
741
+ try {
742
+ return isTrustedOrigin(new URL(referer).origin);
743
+ }
744
+ catch {
745
+ return false;
746
+ }
747
+ }
748
+ return true;
749
+ }
306
750
  const STREAM_IDLE_TIMEOUT_MS = 60_000;
307
751
  // =============================================================================
308
752
  // Resolve the dashboard/dist/ directory relative to this file
@@ -310,6 +754,21 @@ const STREAM_IDLE_TIMEOUT_MS = 60_000;
310
754
  const __filename = fileURLToPath(import.meta.url);
311
755
  // src/http-handler.ts → up to plugin root → dashboard/dist
312
756
  const DIST_DIR = join(__filename, "..", "..", "dashboard", "dist");
757
+ const RESOLVED_DIST_DIR = resolve(DIST_DIR);
758
+ const RESOLVED_DIST_ASSETS_DIR = resolve(DIST_DIR, "assets");
759
+ function resolveSafeDistPath(subPath) {
760
+ if (!subPath || subPath.includes("\0"))
761
+ return null;
762
+ const normalized = normalize(subPath).replace(/^([/\\])+/, "");
763
+ if (!normalized || normalized === ".")
764
+ return null;
765
+ const candidate = resolve(DIST_DIR, normalized);
766
+ const rel = relative(RESOLVED_DIST_DIR, candidate);
767
+ if (!rel || rel === "." || rel.startsWith("..") || rel.includes(`..${sep}`)) {
768
+ return null;
769
+ }
770
+ return candidate;
771
+ }
313
772
  // =============================================================================
314
773
  // Helpers
315
774
  // =============================================================================
@@ -317,6 +776,7 @@ function sendJson(res, status, data) {
317
776
  const body = JSON.stringify(data);
318
777
  res.writeHead(status, {
319
778
  "Content-Type": "application/json; charset=utf-8",
779
+ ...SECURITY_HEADERS,
320
780
  ...CORS_HEADERS,
321
781
  });
322
782
  res.end(body);
@@ -327,6 +787,7 @@ function sendFile(res, filePath, cacheControl) {
327
787
  res.writeHead(200, {
328
788
  "Content-Type": contentType(filePath),
329
789
  "Cache-Control": cacheControl,
790
+ ...SECURITY_HEADERS,
330
791
  ...CORS_HEADERS,
331
792
  });
332
793
  res.end(content);
@@ -338,6 +799,7 @@ function sendFile(res, filePath, cacheControl) {
338
799
  function send404(res) {
339
800
  res.writeHead(404, {
340
801
  "Content-Type": "text/plain; charset=utf-8",
802
+ ...SECURITY_HEADERS,
341
803
  ...CORS_HEADERS,
342
804
  });
343
805
  res.end("Not Found");
@@ -350,6 +812,7 @@ function sendIndexHtml(res) {
350
812
  else {
351
813
  res.writeHead(503, {
352
814
  "Content-Type": "text/html; charset=utf-8",
815
+ ...SECURITY_HEADERS,
353
816
  ...CORS_HEADERS,
354
817
  });
355
818
  res.end("<html><body><h1>Dashboard not built</h1>" +
@@ -382,11 +845,120 @@ function parseJsonBody(body) {
382
845
  return {};
383
846
  }
384
847
  }
848
+ if (body instanceof Uint8Array) {
849
+ try {
850
+ const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
851
+ return typeof parsed === "object" && parsed !== null
852
+ ? parsed
853
+ : {};
854
+ }
855
+ catch {
856
+ return {};
857
+ }
858
+ }
859
+ if (body instanceof ArrayBuffer) {
860
+ try {
861
+ const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
862
+ return typeof parsed === "object" && parsed !== null
863
+ ? parsed
864
+ : {};
865
+ }
866
+ catch {
867
+ return {};
868
+ }
869
+ }
385
870
  if (typeof body === "object") {
386
871
  return body;
387
872
  }
388
873
  return {};
389
874
  }
875
+ const MAX_JSON_BODY_BYTES = 1_000_000;
876
+ const JSON_BODY_TIMEOUT_MS = 2_000;
877
+ function chunkToBuffer(chunk) {
878
+ if (!chunk)
879
+ return Buffer.alloc(0);
880
+ if (Buffer.isBuffer(chunk))
881
+ return chunk;
882
+ if (typeof chunk === "string")
883
+ return Buffer.from(chunk, "utf8");
884
+ if (chunk instanceof Uint8Array)
885
+ return Buffer.from(chunk);
886
+ try {
887
+ return Buffer.from(JSON.stringify(chunk), "utf8");
888
+ }
889
+ catch {
890
+ return Buffer.from(String(chunk), "utf8");
891
+ }
892
+ }
893
+ async function readRequestBodyBuffer(req) {
894
+ const on = req.on ? req.on.bind(req) : null;
895
+ if (!on)
896
+ return null;
897
+ return await new Promise((resolve) => {
898
+ const chunks = [];
899
+ let totalBytes = 0;
900
+ let finished = false;
901
+ const finish = (buffer) => {
902
+ if (finished)
903
+ return;
904
+ finished = true;
905
+ clearTimeout(timer);
906
+ resolve(buffer);
907
+ };
908
+ const timer = setTimeout(() => finish(null), JSON_BODY_TIMEOUT_MS);
909
+ on("data", (chunk) => {
910
+ const buf = chunkToBuffer(chunk);
911
+ if (buf.length === 0)
912
+ return;
913
+ totalBytes += buf.length;
914
+ if (totalBytes > MAX_JSON_BODY_BYTES) {
915
+ finish(null);
916
+ return;
917
+ }
918
+ chunks.push(buf);
919
+ });
920
+ const onDone = () => {
921
+ if (chunks.length === 0) {
922
+ finish(Buffer.alloc(0));
923
+ }
924
+ else {
925
+ finish(Buffer.concat(chunks, totalBytes));
926
+ }
927
+ };
928
+ const once = (req.once ?? req.on)?.bind(req) ?? null;
929
+ if (once) {
930
+ once("end", onDone);
931
+ once("error", () => finish(null));
932
+ }
933
+ else {
934
+ on("end", onDone);
935
+ on("error", () => finish(null));
936
+ }
937
+ });
938
+ }
939
+ async function parseJsonRequest(req) {
940
+ const body = req.body;
941
+ if (typeof body === "string" && body.length > 0) {
942
+ return parseJsonBody(body);
943
+ }
944
+ if (Buffer.isBuffer(body) && body.length > 0) {
945
+ return parseJsonBody(body);
946
+ }
947
+ if (body instanceof Uint8Array && body.byteLength > 0) {
948
+ return parseJsonBody(body);
949
+ }
950
+ if (body instanceof ArrayBuffer && body.byteLength > 0) {
951
+ return parseJsonBody(body);
952
+ }
953
+ if (body && typeof body === "object" && !Buffer.isBuffer(body)) {
954
+ return parseJsonBody(body);
955
+ }
956
+ const streamed = await readRequestBodyBuffer(req);
957
+ if (!streamed || streamed.length === 0) {
958
+ return {};
959
+ }
960
+ return parseJsonBody(streamed);
961
+ }
390
962
  function pickString(record, keys) {
391
963
  for (const key of keys) {
392
964
  const value = record[key];
@@ -487,17 +1059,101 @@ function parsePositiveInt(raw, fallback) {
487
1059
  return fallback;
488
1060
  return Math.max(1, Math.floor(parsed));
489
1061
  }
1062
+ function parseBooleanQuery(raw) {
1063
+ if (!raw)
1064
+ return false;
1065
+ const normalized = raw.trim().toLowerCase();
1066
+ return (normalized === "1" ||
1067
+ normalized === "true" ||
1068
+ normalized === "yes" ||
1069
+ normalized === "on");
1070
+ }
490
1071
  const DEFAULT_DURATION_HOURS = {
491
1072
  initiative: 40,
492
1073
  workstream: 16,
493
1074
  milestone: 6,
494
1075
  task: 2,
495
1076
  };
1077
+ function readBudgetEnvNumber(name, fallback, bounds = {}) {
1078
+ const raw = process.env[name];
1079
+ if (typeof raw !== "string" || raw.trim().length === 0)
1080
+ return fallback;
1081
+ const parsed = Number(raw);
1082
+ if (!Number.isFinite(parsed))
1083
+ return fallback;
1084
+ if (typeof bounds.min === "number" && parsed < bounds.min)
1085
+ return fallback;
1086
+ if (typeof bounds.max === "number" && parsed > bounds.max)
1087
+ return fallback;
1088
+ return parsed;
1089
+ }
1090
+ const DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M = {
1091
+ // GPT-5.3 Codex API pricing is not published yet; use GPT-5.2 Codex pricing as proxy.
1092
+ gpt53CodexProxy: {
1093
+ input: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_INPUT_PER_1M", 1.75, { min: 0 }),
1094
+ cachedInput: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_CACHED_INPUT_PER_1M", 0.175, {
1095
+ min: 0,
1096
+ }),
1097
+ output: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_OUTPUT_PER_1M", 14, { min: 0 }),
1098
+ },
1099
+ opus46: {
1100
+ input: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_INPUT_PER_1M", 5, { min: 0 }),
1101
+ // Anthropic does not publish a fixed cached-input rate on the model page.
1102
+ cachedInput: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_CACHED_INPUT_PER_1M", 5, { min: 0 }),
1103
+ output: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_OUTPUT_PER_1M", 25, { min: 0 }),
1104
+ },
1105
+ };
1106
+ const DEFAULT_TOKEN_BUDGET_ASSUMPTIONS = {
1107
+ tokensPerHour: readBudgetEnvNumber("ORGX_BUDGET_TOKENS_PER_HOUR", 1_200_000, { min: 1 }),
1108
+ inputShare: readBudgetEnvNumber("ORGX_BUDGET_INPUT_TOKEN_SHARE", 0.86, { min: 0, max: 1 }),
1109
+ cachedInputShare: readBudgetEnvNumber("ORGX_BUDGET_CACHED_INPUT_SHARE", 0.15, {
1110
+ min: 0,
1111
+ max: 1,
1112
+ }),
1113
+ contingencyMultiplier: readBudgetEnvNumber("ORGX_BUDGET_CONTINGENCY_MULTIPLIER", 1.3, {
1114
+ min: 0.1,
1115
+ }),
1116
+ roundingStepUsd: readBudgetEnvNumber("ORGX_BUDGET_ROUNDING_STEP_USD", 5, { min: 0.01 }),
1117
+ };
1118
+ const DEFAULT_TOKEN_MODEL_MIX = {
1119
+ gpt53CodexProxy: 0.7,
1120
+ opus46: 0.3,
1121
+ };
1122
+ function modelCostPerMillionTokensUsd(pricing) {
1123
+ const inputShare = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.inputShare;
1124
+ const outputShare = Math.max(0, 1 - inputShare);
1125
+ const cachedShare = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.cachedInputShare;
1126
+ const uncachedShare = Math.max(0, 1 - cachedShare);
1127
+ const effectiveInputRate = pricing.input * uncachedShare + pricing.cachedInput * cachedShare;
1128
+ return inputShare * effectiveInputRate + outputShare * pricing.output;
1129
+ }
1130
+ function estimateBudgetUsdFromDurationHours(durationHours) {
1131
+ if (!Number.isFinite(durationHours) || durationHours <= 0)
1132
+ return 0;
1133
+ const blendedPerMillionUsd = DEFAULT_TOKEN_MODEL_MIX.gpt53CodexProxy *
1134
+ modelCostPerMillionTokensUsd(DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M.gpt53CodexProxy) +
1135
+ DEFAULT_TOKEN_MODEL_MIX.opus46 *
1136
+ modelCostPerMillionTokensUsd(DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M.opus46);
1137
+ const tokenMillions = (durationHours * DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour) / 1_000_000;
1138
+ const rawBudgetUsd = tokenMillions *
1139
+ blendedPerMillionUsd *
1140
+ DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
1141
+ const roundedBudgetUsd = Math.round(rawBudgetUsd / DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.roundingStepUsd) *
1142
+ DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.roundingStepUsd;
1143
+ return Math.max(0, roundedBudgetUsd);
1144
+ }
1145
+ function isLegacyHourlyBudget(budgetUsd, durationHours) {
1146
+ if (!Number.isFinite(budgetUsd) || !Number.isFinite(durationHours) || durationHours <= 0) {
1147
+ return false;
1148
+ }
1149
+ const legacyHourlyBudget = durationHours * 40;
1150
+ return Math.abs(budgetUsd - legacyHourlyBudget) <= 0.5;
1151
+ }
496
1152
  const DEFAULT_BUDGET_USD = {
497
- initiative: 1500,
498
- workstream: 300,
499
- milestone: 120,
500
- task: 40,
1153
+ initiative: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.initiative),
1154
+ workstream: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.workstream),
1155
+ milestone: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.milestone),
1156
+ task: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.task),
501
1157
  };
502
1158
  const PRIORITY_LABEL_TO_NUM = {
503
1159
  urgent: 10,
@@ -726,7 +1382,7 @@ function toMissionControlNode(type, entity, fallbackInitiativeId) {
726
1382
  ]) ??
727
1383
  extractDurationHoursFromText(pickString(record, ["description", "summary", "context"]), pickString(metadata, ["description", "summary", "context"])) ??
728
1384
  DEFAULT_DURATION_HOURS[type];
729
- const expectedBudget = pickNumber(record, [
1385
+ const explicitBudget = pickNumber(record, [
730
1386
  "expected_budget_usd",
731
1387
  "expectedBudgetUsd",
732
1388
  "budget_usd",
@@ -737,9 +1393,15 @@ function toMissionControlNode(type, entity, fallbackInitiativeId) {
737
1393
  "expectedBudgetUsd",
738
1394
  "budget_usd",
739
1395
  "budgetUsd",
740
- ]) ??
741
- extractBudgetUsdFromText(pickString(record, ["description", "summary", "context"]), pickString(metadata, ["description", "summary", "context"])) ??
742
- DEFAULT_BUDGET_USD[type];
1396
+ ]);
1397
+ const extractedBudget = extractBudgetUsdFromText(pickString(record, ["description", "summary", "context"]), pickString(metadata, ["description", "summary", "context"])) ?? null;
1398
+ const tokenModeledBudget = estimateBudgetUsdFromDurationHours(expectedDuration > 0 ? expectedDuration : DEFAULT_DURATION_HOURS[type]) || DEFAULT_BUDGET_USD[type];
1399
+ const expectedBudget = explicitBudget ??
1400
+ (typeof extractedBudget === "number"
1401
+ ? isLegacyHourlyBudget(extractedBudget, expectedDuration)
1402
+ ? tokenModeledBudget
1403
+ : extractedBudget
1404
+ : DEFAULT_BUDGET_USD[type]);
743
1405
  const priority = normalizePriorityForEntity(record);
744
1406
  return {
745
1407
  id: String(record.id ?? ""),
@@ -1199,27 +1861,501 @@ async function resolveAutoAssignments(input) {
1199
1861
  // =============================================================================
1200
1862
  // Factory
1201
1863
  // =============================================================================
1202
- export function createHttpHandler(config, client, getSnapshot, onboarding) {
1864
+ export function createHttpHandler(config, client, getSnapshot, onboarding, diagnostics, adapters) {
1203
1865
  const dashboardEnabled = config.dashboardEnabled ??
1204
1866
  true;
1205
- return async function handler(req, res) {
1206
- const method = (req.method ?? "GET").toUpperCase();
1207
- const rawUrl = req.url ?? "/";
1208
- const [path, queryString] = rawUrl.split("?", 2);
1209
- const url = path;
1210
- const searchParams = new URLSearchParams(queryString ?? "");
1211
- // Only handle /orgx paths — return false for everything else
1212
- if (!url.startsWith("/orgx")) {
1213
- return false;
1867
+ const outboxAdapter = adapters?.outbox ?? defaultOutboxAdapter;
1868
+ const autoContinueRuns = new Map();
1869
+ let autoContinueTickInFlight = false;
1870
+ const AUTO_CONTINUE_TICK_MS = 2_500;
1871
+ function normalizeTokenBudget(value, fallback) {
1872
+ if (typeof value === "number" && Number.isFinite(value)) {
1873
+ return Math.max(1_000, Math.round(value));
1874
+ }
1875
+ if (typeof value === "string" && value.trim().length > 0) {
1876
+ const parsed = Number(value);
1877
+ if (Number.isFinite(parsed)) {
1878
+ return Math.max(1_000, Math.round(parsed));
1879
+ }
1880
+ }
1881
+ return Math.max(1_000, Math.round(fallback));
1882
+ }
1883
+ function defaultAutoContinueTokenBudget() {
1884
+ const hours = readBudgetEnvNumber("ORGX_AUTO_CONTINUE_BUDGET_HOURS", 4, {
1885
+ min: 0.05,
1886
+ max: 24,
1887
+ });
1888
+ const fallback = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour *
1889
+ hours *
1890
+ DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
1891
+ return normalizeTokenBudget(process.env.ORGX_AUTO_CONTINUE_TOKEN_BUDGET, fallback);
1892
+ }
1893
+ function estimateTokensForDurationHours(durationHours) {
1894
+ if (!Number.isFinite(durationHours) || durationHours <= 0)
1895
+ return 0;
1896
+ const raw = durationHours *
1897
+ DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour *
1898
+ DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
1899
+ return Math.max(0, Math.round(raw));
1900
+ }
1901
+ function isSafePathSegment(value) {
1902
+ const normalized = value.trim();
1903
+ if (!normalized || normalized === "." || normalized === "..")
1904
+ return false;
1905
+ if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
1906
+ return false;
1907
+ }
1908
+ if (normalized.includes(".."))
1909
+ return false;
1910
+ return true;
1911
+ }
1912
+ function toFiniteNumber(value) {
1913
+ if (typeof value === "number" && Number.isFinite(value))
1914
+ return value;
1915
+ if (typeof value === "string" && value.trim().length > 0) {
1916
+ const parsed = Number(value);
1917
+ if (Number.isFinite(parsed))
1918
+ return parsed;
1919
+ }
1920
+ return null;
1921
+ }
1922
+ function readOpenClawSessionSummary(input) {
1923
+ const agentId = input.agentId.trim();
1924
+ const sessionId = input.sessionId.trim();
1925
+ if (!agentId || !sessionId) {
1926
+ return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
1927
+ }
1928
+ if (!isSafePathSegment(agentId) || !isSafePathSegment(sessionId)) {
1929
+ return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
1930
+ }
1931
+ const jsonlPath = join(homedir(), ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`);
1932
+ try {
1933
+ if (!existsSync(jsonlPath)) {
1934
+ return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
1935
+ }
1936
+ const raw = readFileSync(jsonlPath, "utf8");
1937
+ const lines = raw.split("\n");
1938
+ let tokens = 0;
1939
+ let costUsd = 0;
1940
+ let hadError = false;
1941
+ let errorMessage = null;
1942
+ for (const line of lines) {
1943
+ const trimmed = line.trim();
1944
+ if (!trimmed)
1945
+ continue;
1946
+ try {
1947
+ const evt = JSON.parse(trimmed);
1948
+ if (evt.type !== "message")
1949
+ continue;
1950
+ const msg = evt.message;
1951
+ if (!msg || typeof msg !== "object")
1952
+ continue;
1953
+ const usage = msg.usage;
1954
+ if (usage && typeof usage === "object") {
1955
+ const totalTokens = toFiniteNumber(usage.totalTokens) ??
1956
+ toFiniteNumber(usage.total_tokens) ??
1957
+ null;
1958
+ const inputTokens = toFiniteNumber(usage.input) ?? 0;
1959
+ const outputTokens = toFiniteNumber(usage.output) ?? 0;
1960
+ const cacheReadTokens = toFiniteNumber(usage.cacheRead) ?? 0;
1961
+ const cacheWriteTokens = toFiniteNumber(usage.cacheWrite) ?? 0;
1962
+ tokens += Math.max(0, Math.round(totalTokens ??
1963
+ inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens));
1964
+ const cost = usage.cost;
1965
+ const costTotal = cost ? toFiniteNumber(cost.total) : null;
1966
+ if (costTotal !== null) {
1967
+ costUsd += Math.max(0, costTotal);
1968
+ }
1969
+ }
1970
+ const stopReason = typeof msg.stopReason === "string" ? msg.stopReason : "";
1971
+ const msgError = typeof msg.errorMessage === "string" && msg.errorMessage.trim().length > 0
1972
+ ? msg.errorMessage.trim()
1973
+ : null;
1974
+ if (stopReason === "error" || msgError) {
1975
+ hadError = true;
1976
+ errorMessage = msgError ?? errorMessage;
1977
+ }
1978
+ }
1979
+ catch {
1980
+ // Ignore malformed lines.
1981
+ }
1982
+ }
1983
+ return {
1984
+ tokens,
1985
+ costUsd: Math.round(costUsd * 10_000) / 10_000,
1986
+ hadError,
1987
+ errorMessage,
1988
+ };
1989
+ }
1990
+ catch {
1991
+ return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
1992
+ }
1993
+ }
1994
+ async function fetchInitiativeEntity(initiativeId) {
1995
+ try {
1996
+ const list = await client.listEntities("initiative", { limit: 200 });
1997
+ const match = list.data.find((candidate) => String(candidate?.id ?? "") === initiativeId);
1998
+ return match ?? null;
1999
+ }
2000
+ catch {
2001
+ return null;
2002
+ }
2003
+ }
2004
+ async function updateInitiativeMetadata(initiativeId, patch) {
2005
+ const existing = await fetchInitiativeEntity(initiativeId);
2006
+ const existingMeta = existing && typeof existing === "object"
2007
+ ? getRecordMetadata(existing)
2008
+ : {};
2009
+ const nextMeta = { ...existingMeta, ...patch };
2010
+ await client.updateEntity("initiative", initiativeId, { metadata: nextMeta });
2011
+ }
2012
+ async function updateInitiativeAutoContinueState(input) {
2013
+ const now = new Date().toISOString();
2014
+ const patch = {
2015
+ auto_continue_enabled: true,
2016
+ auto_continue_status: input.run.status,
2017
+ auto_continue_stop_reason: input.run.stopReason,
2018
+ auto_continue_started_at: input.run.startedAt,
2019
+ auto_continue_stopped_at: input.run.stoppedAt,
2020
+ auto_continue_updated_at: now,
2021
+ auto_continue_token_budget: input.run.tokenBudget,
2022
+ auto_continue_tokens_used: input.run.tokensUsed,
2023
+ auto_continue_active_task_id: input.run.activeTaskId,
2024
+ auto_continue_active_run_id: input.run.activeRunId,
2025
+ auto_continue_active_task_token_estimate: input.run.activeTaskTokenEstimate,
2026
+ auto_continue_last_task_id: input.run.lastTaskId,
2027
+ auto_continue_last_run_id: input.run.lastRunId,
2028
+ auto_continue_include_verification: input.run.includeVerification,
2029
+ auto_continue_workstream_filter: input.run.allowedWorkstreamIds,
2030
+ ...(input.run.lastError ? { auto_continue_last_error: input.run.lastError } : {}),
2031
+ };
2032
+ await updateInitiativeMetadata(input.initiativeId, patch);
2033
+ }
2034
+ async function stopAutoContinueRun(input) {
2035
+ const now = new Date().toISOString();
2036
+ input.run.status = "stopped";
2037
+ input.run.stopReason = input.reason;
2038
+ input.run.stoppedAt = now;
2039
+ input.run.updatedAt = now;
2040
+ input.run.stopRequested = false;
2041
+ input.run.activeRunId = null;
2042
+ input.run.activeTaskId = null;
2043
+ if (input.error)
2044
+ input.run.lastError = input.error;
2045
+ try {
2046
+ if (input.reason === "completed") {
2047
+ await client.updateEntity("initiative", input.run.initiativeId, {
2048
+ status: "completed",
2049
+ });
2050
+ }
2051
+ else {
2052
+ await client.updateEntity("initiative", input.run.initiativeId, {
2053
+ status: "paused",
2054
+ });
2055
+ }
2056
+ }
2057
+ catch {
2058
+ // best effort; UI still derives paused state locally
2059
+ }
2060
+ try {
2061
+ await updateInitiativeAutoContinueState({
2062
+ initiativeId: input.run.initiativeId,
2063
+ run: input.run,
2064
+ });
2065
+ }
2066
+ catch {
2067
+ // best effort
2068
+ }
2069
+ }
2070
+ async function tickAutoContinueRun(run) {
2071
+ if (run.status !== "running" && run.status !== "stopping")
2072
+ return;
2073
+ const now = new Date().toISOString();
2074
+ // 1) If we have an active run, wait for it to finish.
2075
+ if (run.activeRunId) {
2076
+ const record = getAgentRun(run.activeRunId);
2077
+ const pid = record?.pid ?? null;
2078
+ if (pid && isPidAlive(pid)) {
2079
+ return;
2080
+ }
2081
+ // Run finished (or pid missing). Mark stopped and auto-complete the task.
2082
+ if (record) {
2083
+ try {
2084
+ markAgentRunStopped(record.runId);
2085
+ }
2086
+ catch {
2087
+ // ignore
2088
+ }
2089
+ const summary = readOpenClawSessionSummary({
2090
+ agentId: record.agentId,
2091
+ sessionId: record.runId,
2092
+ });
2093
+ const modeledTokens = run.activeTaskTokenEstimate ?? 0;
2094
+ const consumedTokens = summary.tokens > 0 ? summary.tokens : modeledTokens;
2095
+ run.tokensUsed += Math.max(0, consumedTokens);
2096
+ run.activeTaskTokenEstimate = null;
2097
+ if (record.taskId) {
2098
+ try {
2099
+ await client.updateEntity("task", record.taskId, {
2100
+ status: summary.hadError ? "blocked" : "done",
2101
+ });
2102
+ }
2103
+ catch (err) {
2104
+ run.lastError = safeErrorMessage(err);
2105
+ }
2106
+ }
2107
+ run.lastRunId = record.runId;
2108
+ run.lastTaskId = record.taskId ?? run.lastTaskId;
2109
+ run.activeRunId = null;
2110
+ run.activeTaskId = null;
2111
+ run.updatedAt = now;
2112
+ if (summary.hadError && summary.errorMessage) {
2113
+ run.lastError = summary.errorMessage;
2114
+ }
2115
+ try {
2116
+ await updateInitiativeAutoContinueState({
2117
+ initiativeId: run.initiativeId,
2118
+ run,
2119
+ });
2120
+ }
2121
+ catch {
2122
+ // best effort
2123
+ }
2124
+ }
2125
+ else {
2126
+ // No record; clear active pointers so we can continue.
2127
+ run.activeRunId = null;
2128
+ run.activeTaskId = null;
2129
+ }
2130
+ // If a stop was requested, finalize after the active run completes.
2131
+ if (run.stopRequested) {
2132
+ await stopAutoContinueRun({ run, reason: "stopped" });
2133
+ return;
2134
+ }
2135
+ }
2136
+ if (run.stopRequested) {
2137
+ run.status = "stopping";
2138
+ run.updatedAt = now;
2139
+ await stopAutoContinueRun({ run, reason: "stopped" });
2140
+ return;
2141
+ }
2142
+ // 2) Enforce token guardrail before starting a new task.
2143
+ if (run.tokensUsed >= run.tokenBudget) {
2144
+ await stopAutoContinueRun({ run, reason: "budget_exhausted" });
2145
+ return;
2146
+ }
2147
+ // 3) Pick next-up task and dispatch.
2148
+ let graph;
2149
+ try {
2150
+ graph = await buildMissionControlGraph(client, run.initiativeId);
2151
+ }
2152
+ catch (err) {
2153
+ await stopAutoContinueRun({
2154
+ run,
2155
+ reason: "error",
2156
+ error: safeErrorMessage(err),
2157
+ });
2158
+ return;
2159
+ }
2160
+ const nodes = graph.nodes;
2161
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
2162
+ const taskNodes = nodes.filter((node) => node.type === "task");
2163
+ const todoTasks = taskNodes.filter((node) => isTodoStatus(node.status));
2164
+ if (todoTasks.length === 0) {
2165
+ await stopAutoContinueRun({ run, reason: "completed" });
2166
+ return;
2167
+ }
2168
+ const taskIsReady = (task) => task.dependencyIds.every((depId) => {
2169
+ const dependency = nodeById.get(depId);
2170
+ return dependency ? isDoneStatus(dependency.status) : true;
2171
+ });
2172
+ const taskHasBlockedParent = (task) => {
2173
+ const milestone = task.milestoneId ? nodeById.get(task.milestoneId) ?? null : null;
2174
+ const workstream = task.workstreamId ? nodeById.get(task.workstreamId) ?? null : null;
2175
+ return (milestone?.status?.toLowerCase() === "blocked" ||
2176
+ workstream?.status?.toLowerCase() === "blocked");
2177
+ };
2178
+ let nextTaskNode = null;
2179
+ for (const taskId of graph.recentTodos) {
2180
+ const node = nodeById.get(taskId);
2181
+ if (!node || node.type !== "task")
2182
+ continue;
2183
+ if (!isTodoStatus(node.status))
2184
+ continue;
2185
+ if (!run.includeVerification &&
2186
+ typeof node.title === "string" &&
2187
+ /^verification\s+scenario/i.test(node.title)) {
2188
+ continue;
2189
+ }
2190
+ if (run.allowedWorkstreamIds &&
2191
+ node.workstreamId &&
2192
+ !run.allowedWorkstreamIds.includes(node.workstreamId)) {
2193
+ continue;
2194
+ }
2195
+ if (node.workstreamId) {
2196
+ const ws = nodeById.get(node.workstreamId);
2197
+ if (ws && !isInProgressStatus(ws.status)) {
2198
+ continue;
2199
+ }
2200
+ }
2201
+ if (!taskIsReady(node))
2202
+ continue;
2203
+ if (taskHasBlockedParent(node))
2204
+ continue;
2205
+ nextTaskNode = node;
2206
+ break;
2207
+ }
2208
+ if (!nextTaskNode) {
2209
+ await stopAutoContinueRun({ run, reason: "blocked" });
2210
+ return;
2211
+ }
2212
+ const nextTaskTokenEstimate = estimateTokensForDurationHours(typeof nextTaskNode.expectedDurationHours === "number"
2213
+ ? nextTaskNode.expectedDurationHours
2214
+ : 0);
2215
+ if (nextTaskTokenEstimate > 0 &&
2216
+ run.tokensUsed + nextTaskTokenEstimate > run.tokenBudget) {
2217
+ await stopAutoContinueRun({ run, reason: "budget_exhausted" });
2218
+ return;
2219
+ }
2220
+ const agentId = run.agentId || "main";
2221
+ const sessionId = randomUUID();
2222
+ const initiativeNode = nodes.find((node) => node.type === "initiative") ?? null;
2223
+ const workstreamTitle = nextTaskNode.workstreamId
2224
+ ? nodeById.get(nextTaskNode.workstreamId)?.title ?? null
2225
+ : null;
2226
+ const milestoneTitle = nextTaskNode.milestoneId
2227
+ ? nodeById.get(nextTaskNode.milestoneId)?.title ?? null
2228
+ : null;
2229
+ const message = [
2230
+ initiativeNode ? `Initiative: ${initiativeNode.title}` : null,
2231
+ workstreamTitle ? `Workstream: ${workstreamTitle}` : null,
2232
+ milestoneTitle ? `Milestone: ${milestoneTitle}` : null,
2233
+ "",
2234
+ `Task: ${nextTaskNode.title}`,
2235
+ "",
2236
+ "Execute this task. When finished, provide a concise completion summary and any relevant commands/notes.",
2237
+ ]
2238
+ .filter((line) => typeof line === "string")
2239
+ .join("\n");
2240
+ try {
2241
+ await client.updateEntity("task", nextTaskNode.id, {
2242
+ status: "in_progress",
2243
+ });
2244
+ }
2245
+ catch (err) {
2246
+ await stopAutoContinueRun({
2247
+ run,
2248
+ reason: "error",
2249
+ error: safeErrorMessage(err),
2250
+ });
2251
+ return;
2252
+ }
2253
+ upsertAgentContext({
2254
+ agentId,
2255
+ initiativeId: run.initiativeId,
2256
+ initiativeTitle: initiativeNode?.title ?? null,
2257
+ workstreamId: nextTaskNode.workstreamId,
2258
+ taskId: nextTaskNode.id,
2259
+ });
2260
+ const spawned = spawnOpenClawAgentTurn({
2261
+ agentId,
2262
+ sessionId,
2263
+ message,
2264
+ });
2265
+ upsertAgentRun({
2266
+ runId: sessionId,
2267
+ agentId,
2268
+ pid: spawned.pid,
2269
+ message,
2270
+ provider: null,
2271
+ model: null,
2272
+ initiativeId: run.initiativeId,
2273
+ initiativeTitle: initiativeNode?.title ?? null,
2274
+ workstreamId: nextTaskNode.workstreamId,
2275
+ taskId: nextTaskNode.id,
2276
+ startedAt: now,
2277
+ status: "running",
2278
+ });
2279
+ run.lastTaskId = nextTaskNode.id;
2280
+ run.lastRunId = sessionId;
2281
+ run.activeTaskId = nextTaskNode.id;
2282
+ run.activeRunId = sessionId;
2283
+ run.activeTaskTokenEstimate = nextTaskTokenEstimate > 0 ? nextTaskTokenEstimate : null;
2284
+ run.updatedAt = now;
2285
+ try {
2286
+ await client.updateEntity("initiative", run.initiativeId, { status: "active" });
2287
+ }
2288
+ catch {
2289
+ // best effort
2290
+ }
2291
+ try {
2292
+ await updateInitiativeAutoContinueState({
2293
+ initiativeId: run.initiativeId,
2294
+ run,
2295
+ });
2296
+ }
2297
+ catch {
2298
+ // best effort
2299
+ }
2300
+ }
2301
+ async function tickAllAutoContinue() {
2302
+ if (autoContinueTickInFlight)
2303
+ return;
2304
+ autoContinueTickInFlight = true;
2305
+ try {
2306
+ for (const run of autoContinueRuns.values()) {
2307
+ try {
2308
+ await tickAutoContinueRun(run);
2309
+ }
2310
+ catch (err) {
2311
+ // Never let one loop crash the whole handler.
2312
+ run.lastError = safeErrorMessage(err);
2313
+ run.updatedAt = new Date().toISOString();
2314
+ await stopAutoContinueRun({ run, reason: "error", error: run.lastError });
2315
+ }
2316
+ }
2317
+ }
2318
+ finally {
2319
+ autoContinueTickInFlight = false;
2320
+ }
2321
+ }
2322
+ const autoContinueTimer = setInterval(() => {
2323
+ void tickAllAutoContinue();
2324
+ }, AUTO_CONTINUE_TICK_MS);
2325
+ autoContinueTimer.unref?.();
2326
+ return async function handler(req, res) {
2327
+ const method = (req.method ?? "GET").toUpperCase();
2328
+ const rawUrl = req.url ?? "/";
2329
+ const [path, queryString] = rawUrl.split("?", 2);
2330
+ const url = path;
2331
+ const searchParams = new URLSearchParams(queryString ?? "");
2332
+ // Only handle /orgx paths — return false for everything else
2333
+ if (!url.startsWith("/orgx")) {
2334
+ return false;
1214
2335
  }
1215
2336
  // Handle CORS preflight
1216
2337
  if (method === "OPTIONS") {
1217
- res.writeHead(204, CORS_HEADERS);
2338
+ if (url.startsWith("/orgx/api/") && !isTrustedRequestSource(req.headers)) {
2339
+ sendJson(res, 403, {
2340
+ error: "Cross-origin browser requests are blocked for /orgx/api endpoints.",
2341
+ });
2342
+ return true;
2343
+ }
2344
+ res.writeHead(204, {
2345
+ ...SECURITY_HEADERS,
2346
+ ...CORS_HEADERS,
2347
+ });
1218
2348
  res.end();
1219
2349
  return true;
1220
2350
  }
1221
2351
  // ── API endpoints ──────────────────────────────────────────────────────
1222
2352
  if (url.startsWith("/orgx/api/")) {
2353
+ if (!isTrustedRequestSource(req.headers)) {
2354
+ sendJson(res, 403, {
2355
+ error: "Cross-origin browser requests are blocked for /orgx/api endpoints.",
2356
+ });
2357
+ return true;
2358
+ }
1223
2359
  const route = url.replace("/orgx/api/", "").replace(/\/+$/, "");
1224
2360
  const decisionApproveMatch = route.match(/^live\/decisions\/([^/]+)\/approve$/);
1225
2361
  const runActionMatch = route.match(/^runs\/([^/]+)\/actions\/([^/]+)$/);
@@ -1227,6 +2363,8 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1227
2363
  const runCheckpointRestoreMatch = route.match(/^runs\/([^/]+)\/checkpoints\/([^/]+)\/restore$/);
1228
2364
  const isDelegationPreflight = route === "delegation/preflight";
1229
2365
  const isMissionControlAutoAssignmentRoute = route === "mission-control/assignments/auto";
2366
+ const isMissionControlAutoContinueStartRoute = route === "mission-control/auto-continue/start";
2367
+ const isMissionControlAutoContinueStopRoute = route === "mission-control/auto-continue/stop";
1230
2368
  const isEntitiesRoute = route === "entities";
1231
2369
  const entityActionMatch = route.match(/^entities\/([^/]+)\/([^/]+)\/([^/]+)$/);
1232
2370
  const isOnboardingStartRoute = route === "onboarding/start";
@@ -1234,9 +2372,13 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1234
2372
  const isOnboardingManualKeyRoute = route === "onboarding/manual-key";
1235
2373
  const isOnboardingDisconnectRoute = route === "onboarding/disconnect";
1236
2374
  const isLiveActivityHeadlineRoute = route === "live/activity/headline";
2375
+ const isAgentLaunchRoute = route === "agents/launch";
2376
+ const isAgentStopRoute = route === "agents/stop";
2377
+ const isAgentRestartRoute = route === "agents/restart";
2378
+ const isByokSettingsRoute = route === "settings/byok";
1237
2379
  if (method === "POST" && isOnboardingStartRoute) {
1238
2380
  try {
1239
- const payload = parseJsonBody(req.body);
2381
+ const payload = await parseJsonRequest(req);
1240
2382
  const started = await onboarding.startPairing({
1241
2383
  openclawVersion: pickString(payload, ["openclawVersion", "openclaw_version"]) ??
1242
2384
  undefined,
@@ -1280,7 +2422,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1280
2422
  }
1281
2423
  if (method === "POST" && isOnboardingManualKeyRoute) {
1282
2424
  try {
1283
- const payload = parseJsonBody(req.body);
2425
+ const payload = await parseJsonRequest(req);
1284
2426
  const authHeader = pickHeaderString(req.headers, ["authorization"]);
1285
2427
  const bearerApiKey = authHeader && authHeader.toLowerCase().startsWith("bearer ")
1286
2428
  ? authHeader.slice("bearer ".length).trim()
@@ -1299,9 +2441,10 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1299
2441
  });
1300
2442
  return true;
1301
2443
  }
1302
- const userId = pickString(payload, ["userId", "user_id"]) ??
2444
+ const requestedUserId = pickString(payload, ["userId", "user_id"]) ??
1303
2445
  pickHeaderString(req.headers, ["x-orgx-user-id", "x-user-id"]) ??
1304
2446
  undefined;
2447
+ const userId = isUserScopedApiKey(apiKey) ? undefined : requestedUserId;
1305
2448
  const state = await onboarding.submitManualKey({
1306
2449
  apiKey,
1307
2450
  userId,
@@ -1335,10 +2478,560 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1335
2478
  }
1336
2479
  return true;
1337
2480
  }
2481
+ if (method === "POST" && isAgentLaunchRoute) {
2482
+ try {
2483
+ const payload = await parseJsonRequest(req);
2484
+ const agentId = (pickString(payload, ["agentId", "agent_id", "id"]) ??
2485
+ searchParams.get("agentId") ??
2486
+ searchParams.get("agent_id") ??
2487
+ searchParams.get("id") ??
2488
+ "")
2489
+ .trim();
2490
+ if (!agentId) {
2491
+ sendJson(res, 400, { ok: false, error: "agentId is required" });
2492
+ return true;
2493
+ }
2494
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
2495
+ sendJson(res, 400, {
2496
+ ok: false,
2497
+ error: "agentId must be a simple identifier (letters, numbers, _ or -).",
2498
+ });
2499
+ return true;
2500
+ }
2501
+ const sessionId = (pickString(payload, ["sessionId", "session_id"]) ??
2502
+ searchParams.get("sessionId") ??
2503
+ searchParams.get("session_id") ??
2504
+ "")
2505
+ .trim() ||
2506
+ randomUUID();
2507
+ const initiativeId = pickString(payload, ["initiativeId", "initiative_id"]) ??
2508
+ searchParams.get("initiativeId") ??
2509
+ searchParams.get("initiative_id") ??
2510
+ null;
2511
+ const initiativeTitle = pickString(payload, [
2512
+ "initiativeTitle",
2513
+ "initiative_title",
2514
+ "initiativeName",
2515
+ "initiative_name",
2516
+ ]) ??
2517
+ searchParams.get("initiativeTitle") ??
2518
+ searchParams.get("initiative_title") ??
2519
+ searchParams.get("initiativeName") ??
2520
+ searchParams.get("initiative_name") ??
2521
+ null;
2522
+ const workstreamId = pickString(payload, ["workstreamId", "workstream_id"]) ??
2523
+ searchParams.get("workstreamId") ??
2524
+ searchParams.get("workstream_id") ??
2525
+ null;
2526
+ const taskId = pickString(payload, ["taskId", "task_id"]) ??
2527
+ searchParams.get("taskId") ??
2528
+ searchParams.get("task_id") ??
2529
+ null;
2530
+ const thinking = (pickString(payload, ["thinking"]) ??
2531
+ searchParams.get("thinking") ??
2532
+ "")
2533
+ .trim() || null;
2534
+ const provider = normalizeOpenClawProvider(pickString(payload, ["provider", "modelProvider", "model_provider"]) ??
2535
+ searchParams.get("provider") ??
2536
+ searchParams.get("modelProvider") ??
2537
+ searchParams.get("model_provider") ??
2538
+ null);
2539
+ const requestedModel = (pickString(payload, ["model", "modelId", "model_id"]) ??
2540
+ searchParams.get("model") ??
2541
+ searchParams.get("modelId") ??
2542
+ searchParams.get("model_id") ??
2543
+ "")
2544
+ .trim() || null;
2545
+ const dryRunRaw = payload.dryRun ??
2546
+ payload.dry_run ??
2547
+ searchParams.get("dryRun") ??
2548
+ searchParams.get("dry_run") ??
2549
+ null;
2550
+ const dryRun = typeof dryRunRaw === "boolean"
2551
+ ? dryRunRaw
2552
+ : parseBooleanQuery(typeof dryRunRaw === "string" ? dryRunRaw : null);
2553
+ let requiresPremiumLaunch = Boolean(provider) || modelImpliesByok(requestedModel);
2554
+ if (!requiresPremiumLaunch) {
2555
+ try {
2556
+ const agents = await listOpenClawAgents();
2557
+ const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === agentId) ??
2558
+ null;
2559
+ const agentModel = agentEntry && typeof agentEntry.model === "string"
2560
+ ? agentEntry.model
2561
+ : null;
2562
+ requiresPremiumLaunch = modelImpliesByok(agentModel);
2563
+ }
2564
+ catch {
2565
+ // ignore
2566
+ }
2567
+ }
2568
+ if (requiresPremiumLaunch) {
2569
+ const billingStatus = await fetchBillingStatusSafe(client);
2570
+ if (billingStatus && billingStatus.plan === "free") {
2571
+ const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
2572
+ sendJson(res, 402, {
2573
+ ok: false,
2574
+ code: "upgrade_required",
2575
+ error: "BYOK agent launch requires a paid OrgX plan. Upgrade, then retry.",
2576
+ currentPlan: billingStatus.plan,
2577
+ requiredPlan: "starter",
2578
+ actions: {
2579
+ checkout: "/orgx/api/billing/checkout",
2580
+ portal: "/orgx/api/billing/portal",
2581
+ pricing: pricingUrl,
2582
+ },
2583
+ });
2584
+ return true;
2585
+ }
2586
+ }
2587
+ const messageInput = (pickString(payload, ["message", "prompt", "text"]) ??
2588
+ searchParams.get("message") ??
2589
+ searchParams.get("prompt") ??
2590
+ searchParams.get("text") ??
2591
+ "")
2592
+ .trim();
2593
+ const message = messageInput ||
2594
+ (initiativeTitle
2595
+ ? `Kick off: ${initiativeTitle}`
2596
+ : initiativeId
2597
+ ? `Kick off initiative ${initiativeId}`
2598
+ : `Kick off agent ${agentId}`);
2599
+ if (dryRun) {
2600
+ sendJson(res, 200, {
2601
+ ok: true,
2602
+ dryRun: true,
2603
+ agentId,
2604
+ initiativeId,
2605
+ workstreamId,
2606
+ taskId,
2607
+ requiresPremiumLaunch,
2608
+ startedAt: new Date().toISOString(),
2609
+ message,
2610
+ });
2611
+ return true;
2612
+ }
2613
+ let routedProvider = null;
2614
+ let routedModel = null;
2615
+ if (provider) {
2616
+ const routed = await configureOpenClawProviderRouting({
2617
+ agentId,
2618
+ provider,
2619
+ requestedModel,
2620
+ });
2621
+ routedProvider = routed.provider;
2622
+ routedModel = routed.model;
2623
+ }
2624
+ upsertAgentContext({
2625
+ agentId,
2626
+ initiativeId,
2627
+ initiativeTitle,
2628
+ workstreamId,
2629
+ taskId,
2630
+ });
2631
+ const spawned = spawnOpenClawAgentTurn({
2632
+ agentId,
2633
+ sessionId,
2634
+ message,
2635
+ thinking,
2636
+ });
2637
+ upsertAgentRun({
2638
+ runId: sessionId,
2639
+ agentId,
2640
+ pid: spawned.pid,
2641
+ message,
2642
+ provider: routedProvider,
2643
+ model: routedModel,
2644
+ initiativeId,
2645
+ initiativeTitle,
2646
+ workstreamId,
2647
+ taskId,
2648
+ startedAt: new Date().toISOString(),
2649
+ status: "running",
2650
+ });
2651
+ sendJson(res, 202, {
2652
+ ok: true,
2653
+ agentId,
2654
+ sessionId,
2655
+ pid: spawned.pid,
2656
+ provider: routedProvider,
2657
+ model: routedModel,
2658
+ initiativeId,
2659
+ workstreamId,
2660
+ taskId,
2661
+ startedAt: new Date().toISOString(),
2662
+ });
2663
+ }
2664
+ catch (err) {
2665
+ sendJson(res, 500, {
2666
+ ok: false,
2667
+ error: safeErrorMessage(err),
2668
+ });
2669
+ }
2670
+ return true;
2671
+ }
2672
+ if (method === "POST" && isAgentStopRoute) {
2673
+ try {
2674
+ const payload = await parseJsonRequest(req);
2675
+ const runId = (pickString(payload, ["runId", "run_id", "sessionId", "session_id"]) ??
2676
+ searchParams.get("runId") ??
2677
+ searchParams.get("run_id") ??
2678
+ searchParams.get("sessionId") ??
2679
+ searchParams.get("session_id") ??
2680
+ "")
2681
+ .trim();
2682
+ if (!runId) {
2683
+ sendJson(res, 400, { ok: false, error: "runId is required" });
2684
+ return true;
2685
+ }
2686
+ const record = getAgentRun(runId);
2687
+ if (!record) {
2688
+ sendJson(res, 404, { ok: false, error: "Run not found" });
2689
+ return true;
2690
+ }
2691
+ if (!record.pid) {
2692
+ sendJson(res, 409, { ok: false, error: "Run has no tracked pid" });
2693
+ return true;
2694
+ }
2695
+ const result = await stopDetachedProcess(record.pid);
2696
+ const updated = markAgentRunStopped(runId);
2697
+ sendJson(res, 200, {
2698
+ ok: true,
2699
+ runId,
2700
+ agentId: record.agentId,
2701
+ pid: record.pid,
2702
+ stopped: result.stopped,
2703
+ wasRunning: result.wasRunning,
2704
+ record: updated,
2705
+ });
2706
+ }
2707
+ catch (err) {
2708
+ sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
2709
+ }
2710
+ return true;
2711
+ }
2712
+ if (method === "POST" && isAgentRestartRoute) {
2713
+ try {
2714
+ const payload = await parseJsonRequest(req);
2715
+ const previousRunId = (pickString(payload, ["runId", "run_id", "sessionId", "session_id"]) ??
2716
+ searchParams.get("runId") ??
2717
+ searchParams.get("run_id") ??
2718
+ searchParams.get("sessionId") ??
2719
+ searchParams.get("session_id") ??
2720
+ "")
2721
+ .trim();
2722
+ if (!previousRunId) {
2723
+ sendJson(res, 400, { ok: false, error: "runId is required" });
2724
+ return true;
2725
+ }
2726
+ const record = getAgentRun(previousRunId);
2727
+ if (!record) {
2728
+ sendJson(res, 404, { ok: false, error: "Run not found" });
2729
+ return true;
2730
+ }
2731
+ const messageOverride = (pickString(payload, ["message", "prompt", "text"]) ??
2732
+ searchParams.get("message") ??
2733
+ searchParams.get("prompt") ??
2734
+ searchParams.get("text") ??
2735
+ "")
2736
+ .trim() || null;
2737
+ const providerOverride = normalizeOpenClawProvider(pickString(payload, ["provider", "modelProvider", "model_provider"]) ??
2738
+ searchParams.get("provider") ??
2739
+ searchParams.get("modelProvider") ??
2740
+ searchParams.get("model_provider") ??
2741
+ record.provider ??
2742
+ null);
2743
+ const requestedModel = (pickString(payload, ["model", "modelId", "model_id"]) ??
2744
+ searchParams.get("model") ??
2745
+ searchParams.get("modelId") ??
2746
+ searchParams.get("model_id") ??
2747
+ record.model ??
2748
+ "")
2749
+ .trim() || null;
2750
+ let requiresPremiumRestart = Boolean(providerOverride) ||
2751
+ modelImpliesByok(requestedModel) ||
2752
+ modelImpliesByok(record.model ?? null);
2753
+ if (!requiresPremiumRestart) {
2754
+ try {
2755
+ const agents = await listOpenClawAgents();
2756
+ const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === record.agentId) ?? null;
2757
+ const agentModel = agentEntry && typeof agentEntry.model === "string"
2758
+ ? agentEntry.model
2759
+ : null;
2760
+ requiresPremiumRestart = modelImpliesByok(agentModel);
2761
+ }
2762
+ catch {
2763
+ // ignore
2764
+ }
2765
+ }
2766
+ if (requiresPremiumRestart) {
2767
+ const billingStatus = await fetchBillingStatusSafe(client);
2768
+ if (billingStatus && billingStatus.plan === "free") {
2769
+ const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
2770
+ sendJson(res, 402, {
2771
+ ok: false,
2772
+ code: "upgrade_required",
2773
+ error: "BYOK agent launch requires a paid OrgX plan. Upgrade, then retry.",
2774
+ currentPlan: billingStatus.plan,
2775
+ requiredPlan: "starter",
2776
+ actions: {
2777
+ checkout: "/orgx/api/billing/checkout",
2778
+ portal: "/orgx/api/billing/portal",
2779
+ pricing: pricingUrl,
2780
+ },
2781
+ });
2782
+ return true;
2783
+ }
2784
+ }
2785
+ const sessionId = randomUUID();
2786
+ const message = messageOverride ?? record.message ?? `Restart agent ${record.agentId}`;
2787
+ let routedProvider = providerOverride ?? null;
2788
+ let routedModel = requestedModel ?? null;
2789
+ if (providerOverride) {
2790
+ const routed = await configureOpenClawProviderRouting({
2791
+ agentId: record.agentId,
2792
+ provider: providerOverride,
2793
+ requestedModel,
2794
+ });
2795
+ routedProvider = routed.provider;
2796
+ routedModel = routed.model;
2797
+ }
2798
+ upsertAgentContext({
2799
+ agentId: record.agentId,
2800
+ initiativeId: record.initiativeId,
2801
+ initiativeTitle: record.initiativeTitle,
2802
+ workstreamId: record.workstreamId,
2803
+ taskId: record.taskId,
2804
+ });
2805
+ const spawned = spawnOpenClawAgentTurn({
2806
+ agentId: record.agentId,
2807
+ sessionId,
2808
+ message,
2809
+ });
2810
+ upsertAgentRun({
2811
+ runId: sessionId,
2812
+ agentId: record.agentId,
2813
+ pid: spawned.pid,
2814
+ message,
2815
+ provider: routedProvider,
2816
+ model: routedModel,
2817
+ initiativeId: record.initiativeId,
2818
+ initiativeTitle: record.initiativeTitle,
2819
+ workstreamId: record.workstreamId,
2820
+ taskId: record.taskId,
2821
+ startedAt: new Date().toISOString(),
2822
+ status: "running",
2823
+ });
2824
+ sendJson(res, 202, {
2825
+ ok: true,
2826
+ previousRunId,
2827
+ sessionId,
2828
+ agentId: record.agentId,
2829
+ pid: spawned.pid,
2830
+ provider: routedProvider,
2831
+ model: routedModel,
2832
+ });
2833
+ }
2834
+ catch (err) {
2835
+ sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
2836
+ }
2837
+ return true;
2838
+ }
2839
+ if (method === "POST" && isMissionControlAutoContinueStartRoute) {
2840
+ try {
2841
+ const payload = await parseJsonRequest(req);
2842
+ const initiativeId = (pickString(payload, ["initiativeId", "initiative_id"]) ??
2843
+ searchParams.get("initiativeId") ??
2844
+ searchParams.get("initiative_id") ??
2845
+ "")
2846
+ .trim();
2847
+ if (!initiativeId) {
2848
+ sendJson(res, 400, { ok: false, error: "initiativeId is required" });
2849
+ return true;
2850
+ }
2851
+ const agentIdRaw = (pickString(payload, ["agentId", "agent_id"]) ??
2852
+ searchParams.get("agentId") ??
2853
+ searchParams.get("agent_id") ??
2854
+ "main")
2855
+ .trim();
2856
+ const agentId = agentIdRaw || "main";
2857
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
2858
+ sendJson(res, 400, {
2859
+ ok: false,
2860
+ error: "agentId must be a simple identifier (letters, numbers, _ or -).",
2861
+ });
2862
+ return true;
2863
+ }
2864
+ let requiresPremiumAutoContinue = false;
2865
+ try {
2866
+ const agents = await listOpenClawAgents();
2867
+ const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === agentId) ??
2868
+ null;
2869
+ const agentModel = agentEntry && typeof agentEntry.model === "string"
2870
+ ? agentEntry.model
2871
+ : null;
2872
+ requiresPremiumAutoContinue = modelImpliesByok(agentModel);
2873
+ }
2874
+ catch {
2875
+ // ignore
2876
+ }
2877
+ if (requiresPremiumAutoContinue) {
2878
+ const billingStatus = await fetchBillingStatusSafe(client);
2879
+ if (billingStatus && billingStatus.plan === "free") {
2880
+ const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
2881
+ sendJson(res, 402, {
2882
+ ok: false,
2883
+ code: "upgrade_required",
2884
+ error: "Auto-continue for BYOK agents requires a paid OrgX plan. Upgrade, then retry.",
2885
+ currentPlan: billingStatus.plan,
2886
+ requiredPlan: "starter",
2887
+ actions: {
2888
+ checkout: "/orgx/api/billing/checkout",
2889
+ portal: "/orgx/api/billing/portal",
2890
+ pricing: pricingUrl,
2891
+ },
2892
+ });
2893
+ return true;
2894
+ }
2895
+ }
2896
+ const tokenBudget = pickNumber(payload, [
2897
+ "tokenBudget",
2898
+ "token_budget",
2899
+ "tokenBudgetTokens",
2900
+ "token_budget_tokens",
2901
+ "maxTokens",
2902
+ "max_tokens",
2903
+ ]) ??
2904
+ searchParams.get("tokenBudget") ??
2905
+ searchParams.get("token_budget") ??
2906
+ searchParams.get("tokenBudgetTokens") ??
2907
+ searchParams.get("token_budget_tokens") ??
2908
+ searchParams.get("maxTokens") ??
2909
+ searchParams.get("max_tokens") ??
2910
+ null;
2911
+ const includeVerificationRaw = payload.includeVerification ??
2912
+ payload.include_verification ??
2913
+ searchParams.get("includeVerification") ??
2914
+ searchParams.get("include_verification") ??
2915
+ null;
2916
+ const includeVerification = typeof includeVerificationRaw === "boolean"
2917
+ ? includeVerificationRaw
2918
+ : parseBooleanQuery(typeof includeVerificationRaw === "string"
2919
+ ? includeVerificationRaw
2920
+ : null);
2921
+ const workstreamFilter = dedupeStrings([
2922
+ ...pickStringArray(payload, [
2923
+ "workstreamIds",
2924
+ "workstream_ids",
2925
+ "workstreamId",
2926
+ "workstream_id",
2927
+ ]),
2928
+ ...(searchParams.get("workstreamIds") ??
2929
+ searchParams.get("workstream_ids") ??
2930
+ searchParams.get("workstreamId") ??
2931
+ searchParams.get("workstream_id") ??
2932
+ "")
2933
+ .split(",")
2934
+ .map((entry) => entry.trim())
2935
+ .filter(Boolean),
2936
+ ]);
2937
+ const allowedWorkstreamIds = workstreamFilter.length > 0 ? workstreamFilter : null;
2938
+ const now = new Date().toISOString();
2939
+ const existing = autoContinueRuns.get(initiativeId) ?? null;
2940
+ const run = existing ??
2941
+ {
2942
+ initiativeId,
2943
+ agentId,
2944
+ includeVerification: false,
2945
+ allowedWorkstreamIds: null,
2946
+ tokenBudget: defaultAutoContinueTokenBudget(),
2947
+ tokensUsed: 0,
2948
+ status: "running",
2949
+ stopReason: null,
2950
+ stopRequested: false,
2951
+ startedAt: now,
2952
+ stoppedAt: null,
2953
+ updatedAt: now,
2954
+ lastError: null,
2955
+ lastTaskId: null,
2956
+ lastRunId: null,
2957
+ activeTaskId: null,
2958
+ activeRunId: null,
2959
+ activeTaskTokenEstimate: null,
2960
+ };
2961
+ run.agentId = agentId;
2962
+ run.includeVerification = includeVerification;
2963
+ run.allowedWorkstreamIds = allowedWorkstreamIds;
2964
+ run.tokenBudget = normalizeTokenBudget(tokenBudget, run.tokenBudget || defaultAutoContinueTokenBudget());
2965
+ run.status = "running";
2966
+ run.stopReason = null;
2967
+ run.stopRequested = false;
2968
+ run.startedAt = now;
2969
+ run.stoppedAt = null;
2970
+ run.updatedAt = now;
2971
+ run.lastError = null;
2972
+ autoContinueRuns.set(initiativeId, run);
2973
+ try {
2974
+ await client.updateEntity("initiative", initiativeId, { status: "active" });
2975
+ }
2976
+ catch {
2977
+ // best effort
2978
+ }
2979
+ try {
2980
+ await updateInitiativeAutoContinueState({ initiativeId, run });
2981
+ }
2982
+ catch {
2983
+ // best effort
2984
+ }
2985
+ sendJson(res, 200, { ok: true, run });
2986
+ }
2987
+ catch (err) {
2988
+ sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
2989
+ }
2990
+ return true;
2991
+ }
2992
+ if (method === "POST" && isMissionControlAutoContinueStopRoute) {
2993
+ try {
2994
+ const payload = await parseJsonRequest(req);
2995
+ const initiativeId = (pickString(payload, ["initiativeId", "initiative_id"]) ??
2996
+ searchParams.get("initiativeId") ??
2997
+ searchParams.get("initiative_id") ??
2998
+ "")
2999
+ .trim();
3000
+ if (!initiativeId) {
3001
+ sendJson(res, 400, { ok: false, error: "initiativeId is required" });
3002
+ return true;
3003
+ }
3004
+ const run = autoContinueRuns.get(initiativeId) ?? null;
3005
+ if (!run) {
3006
+ sendJson(res, 404, { ok: false, error: "No auto-continue run found" });
3007
+ return true;
3008
+ }
3009
+ const now = new Date().toISOString();
3010
+ run.stopRequested = true;
3011
+ run.status = run.activeRunId ? "stopping" : "stopped";
3012
+ run.updatedAt = now;
3013
+ if (!run.activeRunId) {
3014
+ await stopAutoContinueRun({ run, reason: "stopped" });
3015
+ }
3016
+ else {
3017
+ try {
3018
+ await updateInitiativeAutoContinueState({ initiativeId, run });
3019
+ }
3020
+ catch {
3021
+ // best effort
3022
+ }
3023
+ }
3024
+ sendJson(res, 200, { ok: true, run });
3025
+ }
3026
+ catch (err) {
3027
+ sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
3028
+ }
3029
+ return true;
3030
+ }
1338
3031
  if (method === "POST" &&
1339
3032
  (route === "live/decisions/approve" || decisionApproveMatch)) {
1340
3033
  try {
1341
- const payload = parseJsonBody(req.body);
3034
+ const payload = await parseJsonRequest(req);
1342
3035
  const action = payload.action === "reject" ? "reject" : "approve";
1343
3036
  const note = typeof payload.note === "string" && payload.note.trim().length > 0
1344
3037
  ? payload.note.trim()
@@ -1381,7 +3074,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1381
3074
  }
1382
3075
  if (method === "POST" && isDelegationPreflight) {
1383
3076
  try {
1384
- const payload = parseJsonBody(req.body);
3077
+ const payload = await parseJsonRequest(req);
1385
3078
  const intent = pickString(payload, ["intent"]);
1386
3079
  if (!intent) {
1387
3080
  sendJson(res, 400, { error: "intent is required" });
@@ -1407,7 +3100,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1407
3100
  }
1408
3101
  if (method === "POST" && isMissionControlAutoAssignmentRoute) {
1409
3102
  try {
1410
- const payload = parseJsonBody(req.body);
3103
+ const payload = await parseJsonRequest(req);
1411
3104
  const entityId = pickString(payload, ["entity_id", "entityId"]);
1412
3105
  const entityType = pickString(payload, ["entity_type", "entityType"]);
1413
3106
  const initiativeId = pickString(payload, ["initiative_id", "initiativeId"]) ?? null;
@@ -1441,7 +3134,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1441
3134
  if (runCheckpointsMatch && method === "POST") {
1442
3135
  try {
1443
3136
  const runId = decodeURIComponent(runCheckpointsMatch[1]);
1444
- const payload = parseJsonBody(req.body);
3137
+ const payload = await parseJsonRequest(req);
1445
3138
  const reason = pickString(payload, ["reason"]) ?? undefined;
1446
3139
  const rawPayload = payload.payload;
1447
3140
  const checkpointPayload = rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
@@ -1464,7 +3157,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1464
3157
  try {
1465
3158
  const runId = decodeURIComponent(runCheckpointRestoreMatch[1]);
1466
3159
  const checkpointId = decodeURIComponent(runCheckpointRestoreMatch[2]);
1467
- const payload = parseJsonBody(req.body);
3160
+ const payload = await parseJsonRequest(req);
1468
3161
  const reason = pickString(payload, ["reason"]) ?? undefined;
1469
3162
  const data = await client.restoreRunCheckpoint(runId, {
1470
3163
  checkpointId,
@@ -1483,7 +3176,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1483
3176
  try {
1484
3177
  const runId = decodeURIComponent(runActionMatch[1]);
1485
3178
  const action = decodeURIComponent(runActionMatch[2]);
1486
- const payload = parseJsonBody(req.body);
3179
+ const payload = await parseJsonRequest(req);
1487
3180
  const checkpointId = pickString(payload, ["checkpointId", "checkpoint_id"]);
1488
3181
  const reason = pickString(payload, ["reason"]);
1489
3182
  const data = await client.runAction(runId, action, {
@@ -1505,7 +3198,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1505
3198
  const entityType = decodeURIComponent(entityActionMatch[1]);
1506
3199
  const entityId = decodeURIComponent(entityActionMatch[2]);
1507
3200
  const entityAction = decodeURIComponent(entityActionMatch[3]);
1508
- const payload = parseJsonBody(req.body);
3201
+ const payload = await parseJsonRequest(req);
1509
3202
  if (entityAction === "delete") {
1510
3203
  // Delete via status update
1511
3204
  const entity = await client.updateEntity(entityType, entityId, {
@@ -1545,6 +3238,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1545
3238
  return true;
1546
3239
  }
1547
3240
  if (method !== "GET" &&
3241
+ method !== "HEAD" &&
1548
3242
  !(runCheckpointsMatch && method === "POST") &&
1549
3243
  !(runCheckpointRestoreMatch && method === "POST") &&
1550
3244
  !(runActionMatch && method === "POST") &&
@@ -1556,9 +3250,11 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1556
3250
  !(isOnboardingStartRoute && method === "POST") &&
1557
3251
  !(isOnboardingManualKeyRoute && method === "POST") &&
1558
3252
  !(isOnboardingDisconnectRoute && method === "POST") &&
3253
+ !(isByokSettingsRoute && method === "POST") &&
1559
3254
  !(isLiveActivityHeadlineRoute && method === "POST")) {
1560
3255
  res.writeHead(405, {
1561
3256
  "Content-Type": "text/plain",
3257
+ ...SECURITY_HEADERS,
1562
3258
  ...CORS_HEADERS,
1563
3259
  });
1564
3260
  res.end("Method Not Allowed");
@@ -1576,12 +3272,150 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1576
3272
  // use null snapshot
1577
3273
  }
1578
3274
  }
3275
+ if (method === "HEAD") {
3276
+ // The dashboard uses a HEAD probe to determine connection state.
3277
+ // Mirror the GET semantics (connected vs not) via status code,
3278
+ // but omit a response body.
3279
+ res.writeHead(snapshot ? 200 : 503, {
3280
+ ...SECURITY_HEADERS,
3281
+ ...CORS_HEADERS,
3282
+ });
3283
+ res.end();
3284
+ return true;
3285
+ }
1579
3286
  sendJson(res, 200, formatStatus(snapshot));
1580
3287
  return true;
1581
3288
  }
3289
+ case "health": {
3290
+ const probeRemote = parseBooleanQuery(searchParams.get("probe") ?? searchParams.get("probe_remote"));
3291
+ try {
3292
+ if (diagnostics?.getHealth) {
3293
+ const health = await diagnostics.getHealth({ probeRemote });
3294
+ sendJson(res, 200, health);
3295
+ return true;
3296
+ }
3297
+ const outbox = await outboxAdapter.readSummary();
3298
+ sendJson(res, 200, {
3299
+ ok: true,
3300
+ status: "ok",
3301
+ generatedAt: new Date().toISOString(),
3302
+ checks: [],
3303
+ plugin: {
3304
+ baseUrl: config.baseUrl,
3305
+ },
3306
+ auth: {
3307
+ hasApiKey: Boolean(config.apiKey),
3308
+ },
3309
+ outbox: {
3310
+ pendingTotal: outbox.pendingTotal,
3311
+ pendingByQueue: outbox.pendingByQueue,
3312
+ oldestEventAt: outbox.oldestEventAt,
3313
+ newestEventAt: outbox.newestEventAt,
3314
+ replayStatus: "idle",
3315
+ lastReplayAttemptAt: null,
3316
+ lastReplaySuccessAt: null,
3317
+ lastReplayFailureAt: null,
3318
+ lastReplayError: null,
3319
+ },
3320
+ remote: {
3321
+ enabled: false,
3322
+ reachable: null,
3323
+ latencyMs: null,
3324
+ error: null,
3325
+ },
3326
+ });
3327
+ }
3328
+ catch (err) {
3329
+ sendJson(res, 500, {
3330
+ error: safeErrorMessage(err),
3331
+ });
3332
+ }
3333
+ return true;
3334
+ }
1582
3335
  case "agents":
1583
3336
  sendJson(res, 200, formatAgents(getSnapshot()));
1584
3337
  return true;
3338
+ case "agents/catalog": {
3339
+ try {
3340
+ const [openclawAgents, localSnapshot] = await Promise.all([
3341
+ listOpenClawAgents(),
3342
+ loadLocalOpenClawSnapshot(240).catch(() => null),
3343
+ ]);
3344
+ const localById = new Map();
3345
+ if (localSnapshot) {
3346
+ for (const agent of localSnapshot.agents) {
3347
+ localById.set(agent.id, {
3348
+ status: agent.status,
3349
+ currentTask: agent.currentTask,
3350
+ runId: agent.runId,
3351
+ startedAt: agent.startedAt,
3352
+ blockers: agent.blockers,
3353
+ });
3354
+ }
3355
+ }
3356
+ const contexts = readAgentContexts().agents;
3357
+ const runs = readAgentRuns().runs;
3358
+ const latestRunByAgent = new Map();
3359
+ for (const run of Object.values(runs)) {
3360
+ if (!run || typeof run !== "object")
3361
+ continue;
3362
+ const agentId = typeof run.agentId === "string" ? run.agentId.trim() : "";
3363
+ if (!agentId)
3364
+ continue;
3365
+ const existing = latestRunByAgent.get(agentId);
3366
+ const nextTs = Date.parse(run.startedAt ?? "");
3367
+ const existingTs = existing ? Date.parse(existing.startedAt ?? "") : 0;
3368
+ // Prefer latest running record; fall back to latest overall if none running.
3369
+ if (!existing) {
3370
+ latestRunByAgent.set(agentId, run);
3371
+ continue;
3372
+ }
3373
+ const existingRunning = existing.status === "running";
3374
+ const nextRunning = run.status === "running";
3375
+ if (nextRunning && !existingRunning) {
3376
+ latestRunByAgent.set(agentId, run);
3377
+ continue;
3378
+ }
3379
+ if (nextRunning === existingRunning && nextTs > existingTs) {
3380
+ latestRunByAgent.set(agentId, run);
3381
+ }
3382
+ }
3383
+ const agents = openclawAgents.map((entry) => {
3384
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
3385
+ const name = typeof entry.name === "string" && entry.name.trim().length > 0
3386
+ ? entry.name.trim()
3387
+ : id || "unknown";
3388
+ const local = id ? localById.get(id) ?? null : null;
3389
+ const context = id ? contexts[id] ?? null : null;
3390
+ const runFromSession = id && local?.runId ? runs[local.runId] ?? null : null;
3391
+ const run = runFromSession ?? (id ? latestRunByAgent.get(id) ?? null : null);
3392
+ return {
3393
+ id,
3394
+ name,
3395
+ workspace: typeof entry.workspace === "string" ? entry.workspace : null,
3396
+ model: typeof entry.model === "string" ? entry.model : null,
3397
+ isDefault: Boolean(entry.isDefault),
3398
+ status: local?.status ?? null,
3399
+ currentTask: local?.currentTask ?? null,
3400
+ runId: local?.runId ?? null,
3401
+ startedAt: local?.startedAt ?? null,
3402
+ blockers: local?.blockers ?? [],
3403
+ context,
3404
+ run,
3405
+ };
3406
+ });
3407
+ sendJson(res, 200, {
3408
+ generatedAt: new Date().toISOString(),
3409
+ agents,
3410
+ });
3411
+ }
3412
+ catch (err) {
3413
+ sendJson(res, 500, {
3414
+ error: safeErrorMessage(err),
3415
+ });
3416
+ }
3417
+ return true;
3418
+ }
1585
3419
  case "activity":
1586
3420
  sendJson(res, 200, formatActivity(getSnapshot()));
1587
3421
  return true;
@@ -1591,6 +3425,229 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1591
3425
  case "onboarding":
1592
3426
  sendJson(res, 200, getOnboardingState(await onboarding.getStatus()));
1593
3427
  return true;
3428
+ case "mission-control/auto-continue/status": {
3429
+ const initiativeId = searchParams.get("initiative_id") ??
3430
+ searchParams.get("initiativeId") ??
3431
+ "";
3432
+ const id = initiativeId.trim();
3433
+ if (!id) {
3434
+ sendJson(res, 400, {
3435
+ ok: false,
3436
+ error: "Query parameter 'initiative_id' is required.",
3437
+ });
3438
+ return true;
3439
+ }
3440
+ const run = autoContinueRuns.get(id) ?? null;
3441
+ sendJson(res, 200, {
3442
+ ok: true,
3443
+ initiativeId: id,
3444
+ run,
3445
+ defaults: {
3446
+ tokenBudget: defaultAutoContinueTokenBudget(),
3447
+ tickMs: AUTO_CONTINUE_TICK_MS,
3448
+ },
3449
+ });
3450
+ return true;
3451
+ }
3452
+ case "billing/status": {
3453
+ if (method !== "GET") {
3454
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
3455
+ return true;
3456
+ }
3457
+ try {
3458
+ const status = await client.getBillingStatus();
3459
+ sendJson(res, 200, { ok: true, data: status });
3460
+ }
3461
+ catch (err) {
3462
+ sendJson(res, 200, { ok: false, error: safeErrorMessage(err) });
3463
+ }
3464
+ return true;
3465
+ }
3466
+ case "billing/checkout": {
3467
+ if (method !== "POST") {
3468
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
3469
+ return true;
3470
+ }
3471
+ const basePricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
3472
+ try {
3473
+ const payload = await parseJsonRequest(req);
3474
+ const planIdRaw = (pickString(payload, ["planId", "plan_id", "plan"]) ?? "starter").trim().toLowerCase();
3475
+ const billingCycleRaw = (pickString(payload, ["billingCycle", "billing_cycle"]) ?? "monthly").trim().toLowerCase();
3476
+ const planId = planIdRaw === "team" || planIdRaw === "enterprise" ? planIdRaw : "starter";
3477
+ const billingCycle = billingCycleRaw === "annual" ? "annual" : "monthly";
3478
+ const result = await client.createBillingCheckout({
3479
+ planId,
3480
+ billingCycle,
3481
+ });
3482
+ const url = result?.url ?? result?.checkout_url ?? null;
3483
+ sendJson(res, 200, { ok: true, data: { url: url ?? basePricingUrl } });
3484
+ }
3485
+ catch (err) {
3486
+ // If the remote billing endpoints are not deployed yet, degrade gracefully.
3487
+ sendJson(res, 200, { ok: true, data: { url: basePricingUrl } });
3488
+ }
3489
+ return true;
3490
+ }
3491
+ case "billing/portal": {
3492
+ if (method !== "POST") {
3493
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
3494
+ return true;
3495
+ }
3496
+ const basePricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
3497
+ try {
3498
+ const result = await client.createBillingPortal();
3499
+ const url = result?.url ?? null;
3500
+ sendJson(res, 200, { ok: true, data: { url: url ?? basePricingUrl } });
3501
+ }
3502
+ catch (err) {
3503
+ sendJson(res, 200, { ok: true, data: { url: basePricingUrl } });
3504
+ }
3505
+ return true;
3506
+ }
3507
+ case "settings/byok": {
3508
+ const stored = readByokKeys();
3509
+ const effectiveOpenai = stored?.openaiApiKey ?? process.env.OPENAI_API_KEY ?? null;
3510
+ const effectiveAnthropic = stored?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? null;
3511
+ const effectiveOpenrouter = stored?.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
3512
+ const toProvider = (input) => {
3513
+ const hasStored = typeof input.storedValue === "string" && input.storedValue.trim().length > 0;
3514
+ const hasEnv = typeof input.envValue === "string" && input.envValue.trim().length > 0;
3515
+ const source = hasStored ? "stored" : hasEnv ? "env" : "none";
3516
+ return {
3517
+ configured: Boolean(input.effective && input.effective.trim().length > 0),
3518
+ source,
3519
+ masked: maskSecret(input.effective),
3520
+ };
3521
+ };
3522
+ if (method === "POST") {
3523
+ try {
3524
+ const payload = await parseJsonRequest(req);
3525
+ const updates = {};
3526
+ const setIfPresent = (key, aliases) => {
3527
+ for (const alias of aliases) {
3528
+ if (!Object.prototype.hasOwnProperty.call(payload, alias))
3529
+ continue;
3530
+ const raw = payload[alias];
3531
+ if (raw === null) {
3532
+ updates[key] = null;
3533
+ return;
3534
+ }
3535
+ if (typeof raw === "string") {
3536
+ updates[key] = raw;
3537
+ return;
3538
+ }
3539
+ }
3540
+ };
3541
+ setIfPresent("openaiApiKey", ["openaiApiKey", "openai_api_key", "openaiKey", "openai_key"]);
3542
+ setIfPresent("anthropicApiKey", [
3543
+ "anthropicApiKey",
3544
+ "anthropic_api_key",
3545
+ "anthropicKey",
3546
+ "anthropic_key",
3547
+ ]);
3548
+ setIfPresent("openrouterApiKey", [
3549
+ "openrouterApiKey",
3550
+ "openrouter_api_key",
3551
+ "openrouterKey",
3552
+ "openrouter_key",
3553
+ ]);
3554
+ const saved = writeByokKeys(updates);
3555
+ const nextEffectiveOpenai = saved.openaiApiKey ?? process.env.OPENAI_API_KEY ?? null;
3556
+ const nextEffectiveAnthropic = saved.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? null;
3557
+ const nextEffectiveOpenrouter = saved.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
3558
+ sendJson(res, 200, {
3559
+ ok: true,
3560
+ updatedAt: saved.updatedAt,
3561
+ providers: {
3562
+ openai: toProvider({
3563
+ storedValue: saved.openaiApiKey,
3564
+ envValue: process.env.OPENAI_API_KEY,
3565
+ effective: nextEffectiveOpenai,
3566
+ }),
3567
+ anthropic: toProvider({
3568
+ storedValue: saved.anthropicApiKey,
3569
+ envValue: process.env.ANTHROPIC_API_KEY,
3570
+ effective: nextEffectiveAnthropic,
3571
+ }),
3572
+ openrouter: toProvider({
3573
+ storedValue: saved.openrouterApiKey,
3574
+ envValue: process.env.OPENROUTER_API_KEY,
3575
+ effective: nextEffectiveOpenrouter,
3576
+ }),
3577
+ },
3578
+ });
3579
+ }
3580
+ catch (err) {
3581
+ sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
3582
+ }
3583
+ return true;
3584
+ }
3585
+ sendJson(res, 200, {
3586
+ ok: true,
3587
+ updatedAt: stored?.updatedAt ?? null,
3588
+ providers: {
3589
+ openai: toProvider({
3590
+ storedValue: stored?.openaiApiKey,
3591
+ envValue: process.env.OPENAI_API_KEY,
3592
+ effective: effectiveOpenai,
3593
+ }),
3594
+ anthropic: toProvider({
3595
+ storedValue: stored?.anthropicApiKey,
3596
+ envValue: process.env.ANTHROPIC_API_KEY,
3597
+ effective: effectiveAnthropic,
3598
+ }),
3599
+ openrouter: toProvider({
3600
+ storedValue: stored?.openrouterApiKey,
3601
+ envValue: process.env.OPENROUTER_API_KEY,
3602
+ effective: effectiveOpenrouter,
3603
+ }),
3604
+ },
3605
+ });
3606
+ return true;
3607
+ }
3608
+ case "settings/byok/health": {
3609
+ let agentId = searchParams.get("agentId") ??
3610
+ searchParams.get("agent_id") ??
3611
+ "";
3612
+ agentId = agentId.trim();
3613
+ if (!agentId) {
3614
+ try {
3615
+ const agents = await listOpenClawAgents();
3616
+ const defaultAgent = agents.find((entry) => Boolean(entry.isDefault)) ?? agents[0] ?? null;
3617
+ const candidate = defaultAgent && typeof defaultAgent.id === "string" ? defaultAgent.id.trim() : "";
3618
+ if (candidate)
3619
+ agentId = candidate;
3620
+ }
3621
+ catch {
3622
+ // ignore
3623
+ }
3624
+ }
3625
+ if (!agentId)
3626
+ agentId = "main";
3627
+ const providers = {};
3628
+ for (const provider of ["openai", "anthropic", "openrouter"]) {
3629
+ try {
3630
+ const models = await listOpenClawProviderModels({ agentId, provider });
3631
+ providers[provider] = {
3632
+ ok: true,
3633
+ modelCount: models.length,
3634
+ sample: models.slice(0, 4).map((model) => model.key),
3635
+ };
3636
+ }
3637
+ catch (err) {
3638
+ providers[provider] = {
3639
+ ok: false,
3640
+ error: safeErrorMessage(err),
3641
+ };
3642
+ }
3643
+ }
3644
+ sendJson(res, 200, {
3645
+ ok: true,
3646
+ agentId,
3647
+ providers,
3648
+ });
3649
+ return true;
3650
+ }
1594
3651
  case "mission-control/graph": {
1595
3652
  const initiativeId = searchParams.get("initiative_id") ??
1596
3653
  searchParams.get("initiativeId");
@@ -1614,7 +3671,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1614
3671
  case "entities": {
1615
3672
  if (method === "POST") {
1616
3673
  try {
1617
- const payload = parseJsonBody(req.body);
3674
+ const payload = await parseJsonRequest(req);
1618
3675
  const type = pickString(payload, ["type"]);
1619
3676
  const title = pickString(payload, ["title", "name"]);
1620
3677
  if (!type || !title) {
@@ -1660,7 +3717,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1660
3717
  }
1661
3718
  if (method === "PATCH") {
1662
3719
  try {
1663
- const payload = parseJsonBody(req.body);
3720
+ const payload = await parseJsonRequest(req);
1664
3721
  const type = pickString(payload, ["type"]);
1665
3722
  const id = pickString(payload, ["id"]);
1666
3723
  if (!type || !id) {
@@ -1709,6 +3766,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1709
3766
  }
1710
3767
  return true;
1711
3768
  }
3769
+ case "dashboard-bundle":
1712
3770
  case "live/snapshot": {
1713
3771
  const sessionsLimit = parsePositiveInt(searchParams.get("sessionsLimit") ?? searchParams.get("sessions_limit"), 320);
1714
3772
  const activityLimit = parsePositiveInt(searchParams.get("activityLimit") ?? searchParams.get("activity_limit"), 600);
@@ -1719,6 +3777,49 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1719
3777
  const decisionStatus = searchParams.get("status") ?? "pending";
1720
3778
  const includeIdleRaw = searchParams.get("include_idle");
1721
3779
  const includeIdle = includeIdleRaw === null ? undefined : includeIdleRaw !== "false";
3780
+ const degraded = [];
3781
+ const agentContexts = readAgentContexts().agents;
3782
+ const scopedAgentIds = getScopedAgentIds(agentContexts);
3783
+ let outboxStatus = null;
3784
+ try {
3785
+ if (diagnostics?.getHealth) {
3786
+ const health = await diagnostics.getHealth({ probeRemote: false });
3787
+ if (health && typeof health === "object") {
3788
+ const maybeOutbox = health.outbox;
3789
+ if (maybeOutbox && typeof maybeOutbox === "object") {
3790
+ outboxStatus = maybeOutbox;
3791
+ }
3792
+ }
3793
+ }
3794
+ if (!outboxStatus) {
3795
+ const outbox = await outboxAdapter.readSummary();
3796
+ outboxStatus = {
3797
+ pendingTotal: outbox.pendingTotal,
3798
+ pendingByQueue: outbox.pendingByQueue,
3799
+ oldestEventAt: outbox.oldestEventAt,
3800
+ newestEventAt: outbox.newestEventAt,
3801
+ replayStatus: "idle",
3802
+ lastReplayAttemptAt: null,
3803
+ lastReplaySuccessAt: null,
3804
+ lastReplayFailureAt: null,
3805
+ lastReplayError: null,
3806
+ };
3807
+ }
3808
+ }
3809
+ catch (err) {
3810
+ degraded.push(`outbox status unavailable (${safeErrorMessage(err)})`);
3811
+ outboxStatus = {
3812
+ pendingTotal: 0,
3813
+ pendingByQueue: {},
3814
+ oldestEventAt: null,
3815
+ newestEventAt: null,
3816
+ replayStatus: "idle",
3817
+ lastReplayAttemptAt: null,
3818
+ lastReplaySuccessAt: null,
3819
+ lastReplayFailureAt: null,
3820
+ lastReplayError: null,
3821
+ };
3822
+ }
1722
3823
  let localSnapshot = null;
1723
3824
  const ensureLocalSnapshot = async (minimumLimit) => {
1724
3825
  if (!localSnapshot || localSnapshot.sessions.length < minimumLimit) {
@@ -1746,7 +3847,6 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1746
3847
  includeIdle,
1747
3848
  }),
1748
3849
  ]);
1749
- const degraded = [];
1750
3850
  // sessions
1751
3851
  let sessions = {
1752
3852
  nodes: [],
@@ -1761,6 +3861,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1761
3861
  degraded.push(`sessions unavailable (${safeErrorMessage(sessionsResult.reason)})`);
1762
3862
  try {
1763
3863
  let local = toLocalSessionTree(await ensureLocalSnapshot(Math.max(sessionsLimit, 200)), sessionsLimit);
3864
+ local = applyAgentContextsToSessionTree(local, agentContexts);
1764
3865
  if (initiative && initiative.trim().length > 0) {
1765
3866
  const filteredNodes = local.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
1766
3867
  const filteredIds = new Set(filteredNodes.map((node) => node.id));
@@ -1799,6 +3900,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1799
3900
  filtered = filtered.filter((item) => Date.parse(item.timestamp) >= sinceEpoch);
1800
3901
  }
1801
3902
  }
3903
+ filtered = applyAgentContextsToActivity(filtered, agentContexts);
1802
3904
  activity = filtered.slice(0, activityLimit);
1803
3905
  }
1804
3906
  catch (localErr) {
@@ -1852,9 +3954,51 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1852
3954
  degraded.push(`agents local fallback failed (${safeErrorMessage(localErr)})`);
1853
3955
  }
1854
3956
  }
3957
+ // Merge locally-launched OpenClaw agent sessions/activity into the snapshot so
3958
+ // the UI reflects one-click launches even when the cloud reporting plane is reachable.
3959
+ if (scopedAgentIds.size > 0) {
3960
+ try {
3961
+ const minimum = Math.max(Math.max(sessionsLimit, activityLimit), 240);
3962
+ const snapshot = await ensureLocalSnapshot(minimum);
3963
+ const scopedSnapshot = {
3964
+ ...snapshot,
3965
+ sessions: snapshot.sessions.filter((session) => Boolean(session.agentId && scopedAgentIds.has(session.agentId))),
3966
+ agents: snapshot.agents.filter((agent) => scopedAgentIds.has(agent.id)),
3967
+ };
3968
+ // Sessions
3969
+ let localSessions = applyAgentContextsToSessionTree(toLocalSessionTree(scopedSnapshot, sessionsLimit), agentContexts);
3970
+ if (initiative && initiative.trim().length > 0) {
3971
+ const filteredNodes = localSessions.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
3972
+ const filteredIds = new Set(filteredNodes.map((node) => node.id));
3973
+ const filteredGroupIds = new Set(filteredNodes.map((node) => node.groupId));
3974
+ localSessions = {
3975
+ nodes: filteredNodes,
3976
+ edges: localSessions.edges.filter((edge) => filteredIds.has(edge.parentId) && filteredIds.has(edge.childId)),
3977
+ groups: localSessions.groups.filter((group) => filteredGroupIds.has(group.id)),
3978
+ };
3979
+ }
3980
+ sessions = mergeSessionTrees(sessions, localSessions);
3981
+ // Activity
3982
+ const localActivity = await toLocalLiveActivity(scopedSnapshot, Math.max(activityLimit, 240));
3983
+ let localItems = applyAgentContextsToActivity(localActivity.activities, agentContexts);
3984
+ if (run && run.trim().length > 0) {
3985
+ localItems = localItems.filter((item) => item.runId === run);
3986
+ }
3987
+ if (since && since.trim().length > 0) {
3988
+ const sinceEpoch = Date.parse(since);
3989
+ if (Number.isFinite(sinceEpoch)) {
3990
+ localItems = localItems.filter((item) => Date.parse(item.timestamp) >= sinceEpoch);
3991
+ }
3992
+ }
3993
+ activity = mergeActivities(activity, localItems, activityLimit);
3994
+ }
3995
+ catch (err) {
3996
+ degraded.push(`local agent merge failed (${safeErrorMessage(err)})`);
3997
+ }
3998
+ }
1855
3999
  // include locally buffered events so offline-generated actions are visible
1856
4000
  try {
1857
- const buffered = await readAllOutboxItems();
4001
+ const buffered = await outboxAdapter.readAllItems();
1858
4002
  if (buffered.length > 0) {
1859
4003
  const merged = [...activity, ...buffered]
1860
4004
  .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
@@ -1879,6 +4023,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1879
4023
  handoffs,
1880
4024
  decisions,
1881
4025
  agents,
4026
+ outbox: outboxStatus,
1882
4027
  generatedAt: new Date().toISOString(),
1883
4028
  degraded: degraded.length > 0 ? degraded : undefined,
1884
4029
  });
@@ -1905,6 +4050,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1905
4050
  : undefined;
1906
4051
  const limit = Number.isFinite(limitRaw) ? Math.max(1, Number(limitRaw)) : 100;
1907
4052
  let local = toLocalSessionTree(await loadLocalOpenClawSnapshot(Math.max(limit, 200)), limit);
4053
+ local = applyAgentContextsToSessionTree(local, readAgentContexts().agents);
1908
4054
  if (initiative && initiative.trim().length > 0) {
1909
4055
  const filteredNodes = local.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
1910
4056
  const filteredIds = new Set(filteredNodes.map((node) => node.id));
@@ -1966,8 +4112,9 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
1966
4112
  };
1967
4113
  }
1968
4114
  }
4115
+ const activitiesWithContexts = applyAgentContextsToActivity(local.activities, readAgentContexts().agents);
1969
4116
  sendJson(res, 200, {
1970
- activities: local.activities.slice(0, limit),
4117
+ activities: activitiesWithContexts.slice(0, limit),
1971
4118
  total: local.total,
1972
4119
  });
1973
4120
  }
@@ -2014,7 +4161,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2014
4161
  return true;
2015
4162
  }
2016
4163
  try {
2017
- const payload = parseJsonBody(req.body);
4164
+ const payload = await parseJsonRequest(req);
2018
4165
  const text = pickString(payload, ["text", "summary", "detail", "content"]);
2019
4166
  if (!text) {
2020
4167
  sendJson(res, 400, { error: "text is required" });
@@ -2192,12 +4339,14 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2192
4339
  }, STREAM_IDLE_TIMEOUT_MS);
2193
4340
  };
2194
4341
  try {
4342
+ const includeUserHeader = Boolean(config.userId && config.userId.trim().length > 0) &&
4343
+ !isUserScopedApiKey(config.apiKey);
2195
4344
  const upstream = await fetch(target, {
2196
4345
  method: "GET",
2197
4346
  headers: {
2198
4347
  Authorization: `Bearer ${config.apiKey}`,
2199
4348
  Accept: "text/event-stream",
2200
- ...(config.userId
4349
+ ...(includeUserHeader
2201
4350
  ? { "X-Orgx-User-Id": config.userId }
2202
4351
  : {}),
2203
4352
  },
@@ -2220,6 +4369,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2220
4369
  "Content-Type": "text/event-stream; charset=utf-8",
2221
4370
  "Cache-Control": "no-cache, no-transform",
2222
4371
  Connection: "keep-alive",
4372
+ ...SECURITY_HEADERS,
2223
4373
  ...CORS_HEADERS,
2224
4374
  });
2225
4375
  streamOpened = true;
@@ -2306,6 +4456,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2306
4456
  if (!dashboardEnabled) {
2307
4457
  res.writeHead(404, {
2308
4458
  "Content-Type": "text/plain",
4459
+ ...SECURITY_HEADERS,
2309
4460
  ...CORS_HEADERS,
2310
4461
  });
2311
4462
  res.end("Dashboard is disabled");
@@ -2317,8 +4468,14 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2317
4468
  // Static assets: /orgx/live/assets/* → dashboard/dist/assets/*
2318
4469
  // Hashed filenames get long-lived cache
2319
4470
  if (subPath.startsWith("assets/")) {
2320
- const assetPath = join(DIST_DIR, subPath);
2321
- if (existsSync(assetPath)) {
4471
+ const assetPath = resolveSafeDistPath(subPath);
4472
+ let isWithinAssetsDir = false;
4473
+ if (assetPath) {
4474
+ isWithinAssetsDir =
4475
+ assetPath === RESOLVED_DIST_ASSETS_DIR ||
4476
+ assetPath.startsWith(`${RESOLVED_DIST_ASSETS_DIR}${sep}`);
4477
+ }
4478
+ if (assetPath && isWithinAssetsDir && existsSync(assetPath)) {
2322
4479
  sendFile(res, assetPath, "public, max-age=31536000, immutable");
2323
4480
  }
2324
4481
  else {
@@ -2327,9 +4484,9 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2327
4484
  return true;
2328
4485
  }
2329
4486
  // Check for an exact file match (e.g. favicon, manifest)
2330
- if (subPath && !subPath.includes("..")) {
2331
- const filePath = join(DIST_DIR, subPath);
2332
- if (existsSync(filePath)) {
4487
+ if (subPath) {
4488
+ const filePath = resolveSafeDistPath(subPath);
4489
+ if (filePath && existsSync(filePath)) {
2333
4490
  sendFile(res, filePath, "no-cache");
2334
4491
  return true;
2335
4492
  }
@@ -2343,6 +4500,7 @@ export function createHttpHandler(config, client, getSnapshot, onboarding) {
2343
4500
  // Redirect to dashboard
2344
4501
  res.writeHead(302, {
2345
4502
  Location: "/orgx/live",
4503
+ ...SECURITY_HEADERS,
2346
4504
  ...CORS_HEADERS,
2347
4505
  });
2348
4506
  res.end();