omp-conductor 0.5.0 → 0.5.2

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 CHANGED
@@ -149,21 +149,27 @@ be present. If you run omp, it is.
149
149
  Also required on the host:
150
150
 
151
151
  - `bun`: the CLI and the daemon run on it (`Bun.serve` backs `/healthz`).
152
- - **A model credential a *session* can reach.** Sessions inherit the daemon's
153
- environment and `$HOME` unmodified, so a session authenticates with whatever
154
- the daemon authenticates with, and with nothing else. A login recorded under a
155
- different account's `~/.omp/agent/agent.db` is invisible to the service, and
156
- its workers die at turn 0 with `No model selected`. Two supported shapes:
157
- - a model API key in the **daemon's** environment (`ANTHROPIC_API_KEY`,
158
- `OPENAI_API_KEY`, …).
159
- - the harness's **auth broker** `omp auth-broker serve` plus `omp auth-broker
160
- token`, or `auth.broker.url` / `auth.broker.token` in
161
- `~/.omp/agent/config.yml`. The conductor resolves that connection and injects
162
- it into each session. Refresh tokens stay in the broker and a session receives
163
- only short-lived access tokens.
164
-
165
- With neither, `omp-conductor status` reports the hold and **nothing is
166
- dispatched** no issue is claimed and no attempt is spent.
152
+ - **A model credential the *daemon's own account* can reach.** Sessions are child
153
+ processes of the daemon and inherit its environment and `$HOME` unmodified, so a
154
+ session authenticates with exactly what the daemon authenticates with nothing
155
+ is injected and nothing is scrubbed. Any shape the harness itself understands
156
+ works, including the ordinary one:
157
+ - an **OAuth login** already recorded for that account (`omp` login state under
158
+ its `~/.omp/agent`). This is the common case and needs no configuration.
159
+ - a model **API key** in the daemon's environment (`ANTHROPIC_API_KEY`,
160
+ `OPENAI_API_KEY`, …). Note systemd starts the service with a clean
161
+ environment, so it has to be an `Environment=` line on the unit, not something
162
+ exported in your shell.
163
+ - the harness's **auth broker**, configured in that account's
164
+ `~/.omp/agent/config.yml`.
165
+
166
+ The account matters more than the shape: a login recorded under a *different*
167
+ account is invisible to the service. A unit running `User=fleet` cannot see
168
+ `root`'s login, and workers then die at turn 0 with `No model selected`. Such a
169
+ run is classified `env-start-failure` and charges **neither** the failure budget
170
+ nor a continuation — an environment fault is not a failed implementation, and a
171
+ run that recorded no turn, no commit and no error did not attempt anything — but
172
+ nothing dispatches successfully until the credential is reachable.
167
173
  - `gh`, already authenticated: every tracker operation shells out to it, so the
168
174
  daemon never handles a GitHub token itself.
169
175
  - `git`: mirrors and worktrees.
@@ -2149,18 +2155,29 @@ on run X asking to merge run Y's PR is not merely wrong, it is unexpressible.
2149
2155
 
2150
2156
  ```
2151
2157
  <state dir>/verbs/ daemon-owned, mode 0711
2152
- run-7-9a783d877d422b9e.sock 0600, run 7's and nothing else's
2153
- run-9-1c40e2a5b6d3f018.sock 0600, run 9's and nothing else's
2154
- orchestrator-4b1f...c2.sock 0600, a third, distinct one
2158
+ run-7-9a783d877d422b9e.sock 0600, bound for run 7
2159
+ run-9-1c40e2a5b6d3f018.sock 0600, bound for run 9
2160
+ orchestrator-4b1f...c2.sock 0600, the orchestrator's
2155
2161
  ```
2156
2162
 
2157
- `0711` is load-bearing and the obvious `0700` does not work: *connecting* needs
2158
- **search** (`+x`) on every path component, not read. So the directory is
2159
- traversable by a process that already knows a socket's name, listable by none,
2160
- and writable by none but the daemon that last one is what stops anything else
2161
- on the host unlinking a socket and binding an impostor in its place. A
2162
- *directory* per run, owned by the run, would hand exactly that power back, and
2163
- is deliberately not used.
2163
+ **What these modes buy, and what they do not.** They keep every *other local
2164
+ account* out: `0711` on the parent is traversable but not listable, so no other
2165
+ user can enumerate the fleet's sockets, the suffixes are unguessable, and only
2166
+ the daemon's uid can connect to a `0600` socket at all.
2167
+
2168
+ They are **not** a boundary between runs. Sessions are child processes of the
2169
+ daemon running as its own uid, so a session matches the owner class on all of
2170
+ these: it could list the directory and connect to a sibling's socket. Each run is
2171
+ *handed* its own path and nothing else, which is a convention the run has no
2172
+ reason to break — not an enforcement. What makes breaking it visible is the
2173
+ [ledger](#the-ledger): every call is recorded with the channel it
2174
+ arrived on, so a worker calling on another run's socket is in the record.
2175
+
2176
+ Closing that properly needs the sessions to be different OS principals. A
2177
+ per-run credential boundary that did exactly this shipped and was removed in
2178
+ 0.5.0 — it worked, and the cost was that it also hid the operator's own model
2179
+ credential from every session, so nothing could start. It is not worth
2180
+ re-litigating without solving that first.
2164
2181
 
2165
2182
  Before binding, the daemon verifies every component of the path is owned by
2166
2183
  itself (or root), free of symlinks, and unwritable by anyone else; a failed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -65,10 +65,11 @@ function normalise(state: string): string {
65
65
  /**
66
66
  * Harness start failures, matched on the text it prints.
67
67
  *
68
- * Deliberately a small closed list rather than "anything at turn 0": a run that
69
- * ended at turn 0 for a reason nobody has named is exactly what `unknown` is for,
70
- * and quietly declaring it an environment fault would waive an attempt that may
71
- * have been genuinely spent.
68
+ * A small closed list rather than "any error at turn 0": a run that ended at
69
+ * turn 0 with an error nobody has named is exactly what `unknown` is for, and
70
+ * declaring that an environment fault would waive an attempt that may have been
71
+ * genuinely spent. A mirror fetch that failed is not a session that never
72
+ * started.
72
73
  */
73
74
  const START_FAILURE_SIGNATURES = [
74
75
  "no model selected",
@@ -90,6 +91,42 @@ export function startFailure(lastError: string | undefined): string | undefined
90
91
  return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
91
92
  }
92
93
 
94
+ /**
95
+ * Evidence that this run never started, or `undefined` when something did happen.
96
+ *
97
+ * Two shapes, and the second is the one the reference fleet actually produced.
98
+ *
99
+ * 1. Turn zero with a recognised harness start error — the session said why.
100
+ * 2. Turn zero, **no artifacts and no error text at all**. The row records
101
+ * nothing: no turn, no head, no salvage, not even a message. On the reference
102
+ * fleet every worker died at turn 0 with `No model selected`, and that string
103
+ * went into the tier-1 escalation rather than into `lastError` — so the rows
104
+ * for issues 373, 359, 343 and 319 carry no error whatsoever and were logged
105
+ * `unknown`, each charging an implementation attempt (#152).
106
+ *
107
+ * Shape 2 is a deliberate judgement, not a loosening. There is no evidence an
108
+ * attempt was spent, so charging one is charging for a run whose only trace is
109
+ * that it existed; and the two ways of being wrong are not symmetric — being
110
+ * generous grants an issue one more attempt a human will see, while being strict
111
+ * leaves it permanently undispatchable for a fault that was never in its code.
112
+ * A turn-0 run carrying an *unrecognised* error still reads `unknown`, because
113
+ * there the row does say something happened.
114
+ *
115
+ * Shared with the store's one-time repair so history and future agree.
116
+ */
117
+ export function neverStarted(
118
+ run: Pick<RunRecord, "turns" | "lastError" | "prUrl" | "headSha" | "salvageSha">,
119
+ ): string | undefined {
120
+ if (run.turns !== 0) return undefined;
121
+ const detail = startFailure(run.lastError);
122
+ if (detail !== undefined) return `the session never started: ${detail}`;
123
+ const produced = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
124
+ if (!produced && run.lastError === undefined) {
125
+ return "the session never started: no turns, no commit and no error were recorded";
126
+ }
127
+ return undefined;
128
+ }
129
+
93
130
  export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
94
131
  const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
95
132
 
@@ -113,19 +150,14 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
113
150
  };
114
151
  }
115
152
 
116
- // Turn zero with an explicit harness start error is the most classifiable
117
- // failure there is, and the least deserving of an implementation attempt: the
118
- // session never got as far as reading the issue. 27 recoveries on the reference
119
- // fleet were this, logged as `unknown` while the session's own output carried
120
- // the literal string `No model selected` (#152).
121
- if ((run.state === "failed" || run.state === "killed") && run.turns === 0) {
122
- const detail = startFailure(run.lastError);
123
- if (detail !== undefined) {
124
- return {
125
- cls: "env-start-failure",
126
- recovery: "escalate",
127
- evidence: `the session never started: ${detail}`,
128
- };
153
+ // A run that never started is the most classifiable failure there is, and the
154
+ // least deserving of an implementation attempt: the session did not get as far
155
+ // as reading the issue. See {@link neverStarted} for the two shapes and why the
156
+ // second one counts.
157
+ if (run.state === "failed" || run.state === "killed") {
158
+ const evidence = neverStarted(run);
159
+ if (evidence !== undefined) {
160
+ return { cls: "env-start-failure", recovery: "escalate", evidence };
129
161
  }
130
162
  }
131
163
 
package/src/omp.ts CHANGED
@@ -428,9 +428,11 @@ export interface CreateSessionOptions {
428
428
  */
429
429
  export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
430
430
  const owned = opts.socketPath === undefined;
431
- // 0711, not 0700: searchable, so a session reaches its own socket by name, but
432
- // not listable nothing here can enumerate the other sockets, unlink one and
433
- // bind an impostor in its place. Same reasoning as the run workspace parent.
431
+ // 0711, not 0700: searchable but not listable, which keeps *other local
432
+ // accounts* from enumerating this directory. It is not a restraint on the
433
+ // session that runs as this process's own uid, matches the owner class and
434
+ // therefore has `rwx` here regardless. Same reasoning, and the same limit, as
435
+ // the run workspace parent.
434
436
  const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
435
437
  const socketPath = opts.socketPath ?? join(socketDir, "s");
436
438
  mkdirSync(socketDir, { recursive: true, mode: 0o711 });
package/src/store.ts CHANGED
@@ -15,7 +15,7 @@ import { dirname, join } from "node:path";
15
15
 
16
16
  import { stateDir } from "./config.ts";
17
17
  import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
18
- import { startFailure } from "./failure-class.ts";
18
+ import { neverStarted } from "./failure-class.ts";
19
19
  import type {
20
20
  DecisionDraft,
21
21
  FailureClass,
@@ -693,18 +693,32 @@ export function openStore(dbPath: string): Store {
693
693
  // charged, and an issue sitting at its cap for a fault that was never in its
694
694
  // code stays undispatchable forever.
695
695
  //
696
- // Narrow by construction: `turns = 0` (the session never took a turn), an
697
- // error the classifier itself recognises as a start failure, and a class of
696
+ // Narrow by construction, and narrow through the CLASSIFIER's own predicate
697
+ // rather than a second copy of the rule: {@link neverStarted}. A class of
698
698
  // exactly `unknown` — a row a human or a later release classified as anything
699
- // else is left alone. Idempotent for free: afterwards those rows read
700
- // `env-start-failure`, which the WHERE no longer matches.
699
+ // else is left alone plus zero turns and either a recognised start error or
700
+ // nothing recorded at all. A turn-0 row carrying an unrecognised error (a
701
+ // failed mirror fetch, say) is untouched. Idempotent for free: afterwards these
702
+ // rows read `env-start-failure`, which the WHERE no longer matches.
701
703
  const misread = db
702
704
  .query<
703
- { id: string; lastError: string | null },
705
+ { id: string; lastError: string | null; prUrl: string | null; headSha: string | null; salvageSha: string | null },
704
706
  []
705
- >(`SELECT id, lastError FROM runs WHERE failureClass = 'unknown' AND turns = 0 AND lastError IS NOT NULL`)
707
+ >(
708
+ `SELECT id, lastError, prUrl, headSha, salvageSha FROM runs
709
+ WHERE failureClass = 'unknown' AND turns = 0`,
710
+ )
706
711
  .all()
707
- .filter((row) => startFailure(row.lastError ?? undefined) !== undefined);
712
+ .filter(
713
+ (row) =>
714
+ neverStarted({
715
+ turns: 0,
716
+ ...(row.lastError === null ? {} : { lastError: row.lastError }),
717
+ ...(row.prUrl === null ? {} : { prUrl: row.prUrl }),
718
+ ...(row.headSha === null ? {} : { headSha: row.headSha }),
719
+ ...(row.salvageSha === null ? {} : { salvageSha: row.salvageSha }),
720
+ }) !== undefined,
721
+ );
708
722
  if (misread.length > 0) {
709
723
  const reclassify = db.query<unknown, [string]>(
710
724
  `UPDATE runs SET failureClass = 'env-start-failure' WHERE id = ?`,
@@ -51,7 +51,6 @@ import {
51
51
  secureBoundSocket,
52
52
  socketFd,
53
53
  unlinkStaleSocket,
54
- traversalProblem,
55
54
  validateSocketPath,
56
55
  type PeerReader,
57
56
  type SocketOwnership,
@@ -935,13 +934,6 @@ export interface ListenOptions {
935
934
  daemonUid?: number;
936
935
  /** Injected in tests; the real one chowns, which needs privilege. */
937
936
  secure?: typeof secureBoundSocket;
938
- /**
939
- * Injected in tests for the same reason `secure` is: the traversal rule is
940
- * about the *deployment's* directory chain, and a test that needs a bound
941
- * socket in a developer's `0750` home is not the case it is asserting. The
942
- * rule itself is pinned directly in `socket.test.ts`.
943
- */
944
- traversal?: typeof traversalProblem;
945
937
  }
946
938
 
947
939
  /**
@@ -1,28 +1,34 @@
1
1
  /**
2
2
  * The authenticated local transport the mutation verbs ride on (#126).
3
3
  *
4
- * The permission layout is the whole security argument, and the obvious one
5
- * does not work. A socket `chmod 0600` and `chown`ed to a run's principal is
6
- * *unreachable* if its parent is the daemon's usual `0700`: connecting needs
7
- * **search** (`+x`) on every path component, not read. So:
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
- * - the parent directory is daemon-owned and mode **`0711`**searchable by
10
- * run principals, listable by none, and **writable by none but the daemon**;
11
- * - each socket is `0600`, owned by the principal of the run it belongs to, so
12
- * exactly one uid can connect, and the orchestrator's is a third distinct one;
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
- * Two further rules make that layout hold rather than merely describe it:
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
- * Every decision in this file is a pure function of `lstat` results or of a
24
- * credentials struct, so the adversarial cases are testable without root and
25
- * without a second uid.
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 by run principals, listable by none, writable only by the daemon. */
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
- /** One uid may connect. The parent's `0711` is what makes that reachable. */
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.
package/src/worktree.ts CHANGED
@@ -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, so a session reaches its own checkout by name; not
316
- // listable, so it cannot enumerate its siblings; not writable, so it cannot
317
- // create or unlink one. Same reasoning as the socket parent directory.
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);