@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.
- package/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -1
- package/packages/server/dist/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +87 -0
- package/packages/server/dist/queued-message.js +214 -1
- package/packages/server/dist/routes/chats.js +27 -8
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/turn-interlock.js +8 -0
- package/packages/server/dist/ws-protocol.d.ts +128 -5
- package/packages/server/dist/ws-protocol.js +30 -13
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +545 -127
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
- package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
- package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
- package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
- package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- 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 {
|
|
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 =
|
|
62
|
-
return Number.
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
: {
|
|
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 =
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 {
|
|
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);
|