omp-conductor 0.3.25 → 0.4.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.
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The child process one omp session runs in (#125 A1).
4
+ *
5
+ * Before this file existed, `createSession` called the harness in the daemon's
6
+ * own process, so every session inherited the daemon's `process.env`, its
7
+ * `$HOME` and its whole filesystem view — and the daemon authenticates by
8
+ * shelling out to the operator's logged-in `gh`. No amount of tool-layer
9
+ * confinement closes that, because `bash` is deliberately unconfined and the
10
+ * credential is reachable by running the same binary the daemon runs.
11
+ *
12
+ * So the session moves out of process. This file is the far side: it loads the
13
+ * harness, runs the real session, and speaks a five-verb protocol back over a
14
+ * unix socket to the {@link AgentSessionLike} proxy in `omp.ts`. It holds no
15
+ * conductor state, opens no database, and reads no config — everything it needs
16
+ * arrives in {@link SessionHostSpec}, because under `uid-pool` this process runs
17
+ * as a principal that cannot read the daemon's state directory at all.
18
+ *
19
+ * The protocol is deliberately tiny. `AgentSessionLike` has five members, so
20
+ * there are five things to carry, and every one of them is data.
21
+ */
22
+
23
+ import { connect } from "node:net";
24
+
25
+ import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
26
+ import type { OrchestratorJail, OrchestratorRefusal } from "./confinement.ts";
27
+ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
28
+
29
+ /**
30
+ * Everything the child needs to build the session. Plain JSON on purpose:
31
+ * callbacks (`onReleaseBlocked`, the confinement audit sink) cannot cross a
32
+ * process boundary, so they become messages instead — see
33
+ * {@link HostToParent}.
34
+ */
35
+ export interface SessionHostSpec {
36
+ socket: string;
37
+ cwd: string;
38
+ role: SessionRole;
39
+ sessionDir?: string;
40
+ model?: string;
41
+ resume?: boolean;
42
+ releaseGrants?: ResolvedGrants;
43
+ /**
44
+ * Resolved by the *parent*, never by this process. `orchestratorJailFromConfig`
45
+ * reads the conductor config, and an isolated child cannot — a jail that
46
+ * silently fell back to defaults here would widen the very gate #127 closed.
47
+ */
48
+ orchestratorJail?: OrchestratorJail;
49
+ /**
50
+ * The conductor verb socket this session may call mutations on (#126).
51
+ *
52
+ * Carried in the spec rather than read from the environment by the child,
53
+ * for the same reason `orchestratorJail` is resolved by the parent: the
54
+ * child deciding *which* socket it owns is the child deciding which run it
55
+ * is, and that is the question the transport exists to stop it answering.
56
+ * Absent, the verbs are registered and every one of them fails closed.
57
+ */
58
+ verbSocketPath?: string;
59
+ }
60
+
61
+ /** Parent → child. */
62
+ export type ParentToHost =
63
+ | { t: "prompt"; id: number; text: string; opts?: Record<string, unknown> }
64
+ | { t: "abort" }
65
+ | { t: "dispose" };
66
+
67
+ /** Child → parent. */
68
+ export type HostToParent =
69
+ | { t: "ready"; sessionFile?: string; modelFallbackMessage?: string }
70
+ | { t: "start-error"; message: string }
71
+ | { t: "event"; event: unknown }
72
+ | { t: "session-file"; path: string }
73
+ | { t: "prompt-result"; id: number; ok: boolean; error?: string }
74
+ | { t: "release-blocked"; shape: ReleaseShape }
75
+ | { t: "confinement-refusal"; refusal: OrchestratorRefusal };
76
+
77
+ /**
78
+ * Depth at which a harness event stops being copied for the wire.
79
+ *
80
+ * Every field this package actually reads — `type`, `message.role`,
81
+ * `message.content[].text`, `message.usage.cost.total`,
82
+ * `telemetry.cost.estimatedUsd`, `isTerminal` — is within five levels. The cap
83
+ * exists for the other direction: a harness event holding a client, a socket or
84
+ * a parent pointer would otherwise be walked until something threw, and losing
85
+ * the event stream is losing turn counting, spend accounting and the run's
86
+ * report.
87
+ */
88
+ const EVENT_DEPTH = 8;
89
+
90
+ /**
91
+ * A JSON-safe copy of one harness event.
92
+ *
93
+ * Cycle- and depth-guarded rather than a bare `JSON.stringify`, because the
94
+ * event union belongs to the peer dependency and this package does not get to
95
+ * assume it is a tree. Functions, symbols and `undefined` drop out the way they
96
+ * would through `JSON.stringify`; a `Date` keeps its ISO form; anything that
97
+ * refuses to be read is replaced by `null` rather than taking the event with it.
98
+ */
99
+ export function serializableEvent(value: unknown, depth = EVENT_DEPTH, seen = new WeakSet<object>()): unknown {
100
+ if (value === null || typeof value !== "object") {
101
+ return typeof value === "function" || typeof value === "symbol" || typeof value === "bigint"
102
+ ? undefined
103
+ : value;
104
+ }
105
+ if (value instanceof Date) return value.toISOString();
106
+ if (seen.has(value)) return null;
107
+ if (depth <= 0) return null;
108
+ seen.add(value);
109
+ try {
110
+ if (Array.isArray(value)) return value.map((item) => serializableEvent(item, depth - 1, seen));
111
+ const out: Record<string, unknown> = {};
112
+ for (const [key, raw] of Object.entries(value)) {
113
+ const copied = serializableEvent(raw, depth - 1, seen);
114
+ if (copied !== undefined) out[key] = copied;
115
+ }
116
+ return out;
117
+ } catch {
118
+ return null;
119
+ } finally {
120
+ seen.delete(value);
121
+ }
122
+ }
123
+
124
+ /** Newline-delimited JSON both ways. Values are escaped, so a newline is a frame. */
125
+ export function encodeFrame(message: HostToParent | ParentToHost): string {
126
+ return `${JSON.stringify(message)}\n`;
127
+ }
128
+
129
+ /**
130
+ * Split a growing buffer into whole frames. Returns the frames and the tail that
131
+ * is not a frame yet — a socket read boundary lands mid-message routinely, and
132
+ * dropping that tail loses whichever message straddled it.
133
+ */
134
+ export function decodeFrames(buffer: string): { frames: unknown[]; rest: string } {
135
+ const parts = buffer.split("\n");
136
+ const rest = parts.pop() ?? "";
137
+ const frames: unknown[] = [];
138
+ for (const part of parts) {
139
+ if (part === "") continue;
140
+ try {
141
+ frames.push(JSON.parse(part));
142
+ } catch {
143
+ // A torn frame must not stop the frames written after it: losing one
144
+ // event costs a turn count, losing the stream costs the run.
145
+ }
146
+ }
147
+ return { frames, rest };
148
+ }
149
+
150
+ export interface SessionHostDeps {
151
+ createSession: typeof createLocalSession;
152
+ }
153
+
154
+ /**
155
+ * Run one session until the parent disposes it or the socket drops.
156
+ *
157
+ * Resolves when the session is over. Never throws for a session that failed to
158
+ * start: the failure is reported as `start-error` and the parent turns it back
159
+ * into the same `Error` the in-process path would have raised, so a missing
160
+ * peer dependency still reads as a deployment mistake rather than as an
161
+ * unexplained child exit.
162
+ */
163
+ export async function runSessionHost(
164
+ spec: SessionHostSpec,
165
+ deps: SessionHostDeps = { createSession: createLocalSession },
166
+ ): Promise<void> {
167
+ // umask before anything is created. Under uid-pool the run tree is
168
+ // `2770 slot:conductor-daemon`, and the setgid bit only decides the *group*
169
+ // of what this process writes — without `0007` the mode still lands `0644`
170
+ // and the daemon's access to a file the worker created is silently partial,
171
+ // which surfaces as a failed salvage in production rather than here.
172
+ process.umask(0o007);
173
+
174
+ const socket = connect(spec.socket);
175
+ socket.setNoDelay(true);
176
+ const send = (message: HostToParent): void => {
177
+ if (!socket.writableEnded) socket.write(encodeFrame(message));
178
+ };
179
+
180
+ const { promise: connected, resolve: onConnect, reject: onConnectFail } = Promise.withResolvers<void>();
181
+ socket.once("connect", () => {
182
+ onConnect();
183
+ });
184
+ socket.once("error", (err: Error) => {
185
+ onConnectFail(err);
186
+ });
187
+ await connected;
188
+
189
+ let session: AgentSessionLike | undefined;
190
+ try {
191
+ session = await deps.createSession({
192
+ cwd: spec.cwd,
193
+ role: spec.role,
194
+ ...(spec.sessionDir === undefined ? {} : { sessionDir: spec.sessionDir }),
195
+ ...(spec.model === undefined ? {} : { model: spec.model }),
196
+ ...(spec.resume === undefined ? {} : { resume: spec.resume }),
197
+ ...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
198
+ ...(spec.orchestratorJail === undefined ? {} : { orchestratorJail: spec.orchestratorJail }),
199
+ ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
200
+ // Both audit sinks live in the daemon's state directory, which this
201
+ // process may not be able to write and must not be trusted to. They
202
+ // become messages; the parent performs the durable write.
203
+ onReleaseBlocked: (shape) => {
204
+ send({ t: "release-blocked", shape });
205
+ },
206
+ onConfinementRefusal: (refusal) => {
207
+ send({ t: "confinement-refusal", refusal });
208
+ },
209
+ });
210
+ } catch (err) {
211
+ send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
212
+ socket.end();
213
+ return;
214
+ }
215
+
216
+ const live = session;
217
+ // `sessionFile` is a getter that can materialise after the session opens, and
218
+ // the daemon records it as the only readable evidence a run leaves behind. So
219
+ // it is re-read on every event rather than captured once.
220
+ let lastSessionFile = live.sessionFile;
221
+ live.on("*", (event) => {
222
+ send({ t: "event", event: serializableEvent(event) });
223
+ const current = live.sessionFile;
224
+ if (current !== undefined && current !== lastSessionFile) {
225
+ lastSessionFile = current;
226
+ send({ t: "session-file", path: current });
227
+ }
228
+ });
229
+
230
+ send({
231
+ t: "ready",
232
+ ...(live.sessionFile === undefined ? {} : { sessionFile: live.sessionFile }),
233
+ ...(live.modelFallbackMessage === undefined
234
+ ? {}
235
+ : { modelFallbackMessage: live.modelFallbackMessage }),
236
+ });
237
+
238
+ const { promise: finished, resolve: finish } = Promise.withResolvers<void>();
239
+ let buffer = "";
240
+ socket.on("data", (chunk: Buffer) => {
241
+ buffer += chunk.toString("utf8");
242
+ const { frames, rest } = decodeFrames(buffer);
243
+ buffer = rest;
244
+ for (const frame of frames) {
245
+ const message = frame as ParentToHost;
246
+ if (message.t === "prompt") {
247
+ void live.prompt(message.text, message.opts).then(
248
+ () => {
249
+ send({ t: "prompt-result", id: message.id, ok: true });
250
+ },
251
+ (err: unknown) => {
252
+ send({
253
+ t: "prompt-result",
254
+ id: message.id,
255
+ ok: false,
256
+ error: err instanceof Error ? err.message : String(err),
257
+ });
258
+ },
259
+ );
260
+ continue;
261
+ }
262
+ if (message.t === "abort") {
263
+ live.abort();
264
+ continue;
265
+ }
266
+ if (message.t === "dispose") finish();
267
+ }
268
+ });
269
+
270
+ // A dead parent must not leave a live session holding a checkout and a model
271
+ // budget. The daemon dying used to take its in-process sessions with it; the
272
+ // socket closing is how that stays true now it does not.
273
+ socket.on("close", () => {
274
+ finish();
275
+ });
276
+ await finished;
277
+
278
+ try {
279
+ live.abort();
280
+ } catch {
281
+ // Already finished, or a harness that dislikes a second abort. Either way
282
+ // teardown continues: this path runs on the way out.
283
+ }
284
+ try {
285
+ await disposeSession(live);
286
+ } catch {
287
+ // Teardown noise must not become the run's outcome.
288
+ }
289
+ socket.end();
290
+ }
291
+
292
+ /** Reads the spec the parent handed over. Argv, not stdin: stdin is the harness's. */
293
+ function specFromArgv(argv: readonly string[]): SessionHostSpec {
294
+ const raw = argv[2];
295
+ if (raw === undefined) {
296
+ throw new Error("omp-conductor session host: expected a JSON session spec as argv[2]");
297
+ }
298
+ return JSON.parse(raw) as SessionHostSpec;
299
+ }
300
+
301
+ if (import.meta.main) {
302
+ // Exit code is the parent's only signal when the socket never opened, so a
303
+ // spec that will not parse fails loudly here rather than as a silent hang.
304
+ const spec = specFromArgv(process.argv);
305
+ await runSessionHost(spec);
306
+ process.exit(0);
307
+ }
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, stateDir } from "./config.ts";
4
+ import { configPath, resolveCredentials, sharedRoot, 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 {
@@ -110,6 +110,28 @@ 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
+ ...(resolveCredentials(project).isolation === "per-run"
129
+ ? [
130
+ `Environment=${systemdQuote(`OMP_CONDUCTOR_SHARED=${sharedRoot()}`)}`,
131
+ "AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
132
+ "CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
133
+ ]
134
+ : []),
113
135
  `WorkingDirectory=${systemdQuote(stateDir())}`,
114
136
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
115
137
  "Restart=on-failure",
@@ -283,3 +305,142 @@ export async function runSetupSmoke(
283
305
  await deps.stop();
284
306
  }
285
307
  }
308
+
309
+ /** Absolute path of the unit systemd actually reads. */
310
+ export function installedUnitPath(): string {
311
+ return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
312
+ }
313
+
314
+ /**
315
+ * Why the *live* systemd unit no longer matches what this version would render.
316
+ *
317
+ * `planHostRuntime` compares against the **staged** copy under the state dir,
318
+ * which says nothing about the file systemd booted from. That gap is not
319
+ * cosmetic since 0.4.0: a fleet configured `per-run` whose installed unit
320
+ * predates the capability grant probes down to `group-mode` and then refuses
321
+ * every dispatch — and `upgrade` would happily verify package identities,
322
+ * services, pane and ticks around it, because none of those look at the unit.
323
+ *
324
+ * Checked by directive rather than by whole-file equality on purpose. An
325
+ * operator is entitled to hand-tune `MemoryMax`, add `After=`, or set an
326
+ * `Environment=` of their own, and failing an upgrade over that would teach
327
+ * them to stop running upgrades. What is reported is the set of directives this
328
+ * configuration *requires* and the live unit lacks.
329
+ *
330
+ * `undefined` means either "no drift that matters" or "no installed unit at
331
+ * all" — a host running `omp-conductor start` without systemd is not broken,
332
+ * and must not be told it is.
333
+ */
334
+ /**
335
+ * What systemd has actually loaded for the unit, as `systemctl show` reports it.
336
+ *
337
+ * Deliberately not the file on disk. A unit installed without `daemon-reload`
338
+ * can match the rendered text byte for byte while the manager still runs the
339
+ * previous configuration, and a drop-in under `…service.d/` changes what runs
340
+ * without touching the file at all. Both read clean from the filesystem.
341
+ */
342
+ export interface EffectiveUnit {
343
+ /** `systemctl show -p AmbientCapabilities`; empty string when unset. */
344
+ ambientCapabilities: string;
345
+ capabilityBoundingSet: string;
346
+ /** `systemctl show -p Environment`, newline- or space-joined. */
347
+ environment: string;
348
+ needDaemonReload: boolean;
349
+ }
350
+
351
+ /** Capability names as a comparable set: systemd reports them lowercased, `cap_`-prefixed and reordered. */
352
+ function capabilitySet(text: string): Set<string> {
353
+ return new Set(
354
+ text
355
+ .split(/[\s,]+/)
356
+ .map((name) => name.trim().toLowerCase().replace(/^cap_/, ""))
357
+ .filter((name) => name.length > 0),
358
+ );
359
+ }
360
+
361
+ function renderedDirective(rendered: string, key: string): string | undefined {
362
+ for (const line of rendered.split("\n")) {
363
+ const trimmed = line.trim();
364
+ if (trimmed.startsWith(`${key}=`)) return trimmed.slice(key.length + 1);
365
+ }
366
+ return undefined;
367
+ }
368
+
369
+ /**
370
+ * Why what systemd loaded no longer matches what this version would render.
371
+ *
372
+ * Three asymmetries, each for a reason:
373
+ *
374
+ * - **Ambient capabilities are compared exactly, both ways.** Missing them
375
+ * breaks a per-run fleet; *leftover* ones are worse. Roll back to `none` and
376
+ * there is no `setpriv` launcher at all, so ambient capabilities on the unit
377
+ * are inherited straight into model-executed code, which can then `setuid()`
378
+ * to any account. `systemctl` reports an empty string when unset, so the
379
+ * rollback case is unambiguous.
380
+ * - **The bounding set is checked only when this version sets it.** systemd
381
+ * reports the full default set when a unit does not, so comparing both ways
382
+ * would flag every ordinary host.
383
+ * - **Only directives this configuration manages are considered**, so an
384
+ * operator's own `MemoryMax`, `After=` or drop-in never fails an upgrade.
385
+ */
386
+ export function unitDriftReason(rendered: string, effective: EffectiveUnit | undefined): string | undefined {
387
+ if (effective === undefined) return undefined;
388
+ const problems: string[] = [];
389
+
390
+ const wantAmbient = capabilitySet(renderedDirective(rendered, "AmbientCapabilities") ?? "");
391
+ const haveAmbient = capabilitySet(effective.ambientCapabilities);
392
+ const missingAmbient = [...wantAmbient].filter((c) => !haveAmbient.has(c));
393
+ const extraAmbient = [...haveAmbient].filter((c) => !wantAmbient.has(c));
394
+ if (missingAmbient.length > 0) {
395
+ problems.push(`AmbientCapabilities is missing ${missingAmbient.join(", ")}`);
396
+ }
397
+ if (extraAmbient.length > 0) {
398
+ problems.push(
399
+ `AmbientCapabilities grants ${extraAmbient.join(", ")} that this configuration does not — with ` +
400
+ `isolation off there is no privilege-dropping launcher, so these are inherited by model-executed code`,
401
+ );
402
+ }
403
+
404
+ const wantBounding = renderedDirective(rendered, "CapabilityBoundingSet");
405
+ if (wantBounding !== undefined) {
406
+ const missing = [...capabilitySet(wantBounding)].filter(
407
+ (c) => !capabilitySet(effective.capabilityBoundingSet).has(c),
408
+ );
409
+ if (missing.length > 0) problems.push(`CapabilityBoundingSet is missing ${missing.join(", ")}`);
410
+ }
411
+
412
+ const wantShared = rendered
413
+ .split("\n")
414
+ .map((l) => l.trim())
415
+ .find((l) => l.includes("OMP_CONDUCTOR_SHARED="));
416
+ if (wantShared !== undefined) {
417
+ const value = wantShared.slice(wantShared.indexOf("OMP_CONDUCTOR_SHARED=")).replace(/"$/, "");
418
+ if (!effective.environment.includes(value)) {
419
+ problems.push(`Environment is missing ${value}`);
420
+ }
421
+ }
422
+
423
+ if (effective.needDaemonReload) {
424
+ problems.push("systemd reports the unit needs a daemon-reload, so what runs is not what is on disk");
425
+ }
426
+
427
+ if (problems.length === 0) return undefined;
428
+ return (
429
+ `what systemd loaded for ${STAGED_SERVICE_NAME} does not match this configuration: ${problems.join("; ")}.`
430
+ );
431
+ }
432
+
433
+ /** Parse `systemctl show -p …` key=value output into an {@link EffectiveUnit}. */
434
+ export function parseEffectiveUnit(stdout: string): EffectiveUnit {
435
+ const values = new Map<string, string>();
436
+ for (const line of stdout.split("\n")) {
437
+ const at = line.indexOf("=");
438
+ if (at > 0) values.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
439
+ }
440
+ return {
441
+ ambientCapabilities: values.get("AmbientCapabilities") ?? "",
442
+ capabilityBoundingSet: values.get("CapabilityBoundingSet") ?? "",
443
+ environment: values.get("Environment") ?? "",
444
+ needDaemonReload: (values.get("NeedDaemonReload") ?? "no") === "yes",
445
+ };
446
+ }