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/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
|
@@ -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,24 @@ 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
|
+
// Baked in so a custom shared root survives systemd's clean environment.
|
|
114
|
+
// Without it the daemon resolves the platform default while the operator
|
|
115
|
+
// provisioned somewhere else, and every per-run dispatch is refused for a
|
|
116
|
+
// path nobody chose (#125).
|
|
117
|
+
`Environment=${systemdQuote(`OMP_CONDUCTOR_SHARED=${sharedRoot()}`)}`,
|
|
118
|
+
// The capabilities exist to be DROPPED INTO run children by the
|
|
119
|
+
// privilege-dropping launcher, never inherited by them — see `setprivArgv`.
|
|
120
|
+
// Rendered only for `per-run`, because that is the only isolation that
|
|
121
|
+
// changes uid; granting them to a fleet that does not need them widens the
|
|
122
|
+
// daemon for nothing. Without them the probe honestly reports `group-mode`
|
|
123
|
+
// and a configured per-run fleet refuses every dispatch, which is the
|
|
124
|
+
// failure an operator following setup would otherwise hit first.
|
|
125
|
+
...(resolveCredentials(project).isolation === "per-run"
|
|
126
|
+
? [
|
|
127
|
+
"AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
|
|
128
|
+
"CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
|
|
129
|
+
]
|
|
130
|
+
: []),
|
|
113
131
|
`WorkingDirectory=${systemdQuote(stateDir())}`,
|
|
114
132
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
115
133
|
"Restart=on-failure",
|