omp-conductor 0.4.5 → 0.5.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/setup-host.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir, userInfo } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { configPath, resolveCredentials, sharedRoot, stateDir } from "./config.ts";
4
+ import { configPath, stateDir } from "./config.ts";
5
5
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
6
6
  import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
7
7
  import {
@@ -18,7 +18,7 @@ import {
18
18
  TICK_CONFIG_FILE,
19
19
  type TickConfig,
20
20
  } from "./orchestrator-tick.ts";
21
- import { CONDUCTOR_GROUPS, type Caps, type ProjectConfig } from "./types.ts";
21
+ import type { Caps, ProjectConfig } from "./types.ts";
22
22
 
23
23
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
24
24
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
@@ -110,39 +110,6 @@ export function renderDaemonService(
110
110
  `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
111
111
  `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
112
112
  `Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
113
- // Both of these are per-run only, and grouped so the managed set stays one
114
- // idea. The shared root is baked in so a custom location survives systemd's
115
- // clean environment — without it the daemon resolves the platform default
116
- // while the operator provisioned somewhere else, and every dispatch is
117
- // refused for a path nobody chose. It is meaningless under `none`, where
118
- // nothing resolves it, and rendering it there would make every unit
119
- // installed before 0.4.0 look drifted for no reason (#125).
120
- //
121
- // The capabilities exist to be DROPPED INTO run children by the
122
- // privilege-dropping launcher, never inherited by them — see `setprivArgv`.
123
- // Rendered only for `per-run`, because that is the only isolation that
124
- // changes uid; granting them to a fleet that does not need them widens the
125
- // daemon for nothing. Without them the probe honestly reports `group-mode`
126
- // and a configured per-run fleet refuses every dispatch, which is the
127
- // failure an operator following setup would otherwise hit first.
128
- //
129
- // `SupplementaryGroups=` is declared rather than left to initgroups, and that
130
- // is not belt-and-braces. Supplementary groups are fixed when a process
131
- // starts, so a daemon that was running when the accounts were provisioned can
132
- // never join them — and a unit with no `User=` gets no initgroups call at
133
- // all, so it comes up with `Groups: 0` even after the group database is
134
- // correct. `probeHost` then reports `none` forever, the wizard cannot offer
135
- // `uid-pool`, and the fleet quietly keeps the fail-closed value (#153).
136
- // Declaring the membership makes it a property of the unit instead of a
137
- // property of how the unit happened to be written.
138
- ...(resolveCredentials(project).isolation === "per-run"
139
- ? [
140
- `Environment=${systemdQuote(`OMP_CONDUCTOR_SHARED=${sharedRoot()}`)}`,
141
- `SupplementaryGroups=${CONDUCTOR_GROUPS.daemon} ${CONDUCTOR_GROUPS.runs}`,
142
- "AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
143
- "CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
144
- ]
145
- : []),
146
113
  `WorkingDirectory=${systemdQuote(stateDir())}`,
147
114
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
148
115
  "Restart=on-failure",
@@ -321,160 +288,3 @@ export async function runSetupSmoke(
321
288
  export function installedUnitPath(): string {
322
289
  return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
323
290
  }
324
-
325
- /**
326
- * Why the *live* systemd unit no longer matches what this version would render.
327
- *
328
- * `planHostRuntime` compares against the **staged** copy under the state dir,
329
- * which says nothing about the file systemd booted from. That gap is not
330
- * cosmetic since 0.4.0: a fleet configured `per-run` whose installed unit
331
- * predates the capability grant probes down to `group-mode` and then refuses
332
- * every dispatch — and `upgrade` would happily verify package identities,
333
- * services, pane and ticks around it, because none of those look at the unit.
334
- *
335
- * Checked by directive rather than by whole-file equality on purpose. An
336
- * operator is entitled to hand-tune `MemoryMax`, add `After=`, or set an
337
- * `Environment=` of their own, and failing an upgrade over that would teach
338
- * them to stop running upgrades. What is reported is the set of directives this
339
- * configuration *requires* and the live unit lacks.
340
- *
341
- * `undefined` means either "no drift that matters" or "no installed unit at
342
- * all" — a host running `omp-conductor start` without systemd is not broken,
343
- * and must not be told it is.
344
- */
345
- /**
346
- * What systemd has actually loaded for the unit, as `systemctl show` reports it.
347
- *
348
- * Deliberately not the file on disk. A unit installed without `daemon-reload`
349
- * can match the rendered text byte for byte while the manager still runs the
350
- * previous configuration, and a drop-in under `…service.d/` changes what runs
351
- * without touching the file at all. Both read clean from the filesystem.
352
- */
353
- export interface EffectiveUnit {
354
- /** `systemctl show -p AmbientCapabilities`; empty string when unset. */
355
- ambientCapabilities: string;
356
- capabilityBoundingSet: string;
357
- /** `systemctl show -p SupplementaryGroups`; empty string when unset. */
358
- supplementaryGroups: string;
359
- /** `systemctl show -p Environment`, newline- or space-joined. */
360
- environment: string;
361
- needDaemonReload: boolean;
362
- }
363
-
364
- /** Whitespace-separated names as a comparable set; systemd reorders them. */
365
- function nameSet(text: string): Set<string> {
366
- return new Set(text.split(/[\s,]+/).map((n) => n.trim()).filter((n) => n.length > 0));
367
- }
368
-
369
- /** Capability names as a comparable set: systemd reports them lowercased, `cap_`-prefixed and reordered. */
370
- function capabilitySet(text: string): Set<string> {
371
- return new Set(
372
- text
373
- .split(/[\s,]+/)
374
- .map((name) => name.trim().toLowerCase().replace(/^cap_/, ""))
375
- .filter((name) => name.length > 0),
376
- );
377
- }
378
-
379
- function renderedDirective(rendered: string, key: string): string | undefined {
380
- for (const line of rendered.split("\n")) {
381
- const trimmed = line.trim();
382
- if (trimmed.startsWith(`${key}=`)) return trimmed.slice(key.length + 1);
383
- }
384
- return undefined;
385
- }
386
-
387
- /**
388
- * Why what systemd loaded no longer matches what this version would render.
389
- *
390
- * Three asymmetries, each for a reason:
391
- *
392
- * - **Ambient capabilities are compared exactly, both ways.** Missing them
393
- * breaks a per-run fleet; *leftover* ones are worse. Roll back to `none` and
394
- * there is no `setpriv` launcher at all, so ambient capabilities on the unit
395
- * are inherited straight into model-executed code, which can then `setuid()`
396
- * to any account. `systemctl` reports an empty string when unset, so the
397
- * rollback case is unambiguous.
398
- * - **The bounding set is checked only when this version sets it.** systemd
399
- * reports the full default set when a unit does not, so comparing both ways
400
- * would flag every ordinary host.
401
- * - **Only directives this configuration manages are considered**, so an
402
- * operator's own `MemoryMax`, `After=` or drop-in never fails an upgrade.
403
- */
404
- export function unitDriftReason(rendered: string, effective: EffectiveUnit | undefined): string | undefined {
405
- if (effective === undefined) return undefined;
406
- const problems: string[] = [];
407
-
408
- // Missing groups only, never extra: an operator may legitimately add their own,
409
- // and unlike a leftover capability a spare group grants nothing this package
410
- // relies on. Missing ones are load-bearing — without them the probe resolves to
411
- // `none` and a configured per-run fleet refuses every dispatch (#153).
412
- const wantGroups = nameSet(renderedDirective(rendered, "SupplementaryGroups") ?? "");
413
- const haveGroups = nameSet(effective.supplementaryGroups);
414
- const missingGroups = [...wantGroups].filter((g) => !haveGroups.has(g));
415
- if (missingGroups.length > 0) {
416
- problems.push(
417
- `SupplementaryGroups is missing ${missingGroups.join(", ")} — supplementary groups are fixed at ` +
418
- `process start, so the running daemon cannot be a member and the credential boundary probe ` +
419
- `resolves to none until the unit is reloaded and the service restarted`,
420
- );
421
- }
422
-
423
- const wantAmbient = capabilitySet(renderedDirective(rendered, "AmbientCapabilities") ?? "");
424
- const haveAmbient = capabilitySet(effective.ambientCapabilities);
425
- const missingAmbient = [...wantAmbient].filter((c) => !haveAmbient.has(c));
426
- const extraAmbient = [...haveAmbient].filter((c) => !wantAmbient.has(c));
427
- if (missingAmbient.length > 0) {
428
- problems.push(`AmbientCapabilities is missing ${missingAmbient.join(", ")}`);
429
- }
430
- if (extraAmbient.length > 0) {
431
- problems.push(
432
- `AmbientCapabilities grants ${extraAmbient.join(", ")} that this configuration does not — with ` +
433
- `isolation off there is no privilege-dropping launcher, so these are inherited by model-executed code`,
434
- );
435
- }
436
-
437
- const wantBounding = renderedDirective(rendered, "CapabilityBoundingSet");
438
- if (wantBounding !== undefined) {
439
- const missing = [...capabilitySet(wantBounding)].filter(
440
- (c) => !capabilitySet(effective.capabilityBoundingSet).has(c),
441
- );
442
- if (missing.length > 0) problems.push(`CapabilityBoundingSet is missing ${missing.join(", ")}`);
443
- }
444
-
445
- const wantShared = rendered
446
- .split("\n")
447
- .map((l) => l.trim())
448
- .find((l) => l.includes("OMP_CONDUCTOR_SHARED="));
449
- if (wantShared !== undefined) {
450
- const value = wantShared.slice(wantShared.indexOf("OMP_CONDUCTOR_SHARED=")).replace(/"$/, "");
451
- if (!effective.environment.includes(value)) {
452
- problems.push(`Environment is missing ${value}`);
453
- }
454
- }
455
-
456
- if (effective.needDaemonReload) {
457
- problems.push("systemd reports the unit needs a daemon-reload, so what runs is not what is on disk");
458
- }
459
-
460
- if (problems.length === 0) return undefined;
461
- return (
462
- `what systemd loaded for ${STAGED_SERVICE_NAME} does not match this configuration: ${problems.join("; ")}.`
463
- );
464
- }
465
-
466
- /** Parse `systemctl show -p …` key=value output into an {@link EffectiveUnit}. */
467
- export function parseEffectiveUnit(stdout: string): EffectiveUnit {
468
- const values = new Map<string, string>();
469
- for (const line of stdout.split("\n")) {
470
- const at = line.indexOf("=");
471
- if (at > 0) values.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
472
- }
473
- return {
474
- ambientCapabilities: values.get("AmbientCapabilities") ?? "",
475
- capabilityBoundingSet: values.get("CapabilityBoundingSet") ?? "",
476
- supplementaryGroups: values.get("SupplementaryGroups") ?? "",
477
- environment: values.get("Environment") ?? "",
478
- needDaemonReload: (values.get("NeedDaemonReload") ?? "no") === "yes",
479
- };
480
- }
package/src/setup.ts CHANGED
@@ -35,11 +35,11 @@ import {
35
35
  import {
36
36
  clonePolicy,
37
37
  configPath,
38
+ defaultMirrorRoot,
39
+ defaultWorkspaceRoot,
38
40
  resolveCaps,
39
- resolveCredentials,
40
41
  resolvePolicy,
41
42
  resolveReleaseGrants,
42
- projectTreeBase,
43
43
  stateDir,
44
44
  } from "./config.ts";
45
45
  import { graphProjectPath, graphRepos } from "./graph.ts";
@@ -51,8 +51,6 @@ import {
51
51
  DEFAULT_REPORT_SCOPE,
52
52
  DENIED_RELEASE_GRANTS,
53
53
  RELEASE_SHAPES,
54
- type CredentialConfig,
55
- type CredentialIsolation,
56
54
  type BaseFreshness,
57
55
  type BehindBaseAction,
58
56
  type Caps,
@@ -103,13 +101,6 @@ export interface SetupAnswers {
103
101
  * leave one to be defaulted by whichever reader gets there first.
104
102
  */
105
103
  policy: ProjectPolicy;
106
- /**
107
- * Whether model-executed code runs under its own OS principal (#125). The
108
- * wizard offers `per-run` only when the host probe says this box can build
109
- * the boundary, because an answer the host cannot honour is a fleet that
110
- * refuses to dispatch rather than a fleet that is protected.
111
- */
112
- credentials: CredentialConfig;
113
104
  /**
114
105
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
115
106
  * part of the config — the brief is the operator's file, and the conductor
@@ -174,13 +165,6 @@ export const SETUP_DEFAULTS = {
174
165
  releaseGrants: DENIED_RELEASE_GRANTS,
175
166
  /** The strictest reading of the prose these conditions replaced (#129). */
176
167
  policy: DEFAULT_PROJECT_POLICY,
177
- /**
178
- * No boundary until an operator asks for one on a host that can build it.
179
- * `none` is the honest default rather than the safe-sounding one: writing
180
- * `per-run` into a fresh config on a box with no mechanism would produce a
181
- * conductor that installs cleanly and then declines every issue (#125).
182
- */
183
- credentials: { isolation: "none" } as CredentialConfig,
184
168
  /** The daemon runs its own triage session unless an operator already runs one. */
185
169
  orchestratorMode: "embedded",
186
170
  } as const;
@@ -520,11 +504,6 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
520
504
  * with {@link answersFromProject} — the pair an amend's carry-through rests on.
521
505
  */
522
506
  export function buildProject(a: SetupAnswers): ProjectConfig {
523
- // Under a per-run boundary the worktrees and mirrors have to sit where a slot
524
- // principal can actually traverse to them. Defaulting them under the 0700
525
- // state directory would provision a repository and then be refused at
526
- // dispatch, which looks like a broken install rather than a layout choice.
527
- const dir = projectTreeBase(a.credentials.isolation);
528
507
 
529
508
  const repos: Record<string, RepoTarget> = {};
530
509
  const graphRoot = a.graphRoot?.trim();
@@ -580,14 +559,13 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
580
559
  // Written out in full for the same reason: the file then says what a merge
581
560
  // and a release require without anyone having to know a default (#129).
582
561
  policy: clonePolicy(a.policy),
583
- // Written out even when it is the default, so `status` reporting the fleet
584
- // unprotected always has a line in the file to point at (#125).
585
- credentials: { ...a.credentials },
562
+ // Written out even when it is the default, so an operator amending the
563
+ // volume has a line in the file to point at.
586
564
  reporting: { scope: a.reportScope },
587
565
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
588
566
  // uninstall, and neither can land in a repo the daemon then tries to commit.
589
- workspaceRoot: join(dir, "worktrees"),
590
- mirrorRoot: join(dir, "mirrors"),
567
+ workspaceRoot: defaultWorkspaceRoot(),
568
+ mirrorRoot: defaultMirrorRoot(),
591
569
  };
592
570
  }
593
571
 
@@ -641,7 +619,6 @@ export function defaultAnswers(projectName: string): SetupAnswers {
641
619
  authority: { ...SETUP_DEFAULTS.authority },
642
620
  releaseGrants: { ...SETUP_DEFAULTS.releaseGrants },
643
621
  policy: clonePolicy(SETUP_DEFAULTS.policy),
644
- credentials: { ...SETUP_DEFAULTS.credentials },
645
622
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
646
623
  reportScope: SETUP_DEFAULT_REPORT_SCOPE,
647
624
  writeOrchestratorBrief: false,
@@ -689,7 +666,6 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
689
666
  authority: { ...p.authority },
690
667
  releaseGrants: resolveReleaseGrants(p),
691
668
  policy: resolvePolicy(p),
692
- credentials: resolveCredentials(p),
693
669
  orchestratorMode: p.escalation.orchestrator,
694
670
  reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
695
671
  writeOrchestratorBrief: false,
@@ -808,46 +784,6 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
808
784
  * that dialog's answer un-actionable — an operator who says "yes, overwrite it"
809
785
  * must get an overwrite.
810
786
  */
811
- /**
812
- * Carry a fleet's own `POLICY.md` to a workspace root that just moved.
813
- *
814
- * `workspaceRoot` is derived from `credentials.isolation`: `none` puts it under
815
- * the 0700 state directory, `per-run` under the shared root a slot principal can
816
- * traverse. So switching a fleet to worker isolation *relocates* the briefs, and
817
- * the operator's half must go with them.
818
- *
819
- * Without this, amending isolation on an external-orchestrator fleet was a
820
- * deadlock with a data-loss escape hatch. The setup gate refused because neither
821
- * file existed at the new root; the only way past it was approving a brief write
822
- * in the same pass, and {@link writeOrchestratorBrief} renders a *fresh* POLICY.md
823
- * scaffold — silently replacing the operator's release procedure and amendment
824
- * log, and orphaning the real one at the old root.
825
- *
826
- * `ORCHESTRATOR.md` is recomposed rather than copied, because it is derived from
827
- * the package floor plus that policy on every tick anyway. The source is left in
828
- * place: this package does not delete an operator's file, and a stale copy under
829
- * the old root is inert once nothing reads it.
830
- *
831
- * Returns the moved path, or undefined when nothing needed moving — the roots
832
- * match, there was no policy to carry, or the destination already has one.
833
- */
834
- export function relocateBriefIfMoved(
835
- previous: ProjectConfig | undefined,
836
- next: ProjectConfig,
837
- ): { from: string; to: string } | undefined {
838
- if (previous === undefined) return undefined;
839
- if (previous.workspaceRoot === next.workspaceRoot) return undefined;
840
- const from = policyPathForProject(previous);
841
- const to = policyPathForProject(next);
842
- if (!existsSync(from) || existsSync(to)) return undefined;
843
-
844
- const policy = readFileSync(from, "utf8");
845
- mkdirSync(dirname(to), { recursive: true });
846
- writeFileSync(to, policy);
847
- writeFileSync(briefPathForProject(next), composeOrchestrator(renderFloorForProject(next), policy));
848
- return { from, to };
849
- }
850
-
851
787
  export function writeOrchestratorBrief(a: SetupAnswers): string {
852
788
  const project = buildProject(a);
853
789
  const policyPath = policyPathForProject(project);
@@ -1177,7 +1113,6 @@ export const AMEND_AREA_IDS = [
1177
1113
  "graph",
1178
1114
  "authority",
1179
1115
  "policy",
1180
- "credentials",
1181
1116
  "escalation",
1182
1117
  "reporting",
1183
1118
  "brief",
@@ -1283,16 +1218,6 @@ export const AMEND_AREAS: {
1283
1218
  );
1284
1219
  },
1285
1220
  },
1286
- credentials: {
1287
- name: "credential isolation",
1288
- asks: "whether worker and orchestrator sessions run under their own OS principal, or as the daemon's own user",
1289
- describe: (p) => {
1290
- const c = resolveCredentials(p);
1291
- return c.isolation === "per-run"
1292
- ? `per-run principals${c.readToken === undefined ? "" : ", with a read-scoped token"}`
1293
- : "none — sessions run as the daemon's user and can reach its credentials";
1294
- },
1295
- },
1296
1221
  escalation: {
1297
1222
  name: "escalation & triage",
1298
1223
  asks: "the tier-2 Telegram chat, whether escalations also comment, and where the orchestrator session lives",
package/src/store.ts CHANGED
@@ -15,6 +15,7 @@ import { dirname, join } from "node:path";
15
15
 
16
16
  import { stateDir } from "./config.ts";
17
17
  import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
18
+ import { startFailure } from "./failure-class.ts";
18
19
  import type {
19
20
  DecisionDraft,
20
21
  FailureClass,
@@ -678,6 +679,39 @@ export function openStore(dbPath: string): Store {
678
679
  }
679
680
  }
680
681
 
682
+ // One-time repair, and the reason it lives here rather than in the classifier:
683
+ // a turn-0 environment fault that was ALREADY classified `unknown` and
684
+ // escalated is never revisited. `runsNeedingClassification` only offers a row
685
+ // whose class is NULL, or one whose recovery has not run yet — an
686
+ // `unknown`/`escalate` row with `recoveredAt` set satisfies neither, so no
687
+ // upgrade and no later tick would ever correct it.
688
+ //
689
+ // That mattered on a real fleet. The per-run `$HOME` redirect this release
690
+ // deletes hid the harness's own credentials, so every dispatch died before it
691
+ // read its issue and each one still spent an implementation attempt. Deleting
692
+ // the redirect stops the bleeding; it does not give back attempts already
693
+ // charged, and an issue sitting at its cap for a fault that was never in its
694
+ // code stays undispatchable forever.
695
+ //
696
+ // Narrow by construction: `turns = 0` (the session never took a turn), an
697
+ // error the classifier itself recognises as a start failure, and a class of
698
+ // exactly `unknown` — a row a human or a later release classified as anything
699
+ // else is left alone. Idempotent for free: afterwards those rows read
700
+ // `env-start-failure`, which the WHERE no longer matches.
701
+ const misread = db
702
+ .query<
703
+ { id: string; lastError: string | null },
704
+ []
705
+ >(`SELECT id, lastError FROM runs WHERE failureClass = 'unknown' AND turns = 0 AND lastError IS NOT NULL`)
706
+ .all()
707
+ .filter((row) => startFailure(row.lastError ?? undefined) !== undefined);
708
+ if (misread.length > 0) {
709
+ const reclassify = db.query<unknown, [string]>(
710
+ `UPDATE runs SET failureClass = 'env-start-failure' WHERE id = ?`,
711
+ );
712
+ for (const row of misread) reclassify.run(row.id);
713
+ }
714
+
681
715
  const insertRun = db.query<unknown, SqlValue[]>(
682
716
  `INSERT INTO runs (
683
717
  id, project, issue, repo, branch, worktree, state, attempt, turns,
@@ -21,7 +21,7 @@
21
21
  * `gh auth token`; the eleven Tracker methods above it stay untouched.
22
22
  */
23
23
 
24
- import { credentialedEnv } from "../credentials.ts";
24
+ import { credentialedEnv } from "../gitops.ts";
25
25
  import { parsePrDiff } from "../diff-flags.ts";
26
26
  import type {
27
27
  IssueState,
package/src/types.ts CHANGED
@@ -457,76 +457,6 @@ export type ReleaseJustification = VerbJustification<ReleaseReason>;
457
457
 
458
458
  export type LabelJustification = VerbJustification<LabelReason>;
459
459
 
460
- /**
461
- * How hard a boundary separates model-executed code from the operator's GitHub
462
- * write credential (#125).
463
- *
464
- * Three values, ordered strongest first, because the difference between them is
465
- * a difference in what anyone can honestly claim:
466
- *
467
- * - `per-run` — every session runs as its own OS principal that cannot read the
468
- * daemon's `gh` config, keychain, `~/.ssh` or `~/.npmrc`, and cannot write a
469
- * sibling run's checkout. Satisfied **only** by the `uid-pool` and
470
- * `sandbox-exec` mechanisms.
471
- * - `group-mode` — same uid as the daemon, cross-run separation by group and
472
- * mode only. It bounds accidents and does **not** contain a determined bash
473
- * escape. It exists as a config value rather than as a fallback so that an
474
- * operator who takes it has said the weaker sentence out loud: a `per-run`
475
- * request that quietly resolved to this would let somebody believe they had
476
- * configured the full boundary and shipped the lesser one, which is precisely
477
- * the "never the silent default" failure #125 exists to end.
478
- * - `none` — the session runs as the daemon's own user and the env scrubbing in
479
- * `credentials.ts` is *all* that stands between the model and the credential,
480
- * which same-uid code defeats in one line.
481
- *
482
- * A request is never downgraded. If the host cannot build what was asked for,
483
- * dispatch refuses and names the missing mechanism.
484
- */
485
- export const CREDENTIAL_ISOLATIONS = ["per-run", "group-mode", "none"] as const;
486
-
487
- export type CredentialIsolation = (typeof CREDENTIAL_ISOLATIONS)[number];
488
-
489
- /**
490
- * What the host can actually enforce, decided by the startup probe rather than
491
- * by config. Kept separate from {@link CREDENTIAL_ISOLATIONS} on purpose: the
492
- * operator asks for a boundary, the host says which one it can build, and
493
- * `status` reports the difference instead of letting either side guess.
494
- *
495
- * - `uid-pool` — one unprivileged account per run slot, entered through a
496
- * privilege-dropping launcher. The only mechanism that claims to contain a
497
- * determined bash escape on Linux.
498
- * - `group-mode` — same uid as the daemon, cross-run separation by group and
499
- * mode only. Bounds accidents, does **not** contain an escape.
500
- * - `sandbox-exec` — the macOS equivalent of `uid-pool` for a dev host.
501
- * - `none` — no boundary at all.
502
- */
503
- export const ISOLATION_MECHANISMS = ["uid-pool", "group-mode", "sandbox-exec", "none"] as const;
504
-
505
- export type IsolationMechanism = (typeof ISOLATION_MECHANISMS)[number];
506
-
507
- /**
508
- * The two groups the filesystem model needs, named once so the probe, the
509
- * provisioning docs and the adversarial tests cannot drift apart (#125).
510
- *
511
- * `daemon` holds the daemon account **only** and is what lets it fetch, salvage
512
- * and reclaim every run repo. `runs` holds every slot principal and grants
513
- * read-only access to the shared mirror. A slot principal in `daemon` would
514
- * void the whole cross-run boundary, so the probe asserts the absence.
515
- */
516
- export const CONDUCTOR_GROUPS = { daemon: "conductor-daemon", runs: "conductor-runs" } as const;
517
-
518
- /** Per-project credential boundary settings. See {@link CREDENTIAL_ISOLATIONS}. */
519
- export interface CredentialConfig {
520
- isolation: CredentialIsolation;
521
- /**
522
- * Optional **read-scoped** token handed to sessions so they can look things
523
- * up on GitHub. Absent is the default and the safe reading: a worker then
524
- * works from its dispatch brief and the daemon's mediated verbs, which is
525
- * why the brief must not tell it to run `gh pr view`.
526
- */
527
- readToken?: string;
528
- }
529
-
530
460
  /**
531
461
  * Where the session that triages escalations lives. `embedded` is the daemon's
532
462
  * own child session; `external` means an operator already runs one — a visible
@@ -601,16 +531,6 @@ export interface ProjectConfig {
601
531
  * `resolveReportScope` rather than reaching for `.scope` directly.
602
532
  */
603
533
  reporting?: { scope: ReportScope };
604
- /**
605
- * The credential boundary this project's sessions run behind (#125).
606
- *
607
- * Optional only on disk, and only for one release: a config written before
608
- * this key existed is migrated to an explicit `{ isolation: "none" }` and
609
- * rewritten, so the operator ends up with the answer in the file rather than
610
- * inheriting a silent default. Never read directly — go through
611
- * `resolveCredentials`, which is where that migration is spelled once.
612
- */
613
- credentials?: CredentialConfig;
614
534
  /** Parent directory for per-run worktrees. */
615
535
  workspaceRoot: string;
616
536
  /** Cache of bare clones, so N runs share one fetch instead of N. */
@@ -1001,16 +921,6 @@ export type AdmissionHoldReason =
1001
921
  | "unsalvaged-wip"
1002
922
  | "daily-spend-cap"
1003
923
  | "plan-usage-cap"
1004
- /** `credentials.isolation: "per-run"` on a host whose probe found no
1005
- * mechanism. The operator asked for a boundary this host cannot build, so
1006
- * dispatch refuses rather than running unprotected under a config that says
1007
- * otherwise (#125). */
1008
- | "credential-boundary"
1009
- /** No model credential is reachable from a session's redirected `$HOME`, so
1010
- * every worker would die at turn 0 with "No model selected" and spend an issue
1011
- * attempt doing it (#152). Held rather than dispatched: an environment fault
1012
- * is not a failed implementation. */
1013
- | "no-model-credential"
1014
924
  | "unroutable:no-repo-label"
1015
925
  | "unroutable:multiple-repo-labels"
1016
926
  | "unroutable:unknown-repo";