@dadado/agent-kit-cli 4.8.0 → 4.8.2

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.
@@ -0,0 +1,917 @@
1
+ // dashboard/lib/guards.mjs
2
+ // Pure helpers for Mission Control snapshot redaction and serve lockdown (testable).
3
+
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { networkInterfaces } from "node:os";
6
+ import { resolve } from "node:path";
7
+
8
+ export const DEFAULT_HOST = "127.0.0.1";
9
+ /** Env key for the opt-in LAN broadcast session token. */
10
+ export const BROADCAST_TOKEN_ENV = "MISSION_CONTROL_TOKEN";
11
+ /**
12
+ * Env key for the repo whose `.cursor/`, git, and plans Mission Control snapshots.
13
+ * Static assets still load from the kit tree that contains `dashboard/`.
14
+ * When unset, the snapshot root is the kit tree (parent of `dashboard/`).
15
+ */
16
+ export const REPO_ROOT_ENV = "MISSION_CONTROL_REPO_ROOT";
17
+ /** Env keys that point at an agent-kit checkout with `dashboard/start.mjs` (consumer launch). */
18
+ export const KIT_ROOT_ENV_KEYS = Object.freeze(["MISSION_CONTROL_KIT_ROOT", "AGENT_KIT_HOME"]);
19
+ /** Minimum token length (refuse empty / weak). */
20
+ export const BROADCAST_TOKEN_MIN_LEN = 16;
21
+ /** Cookie name for same-origin broadcast auth after `?token=` boot. */
22
+ export const BROADCAST_TOKEN_COOKIE = "mc_token";
23
+
24
+ /** Default Mission Control listen port when `PORT` is unset and hashing is disabled. */
25
+ export const DEFAULT_PORT_BASE = 3333;
26
+ /**
27
+ * Size of the stable per-workspace port window: `DEFAULT_PORT_BASE` .. base+range-1.
28
+ * Preferred port = base + (hash(repoRoot) % range); collisions walk the ring.
29
+ */
30
+ export const DEFAULT_PORT_RANGE = 256;
31
+
32
+ /**
33
+ * Resolve the repository root Mission Control should snapshot.
34
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
35
+ * @param {string} kitRoot - absolute path to the kit tree (parent of `dashboard/`)
36
+ * @returns {string} absolute snapshot root
37
+ */
38
+ export function resolveSnapshotRepoRoot(env = process.env, kitRoot) {
39
+ const raw = env?.[REPO_ROOT_ENV];
40
+ if (typeof raw === "string" && raw.trim()) {
41
+ return resolve(raw.trim());
42
+ }
43
+ return resolve(kitRoot);
44
+ }
45
+
46
+ /**
47
+ * Normalize a repo root for hashing / equality (absolute, forward slashes, no trailing slash).
48
+ * @param {string} repoRoot
49
+ * @returns {string}
50
+ */
51
+ export function normalizeRepoRootKey(repoRoot) {
52
+ if (typeof repoRoot !== "string" || !repoRoot.trim()) return "";
53
+ let key = resolve(repoRoot.trim()).replace(/\\/g, "/");
54
+ if (key.length > 1 && key.endsWith("/")) key = key.slice(0, -1);
55
+ // macOS / Windows paths often differ only by case; treat as case-insensitive for port identity.
56
+ if (process.platform === "darwin" || process.platform === "win32") {
57
+ key = key.toLowerCase();
58
+ }
59
+ return key;
60
+ }
61
+
62
+ /**
63
+ * FNV-1a 32-bit hash of the normalized repo root (stable across process restarts).
64
+ * @param {string} repoRoot
65
+ * @returns {number} unsigned 32-bit
66
+ */
67
+ export function hashRepoRoot(repoRoot) {
68
+ const key = normalizeRepoRootKey(repoRoot);
69
+ let h = 2166136261;
70
+ for (let i = 0; i < key.length; i++) {
71
+ h ^= key.charCodeAt(i);
72
+ h = Math.imul(h, 16777619);
73
+ }
74
+ return h >>> 0;
75
+ }
76
+
77
+ /**
78
+ * Short hex id for log filenames (`mission-control-<id>.log`).
79
+ * @param {string} repoRoot
80
+ * @returns {string}
81
+ */
82
+ export function repoRootLogId(repoRoot) {
83
+ return hashRepoRoot(repoRoot).toString(16).padStart(8, "0").slice(0, 8);
84
+ }
85
+
86
+ /**
87
+ * Preferred listen port for a workspace (deterministic).
88
+ * @param {string} repoRoot
89
+ * @param {{ base?: number, range?: number }} [opts]
90
+ * @returns {number}
91
+ */
92
+ export function preferredPortForRepoRoot(repoRoot, opts = {}) {
93
+ const base = Number.isFinite(opts.base) ? opts.base : DEFAULT_PORT_BASE;
94
+ const range = Number.isFinite(opts.range) && opts.range > 0 ? opts.range : DEFAULT_PORT_RANGE;
95
+ return base + (hashRepoRoot(repoRoot) % range);
96
+ }
97
+
98
+ /**
99
+ * Ordered port candidates: preferred, then +1 wrapping within [base, base+range).
100
+ * @param {string} repoRoot
101
+ * @param {{ base?: number, range?: number }} [opts]
102
+ * @returns {number[]}
103
+ */
104
+ export function portCandidatesForRepoRoot(repoRoot, opts = {}) {
105
+ const base = Number.isFinite(opts.base) ? opts.base : DEFAULT_PORT_BASE;
106
+ const range = Number.isFinite(opts.range) && opts.range > 0 ? opts.range : DEFAULT_PORT_RANGE;
107
+ const preferred = preferredPortForRepoRoot(repoRoot, { base, range });
108
+ const out = [];
109
+ for (let i = 0; i < range; i++) {
110
+ out.push(base + ((preferred - base + i) % range));
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Compare two repo roots for Mission Control identity.
117
+ * @param {string | null | undefined} a
118
+ * @param {string | null | undefined} b
119
+ * @returns {boolean}
120
+ */
121
+ export function sameRepoRoot(a, b) {
122
+ if (a == null || b == null) return false;
123
+ const ka = normalizeRepoRootKey(String(a));
124
+ const kb = normalizeRepoRootKey(String(b));
125
+ return Boolean(ka && kb && ka === kb);
126
+ }
127
+
128
+ /**
129
+ * Pick a listen port for this workspace.
130
+ *
131
+ * - Explicit `envPort` (from `PORT`) wins: reuse if live root matches; error if foreign; start if free.
132
+ * - Otherwise walk hash candidates; reuse matching root; skip foreign/unknown listeners (never kill them).
133
+ *
134
+ * @param {object} args
135
+ * @param {string} args.repoRoot
136
+ * @param {string | number | undefined | null} [args.envPort] - raw `PORT` env
137
+ * @param {(port: number) => { listening: boolean, repoRoot: string | null }} args.probe
138
+ * @param {{ base?: number, range?: number }} [args.opts]
139
+ * @returns {{ port: number, reuse: boolean, explicit: boolean }}
140
+ */
141
+ export function resolveMissionControlPort({ repoRoot, envPort, probe, opts = {} }) {
142
+ const root = resolve(String(repoRoot || "").trim() || ".");
143
+ const raw =
144
+ envPort != null && String(envPort).trim() !== "" ? Number.parseInt(String(envPort), 10) : NaN;
145
+
146
+ if (Number.isFinite(raw) && raw > 0) {
147
+ const info = probe(raw);
148
+ if (!info.listening) {
149
+ return { port: raw, reuse: false, explicit: true };
150
+ }
151
+ if (sameRepoRoot(info.repoRoot, root)) {
152
+ return { port: raw, reuse: true, explicit: true };
153
+ }
154
+ const other = info.repoRoot ? info.repoRoot : "unknown process";
155
+ throw new Error(
156
+ `PORT ${raw} is already in use by ${other}. Unset PORT to auto-pick a per-workspace port, or stop that listener first. Mission Control will not kill another workspace.`,
157
+ );
158
+ }
159
+
160
+ const candidates = portCandidatesForRepoRoot(root, opts);
161
+ for (const port of candidates) {
162
+ const info = probe(port);
163
+ if (!info.listening) {
164
+ return { port, reuse: false, explicit: false };
165
+ }
166
+ if (sameRepoRoot(info.repoRoot, root)) {
167
+ return { port, reuse: true, explicit: false };
168
+ }
169
+ // Foreign or unknown listener: leave it alone, try next port.
170
+ }
171
+
172
+ const base = Number.isFinite(opts.base) ? opts.base : DEFAULT_PORT_BASE;
173
+ const range = Number.isFinite(opts.range) && opts.range > 0 ? opts.range : DEFAULT_PORT_RANGE;
174
+ throw new Error(
175
+ `No free Mission Control port in ${base}-${base + range - 1} for ${root}. Stop an unused instance or set PORT explicitly.`,
176
+ );
177
+ }
178
+
179
+ export const MAX_STRING = {
180
+ branch: 64,
181
+ lastCommit: 120,
182
+ terminalCwd: 200,
183
+ terminalCommand: 120,
184
+ processCommand: 80,
185
+ };
186
+
187
+ export const MAX_GIT_FILES = 50;
188
+ export const MAX_GIT_PATH = 240;
189
+
190
+ /** Repo-relative path safe to join onto a trusted root (no traversal / schemes). */
191
+ export function isSafeRepoRelativePath(relPath) {
192
+ if (typeof relPath !== "string") return false;
193
+ const p = relPath.trim().replace(/\\/g, "/");
194
+ if (!p || p.length > MAX_GIT_PATH) return false;
195
+ if (p.startsWith("/") || /^[A-Za-z]:\//.test(p)) return false;
196
+ if (p.includes("\0") || p.includes("://")) return false;
197
+ const parts = p.split("/");
198
+ if (parts.some((part) => part === "" || part === "." || part === "..")) return false;
199
+ return true;
200
+ }
201
+
202
+ export function resolveBindHost(envHost) {
203
+ return envHost || DEFAULT_HOST;
204
+ }
205
+
206
+ /**
207
+ * True when the listen host is loopback (not a LAN / all-interfaces bind).
208
+ * @param {string | undefined | null} host
209
+ */
210
+ export function isLoopbackBindHost(host) {
211
+ if (!host || typeof host !== "string") return true;
212
+ const h = host.trim().toLowerCase();
213
+ return h === DEFAULT_HOST || h === "localhost" || h === "::1";
214
+ }
215
+
216
+ /**
217
+ * @param {unknown} raw
218
+ * @returns {string}
219
+ */
220
+ export function normalizeAuthToken(raw) {
221
+ if (raw == null) return "";
222
+ return String(raw).trim();
223
+ }
224
+
225
+ /**
226
+ * @param {unknown} token
227
+ */
228
+ export function isValidBroadcastToken(token) {
229
+ return normalizeAuthToken(token).length >= BROADCAST_TOKEN_MIN_LEN;
230
+ }
231
+
232
+ /** Cryptographically random token suitable for MISSION_CONTROL_TOKEN. */
233
+ export function generateBroadcastToken() {
234
+ return randomBytes(24).toString("base64url");
235
+ }
236
+
237
+ /**
238
+ * Timing-safe equality for UTF-8 token strings.
239
+ * @param {unknown} a
240
+ * @param {unknown} b
241
+ */
242
+ export function tokensMatch(a, b) {
243
+ const left = Buffer.from(normalizeAuthToken(a), "utf8");
244
+ const right = Buffer.from(normalizeAuthToken(b), "utf8");
245
+ if (left.length === 0 || right.length === 0) return false;
246
+ if (left.length !== right.length) {
247
+ const pad = Buffer.alloc(left.length || 1);
248
+ timingSafeEqual(pad, pad);
249
+ return false;
250
+ }
251
+ return timingSafeEqual(left, right);
252
+ }
253
+
254
+ /**
255
+ * Resolve bind + token gate for Mission Control serve.
256
+ * Non-loopback bind requires a valid MISSION_CONTROL_TOKEN (no warn-only 0.0.0.0).
257
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
258
+ * @returns
259
+ * | { ok: true, host: string, tokenRequired: boolean, token: string | null, broadcast: boolean }
260
+ * | { ok: false, error: string }
261
+ */
262
+ export function resolveBroadcastAuth(env = process.env) {
263
+ const host = resolveBindHost(env?.HOST);
264
+ const loopback = isLoopbackBindHost(host);
265
+ const token = normalizeAuthToken(env?.[BROADCAST_TOKEN_ENV]);
266
+ if (!loopback) {
267
+ if (!isValidBroadcastToken(token)) {
268
+ return {
269
+ ok: false,
270
+ error: `Non-loopback bind (${host}) requires ${BROADCAST_TOKEN_ENV} (min ${BROADCAST_TOKEN_MIN_LEN} chars). Use /dashboard-broadcast or agent-kit dashboard-broadcast.`,
271
+ };
272
+ }
273
+ return { ok: true, host, tokenRequired: true, token, broadcast: true };
274
+ }
275
+ return { ok: true, host, tokenRequired: false, token: null, broadcast: false };
276
+ }
277
+
278
+ /**
279
+ * Extract session token from Authorization, header, query, or cookie.
280
+ * @param {{ headers?: Record<string, string | string[] | undefined> }} req
281
+ * @param {URL} url
282
+ */
283
+ export function extractRequestToken(req, url) {
284
+ const headers = req?.headers || {};
285
+ const authRaw = headers.authorization;
286
+ const auth = Array.isArray(authRaw) ? authRaw[0] : authRaw;
287
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
288
+ return auth.slice(7).trim();
289
+ }
290
+ const hdrRaw = headers["x-mission-control-token"];
291
+ const hdr = Array.isArray(hdrRaw) ? hdrRaw[0] : hdrRaw;
292
+ if (typeof hdr === "string" && hdr.trim()) return hdr.trim();
293
+
294
+ const q = url?.searchParams?.get("token");
295
+ if (q) return q.trim();
296
+
297
+ const cookieRaw = headers.cookie;
298
+ const cookie = Array.isArray(cookieRaw) ? cookieRaw[0] : cookieRaw;
299
+ if (typeof cookie === "string") {
300
+ const m = new RegExp(`(?:^|;\\s*)${BROADCAST_TOKEN_COOKIE}=([^;]+)`).exec(cookie);
301
+ if (m?.[1]) {
302
+ try {
303
+ return decodeURIComponent(m[1].trim());
304
+ } catch {
305
+ return m[1].trim();
306
+ }
307
+ }
308
+ }
309
+ return "";
310
+ }
311
+
312
+ /**
313
+ * Authorize a Mission Control HTTP request when token gate is on.
314
+ * @param {{ headers?: Record<string, string | string[] | undefined> }} req
315
+ * @param {URL} url
316
+ * @param {{ tokenRequired: boolean, expectedToken: string | null }} opts
317
+ */
318
+ export function authorizeMissionControlRequest(req, url, opts) {
319
+ if (!opts?.tokenRequired) return { ok: true, viaQuery: false };
320
+ const expected = normalizeAuthToken(opts.expectedToken);
321
+ if (!isValidBroadcastToken(expected)) {
322
+ return { ok: false, status: 401, error: "unauthorized" };
323
+ }
324
+ const provided = extractRequestToken(req, url);
325
+ if (!tokensMatch(provided, expected)) {
326
+ return { ok: false, status: 401, error: "unauthorized" };
327
+ }
328
+ const viaQuery = Boolean(url?.searchParams?.get("token"));
329
+ return { ok: true, viaQuery };
330
+ }
331
+
332
+ /**
333
+ * Set-Cookie header value for broadcast token (HttpOnly, SameSite=Strict).
334
+ * @param {string} token
335
+ */
336
+ export function broadcastAuthCookieHeader(token) {
337
+ const t = normalizeAuthToken(token);
338
+ return `${BROADCAST_TOKEN_COOKIE}=${encodeURIComponent(t)}; Path=/; HttpOnly; SameSite=Strict`;
339
+ }
340
+
341
+ /**
342
+ * Non-internal IPv4 addresses for printing LAN URLs (excludes loopback).
343
+ * @returns {string[]}
344
+ */
345
+ export function listLanIPv4Addresses() {
346
+ const nets = networkInterfaces();
347
+ const out = [];
348
+ for (const entries of Object.values(nets)) {
349
+ if (!entries) continue;
350
+ for (const net of entries) {
351
+ const family = net.family;
352
+ const v4 = family === "IPv4" || family === 4;
353
+ if (!v4 || net.internal) continue;
354
+ if (net.address && !out.includes(net.address)) out.push(net.address);
355
+ }
356
+ }
357
+ return out;
358
+ }
359
+
360
+ export function truncateStr(value, maxLen) {
361
+ if (value == null) return value;
362
+ const s = String(value);
363
+ return s.length <= maxLen ? s : `${s.slice(0, maxLen)}…`;
364
+ }
365
+
366
+ /** Parse `git status --short` into bounded file entries (paths only, no contents). */
367
+ export function parseGitStatusShort(output) {
368
+ if (!output || !String(output).trim()) {
369
+ return { files: [], total: 0, truncated: false };
370
+ }
371
+ const lines = String(output)
372
+ .split("\n")
373
+ .map((line) => line.replace(/\r$/, ""))
374
+ .filter((line) => line.trim().length > 0);
375
+ const files = [];
376
+ let truncated = false;
377
+
378
+ for (const line of lines) {
379
+ if (files.length >= MAX_GIT_FILES) {
380
+ truncated = true;
381
+ break;
382
+ }
383
+ if (line.length < 3) continue;
384
+
385
+ const status = line.slice(0, 2);
386
+ const rest = line.slice(2).trimStart();
387
+ if (!rest) continue;
388
+
389
+ let path = rest;
390
+ let oldPath = null;
391
+ if (rest.includes(" -> ")) {
392
+ const arrowIdx = rest.indexOf(" -> ");
393
+ oldPath = rest.slice(0, arrowIdx).trim();
394
+ path = rest.slice(arrowIdx + 4).trim() || oldPath;
395
+ }
396
+
397
+ const untracked = status === "??" || status[0] === "?" || status[1] === "?";
398
+ const staged = !untracked && status[0] !== " " && status[0] !== "?";
399
+ const unstaged = !untracked && status[1] !== " " && status[1] !== "?";
400
+
401
+ const entry = {
402
+ path: truncateStr(path, MAX_GIT_PATH),
403
+ status,
404
+ staged,
405
+ unstaged,
406
+ untracked,
407
+ };
408
+ if (oldPath) {
409
+ entry.oldPath = truncateStr(oldPath, MAX_GIT_PATH);
410
+ entry.renamed =
411
+ status[0] === "R" || status[1] === "R" || status[0] === "C" || status[1] === "C";
412
+ }
413
+
414
+ files.push(entry);
415
+ }
416
+
417
+ return { files, total: lines.length, truncated };
418
+ }
419
+
420
+ /** Repo-relative path for session prefs written by Mission Control Config. */
421
+ export const CONTEXT_CONFIG_REL = ".cursor/context/config.json";
422
+
423
+ /** Builtin Agent Persona ids accepted by the Config write API. */
424
+ export const CONFIG_PERSONA_IDS = Object.freeze(["autopilot", "night-shift", "ghost-runner"]);
425
+
426
+ /** Mode keys under agentPersona.modes that Config may edit. */
427
+ export const CONFIG_PERSONA_MODES = Object.freeze(["continue-plan", "run-plan", "cli-run-plan"]);
428
+
429
+ /** Allowed externalPlanReview.backend values. */
430
+ export const CONFIG_REVIEW_BACKENDS = Object.freeze(["claude"]);
431
+
432
+ /** Allowed externalPlanReview.mode values (audits arming path). */
433
+ export const CONFIG_REVIEW_MODES = Object.freeze(["paste", "autonomous"]);
434
+
435
+ /** Allowed externalPlanReview.preflight values (audits pre-flight on plan-run commands). */
436
+ export const CONFIG_REVIEW_PREFLIGHT = Object.freeze(["off", "warn", "block"]);
437
+
438
+ /**
439
+ * True when the remote address is loopback (IPv4, IPv6, or IPv4-mapped IPv6).
440
+ * @param {string | undefined | null} addr
441
+ */
442
+ export function isLoopbackAddress(addr) {
443
+ if (!addr || typeof addr !== "string") return false;
444
+ const a = addr.trim().toLowerCase();
445
+ if (a === "127.0.0.1" || a === "::1" || a === "localhost") return true;
446
+ if (a.startsWith("::ffff:")) {
447
+ const v4 = a.slice("::ffff:".length);
448
+ return v4 === "127.0.0.1" || v4.startsWith("127.");
449
+ }
450
+ return a.startsWith("127.");
451
+ }
452
+
453
+ /**
454
+ * Resolve and lock the session config path under repoRoot.
455
+ * @param {string} repoRoot
456
+ * @param {{ existsSync?: Function, realpathSync?: Function, mkdirSync?: Function }} [fsHooks]
457
+ * @returns {{ ok: true, path: string } | { ok: false, error: string }}
458
+ */
459
+ export function resolveContextConfigPath(repoRoot, fsHooks = {}) {
460
+ const exists = fsHooks.existsSync;
461
+ const realpath = fsHooks.realpathSync;
462
+ const mkdir = fsHooks.mkdirSync;
463
+ if (typeof repoRoot !== "string" || !repoRoot) {
464
+ return { ok: false, error: "invalid repo root" };
465
+ }
466
+ const abs = resolve(repoRoot, CONTEXT_CONFIG_REL);
467
+ const contextDir = resolve(repoRoot, ".cursor", "context");
468
+ const absNorm = abs.replace(/\\/g, "/");
469
+ if (!absNorm.endsWith("/.cursor/context/config.json")) {
470
+ return { ok: false, error: "path escape" };
471
+ }
472
+ try {
473
+ if (typeof mkdir === "function" && typeof exists === "function" && !exists(contextDir)) {
474
+ mkdir(contextDir, { recursive: true });
475
+ }
476
+ if (typeof realpath === "function" && typeof exists === "function" && exists(abs)) {
477
+ const fileReal = String(realpath(abs)).replace(/\\/g, "/");
478
+ const rootReal = String(realpath(repoRoot)).replace(/\\/g, "/");
479
+ if (!fileReal.startsWith(`${rootReal}/`)) {
480
+ return { ok: false, error: "path escape" };
481
+ }
482
+ if (!fileReal.endsWith("/.cursor/context/config.json")) {
483
+ return { ok: false, error: "path escape" };
484
+ }
485
+ return { ok: true, path: realpath(abs) };
486
+ }
487
+ } catch {
488
+ return { ok: false, error: "path escape" };
489
+ }
490
+ return { ok: true, path: abs };
491
+ }
492
+
493
+ /**
494
+ * Validate a Config write body. Unknown top-level keys are rejected.
495
+ * @param {unknown} body
496
+ * @returns {{ ok: true, patch: object } | { ok: false, error: string }}
497
+ */
498
+ export function validateConfigWriteBody(body) {
499
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
500
+ return { ok: false, error: "body must be a JSON object" };
501
+ }
502
+ const allowedTop = new Set([
503
+ "autoHandoff",
504
+ "interTickCooldownMs",
505
+ "externalPlanReview",
506
+ "fieldReportReviewCadence",
507
+ "agentPersona",
508
+ "updateCheck",
509
+ ]);
510
+ for (const key of Object.keys(body)) {
511
+ if (!allowedTop.has(key)) {
512
+ return { ok: false, error: `unknown key: ${key}` };
513
+ }
514
+ }
515
+
516
+ /** @type {Record<string, unknown>} */
517
+ const patch = {};
518
+
519
+ if ("autoHandoff" in body) {
520
+ if (typeof body.autoHandoff !== "boolean") {
521
+ return { ok: false, error: "autoHandoff must be boolean" };
522
+ }
523
+ patch.autoHandoff = body.autoHandoff;
524
+ }
525
+
526
+ if ("interTickCooldownMs" in body) {
527
+ const n = body.interTickCooldownMs;
528
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > 3_600_000) {
529
+ return { ok: false, error: "interTickCooldownMs must be an integer 0..3600000" };
530
+ }
531
+ patch.interTickCooldownMs = n;
532
+ }
533
+
534
+ if ("fieldReportReviewCadence" in body) {
535
+ const frc = body.fieldReportReviewCadence;
536
+ if (!frc || typeof frc !== "object" || Array.isArray(frc)) {
537
+ return { ok: false, error: "fieldReportReviewCadence must be an object" };
538
+ }
539
+ const frcAllowed = new Set(["enabled", "tickThreshold"]);
540
+ for (const key of Object.keys(frc)) {
541
+ if (!frcAllowed.has(key)) {
542
+ return { ok: false, error: `unknown fieldReportReviewCadence key: ${key}` };
543
+ }
544
+ }
545
+ /** @type {Record<string, unknown>} */
546
+ const frcPatch = {};
547
+ if ("enabled" in frc) {
548
+ if (typeof frc.enabled !== "boolean") {
549
+ return { ok: false, error: "fieldReportReviewCadence.enabled must be boolean" };
550
+ }
551
+ frcPatch.enabled = frc.enabled;
552
+ }
553
+ if ("tickThreshold" in frc) {
554
+ const n = frc.tickThreshold;
555
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 100) {
556
+ return {
557
+ ok: false,
558
+ error: "fieldReportReviewCadence.tickThreshold must be an integer 1..100",
559
+ };
560
+ }
561
+ frcPatch.tickThreshold = n;
562
+ }
563
+ if (Object.keys(frcPatch).length > 0) {
564
+ patch.fieldReportReviewCadence = frcPatch;
565
+ }
566
+ }
567
+
568
+ if ("externalPlanReview" in body) {
569
+ const epr = body.externalPlanReview;
570
+ if (!epr || typeof epr !== "object" || Array.isArray(epr)) {
571
+ return { ok: false, error: "externalPlanReview must be an object" };
572
+ }
573
+ const eprAllowed = new Set([
574
+ "enabled",
575
+ "backend",
576
+ "autoRemediate",
577
+ "offerOnExhausted",
578
+ "mode",
579
+ "midBatchAudits",
580
+ "preflight",
581
+ ]);
582
+ for (const key of Object.keys(epr)) {
583
+ if (!eprAllowed.has(key)) {
584
+ return { ok: false, error: `unknown externalPlanReview key: ${key}` };
585
+ }
586
+ }
587
+ /** @type {Record<string, unknown>} */
588
+ const eprPatch = {};
589
+ if ("enabled" in epr) {
590
+ if (typeof epr.enabled !== "boolean") {
591
+ return { ok: false, error: "externalPlanReview.enabled must be boolean" };
592
+ }
593
+ eprPatch.enabled = epr.enabled;
594
+ }
595
+ if ("backend" in epr) {
596
+ if (typeof epr.backend !== "string" || !CONFIG_REVIEW_BACKENDS.includes(epr.backend)) {
597
+ return { ok: false, error: "externalPlanReview.backend must be a known backend" };
598
+ }
599
+ eprPatch.backend = epr.backend;
600
+ }
601
+ if ("autoRemediate" in epr) {
602
+ if (typeof epr.autoRemediate !== "boolean") {
603
+ return { ok: false, error: "externalPlanReview.autoRemediate must be boolean" };
604
+ }
605
+ eprPatch.autoRemediate = epr.autoRemediate;
606
+ }
607
+ if ("offerOnExhausted" in epr) {
608
+ if (typeof epr.offerOnExhausted !== "boolean") {
609
+ return { ok: false, error: "externalPlanReview.offerOnExhausted must be boolean" };
610
+ }
611
+ eprPatch.offerOnExhausted = epr.offerOnExhausted;
612
+ }
613
+ if ("mode" in epr) {
614
+ if (typeof epr.mode !== "string" || !CONFIG_REVIEW_MODES.includes(epr.mode)) {
615
+ return { ok: false, error: "externalPlanReview.mode must be paste or autonomous" };
616
+ }
617
+ eprPatch.mode = epr.mode;
618
+ }
619
+ if ("midBatchAudits" in epr) {
620
+ if (typeof epr.midBatchAudits !== "boolean") {
621
+ return { ok: false, error: "externalPlanReview.midBatchAudits must be boolean" };
622
+ }
623
+ eprPatch.midBatchAudits = epr.midBatchAudits;
624
+ }
625
+ if ("preflight" in epr) {
626
+ if (typeof epr.preflight !== "string" || !CONFIG_REVIEW_PREFLIGHT.includes(epr.preflight)) {
627
+ return { ok: false, error: "externalPlanReview.preflight must be off, warn, or block" };
628
+ }
629
+ eprPatch.preflight = epr.preflight;
630
+ }
631
+ if (Object.keys(eprPatch).length > 0) {
632
+ patch.externalPlanReview = eprPatch;
633
+ }
634
+ }
635
+
636
+ if ("agentPersona" in body) {
637
+ const ap = body.agentPersona;
638
+ if (!ap || typeof ap !== "object" || Array.isArray(ap)) {
639
+ return { ok: false, error: "agentPersona must be an object" };
640
+ }
641
+ const apAllowed = new Set(["default", "modes"]);
642
+ for (const key of Object.keys(ap)) {
643
+ if (!apAllowed.has(key)) {
644
+ return { ok: false, error: `unknown agentPersona key: ${key}` };
645
+ }
646
+ }
647
+ /** @type {Record<string, unknown>} */
648
+ const apPatch = {};
649
+ if ("default" in ap) {
650
+ if (typeof ap.default !== "string" || !CONFIG_PERSONA_IDS.includes(ap.default)) {
651
+ return { ok: false, error: "agentPersona.default must be a builtin persona id" };
652
+ }
653
+ apPatch.default = ap.default;
654
+ }
655
+ if ("modes" in ap) {
656
+ const modes = ap.modes;
657
+ if (!modes || typeof modes !== "object" || Array.isArray(modes)) {
658
+ return { ok: false, error: "agentPersona.modes must be an object" };
659
+ }
660
+ /** @type {Record<string, string>} */
661
+ const modesPatch = {};
662
+ for (const [mode, persona] of Object.entries(modes)) {
663
+ if (!CONFIG_PERSONA_MODES.includes(mode)) {
664
+ return { ok: false, error: `unknown agentPersona.modes key: ${mode}` };
665
+ }
666
+ if (typeof persona !== "string" || !CONFIG_PERSONA_IDS.includes(persona)) {
667
+ return { ok: false, error: `agentPersona.modes.${mode} must be a builtin persona id` };
668
+ }
669
+ modesPatch[mode] = persona;
670
+ }
671
+ if (Object.keys(modesPatch).length > 0) {
672
+ apPatch.modes = modesPatch;
673
+ }
674
+ }
675
+ if (Object.keys(apPatch).length > 0) {
676
+ patch.agentPersona = apPatch;
677
+ }
678
+ }
679
+
680
+ if ("updateCheck" in body) {
681
+ const uc = body.updateCheck;
682
+ if (!uc || typeof uc !== "object" || Array.isArray(uc)) {
683
+ return { ok: false, error: "updateCheck must be an object" };
684
+ }
685
+ // lastCheckedAt is stamped by CLI/hooks; updateApply.auto is never writable via MC.
686
+ const ucAllowed = new Set(["enabled", "intervalDays"]);
687
+ for (const key of Object.keys(uc)) {
688
+ if (!ucAllowed.has(key)) {
689
+ return { ok: false, error: `unknown updateCheck key: ${key}` };
690
+ }
691
+ }
692
+ /** @type {Record<string, unknown>} */
693
+ const ucPatch = {};
694
+ if ("enabled" in uc) {
695
+ if (typeof uc.enabled !== "boolean") {
696
+ return { ok: false, error: "updateCheck.enabled must be boolean" };
697
+ }
698
+ ucPatch.enabled = uc.enabled;
699
+ }
700
+ if ("intervalDays" in uc) {
701
+ const n = uc.intervalDays;
702
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 365) {
703
+ return { ok: false, error: "updateCheck.intervalDays must be an integer 1..365" };
704
+ }
705
+ ucPatch.intervalDays = n;
706
+ }
707
+ if (Object.keys(ucPatch).length > 0) {
708
+ patch.updateCheck = ucPatch;
709
+ }
710
+ }
711
+
712
+ if (Object.keys(patch).length === 0) {
713
+ return { ok: false, error: "no editable fields in body" };
714
+ }
715
+ return { ok: true, patch };
716
+ }
717
+
718
+ /**
719
+ * Merge an allowlisted patch into existing config without wiping other nests.
720
+ * @param {object} existing
721
+ * @param {object} patch
722
+ */
723
+ export function mergeConfigAllowlist(existing, patch) {
724
+ const base =
725
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
726
+ if ("autoHandoff" in patch) base.autoHandoff = patch.autoHandoff;
727
+ if ("interTickCooldownMs" in patch) base.interTickCooldownMs = patch.interTickCooldownMs;
728
+ if (patch.fieldReportReviewCadence && typeof patch.fieldReportReviewCadence === "object") {
729
+ const prev =
730
+ base.fieldReportReviewCadence && typeof base.fieldReportReviewCadence === "object"
731
+ ? { ...base.fieldReportReviewCadence }
732
+ : {};
733
+ base.fieldReportReviewCadence = { ...prev, ...patch.fieldReportReviewCadence };
734
+ }
735
+ if (patch.externalPlanReview && typeof patch.externalPlanReview === "object") {
736
+ const prev =
737
+ base.externalPlanReview && typeof base.externalPlanReview === "object"
738
+ ? { ...base.externalPlanReview }
739
+ : {};
740
+ base.externalPlanReview = { ...prev, ...patch.externalPlanReview };
741
+ }
742
+ if (patch.agentPersona && typeof patch.agentPersona === "object") {
743
+ const prev =
744
+ base.agentPersona && typeof base.agentPersona === "object" ? { ...base.agentPersona } : {};
745
+ const next = { ...prev };
746
+ if ("default" in patch.agentPersona) next.default = patch.agentPersona.default;
747
+ if (patch.agentPersona.modes && typeof patch.agentPersona.modes === "object") {
748
+ const prevModes =
749
+ prev.modes && typeof prev.modes === "object" && !Array.isArray(prev.modes)
750
+ ? { ...prev.modes }
751
+ : {};
752
+ next.modes = { ...prevModes, ...patch.agentPersona.modes };
753
+ }
754
+ base.agentPersona = next;
755
+ }
756
+ if (patch.updateCheck && typeof patch.updateCheck === "object") {
757
+ const prev =
758
+ base.updateCheck && typeof base.updateCheck === "object" ? { ...base.updateCheck } : {};
759
+ base.updateCheck = { ...prev, ...patch.updateCheck };
760
+ }
761
+ return base;
762
+ }
763
+
764
+ /** Export only safe, UI-relevant config fields (no full nested onboarding checks). */
765
+ export function allowlistConfig(raw) {
766
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
767
+ return { error: "invalid" };
768
+ }
769
+ const summary = {};
770
+ if (typeof raw.onboarded === "boolean") summary.onboarded = raw.onboarded;
771
+ if (typeof raw.autoHandoff === "boolean") summary.autoHandoff = raw.autoHandoff;
772
+ if (typeof raw.interTickCooldownMs === "number") {
773
+ summary.interTickCooldownMs = raw.interTickCooldownMs;
774
+ }
775
+ if (raw.fieldReportReviewCadence && typeof raw.fieldReportReviewCadence === "object") {
776
+ const frc = {};
777
+ if (typeof raw.fieldReportReviewCadence.enabled === "boolean") {
778
+ frc.enabled = raw.fieldReportReviewCadence.enabled;
779
+ }
780
+ if (typeof raw.fieldReportReviewCadence.tickThreshold === "number") {
781
+ frc.tickThreshold = raw.fieldReportReviewCadence.tickThreshold;
782
+ }
783
+ if (Object.keys(frc).length > 0) summary.fieldReportReviewCadence = frc;
784
+ }
785
+ if (raw.updateCheck && typeof raw.updateCheck === "object") {
786
+ const uc = {};
787
+ if (typeof raw.updateCheck.enabled === "boolean") uc.enabled = raw.updateCheck.enabled;
788
+ if (typeof raw.updateCheck.intervalDays === "number") {
789
+ uc.intervalDays = raw.updateCheck.intervalDays;
790
+ }
791
+ if (Object.keys(uc).length > 0) summary.updateCheck = uc;
792
+ }
793
+ if (raw.onboarding && typeof raw.onboarding === "object") {
794
+ summary.onboarding = {
795
+ status: typeof raw.onboarding.status === "string" ? raw.onboarding.status : "unknown",
796
+ contractVersion: raw.onboarding.contractVersion,
797
+ };
798
+ }
799
+ if (raw.externalPlanReview && typeof raw.externalPlanReview === "object") {
800
+ const epr = { enabled: !!raw.externalPlanReview.enabled };
801
+ if (typeof raw.externalPlanReview.backend === "string") {
802
+ epr.backend = truncateStr(raw.externalPlanReview.backend, 64);
803
+ }
804
+ if (typeof raw.externalPlanReview.autoRemediate === "boolean") {
805
+ epr.autoRemediate = raw.externalPlanReview.autoRemediate;
806
+ }
807
+ if (typeof raw.externalPlanReview.offerOnExhausted === "boolean") {
808
+ epr.offerOnExhausted = raw.externalPlanReview.offerOnExhausted;
809
+ }
810
+ if (typeof raw.externalPlanReview.mode === "string") {
811
+ epr.mode = truncateStr(raw.externalPlanReview.mode, 32);
812
+ }
813
+ if (typeof raw.externalPlanReview.midBatchAudits === "boolean") {
814
+ epr.midBatchAudits = raw.externalPlanReview.midBatchAudits;
815
+ }
816
+ if (typeof raw.externalPlanReview.preflight === "string") {
817
+ epr.preflight = truncateStr(raw.externalPlanReview.preflight, 16);
818
+ }
819
+ summary.externalPlanReview = epr;
820
+ }
821
+ if (raw.agentPersona && typeof raw.agentPersona === "object") {
822
+ const modes = {};
823
+ if (raw.agentPersona.modes && typeof raw.agentPersona.modes === "object") {
824
+ for (const [mode, persona] of Object.entries(raw.agentPersona.modes)) {
825
+ modes[mode] = truncateStr(persona, 64);
826
+ }
827
+ }
828
+ summary.agentPersona = {
829
+ default: truncateStr(raw.agentPersona.default, 64),
830
+ modes,
831
+ };
832
+ } else if (raw.workspaceSkin && typeof raw.workspaceSkin === "object") {
833
+ // Legacy key: surface as agentPersona for Mission Control consumers.
834
+ const modes = {};
835
+ if (raw.workspaceSkin.modes && typeof raw.workspaceSkin.modes === "object") {
836
+ for (const [mode, persona] of Object.entries(raw.workspaceSkin.modes)) {
837
+ modes[mode] = truncateStr(persona, 64);
838
+ }
839
+ }
840
+ summary.agentPersona = {
841
+ default: truncateStr(raw.workspaceSkin.default, 64),
842
+ modes,
843
+ };
844
+ }
845
+ return summary;
846
+ }
847
+
848
+ export function isAllowedOrigin(origin, port) {
849
+ if (!origin) return false;
850
+ try {
851
+ const url = new URL(origin);
852
+ const resolvedPort = url.port || (url.protocol === "https:" ? "443" : "80");
853
+ return (
854
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
855
+ resolvedPort === String(port)
856
+ );
857
+ } catch {
858
+ return false;
859
+ }
860
+ }
861
+
862
+ export function applyCorsHeaders(req, res, port) {
863
+ const origin = req.headers?.origin;
864
+ if (isAllowedOrigin(origin, port)) {
865
+ res.setHeader("Access-Control-Allow-Origin", origin);
866
+ res.setHeader("Vary", "Origin");
867
+ return true;
868
+ }
869
+ return false;
870
+ }
871
+
872
+ export function isUnderDashboard(resolvedPath, dashboardReal) {
873
+ return resolvedPath === dashboardReal || resolvedPath.startsWith(`${dashboardReal}/`);
874
+ }
875
+
876
+ /**
877
+ * Resolve a static pathname to an absolute file under dashboardReal, or null if blocked.
878
+ * fs hooks default to node:fs for production; tests may inject mocks.
879
+ */
880
+ export function resolveDashboardStatic(
881
+ pathname,
882
+ { dashboardDir, dashboardReal, existsSync, realpathSync },
883
+ ) {
884
+ let rel = pathname;
885
+ if (rel === "/" || rel === "") {
886
+ rel = "/dashboard.html";
887
+ }
888
+
889
+ if (!rel.startsWith("/") || rel.includes("..") || rel.includes("\\")) {
890
+ return null;
891
+ }
892
+
893
+ for (const segment of rel.split("/").filter(Boolean)) {
894
+ if (segment.startsWith(".")) {
895
+ return null;
896
+ }
897
+ }
898
+
899
+ const candidate = resolve(dashboardDir, `.${rel}`);
900
+ if (!isUnderDashboard(candidate, dashboardReal)) {
901
+ return null;
902
+ }
903
+
904
+ if (!existsSync(candidate)) {
905
+ return null;
906
+ }
907
+
908
+ try {
909
+ const fileReal = realpathSync(candidate);
910
+ if (!isUnderDashboard(fileReal, dashboardReal)) {
911
+ return null;
912
+ }
913
+ return fileReal;
914
+ } catch {
915
+ return null;
916
+ }
917
+ }