omp-conductor 0.3.25 → 0.4.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/src/config.ts CHANGED
@@ -17,21 +17,36 @@ import { homedir } from "node:os";
17
17
  import { dirname, isAbsolute, join } from "node:path";
18
18
  import {
19
19
  AUTHORITY_HOLDERS,
20
+ BASE_FRESHNESS,
21
+ BEHIND_BASE_ACTIONS,
20
22
  CONFIG_VERSION,
23
+ CREDENTIAL_ISOLATIONS,
21
24
  DEFAULT_AUTHORITY,
22
25
  DEFAULT_CAPS,
23
- DEFAULT_RELEASE_POLICY,
26
+ DEFAULT_PROJECT_POLICY,
24
27
  DEFAULT_REPORT_SCOPE,
28
+ DENIED_RELEASE_GRANTS,
29
+ DRAFT_POLICIES,
30
+ LEGACY_RELEASE_POLICIES,
31
+ OPERATOR_BRIEF_GRANTS,
25
32
  ORCHESTRATOR_MODES,
26
33
  READABLE_CONFIG_VERSIONS,
27
- RELEASE_POLICIES,
34
+ RELEASE_REQUIREMENTS,
35
+ RELEASE_SHAPES,
28
36
  REPORT_SCOPES,
29
37
  type Caps,
30
38
  type ConductorConfig,
39
+ type CredentialConfig,
40
+ type CredentialIsolation,
41
+ type MergePreconditions,
42
+ type PlanUsageCap,
31
43
  type ProjectConfig,
44
+ type ProjectPolicy,
45
+ type ReleasePreconditions,
46
+ type ReleaseRequirement,
32
47
  type ReportScope,
33
- type ReleasePolicy,
34
48
  type RepoTarget,
49
+ type ResolvedGrants,
35
50
  } from "./types.ts";
36
51
 
37
52
  /**
@@ -53,6 +68,13 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
53
68
  const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
54
69
  const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
55
70
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
71
+ const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
72
+ const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
73
+ const CREDENTIAL_ISOLATION_LIST = quoteList(CREDENTIAL_ISOLATIONS);
74
+ const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
75
+ const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
76
+ const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
77
+ const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
56
78
 
57
79
  /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
58
80
  const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
@@ -85,6 +107,82 @@ export function stateDir(): string {
85
107
  return dirname(configPath());
86
108
  }
87
109
 
110
+ /**
111
+ * Root of everything a run principal must reach: worktrees, mirrors, per-run
112
+ * session transcripts and per-run boundary homes.
113
+ *
114
+ * **Outside the daemon's home, not a sibling of the state directory**, and both
115
+ * halves of that are load-bearing:
116
+ *
117
+ * - It cannot live *inside* {@link stateDir}, which stays `0700` because it
118
+ * holds `config.json`, `conductor.db` and the WAL/SHM files SQLite recreates
119
+ * at runtime with the process umask. Making it searchable — all a slot needs
120
+ * — would publish fleet, run and report history to every local account, and
121
+ * chmod-ing the leaves once would not hold.
122
+ * - It cannot live under the daemon's **home** either, which the boundary
123
+ * requires to be `0700` so a slot cannot reach `gh` config, `~/.ssh` or
124
+ * `~/.npmrc`. A shared root at `~/.omp/conductor-shared` is unreachable for
125
+ * exactly the same reason, and the two hardening steps would silently cancel
126
+ * each other out — the boundary would look correct and no run could start.
127
+ *
128
+ * So it is a system path: `0711` — traversable by a slot that already knows its
129
+ * own path, listable by nobody, writable by nobody but the daemon. Only used
130
+ * when a per-run boundary is active; an `isolation: "none"` fleet keeps every
131
+ * path exactly where it was (#125).
132
+ *
133
+ * `$OMP_CONDUCTOR_SHARED` overrides it, which is how tests and non-FHS hosts
134
+ * relocate it. Uninstall is two paths instead of one, and the README says so.
135
+ */
136
+ export function sharedRoot(): string {
137
+ const override = process.env["OMP_CONDUCTOR_SHARED"];
138
+ if (override !== undefined && override.length > 0) return expandHome(override);
139
+ // Deliberately NOT derived from whether `$OMP_CONDUCTOR_HOME` happens to be
140
+ // set: generated systemd units set it even for a default install, so keying
141
+ // the security topology off it lets the CLI, setup and the daemon each pick a
142
+ // different root depending on how they were invoked. One explicit override,
143
+ // or the platform default.
144
+ // Not `/tmp`: a shared root a slot could rename or replace would hand one run
145
+ // control of another's checkout path.
146
+ return process.platform === "linux" ? "/var/lib/omp-conductor" : `${stateDir()}-shared`;
147
+ }
148
+
149
+ /**
150
+ * Where per-run worktrees and bare mirrors live when a project names neither.
151
+ *
152
+ * Exported because two readers need the same answer: `normalizeProject` below,
153
+ * and the orchestrator jail (`confinement.ts`, #127), which must deny the
154
+ * worker checkouts even when the config was too broken to load and say where
155
+ * they are. Two copies of this default would mean a jail guarding the wrong
156
+ * directory on exactly the day the config is broken.
157
+ *
158
+ * `base` is what makes these usable under a per-run boundary. A slot principal
159
+ * cannot traverse the `0700` state directory, and dispatch now refuses rather
160
+ * than widening it — so an isolated fleet whose roots defaulted under the state
161
+ * dir would provision a repository and then fail before launching anything.
162
+ * Setup passes {@link sharedRoot} for those fleets and `stateDir()` for the
163
+ * rest, so an `isolation: "none"` install keeps every path exactly where it was
164
+ * (#125).
165
+ */
166
+ export function defaultWorkspaceRoot(base: string = stateDir()): string {
167
+ return join(base, "worktrees");
168
+ }
169
+
170
+ export function defaultMirrorRoot(base: string = stateDir()): string {
171
+ return join(base, "mirrors");
172
+ }
173
+ /**
174
+ * The root a project's slot-accessible trees belong under, given its isolation.
175
+ *
176
+ * Only `per-run` moves. `group-mode` runs sessions as the daemon's *own* uid —
177
+ * it separates checkouts by group and mode, not by principal — so it can reach
178
+ * the state directory perfectly well, and sending it to a system path would
179
+ * demand privileged provisioning a group-mode host may deliberately not have.
180
+ * `none` never moves either.
181
+ */
182
+ export function projectTreeBase(isolation: CredentialIsolation): string {
183
+ return isolation === "per-run" ? sharedRoot() : stateDir();
184
+ }
185
+
88
186
  /**
89
187
  * Reads, validates and normalises the config. Throws an `Error` naming the
90
188
  * path and the fix; never returns a partially-shaped `ConductorConfig`.
@@ -149,6 +247,7 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
149
247
  return {
150
248
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
151
249
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
250
+ planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
152
251
  workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
153
252
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
154
253
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
@@ -157,9 +256,83 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
157
256
  };
158
257
  }
159
258
 
160
- /** Old configs and omitted keys are fail-closed at the enforcement boundary. */
161
- export function resolveReleasePolicy(p: ProjectConfig): ReleasePolicy {
162
- return p.releasePolicy ?? DEFAULT_RELEASE_POLICY;
259
+ /**
260
+ * The effective grant for every shape. Old configs, an omitted key and a
261
+ * hand-written partial map are all fail-closed here: a shape the map does not
262
+ * name is denied, never inherited from a neighbour and never opened.
263
+ *
264
+ * `loadConfig` has already migrated what it read, so for a loaded project this
265
+ * is a copy. It stays because a `ProjectConfig` also reaches the gate from
266
+ * `buildProject` and from tests, and #122's point is that "absent means denied"
267
+ * has exactly one spelling in the package.
268
+ */
269
+ export function resolveReleaseGrants(p: ProjectConfig): ResolvedGrants {
270
+ const policy = p.releasePolicy;
271
+ if (policy === undefined || policy === "none") return { ...DENIED_RELEASE_GRANTS };
272
+
273
+ const grants = { ...DENIED_RELEASE_GRANTS };
274
+ for (const shape of RELEASE_SHAPES) {
275
+ const holder = policy[shape];
276
+ if (holder !== undefined) grants[shape] = holder;
277
+ }
278
+ return grants;
279
+ }
280
+
281
+ /**
282
+ * The gating conditions for one project, complete (#129).
283
+ *
284
+ * The loader always writes a complete `policy`, so this is only ever the
285
+ * fallback for a `ProjectConfig` that never went through it — a hand-built one
286
+ * in a test, or a config written before the key existed. Spelled once here so a
287
+ * caller cannot invent a different answer for the absent case, which is exactly
288
+ * how "conditions live in typed config" degrades back into "conditions live
289
+ * wherever the reader looked".
290
+ */
291
+ export function resolvePolicy(p: ProjectConfig): ProjectPolicy {
292
+ return clonePolicy(p.policy ?? DEFAULT_PROJECT_POLICY);
293
+ }
294
+
295
+ /**
296
+ * A policy with no array shared with its source.
297
+ *
298
+ * {@link DEFAULT_PROJECT_POLICY} is one object for the whole process, and every
299
+ * consumer of a resolved policy holds something it may hand to the wizard and
300
+ * back. A shared `requiredChecks` array would let one project's amend push a
301
+ * check onto every project that had never answered the question.
302
+ */
303
+ export function clonePolicy(policy: ProjectPolicy): ProjectPolicy {
304
+ return {
305
+ merge: { ...policy.merge, requiredChecks: [...policy.merge.requiredChecks] },
306
+ release: {
307
+ ...policy.release,
308
+ requires: [...policy.release.requires],
309
+ requiredChecks: [...policy.release.requiredChecks],
310
+ artefacts: [...policy.release.artefacts],
311
+ environments: [...policy.release.environments],
312
+ },
313
+ };
314
+ }
315
+
316
+ /**
317
+ * The runtime twin of {@link pickLiteral}, for a value a *model* supplied rather
318
+ * than a value an operator wrote (#129).
319
+ *
320
+ * Throws rather than returning a flag: a verb's `reason` is the field the ledger
321
+ * records the act under, and a caller that forgets to check a boolean has
322
+ * written an unexplained act into the audit trail instead of refusing one. The
323
+ * message names every accepted value, because the reader is a session that can
324
+ * only correct itself from what the refusal tells it — "invalid reason" costs a
325
+ * turn and teaches nothing.
326
+ *
327
+ * Free-form rationale is not this function's business. It rides beside the
328
+ * reason as a `rationale` field, is logged verbatim, and is never matched.
329
+ */
330
+ export function requireReason<R extends string>(value: unknown, allowed: readonly R[], field: string): R {
331
+ const hit = allowed.find((a) => a === value);
332
+ if (hit === undefined) {
333
+ throw new Error(`${field} must be ${quoteList(allowed)}, found ${JSON.stringify(value)}`);
334
+ }
335
+ return hit;
163
336
  }
164
337
 
165
338
  /**
@@ -282,14 +455,9 @@ function normalizeProject(
282
455
 
283
456
  const escalation = normalizeEscalation(raw["escalation"], label, problems);
284
457
  const authority = normalizeAuthority(raw["authority"], label, problems);
285
- const releasePolicy = pickLiteral(
286
- raw["releasePolicy"],
287
- RELEASE_POLICIES,
288
- DEFAULT_RELEASE_POLICY,
289
- `${label}: releasePolicy`,
290
- RELEASE_POLICIES.map((value) => JSON.stringify(value)).join(" or "),
291
- problems,
292
- );
458
+ const releasePolicy = normalizeReleasePolicy(raw["releasePolicy"], label, problems);
459
+ const credentials = normalizeCredentials(raw["credentials"], label, problems);
460
+ const policy = normalizeProjectPolicy(raw["policy"], label, problems);
293
461
 
294
462
  const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
295
463
  const reporting = normalizeReporting(raw["reporting"], label, problems);
@@ -298,6 +466,11 @@ function normalizeProject(
298
466
  // (logged by `runWorker`) is what tells the operator the pattern missed.
299
467
  const rawWorkerModel = raw["workerModel"];
300
468
  const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
469
+ const orchestratorReadPaths = normalizeOrchestratorReadPaths(
470
+ raw["orchestratorReadPaths"],
471
+ label,
472
+ problems,
473
+ );
301
474
 
302
475
  if (problems.length > before) return undefined;
303
476
 
@@ -316,9 +489,20 @@ function normalizeProject(
316
489
  escalation,
317
490
  authority,
318
491
  releasePolicy,
492
+ policy,
493
+ credentials,
319
494
  reporting,
320
- workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
321
- mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
495
+ ...(orchestratorReadPaths === undefined ? {} : { orchestratorReadPaths }),
496
+ // Derived from this project's own isolation, not from `stateDir()`: a
497
+ // hand-written `per-run` config that omits these optional keys would
498
+ // otherwise resolve under the 0700 private tree and be refused at dispatch,
499
+ // having never gone through the setup wizard that knows better (#125).
500
+ workspaceRoot: expandHome(
501
+ pickString(raw["workspaceRoot"], defaultWorkspaceRoot(projectTreeBase(credentials.isolation))),
502
+ ),
503
+ mirrorRoot: expandHome(
504
+ pickString(raw["mirrorRoot"], defaultMirrorRoot(projectTreeBase(credentials.isolation))),
505
+ ),
322
506
  };
323
507
  }
324
508
 
@@ -432,6 +616,355 @@ function normalizeAuthority(parsed: unknown, label: string, problems: string[]):
432
616
  };
433
617
  }
434
618
 
619
+ /**
620
+ * Per-shape release grants, and the migration off the two legacy strings.
621
+ *
622
+ * Normalised to a *complete* map at load, so everything downstream — the gate,
623
+ * `status`, the standing orders, the wizard — reads the same five answers and
624
+ * none of them has to know which spelling was on disk.
625
+ *
626
+ * `"operator-brief"` migrates to every shape except `deploy` (#122). That is the
627
+ * safe reading of what an operator believed the binary gate opened: a stale
628
+ * `operator-brief` was enough for an orchestrator session to invoke Komodo
629
+ * `DeployStack`, which is the grant nobody knowingly gave.
630
+ *
631
+ * An unknown shape key is rejected rather than ignored, as in `authority` and
632
+ * for the same reason: the set is closed, so `{ "deploy-prod": "orchestrator" }`
633
+ * is a typo every time — and a config that loaded it cleanly would read as
634
+ * granted in the file while the gate denied every call.
635
+ */
636
+ function normalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
637
+ if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
638
+ if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
639
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
640
+ problems.push(
641
+ `${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
642
+ `${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
643
+ );
644
+ return { ...DENIED_RELEASE_GRANTS };
645
+ }
646
+ const raw = parsed as Raw;
647
+
648
+ const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
649
+ if (unknownKeys.length > 0) {
650
+ problems.push(
651
+ `${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
652
+ );
653
+ }
654
+
655
+ const grants = { ...DENIED_RELEASE_GRANTS };
656
+ for (const shape of RELEASE_SHAPES) {
657
+ // An absent shape stays denied; a present-but-malformed one is reported and
658
+ // also stays denied, never folded to the value the operator asked for.
659
+ grants[shape] = pickLiteral(
660
+ raw[shape],
661
+ AUTHORITY_HOLDERS,
662
+ "human",
663
+ `${label}: releasePolicy.${shape}`,
664
+ AUTHORITY_HOLDER_LIST,
665
+ problems,
666
+ );
667
+ }
668
+ return grants;
669
+ }
670
+
671
+ /**
672
+ * The merge and release gating conditions (#129).
673
+ *
674
+ * These used to be sentences in the operator's POLICY.md, which meant a verb
675
+ * could only honour them by asking a model to read prose. Typed and normalised
676
+ * here to a *complete* value, so the gate, the wizard, the plan summary and the
677
+ * brief all read the same answers and none of them has to know which fields
678
+ * were spelled on disk.
679
+ *
680
+ * Fail-closed throughout, as in `authority` and for the same reason: both
681
+ * sections have a closed key set, so an unrecognised key is a typo every time —
682
+ * and a `policy: { merge: { requiredCheck: [...] } }` that loaded cleanly would
683
+ * read as configured in the file while the gate went on requiring every check.
684
+ * A malformed value takes the documented default and is reported; it is never
685
+ * folded to whatever the operator asked for.
686
+ */
687
+ function normalizeProjectPolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
688
+ if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY);
689
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
690
+ problems.push(`${label}: policy must be an object with "merge" and "release" sections`);
691
+ return clonePolicy(DEFAULT_PROJECT_POLICY);
692
+ }
693
+ const raw = parsed as Raw;
694
+
695
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
696
+ if (unknownKeys.length > 0) {
697
+ problems.push(`${label}: policy has unknown key(s): ${unknownKeys.join(", ")} — expected "merge" or "release"`);
698
+ }
699
+
700
+ return {
701
+ merge: normalizeMergePreconditions(raw["merge"], `${label}: policy.merge`, problems),
702
+ release: normalizeReleasePreconditions(raw["release"], `${label}: policy.release`, problems),
703
+ };
704
+ }
705
+
706
+ /** When a pull request may be merged. Absent members take {@link DEFAULT_PROJECT_POLICY}. */
707
+ function normalizeMergePreconditions(parsed: unknown, at: string, problems: string[]): MergePreconditions {
708
+ const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
709
+ if (parsed === undefined) return fallback;
710
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
711
+ problems.push(`${at} must be an object`);
712
+ return fallback;
713
+ }
714
+ const raw = parsed as Raw;
715
+
716
+ const known = new Set(Object.keys(fallback));
717
+ const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
718
+ if (unknownKeys.length > 0) {
719
+ problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
720
+ }
721
+
722
+ return {
723
+ requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
724
+ baseFreshness: pickLiteral(
725
+ raw["baseFreshness"],
726
+ BASE_FRESHNESS,
727
+ fallback.baseFreshness,
728
+ `${at}.baseFreshness`,
729
+ BASE_FRESHNESS_LIST,
730
+ problems,
731
+ ),
732
+ drafts: pickLiteral(raw["drafts"], DRAFT_POLICIES, fallback.drafts, `${at}.drafts`, DRAFT_POLICY_LIST, problems),
733
+ whenBehindBase: pickLiteral(
734
+ raw["whenBehindBase"],
735
+ BEHIND_BASE_ACTIONS,
736
+ fallback.whenBehindBase,
737
+ `${at}.whenBehindBase`,
738
+ BEHIND_BASE_ACTION_LIST,
739
+ problems,
740
+ ),
741
+ };
742
+ }
743
+
744
+ /** When a release may be cut, what it produces, and where it may go. */
745
+ function normalizeReleasePreconditions(parsed: unknown, at: string, problems: string[]): ReleasePreconditions {
746
+ const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
747
+ if (parsed === undefined) return fallback;
748
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
749
+ problems.push(`${at} must be an object`);
750
+ return fallback;
751
+ }
752
+ const raw = parsed as Raw;
753
+
754
+ const known = new Set(Object.keys(fallback));
755
+ const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
756
+ if (unknownKeys.length > 0) {
757
+ problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
758
+ }
759
+
760
+ return {
761
+ requires: normalizeReleaseRequirements(raw["requires"], `${at}.requires`, fallback.requires, problems),
762
+ requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
763
+ artefacts: normalizeNameList(raw["artefacts"], `${at}.artefacts`, problems),
764
+ environments: normalizeNameList(raw["environments"], `${at}.environments`, problems),
765
+ };
766
+ }
767
+
768
+ /**
769
+ * The `requires` set, in the vocabulary's own order rather than the file's.
770
+ *
771
+ * Canonical order and de-duplication because this list is rendered into a
772
+ * refusal and into the plan summary: two configs that require the same three
773
+ * things must read identically, or an operator diffing them sees a change that
774
+ * is not one.
775
+ */
776
+ function normalizeReleaseRequirements(
777
+ parsed: unknown,
778
+ at: string,
779
+ fallback: readonly ReleaseRequirement[],
780
+ problems: string[],
781
+ ): ReleaseRequirement[] {
782
+ if (parsed === undefined) return [...fallback];
783
+ if (!Array.isArray(parsed)) {
784
+ problems.push(`${at} must be an array of ${RELEASE_REQUIREMENT_LIST}`);
785
+ return [...fallback];
786
+ }
787
+
788
+ const chosen = new Set<string>();
789
+ parsed.forEach((entry: unknown, i) => {
790
+ if (!RELEASE_REQUIREMENTS.some((r) => r === entry)) {
791
+ problems.push(`${at}[${i}] must be ${RELEASE_REQUIREMENT_LIST}, found ${JSON.stringify(entry)}`);
792
+ return;
793
+ }
794
+ chosen.add(entry as ReleaseRequirement);
795
+ });
796
+ return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
797
+ }
798
+
799
+ /**
800
+ * A list of names an operator wrote: check names, artefacts, environments.
801
+ *
802
+ * Open-ended by necessity — this package cannot know what a fleet publishes —
803
+ * but a malformed entry still rejects the whole config rather than being
804
+ * dropped. A silently dropped environment name is an operator debugging a
805
+ * refusal that reads exactly like a correctly-denied one, which is the same
806
+ * trap `orchestratorReadPaths` documents.
807
+ */
808
+ function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
809
+ if (parsed === undefined) return [];
810
+ if (!Array.isArray(parsed)) {
811
+ problems.push(`${at} must be an array of non-empty strings`);
812
+ return [];
813
+ }
814
+
815
+ const names: string[] = [];
816
+ parsed.forEach((entry: unknown, i) => {
817
+ if (!nonEmptyString(entry)) {
818
+ problems.push(`${at}[${i}] must be a non-empty string, found ${JSON.stringify(entry)}`);
819
+ return;
820
+ }
821
+ names.push(entry.trim());
822
+ });
823
+ return names;
824
+ }
825
+
826
+
827
+ /**
828
+ * The credential boundary, and the one migration that must never brick a fleet.
829
+ *
830
+ * `omp/systemd/omp-conductor.service.example` runs the daemon as an
831
+ * unprivileged account. A naive "fail closed unless per-run uids are available"
832
+ * release would therefore stop every *existing* fleet from dispatching on
833
+ * upgrade — a production outage caused by a security feature, which is a worse
834
+ * outcome than the day before. The issue's rule ("never the silent default")
835
+ * still holds; it is satisfied by a **migration** rather than by a default:
836
+ *
837
+ * 1. Key present → honoured exactly.
838
+ * 2. Key absent → resolves to `"none"`, and `migrateCredentialsOnDisk` rewrites
839
+ * the file so the answer is explicit on disk, exactly the way the v1→v2 caps
840
+ * migration normalises on load. The operator ends up with
841
+ * `"isolation": "none"` in their config plus one log line naming it, and
842
+ * nothing stops dispatching.
843
+ * 3. A new config from `setup` asks, and only offers `per-run` as the default
844
+ * when the host capability probe passes.
845
+ *
846
+ * A malformed value is rejected rather than folded to `"none"`, for the reason
847
+ * every other vocabulary here is: a misspelt `"per_run"` that silently resolved
848
+ * to unprotected would read as configured in the file while the fleet ran open.
849
+ */
850
+ function normalizeCredentials(parsed: unknown, label: string, problems: string[]): CredentialConfig {
851
+ if (parsed === undefined) return { isolation: "none" };
852
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
853
+ problems.push(
854
+ `${label}: credentials must be an object with an "isolation" of ${CREDENTIAL_ISOLATION_LIST}, ` +
855
+ `found ${JSON.stringify(parsed)}`,
856
+ );
857
+ return { isolation: "none" };
858
+ }
859
+ const raw = parsed as Raw;
860
+ const isolation = pickLiteral(
861
+ raw["isolation"],
862
+ CREDENTIAL_ISOLATIONS,
863
+ "none",
864
+ `${label}: credentials.isolation`,
865
+ CREDENTIAL_ISOLATION_LIST,
866
+ problems,
867
+ );
868
+ const rawToken = raw["readToken"];
869
+ if (rawToken !== undefined && !nonEmptyString(rawToken)) {
870
+ problems.push(
871
+ `${label}: credentials.readToken must be a non-empty string when present — an empty token is not ` +
872
+ `"no token", it is a token that fails every call`,
873
+ );
874
+ }
875
+ return {
876
+ isolation,
877
+ ...(nonEmptyString(rawToken) ? { readToken: rawToken } : {}),
878
+ };
879
+ }
880
+
881
+ /**
882
+ * The effective boundary for a project. `loadConfig` always writes one, so this
883
+ * only matters for a `ProjectConfig` some caller hand-built — and it resolves
884
+ * the same way the loader does rather than inventing a second answer.
885
+ */
886
+ export function resolveCredentials(p: ProjectConfig): CredentialConfig {
887
+ return p.credentials ?? { isolation: "none" };
888
+ }
889
+
890
+ /**
891
+ * Named projects whose on-disk config predates `credentials` — the input to the
892
+ * migration, and pure so it can be tested against a raw object.
893
+ */
894
+ export function projectsMissingCredentials(parsed: unknown): string[] {
895
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
896
+ const projects = (parsed as Raw)["projects"];
897
+ if (!Array.isArray(projects)) return [];
898
+ const missing: string[] = [];
899
+ projects.forEach((p: unknown, i) => {
900
+ if (typeof p !== "object" || p === null || Array.isArray(p)) return;
901
+ const raw = p as Raw;
902
+ const credentials = raw["credentials"];
903
+ const explicit =
904
+ typeof credentials === "object" &&
905
+ credentials !== null &&
906
+ !Array.isArray(credentials) &&
907
+ (credentials as Raw)["isolation"] !== undefined;
908
+ if (!explicit) missing.push(nonEmptyString(raw["name"]) ? raw["name"] : `projects[${String(i)}]`);
909
+ });
910
+ return missing;
911
+ }
912
+
913
+ /**
914
+ * Write the migration through, once. Returns the projects that gained an
915
+ * explicit key, so the caller can log it by name.
916
+ *
917
+ * Separate from `loadConfig` on purpose: `loadConfig` is read-only and is called
918
+ * by every CLI surface, and a loader with a write side-effect is one an
919
+ * operator cannot run against a config they are only inspecting. A failure to
920
+ * persist is logged by the caller and is **not** fatal — the in-memory value is
921
+ * already `"none"`, and refusing to boot because the config file is read-only
922
+ * would be exactly the outage this migration exists to avoid.
923
+ */
924
+ export function migrateCredentialsOnDisk(): { migrated: string[]; path: string } {
925
+ const path = configPath();
926
+ let parsed: unknown;
927
+ try {
928
+ parsed = JSON.parse(readFileSync(path, "utf8"));
929
+ } catch {
930
+ return { migrated: [], path };
931
+ }
932
+ const migrated = projectsMissingCredentials(parsed);
933
+ if (migrated.length === 0) return { migrated: [], path };
934
+ // Through the loader and back out, so the rewrite is the same normalisation
935
+ // every other key already gets rather than a second, divergent writer.
936
+ saveConfig(loadConfig());
937
+ return { migrated, path };
938
+ }
939
+
940
+ /**
941
+ * A clone URL carrying credentials voids the entire boundary (#125).
942
+ *
943
+ * `git clone` persists whatever is in the URL into the mirror's config, and the
944
+ * run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
945
+ * hands every session the write credential the rest of this release removes,
946
+ * through a file no environment scrub touches. This used to be a `ponytail`
947
+ * note on `worktree.ts`'s `ensureMirror`; a warning is not a boundary, so it is
948
+ * now rejected at load with the field named.
949
+ *
950
+ * An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
951
+ * is not a credential and is left alone — that is how nearly every fleet is
952
+ * configured, and rejecting it would be a migration nobody asked for.
953
+ */
954
+ export function cloneUrlCredentialProblem(url: string): string | undefined {
955
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
956
+ if (m === null) return undefined;
957
+ const scheme = (m[1] ?? "").toLowerCase();
958
+ const userinfo = m[2] ?? "";
959
+ if (userinfo.includes(":")) {
960
+ return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
961
+ }
962
+ if (scheme === "http" || scheme === "https") {
963
+ return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
964
+ }
965
+ return undefined;
966
+ }
967
+
435
968
  function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
436
969
  const repos: Record<string, RepoTarget> = {};
437
970
 
@@ -449,6 +982,14 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
449
982
  problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
450
983
  continue;
451
984
  }
985
+ const credentialInUrl = cloneUrlCredentialProblem(cloneUrl);
986
+ if (credentialInUrl !== undefined) {
987
+ problems.push(
988
+ `${label}: routing.repos.${key}.cloneUrl ${credentialInUrl} (#125). Use an SSH URL, or an https URL ` +
989
+ `backed by the daemon's own credential helper.`,
990
+ );
991
+ continue;
992
+ }
452
993
  const target: RepoTarget = {
453
994
  name: pickString(value?.["name"], key),
454
995
  cloneUrl,
@@ -490,6 +1031,53 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
490
1031
  return undefined;
491
1032
  }
492
1033
 
1034
+ /**
1035
+ * Extra roots the orchestrator's tool gate will let it read (#127).
1036
+ *
1037
+ * Fail-closed for the same reason `graphProject` is: the value is written by a
1038
+ * human here and *enforced* in another process, by a session whose cwd is the
1039
+ * state directory. A relative `../notes` would name a different directory for
1040
+ * every reader, so it is an error rather than something resolved against
1041
+ * whichever cwd happened to load the file — and a jail entry that resolves
1042
+ * somewhere unintended is worse than no entry at all.
1043
+ *
1044
+ * A malformed entry rejects the whole config rather than being dropped: an
1045
+ * operator who mistyped a path they believed they had granted would otherwise
1046
+ * debug a refusal that reads exactly like a correctly-denied one.
1047
+ */
1048
+ function normalizeOrchestratorReadPaths(
1049
+ parsed: unknown,
1050
+ label: string,
1051
+ problems: string[],
1052
+ ): string[] | undefined {
1053
+ if (parsed === undefined) return undefined;
1054
+ if (!Array.isArray(parsed)) {
1055
+ problems.push(`${label}.orchestratorReadPaths must be an array of absolute paths`);
1056
+ return undefined;
1057
+ }
1058
+
1059
+ const paths: string[] = [];
1060
+ parsed.forEach((entry: unknown, i) => {
1061
+ if (!nonEmptyString(entry)) {
1062
+ problems.push(
1063
+ `${label}.orchestratorReadPaths[${i}] must be a non-empty absolute path, found ${JSON.stringify(entry)}`,
1064
+ );
1065
+ return;
1066
+ }
1067
+ const path = expandHome(entry.trim());
1068
+ if (!isAbsolute(path)) {
1069
+ problems.push(
1070
+ `${label}.orchestratorReadPaths[${i}] must be an absolute path (or start with "~") — the ` +
1071
+ `orchestrator session's cwd is the state directory, not wherever this file was edited — ` +
1072
+ `found ${JSON.stringify(entry)}`,
1073
+ );
1074
+ return;
1075
+ }
1076
+ paths.push(path);
1077
+ });
1078
+ return paths.length === 0 ? undefined : paths;
1079
+ }
1080
+
493
1081
  /**
494
1082
  * Gates are the pre-push CI equivalent, so a malformed entry is an error, not
495
1083
  * something to drop quietly: a skipped gate is exactly how a lint failure
@@ -537,6 +1125,14 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
537
1125
  for (const key of CAP_KEYS) {
538
1126
  const v = raw[key];
539
1127
  if (v === undefined) continue;
1128
+ // The plan-allowance guard is the only cap that is an object rather than a
1129
+ // number, so it validates its own shape before the numeric rule below can
1130
+ // reject it wholesale.
1131
+ if (key === "planUsage") {
1132
+ const cap = coercePlanUsage(v, `${label}.planUsage`, problems);
1133
+ if (cap !== undefined) out.planUsage = cap;
1134
+ continue;
1135
+ }
540
1136
  // Spend is the only cap that may be null (= no gate). Every other ceiling
541
1137
  // is a non-negative number; 0 remains a hard stop where it already was.
542
1138
  if (key === "dailySpendUsd" && v === null) {
@@ -566,6 +1162,63 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
566
1162
  return out;
567
1163
  }
568
1164
 
1165
+ /**
1166
+ * `{ windowId, maxUsedFraction }`, or `null` for unmetered.
1167
+ *
1168
+ * Fail-closed for the same reason every other guard here is: a plan-allowance
1169
+ * cap the daemon cannot read is a ceiling the operator believes they have. Two
1170
+ * mistakes in particular are rejected by name rather than folded:
1171
+ *
1172
+ * - **`maxUsedFraction: 85`.** The threshold is a fraction, so `85` compares
1173
+ * as "hold at 8500% consumed" — a guard that can never fire, and one that
1174
+ * reads in the config file exactly like a deliberate 85% ceiling.
1175
+ * - **A misspelled key.** `windowID`, `window`, `maxUsedPercent` and friends
1176
+ * would leave a cap with a missing half, which is the same silent
1177
+ * never-fires outcome.
1178
+ *
1179
+ * A `windowId` no provider reports cannot be caught here — only a live reading
1180
+ * knows what exists — so that case is caught at admission instead, where it
1181
+ * holds dispatch and names the window (see `planUsageStatus` in `usage.ts`).
1182
+ */
1183
+ function coercePlanUsage(
1184
+ v: unknown,
1185
+ label: string,
1186
+ problems: string[],
1187
+ ): PlanUsageCap | null | undefined {
1188
+ if (v === null) return null;
1189
+ if (typeof v !== "object" || Array.isArray(v)) {
1190
+ problems.push(
1191
+ `${label} must be { windowId, maxUsedFraction } or null (unmetered), found ${JSON.stringify(v)}`,
1192
+ );
1193
+ return undefined;
1194
+ }
1195
+ const raw = v as Raw;
1196
+ const rawId = raw["windowId"];
1197
+ const rawFraction = raw["maxUsedFraction"];
1198
+ const windowId = nonEmptyString(rawId) ? rawId.trim() : undefined;
1199
+ const maxUsedFraction =
1200
+ typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1
1201
+ ? rawFraction
1202
+ : undefined;
1203
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
1204
+
1205
+ if (windowId === undefined) {
1206
+ problems.push(
1207
+ `${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`,
1208
+ );
1209
+ }
1210
+ if (maxUsedFraction === undefined) {
1211
+ problems.push(
1212
+ `${label}.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${JSON.stringify(rawFraction)}`,
1213
+ );
1214
+ }
1215
+ if (unknownKeys.length > 0) {
1216
+ problems.push(`${label} has unknown key(s): ${unknownKeys.join(", ")} — expected windowId and maxUsedFraction`);
1217
+ }
1218
+ if (windowId === undefined || maxUsedFraction === undefined || unknownKeys.length > 0) return undefined;
1219
+ return { windowId, maxUsedFraction };
1220
+ }
1221
+
569
1222
  function nonEmptyString(v: unknown): v is string {
570
1223
  return typeof v === "string" && v.trim().length > 0;
571
1224
  }