@sars267/pi-delegation-broker 0.1.0-alpha.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.
@@ -0,0 +1,278 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { isAbsolute, join } from "node:path";
17
+ import { SqliteLeaseBroker } from "./broker.mjs";
18
+ import { BrokerIpcServer } from "./ipc.mjs";
19
+ import { verifySignedRegistry } from "./signed-registry.mjs";
20
+
21
+ const SOCKET_NAME = "broker.sock";
22
+ const DATABASE_NAME = "broker.sqlite";
23
+ const LOCK_NAME = "broker.lock";
24
+ // macOS permits roughly 104 bytes for a Unix-domain socket path; stay below
25
+ // the narrower common limit so startup fails clearly instead of listen(EINVAL).
26
+ const MAX_SOCKET_PATH_BYTES = 100;
27
+
28
+ function requireOwnerOnlyDirectory(path) {
29
+ if (typeof path !== "string" || !isAbsolute(path)) throw new Error("Broker stateDir must be an absolute path");
30
+ mkdirSync(path, { recursive: true, mode: 0o700 });
31
+ const link = lstatSync(path);
32
+ if (link.isSymbolicLink()) throw new Error("Broker stateDir must not be a symbolic link");
33
+ const canonical = realpathSync(path);
34
+ const stat = statSync(canonical);
35
+ if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) {
36
+ throw new Error(`Broker stateDir must be an owner-only directory: ${canonical}`);
37
+ }
38
+ return canonical;
39
+ }
40
+
41
+ function rejectUnsafeExistingFile(path, label) {
42
+ if (!existsSync(path)) return;
43
+ const stat = lstatSync(path);
44
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Broker ${label} must be a regular non-symlink file: ${path}`);
45
+ }
46
+
47
+ function hardenFile(path) {
48
+ if (existsSync(path)) chmodSync(path, 0o600);
49
+ }
50
+
51
+ function validateControllerToken(token) {
52
+ if (typeof token !== "string" || token.length < 32 || token.length > 512 || /[\0\r\n]/.test(token)) {
53
+ throw new Error("Broker controller token must be a bounded non-empty secret of at least 32 characters");
54
+ }
55
+ return token;
56
+ }
57
+
58
+ function deepFreeze(value) {
59
+ if (Array.isArray(value)) value.forEach(deepFreeze);
60
+ else if (value && typeof value === "object") Object.values(value).forEach(deepFreeze);
61
+ return Object.freeze(value);
62
+ }
63
+
64
+ /**
65
+ * Lifecycle owner for the current single-host fake broker MVP.
66
+ *
67
+ * It establishes one fail-closed owner-only state directory, a durable SQLite
68
+ * ledger, a private Unix socket, periodic lease expiry and a bounded shutdown.
69
+ * It intentionally is not a detached production daemon: the controller keeps
70
+ * the object and its bootstrap token in-process. It has no provider client or
71
+ * credential path.
72
+ */
73
+ export class SingleHostBrokerSupervisor {
74
+ #stateDir;
75
+ #registry;
76
+ #registryStatus;
77
+ #controllerToken;
78
+ #fakeProvider;
79
+ #sweepIntervalMs;
80
+ #shutdownDrainMs;
81
+ #instanceId = randomUUID();
82
+ #broker;
83
+ #server;
84
+ #sweepTimer;
85
+ #state = "new";
86
+ #startedAt;
87
+ #lastSweepAt;
88
+ #lastSweepError;
89
+
90
+ constructor({
91
+ stateDir,
92
+ registry,
93
+ signedRegistry,
94
+ trustedRegistryKeys,
95
+ /** Explicit test-only escape hatch. Production callers must use signedRegistry. */
96
+ allowUnsignedFixture = false,
97
+ controllerToken = randomBytes(32).toString("base64url"),
98
+ fakeProvider,
99
+ sweepIntervalMs = 1_000,
100
+ shutdownDrainMs = 2_000,
101
+ }) {
102
+ if (signedRegistry !== undefined && registry !== undefined) {
103
+ throw new Error("Broker supervisor accepts either signedRegistry or unsigned fixture registry, not both");
104
+ }
105
+ if (signedRegistry !== undefined) {
106
+ const verified = verifySignedRegistry(signedRegistry, { trustedKeys: trustedRegistryKeys });
107
+ this.#registry = verified.brokerRegistry;
108
+ this.#registryStatus = Object.freeze({
109
+ source: "signed",
110
+ keyId: verified.keyId,
111
+ registryVersion: verified.registryVersion,
112
+ fingerprint: verified.fingerprint,
113
+ expiresAt: verified.expiresAt,
114
+ });
115
+ } else {
116
+ if (!allowUnsignedFixture || !registry) {
117
+ throw new Error("Broker supervisor requires a signedRegistry; unsigned fixture registry needs allowUnsignedFixture: true");
118
+ }
119
+ this.#registry = registry;
120
+ this.#registryStatus = Object.freeze({ source: "unsigned_fixture" });
121
+ }
122
+ this.#stateDir = requireOwnerOnlyDirectory(stateDir);
123
+ if (Buffer.byteLength(join(this.#stateDir, SOCKET_NAME)) > MAX_SOCKET_PATH_BYTES) {
124
+ throw new Error(`Broker stateDir makes Unix socket path exceed ${MAX_SOCKET_PATH_BYTES} bytes`);
125
+ }
126
+ if (!Number.isInteger(sweepIntervalMs) || sweepIntervalMs < 100 || sweepIntervalMs > 60_000) {
127
+ throw new Error("Broker sweepIntervalMs must be an integer between 100 and 60000");
128
+ }
129
+ if (!Number.isInteger(shutdownDrainMs) || shutdownDrainMs < 0 || shutdownDrainMs > 60_000) {
130
+ throw new Error("Broker shutdownDrainMs must be an integer between 0 and 60000");
131
+ }
132
+ this.#controllerToken = validateControllerToken(controllerToken);
133
+ this.#fakeProvider = fakeProvider;
134
+ this.#sweepIntervalMs = sweepIntervalMs;
135
+ this.#shutdownDrainMs = shutdownDrainMs;
136
+ }
137
+
138
+ get stateDir() { return this.#stateDir; }
139
+ get socketPath() { return join(this.#stateDir, SOCKET_NAME); }
140
+ get databasePath() { return join(this.#stateDir, DATABASE_NAME); }
141
+ /** Controller-only bootstrap capability; never put this in a child policy. */
142
+ get controllerToken() { return this.#controllerToken; }
143
+
144
+ /** Controller-local redacted read-only state; never exposed through child IPC. */
145
+ auditSnapshot() {
146
+ if (!this.#broker) throw new Error("Broker supervisor is not running");
147
+ return deepFreeze(structuredClone({ leases: this.#broker.leases(), events: this.#broker.events() }));
148
+ }
149
+
150
+ status() {
151
+ return Object.freeze({
152
+ state: this.#state,
153
+ stateDir: this.#stateDir,
154
+ registry: this.#registryStatus,
155
+ ...(this.#state === "running" ? { socketPath: this.socketPath, startedAt: this.#startedAt, lastSweepAt: this.#lastSweepAt } : {}),
156
+ ...(this.#lastSweepError ? { lastSweepError: "broker_sweep_failed" } : {}),
157
+ });
158
+ }
159
+
160
+ async start() {
161
+ if (this.#state !== "new") throw new Error(`Broker supervisor cannot start from state ${this.#state}`);
162
+ this.#state = "starting";
163
+ try {
164
+ this.#acquireLock();
165
+ rejectUnsafeExistingFile(this.databasePath, "database");
166
+ this.#broker = new SqliteLeaseBroker({ path: this.databasePath, registry: this.#registry });
167
+ hardenFile(this.databasePath);
168
+ this.#server = new BrokerIpcServer({
169
+ broker: this.#broker,
170
+ socketPath: this.socketPath,
171
+ controllerToken: this.#controllerToken,
172
+ ...(this.#fakeProvider === undefined ? {} : { fakeProvider: this.#fakeProvider }),
173
+ });
174
+ await this.#server.start();
175
+ // SQLite may create WAL/SHM beside the DB. The owner-only directory is
176
+ // the primary protection; harden files too when present.
177
+ hardenFile(this.databasePath);
178
+ hardenFile(`${this.databasePath}-wal`);
179
+ hardenFile(`${this.databasePath}-shm`);
180
+ this.#startedAt = Date.now();
181
+ this.#runSweep();
182
+ this.#sweepTimer = setInterval(() => this.#runSweep(), this.#sweepIntervalMs);
183
+ this.#sweepTimer.unref?.();
184
+ this.#state = "running";
185
+ return this.status();
186
+ } catch (error) {
187
+ await this.#cleanupAfterFailedStart();
188
+ this.#state = "failed";
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ async stop() {
194
+ if (this.#state === "stopped") return this.status();
195
+ if (this.#state !== "running" && this.#state !== "failed") {
196
+ throw new Error(`Broker supervisor cannot stop from state ${this.#state}`);
197
+ }
198
+ this.#state = "stopping";
199
+ clearInterval(this.#sweepTimer);
200
+ let failure;
201
+ try {
202
+ await this.#server?.stop({ drainMs: this.#shutdownDrainMs });
203
+ } catch (error) {
204
+ failure = error;
205
+ }
206
+ try {
207
+ this.#broker?.close();
208
+ this.#broker = undefined;
209
+ } catch (error) {
210
+ failure ??= error;
211
+ }
212
+ try {
213
+ this.#releaseOwnLock();
214
+ } catch (error) {
215
+ failure ??= error;
216
+ }
217
+ this.#state = "stopped";
218
+ if (failure) throw failure;
219
+ return this.status();
220
+ }
221
+
222
+ #lockPath() { return join(this.#stateDir, LOCK_NAME); }
223
+
224
+ #acquireLock() {
225
+ const lockPath = this.#lockPath();
226
+ rejectUnsafeExistingFile(lockPath, "lock");
227
+ let descriptor;
228
+ try {
229
+ descriptor = openSync(lockPath, "wx", 0o600);
230
+ } catch (error) {
231
+ if (error && typeof error === "object" && error.code === "EEXIST") {
232
+ throw new Error("Broker stateDir is already locked or has a stale lock; manual recovery is required");
233
+ }
234
+ throw error;
235
+ }
236
+ try {
237
+ writeFileSync(descriptor, `${JSON.stringify({ instanceId: this.#instanceId, pid: process.pid, startedAt: Date.now() })}\n`);
238
+ fsyncSync(descriptor);
239
+ } finally {
240
+ closeSync(descriptor);
241
+ }
242
+ hardenFile(lockPath);
243
+ }
244
+
245
+ #releaseOwnLock() {
246
+ const lockPath = this.#lockPath();
247
+ if (!existsSync(lockPath)) return;
248
+ rejectUnsafeExistingFile(lockPath, "lock");
249
+ let parsed;
250
+ try { parsed = JSON.parse(readFileSync(lockPath, "utf8")); } catch {
251
+ throw new Error("Broker lock contents are invalid; refusing to remove lock");
252
+ }
253
+ if (parsed?.instanceId !== this.#instanceId) {
254
+ throw new Error("Broker lock ownership changed; refusing to remove lock");
255
+ }
256
+ rmSync(lockPath);
257
+ }
258
+
259
+ #runSweep() {
260
+ try {
261
+ this.#broker?.expire(Date.now());
262
+ this.#lastSweepAt = Date.now();
263
+ this.#lastSweepError = undefined;
264
+ } catch {
265
+ // Status surfaces the failure without copying SQLite/path details into a
266
+ // child-visible channel or an audit event.
267
+ this.#lastSweepError = true;
268
+ }
269
+ }
270
+
271
+ async #cleanupAfterFailedStart() {
272
+ clearInterval(this.#sweepTimer);
273
+ try { await this.#server?.stop({ drainMs: 0 }); } catch { /* cleanup only */ }
274
+ try { this.#broker?.close(); } catch { /* cleanup only */ }
275
+ this.#broker = undefined;
276
+ try { this.#releaseOwnLock(); } catch { /* preserve uncertain lock */ }
277
+ }
278
+ }
@@ -0,0 +1,3 @@
1
+ /** Test-only fixtures and deterministic fake transport. Never use as a real provider adapter. */
2
+ export { ScriptedFakeProvider } from "./fake-provider.mjs";
3
+ export { fixtureContract, fixtureRegistry } from "./broker.mjs";
@@ -0,0 +1,194 @@
1
+ import { existsSync, lstatSync, mkdirSync, realpathSync, rmSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { provisionBrokeredAgentDir } from "./isolated-child-config.mjs";
4
+ import { requestBrokerIpc } from "./ipc.mjs";
5
+
6
+ const CHILD_ID = /^[A-Za-z0-9_-]{1,160}$/;
7
+
8
+ function ownerOnlyDirectory(path, label) {
9
+ if (typeof path !== "string" || !isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
10
+ mkdirSync(path, { recursive: true, mode: 0o700 });
11
+ if (lstatSync(path).isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
12
+ const canonical = realpathSync(path);
13
+ const stat = statSync(canonical);
14
+ if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) throw new Error(`${label} must be an owner-only directory`);
15
+ return canonical;
16
+ }
17
+
18
+ function expectedModel(selection) {
19
+ const model = selection?.expectedModel;
20
+ if (!model || typeof model.provider !== "string" || !model.provider || typeof model.modelId !== "string" || !model.modelId) {
21
+ throw new Error("Broker launch selection needs an explicit expectedModel");
22
+ }
23
+ return model;
24
+ }
25
+
26
+ /**
27
+ * Controller-side implementation of pi-subagent-workflow's trusted resolver.
28
+ * It is intentionally separate from child code: only this object can use the
29
+ * supervisor controller token to reserve/release; a child receives only the
30
+ * lease-scoped capability placed into its normalized launch policy.
31
+ */
32
+ export class BrokeredLaunchResolver {
33
+ #socketPath;
34
+ #controllerToken;
35
+ #agentRoot;
36
+ #extensionPaths;
37
+ #offline;
38
+ #selectContract;
39
+ #admissions = new Map();
40
+
41
+ constructor({
42
+ socketPath,
43
+ controllerToken,
44
+ agentRoot,
45
+ extensionPaths,
46
+ offline = false,
47
+ selectContract,
48
+ }) {
49
+ if (typeof socketPath !== "string" || !isAbsolute(socketPath)) throw new Error("Broker launch resolver needs an absolute socketPath");
50
+ if (typeof controllerToken !== "string" || controllerToken.length < 32) throw new Error("Broker launch resolver needs a controller-only token");
51
+ if (!Array.isArray(extensionPaths) || extensionPaths.length === 0 || extensionPaths.some((path) => typeof path !== "string" || !isAbsolute(path))) {
52
+ throw new Error("Broker launch resolver needs non-empty absolute explicit extension paths");
53
+ }
54
+ if (typeof offline !== "boolean") throw new Error("Broker launch resolver offline must be boolean");
55
+ if (typeof selectContract !== "function") throw new Error("Broker launch resolver needs a controller selectContract function");
56
+ this.#socketPath = socketPath;
57
+ this.#controllerToken = controllerToken;
58
+ this.#agentRoot = ownerOnlyDirectory(agentRoot, "Broker child agent root");
59
+ this.#extensionPaths = Object.freeze([...extensionPaths]);
60
+ this.#offline = offline;
61
+ this.#selectContract = selectContract;
62
+ }
63
+
64
+ /** Compatible with TrustedChildLaunchResolver; request has no raw prompt. */
65
+ async resolve(request) {
66
+ if (!request || !CHILD_ID.test(request.childId ?? "")) throw new Error("Broker launch request has an invalid childId");
67
+ if (this.#admissions.has(request.childId)) return { action: "deny", reason: "duplicate child admission" };
68
+ const selection = await this.#selectContract(Object.freeze({ ...request }));
69
+ if (selection?.action === "deny") return { action: "deny", reason: safeReason(selection.reason) };
70
+ if (!selection?.contract) throw new Error("Broker launch selection did not return a contract");
71
+ const expected = expectedModel(selection);
72
+ if (request.model?.provider !== expected.provider || request.model?.modelId !== expected.modelId) {
73
+ return { action: "deny", reason: "resolved model is not approved for this broker contract" };
74
+ }
75
+
76
+ const reservation = await this.#controller("reserve", { contract: selection.contract });
77
+ if (reservation?.status !== "leased") return { action: "deny", reason: reservation?.status === "denied_capacity" ? "no compatible broker capacity" : "broker policy denied launch" };
78
+ const lease = reservation.lease;
79
+ let agentDir;
80
+ try {
81
+ const issued = await this.#controller("issueLeaseCapability", { leaseId: lease.leaseId, fencingToken: lease.fencingToken });
82
+ if (issued?.status !== "issued" || typeof issued.capability !== "string") throw new Error("Broker declined lease capability issuance");
83
+ agentDir = provisionBrokeredAgentDir(join(this.#agentRoot, request.childId)).agentDir;
84
+ const admission = { lease, agentDir, capability: issued.capability, phase: "pending_handoff" };
85
+ this.#admissions.set(request.childId, admission);
86
+ return {
87
+ action: "allow",
88
+ policy: {
89
+ policyId: lease.leaseId,
90
+ agentDir,
91
+ offline: this.#offline,
92
+ extensionPaths: this.#extensionPaths,
93
+ environment: {
94
+ PI_BROKER_SOCKET: this.#socketPath,
95
+ PI_BROKER_LEASE_ID: lease.leaseId,
96
+ PI_BROKER_FENCING_TOKEN: String(lease.fencingToken),
97
+ PI_BROKER_CAPABILITY: issued.capability,
98
+ },
99
+ onBeforeChildAbandoned: async () => this.releaseUnhanded(request.childId),
100
+ onChildSessionOpened: async () => this.markChildHanded(request.childId),
101
+ onChildSessionClosed: async () => this.finalizeHandedChild(request.childId),
102
+ },
103
+ };
104
+ } catch (error) {
105
+ await this.#releaseLease(lease).catch(() => undefined);
106
+ if (agentDir) this.#removeAgentDir(agentDir);
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ /** Idempotent pre-handoff cleanup called only by the trusted parent runner. */
112
+ async releaseUnhanded(childId) {
113
+ return this.#releaseAdmission(childId, "release_pending");
114
+ }
115
+
116
+ /** Called after the trusted runner has a real ChildSession/process. */
117
+ async markChildHanded(childId) {
118
+ const admission = this.#admissions.get(childId);
119
+ if (!admission) return { status: "already_released" };
120
+ if (admission.phase === "pending_handoff") admission.phase = "handed";
121
+ return { status: admission.phase };
122
+ }
123
+
124
+ /** Idempotent post-session cleanup called only after trusted runner disposal. */
125
+ async finalizeHandedChild(childId) {
126
+ return this.#releaseAdmission(childId, "closed_release_pending");
127
+ }
128
+
129
+ /** Retry only records explicitly known to be unhanded or session-closed. */
130
+ async reconcilePendingReleases() {
131
+ const outcomes = [];
132
+ for (const [childId, admission] of this.#admissions) {
133
+ if (admission.phase !== "release_pending" && admission.phase !== "closed_release_pending") continue;
134
+ try {
135
+ outcomes.push(Object.freeze({ childId, ...(await this.#releaseAdmission(childId, admission.phase)) }));
136
+ } catch {
137
+ outcomes.push(Object.freeze({ childId, status: "pending" }));
138
+ }
139
+ }
140
+ return Object.freeze(outcomes);
141
+ }
142
+
143
+ /** Redacted controller-local visibility; capabilities/controller token stay private. */
144
+ admissions() {
145
+ return Object.freeze([...this.#admissions.entries()].map(([childId, admission]) => Object.freeze({
146
+ childId,
147
+ leaseId: admission.lease.leaseId,
148
+ fencingToken: admission.lease.fencingToken,
149
+ expiresAt: admission.lease.expiresAt,
150
+ phase: admission.phase,
151
+ })));
152
+ }
153
+
154
+ async #releaseAdmission(childId, pendingPhase) {
155
+ const admission = this.#admissions.get(childId);
156
+ if (!admission) return { status: "already_released" };
157
+ admission.phase = pendingPhase;
158
+ // Do not forget an admission before controller IPC confirms. On a broker
159
+ // transport failure the controller retains a redacted record and agent dir
160
+ // for explicit retry/reconciliation instead of silently relying on TTL.
161
+ await this.#releaseLease(admission.lease);
162
+ this.#removeAgentDir(admission.agentDir);
163
+ this.#admissions.delete(childId);
164
+ return { status: "released" };
165
+ }
166
+
167
+ async #controller(method, params) {
168
+ return requestBrokerIpc({
169
+ socketPath: this.#socketPath,
170
+ authorization: this.#controllerToken,
171
+ method,
172
+ params,
173
+ });
174
+ }
175
+
176
+ async #releaseLease(lease) {
177
+ await this.#controller("release", { leaseId: lease.leaseId, fencingToken: lease.fencingToken });
178
+ }
179
+
180
+ #removeAgentDir(agentDir) {
181
+ // agentDir was constructed from a validated child ID under the canonical
182
+ // root. Refuse a surprising path rather than recursively removing outside.
183
+ if (join(this.#agentRoot, agentDir.slice(this.#agentRoot.length + 1)) !== agentDir || !agentDir.startsWith(`${this.#agentRoot}/`)) {
184
+ throw new Error("Broker resolver refused to remove an agent directory outside its root");
185
+ }
186
+ if (existsSync(agentDir)) rmSync(agentDir, { recursive: true, force: true });
187
+ }
188
+ }
189
+
190
+ function safeReason(reason) {
191
+ if (typeof reason !== "string") return "broker policy denied launch";
192
+ // Denial enters a child-visible runner error; do not pass controller detail.
193
+ return /^[A-Za-z0-9 _.-]{1,120}$/.test(reason) ? reason : "broker policy denied launch";
194
+ }