omp-conductor 0.4.5 → 0.5.1
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/README.md +66 -287
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +0 -56
- package/skills/conductor-update/SKILL.md +4 -9
- package/src/briefs/orchestrator.md +13 -19
- package/src/briefs/worker.md +18 -16
- package/src/cli.ts +1 -48
- package/src/config.ts +7 -224
- package/src/confinement.ts +2 -3
- package/src/daemon.ts +23 -477
- package/src/failure-class.ts +6 -1
- package/src/fleet.ts +1 -85
- package/src/gitops.ts +189 -0
- package/src/omp.ts +13 -35
- package/src/orchestrator.ts +0 -4
- package/src/plugin.ts +0 -64
- package/src/session-host.ts +12 -19
- package/src/setup-host.ts +2 -192
- package/src/setup.ts +6 -81
- package/src/store.ts +34 -0
- package/src/tracker/github.ts +1 -1
- package/src/types.ts +0 -90
- package/src/upgrade.ts +0 -90
- package/src/verbs/actions.ts +1 -1
- package/src/verbs/server.ts +3 -36
- package/src/verbs/socket.ts +42 -91
- package/src/worker.ts +1 -13
- package/src/worktree.ts +8 -6
- package/systemd/omp-conductor.service.example +8 -102
- package/src/credentials.ts +0 -2203
package/src/upgrade.ts
CHANGED
|
@@ -7,11 +7,8 @@ import { configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from
|
|
|
7
7
|
import { renderBriefForProject } from "./setup.ts";
|
|
8
8
|
import {
|
|
9
9
|
STAGED_SERVICE_NAME,
|
|
10
|
-
parseEffectiveUnit,
|
|
11
10
|
planHostRuntime,
|
|
12
|
-
unitDriftReason,
|
|
13
11
|
writeHostRuntime,
|
|
14
|
-
type EffectiveUnit,
|
|
15
12
|
} from "./setup-host.ts";
|
|
16
13
|
|
|
17
14
|
const PACKAGE = "omp-conductor";
|
|
@@ -41,15 +38,6 @@ export interface UpgradeResult {
|
|
|
41
38
|
dispatch: DispatchLayer;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
/** What the unit check reads, and how it repairs what it can. */
|
|
45
|
-
export interface UnitFiles {
|
|
46
|
-
rendered: string;
|
|
47
|
-
/** What systemd loaded, not what is on disk. Undefined means no systemd here. */
|
|
48
|
-
effective: EffectiveUnit | undefined;
|
|
49
|
-
/** Writes the corrected unit to the state dir; returns the commands root must run. */
|
|
50
|
-
stage?(): readonly string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
41
|
export interface UpgradeDeps {
|
|
54
42
|
run(command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
|
|
55
43
|
snapshot(project?: string): { liveWorkers: number };
|
|
@@ -61,12 +49,6 @@ export interface UpgradeDeps {
|
|
|
61
49
|
sleep(ms: number): Promise<void>;
|
|
62
50
|
env: NodeJS.ProcessEnv;
|
|
63
51
|
log(message: string): void;
|
|
64
|
-
/**
|
|
65
|
-
* What this version would render as the unit, and what systemd actually
|
|
66
|
-
* booted from. Injected so the drift check is testable without a systemd host
|
|
67
|
-
* — and defaulted, so no production caller has to know it exists.
|
|
68
|
-
*/
|
|
69
|
-
unitFiles?(): Promise<UnitFiles | undefined> | UnitFiles | undefined;
|
|
70
52
|
}
|
|
71
53
|
|
|
72
54
|
async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
|
|
@@ -450,51 +432,6 @@ async function rollbackUpgrade(
|
|
|
450
432
|
if (failures.length > 0) throw new Error(failures.join("; "));
|
|
451
433
|
}
|
|
452
434
|
|
|
453
|
-
/**
|
|
454
|
-
* The rendered-vs-installed pair for {@link unitDriftReason}.
|
|
455
|
-
*
|
|
456
|
-
* Best-effort by design: a host with no config, no project, or no systemd is
|
|
457
|
-
* not a host with a broken unit, and an upgrade must not fail because it could
|
|
458
|
-
* not answer a question that does not apply there.
|
|
459
|
-
*/
|
|
460
|
-
async function defaultUnitFiles(deps: UpgradeDeps): Promise<UnitFiles | undefined> {
|
|
461
|
-
try {
|
|
462
|
-
const cfg = loadConfig();
|
|
463
|
-
const project = cfg.projects[0];
|
|
464
|
-
if (project === undefined) return undefined;
|
|
465
|
-
const plan = planHostRuntime(project, resolveCaps(project, cfg.defaults), telegramStateDir());
|
|
466
|
-
const shown = await deps.run("systemctl", [
|
|
467
|
-
"show",
|
|
468
|
-
STAGED_SERVICE_NAME,
|
|
469
|
-
"-p",
|
|
470
|
-
"AmbientCapabilities",
|
|
471
|
-
"-p",
|
|
472
|
-
"CapabilityBoundingSet",
|
|
473
|
-
"-p",
|
|
474
|
-
"SupplementaryGroups",
|
|
475
|
-
"-p",
|
|
476
|
-
"Environment",
|
|
477
|
-
"-p",
|
|
478
|
-
"NeedDaemonReload",
|
|
479
|
-
]);
|
|
480
|
-
return {
|
|
481
|
-
rendered: plan.service.content,
|
|
482
|
-
effective: shown.code === 0 ? parseEffectiveUnit(shown.stdout) : undefined,
|
|
483
|
-
// Staging needs no privilege, so the repair is reduced to the two lines
|
|
484
|
-
// that genuinely do. Installing the unit is root's, and this command runs
|
|
485
|
-
// as the fleet user by design — so it goes as far as it can and then says
|
|
486
|
-
// exactly what is left, rather than sending the operator back through a
|
|
487
|
-
// wizard to regenerate a file it could write itself.
|
|
488
|
-
stage: () => {
|
|
489
|
-
writeHostRuntime(plan);
|
|
490
|
-
return plan.installCommands;
|
|
491
|
-
},
|
|
492
|
-
};
|
|
493
|
-
} catch {
|
|
494
|
-
return undefined;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
435
|
export async function upgradeConductor(
|
|
499
436
|
options: UpgradeOptions = {},
|
|
500
437
|
overrides: Partial<UpgradeDeps> = {},
|
|
@@ -521,22 +458,6 @@ export async function upgradeConductor(
|
|
|
521
458
|
const surfaces = await inspectSurfaces(deps);
|
|
522
459
|
const brief = deps.brief(project);
|
|
523
460
|
const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
|
|
524
|
-
// Computed BEFORE the no-op decision, not inside verification, and that
|
|
525
|
-
// placement is the whole point. The upgrade that installs a version is run by
|
|
526
|
-
// the *previous* CLI, so a check living only in the new code never executes
|
|
527
|
-
// for the release that introduces it — and re-running afterwards would take
|
|
528
|
-
// the already-current early return and skip it forever. A fleet whose unit is
|
|
529
|
-
// stale is not current, whatever its package identities say.
|
|
530
|
-
const files = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
|
|
531
|
-
const drift = files === undefined ? undefined : unitDriftReason(files.rendered, files.effective);
|
|
532
|
-
if (drift !== undefined) {
|
|
533
|
-
const commands = files?.stage?.() ?? [];
|
|
534
|
-
throw new Error(
|
|
535
|
-
commands.length === 0
|
|
536
|
-
? drift
|
|
537
|
-
: `${drift}\n\nThe corrected unit has been staged. Run:\n${commands.map((c) => ` ${c}`).join("\n")}`,
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
461
|
if (!installNeeded && brief.current) {
|
|
541
462
|
return {
|
|
542
463
|
previousVersion: surfaces.cliVersion,
|
|
@@ -631,17 +552,6 @@ export async function upgradeConductor(
|
|
|
631
552
|
const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
|
|
632
553
|
if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
|
|
633
554
|
}
|
|
634
|
-
// The unit is the one surface `upgrade` never looked at, and since 0.4.0 it
|
|
635
|
-
// is the difference between a working per-run fleet and one that refuses
|
|
636
|
-
// every issue. Fatal rather than a warning: the whole point of this command
|
|
637
|
-
// is that a fleet is either upgraded or explicitly left paused, and a
|
|
638
|
-
// "verified" fleet that cannot dispatch is the worse outcome.
|
|
639
|
-
// Re-read rather than trust the earlier pass: the daemon has restarted
|
|
640
|
-
// since, and an operator who installed a unit mid-upgrade should not get a
|
|
641
|
-
// green verification for a file nobody checked.
|
|
642
|
-
const after = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
|
|
643
|
-
const stillDrifted = after === undefined ? undefined : unitDriftReason(after.rendered, after.effective);
|
|
644
|
-
if (stillDrifted !== undefined) throw new Error(stillDrifted);
|
|
645
555
|
await waitForRecovery(deps, initial, project);
|
|
646
556
|
deps.log("verify 2/2: recovered fleet remains stable");
|
|
647
557
|
await deps.sleep(1_000);
|
package/src/verbs/actions.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* enough to actually write.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../
|
|
17
|
+
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../gitops.ts";
|
|
18
18
|
import type { ProjectConfig, ReleaseShape } from "../types.ts";
|
|
19
19
|
import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
20
20
|
|
package/src/verbs/server.ts
CHANGED
|
@@ -51,21 +51,11 @@ import {
|
|
|
51
51
|
secureBoundSocket,
|
|
52
52
|
socketFd,
|
|
53
53
|
unlinkStaleSocket,
|
|
54
|
-
traversalProblem,
|
|
55
54
|
validateSocketPath,
|
|
56
55
|
type PeerReader,
|
|
57
56
|
type SocketOwnership,
|
|
58
57
|
} from "./socket.ts";
|
|
59
58
|
|
|
60
|
-
/**
|
|
61
|
-
* The OS principal a run was allocated (#125).
|
|
62
|
-
*
|
|
63
|
-
* Imported as a type from the credential boundary rather than redeclared, so
|
|
64
|
-
* there is one definition of "which uid is this run": a second copy is a second
|
|
65
|
-
* place identity is decided, which is the bug both issues exist to remove.
|
|
66
|
-
*/
|
|
67
|
-
import type { SlotPrincipal } from "../credentials.ts";
|
|
68
|
-
|
|
69
59
|
/**
|
|
70
60
|
* How long a merge lock may be held before another daemon may break it.
|
|
71
61
|
*
|
|
@@ -101,17 +91,15 @@ export type VerbChannel =
|
|
|
101
91
|
issue: number;
|
|
102
92
|
/** The routed checkout, for the privileged push/PR half. */
|
|
103
93
|
repo: RepoTarget;
|
|
104
|
-
/** The run's own repository on disk
|
|
94
|
+
/** The run's own repository on disk. */
|
|
105
95
|
runRepoPath: string;
|
|
106
96
|
branch: string;
|
|
107
|
-
principal?: SlotPrincipal;
|
|
108
97
|
}
|
|
109
98
|
| {
|
|
110
99
|
kind: "orchestrator";
|
|
111
100
|
path: string;
|
|
112
101
|
project: string;
|
|
113
102
|
role: "orchestrator";
|
|
114
|
-
principal?: SlotPrincipal;
|
|
115
103
|
};
|
|
116
104
|
|
|
117
105
|
export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
|
|
@@ -946,13 +934,6 @@ export interface ListenOptions {
|
|
|
946
934
|
daemonUid?: number;
|
|
947
935
|
/** Injected in tests; the real one chowns, which needs privilege. */
|
|
948
936
|
secure?: typeof secureBoundSocket;
|
|
949
|
-
/**
|
|
950
|
-
* Injected in tests for the same reason `secure` is: the traversal rule is
|
|
951
|
-
* about the *deployment's* directory chain, and a test that needs a bound
|
|
952
|
-
* socket in a developer's `0750` home is not the case it is asserting. The
|
|
953
|
-
* rule itself is pinned directly in `socket.test.ts`.
|
|
954
|
-
*/
|
|
955
|
-
traversal?: typeof traversalProblem;
|
|
956
937
|
}
|
|
957
938
|
|
|
958
939
|
/**
|
|
@@ -978,20 +959,6 @@ export async function listenVerbChannel(
|
|
|
978
959
|
"owned by the daemon (or root), free of symlinks, and unwritable by anyone else.",
|
|
979
960
|
);
|
|
980
961
|
}
|
|
981
|
-
// Traversal, proven rather than assumed, and only when it can actually fail:
|
|
982
|
-
// a run with its own principal reaches this socket by searching every
|
|
983
|
-
// component, and the daemon's state directory is 0700. Refusing here names
|
|
984
|
-
// the directory and the fix; letting it bind would produce an EACCES inside
|
|
985
|
-
// a worker turn, about a path nobody was thinking about.
|
|
986
|
-
if (channel.principal !== undefined) {
|
|
987
|
-
const blocked = (opts.traversal ?? traversalProblem)(channel.path);
|
|
988
|
-
if (blocked !== undefined) {
|
|
989
|
-
throw new VerbSocketRefusal(
|
|
990
|
-
`dispatch refused: ${blocked.message}. This run has its own OS principal, so it must be able to ` +
|
|
991
|
-
"traverse to its socket; binding one it cannot reach would take its verbs away silently.",
|
|
992
|
-
);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
962
|
// Only the daemon ever unlinks these, and it has just proven the parent is
|
|
996
963
|
// not writable by anyone else — which is what makes unlink-then-bind safe
|
|
997
964
|
// here and a race in a world-writable directory.
|
|
@@ -1013,7 +980,7 @@ export async function listenVerbChannel(
|
|
|
1013
980
|
});
|
|
1014
981
|
|
|
1015
982
|
const secure = opts.secure ?? secureBoundSocket;
|
|
1016
|
-
const ownership = secure(channel.path
|
|
983
|
+
const ownership = secure(channel.path);
|
|
1017
984
|
|
|
1018
985
|
return {
|
|
1019
986
|
path: channel.path,
|
|
@@ -1037,7 +1004,7 @@ function handleConnection(
|
|
|
1037
1004
|
): void {
|
|
1038
1005
|
const fd = socketFd(socket);
|
|
1039
1006
|
const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
|
|
1040
|
-
const verdict = peerVerdict(
|
|
1007
|
+
const verdict = peerVerdict({ uid: process.getuid?.() ?? 0 }, peer);
|
|
1041
1008
|
if (!verdict.ok) {
|
|
1042
1009
|
// Not a client error, and deliberately not answered: a caller who is not
|
|
1043
1010
|
// who the socket was allocated to gets no reply to calibrate against.
|
package/src/verbs/socket.ts
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The authenticated local transport the mutation verbs ride on (#126).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* *
|
|
7
|
-
*
|
|
4
|
+
* **What the permission layout does and does not buy, stated plainly.** Every
|
|
5
|
+
* session is a child process running as the daemon's own uid. So the modes here
|
|
6
|
+
* keep *other local accounts* out; they do not restrain a session, because a
|
|
7
|
+
* session matches the owner class and owner bits are `rwx`. A session that
|
|
8
|
+
* wanted to list the socket directory, unlink a sibling's socket and bind an
|
|
9
|
+
* impostor in its place could do so, and nothing in this file would stop it.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - the parent being non-writable is what stops a run unlinking a sibling's
|
|
14
|
-
* socket or binding an impostor listener in its place. A per-run directory
|
|
15
|
-
* owned by the run principal would hand back exactly that power, and is why
|
|
16
|
-
* the obvious layout is not used here.
|
|
11
|
+
* That is not a hole this file is hiding — it is the consequence of running
|
|
12
|
+
* sessions as the daemon. What restrains a session is the mechanical worktree
|
|
13
|
+
* gate on its structured tools plus the fact that publication only happens
|
|
14
|
+
* through the daemon, and what makes a bad push *visible* is the verb ledger.
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
* {@link validateSocketPath} refuses to bind under a path anyone else could
|
|
20
|
-
* have tampered with, and {@link peerVerdict} compares the connecting uid the
|
|
21
|
-
* kernel reports against the uid the daemon allocated for that run.
|
|
16
|
+
* So the layout is:
|
|
22
17
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
* - the parent directory is daemon-owned and mode **`0711`** — searchable so a
|
|
19
|
+
* caller reaches a socket it can already name, not listable and not writable
|
|
20
|
+
* by any account other than the daemon's;
|
|
21
|
+
* - each socket is `0600`, so only the daemon's uid can connect at all;
|
|
22
|
+
* - the per-socket suffix is random, so the path is not guessable by a foreign
|
|
23
|
+
* account that cannot list the directory.
|
|
24
|
+
*
|
|
25
|
+
* Two further rules make that hold rather than merely describe it:
|
|
26
|
+
* {@link validateSocketPath} refuses to bind under a path anyone else could have
|
|
27
|
+
* tampered with, and {@link peerVerdict} compares the connecting uid the kernel
|
|
28
|
+
* reports against the daemon's own.
|
|
29
|
+
*
|
|
30
|
+
* Every decision in this file is a pure function of `lstat` results, so the
|
|
31
|
+
* adversarial cases are testable without root and without a second uid.
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
import { dlopen, FFIType, ptr, suffix } from "bun:ffi";
|
|
@@ -31,10 +37,10 @@ import { chmodSync, chownSync, lstatSync, mkdirSync, rmSync } from "node:fs";
|
|
|
31
37
|
import type { Stats } from "node:fs";
|
|
32
38
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
33
39
|
|
|
34
|
-
/** Searchable
|
|
40
|
+
/** Searchable, but not listable or writable by any account but the daemon's. */
|
|
35
41
|
export const VERB_DIR_MODE = 0o711;
|
|
36
42
|
|
|
37
|
-
/**
|
|
43
|
+
/** Only the daemon's uid may connect. The parent's `0711` keeps that reachable. */
|
|
38
44
|
export const VERB_SOCKET_MODE = 0o600;
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -196,53 +202,6 @@ export function validateSocketPath(
|
|
|
196
202
|
return undefined;
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
/**
|
|
200
|
-
* The first path component a *foreign* uid could not search through, or
|
|
201
|
-
* `undefined` when the whole chain is traversable.
|
|
202
|
-
*
|
|
203
|
-
* Checked rather than assumed, because this is the failure the layout in this
|
|
204
|
-
* file is most likely to hit in the field and the least likely to be noticed:
|
|
205
|
-
* connecting to a socket needs `+x` on **every** component, and the daemon's
|
|
206
|
-
* own state directory is `0700`. A run principal would then be refused at the
|
|
207
|
-
* state directory rather than at the socket, with an `EACCES` that names a
|
|
208
|
-
* directory nobody was thinking about.
|
|
209
|
-
*
|
|
210
|
-
* Only meaningful when the run has a distinct principal. Under one uid the
|
|
211
|
-
* daemon is the caller, so `0700` is traversable by definition — which is
|
|
212
|
-
* exactly why this cannot be left to be discovered on the first fleet that
|
|
213
|
-
* turns per-run principals on.
|
|
214
|
-
*
|
|
215
|
-
* Deliberately reported rather than repaired: the fix is `chmod o+x` on a
|
|
216
|
-
* directory that may be the operator's home, and a daemon that silently
|
|
217
|
-
* widened `$HOME` would be trading a legible refusal for a surprise.
|
|
218
|
-
*/
|
|
219
|
-
export function traversalProblem(
|
|
220
|
-
path: string,
|
|
221
|
-
lstat: (p: string) => Stats = lstatSync,
|
|
222
|
-
): SocketPathProblem | undefined {
|
|
223
|
-
for (const component of ancestors(path)) {
|
|
224
|
-
let stat: Stats;
|
|
225
|
-
try {
|
|
226
|
-
stat = lstat(component);
|
|
227
|
-
} catch {
|
|
228
|
-
return {
|
|
229
|
-
component,
|
|
230
|
-
fault: "missing",
|
|
231
|
-
message: `verb socket parent ${component} does not exist or cannot be read`,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
if ((stat.mode & 0o001) === 0) {
|
|
235
|
-
return {
|
|
236
|
-
component,
|
|
237
|
-
fault: "not-searchable",
|
|
238
|
-
message:
|
|
239
|
-
`verb socket parent ${component} is mode ${(stat.mode & 0o7777).toString(8)}, which a run principal ` +
|
|
240
|
-
`cannot search through. Connecting needs +x on every component. Run: chmod o+x ${component}`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return undefined;
|
|
245
|
-
}
|
|
246
205
|
|
|
247
206
|
/**
|
|
248
207
|
* Remove a stale socket at `path`, and only ever one the daemon itself placed.
|
|
@@ -256,26 +215,22 @@ export function unlinkStaleSocket(path: string): void {
|
|
|
256
215
|
}
|
|
257
216
|
|
|
258
217
|
/**
|
|
259
|
-
*
|
|
218
|
+
* Restrict a bound socket to the daemon's own uid.
|
|
260
219
|
*
|
|
261
|
-
* Returns
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
220
|
+
* Returns the guarantee in force, because #126 asks the daemon to *state* its
|
|
221
|
+
* mechanism at startup rather than guess: the socket is `0600` under a `0711`
|
|
222
|
+
* daemon-owned parent, so it is one-user, and the unguessable suffix plus that
|
|
223
|
+
* parent are the whole story. Saying so is the difference between an audited
|
|
224
|
+
* boundary and a hopeful one.
|
|
266
225
|
*/
|
|
267
|
-
export type SocketOwnership = "
|
|
226
|
+
export type SocketOwnership = "daemon-user";
|
|
268
227
|
|
|
269
228
|
export function secureBoundSocket(
|
|
270
229
|
path: string,
|
|
271
|
-
principal: { uid: number; gid: number } | undefined,
|
|
272
230
|
chmod: (p: string, mode: number) => void = chmodSync,
|
|
273
|
-
chown: (p: string, uid: number, gid: number) => void = chownSync,
|
|
274
231
|
): SocketOwnership {
|
|
275
232
|
chmod(path, VERB_SOCKET_MODE);
|
|
276
|
-
|
|
277
|
-
chown(path, principal.uid, principal.gid);
|
|
278
|
-
return "run-principal";
|
|
233
|
+
return "daemon-user";
|
|
279
234
|
}
|
|
280
235
|
|
|
281
236
|
export interface PeerCredentials {
|
|
@@ -406,14 +361,14 @@ export function peerVerdict(
|
|
|
406
361
|
return {
|
|
407
362
|
ok: true,
|
|
408
363
|
basis: "socket-ownership",
|
|
409
|
-
why: "
|
|
364
|
+
why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is the whole boundary",
|
|
410
365
|
};
|
|
411
366
|
}
|
|
412
367
|
if (peer === undefined) {
|
|
413
368
|
return {
|
|
414
369
|
ok: true,
|
|
415
370
|
basis: "socket-ownership",
|
|
416
|
-
why: "this host exposes no peer credentials, so the 0600 socket
|
|
371
|
+
why: "this host exposes no peer credentials, so the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
|
|
417
372
|
};
|
|
418
373
|
}
|
|
419
374
|
if (peer.uid !== expected.uid) {
|
|
@@ -428,19 +383,15 @@ export function peerVerdict(
|
|
|
428
383
|
}
|
|
429
384
|
|
|
430
385
|
/** One line naming what the transport can actually enforce on this host. */
|
|
431
|
-
export function transportBanner(
|
|
432
|
-
dir: string,
|
|
433
|
-
peerReader: PeerReader | undefined,
|
|
434
|
-
principals: boolean,
|
|
435
|
-
): string {
|
|
386
|
+
export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
|
|
436
387
|
const peers =
|
|
437
388
|
peerReader === undefined
|
|
438
389
|
? `no peer-credential call on ${process.platform}`
|
|
439
390
|
: process.platform === "darwin"
|
|
440
391
|
? "peer uid asserted with getpeereid"
|
|
441
392
|
: "peer uid asserted with SO_PEERCRED";
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
393
|
+
return (
|
|
394
|
+
`verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
|
|
395
|
+
`each socket 0600 under the daemon's own uid; ${peers}`
|
|
396
|
+
);
|
|
446
397
|
}
|
package/src/worker.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* sliding into a merge queue.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { SessionBoundary } from "./credentials.ts";
|
|
14
13
|
import { createSession, disposeSession } from "./omp.ts";
|
|
15
14
|
import type { Caps, ReleaseShape, ResolvedGrants, RunState } from "./types.ts";
|
|
16
15
|
|
|
@@ -54,17 +53,7 @@ export interface WorkerOpts {
|
|
|
54
53
|
releaseGrants?: ResolvedGrants;
|
|
55
54
|
/** Durable audit sink for rejected release/deploy calls. */
|
|
56
55
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
57
|
-
/**
|
|
58
|
-
* The OS principal this run's session executes as (#125). The worker does not
|
|
59
|
-
* interpret it — it hands it to {@link createSession}, which is where the
|
|
60
|
-
* launcher lives. Omitted, the session is still a child process but runs as
|
|
61
|
-
* the daemon's own user: that is A1's shape and carries no security claim.
|
|
62
|
-
*/
|
|
63
|
-
boundary?: SessionBoundary;
|
|
64
|
-
/**
|
|
65
|
-
* Control socket for that child. The daemon puts it inside the run's own
|
|
66
|
-
* boundary root, because a slot principal has to be able to reach it.
|
|
67
|
-
*/
|
|
56
|
+
/** Control socket for that child, beside the run's own session directory. */
|
|
68
57
|
socketPath?: string;
|
|
69
58
|
/**
|
|
70
59
|
* The run's conductor verb socket (#126) — its only route to a push, a PR or
|
|
@@ -206,7 +195,6 @@ export async function runWorker(
|
|
|
206
195
|
role: "worker",
|
|
207
196
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
208
197
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
209
|
-
...(o.boundary === undefined ? {} : { boundary: o.boundary }),
|
|
210
198
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
211
199
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
212
200
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
package/src/worktree.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the price of not cloning the repo per run.
|
|
18
18
|
*
|
|
19
19
|
* Publishing is therefore the daemon's job, never the worker's — see
|
|
20
|
-
* `pushRunBranch` in `
|
|
20
|
+
* `pushRunBranch` in `gitops.ts`. The run repo's `origin` deliberately
|
|
21
21
|
* names the real clone URL rather than the mirror, so a worker's
|
|
22
22
|
* `git push origin HEAD` attempts the network and fails on credentials instead
|
|
23
23
|
* of quietly succeeding into the shared object store.
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
27
|
import { dirname, join } from "node:path";
|
|
28
28
|
|
|
29
|
-
import { credentialedEnv, scrubUserinfo } from "./
|
|
29
|
+
import { credentialedEnv, scrubUserinfo } from "./gitops.ts";
|
|
30
30
|
import type { RepoTarget } from "./types.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -312,9 +312,11 @@ export async function addRunRepo(
|
|
|
312
312
|
branch: string,
|
|
313
313
|
): Promise<{ path: string; reattached: boolean }> {
|
|
314
314
|
const mirrorPath = await ensureMirror(repo, mirrorRoot);
|
|
315
|
-
// 0711: searchable
|
|
316
|
-
//
|
|
317
|
-
//
|
|
315
|
+
// 0711: searchable but not listable, so no *other local account* can
|
|
316
|
+
// enumerate the fleet's checkouts. It does not stop a session reaching a
|
|
317
|
+
// sibling's tree: sessions run as the daemon's own uid, so the owner bits are
|
|
318
|
+
// theirs. What confines a worker to its own checkout is the mechanical
|
|
319
|
+
// worktree gate on its tools, not this mode.
|
|
318
320
|
mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
|
|
319
321
|
|
|
320
322
|
const runRepo = worktreePathFor(workspaceRoot, issue);
|
|
@@ -418,7 +420,7 @@ export async function addRunRepo(
|
|
|
418
420
|
*
|
|
419
421
|
* Declared here as a function type rather than imported as a concrete
|
|
420
422
|
* implementation so this module stays free of the credential path: provisioning
|
|
421
|
-
* and salvage decide *what* to publish, `
|
|
423
|
+
* and salvage decide *what* to publish, `gitops.ts` is the only place that
|
|
422
424
|
* decides *how*, and the daemon is the only thing that holds both.
|
|
423
425
|
*/
|
|
424
426
|
export type RunPublisher = (
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# Example unit for a supervised omp-conductor daemon.
|
|
2
2
|
#
|
|
3
3
|
# Why MemoryMax exists: worker and orchestrator sessions are child processes of
|
|
4
|
-
# this service
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# it is enough to thrash swap or OOM the daemon mid-flight (orphan path).
|
|
4
|
+
# this service and stay inside its cgroup, so MemoryMax governs the whole
|
|
5
|
+
# fleet's footprint rather than one process. With the default two workers plus
|
|
6
|
+
# the orchestrator session, journald on a reference 7.6 GB host recorded Memory
|
|
7
|
+
# peaks of ~3.2–4.2 GB for this unit (issue #51). That is expected load, not a
|
|
8
|
+
# leak — and on a shared VPS it is enough to thrash swap or OOM the daemon
|
|
9
|
+
# mid-flight (orphan path).
|
|
11
10
|
#
|
|
12
11
|
# Before enabling:
|
|
13
12
|
# 1. Set User=/Group=/HOME=/PATH for the account that owns ~/.omp/conductor.
|
|
@@ -16,66 +15,6 @@
|
|
|
16
15
|
# consider MemoryMax=3G.
|
|
17
16
|
# 4. Do not co-locate ClickHouse + other multi-GB services beside a 2-worker
|
|
18
17
|
# 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.
|
|
79
18
|
#
|
|
80
19
|
# Install:
|
|
81
20
|
# sudo install -m 0644 omp-conductor.service.example /etc/systemd/system/omp-conductor.service
|
|
@@ -95,43 +34,10 @@ Type=simple
|
|
|
95
34
|
User=fleet
|
|
96
35
|
Group=fleet
|
|
97
36
|
Environment=HOME=/home/fleet
|
|
98
|
-
# systemd's default PATH has no user installs; include wherever `omp
|
|
99
|
-
# `omp-conductor
|
|
37
|
+
# systemd's default PATH has no user installs; include wherever `omp`,
|
|
38
|
+
# `omp-conductor` and `gh` live on this host.
|
|
100
39
|
Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin
|
|
101
40
|
|
|
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
|
-
# Supplementary groups are fixed when a process starts, so a daemon that was
|
|
125
|
-
# already running when these accounts were provisioned can never join them --
|
|
126
|
-
# and a unit with no User= gets no initgroups call at all, so it comes up with
|
|
127
|
-
# `Groups: 0` even once /etc/group is correct. Declared here so membership is a
|
|
128
|
-
# property of the unit rather than of how it happened to be started; after a
|
|
129
|
-
# `daemon-reload` and a restart, `omp-conductor status` reports the probed
|
|
130
|
-
# mechanism as uid-pool.
|
|
131
|
-
SupplementaryGroups=conductor-daemon conductor-runs
|
|
132
|
-
AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
133
|
-
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
134
|
-
|
|
135
41
|
WorkingDirectory=/home/fleet
|
|
136
42
|
|
|
137
43
|
# Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;
|