omp-conductor 0.4.4 → 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/README.md +53 -269
- 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 +49 -406
- package/src/failure-class.ts +44 -0
- package/src/fleet.ts +1 -85
- package/src/gitops.ts +189 -0
- package/src/omp.ts +11 -35
- package/src/orchestrator.ts +0 -4
- package/src/plugin.ts +1 -42
- package/src/session-host.ts +12 -19
- package/src/setup-host.ts +1 -157
- package/src/setup.ts +6 -41
- package/src/store.ts +36 -2
- package/src/tracker/github.ts +1 -1
- package/src/types.ts +1 -85
- package/src/upgrade.ts +0 -88
- package/src/verbs/actions.ts +1 -1
- package/src/verbs/server.ts +3 -28
- package/src/verbs/socket.ts +15 -23
- package/src/worker.ts +1 -13
- package/src/worktree.ts +4 -4
- package/systemd/omp-conductor.service.example +8 -94
- package/src/credentials.ts +0 -2029
package/src/verbs/server.ts
CHANGED
|
@@ -57,15 +57,6 @@ import {
|
|
|
57
57
|
type SocketOwnership,
|
|
58
58
|
} from "./socket.ts";
|
|
59
59
|
|
|
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
60
|
/**
|
|
70
61
|
* How long a merge lock may be held before another daemon may break it.
|
|
71
62
|
*
|
|
@@ -101,17 +92,15 @@ export type VerbChannel =
|
|
|
101
92
|
issue: number;
|
|
102
93
|
/** The routed checkout, for the privileged push/PR half. */
|
|
103
94
|
repo: RepoTarget;
|
|
104
|
-
/** The run's own repository on disk
|
|
95
|
+
/** The run's own repository on disk. */
|
|
105
96
|
runRepoPath: string;
|
|
106
97
|
branch: string;
|
|
107
|
-
principal?: SlotPrincipal;
|
|
108
98
|
}
|
|
109
99
|
| {
|
|
110
100
|
kind: "orchestrator";
|
|
111
101
|
path: string;
|
|
112
102
|
project: string;
|
|
113
103
|
role: "orchestrator";
|
|
114
|
-
principal?: SlotPrincipal;
|
|
115
104
|
};
|
|
116
105
|
|
|
117
106
|
export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
|
|
@@ -978,20 +967,6 @@ export async function listenVerbChannel(
|
|
|
978
967
|
"owned by the daemon (or root), free of symlinks, and unwritable by anyone else.",
|
|
979
968
|
);
|
|
980
969
|
}
|
|
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
970
|
// Only the daemon ever unlinks these, and it has just proven the parent is
|
|
996
971
|
// not writable by anyone else — which is what makes unlink-then-bind safe
|
|
997
972
|
// here and a race in a world-writable directory.
|
|
@@ -1013,7 +988,7 @@ export async function listenVerbChannel(
|
|
|
1013
988
|
});
|
|
1014
989
|
|
|
1015
990
|
const secure = opts.secure ?? secureBoundSocket;
|
|
1016
|
-
const ownership = secure(channel.path
|
|
991
|
+
const ownership = secure(channel.path);
|
|
1017
992
|
|
|
1018
993
|
return {
|
|
1019
994
|
path: channel.path,
|
|
@@ -1037,7 +1012,7 @@ function handleConnection(
|
|
|
1037
1012
|
): void {
|
|
1038
1013
|
const fd = socketFd(socket);
|
|
1039
1014
|
const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
|
|
1040
|
-
const verdict = peerVerdict(
|
|
1015
|
+
const verdict = peerVerdict({ uid: process.getuid?.() ?? 0 }, peer);
|
|
1041
1016
|
if (!verdict.ok) {
|
|
1042
1017
|
// Not a client error, and deliberately not answered: a caller who is not
|
|
1043
1018
|
// who the socket was allocated to gets no reply to calibrate against.
|
package/src/verbs/socket.ts
CHANGED
|
@@ -256,26 +256,22 @@ export function unlinkStaleSocket(path: string): void {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
|
-
*
|
|
259
|
+
* Restrict a bound socket to the daemon's own uid.
|
|
260
260
|
*
|
|
261
|
-
* Returns
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
261
|
+
* Returns the guarantee in force, because #126 asks the daemon to *state* its
|
|
262
|
+
* mechanism at startup rather than guess: the socket is `0600` under a `0711`
|
|
263
|
+
* daemon-owned parent, so it is one-user, and the unguessable suffix plus that
|
|
264
|
+
* parent are the whole story. Saying so is the difference between an audited
|
|
265
|
+
* boundary and a hopeful one.
|
|
266
266
|
*/
|
|
267
|
-
export type SocketOwnership = "
|
|
267
|
+
export type SocketOwnership = "daemon-user";
|
|
268
268
|
|
|
269
269
|
export function secureBoundSocket(
|
|
270
270
|
path: string,
|
|
271
|
-
principal: { uid: number; gid: number } | undefined,
|
|
272
271
|
chmod: (p: string, mode: number) => void = chmodSync,
|
|
273
|
-
chown: (p: string, uid: number, gid: number) => void = chownSync,
|
|
274
272
|
): SocketOwnership {
|
|
275
273
|
chmod(path, VERB_SOCKET_MODE);
|
|
276
|
-
|
|
277
|
-
chown(path, principal.uid, principal.gid);
|
|
278
|
-
return "run-principal";
|
|
274
|
+
return "daemon-user";
|
|
279
275
|
}
|
|
280
276
|
|
|
281
277
|
export interface PeerCredentials {
|
|
@@ -406,14 +402,14 @@ export function peerVerdict(
|
|
|
406
402
|
return {
|
|
407
403
|
ok: true,
|
|
408
404
|
basis: "socket-ownership",
|
|
409
|
-
why: "
|
|
405
|
+
why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is the whole boundary",
|
|
410
406
|
};
|
|
411
407
|
}
|
|
412
408
|
if (peer === undefined) {
|
|
413
409
|
return {
|
|
414
410
|
ok: true,
|
|
415
411
|
basis: "socket-ownership",
|
|
416
|
-
why: "this host exposes no peer credentials, so the 0600 socket
|
|
412
|
+
why: "this host exposes no peer credentials, so the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
|
|
417
413
|
};
|
|
418
414
|
}
|
|
419
415
|
if (peer.uid !== expected.uid) {
|
|
@@ -428,19 +424,15 @@ export function peerVerdict(
|
|
|
428
424
|
}
|
|
429
425
|
|
|
430
426
|
/** 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 {
|
|
427
|
+
export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
|
|
436
428
|
const peers =
|
|
437
429
|
peerReader === undefined
|
|
438
430
|
? `no peer-credential call on ${process.platform}`
|
|
439
431
|
: process.platform === "darwin"
|
|
440
432
|
? "peer uid asserted with getpeereid"
|
|
441
433
|
: "peer uid asserted with SO_PEERCRED";
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
434
|
+
return (
|
|
435
|
+
`verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
|
|
436
|
+
`each socket 0600 under the daemon's own uid; ${peers}`
|
|
437
|
+
);
|
|
446
438
|
}
|
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,7 +312,7 @@ 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, so a
|
|
315
|
+
// 0711: searchable, so a session reaches its own checkout by name; not
|
|
316
316
|
// listable, so it cannot enumerate its siblings; not writable, so it cannot
|
|
317
317
|
// create or unlink one. Same reasoning as the socket parent directory.
|
|
318
318
|
mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
|
|
@@ -418,7 +418,7 @@ export async function addRunRepo(
|
|
|
418
418
|
*
|
|
419
419
|
* Declared here as a function type rather than imported as a concrete
|
|
420
420
|
* implementation so this module stays free of the credential path: provisioning
|
|
421
|
-
* and salvage decide *what* to publish, `
|
|
421
|
+
* and salvage decide *what* to publish, `gitops.ts` is the only place that
|
|
422
422
|
* decides *how*, and the daemon is the only thing that holds both.
|
|
423
423
|
*/
|
|
424
424
|
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,35 +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
|
-
AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
125
|
-
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
126
|
-
|
|
127
41
|
WorkingDirectory=/home/fleet
|
|
128
42
|
|
|
129
43
|
# Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;
|