@edspencer/paddock 0.66.0 → 0.66.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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +8 -0
  3. package/packages/server/dist/app.js +4 -1
  4. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  5. package/packages/server/dist/herdctl-jobs.js +94 -0
  6. package/packages/server/dist/herdctl.d.ts +46 -0
  7. package/packages/server/dist/herdctl.js +58 -0
  8. package/packages/server/dist/instance-config.d.ts +56 -5
  9. package/packages/server/dist/instance-config.js +206 -24
  10. package/packages/server/dist/project-paths.d.ts +46 -1
  11. package/packages/server/dist/project-paths.js +66 -2
  12. package/packages/server/dist/project-types.d.ts +23 -0
  13. package/packages/server/dist/project-types.js +35 -0
  14. package/packages/server/dist/projects.d.ts +5 -2
  15. package/packages/server/dist/projects.js +71 -17
  16. package/packages/server/dist/queued-message.d.ts +87 -0
  17. package/packages/server/dist/queued-message.js +214 -1
  18. package/packages/server/dist/routes/chats.js +27 -8
  19. package/packages/server/dist/routes/meta.js +32 -5
  20. package/packages/server/dist/routes/projects.js +71 -7
  21. package/packages/server/dist/session-hub.d.ts +69 -1
  22. package/packages/server/dist/session-hub.js +73 -1
  23. package/packages/server/dist/turn-interlock.js +8 -0
  24. package/packages/server/dist/ws-protocol.d.ts +128 -5
  25. package/packages/server/dist/ws-protocol.js +30 -13
  26. package/packages/server/dist/ws.d.ts +0 -52
  27. package/packages/server/dist/ws.js +545 -127
  28. package/packages/server/package.json +1 -1
  29. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  30. package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
  32. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  33. package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  34. package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
  35. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  36. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  37. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  38. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  39. package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  47. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  48. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  49. package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
  50. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  51. package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  57. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  58. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  59. package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
  60. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  61. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  62. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  63. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  64. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  65. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  66. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  67. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  68. package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
  69. package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
  70. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  71. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  72. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  73. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
  87. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
  88. package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
  91. package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
  92. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  93. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  94. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
@@ -29,7 +29,8 @@
29
29
  */
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
32
- import { parseDocument } from "yaml";
32
+ import { createHash } from "node:crypto";
33
+ import { parse, parseDocument } from "yaml";
33
34
  import { DRIVE_MODES, DEFAULT_DRIVE_MODE, MODELS, isKnownModel } from "./models.js";
34
35
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
35
36
  import { DEFAULT_RECOVERY } from "./recovery-config.js";
@@ -57,32 +58,82 @@ export const GROUPS = [
57
58
  ];
58
59
  // --- coercion helpers -------------------------------------------------------
59
60
  const asBool = (raw) => typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: "must be a boolean" };
61
+ /**
62
+ * Parse a JSON patch value that is meant to be a number, WITHOUT `Number()`'s
63
+ * coercions (issue #723). `Number()` happily turns `null` into `0`, `true` into
64
+ * `1` and `[7]` into `7` — so `{"recovery.maxRetries": null}`, documented to
65
+ * CLEAR the override, used to write a very meaningful `0` instead. Only a real
66
+ * number, or a non-blank numeric string (what an `<input type=number>` sends),
67
+ * counts; everything else is `NaN` and gets rejected by the callers below.
68
+ */
69
+ const numeric = (raw) => {
70
+ if (typeof raw === "number")
71
+ return raw;
72
+ if (typeof raw === "string" && raw.trim() !== "")
73
+ return Number(raw.trim());
74
+ return NaN;
75
+ };
76
+ /**
77
+ * Ceiling for the numeric fields. Nothing downstream bounds these — a paste-o of
78
+ * `20000000` into a millisecond timeout is a 231-day debounce, and a token budget
79
+ * of 1e15 disables curation silently. A billion is far past any deliberate value
80
+ * (≈31 years in ms) and keeps every one of them a safe integer.
81
+ */
82
+ const MAX_NUMBER = 1e9;
83
+ /**
84
+ * Ceiling for a plain `string` field, in characters. These are names, paths and
85
+ * URLs; without a bound a 200 KB paste into `brand.name` becomes a 200 KB
86
+ * `paddock.config.yaml` that every boot must parse. The prompt-shaped `text`
87
+ * fields have their own, much larger {@link MAX_PROMPT_CHARS}.
88
+ */
89
+ const MAX_STRING_CHARS = 1024;
60
90
  const posInt = (raw) => {
61
- const n = Number(raw);
62
- return Number.isFinite(n) && Number.isInteger(n) && n > 0
91
+ const n = numeric(raw);
92
+ return Number.isInteger(n) && n > 0 && n <= MAX_NUMBER
63
93
  ? { ok: true, value: n }
64
- : { ok: false, error: "must be a positive integer" };
94
+ : { ok: false, error: `must be a positive integer (at most ${MAX_NUMBER})` };
65
95
  };
96
+ /** A non-negative integer, OR null/empty-string to clear the override (#723). */
66
97
  const nonNegInt = (raw) => {
67
- const n = Number(raw);
68
- return Number.isFinite(n) && Number.isInteger(n) && n >= 0
98
+ if (raw === null || raw === "" || raw === undefined)
99
+ return { ok: true, value: null };
100
+ const n = numeric(raw);
101
+ return Number.isInteger(n) && n >= 0 && n <= MAX_NUMBER
69
102
  ? { ok: true, value: n }
70
- : { ok: false, error: "must be a non-negative integer" };
103
+ : {
104
+ ok: false,
105
+ error: `must be a non-negative integer up to ${MAX_NUMBER} (or blank to use the default)`,
106
+ };
71
107
  };
72
108
  /** A non-negative number, OR null/empty-string to clear the override. */
73
109
  const optNonNegNumber = (raw) => {
74
110
  if (raw === null || raw === "" || raw === undefined)
75
111
  return { ok: true, value: null };
76
- const n = Number(raw);
77
- return Number.isFinite(n) && n >= 0
112
+ const n = numeric(raw);
113
+ return Number.isFinite(n) && n >= 0 && n <= MAX_NUMBER
78
114
  ? { ok: true, value: n }
79
- : { ok: false, error: "must be a non-negative number (or blank to use the default)" };
115
+ : { ok: false, error: `must be a non-negative number up to ${MAX_NUMBER} (or blank to use the default)` };
116
+ };
117
+ const nonEmptyString = (raw) => {
118
+ if (typeof raw !== "string")
119
+ return { ok: false, error: "must be a non-empty string" };
120
+ const trimmed = raw.trim();
121
+ if (trimmed.length === 0)
122
+ return { ok: false, error: "must be a non-empty string" };
123
+ if (trimmed.length > MAX_STRING_CHARS) {
124
+ return { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
125
+ }
126
+ return { ok: true, value: trimmed };
80
127
  };
81
- const nonEmptyString = (raw) => typeof raw === "string" && raw.trim().length > 0
82
- ? { ok: true, value: raw.trim() }
83
- : { ok: false, error: "must be a non-empty string" };
84
128
  /** A string that may be blank (blank clears the override → falls back to default). */
85
- const optString = (raw) => typeof raw === "string" ? { ok: true, value: raw.trim() } : { ok: false, error: "must be a string" };
129
+ const optString = (raw) => {
130
+ if (typeof raw !== "string")
131
+ return { ok: false, error: "must be a string" };
132
+ const trimmed = raw.trim();
133
+ return trimmed.length <= MAX_STRING_CHARS
134
+ ? { ok: true, value: trimmed }
135
+ : { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
136
+ };
86
137
  /**
87
138
  * Upper bound on a prompt-shaped field, in characters. Nothing enforces a limit
88
139
  * downstream — the text is concatenated into the system prompt of every single
@@ -120,17 +171,33 @@ const oneOf = (values) => (raw) => typeof raw === "string" && values.includes(ra
120
171
  ? { ok: true, value: raw }
121
172
  : { ok: false, error: `must be one of: ${values.join(", ")}` };
122
173
  const spawnDepth = (raw) => {
123
- const n = Number(raw);
174
+ // Same shape as `nonNegInt`, for the same #723 reason: `Number(null)` is 0,
175
+ // which IS a valid depth — so "clear this override" wrote the one depth that
176
+ // switches every child's self-MCP off. Depth 0 stays settable; it just has to
177
+ // be asked for.
178
+ if (raw === null || raw === "" || raw === undefined)
179
+ return { ok: true, value: null };
180
+ const n = numeric(raw);
124
181
  return isValidMaxSpawnDepth(n)
125
182
  ? { ok: true, value: n }
126
- : { ok: false, error: "must be a small non-negative integer" };
183
+ : { ok: false, error: "must be a small non-negative integer (or blank to use the default)" };
127
184
  };
128
185
  const hexColor = (raw) => typeof raw === "string" && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw.trim())
129
186
  ? { ok: true, value: raw.trim() }
130
187
  : { ok: false, error: "must be a hex color like #c2603c" };
188
+ /** Bounds on a free-form list field, for the same reason as {@link MAX_STRING_CHARS}. */
189
+ const MAX_LIST_ITEMS = 64;
190
+ const MAX_LIST_ITEM_CHARS = 256;
131
191
  const stringList = (raw) => {
132
192
  const list = sanitizeAllowedTypes(raw);
133
- return list ? { ok: true, value: list } : { ok: false, error: "must be a non-empty list of type/extension strings" };
193
+ if (!list)
194
+ return { ok: false, error: "must be a non-empty list of type/extension strings" };
195
+ if (list.length > MAX_LIST_ITEMS)
196
+ return { ok: false, error: `must have at most ${MAX_LIST_ITEMS} entries` };
197
+ if (list.some((s) => s.length > MAX_LIST_ITEM_CHARS)) {
198
+ return { ok: false, error: `each entry must be at most ${MAX_LIST_ITEM_CHARS} characters` };
199
+ }
200
+ return { ok: true, value: list };
134
201
  };
135
202
  /**
136
203
  * Coerce a raw model allow-list (issue #457 Step 2) into a de-duped list of
@@ -290,6 +357,55 @@ function envOverride(envVars, whenDefined = false) {
290
357
  }
291
358
  return undefined;
292
359
  }
360
+ /**
361
+ * Read + parse `paddock.config.yaml` for the GET path. Never throws: a missing
362
+ * file is the normal case, and a malformed one must not take down the screen
363
+ * that exists to fix it (the boot loader is the strict reader — see
364
+ * `loadConfigFile`).
365
+ */
366
+ function readConfigFileSnapshot(configPath) {
367
+ let raw;
368
+ try {
369
+ raw = fs.readFileSync(configPath, "utf8");
370
+ }
371
+ catch (err) {
372
+ if (err.code === "ENOENT")
373
+ return { data: null, version: null };
374
+ return { data: null, version: null, error: `failed to read ${configPath}: ${err.message}` };
375
+ }
376
+ const version = createHash("sha256").update(raw).digest("hex").slice(0, 16);
377
+ let parsed;
378
+ try {
379
+ parsed = parse(raw);
380
+ }
381
+ catch (err) {
382
+ return { data: null, version, error: `failed to parse ${configPath}: ${err.message}` };
383
+ }
384
+ // Empty / comments-only file → no overrides, same reading as loadConfigFile.
385
+ if (parsed === null || parsed === undefined)
386
+ return { data: {}, version };
387
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
388
+ return { data: null, version, error: `${configPath} is not a YAML mapping` };
389
+ }
390
+ return { data: parsed, version };
391
+ }
392
+ /**
393
+ * The current fingerprint of the config file (`null` when absent) — the PUT
394
+ * path's half of the optimistic-concurrency check. Deliberately re-reads rather
395
+ * than trusting a cached value: the point is to notice a write we did not make.
396
+ */
397
+ export function instanceConfigVersion(configPath) {
398
+ return readConfigFileSnapshot(configPath).version;
399
+ }
400
+ /** Structural equality for the value shapes a field can hold (scalars + string lists). */
401
+ function valuesEqual(a, b) {
402
+ if (Array.isArray(a) || Array.isArray(b)) {
403
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
404
+ return false;
405
+ return a.every((v, i) => v === b[i]);
406
+ }
407
+ return a === b;
408
+ }
293
409
  /**
294
410
  * Default filename for the instance-config file, resolved under the data dir.
295
411
  * Kept in sync with config.ts's private constant (both point at the same file);
@@ -310,9 +426,25 @@ export function instanceConfigPath(cfg) {
310
426
  return path.join(cfg.dataDir, DEFAULT_CONFIG_FILENAME);
311
427
  }
312
428
  /**
313
- * Build the grouped GET DTO from the resolved (frozen) config + live env. Each
314
- * field reports its current `value`, built-in `default`, and whether an env var
315
- * currently shadows it (so the UI renders env-shadowed fields read-only).
429
+ * Build the grouped GET DTO from the resolved (frozen) config, the live env AND
430
+ * the config file on disk. Each field reports:
431
+ *
432
+ * - `value` — EFFECTIVE now, out of the frozen boot config;
433
+ * - `pendingValue` — what a restart would resolve, out of the file this instant;
434
+ * - `pendingRestart` — those two differ.
435
+ *
436
+ * Reading the file is the load-bearing part (#722). While this built the DTO
437
+ * from the frozen config alone, `GET` could not observe ANY write — including
438
+ * the one the caller had just made — so a successful save appeared to revert,
439
+ * two tabs clobbered each other with nothing able to notice, and
440
+ * `restartRequired` had no choice but to be a hardcoded `false`.
441
+ *
442
+ * Pending values are computed for EDITABLE, non-env-shadowed fields only:
443
+ * - an env-shadowed field resolves to the same env value after a restart, so
444
+ * the file cannot make it diverge;
445
+ * - the read-only `advanced` bindings are normalised at boot (`dataDir` and
446
+ * friends are canonicalised, `port` is `Number()`-ed), so comparing them
447
+ * against raw file text would manufacture divergence that isn't there.
316
448
  */
317
449
  export function buildInstanceConfig(cfg) {
318
450
  const groups = GROUPS.map((g) => ({
@@ -322,9 +454,31 @@ export function buildInstanceConfig(cfg) {
322
454
  fields: [],
323
455
  }));
324
456
  const groupById = new Map(groups.map((g) => [g.id, g]));
457
+ const configPath = instanceConfigPath(cfg);
458
+ const file = readConfigFileSnapshot(configPath);
459
+ let restartRequired = false;
325
460
  for (const f of FIELDS) {
326
461
  const shadow = envOverride(f.envVars, f.envShadowWhenDefined);
327
462
  const raw = readPath(cfg, f.key);
463
+ // `undefined` (e.g. sweepMinIntervalMs unset, optional endpoint) → null so
464
+ // it JSON-serializes as an explicit absence rather than dropping the key.
465
+ const value = raw === undefined ? null : raw;
466
+ let pendingValue = value;
467
+ let pendingRestart = false;
468
+ if (f.editable && shadow === undefined && file.data) {
469
+ // Absent, or present-but-null (`debounceMs:` with nothing after it), both
470
+ // mean "no override" — `fileOr`/`fileOpt` degrade a null to the default.
471
+ const fileRaw = readPath(file.data, f.key);
472
+ pendingValue = fileRaw == null ? (f.default ?? null) : coerceForDisplay(f, fileRaw);
473
+ // A field the frozen config leaves unset IS its built-in default (that is
474
+ // what `default` documents), so normalise before comparing — otherwise
475
+ // e.g. `models`, unset and therefore null, would read as forever diverging
476
+ // from the catalog list the file's absence implies.
477
+ const effective = value === null ? (f.default ?? null) : value;
478
+ pendingRestart = !valuesEqual(pendingValue, effective);
479
+ if (pendingRestart)
480
+ restartRequired = true;
481
+ }
328
482
  const dto = {
329
483
  key: f.key,
330
484
  group: f.group,
@@ -332,9 +486,9 @@ export function buildInstanceConfig(cfg) {
332
486
  help: f.help,
333
487
  type: f.type,
334
488
  enumValues: f.enumValues,
335
- // `undefined` (e.g. sweepMinIntervalMs unset, optional endpoint) → null so
336
- // it JSON-serializes as an explicit absence rather than dropping the key.
337
- value: raw === undefined ? null : raw,
489
+ value,
490
+ pendingValue,
491
+ pendingRestart,
338
492
  default: f.default,
339
493
  editable: f.editable,
340
494
  sensitive: f.sensitive ?? false,
@@ -343,7 +497,26 @@ export function buildInstanceConfig(cfg) {
343
497
  };
344
498
  groupById.get(f.group)?.fields.push(dto);
345
499
  }
346
- return { groups, configPath: instanceConfigPath(cfg), restartRequired: false };
500
+ return {
501
+ groups,
502
+ configPath,
503
+ restartRequired,
504
+ configVersion: file.version,
505
+ ...(file.error ? { configFileError: file.error } : {}),
506
+ };
507
+ }
508
+ /**
509
+ * Normalise a value read back out of the file into the shape the writer would
510
+ * have produced, so a hand-typed `"1234"` doesn't read as diverging from a
511
+ * written `1234`. A value the field's own validator rejects is reported
512
+ * verbatim: the file really does say that, and the screen showing it is how an
513
+ * operator finds out (the boot loader would degrade it to the default).
514
+ */
515
+ function coerceForDisplay(f, raw) {
516
+ if (!f.coerce)
517
+ return raw;
518
+ const res = f.coerce(raw);
519
+ return res.ok ? res.value : raw;
347
520
  }
348
521
  /** A rejected PUT: which field failed and why (surfaced as a 400). */
349
522
  export class InstanceConfigError extends Error {
@@ -359,6 +532,7 @@ export class InstanceConfigError extends Error {
359
532
  * {@link InstanceConfigError} on the first offending field:
360
533
  * - an unknown key,
361
534
  * - a read-only / non-editable key,
535
+ * - a key an env var currently shadows,
362
536
  * - a value the field's `coerce` rejects.
363
537
  * Returns the coerced `{ key, value }` pairs ready to write. A field whose value
364
538
  * is `null` (an editable optional being cleared) is returned with `value: null`
@@ -373,6 +547,14 @@ export function validatePatch(patch) {
373
547
  if (!spec.editable || !spec.coerce) {
374
548
  throw new InstanceConfigError(`Setting is read-only: ${key}`, key);
375
549
  }
550
+ // `env > file > default`: writing a shadowed field to the file changes
551
+ // nothing an operator can observe, now or after a restart. The UI already
552
+ // renders these read-only; without this check the API happily returned 200 +
553
+ // `restartRequired: true` for a write that could never take effect.
554
+ const shadow = envOverride(spec.envVars, spec.envShadowWhenDefined);
555
+ if (shadow !== undefined) {
556
+ throw new InstanceConfigError(`${spec.label} (${key}) is set by the environment variable ${shadow}, which wins over the config file — change it there instead`, key);
557
+ }
376
558
  const res = spec.coerce(raw);
377
559
  if (!res.ok)
378
560
  throw new InstanceConfigError(`${spec.label} (${key}) ${res.error}`, key);
@@ -71,9 +71,54 @@ export declare function repoCheckoutName(repo: string): string;
71
71
  * (it does NOT follow symlinks — callers that care resolve them beforehand).
72
72
  *
73
73
  * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
74
- * `/data/projects-old` a child of `/data/projects`.
74
+ * `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
75
+ * parent whose string already ends in a separator — the filesystem root — where
76
+ * appending would ask for `"//"` and report every child as outside (issue #719).
77
+ * That made the bidirectional overlap guard in `validatePath` fail open in both
78
+ * directions once any project's working dir was `/`, so two keepers could be
79
+ * created with overlapping working trees. Append the separator only when the
80
+ * parent doesn't already end in one.
75
81
  */
76
82
  export declare function isPathInside(child: string, parent: string): boolean;
83
+ /**
84
+ * Directories beneath which a project may never be linked (issue #720) — the
85
+ * operating system rather than anybody's work.
86
+ *
87
+ * Each entry denies itself and everything under it. The filesystem root is
88
+ * handled separately in {@link isSystemPath}: it is refused by exact match, not
89
+ * by containment, because since #719 EVERY absolute path is "inside" `/`.
90
+ *
91
+ * This is a floor, not an allow-list. The alternative the issue floated —
92
+ * requiring linked paths to sit under a configurable allowed root — is a bigger
93
+ * lever than the problem needs: it adds a config dimension and would invalidate
94
+ * every already-linked project the day it shipped. A denylist only ever refuses
95
+ * paths nobody legitimately links a project at.
96
+ *
97
+ * Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
98
+ * each is somewhere a real checkout or notebook plausibly lives (`/var` in
99
+ * particular holds Paddock's own data dir on some boxes, and macOS temp dirs
100
+ * canonicalise under `/private/var`).
101
+ *
102
+ * On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
103
+ * {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
104
+ * checking, so those are caught by the `/usr` entry too. They are listed anyway
105
+ * for the systems where they are real directories.
106
+ */
107
+ export declare const SYSTEM_PATH_DENYLIST: readonly ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32", "/proc", "/run", "/sbin", "/sys", "/usr"];
108
+ /**
109
+ * Whether a (resolved) path is the filesystem root or sits inside a system
110
+ * directory — the floor beneath which {@link
111
+ * import("./projects.js").ProjectStore.validatePath} refuses to link a project
112
+ * (issue #720).
113
+ *
114
+ * This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
115
+ * projects API can already create projects and run turns as whoever Paddock runs
116
+ * as. It exists because the failure is silent and the blast radius is large — a
117
+ * linked project's directory becomes a keeper's cwd, that keeper runs
118
+ * `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
119
+ * directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
120
+ */
121
+ export declare function isSystemPath(candidate: string): boolean;
77
122
  /**
78
123
  * Resolve a project's working directory (keeper cwd) from its metadata dir plus
79
124
  * whichever backing field is set:
@@ -79,12 +79,76 @@ export function repoCheckoutName(repo) {
79
79
  * (it does NOT follow symlinks — callers that care resolve them beforehand).
80
80
  *
81
81
  * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
82
- * `/data/projects-old` a child of `/data/projects`.
82
+ * `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
83
+ * parent whose string already ends in a separator — the filesystem root — where
84
+ * appending would ask for `"//"` and report every child as outside (issue #719).
85
+ * That made the bidirectional overlap guard in `validatePath` fail open in both
86
+ * directions once any project's working dir was `/`, so two keepers could be
87
+ * created with overlapping working trees. Append the separator only when the
88
+ * parent doesn't already end in one.
83
89
  */
84
90
  export function isPathInside(child, parent) {
85
91
  const c = path.resolve(child);
86
92
  const p = path.resolve(parent);
87
- return c === p || c.startsWith(p + path.sep);
93
+ return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
94
+ }
95
+ /**
96
+ * Directories beneath which a project may never be linked (issue #720) — the
97
+ * operating system rather than anybody's work.
98
+ *
99
+ * Each entry denies itself and everything under it. The filesystem root is
100
+ * handled separately in {@link isSystemPath}: it is refused by exact match, not
101
+ * by containment, because since #719 EVERY absolute path is "inside" `/`.
102
+ *
103
+ * This is a floor, not an allow-list. The alternative the issue floated —
104
+ * requiring linked paths to sit under a configurable allowed root — is a bigger
105
+ * lever than the problem needs: it adds a config dimension and would invalidate
106
+ * every already-linked project the day it shipped. A denylist only ever refuses
107
+ * paths nobody legitimately links a project at.
108
+ *
109
+ * Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
110
+ * each is somewhere a real checkout or notebook plausibly lives (`/var` in
111
+ * particular holds Paddock's own data dir on some boxes, and macOS temp dirs
112
+ * canonicalise under `/private/var`).
113
+ *
114
+ * On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
115
+ * {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
116
+ * checking, so those are caught by the `/usr` entry too. They are listed anyway
117
+ * for the systems where they are real directories.
118
+ */
119
+ export const SYSTEM_PATH_DENYLIST = [
120
+ "/bin",
121
+ "/boot",
122
+ "/dev",
123
+ "/etc",
124
+ "/lib",
125
+ "/lib32",
126
+ "/lib64",
127
+ "/libx32",
128
+ "/proc",
129
+ "/run",
130
+ "/sbin",
131
+ "/sys",
132
+ "/usr",
133
+ ];
134
+ /**
135
+ * Whether a (resolved) path is the filesystem root or sits inside a system
136
+ * directory — the floor beneath which {@link
137
+ * import("./projects.js").ProjectStore.validatePath} refuses to link a project
138
+ * (issue #720).
139
+ *
140
+ * This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
141
+ * projects API can already create projects and run turns as whoever Paddock runs
142
+ * as. It exists because the failure is silent and the blast radius is large — a
143
+ * linked project's directory becomes a keeper's cwd, that keeper runs
144
+ * `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
145
+ * directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
146
+ */
147
+ export function isSystemPath(candidate) {
148
+ const p = path.resolve(candidate);
149
+ if (p === path.parse(p).root)
150
+ return true;
151
+ return SYSTEM_PATH_DENYLIST.some((root) => isPathInside(p, root));
88
152
  }
89
153
  /**
90
154
  * Resolve a project's working directory (keeper cwd) from its metadata dir plus
@@ -412,3 +412,26 @@ export type UpdateProjectInput = Partial<Pick<ProjectYaml, "name" | "status" | "
412
412
  */
413
413
  curation?: CurationOverride | null;
414
414
  };
415
+ /**
416
+ * The plain metadata keys a PATCH may set — the RUNTIME half of
417
+ * {@link UpdateProjectInput} (issue #721).
418
+ *
419
+ * The type is a compile-time shape; the request body is not type-checked at all,
420
+ * so `update()` used to spread it straight into the record it persisted and any
421
+ * invented key landed in `project.yaml` verbatim. This list is what it filters
422
+ * against.
423
+ *
424
+ * It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
425
+ * (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
426
+ * `attachments`/`curation`) are destructured out and applied explicitly, because
427
+ * a spread cannot express "delete this field" — which is how an override is
428
+ * cleared back to inherit — so they are patchable without being listed here.
429
+ *
430
+ * Not here, and not patchable by any route: `slug`/`started` (identity),
431
+ * `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
432
+ * three fields that decide the keeper's cwd (issues #206, #718).
433
+ *
434
+ * The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
435
+ * fails the build here rather than silently becoming un-patchable.
436
+ */
437
+ export declare const PATCHABLE_KEYS: readonly ["name", "status", "domain", "group", "visibility", "summary", "links", "model", "permissionMode", "maxTurns", "docker"];
@@ -46,3 +46,38 @@ export function normalizeLinks(raw) {
46
46
  }
47
47
  return out;
48
48
  }
49
+ /**
50
+ * The plain metadata keys a PATCH may set — the RUNTIME half of
51
+ * {@link UpdateProjectInput} (issue #721).
52
+ *
53
+ * The type is a compile-time shape; the request body is not type-checked at all,
54
+ * so `update()` used to spread it straight into the record it persisted and any
55
+ * invented key landed in `project.yaml` verbatim. This list is what it filters
56
+ * against.
57
+ *
58
+ * It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
59
+ * (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
60
+ * `attachments`/`curation`) are destructured out and applied explicitly, because
61
+ * a spread cannot express "delete this field" — which is how an override is
62
+ * cleared back to inherit — so they are patchable without being listed here.
63
+ *
64
+ * Not here, and not patchable by any route: `slug`/`started` (identity),
65
+ * `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
66
+ * three fields that decide the keeper's cwd (issues #206, #718).
67
+ *
68
+ * The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
69
+ * fails the build here rather than silently becoming un-patchable.
70
+ */
71
+ export const PATCHABLE_KEYS = [
72
+ "name",
73
+ "status",
74
+ "domain",
75
+ "group",
76
+ "visibility",
77
+ "summary",
78
+ "links",
79
+ "model",
80
+ "permissionMode",
81
+ "maxTurns",
82
+ "docker",
83
+ ];
@@ -1,7 +1,7 @@
1
1
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor } from "./project-mime.js";
2
2
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
3
- import { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
- export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
3
+ import { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
+ export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
5
5
  import { type Project, type FileKind, type FileEntry, type CreateProjectInput, type UpdateProjectInput } from "./project-types.js";
6
6
  export * from "./project-types.js";
7
7
  export declare class ProjectStore {
@@ -112,6 +112,9 @@ export declare class ProjectStore {
112
112
  * - a relative path (a cwd must be unambiguous — it is baked into every
113
113
  * transcript path, and resolving it against the server's cwd is a footgun);
114
114
  * - a path that exists but isn't a directory;
115
+ * - the filesystem root, or a path inside a system directory (issue #720) —
116
+ * see {@link isSystemPath} for the floor and why it is a denylist rather
117
+ * than a configurable allowed root;
115
118
  * - a path inside `projectsRoot` or the data dir — that is Paddock's own
116
119
  * state, and pointing at it re-creates the nesting this feature exists to
117
120
  * avoid (plus `remove()` would then really delete it);