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/worktree.ts CHANGED
@@ -1,16 +1,32 @@
1
1
  /**
2
2
  * Checkout provisioning for one run.
3
3
  *
4
- * Every issue gets its own working tree so two workers can never see each
5
- * other's half-finished edits. The trees are cut from a per-repo bare mirror
6
- * that is cloned once and refreshed, rather than a fresh full clone per issue:
7
- * a module repo's history is fetched one time and every later run pays only
8
- * for the delta.
4
+ * Every issue gets its own repository so two workers can never see each other's
5
+ * half-finished edits. Objects come from a per-repo bare mirror that is cloned
6
+ * once and refreshed, rather than a fresh full clone per issue: a module repo's
7
+ * history is fetched one time and every later run pays only for the delta.
8
+ *
9
+ * **A run gets its own repository, not a linked worktree of the mirror, and
10
+ * that is a security property rather than a preference (#125 1b).** A linked
11
+ * worktree shares the mirror's common dir — refs, objects, index locks — so it
12
+ * needs *write* access to it, which hands every run write access to every other
13
+ * run's refs. Here the objects arrive read-only through git's alternates
14
+ * mechanism and the run's refs, index and commits live in its own git dir. The
15
+ * documented residual is that a run can still *read* another run's objects out
16
+ * of the shared store: bounded, same source, no write path, no credential, and
17
+ * the price of not cloning the repo per run.
18
+ *
19
+ * Publishing is therefore the daemon's job, never the worker's — see
20
+ * `pushRunBranch` in `credentials.ts`. The run repo's `origin` deliberately
21
+ * names the real clone URL rather than the mirror, so a worker's
22
+ * `git push origin HEAD` attempts the network and fails on credentials instead
23
+ * of quietly succeeding into the shared object store.
9
24
  */
10
25
 
11
26
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
27
  import { dirname, join } from "node:path";
13
28
 
29
+ import { credentialedEnv, scrubUserinfo } from "./credentials.ts";
14
30
  import type { RepoTarget } from "./types.ts";
15
31
 
16
32
  /**
@@ -19,15 +35,12 @@ import type { RepoTarget } from "./types.ts";
19
35
  */
20
36
  const TRACKING_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
21
37
 
22
- /** Matches the `user:token@` part of any URL, so it can be blanked out. */
23
- const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g;
24
-
25
38
  /**
26
39
  * The on-disk layout, in one place. Both are pure functions of config, so a
27
40
  * caller that has to clean up *before* provisioning — the dispatcher on a
28
41
  * retry, or reconciling orphaned trees at startup — can name a mirror or a
29
42
  * tree without a network hop, and cannot drift from what `ensureMirror` and
30
- * `addWorktree` will actually create.
43
+ * `addRunRepo` will actually create.
31
44
  */
32
45
  export function mirrorPathFor(repo: RepoTarget, mirrorRoot: string): string {
33
46
  return join(mirrorRoot, `${repo.name}.git`);
@@ -48,9 +61,12 @@ async function runGit(
48
61
  stdin: "ignore",
49
62
  stdout: "pipe",
50
63
  stderr: "pipe",
51
- // An unattended dispatcher must fail loudly rather than block forever on a
52
- // credential prompt nobody is there to answer.
53
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
64
+ // Every git call in this module runs on the *privileged* side — the daemon
65
+ // provisioning, salvaging or publishing so it goes through the one named
66
+ // construction site of credential material (#125). It also carries
67
+ // GIT_TERMINAL_PROMPT=0, because an unattended dispatcher must fail loudly
68
+ // rather than block forever on a prompt nobody is there to answer.
69
+ env: credentialedEnv(),
54
70
  });
55
71
 
56
72
  const [stdout, stderr, code] = await Promise.all([
@@ -75,12 +91,7 @@ async function git(args: string[], cwd?: string): Promise<string> {
75
91
  const detail = stderr.trim() || stdout.trim() || "no output";
76
92
  // A clone URL can carry a token, and it lands in both our argv and git's
77
93
  // own error text; this string reaches logs and humans, so scrub it.
78
- throw new Error(
79
- `git ${args.join(" ")}${where} exited ${code}: ${detail}`.replace(
80
- URL_USERINFO,
81
- "$1***@",
82
- ),
83
- );
94
+ throw new Error(scrubUserinfo(`git ${args.join(" ")}${where} exited ${code}: ${detail}`));
84
95
  }
85
96
 
86
97
  return stdout.trim();
@@ -267,10 +278,24 @@ export async function ensureMirror(
267
278
  }
268
279
 
269
280
  /**
270
- * Provisions `<workspaceRoot>/<issue>` as a worktree of `repo` for `branch`,
271
- * and returns the path. On the first attempt the branch is cut from the
272
- * upstream tip of the default branch; on a retry the preserved branch is
273
- * reattached (see the comment on the add below).
281
+ * Provisions `<workspaceRoot>/<issue>` as a **repository of its own** for
282
+ * `repo`/`branch`, and returns the path. On the first attempt the branch is cut
283
+ * from the upstream tip of the default branch; on a retry the preserved branch
284
+ * is reattached out of the mirror (see the comment on the fetch below).
285
+ *
286
+ * The objects come from the shared mirror through `objects/info/alternates` —
287
+ * git's own read-only borrowing mechanism — so provisioning still costs one
288
+ * fetch of the delta rather than a full clone, exactly as the linked worktree
289
+ * it replaces did. What changed is who can write what: the run's refs, index
290
+ * and commits are its own, and nothing it does can reach a sibling run's refs
291
+ * or the mirror (#125 1b).
292
+ *
293
+ * `origin` is deliberately the real clone URL and not the mirror path. Pointing
294
+ * it at the mirror would make a worker's ordinary `git push origin HEAD`
295
+ * succeed *into the shared object store*, which is precisely the write access
296
+ * this replacement exists to remove; pointing it upstream makes that push
297
+ * attempt the network and fail on credentials it does not have, while the
298
+ * daemon publishes the branch for it (`pushRunBranch`).
274
299
  *
275
300
  * ponytail: reattachment is the only retry mode, so attempt 2 always inherits
276
301
  * attempt 1's tip — including a half-finished or broken state it might rather
@@ -279,7 +304,7 @@ export async function ensureMirror(
279
304
  * path: snapshot the branch to `refs/conductor/attempt/<issue>/<n>` before
280
305
  * resetting the run branch to `base`, and surface both refs in the escalation.
281
306
  */
282
- export async function addWorktree(
307
+ export async function addRunRepo(
283
308
  repo: RepoTarget,
284
309
  mirrorRoot: string,
285
310
  workspaceRoot: string,
@@ -287,16 +312,19 @@ export async function addWorktree(
287
312
  branch: string,
288
313
  ): Promise<{ path: string; reattached: boolean }> {
289
314
  const mirrorPath = await ensureMirror(repo, mirrorRoot);
290
- mkdirSync(workspaceRoot, { recursive: true });
315
+ // 0711: searchable, so a slot principal can reach its own checkout; not
316
+ // listable, so it cannot enumerate its siblings; not writable, so it cannot
317
+ // create or unlink one. Same reasoning as the socket parent directory.
318
+ mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
291
319
 
292
- const worktreePath = worktreePathFor(workspaceRoot, issue);
293
- if (existsSync(worktreePath)) {
320
+ const runRepo = worktreePathFor(workspaceRoot, issue);
321
+ if (existsSync(runRepo)) {
294
322
  // Reusing a tree is how one worker silently inherits another attempt's
295
323
  // uncommitted edits and pushes them under this issue's name.
296
324
  throw new Error(
297
- `worktree path already exists: ${worktreePath}. Refusing to reuse it — ` +
325
+ `worktree path already exists: ${runRepo}. Refusing to reuse it — ` +
298
326
  `it may hold a previous attempt's uncommitted work. Call ` +
299
- `removeWorktree(${mirrorPath}, ${worktreePath}) first.`,
327
+ `removeWorktree(${mirrorPath}, ${runRepo}) first.`,
300
328
  );
301
329
  }
302
330
 
@@ -305,73 +333,98 @@ export async function addWorktree(
305
333
  // under normal-clone semantics is no longer refreshed at all), so branching
306
334
  // off it would silently start a run on a stale tip.
307
335
  const base = `refs/remotes/origin/${repo.defaultBranch}`;
308
- await git(
309
- [
310
- "fetch",
311
- "--no-tags",
312
- "origin",
313
- `+refs/heads/${repo.defaultBranch}:${base}`,
314
- ],
315
- mirrorPath,
316
- );
317
-
318
- // `--no-track` on the create path because the start point is a
319
- // remote-tracking ref: without it git would set the run branch's upstream to
320
- // the default branch, and the worker's `git push` would then argue with
321
- // `push.default` instead of publishing the branch. The reattach path must
322
- // *omit* the flag — git dies with "--[no-]track can only be used if a new
323
- // branch is created" — and does not need it, since checking out an existing
324
- // branch writes no tracking config at all.
325
- //
326
- // `-b` is only ever correct on the *first* attempt for an issue. Branch
327
- // names are a deterministic function of the issue number, `removeWorktree`
328
- // deliberately leaves the branch behind in the mirror, and `git worktree add
329
- // -b <existing>` is a hard error — so without this probe a second attempt
330
- // could never provision a tree and `maxAttemptsPerIssue` was fiction.
331
- //
332
- // The retry therefore *reattaches* the existing branch rather than doing
333
- // either of the two easier things. A fresh `<branch>-attempt2` would break
334
- // the fleet contract of one issue = one branch = one PR. A `branch -D` or a
335
- // force-reset to the upstream tip would be irreversible: that branch can
336
- // hold the only copy of work attempt 1 committed but never pushed. Attaching
337
- // to it hands the new worker attempt 1's commits, so it can continue or
338
- // recover them, and any destructive call stays a human's to make.
339
- const branchExists = await gitSucceeds(
336
+ await git(["fetch", "--no-tags", "origin", `+refs/heads/${repo.defaultBranch}:${base}`], mirrorPath);
337
+
338
+ // Branch names are a deterministic function of the issue number and the
339
+ // mirror keeps the branch after a run ends, so its presence is what
340
+ // distinguishes a continuation from a first attempt. The retry *reattaches*
341
+ // rather than doing either of the two easier things: a fresh
342
+ // `<branch>-attempt2` would break the fleet contract of one issue = one
343
+ // branch = one PR, and a force-reset to upstream would be irreversible,
344
+ // because that branch can hold the only copy of work attempt 1 committed but
345
+ // never pushed. Attaching to it hands the new worker attempt 1's commits.
346
+ const reattached = await gitSucceeds(
340
347
  ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
341
348
  mirrorPath,
342
349
  );
343
- const addArgs = branchExists
344
- ? ["worktree", "add", worktreePath, branch]
345
- : ["worktree", "add", "--no-track", "-b", branch, worktreePath, base];
350
+ const start = reattached ? `refs/heads/${branch}` : base;
346
351
 
347
352
  try {
348
- await git(addArgs, mirrorPath);
349
- } catch (err) {
350
- // A hard crash can leave the branch registered to a worktree whose
351
- // directory is long gone, and git then refuses the add as "already checked
352
- // out". `prune` drops exactly that stale bookkeeping and never touches a
353
- // commit, so run it and retry once. It is not conditioned on git's wording
354
- // because that text is version- and locale-dependent; an add that is
355
- // broken for any other reason simply fails the same way twice.
356
- await git(["worktree", "prune"], mirrorPath);
357
- try {
358
- await git(addArgs, mirrorPath);
359
- } catch (retryErr) {
360
- const first = err instanceof Error ? err.message : String(err);
361
- const second =
362
- retryErr instanceof Error ? retryErr.message : String(retryErr);
363
- throw new Error(
364
- `failed to provision worktree ${worktreePath} for issue ${issue} on ` +
365
- `branch ${branch} (${branchExists ? "reattaching an existing branch" : "creating a new branch"}), ` +
366
- `both before and after \`git worktree prune\`: ${second} ` +
367
- `(first attempt: ${first})`,
368
- );
353
+ await git(["init", "--quiet", runRepo]);
354
+ // Before the first fetch, or git does not know the mirror's objects are
355
+ // reachable and the fetch below tries to copy the whole history.
356
+ const info = join(runRepo, ".git", "objects", "info");
357
+ mkdirSync(info, { recursive: true });
358
+ writeFileSync(join(info, "alternates"), `${join(mirrorPath, "objects")}\n`);
359
+
360
+ // HEAD is moved onto the run branch *after* the fetch, never before: git
361
+ // refuses to fetch into the branch HEAD is pointing at, even an unborn one,
362
+ // and the error ("refusing to fetch into branch … checked out at …") reads
363
+ // like a stale worktree registration rather than an ordering mistake. A
364
+ // fresh `git init` names its default branch, so the only way to collide is
365
+ // for the run branch to be called that — cheap to rule out, and silent
366
+ // breakage for exactly one issue number if it is not.
367
+ const initialHead = await git(["symbolic-ref", "--quiet", "HEAD"], runRepo);
368
+ if (initialHead === `refs/heads/${branch}`) {
369
+ await git(["symbolic-ref", "HEAD", "refs/heads/omp-conductor-bootstrap"], runRepo);
369
370
  }
371
+
372
+ // Fetched by path, from a remote that is never persisted: the run repo must
373
+ // not keep a handle on the mirror it could push back through. No leading
374
+ // `+` — the ref does not exist yet, so a fast-forward is trivially
375
+ // satisfied, and there is no forcing refspec anywhere on a run's own branch.
376
+ await git(["fetch", "--no-tags", mirrorPath, `${start}:refs/heads/${branch}`], runRepo);
377
+ // The upstream tracking refs, so the worker can read `origin/main` and diff
378
+ // against it. These are remote-tracking refs, which git force-updates by
379
+ // definition; the run branch above is the one that must never be forced.
380
+ await git(
381
+ ["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
382
+ runRepo,
383
+ );
384
+
385
+ // Added after the fetches so it cannot capture them: `origin` is upstream,
386
+ // and it exists so an ordinary `git push origin HEAD` fails on credentials
387
+ // rather than succeeding into the mirror. No upstream is configured for the
388
+ // run branch, for the reason the old `--no-track` existed: otherwise the
389
+ // worker's push argues with `push.default` instead of publishing.
390
+ await git(["remote", "add", "origin", repo.cloneUrl], runRepo);
391
+
392
+ await git(["symbolic-ref", "HEAD", `refs/heads/${branch}`], runRepo);
393
+ await git(["reset", "--hard"], runRepo);
394
+
395
+ // The mirror's `info/exclude` is no longer this repo's common dir, so the
396
+ // managed block has to be written here. Without it a worker's own scratch
397
+ // reaches its `git add -A`, and salvage inherits it (#44).
398
+ const exclude = join(runRepo, ".git", "info", "exclude");
399
+ mkdirSync(dirname(exclude), { recursive: true });
400
+ writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
401
+ } catch (err) {
402
+ // A half-provisioned repo would be seen as "present" by the next attempt
403
+ // and refused as possibly holding work, stranding the issue. It holds
404
+ // nothing yet — the branch's durable copy is in the mirror — so clear it.
405
+ rmSync(runRepo, { recursive: true, force: true });
406
+ throw new Error(
407
+ `failed to provision run repository ${runRepo} for issue ${issue} on branch ${branch} ` +
408
+ `(${reattached ? "reattaching an existing branch" : "creating a new branch"}): ` +
409
+ `${err instanceof Error ? err.message : String(err)}`,
410
+ );
370
411
  }
371
412
 
372
- return { path: worktreePath, reattached: branchExists };
413
+ return { path: runRepo, reattached };
373
414
  }
374
415
 
416
+ /**
417
+ * Publishes one run branch: run repo → mirror → GitHub, fast-forward only.
418
+ *
419
+ * Declared here as a function type rather than imported as a concrete
420
+ * implementation so this module stays free of the credential path: provisioning
421
+ * and salvage decide *what* to publish, `credentials.ts` is the only place that
422
+ * decides *how*, and the daemon is the only thing that holds both.
423
+ */
424
+ export type RunPublisher = (
425
+ branch: string,
426
+ ) => Promise<{ ok: true; sha: string } | { ok: false; stderr: string }>;
427
+
375
428
  /**
376
429
  * What one salvage attempt did. A failure is a *value* rather than a throw
377
430
  * because the only caller is a run that is already ending badly: it has to log
@@ -468,10 +521,10 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
468
521
  * Commits a run's uncommitted work to the run's own branch and pushes it, so
469
522
  * that the tree the next attempt destroys is no longer the only copy.
470
523
  *
471
- * This closes a deliberate asymmetry. `addWorktree` preserves the run branch
524
+ * This closes a deliberate asymmetry. `addRunRepo` preserves the run branch
472
525
  * precisely because "that branch can hold the only copy of work attempt 1
473
526
  * committed but never pushed", while `removeWorktree` runs `worktree remove
474
- * --force` and `addWorktree` refuses to reuse a tree that "may hold a previous
527
+ * --force` and `addRunRepo` refuses to reuse a tree that "may hold a previous
475
528
  * attempt's uncommitted work" — committed work is kept by design, uncommitted
476
529
  * work was discarded by design.
477
530
  *
@@ -490,18 +543,28 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
490
543
  * Never throws. Every outcome, including its own failure, comes back as a value
491
544
  * for the caller to log and to put in front of a human.
492
545
  *
493
- * The push is best-effort and deliberately last, after the sha exists: a
546
+ * Publishing is best-effort and deliberately last, after the sha exists: a
494
547
  * refused push (diverged branch, no credentials, no network) still leaves the
495
- * commit in this host's mirror, which is strictly better than nothing. It is a
496
- * plain fast-forward push never a force and if the run already had a PR
497
- * open, that PR gains the WIP commit and re-runs its checks. That is the price
498
- * of work outliving its host.
548
+ * commit in the run's own repository, which the daemon can still read, and
549
+ * which is strictly better than nothing. It is a plain fast-forward push
550
+ * never a force and if the run already had a PR open, that PR gains the WIP
551
+ * commit and re-runs its checks. That is the price of work outliving its host.
552
+ *
553
+ * `publish` is a **required** parameter rather than an optional one with a
554
+ * no-op default, and that is the whole design. The salvage commit no longer
555
+ * pushes for itself: the run's repository holds no credential (#125), so the
556
+ * network hop is the daemon's, through `pushRunBranch`. A defaulted parameter
557
+ * is how a future call site silently stops publishing salvaged work and nobody
558
+ * finds out until the host that held it is gone — which is #121's failure with
559
+ * one extra step. Passing `undefined` is allowed, and is a visible decision at
560
+ * the call site.
499
561
  */
500
562
  export async function salvageWip(
501
563
  worktree: string,
502
564
  issue: number,
503
565
  attempt: number,
504
566
  ending: string,
567
+ publish: RunPublisher | undefined,
505
568
  ): Promise<SalvageOutcome> {
506
569
  try {
507
570
  // A tree that is not there cannot be holding work. Checked before spawning
@@ -572,18 +635,17 @@ export async function salvageWip(
572
635
  };
573
636
  }
574
637
 
575
- const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
576
- if (push.code === 0) return { ...salvaged, pushed: true };
577
-
578
- return {
579
- ...salvaged,
580
- pushed: false,
581
- pushError: (
582
- push.stderr.trim() ||
583
- push.stdout.trim() ||
584
- `git push exited ${push.code}`
585
- ).replace(URL_USERINFO, "$1***@"),
586
- };
638
+ if (publish === undefined) {
639
+ return {
640
+ ...salvaged,
641
+ pushed: false,
642
+ pushError:
643
+ "no publisher was supplied — the commit exists in the run's own repository and nowhere else",
644
+ };
645
+ }
646
+ const published = await publish(branch);
647
+ if (published.ok) return { ...salvaged, pushed: true };
648
+ return { ...salvaged, pushed: false, pushError: scrubUserinfo(published.stderr) };
587
649
  } catch (err) {
588
650
  return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
589
651
  }
@@ -610,13 +672,22 @@ export async function removeWorktree(
610
672
  }
611
673
 
612
674
  if (existsSync(worktreePath)) {
675
+ // A run repo is a plain directory now, so removal is a plain removal — but
676
+ // the `worktree remove` is still attempted first, because a fleet upgrading
677
+ // into #125 has live trees that ARE linked worktrees of this mirror, and
678
+ // deleting one of those without deregistering it leaves the mirror
679
+ // believing the branch is still checked out. It refuses `worktree add` and,
680
+ // worse, refuses to delete the branch at cleanup. The failure is expected
681
+ // for a run repo and is ignored; what is not tolerated is a path that
682
+ // survives, since the next attempt for this issue would trip over it.
613
683
  try {
614
684
  await git(["worktree", "remove", "--force", worktreePath], mirrorPath);
615
- } catch (err) {
616
- // A path git does not recognise (hand-deleted, never registered) is fine
617
- // for cleanup's purposes. A path that survives the attempt is not: the
618
- // next run for this issue would trip over it.
619
- if (existsSync(worktreePath)) throw err;
685
+ } catch {
686
+ // Not a registered worktree the normal case from here on.
687
+ }
688
+ rmSync(worktreePath, { recursive: true, force: true });
689
+ if (existsSync(worktreePath)) {
690
+ throw new Error(`failed to remove run repository ${worktreePath}: the path still exists`);
620
691
  }
621
692
  }
622
693
 
@@ -664,6 +735,28 @@ export async function cleanupRetainedWorktree(
664
735
  await git(["fetch", "--prune", "origin"], mirrorPath);
665
736
 
666
737
  const ref = `refs/heads/${branch}`;
738
+
739
+ // **Ask the run's own repository first.** Its commits live in its own
740
+ // object store, not the mirror's, so a mirror-only check cannot even see
741
+ // them — it would answer "nothing unpushed" about a branch whose only copy
742
+ // is the directory this function is about to delete. That is the exact
743
+ // data loss #121 exists to prevent, reintroduced by the move to per-run
744
+ // repositories, so it is checked where the objects actually are.
745
+ if (existsSync(worktreePath)) {
746
+ await git(
747
+ ["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
748
+ worktreePath,
749
+ );
750
+ const runUnique = await git(["rev-list", ref, "--not", "--remotes"], worktreePath);
751
+ if (runUnique !== "") {
752
+ return {
753
+ kind: "retained",
754
+ reason: "unpushed",
755
+ detail: `${branch} has commits in the run repository that are absent from every remote ref`,
756
+ };
757
+ }
758
+ }
759
+
667
760
  if (await gitSucceeds(["show-ref", "--verify", "--quiet", ref], mirrorPath)) {
668
761
  const unique = await git(["rev-list", ref, "--not", "--remotes"], mirrorPath);
669
762
  if (unique !== "") {
@@ -1,11 +1,13 @@
1
1
  # Example unit for a supervised omp-conductor daemon.
2
2
  #
3
- # Why MemoryMax exists: workers are *in-process* omp sessions inside this
4
- # service's MainPID (not separate processes). With the default two workers plus
5
- # the orchestrator session, journald on a reference 7.6 GB host recorded
6
- # Memory peaks of ~3.2–4.2 GB for this unit (issue #51). That is expected load,
7
- # not a leak and on a shared VPS it is enough to thrash swap or OOM the
8
- # daemon mid-flight (orphan path).
3
+ # Why MemoryMax exists: worker and orchestrator sessions are child processes of
4
+ # this service (#125 moved them out of the daemon's own process so they can run
5
+ # as a different OS principal). They stay in this unit's cgroup, so MemoryMax
6
+ # still governs the fleet's total footprint it is just no longer a ceiling on
7
+ # one process. With the default two workers plus the orchestrator session,
8
+ # journald on a reference 7.6 GB host recorded Memory peaks of ~3.2–4.2 GB for
9
+ # this unit (issue #51). That is expected load, not a leak — and on a shared VPS
10
+ # it is enough to thrash swap or OOM the daemon mid-flight (orphan path).
9
11
  #
10
12
  # Before enabling:
11
13
  # 1. Set User=/Group=/HOME=/PATH for the account that owns ~/.omp/conductor.
@@ -14,6 +16,66 @@
14
16
  # consider MemoryMax=3G.
15
17
  # 4. Do not co-locate ClickHouse + other multi-GB services beside a 2-worker
16
18
  # fleet on a ≤8 GB box.
19
+ # 5. For credentials.isolation=per-run, do the one-time provisioning below.
20
+ # Without it the daemon runs unprotected (isolation=none) and says so in
21
+ # `omp-conductor status`.
22
+ #
23
+ # One-time provisioning for credentials.isolation=per-run (#125)
24
+ # --------------------------------------------------------------
25
+ # Model-executed code must not run as the account that holds the GitHub
26
+ # credential. Accounts, groups, a shared root and the daemon's own home mode
27
+ # are what make that true; environment scrubbing is only accident-prevention
28
+ # and does not survive a determined session running as the same uid.
29
+ #
30
+ # Do NOT hand-copy the steps. `omp-conductor boundary-setup` prints the exact
31
+ # idempotent commands, generated from the same constants the startup probe then
32
+ # checks — a hand-maintained copy here would drift from what the daemon demands
33
+ # and fail at first dispatch instead of at provisioning time. It needs no config
34
+ # and must be run BEFORE `setup`, because setup writes worktree and mirror paths
35
+ # into the shared root it creates:
36
+ #
37
+ # omp-conductor boundary-setup --slots 2 # read what it will do
38
+ # omp-conductor boundary-setup --slots 2 | sudo bash
39
+ # sudo systemctl restart omp-conductor.service
40
+ #
41
+ # What it establishes, and why each part is load-bearing:
42
+ #
43
+ # * conductor-agent-<n> per concurrent slot, plus conductor-agent-orch. The
44
+ # orchestrator's is distinct so it cannot reach a run checkout, and it is
45
+ # launched with no supplementary group at all.
46
+ # * conductor-daemon — the daemon account ONLY. Lets it fetch a run branch,
47
+ # salvage a killed run and reclaim the tree. A slot principal must NEVER be
48
+ # in it; that absence is what keeps sibling runs apart, and the probe suite
49
+ # asserts it from a live session.
50
+ # * conductor-runs — every slot. Read-only access to the shared mirror that
51
+ # run repos borrow objects from. A run being able to READ another run's
52
+ # objects there is the documented residual of sharing one object store.
53
+ # * /var/lib/omp-conductor, mode 0711 — worktrees, mirrors, per-run session
54
+ # transcripts and per-run boundary homes. OUTSIDE the state directory,
55
+ # because that stays 0700 (it holds conductor.db and the WAL SQLite keeps
56
+ # recreating, so a searchable parent would publish fleet history to every
57
+ # local account), and outside $HOME for the reason below.
58
+ # * $HOME at 0711 with credential leaves closed (.ssh, .config/gh 0700;
59
+ # .npmrc, .git-credentials 0600). Searchable because the runtime and the
60
+ # installed package live in it — a 0700 home kills every worker before it
61
+ # connects, and no shell-based probe notices — and closed at the leaves
62
+ # because that is where the boundary actually rests. The daemon re-checks
63
+ # this empirically at dispatch and refuses if a slot can read any
64
+ # credential path.
65
+ #
66
+ # The restart is REQUIRED, not tidiness: supplementary group membership is fixed
67
+ # when a process starts, so without it the daemon's live credentials lack
68
+ # conductor-daemon even though `getent` shows it, and it would chown every run
69
+ # repo to a group it cannot itself use.
70
+ #
71
+ # # util-linux, for the privilege-dropping launcher. Without it the probe
72
+ # # reports mechanism `none` — there is deliberately no hand-rolled
73
+ # # spawn({uid,gid}) fallback, because a child launched that way can hold
74
+ # # CAP_SETUID and setuid() straight back to a sibling run or to the daemon.
75
+ # sudo apt-get install -y util-linux
76
+ #
77
+ # Verify with `omp-conductor status`: the `boundary` row names the mechanism
78
+ # that is actually live and lists what it does not close.
17
79
  #
18
80
  # Install:
19
81
  # sudo install -m 0644 omp-conductor.service.example /etc/systemd/system/omp-conductor.service
@@ -34,8 +96,34 @@ User=fleet
34
96
  Group=fleet
35
97
  Environment=HOME=/home/fleet
36
98
  # systemd's default PATH has no user installs; include wherever `omp` /
37
- # `omp-conductor` and `gh` live on this host.
38
- Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin
99
+ # `omp-conductor`, `gh` and `setpriv` live on this host.
100
+ Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin
101
+
102
+ # These capabilities exist to be DROPPED INTO run children, never inherited by
103
+ # them (#125). The daemon stays the unprivileged `fleet` account — a capability
104
+ # grant on an existing account is a narrower blast radius than running as root
105
+ # or shipping a setuid binary, both of which widen exactly what this exists to
106
+ # narrow.
107
+ #
108
+ # Every run child is launched through `setpriv`, which sets the group list, then
109
+ # the gid, then the uid, empties the permitted/effective/inheritable/ambient
110
+ # capability sets, drops the bounding set, sets PR_SET_NO_NEW_PRIVS, and only
111
+ # then execs. DO NOT "simplify" this into a raw spawn({uid,gid}): ambient
112
+ # capabilities survive execve for ordinary binaries, so a child launched that
113
+ # way holds CAP_SETUID itself and can setuid() back to another principal —
114
+ # including a sibling run's. That voids the entire boundary while appearing to
115
+ # work, which is the worst possible outcome for a security change.
116
+ #
117
+ # CAP_SETPCAP is present solely so the launcher can empty the child's capability
118
+ # BOUNDING set (PR_CAPBSET_DROP requires it in the caller's own permitted set).
119
+ # It is dropped along with everything else before the session child execs. A
120
+ # host that refuses to grant it takes the documented fallback instead: the
121
+ # launcher omits --bounding-set=-all, the child still ends with every other set
122
+ # empty behind NoNewPrivs, and `status` reports the non-empty CapBnd as a named
123
+ # residual rather than ignoring it.
124
+ AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
125
+ CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
126
+
39
127
  WorkingDirectory=/home/fleet
40
128
 
41
129
  # Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;