omp-conductor 0.3.24 → 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/src/omp.ts CHANGED
@@ -13,9 +13,29 @@
13
13
  * site on purpose: a non-literal specifier stops `tsc` from trying to resolve
14
14
  * the module, which is the whole reason this shim exists.
15
15
  */
16
- import { worktreeConfinement } from "./confinement.ts";
17
- import { releasePolicyTripwire, type ReleaseShape } from "./release-policy.ts";
18
- import type { ReleasePolicy } from "./types.ts";
16
+ import { chmodSync, chownSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
17
+ import { createServer, type Server, type Socket } from "node:net";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+
21
+ import {
22
+ orchestratorConfinement,
23
+ orchestratorJailFromConfig,
24
+ worktreeConfinement,
25
+ type OrchestratorJail,
26
+ type OrchestratorRefusal,
27
+ } from "./confinement.ts";
28
+ import { recordConfinementRefusal } from "./confinement.ts";
29
+ import type { SessionBoundary } from "./credentials.ts";
30
+ import { releasePolicyTripwire } from "./release-policy.ts";
31
+ import type {
32
+ HostToParent,
33
+ ParentToHost,
34
+ SessionHostSpec,
35
+ } from "./session-host.ts";
36
+ import { decodeFrames, encodeFrame } from "./session-host.ts";
37
+ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
38
+ import { conductorVerbs } from "./verbs/client.ts";
19
39
 
20
40
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
21
41
 
@@ -96,8 +116,15 @@ interface RawSession {
96
116
  const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
97
117
 
98
118
  /**
99
- * Start one omp coding session rooted at `cwd`, with a private agent registry
100
- * and a file-backed transcript of its own.
119
+ * Start one omp coding session **in this process**, rooted at `cwd`, with a
120
+ * private agent registry and a file-backed transcript of its own.
121
+ *
122
+ * This is the far side of the boundary, not the dispatcher's entry point.
123
+ * Production callers want {@link createSession}, which runs this same function
124
+ * inside a child process under the run's own OS principal (#125); the only
125
+ * caller of this one is `session-host.ts`, plus the tests that pin what it
126
+ * builds. Calling it directly from the daemon puts the session back in the
127
+ * credential-holding process, which is the thing #125 exists to stop.
101
128
  *
102
129
  * `sessionDir` chooses the directory the harness writes that transcript into;
103
130
  * omitted, the harness picks its default location for `cwd`. Either way the
@@ -113,21 +140,47 @@ const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
113
140
  * deployment mistake, and a stack trace about a failed dynamic import sends the
114
141
  * reader looking in the wrong place.
115
142
  */
116
- export async function createSession(opts: {
143
+ export async function createLocalSession(opts: {
117
144
  cwd: string;
118
145
  sessionDir?: string;
119
146
  model?: string;
120
147
  resume?: boolean;
121
148
  /**
122
- * Install the worktree `tool_call` gate so structured file tools cannot
123
- * leave `cwd`. Workers pass true; the orchestrator leaves this off it has
124
- * to read the state directory and briefs.
149
+ * What kind of session this is. Explicit rather than derived from the caller:
150
+ * it selects the worktree confinement gate *and* is the role the release gate
151
+ * compares grants against, and those two must never be able to disagree about
152
+ * whether a session is a worker (#122).
125
153
  */
126
- confineToCwd?: boolean;
127
- /** Install the release/deploy tool-call gate for this session. */
128
- releasePolicy?: ReleasePolicy;
154
+ role: SessionRole;
155
+ /** Install the release/deploy tool-call gate with these per-shape grants. */
156
+ releaseGrants?: ResolvedGrants;
129
157
  /** Durable audit callback invoked only when that gate rejects a call. */
130
158
  onReleaseBlocked?: (shape: ReleaseShape) => void;
159
+ /**
160
+ * The allowlist an orchestrator session is held to (#127). Omitted, it is
161
+ * derived from the config on disk — which is the only honest default, since
162
+ * a jail that silently resolved to "no jail" would leave the roots this gate
163
+ * exists to close wide open on the day a caller forgot the option.
164
+ */
165
+ orchestratorJail?: OrchestratorJail;
166
+ /**
167
+ * Where an orchestrator confinement refusal is recorded. Defaults to the
168
+ * durable audit in the daemon's state directory — which is exactly why it is
169
+ * injectable: under `uid-pool` this function runs as a principal that cannot
170
+ * write that directory, so {@link runSessionHost} substitutes a forwarder and
171
+ * the *parent* performs the write. A refusal the operator never learns about
172
+ * is indistinguishable from a session that behaved (#127).
173
+ */
174
+ onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
175
+ /**
176
+ * The conductor verb socket this session's mutation tools call (#126).
177
+ *
178
+ * The tools are registered either way. Omitted, every one of them fails
179
+ * closed with an explanation, which is the correct behaviour for a session
180
+ * whose daemon never gave it a channel — a tool that quietly vanished would
181
+ * leave the model to improvise `git push` instead.
182
+ */
183
+ verbSocketPath?: string;
131
184
  }): Promise<AgentSessionLike> {
132
185
  let loaded: unknown;
133
186
  try {
@@ -165,10 +218,27 @@ export async function createSession(opts: {
165
218
  // transcript is the only record of what the worker actually did.
166
219
  const sessionManager = await openSessionManager(mod, opts);
167
220
  const extensions = [
168
- ...(opts.confineToCwd ? [worktreeConfinement(opts.cwd)] : []),
169
- ...(opts.releasePolicy === undefined
221
+ // Two shapes, because the two sessions need different things: a worker is
222
+ // jailed to its checkout, while the orchestrator has to read the state
223
+ // directory and its briefs and is jailed to an allowlist instead (#127).
224
+ ...(opts.role === "worker"
225
+ ? [worktreeConfinement(opts.cwd)]
226
+ : [
227
+ orchestratorConfinement(
228
+ opts.orchestratorJail ?? orchestratorJailFromConfig(),
229
+ opts.cwd,
230
+ opts.onConfinementRefusal,
231
+ ),
232
+ ]),
233
+ ...(opts.releaseGrants === undefined
170
234
  ? []
171
- : [releasePolicyTripwire(opts.releasePolicy, opts.onReleaseBlocked)]),
235
+ : [releasePolicyTripwire(opts.releaseGrants, opts.role, opts.onReleaseBlocked)]),
236
+ // The sanctioned way back to GitHub (#126). A thin client: it forwards
237
+ // arguments over the run's own socket and renders what the daemon decided.
238
+ // Registered unconditionally, including on a session with no socket, so a
239
+ // missing channel is a named refusal rather than an absent tool the model
240
+ // routes around with bash.
241
+ conductorVerbs(opts.verbSocketPath),
172
242
  ];
173
243
 
174
244
  const created = await mod.createAgentSession({
@@ -299,3 +369,389 @@ function asRawSession(created: unknown): RawSession {
299
369
  }
300
370
  return candidate;
301
371
  }
372
+
373
+
374
+ // --------------------------------------------------------- the session proxy
375
+
376
+ /**
377
+ * Absolute path of the child entrypoint, resolved against this package.
378
+ *
379
+ * Exported so the boundary suite can assert a slot principal may actually read
380
+ * it. The launcher execs this path under the slot uid, and on a real deployment
381
+ * it sits under the daemon's home — so a home hardened to `0700` makes every
382
+ * worker die before it connects, in a way no shell-based probe notices (#125).
383
+ */
384
+ export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
385
+
386
+ /**
387
+ * How long the parent waits for a child to connect and report `ready`.
388
+ *
389
+ * Generous on purpose: the child loads the harness, discovers MCP servers and
390
+ * opens a transcript before it can answer, and on a cold cache that is seconds
391
+ * rather than milliseconds. The timeout is not a performance guard, it is the
392
+ * difference between "the launcher was rejected" surfacing as a named error and
393
+ * surfacing as a dispatch that never returns.
394
+ */
395
+ const START_TIMEOUT_MS = 180_000;
396
+
397
+ /** Bounded tail of the child's stderr, so a crash is legible without unbounded buffering. */
398
+ const STDERR_TAIL = 8_000;
399
+
400
+ /** How long a disposed child gets to exit before it is signalled. */
401
+ const DISPOSE_GRACE_MS = 5_000;
402
+
403
+ export interface CreateSessionOptions {
404
+ cwd: string;
405
+ sessionDir?: string;
406
+ model?: string;
407
+ resume?: boolean;
408
+ role: SessionRole;
409
+ releaseGrants?: ResolvedGrants;
410
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
411
+ orchestratorJail?: OrchestratorJail;
412
+ onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
413
+ /**
414
+ * The OS principal and environment this session runs behind (#125). Omitted,
415
+ * the child runs as the daemon's own user with the daemon's environment —
416
+ * which is the A1 shape: out of process, no security claim. The daemon always
417
+ * passes one, and `status` reports which mechanism it names.
418
+ */
419
+ boundary?: SessionBoundary;
420
+ /**
421
+ * Where the control socket is bound. The daemon puts it inside the run's own
422
+ * boundary root so the slot principal can reach it; omitted, a private temp
423
+ * directory is used, which is what the tests and a `none` fleet want.
424
+ */
425
+ socketPath?: string;
426
+ /**
427
+ * The conductor verb socket this session's mutation tools call (#126).
428
+ *
429
+ * A different socket from `socketPath` above, and deliberately so: that one
430
+ * is the parent's own control channel to the child, this one is the child's
431
+ * only route back to a mutation, and collapsing them would make the daemon's
432
+ * session plumbing an attack surface for verbs.
433
+ */
434
+ verbSocketPath?: string;
435
+ /** Child stderr, line by line. Defaults to the process's own stderr. */
436
+ onChildLog?: (line: string) => void;
437
+ startupTimeoutMs?: number;
438
+ /**
439
+ * Test seam, and only that — the same shape as
440
+ * `OrchestratorOpts.createSessionImpl`. Production always wants
441
+ * `session-host.ts`; the parity suite points this at a child that calls
442
+ * {@link runSessionHost} with a hand-written session, so the proxy, the
443
+ * socket, the framing and the process lifecycle are all exercised for real
444
+ * without a live harness or a model bill.
445
+ */
446
+ hostModule?: string;
447
+ }
448
+
449
+ /**
450
+ * Start one omp coding session in a **child process** and return a proxy for it.
451
+ *
452
+ * The proxy is the whole of `omp-conductor`'s view of a session, and it is
453
+ * deliberately thin: {@link AgentSessionLike} has five members, so there are
454
+ * five things to forward and nothing else belongs here. `prompt` and `abort`
455
+ * go out over a unix socket, harness events come back and are re-emitted to the
456
+ * same `on()` subscribers the in-process version served, and `sessionFile` is
457
+ * whatever path the child reports the session actually opened — never one this
458
+ * side invented.
459
+ *
460
+ * Two behaviours that were free in-process and are bought explicitly here:
461
+ *
462
+ * - **A child outliving its parent.** The session used to die with the daemon
463
+ * because it *was* the daemon. Now the child watches its socket and exits
464
+ * when the parent goes away, so a daemon crash still leaves no orphaned
465
+ * session holding a checkout and a model budget.
466
+ * - **A wedged harness.** An in-process `abort()` that the harness ignored hung
467
+ * the daemon. Disposal here escalates: dispose message, then `SIGTERM`, then
468
+ * `SIGKILL`. The cap-kill path therefore always terminates.
469
+ *
470
+ * @throws if the child cannot be launched or the session cannot be built,
471
+ * carrying the child's own message — a missing peer dependency still reads as
472
+ * the deployment mistake it is, not as an unexplained exit code.
473
+ */
474
+ export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
475
+ const boundary = opts.boundary;
476
+ const owned = opts.socketPath === undefined;
477
+ // 0711, not 0700: a slot principal has to *traverse* to its own socket, and
478
+ // must not be able to list what else is in here or unlink the socket and
479
+ // bind its own. Same reasoning as the run workspace parent.
480
+ const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
481
+ const socketPath = opts.socketPath ?? join(socketDir, "s");
482
+ mkdirSync(socketDir, { recursive: true, mode: 0o711 });
483
+ chmodSync(socketDir, 0o711);
484
+ rmSync(socketPath, { force: true });
485
+
486
+ const server = createServer();
487
+ const { promise: attached, resolve: onAttach, reject: onAttachFail } =
488
+ Promise.withResolvers<Socket>();
489
+ server.once("connection", (socket: Socket) => {
490
+ socket.setNoDelay(true);
491
+ onAttach(socket);
492
+ });
493
+ server.once("error", (err: Error) => {
494
+ onAttachFail(err);
495
+ });
496
+ await new Promise<void>((resolve, reject) => {
497
+ server.once("error", reject);
498
+ server.listen(socketPath, () => {
499
+ resolve();
500
+ });
501
+ });
502
+ // The socket is the run's own channel: only its principal may speak on it.
503
+ chmodSync(socketPath, 0o600);
504
+ if (boundary?.principal !== undefined) {
505
+ chownSync(socketPath, boundary.principal.uid, boundary.principal.gid);
506
+ }
507
+
508
+ const spec: SessionHostSpec = {
509
+ socket: socketPath,
510
+ cwd: opts.cwd,
511
+ role: opts.role,
512
+ ...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
513
+ ...(opts.model === undefined ? {} : { model: opts.model }),
514
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
515
+ ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
516
+ // Resolved here rather than in the child: the child may not be able to read
517
+ // the config the jail is derived from, and a jail that fell back to
518
+ // defaults would silently widen the gate #127 closed.
519
+ ...(opts.role === "orchestrator"
520
+ ? { orchestratorJail: opts.orchestratorJail ?? orchestratorJailFromConfig() }
521
+ : {}),
522
+ ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
523
+ };
524
+
525
+ const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
526
+ const argv = [
527
+ ...(boundary?.launcher ?? []),
528
+ process.execPath,
529
+ opts.hostModule ?? SESSION_HOST,
530
+ JSON.stringify(spec),
531
+ ];
532
+ const child = Bun.spawn(argv, {
533
+ cwd: opts.cwd,
534
+ stdin: "ignore",
535
+ // The harness writes progress to stdout; both streams are the daemon's log,
536
+ // never the protocol. The protocol has its own socket precisely so a chatty
537
+ // harness build cannot corrupt it.
538
+ stdout: "pipe",
539
+ stderr: "pipe",
540
+ ...(boundary === undefined || Object.keys(boundary.env).length === 0 ? {} : { env: boundary.env }),
541
+ });
542
+
543
+ let stderrTail = "";
544
+ const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
545
+ if (stream === undefined) return;
546
+ const decoder = new TextDecoder();
547
+ let rest = "";
548
+ for await (const chunk of stream) {
549
+ rest += decoder.decode(chunk, { stream: true });
550
+ const lines = rest.split("\n");
551
+ rest = lines.pop() ?? "";
552
+ for (const line of lines) {
553
+ if (line === "") continue;
554
+ stderrTail = `${stderrTail}${line}\n`.slice(-STDERR_TAIL);
555
+ log(`${prefix}${line}`);
556
+ }
557
+ }
558
+ };
559
+ void drain(child.stdout, "session: ");
560
+ void drain(child.stderr, "session: ");
561
+
562
+ const cleanup = (): void => {
563
+ server.close();
564
+ rmSync(socketPath, { force: true });
565
+ if (owned) rmSync(socketDir, { recursive: true, force: true });
566
+ };
567
+
568
+ const handlers = new Map<string, ((e: unknown) => void)[]>();
569
+ const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
570
+ let sessionFile: string | undefined;
571
+ let modelFallbackMessage: string | undefined;
572
+ let promptSeq = 0;
573
+
574
+ const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
575
+ // Parked immediately. `ready` is rejected from the child's exit handler,
576
+ // which can fire before anything awaits it — and an unhandled rejection in a
577
+ // daemon that supervises sessions is a process-level crash, not a failed run.
578
+ // The real awaiters below still see the rejection.
579
+ ready.catch(() => undefined);
580
+ const { promise: exited, resolve: onExit } = Promise.withResolvers<void>();
581
+ /** Set once teardown has begun, so a clean exit is not reported as a crash. */
582
+ let disposing = false;
583
+
584
+ const fail = (message: string): void => {
585
+ const tail = stderrTail.trim();
586
+ const err = new Error(tail === "" ? message : `${message}\nchild output:\n${tail}`);
587
+ onReadyFail(err);
588
+ for (const [, waiter] of pending) waiter.reject(err);
589
+ pending.clear();
590
+ };
591
+
592
+ void child.exited.then((code) => {
593
+ onExit();
594
+ cleanup();
595
+ // A child that exits during teardown exited because we asked it to. Only an
596
+ // exit while the session is supposed to be live is a failure — and it has
597
+ // to reach whoever is awaiting a prompt, or the dispatcher waits forever
598
+ // for a turn from a process that is gone.
599
+ if (disposing) return;
600
+ fail(`omp-conductor session child exited ${String(code)} before the session ended`);
601
+ });
602
+
603
+ let socket: Socket;
604
+ try {
605
+ socket = await Promise.race([
606
+ attached,
607
+ new Promise<never>((_resolve, reject) => {
608
+ setTimeout(() => {
609
+ reject(
610
+ new Error(
611
+ `omp-conductor session child did not connect within ${String(
612
+ (opts.startupTimeoutMs ?? START_TIMEOUT_MS) / 1000,
613
+ )}s (launcher: ${argv.slice(0, Math.max(1, (boundary?.launcher ?? []).length)).join(" ") || "none"})`,
614
+ ),
615
+ );
616
+ }, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
617
+ }),
618
+ child.exited.then((code): never => {
619
+ throw new Error(`omp-conductor session child exited ${String(code)} before connecting`);
620
+ }),
621
+ ]);
622
+ } catch (err) {
623
+ child.kill("SIGKILL");
624
+ cleanup();
625
+ const tail = stderrTail.trim();
626
+ throw new Error(
627
+ `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
628
+ );
629
+ }
630
+
631
+ let buffer = "";
632
+ socket.on("data", (chunk: Buffer) => {
633
+ buffer += chunk.toString("utf8");
634
+ const { frames, rest } = decodeFrames(buffer);
635
+ buffer = rest;
636
+ for (const frame of frames) {
637
+ const message = frame as HostToParent;
638
+ switch (message.t) {
639
+ case "ready":
640
+ sessionFile = message.sessionFile;
641
+ modelFallbackMessage = message.modelFallbackMessage;
642
+ onReady();
643
+ break;
644
+ case "start-error":
645
+ fail(message.message);
646
+ break;
647
+ case "session-file":
648
+ sessionFile = message.path;
649
+ break;
650
+ case "event": {
651
+ const type = (message.event as { type?: unknown } | null | undefined)?.type;
652
+ if (typeof type !== "string") break;
653
+ for (const cb of handlers.get(type) ?? []) cb(message.event);
654
+ for (const cb of handlers.get("*") ?? []) cb(message.event);
655
+ break;
656
+ }
657
+ case "prompt-result": {
658
+ const waiter = pending.get(message.id);
659
+ if (waiter === undefined) break;
660
+ pending.delete(message.id);
661
+ if (message.ok) waiter.resolve();
662
+ else waiter.reject(new Error(message.error ?? "session prompt failed"));
663
+ break;
664
+ }
665
+ case "release-blocked":
666
+ opts.onReleaseBlocked?.(message.shape);
667
+ break;
668
+ case "confinement-refusal":
669
+ // Performed on this side because the child may not be able to write
670
+ // the state directory the audit lives in. Wrapped for the same reason
671
+ // the in-process version is: the audit is evidence, not the gate.
672
+ try {
673
+ (opts.onConfinementRefusal ?? recordConfinementRefusal)(message.refusal);
674
+ } catch {
675
+ // A full disk must not turn a deny into an allow.
676
+ }
677
+ break;
678
+ }
679
+ }
680
+ });
681
+
682
+ const write = (message: ParentToHost): void => {
683
+ if (!socket.writableEnded) socket.write(encodeFrame(message));
684
+ };
685
+
686
+ await Promise.race([
687
+ ready,
688
+ new Promise<never>((_resolve, reject) => {
689
+ setTimeout(() => {
690
+ reject(new Error("omp-conductor session child connected but never reported a session"));
691
+ }, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
692
+ }),
693
+ ]).catch((err: unknown) => {
694
+ child.kill("SIGKILL");
695
+ cleanup();
696
+ throw err;
697
+ });
698
+
699
+ const session: AgentSessionLike = {
700
+ prompt(text, promptOpts) {
701
+ promptSeq += 1;
702
+ const id = promptSeq;
703
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
704
+ pending.set(id, { resolve, reject });
705
+ write({ t: "prompt", id, text, ...(promptOpts === undefined ? {} : { opts: promptOpts }) });
706
+ // Resolves to `undefined`: no caller in this package reads a prompt's
707
+ // return value, and shipping the harness's own object across the wire
708
+ // would make this seam depend on a shape it deliberately does not name.
709
+ return promise.then(() => undefined);
710
+ },
711
+ on(event, cb) {
712
+ const list = handlers.get(event);
713
+ if (list) list.push(cb);
714
+ else handlers.set(event, [cb]);
715
+ },
716
+ abort() {
717
+ write({ t: "abort" });
718
+ },
719
+ get sessionFile() {
720
+ return sessionFile;
721
+ },
722
+ get modelFallbackMessage() {
723
+ return modelFallbackMessage;
724
+ },
725
+ };
726
+
727
+ disposers.set(session, async () => {
728
+ disposing = true;
729
+ write({ t: "dispose" });
730
+ socket.end();
731
+ const settled = await Promise.race([
732
+ exited.then(() => true),
733
+ new Promise<false>((resolve) => {
734
+ setTimeout(() => {
735
+ resolve(false);
736
+ }, DISPOSE_GRACE_MS).unref?.();
737
+ }),
738
+ ]);
739
+ if (!settled) {
740
+ // The escalation that makes a cap-kill final. In-process, a harness that
741
+ // ignored `abort()` hung the daemon; here it costs one signal.
742
+ child.kill("SIGTERM");
743
+ const stopped = await Promise.race([
744
+ exited.then(() => true),
745
+ new Promise<false>((resolve) => {
746
+ setTimeout(() => {
747
+ resolve(false);
748
+ }, DISPOSE_GRACE_MS).unref?.();
749
+ }),
750
+ ]);
751
+ if (!stopped) child.kill("SIGKILL");
752
+ }
753
+ cleanup();
754
+ });
755
+
756
+ return session;
757
+ }