@edspencer/paddock 0.71.1 → 0.72.0
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.js +27 -0
- package/packages/server/dist/cli/args.d.ts +67 -8
- package/packages/server/dist/cli/args.js +213 -33
- package/packages/server/dist/cli/config.d.ts +157 -0
- package/packages/server/dist/cli/config.js +462 -0
- package/packages/server/dist/cli/paddock.js +53 -3
- package/packages/server/dist/cli/service/backend.d.ts +33 -0
- package/packages/server/dist/cli/service/index.d.ts +3 -3
- package/packages/server/dist/cli/service/index.js +124 -2
- package/packages/server/dist/cli/service/launchd.d.ts +27 -4
- package/packages/server/dist/cli/service/launchd.js +88 -14
- package/packages/server/dist/cli/service/systemd.d.ts +21 -2
- package/packages/server/dist/cli/service/systemd.js +58 -5
- package/packages/server/dist/config.d.ts +36 -1
- package/packages/server/dist/config.js +59 -44
- package/packages/server/dist/herdctl.d.ts +45 -0
- package/packages/server/dist/herdctl.js +58 -4
- package/packages/server/dist/instance-config.d.ts +149 -21
- package/packages/server/dist/instance-config.js +228 -5
- package/packages/server/dist/last-activity.d.ts +21 -0
- package/packages/server/dist/last-activity.js +7 -2
- package/packages/server/dist/profiles.d.ts +154 -0
- package/packages/server/dist/profiles.js +214 -0
- package/packages/server/dist/routes/transcripts.d.ts +61 -0
- package/packages/server/dist/routes/transcripts.js +676 -0
- package/packages/server/dist/routes.d.ts +3 -0
- package/packages/server/dist/routes.js +2 -0
- package/packages/server/dist/transcript-move.d.ts +62 -0
- package/packages/server/dist/transcript-move.js +179 -0
- package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
- package/packages/server/dist/transcripts-migration-execute.js +630 -0
- package/packages/server/dist/transcripts-migration.d.ts +336 -0
- package/packages/server/dist/transcripts-migration.js +917 -0
- package/packages/server/dist/transcripts.d.ts +46 -2
- package/packages/server/dist/transcripts.js +126 -3
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/DiscoverPage-DXI9FjmJ.js +2 -0
- package/packages/web/dist/assets/DiscoverView-D9oqVkT4.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-DyH1j5oR.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DdoNl0Ao.js → MarkdownRenderer-D95ofnQ8.js} +1 -1
- package/packages/web/dist/assets/ProjectMenu-BO70tGwy.js +2 -0
- package/packages/web/dist/assets/{ProjectRedirect-QuGeN-qx.js → ProjectRedirect-BxX_aADU.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BbCyKoh5.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CUFNVTif.js → ProjectsGrid-BafOHL43.js} +1 -1
- package/packages/web/dist/assets/RootHome-BGkMypUJ.js +2 -0
- package/packages/web/dist/assets/Surfaces-B8SyV4r3.js +2 -0
- package/packages/web/dist/assets/{arc-C0DRwCCT.js → arc-DGyr--oL.js} +1 -1
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BNczNNmx.js +37 -0
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DKmCbgWk.js → blockDiagram-GPEHLZMM-DiTvGSrV.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CJ8LiLsj.js → c4Diagram-AAUBKEIU-CKZ_jy81.js} +1 -1
- package/packages/web/dist/assets/channel-CjwDi1A9.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-C3P-O4C3.js → chunk-2J33WTMH-5o96lthu.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Cs9u7x61.js → chunk-4BX2VUAB-38_pZfd8.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-C1st284U.js → chunk-55IACEB6-Db73LvlE.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BjCAWo7B.js → chunk-727SXJPM-C-Q1-wUu.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BP6S9w2Q.js → chunk-AQP2D5EJ-ClJigITk.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-C3ySTaPs.js → chunk-FMBD7UC4-BVzSX2hw.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-eNPD4vzc.js → chunk-ND2GUHAM-DJlL-ELa.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BbBpgHkO.js → chunk-QZHKN3VN-BC0QEk6w.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CxiNtY3i.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CxiNtY3i.js +2 -0
- package/packages/web/dist/assets/{core-ZwDDec0y.js → core-VUdoTe9w.js} +1 -1
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DVqArcBy.js +2 -0
- package/packages/web/dist/assets/{dagre-BM42HDAG-DKIEUM4M.js → dagre-BM42HDAG-BgFGyUdv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CqlX7j8t.js → diagram-2AECGRRQ-BqdZmJt3.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dph5apqo.js → diagram-5GNKFQAL-DeLPu8jT.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-TW6LyHGt.js → diagram-KO2AKTUF-BqTiTGay.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BxxIIv5a.js → diagram-LMA3HP47-CbpAvCgl.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-B8M0DeV7.js → diagram-OG6HWLK6-C4h0V97s.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bfpy6UQa.js → erDiagram-TEJ5UH35-Dwz77uRx.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DstRzxek.js → flowDiagram-I6XJVG4X-DSikJnZR.js} +1 -1
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-DkAGNZb3.js +293 -0
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CX4IozE-.js → gitGraphDiagram-PVQCEYII-BQtzt9hP.js} +1 -1
- package/packages/web/dist/assets/index-BN3rLMpD.js +71 -0
- package/packages/web/dist/assets/index-Dfzb1DvL.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CG2qCk6u.js → infoDiagram-5YYISTIA-YcaBUjeC.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CPErvl3a.js → ishikawaDiagram-YF4QCWOH-BfLZB88u.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bm6krUsf.js → journeyDiagram-JHISSGLW-DwaFhd6C.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B4CC8CAx.js → kanban-definition-UN3LZRKU-e9P3WxsD.js} +1 -1
- package/packages/web/dist/assets/{linear-CJ1DbrOX.js → linear-CsXS5LTz.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dk2kkhf9.js → mermaid.core-DsA72Qyi.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CThvf_YD.js → mindmap-definition-RKZ34NQL-DyLOITDI.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DvkHcng6.js → pieDiagram-4H26LBE5-C36aM3D-.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CqhrTh9q.js → quadrantDiagram-W4KKPZXB-Cq1zTk6a.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZykiLym.js → requirementDiagram-4Y6WPE33-B6MctUk7.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D-TzCKMx.js → sankeyDiagram-5OEKKPKP-CaNRUoPR.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C5pIItVu.js → sequenceDiagram-3UESZ5HK-DDj_tZiQ.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DE6G2JfB.js → stateDiagram-AJRCARHV-DDaMlLyK.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bu6Swk2T.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C1VK2OfU.js → timeline-definition-PNZ67QCA-bMP-7Wam.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CtOozEwa.js → vennDiagram-CIIHVFJN-9nzKgc0Y.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DvHGK-lb.js → wardley-L42UT6IY-DIWbAFW5.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DxKwZ3-G.js → wardleyDiagram-YWT4CUSO-CVL3skbs.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-HM4j3l6y.js → xychartDiagram-2RQKCTM6-DHoVPvll.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-Db--PJFH.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +0 -2
- package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +0 -2
- package/packages/web/dist/assets/ProjectMenu-BxQu14ze.js +0 -2
- package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +0 -13
- package/packages/web/dist/assets/RootHome-SIxo-xX9.js +0 -2
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Wwpf4cvW.js +0 -37
- package/packages/web/dist/assets/channel-DEmtNKeB.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +0 -2
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DiM4eeF5.js +0 -2
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-Bg0oFbAl.js +0 -293
- package/packages/web/dist/assets/index-ASW1Jl0Y.js +0 -71
- package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +0 -2
|
@@ -38,7 +38,8 @@ import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes } from "./attachments-config.
|
|
|
38
38
|
import { DEFAULT_CURATION } from "./curation-config.js";
|
|
39
39
|
import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
|
|
40
40
|
import { CONFIG_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schema-version.js";
|
|
41
|
-
import {
|
|
41
|
+
import { DEFAULT_PROFILE, POSTURE_KEYS, PROFILE_NAMES, isKnownProfile, posture as postureFor, } from "./profiles.js";
|
|
42
|
+
import { DEFAULT_TRANSCRIPTS_MODE, isKnownTranscriptsMode, } from "./transcripts.js";
|
|
42
43
|
import { DEFAULT_CREDENTIALS_MODE } from "./claude-credentials.js";
|
|
43
44
|
import { DEFAULT_INSTRUCTIONS_MODE } from "./claude-instructions.js";
|
|
44
45
|
import { DEFAULT_HOOKS_MODE } from "./claude-settings.js";
|
|
@@ -54,7 +55,7 @@ export const GROUPS = [
|
|
|
54
55
|
{ id: "transcription", label: "Transcription" },
|
|
55
56
|
{ id: "git", label: "Git identity" },
|
|
56
57
|
{ id: "logging", label: "Logging" },
|
|
57
|
-
{ id: "advanced", label: "Advanced (read-only)", description: "
|
|
58
|
+
{ id: "advanced", label: "Advanced (read-only)", description: "The instance's posture profile, its process / filesystem bindings, and what it shares with the Claude Code CLI on this machine. Change these via env/redeploy, not the UI." },
|
|
58
59
|
];
|
|
59
60
|
// --- coercion helpers -------------------------------------------------------
|
|
60
61
|
const asBool = (raw) => typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: "must be a boolean" };
|
|
@@ -284,6 +285,21 @@ export const FIELDS = [
|
|
|
284
285
|
// Logging.
|
|
285
286
|
{ key: "logLevel", group: "logging", label: "Log level", type: "enum", enumValues: LOG_LEVELS, envVars: ["LOG_LEVEL"], default: "info", editable: true, coerce: oneOf(LOG_LEVELS) },
|
|
286
287
|
// Advanced — read-only display (process / filesystem bindings).
|
|
288
|
+
//
|
|
289
|
+
// The posture profile (#878) leads the group because it is the row that
|
|
290
|
+
// EXPLAINS the others: it supplies the default for the five `claude.` modes
|
|
291
|
+
// below and for seven of the Capabilities toggles above, and every field it is
|
|
292
|
+
// actually responsible for is reported with `fromProfile` so the UI can say so
|
|
293
|
+
// on the row itself.
|
|
294
|
+
//
|
|
295
|
+
// READ-ONLY, and not for the usual advanced-group reason. A writable profile
|
|
296
|
+
// row would set exactly the five `claude.` keys below — the ones that are
|
|
297
|
+
// read-only because their symlinks are planted at agent-registration time, so
|
|
298
|
+
// a live toggle silently does nothing until the next boot. Making the profile
|
|
299
|
+
// editable would reintroduce that through the back door, and worse: one
|
|
300
|
+
// control that no-ops five levers at once. Change it in `paddock.config.yaml`
|
|
301
|
+
// or `PADDOCK_PROFILE` and restart, which is what the value here reflects.
|
|
302
|
+
{ key: "profile", group: "advanced", label: "Posture profile", help: "The named security/capability posture this instance resolved at boot. It sets the default for the Claude sharing modes below and most of the Capabilities toggles; anything set explicitly in this file or the environment still wins over it.", type: "enum", enumValues: PROFILE_NAMES, envVars: ["PADDOCK_PROFILE"], default: DEFAULT_PROFILE, editable: false },
|
|
287
303
|
{ key: "port", group: "advanced", label: "Port", type: "number", envVars: ["PORT"], default: 7233, editable: false },
|
|
288
304
|
{ key: "host", group: "advanced", label: "Host", type: "string", envVars: ["HOST", "PADDOCK_HOST"], default: "127.0.0.1", editable: false },
|
|
289
305
|
{ key: "dataDir", group: "advanced", label: "Data dir", type: "string", envVars: ["PADDOCK_DATA_DIR"], default: null, editable: false },
|
|
@@ -396,6 +412,32 @@ function readConfigFileSnapshot(configPath) {
|
|
|
396
412
|
export function instanceConfigVersion(configPath) {
|
|
397
413
|
return readConfigFileSnapshot(configPath).version;
|
|
398
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The transcripts mode a RESTART would resolve — read from the config FILE,
|
|
417
|
+
* falling back to the profile's default when the file is silent about it.
|
|
418
|
+
*
|
|
419
|
+
* NOT `cfg.claude.transcripts`, which is what THIS process froze at boot. The
|
|
420
|
+
* two differ for the whole window between a successful `own → host` migration
|
|
421
|
+
* (#882) and the restart it asks for, and that window is precisely when a
|
|
422
|
+
* second POST arrives: without this the idempotency check would see `own`,
|
|
423
|
+
* decide nothing had happened, and re-run a migration that already had.
|
|
424
|
+
*
|
|
425
|
+
* The environment is deliberately not consulted. `PADDOCK_CLAUDE_TRANSCRIPTS`
|
|
426
|
+
* beats the file, so when it is set the true answer to "what would a restart
|
|
427
|
+
* resolve" is the env var — but a caller in that situation is already refusing
|
|
428
|
+
* with `env_shadowed`, and folding it in here would make a shadowed instance
|
|
429
|
+
* look permanently migrated.
|
|
430
|
+
*/
|
|
431
|
+
export function pendingTranscriptsMode(cfg) {
|
|
432
|
+
const file = readConfigFileSnapshot(instanceConfigPath(cfg));
|
|
433
|
+
const raw = file.data ? readPath(file.data, "claude.transcripts") : undefined;
|
|
434
|
+
if (typeof raw === "string" && isKnownTranscriptsMode(raw))
|
|
435
|
+
return raw;
|
|
436
|
+
const fallback = postureFor(cfg.profile).transcripts;
|
|
437
|
+
return typeof fallback === "string" && isKnownTranscriptsMode(fallback)
|
|
438
|
+
? fallback
|
|
439
|
+
: DEFAULT_TRANSCRIPTS_MODE;
|
|
440
|
+
}
|
|
399
441
|
/** Structural equality for the value shapes a field can hold (scalars + string lists). */
|
|
400
442
|
function valuesEqual(a, b) {
|
|
401
443
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
@@ -445,7 +487,72 @@ export function instanceConfigPath(cfg) {
|
|
|
445
487
|
* friends are canonicalised, `port` is `Number()`-ed), so comparing them
|
|
446
488
|
* against raw file text would manufacture divergence that isn't there.
|
|
447
489
|
*/
|
|
490
|
+
/**
|
|
491
|
+
* What this field falls back to when nothing sets it — which, for the twelve
|
|
492
|
+
* posture keys, is whatever the instance's profile says rather than the static
|
|
493
|
+
* `default` on the spec (#878).
|
|
494
|
+
*
|
|
495
|
+
* Not cosmetic: `default` is also what `pendingRestart` is computed against. A
|
|
496
|
+
* config file that simply omits `selfMcpEnabled` is *agreeing* with the profile,
|
|
497
|
+
* and scoring it against a hardcoded `false` would report a pending restart on a
|
|
498
|
+
* file that changes nothing.
|
|
499
|
+
*
|
|
500
|
+
* The mapping is by name — every posture field's key IS its `Posture` key,
|
|
501
|
+
* modulo the `claude.` prefix — so a lever added to both `FIELDS` and `Posture`
|
|
502
|
+
* is picked up here with no third list to keep in sync.
|
|
503
|
+
*/
|
|
504
|
+
function defaultFor(f, p) {
|
|
505
|
+
const k = postureKeyOf(f);
|
|
506
|
+
return k === null ? f.default : p[k];
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The {@link Posture} key a field is a lever for, or `null` if the profile has
|
|
510
|
+
* no opinion about it. The mapping is by name — every posture field's key IS its
|
|
511
|
+
* `Posture` key, modulo the `claude.` prefix — so a lever added to both `FIELDS`
|
|
512
|
+
* and `Posture` is picked up with no third list to keep in sync.
|
|
513
|
+
*/
|
|
514
|
+
function postureKeyOf(f) {
|
|
515
|
+
const k = f.key.startsWith("claude.") ? f.key.slice("claude.".length) : f.key;
|
|
516
|
+
return POSTURE_KEYS.includes(k) ? k : null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Whether the PROFILE is what put this field's effective value there (#878) — a
|
|
520
|
+
* claim the UI renders as a chip on the row, so it has to be true.
|
|
521
|
+
*
|
|
522
|
+
* Every clause here exists to stop the chip appearing on a value the profile did
|
|
523
|
+
* not choose, because a row that blames the profile for something the operator
|
|
524
|
+
* set themselves is worse than a row that says nothing:
|
|
525
|
+
*
|
|
526
|
+
* - **not a posture key** — the profile is silent on port, auth, models, …
|
|
527
|
+
* - **not env-shadowed** — `PADDOCK_CLAUDE_INSTRUCTIONS=own` under
|
|
528
|
+
* `profile: balanced` resolves `own`, and the profile wanted `host`. The env
|
|
529
|
+
* var beat it; the existing `env` chip is the honest label for that row.
|
|
530
|
+
* - **not set in the file** — an explicit `claude: {hooks: host}` beats the
|
|
531
|
+
* profile too (specific beats general — see `profiles.ts`), even when it
|
|
532
|
+
* happens to agree.
|
|
533
|
+
* - **value actually matches the posture** — belt-and-braces against a lever
|
|
534
|
+
* that is in `POSTURE_KEYS` but whose loader never consulted the profile.
|
|
535
|
+
* That is a real bug class, and the failure mode without this clause is the
|
|
536
|
+
* screen confidently mislabelling it.
|
|
537
|
+
*
|
|
538
|
+
* `fileUnknown` covers the one state where we genuinely cannot tell: the config
|
|
539
|
+
* file exists but no longer parses. It parsed at boot (config.ts throws
|
|
540
|
+
* otherwise, so the process would not be running), which means the file in force
|
|
541
|
+
* is one we cannot read — so we decline to attribute rather than guess.
|
|
542
|
+
*/
|
|
543
|
+
function isFromProfile(f, p, value, envShadowed, file) {
|
|
544
|
+
const k = postureKeyOf(f);
|
|
545
|
+
if (k === null || envShadowed)
|
|
546
|
+
return false;
|
|
547
|
+
const fileUnknown = file.data === null && file.version !== null;
|
|
548
|
+
if (fileUnknown)
|
|
549
|
+
return false;
|
|
550
|
+
if (readPath(file.data ?? {}, f.key) != null)
|
|
551
|
+
return false;
|
|
552
|
+
return valuesEqual(value, p[k]);
|
|
553
|
+
}
|
|
448
554
|
export function buildInstanceConfig(cfg) {
|
|
555
|
+
const p = postureFor(cfg.profile);
|
|
449
556
|
const groups = GROUPS.map((g) => ({
|
|
450
557
|
id: g.id,
|
|
451
558
|
label: g.label,
|
|
@@ -468,12 +575,12 @@ export function buildInstanceConfig(cfg) {
|
|
|
468
575
|
// Absent, or present-but-null (`debounceMs:` with nothing after it), both
|
|
469
576
|
// mean "no override" — `fileOr`/`fileOpt` degrade a null to the default.
|
|
470
577
|
const fileRaw = readPath(file.data, f.key);
|
|
471
|
-
pendingValue = fileRaw == null ? (f
|
|
578
|
+
pendingValue = fileRaw == null ? (defaultFor(f, p) ?? null) : coerceForDisplay(f, fileRaw);
|
|
472
579
|
// A field the frozen config leaves unset IS its built-in default (that is
|
|
473
580
|
// what `default` documents), so normalise before comparing — otherwise
|
|
474
581
|
// e.g. `models`, unset and therefore null, would read as forever diverging
|
|
475
582
|
// from the catalog list the file's absence implies.
|
|
476
|
-
const effective = value === null ? (f
|
|
583
|
+
const effective = value === null ? (defaultFor(f, p) ?? null) : value;
|
|
477
584
|
pendingRestart = !valuesEqual(pendingValue, effective);
|
|
478
585
|
if (pendingRestart)
|
|
479
586
|
restartRequired = true;
|
|
@@ -488,11 +595,12 @@ export function buildInstanceConfig(cfg) {
|
|
|
488
595
|
value,
|
|
489
596
|
pendingValue,
|
|
490
597
|
pendingRestart,
|
|
491
|
-
default: f
|
|
598
|
+
default: defaultFor(f, p),
|
|
492
599
|
editable: f.editable,
|
|
493
600
|
sensitive: f.sensitive ?? false,
|
|
494
601
|
envOverridden: shadow !== undefined,
|
|
495
602
|
envVar: shadow,
|
|
603
|
+
fromProfile: isFromProfile(f, p, value, shadow !== undefined, file),
|
|
496
604
|
};
|
|
497
605
|
groupById.get(f.group)?.fields.push(dto);
|
|
498
606
|
}
|
|
@@ -611,3 +719,118 @@ export function writeInstanceConfig(configPath, pairs) {
|
|
|
611
719
|
fs.writeFileSync(tmp, serialized, "utf8");
|
|
612
720
|
fs.renameSync(tmp, configPath);
|
|
613
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Fields whose effective value is a NORMALISED form of whatever the file said —
|
|
724
|
+
* paths get `abs()` + `canonical()` at boot, so `dataDir: ./data` legitimately
|
|
725
|
+
* resolves to `/home/you/data`. Comparing those two would manufacture a
|
|
726
|
+
* "your file value is not in effect" warning on a file that is working fine, so
|
|
727
|
+
* for these keys the file's mere presence settles provenance.
|
|
728
|
+
*/
|
|
729
|
+
const NORMALISED_AT_BOOT = new Set([
|
|
730
|
+
"dataDir",
|
|
731
|
+
"projectsRoot",
|
|
732
|
+
"stateDir",
|
|
733
|
+
"herdctlConfigPath",
|
|
734
|
+
"webDist",
|
|
735
|
+
]);
|
|
736
|
+
/**
|
|
737
|
+
* Did the file's value survive into the resolved config?
|
|
738
|
+
*
|
|
739
|
+
* Looser than {@link valuesEqual} for scalars on purpose: every file value goes
|
|
740
|
+
* through `String()` in `fileOr`/`fileOpt` before it is parsed, so a hand-typed
|
|
741
|
+
* `port: "7300"` and a resolved `7300` are the same decision, not a divergence.
|
|
742
|
+
*/
|
|
743
|
+
function fileValueTookEffect(written, effective) {
|
|
744
|
+
if (valuesEqual(written, effective))
|
|
745
|
+
return true;
|
|
746
|
+
if (written == null || effective == null)
|
|
747
|
+
return false;
|
|
748
|
+
if (typeof written === "object" || typeof effective === "object")
|
|
749
|
+
return false;
|
|
750
|
+
return String(written) === String(effective);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Where the profile NAME came from. Mirrors `resolveProfileName`'s precedence
|
|
754
|
+
* exactly — a non-blank `PADDOCK_PROFILE` wins outright, and notably does NOT
|
|
755
|
+
* fall through to the file when it names something unrecognised.
|
|
756
|
+
*/
|
|
757
|
+
function resolveProfileProvenance(name, fileData, configPath) {
|
|
758
|
+
const envRaw = process.env.PADDOCK_PROFILE?.trim();
|
|
759
|
+
const fileVal = fileData?.profile;
|
|
760
|
+
const fileRaw = fileVal === undefined || fileVal === null ? "" : String(fileVal).trim();
|
|
761
|
+
const raw = envRaw || fileRaw;
|
|
762
|
+
if (!raw)
|
|
763
|
+
return { name, source: "default" };
|
|
764
|
+
const origin = envRaw ? "PADDOCK_PROFILE" : configPath;
|
|
765
|
+
if (isKnownProfile(raw.toLowerCase())) {
|
|
766
|
+
return { name, source: envRaw ? "env" : "file", origin };
|
|
767
|
+
}
|
|
768
|
+
return { name, source: "default", unrecognised: { value: raw, origin } };
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Resolve every field in {@link FIELDS} against a loaded config, reporting the
|
|
772
|
+
* layer each value came from.
|
|
773
|
+
*
|
|
774
|
+
* Built on the same descriptor table the Config screen uses rather than a
|
|
775
|
+
* parallel list, so a lever added for the UI is printed by the CLI for free and
|
|
776
|
+
* the two can never disagree about what a default is. What it does NOT reuse is
|
|
777
|
+
* `buildInstanceConfig`'s DTO: that answers "what would a restart change?", which
|
|
778
|
+
* needs the file compared against the process. This answers "where did each of
|
|
779
|
+
* these values come from?", which needs the file compared against the LAYERS.
|
|
780
|
+
*
|
|
781
|
+
* No value here is a secret — {@link FIELDS} excludes them by construction — but
|
|
782
|
+
* `sensitive` rides along so a caller can decide what to put on a terminal that
|
|
783
|
+
* ends up pasted into an issue.
|
|
784
|
+
*/
|
|
785
|
+
export function resolveConfigReport(cfg) {
|
|
786
|
+
const configPath = instanceConfigPath(cfg);
|
|
787
|
+
const file = readConfigFileSnapshot(configPath);
|
|
788
|
+
const p = postureFor(cfg.profile);
|
|
789
|
+
const fields = FIELDS.map((f) => {
|
|
790
|
+
const shadow = envOverride(f.envVars, f.envShadowWhenDefined);
|
|
791
|
+
const raw = readPath(cfg, f.key);
|
|
792
|
+
const fallback = defaultFor(f, p) ?? null;
|
|
793
|
+
const value = raw === undefined || raw === null ? fallback : raw;
|
|
794
|
+
const fileRaw = file.data ? readPath(file.data, f.key) : undefined;
|
|
795
|
+
const fileSets = fileRaw !== undefined && fileRaw !== null;
|
|
796
|
+
// The layer a field falls back to when nothing names it: the profile for a
|
|
797
|
+
// posture key, the built-in default for everything else.
|
|
798
|
+
const bare = f.key.startsWith("claude.") ? f.key.slice("claude.".length) : f.key;
|
|
799
|
+
const isPosture = POSTURE_KEYS.includes(bare);
|
|
800
|
+
const unset = isPosture
|
|
801
|
+
? { source: "profile", origin: cfg.profile }
|
|
802
|
+
: { source: "default" };
|
|
803
|
+
let resolved;
|
|
804
|
+
if (shadow !== undefined) {
|
|
805
|
+
resolved = { source: "env", origin: shadow, ...(fileSets ? { shadowedFileValue: fileRaw } : {}) };
|
|
806
|
+
}
|
|
807
|
+
else if (fileSets) {
|
|
808
|
+
resolved =
|
|
809
|
+
NORMALISED_AT_BOOT.has(f.key) || fileValueTookEffect(coerceForDisplay(f, fileRaw), value)
|
|
810
|
+
? { source: "file", origin: configPath }
|
|
811
|
+
: { ...unset, shadowedFileValue: fileRaw };
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
resolved = unset;
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
key: f.key,
|
|
818
|
+
group: f.group,
|
|
819
|
+
label: f.label,
|
|
820
|
+
help: f.help,
|
|
821
|
+
type: f.type,
|
|
822
|
+
value,
|
|
823
|
+
sensitive: f.sensitive ?? false,
|
|
824
|
+
ejectable: (f.editable || isPosture) && f.sensitive !== true,
|
|
825
|
+
...resolved,
|
|
826
|
+
};
|
|
827
|
+
});
|
|
828
|
+
return {
|
|
829
|
+
dataDir: cfg.dataDir,
|
|
830
|
+
configPath,
|
|
831
|
+
configFileExists: file.version !== null,
|
|
832
|
+
...(file.error ? { configFileError: file.error } : {}),
|
|
833
|
+
profile: resolveProfileProvenance(cfg.profile, file.data, configPath),
|
|
834
|
+
fields,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
@@ -12,6 +12,27 @@ export interface SessionWithActivity extends DiscoveredSession {
|
|
|
12
12
|
/** ISO 8601 timestamp of the last real message; absent if none was datable. */
|
|
13
13
|
lastMessageAt?: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Does this record represent a human or the agent actually saying something?
|
|
17
|
+
*
|
|
18
|
+
* The exclusions are the point of the whole issue, not defensive noise. A
|
|
19
|
+
* resume, a permission-mode change or a background agent finishing all append
|
|
20
|
+
* records to a transcript **without any turn having happened**, and counting
|
|
21
|
+
* those as activity reintroduces the same bug one layer up: a chat nobody has
|
|
22
|
+
* touched in a week, freshly stamped, back at the top of the list.
|
|
23
|
+
*
|
|
24
|
+
* Exported because the `own → host` migration preview (#882) counts the same
|
|
25
|
+
* records to show a diverged chat's two copies side by side. A second copy of
|
|
26
|
+
* this rule would drift, and the two numbers would then disagree on screen
|
|
27
|
+
* about what a message is.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isConversationRecord(rec: {
|
|
30
|
+
type?: unknown;
|
|
31
|
+
isMeta?: unknown;
|
|
32
|
+
origin?: {
|
|
33
|
+
kind?: unknown;
|
|
34
|
+
};
|
|
35
|
+
}): boolean;
|
|
15
36
|
/**
|
|
16
37
|
* Timestamp of the last real message in a project chat's transcript.
|
|
17
38
|
*
|
|
@@ -52,8 +52,13 @@ const cache = new Map();
|
|
|
52
52
|
* records to a transcript **without any turn having happened**, and counting
|
|
53
53
|
* those as activity reintroduces the same bug one layer up: a chat nobody has
|
|
54
54
|
* touched in a week, freshly stamped, back at the top of the list.
|
|
55
|
+
*
|
|
56
|
+
* Exported because the `own → host` migration preview (#882) counts the same
|
|
57
|
+
* records to show a diverged chat's two copies side by side. A second copy of
|
|
58
|
+
* this rule would drift, and the two numbers would then disagree on screen
|
|
59
|
+
* about what a message is.
|
|
55
60
|
*/
|
|
56
|
-
function
|
|
61
|
+
export function isConversationRecord(rec) {
|
|
57
62
|
// Drops `summary` title entries and every `type: "system"` control line
|
|
58
63
|
// (local-command records, permission-mode changes, hook output).
|
|
59
64
|
if (rec.type !== "user" && rec.type !== "assistant")
|
|
@@ -103,7 +108,7 @@ function scanBackwards(text, whole) {
|
|
|
103
108
|
catch {
|
|
104
109
|
continue;
|
|
105
110
|
}
|
|
106
|
-
if (!
|
|
111
|
+
if (!isConversationRecord(rec))
|
|
107
112
|
continue;
|
|
108
113
|
const ts = rec.timestamp;
|
|
109
114
|
if (typeof ts === "string" && !Number.isNaN(Date.parse(ts)))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named config profiles (#878) — `paranoid` / `balanced` / `yolo`.
|
|
3
|
+
*
|
|
4
|
+
* Paddock has a dozen independent security/capability levers, each with its own
|
|
5
|
+
* code default. A profile is the single place to express *posture* — "locked
|
|
6
|
+
* down" / "full capability" — instead of hand-setting twelve `PADDOCK_*` vars.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this is a resolved key, not something an installer writes
|
|
9
|
+
*
|
|
10
|
+
* Config resolves as code default < YAML file < `PADDOCK_*` env. A profile
|
|
11
|
+
* implemented as "the installer materialises concrete values into
|
|
12
|
+
* `paddock.config.yaml`" only ever touches the FILE layer — so it only works on
|
|
13
|
+
* the one distribution that runs an installer. Docker and bare `node dist` would
|
|
14
|
+
* keep falling through to code defaults and the distributions would stay
|
|
15
|
+
* divergent, which is the actual complaint #878 opens with. Resolving `profile`
|
|
16
|
+
* at every layer — file, env, and a code default underneath both — is what makes
|
|
17
|
+
* every distribution converge on the same posture.
|
|
18
|
+
*
|
|
19
|
+
* ## Where a profile sits in the precedence chain
|
|
20
|
+
*
|
|
21
|
+
* A profile supplies the DEFAULT that each lever's loader falls back to. It does
|
|
22
|
+
* not become a config layer of its own. So the full chain is:
|
|
23
|
+
*
|
|
24
|
+
* code-default profile < resolved profile < individual file key < individual env var
|
|
25
|
+
*
|
|
26
|
+
* The consequence worth stating out loud, because it inverts this codebase's
|
|
27
|
+
* otherwise universal env-beats-file rule: **an individual key in the YAML file
|
|
28
|
+
* beats `PADDOCK_PROFILE` in the env.** `PADDOCK_PROFILE=paranoid` with
|
|
29
|
+
* `claude: {hooks: host}` in a mounted file resolves hooks to `host`.
|
|
30
|
+
*
|
|
31
|
+
* That is deliberate — specific beats general. `PADDOCK_PROFILE` is a statement
|
|
32
|
+
* about the dozen levers you did NOT mention; a key you wrote by hand is a
|
|
33
|
+
* statement about the one you did. The alternative makes the env var a blunt
|
|
34
|
+
* instrument that silently discards deliberate per-key config, which is a worse
|
|
35
|
+
* failure than the surprise of a file winning. (Env still beats file for the same
|
|
36
|
+
* key, exactly as everywhere else: `PADDOCK_CLAUDE_HOOKS` beats
|
|
37
|
+
* `claude.hooks`.)
|
|
38
|
+
*
|
|
39
|
+
* ## Posture keys only
|
|
40
|
+
*
|
|
41
|
+
* Profiles govern the `claude:` inheritance modes, `maxSpawnDepth`, and the
|
|
42
|
+
* capability toggles — and define ALL of them (see `POSTURE_KEYS`, pinned by a
|
|
43
|
+
* test). They are silent on operational keys (port, data dir, bind host, auth,
|
|
44
|
+
* models, drive mode, curation/recovery/attachments, git author). Switching
|
|
45
|
+
* profile must never change your port or clobber your auth config.
|
|
46
|
+
*
|
|
47
|
+
* ## Two guardrails
|
|
48
|
+
*
|
|
49
|
+
* - Profiles stay orthogonal to bind address and auth. `yolo` must NOT open the
|
|
50
|
+
* bind or relax auth — network exposure remains its own explicit decision,
|
|
51
|
+
* already gated by the dangerous-allow-open opt-in. Otherwise "yolo" quietly
|
|
52
|
+
* becomes "yolo, reachable from the whole network".
|
|
53
|
+
* - `host` resolves against whatever `~/.claude` actually exists: rich on a
|
|
54
|
+
* workstation, typically empty in a container. **The profile sets the levers,
|
|
55
|
+
* the environment sets the blast radius** — which is why the permissive
|
|
56
|
+
* profiles are far more reasonable in a disposable container than on a primary
|
|
57
|
+
* machine.
|
|
58
|
+
*/
|
|
59
|
+
import type { CredentialsMode } from "./claude-credentials.js";
|
|
60
|
+
import type { HooksMode } from "./claude-settings.js";
|
|
61
|
+
import type { InstructionsMode } from "./claude-instructions.js";
|
|
62
|
+
import type { McpServersMode } from "./claude-mcp.js";
|
|
63
|
+
import type { TranscriptsMode } from "./transcripts.js";
|
|
64
|
+
/** The three built-in profiles, in ascending order of capability. */
|
|
65
|
+
export declare const PROFILE_NAMES: readonly ["paranoid", "balanced", "yolo"];
|
|
66
|
+
export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
67
|
+
/**
|
|
68
|
+
* The code-default profile (#878).
|
|
69
|
+
*
|
|
70
|
+
* `balanced` rather than `paranoid` because of the **superset principle**:
|
|
71
|
+
* paddock is a presentation layer over the Claude Code CLI, so its capability
|
|
72
|
+
* surface should be a superset of what the plain CLI already gives you. A user
|
|
73
|
+
* whose CLI has MCP servers configured (and plugin-provided MCP servers, which
|
|
74
|
+
* additionally need `instructions: host`) reasonably expects those to work here;
|
|
75
|
+
* defaulting them off is experienced as a capability regression versus the tool
|
|
76
|
+
* paddock wraps.
|
|
77
|
+
*/
|
|
78
|
+
export declare const DEFAULT_PROFILE: ProfileName;
|
|
79
|
+
/**
|
|
80
|
+
* The posture surface: every lever a profile governs, and the only ones it may.
|
|
81
|
+
*
|
|
82
|
+
* Adding a capability lever means adding it here AND giving all three profiles a
|
|
83
|
+
* value for it — `profiles.test.ts` fails until you do. That is the point of the
|
|
84
|
+
* invariant: today a new toggle just gets a code default and the postures are
|
|
85
|
+
* never revisited, so they silently drift out of date.
|
|
86
|
+
*/
|
|
87
|
+
export interface Posture {
|
|
88
|
+
/** `claude.transcripts` — share the host's transcript store. */
|
|
89
|
+
transcripts: TranscriptsMode;
|
|
90
|
+
/** `claude.credentials` — read the host's Claude login. */
|
|
91
|
+
credentials: CredentialsMode;
|
|
92
|
+
/** `claude.instructions` — inherit the host's CLAUDE.md hierarchy. */
|
|
93
|
+
instructions: InstructionsMode;
|
|
94
|
+
/** `claude.hooks` — inherit the host's hook definitions. */
|
|
95
|
+
hooks: HooksMode;
|
|
96
|
+
/** `claude.mcpServers` — inherit the host's configured MCP servers. */
|
|
97
|
+
mcpServers: McpServersMode;
|
|
98
|
+
/** How deep an agent may spawn sub-agents. */
|
|
99
|
+
maxSpawnDepth: number;
|
|
100
|
+
/** The read-only self-management MCP. */
|
|
101
|
+
selfMcpEnabled: boolean;
|
|
102
|
+
/** The self-management MCP WRITE tools. */
|
|
103
|
+
selfMcpWriteEnabled: boolean;
|
|
104
|
+
/** The self-management MCP PROJECT tools (`create_project`). */
|
|
105
|
+
selfMcpProjectsEnabled: boolean;
|
|
106
|
+
/** Programmatic schedule mutation. */
|
|
107
|
+
scheduleMutationEnabled: boolean;
|
|
108
|
+
/** The hook-management MCP tools. */
|
|
109
|
+
hooksMcpEnabled: boolean;
|
|
110
|
+
/** The Playwright browser MCP. */
|
|
111
|
+
browserMcp: boolean;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Every posture key, as data — so a test can assert that all three profiles
|
|
115
|
+
* cover exactly this set. Kept in sync with `Posture` by that same test: a key
|
|
116
|
+
* added to the interface but not here fails to typecheck against
|
|
117
|
+
* `Record<keyof Posture, …>` at the `PROFILES` declaration below.
|
|
118
|
+
*/
|
|
119
|
+
export declare const POSTURE_KEYS: readonly ["transcripts", "credentials", "instructions", "hooks", "mcpServers", "maxSpawnDepth", "selfMcpEnabled", "selfMcpWriteEnabled", "selfMcpProjectsEnabled", "scheduleMutationEnabled", "hooksMcpEnabled", "browserMcp"];
|
|
120
|
+
/**
|
|
121
|
+
* The three postures. All three define the same key set, differing only in
|
|
122
|
+
* values — so a diff between two columns is a diff in posture, never in
|
|
123
|
+
* coverage.
|
|
124
|
+
*
|
|
125
|
+
* Two choices here are argued rather than assumed:
|
|
126
|
+
*
|
|
127
|
+
* - **`hooks: own` in `balanced`.** Host hooks are shell commands that fire
|
|
128
|
+
* AUTOMATICALLY on every matching tool call — inherited arbitrary code
|
|
129
|
+
* execution. That is a different risk class from MCP servers, which are tools
|
|
130
|
+
* an agent deliberately chooses to call. So `hooks` does not ride along with
|
|
131
|
+
* the other `host` modes; it is `host` only under `yolo`.
|
|
132
|
+
* - **`browserMcp: false` in `balanced`.** It needs a browser installed on the
|
|
133
|
+
* box. Enabling it by default on a host without one leads agents to fall back
|
|
134
|
+
* to scripting a system browser instead, which is worse than not having it.
|
|
135
|
+
*/
|
|
136
|
+
export declare const PROFILES: Record<ProfileName, Posture>;
|
|
137
|
+
/** Whether `v` names a built-in profile. */
|
|
138
|
+
export declare function isKnownProfile(v: string): v is ProfileName;
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the profile NAME from env > file > {@link DEFAULT_PROFILE}.
|
|
141
|
+
*
|
|
142
|
+
* An unrecognised name falls back to the default rather than failing the boot —
|
|
143
|
+
* the rule every other enum in `config.ts` follows (`driveMode`, the `claude:`
|
|
144
|
+
* modes, whisper `mode`). The direction is defensible here in a way it is not
|
|
145
|
+
* for a single lever: the fallback is `balanced`, so a typo can only ever land
|
|
146
|
+
* you on the same posture a config-less instance already has, never on `yolo`.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately NOT tsconfig-style `extends`: a closed set of names, one level,
|
|
149
|
+
* no chains, no path resolution. The inheritance is fine; arbitrary-file
|
|
150
|
+
* inheritance graphs are what make `extends` unpleasant.
|
|
151
|
+
*/
|
|
152
|
+
export declare function resolveProfileName(fileVal: unknown, env?: NodeJS.ProcessEnv): ProfileName;
|
|
153
|
+
/** The posture a profile name expands to. */
|
|
154
|
+
export declare function posture(name: ProfileName): Posture;
|