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/README.md +775 -49
- package/package.json +1 -1
- package/src/approval-surface.ts +36 -1
- package/src/board.ts +30 -6
- package/src/briefs/orchestrator.md +114 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +164 -4
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1213 -78
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +80 -4
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +98 -25
- 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 +555 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +863 -10
- package/src/unblock.ts +53 -3
- 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 +227 -120
- package/systemd/omp-conductor.service.example +96 -8
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 {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
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
|
|
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
|
|
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
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
127
|
-
/** Install the release/deploy tool-call gate
|
|
128
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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.
|
|
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
|
+
}
|