@fitchmultz/pi-subagents 0.46.2 → 0.48.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. package/src/pi-intercom/types.ts +2 -0
@@ -0,0 +1,489 @@
1
+ import { nativePeerId } from "./session-targets.ts";
2
+ import { closeSync, constants, lstatSync, opendirSync, openSync, readSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+ import { setImmediate as yieldTurn } from "node:timers/promises";
5
+ import type * as NativeSession from "@earendil-works/pi-coding-agent";
6
+ import { JournalFrames } from "../shared/journal-reader.ts";
7
+ import { loadNativeSession } from "../shared/native-session-loader.ts";
8
+ import type { UnknownRecord } from "../shared/unknown.ts";
9
+ import { Fault, cancelled } from "./bridge-protocol.ts";
10
+ import type { SessionInfo } from "./types.ts";
11
+ import { resolveIntercomPresenceName } from "./identity.ts";
12
+ import { within, type LaunchPolicy } from "./bridge-launch-policy.ts";
13
+
14
+ export interface SavedSession {
15
+ readonly sessionId: string;
16
+ readonly peerId: string;
17
+ readonly name?: string;
18
+ readonly cwd: string;
19
+ readonly sessionFile: string;
20
+ readonly lastSeen: number;
21
+ }
22
+ export interface CatalogQuery {
23
+ readonly live: boolean;
24
+ readonly saved: boolean;
25
+ readonly q?: string;
26
+ readonly offset: number;
27
+ readonly limit: number;
28
+ }
29
+
30
+ /** Reject links at every journal path component; ordinary owned 0644 journals are valid. */
31
+ export function trustedJournal(policy: LaunchPolicy, file: string): void {
32
+ const root = [policy.sessionDir, ...policy.resumeDirs].find((dir) => within(dir, file));
33
+ if (root === undefined) {
34
+ throw new Fault(403, "journal_forbidden", "Session journal is outside trusted resumeDirs.");
35
+ }
36
+ let path = root;
37
+ for (const part of ["", ...relative(root, file).split(sep).filter(Boolean)]) {
38
+ path = join(path, part);
39
+ const stat = lstatSync(path);
40
+ if (
41
+ stat.isSymbolicLink() ||
42
+ stat.uid !== process.getuid?.() ||
43
+ (!stat.isDirectory() && path !== file)
44
+ ) {
45
+ throw new Fault(
46
+ 403,
47
+ "journal_forbidden",
48
+ "Session journal must have an owned, link-free path.",
49
+ );
50
+ }
51
+ }
52
+ if (!lstatSync(file).isFile()) {
53
+ throw new Fault(403, "journal_forbidden", "Session journal must be an owned regular file.");
54
+ }
55
+ }
56
+ // Reads and projection input are consumed synchronously; prefix/UTF-8 carry never retains this view.
57
+ const METADATA_BYTES = Buffer.allocUnsafe(1024 * 1024);
58
+ const METADATA_FIELDS = new Set(["type", "id", "version", "timestamp", "cwd", "name", "parentId"]);
59
+ // ponytail: fast-skip native type-first envelopes only; other key ordering
60
+ // uses the bounded parser. Extend the fast path if native serialization changes.
61
+ const BODY_ENTRY =
62
+ /^\{"type":"(?:message|model_change|thinking_level_change|compaction|branch_summary|custom|custom_message|label|usage|context_edit)",/;
63
+ class MetadataFrames {
64
+ private prefix = Buffer.alloc(0);
65
+ private skip: boolean | undefined;
66
+ private readonly frames: JournalFrames;
67
+ private readonly nativeHeader: () => boolean;
68
+ constructor(frames: JournalFrames, nativeHeader: () => boolean) {
69
+ this.frames = frames;
70
+ this.nativeHeader = nativeHeader;
71
+ }
72
+ private part(bytes: Buffer, complete: boolean): void {
73
+ let remainder = bytes;
74
+ if (this.skip === undefined) {
75
+ const count = Math.min(64 - this.prefix.length, bytes.length);
76
+ this.prefix = Buffer.concat([this.prefix, bytes.subarray(0, count)]);
77
+ remainder = bytes.subarray(count);
78
+ if (this.prefix.length < 64 && !complete) {
79
+ return;
80
+ }
81
+ this.skip = this.nativeHeader() && BODY_ENTRY.test(this.prefix.toString("ascii"));
82
+ if (!this.skip) {
83
+ this.frames.write(this.prefix);
84
+ }
85
+ this.prefix = Buffer.alloc(0);
86
+ }
87
+ if (!this.skip) {
88
+ this.frames.write(remainder);
89
+ }
90
+ }
91
+ write(bytes: Buffer): void {
92
+ for (let from = 0; from < bytes.length;) {
93
+ const newline = bytes.indexOf(10, from);
94
+ const end = newline < 0 ? bytes.length : newline + 1;
95
+ this.part(bytes.subarray(from, end), newline >= 0);
96
+ if (newline >= 0) {
97
+ this.skip = undefined;
98
+ }
99
+ from = end;
100
+ }
101
+ }
102
+ finish(): void {
103
+ if (this.prefix.length > 0) {
104
+ this.part(Buffer.alloc(0), true);
105
+ }
106
+ this.frames.finish();
107
+ }
108
+ }
109
+ async function readMetadata(
110
+ file: string,
111
+ sdk: typeof NativeSession,
112
+ signal: AbortSignal,
113
+ mode: "header" | "published" | "resume",
114
+ ): Promise<Omit<SavedSession, "lastSeen">> {
115
+ const headerOnly = mode === "header";
116
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
117
+ try {
118
+ const metadata: UnknownRecord[] = [];
119
+ let first = true;
120
+ const frames = new JournalFrames(
121
+ (path) => path.length === 0 || (path.length === 1 && METADATA_FIELDS.has(String(path[0]))),
122
+ ({ value }) => {
123
+ if (
124
+ first &&
125
+ (value.type !== "session" ||
126
+ typeof value.id !== "string" ||
127
+ typeof value.cwd !== "string")
128
+ ) {
129
+ throw new Error("Not a native session journal");
130
+ }
131
+ first = false;
132
+ if (value.type === "session" && metadata.length === 0) {
133
+ metadata.push(value);
134
+ } else if (value.type === "session_info") {
135
+ metadata[1] = value;
136
+ }
137
+ },
138
+ { policy: "inspect", requireNewline: mode !== "resume" },
139
+ );
140
+ const bytes = headerOnly ? METADATA_BYTES.subarray(0, 64 * 1024) : METADATA_BYTES;
141
+ const reader = new MetadataFrames(frames, () => !first);
142
+ for (;;) {
143
+ cancelled(signal);
144
+ // One shared file position/buffer: reads must be serial, and yield to cancellation.
145
+ // oxlint-disable-next-line no-await-in-loop
146
+ await yieldTurn(undefined, { signal });
147
+ const bytesRead = readSync(fd, bytes, 0, bytes.length, null);
148
+ if (bytesRead === 0) {
149
+ break;
150
+ }
151
+ reader.write(bytes.subarray(0, bytesRead));
152
+ if (headerOnly && metadata.length > 0) {
153
+ break;
154
+ }
155
+ }
156
+ reader.finish();
157
+ const header = metadata.at(0);
158
+ if (
159
+ header?.type !== "session" ||
160
+ typeof header.id !== "string" ||
161
+ typeof header.cwd !== "string"
162
+ ) {
163
+ throw new Error("Not a native session journal");
164
+ }
165
+ // Native naming and ID semantics, without opening/migrating a live journal or hydrating bodies.
166
+ const entries = sdk.parseSessionEntries(
167
+ metadata.map((entry) => JSON.stringify(entry)).join("\n"),
168
+ );
169
+ const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
170
+ return {
171
+ sessionId: manager.getSessionId(),
172
+ peerId: nativePeerId(manager.getSessionId()),
173
+ name: manager.getSessionName(),
174
+ cwd: manager.getCwd(),
175
+ sessionFile: file,
176
+ };
177
+ } finally {
178
+ closeSync(fd);
179
+ }
180
+ }
181
+
182
+ /** Native open repairs EOF framing; inspect its identity before allowing that write. */
183
+ export function inspectResumeIdentity(
184
+ file: string,
185
+ sdk: typeof NativeSession,
186
+ signal: AbortSignal,
187
+ ): Promise<Pick<SavedSession, "sessionId" | "cwd" | "name">> {
188
+ return readMetadata(file, sdk, signal, "resume");
189
+ }
190
+
191
+ export function savedView(row: SavedSession, peer?: SessionInfo): UnknownRecord {
192
+ return {
193
+ id: row.sessionId,
194
+ sessionId: row.sessionId,
195
+ peerId: peer?.id,
196
+ name: row.name,
197
+ cwd: row.cwd,
198
+ status: peer?.status ?? "saved",
199
+ lastSeen: peer?.lastSeen ?? row.lastSeen,
200
+ saved: true,
201
+ owned: false,
202
+ };
203
+ }
204
+ export function exactPeer(
205
+ row: SavedSession,
206
+ peers: readonly SessionInfo[],
207
+ ): SessionInfo | undefined {
208
+ const expectedName = resolveIntercomPresenceName(row.name, row.sessionId);
209
+ const peer = peers.find((candidate) => candidate.id === row.peerId);
210
+ if (peer && (peer.cwd !== row.cwd || peer.name !== expectedName)) {
211
+ throw new Fault(
212
+ 409,
213
+ "active_session",
214
+ "Native session has a live peer with changed identity; do not reopen its journal.",
215
+ );
216
+ }
217
+ if (
218
+ !peer &&
219
+ peers.some(
220
+ (candidate) =>
221
+ !candidate.id.startsWith("pi-") &&
222
+ candidate.name === expectedName &&
223
+ candidate.cwd === row.cwd,
224
+ )
225
+ ) {
226
+ throw new Fault(
227
+ 409,
228
+ "active_session",
229
+ "A matching live peer has unproven native identity; refusing to reopen its possible journal.",
230
+ );
231
+ }
232
+ return peer;
233
+ }
234
+ interface BrowseContext {
235
+ readonly policy?: LaunchPolicy;
236
+ readonly signal: AbortSignal;
237
+ readonly peers: (signal: AbortSignal) => Promise<readonly SessionInfo[]>;
238
+ readonly activeState: (id: string) => string | undefined;
239
+ }
240
+ function liveView(peer: SessionInfo, state?: string): UnknownRecord {
241
+ return {
242
+ id: peer.id,
243
+ peerId: peer.id,
244
+ name: peer.name,
245
+ cwd: peer.cwd,
246
+ status: state ?? peer.status,
247
+ lastSeen: peer.lastSeen,
248
+ saved: false,
249
+ owned: state !== undefined,
250
+ };
251
+ }
252
+ export async function browseSessions(
253
+ catalog: SessionCatalog,
254
+ query: CatalogQuery,
255
+ context: BrowseContext,
256
+ ): Promise<UnknownRecord> {
257
+ const peers = query.live ? await context.peers(context.signal) : [];
258
+ const { policy } = context;
259
+ if (query.saved && !policy) {
260
+ throw new Fault(403, "launch_disabled", "Saved sessions require local launch/resume policy.");
261
+ }
262
+ const page =
263
+ policy && query.saved
264
+ ? await catalog.page(policy, query, context.signal)
265
+ : { sessions: [], nextOffset: undefined };
266
+ const rows = page.sessions.map((row) => {
267
+ const state = context.activeState(row.sessionId);
268
+ const peer = exactPeer(row, peers);
269
+ return {
270
+ ...savedView(row, peer),
271
+ status: state ?? peer?.status ?? "saved",
272
+ owned: state !== undefined,
273
+ };
274
+ });
275
+ const included = new Set(page.sessions.map((row) => row.peerId));
276
+ const live = peers.filter(
277
+ (peer) =>
278
+ !included.has(peer.id) &&
279
+ (query.q === undefined ||
280
+ `${peer.id} ${peer.name ?? ""} ${peer.cwd}`.toLowerCase().includes(query.q.toLowerCase())),
281
+ );
282
+ if (!query.saved) {
283
+ const end = query.offset + query.limit;
284
+ return {
285
+ sessions: live
286
+ .slice(query.offset, end)
287
+ .map((peer) => liveView(peer, context.activeState(peer.id))),
288
+ nextOffset: end < live.length ? end : undefined,
289
+ };
290
+ }
291
+ return {
292
+ sessions: [
293
+ ...rows,
294
+ ...live
295
+ .slice(0, query.limit - rows.length)
296
+ .map((peer) => liveView(peer, context.activeState(peer.id))),
297
+ ],
298
+ liveTruncated: rows.length + live.length > query.limit ? true : undefined,
299
+ nextOffset: page.nextOffset,
300
+ };
301
+ }
302
+
303
+ function uniqueSavedSession(matches: readonly SavedSession[]): SavedSession {
304
+ if (matches.length > 1) {
305
+ throw new Fault(
306
+ 409,
307
+ "ambiguous_session",
308
+ "Multiple saved sessions match; choose an exact unique native ID.",
309
+ );
310
+ }
311
+ const session = matches.at(0);
312
+ if (!session) {
313
+ throw new Fault(
314
+ 404,
315
+ "unknown_session",
316
+ "No saved native session matches within trusted resumeDirs.",
317
+ );
318
+ }
319
+ return session;
320
+ }
321
+
322
+ export class SessionCatalog {
323
+ private readonly cache = new Map<
324
+ string,
325
+ { readonly stamp: string; readonly session: SavedSession }
326
+ >();
327
+ private readonly headers = new Map<
328
+ string,
329
+ { readonly stamp: string; readonly session: SavedSession }
330
+ >();
331
+
332
+ private async *walk(dir: string, signal: AbortSignal): AsyncGenerator<string> {
333
+ await yieldTurn(undefined, { signal });
334
+ const stat = lstatSync(dir);
335
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.()) {
336
+ return;
337
+ }
338
+ const directory = opendirSync(dir);
339
+ let visited = 0;
340
+ try {
341
+ for (let row = directory.readSync(); row !== null; row = directory.readSync()) {
342
+ // Yield during offset skips and empty-directory traversal too.
343
+ if (visited++ % 32 === 0) {
344
+ // Directory entries must be processed without retaining a corpus-sized array.
345
+ // oxlint-disable-next-line no-await-in-loop
346
+ await yieldTurn(undefined, { signal });
347
+ }
348
+ cancelled(signal);
349
+ const file = join(dir, row.name);
350
+ if (row.isDirectory()) {
351
+ yield* this.walk(file, signal);
352
+ } else if (row.isFile() && row.name.endsWith(".jsonl")) {
353
+ yield file;
354
+ }
355
+ }
356
+ } finally {
357
+ directory.closeSync();
358
+ }
359
+ }
360
+ private async *files(policy: LaunchPolicy, signal: AbortSignal): AsyncGenerator<string> {
361
+ const unique = [...new Set([policy.sessionDir, ...policy.resumeDirs])];
362
+ const roots = unique.filter(
363
+ (root) => !unique.some((other) => other !== root && within(other, root)),
364
+ );
365
+ for (const root of roots) {
366
+ yield* this.walk(root, signal);
367
+ }
368
+ }
369
+ async read(
370
+ policy: LaunchPolicy,
371
+ file: string,
372
+ signal: AbortSignal,
373
+ headerOnly = false,
374
+ ): Promise<SavedSession | undefined> {
375
+ cancelled(signal);
376
+ try {
377
+ trustedJournal(policy, file);
378
+ const stat = lstatSync(file);
379
+ const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
380
+ const cache = headerOnly ? this.headers : this.cache;
381
+ const cached = this.cache.get(file) ?? cache.get(file);
382
+ if (cached?.stamp === stamp) {
383
+ return cached.session;
384
+ }
385
+ const sdk = await loadNativeSession(policy.packageRoot);
386
+ cancelled(signal);
387
+ const session = {
388
+ ...(await readMetadata(file, sdk, signal, headerOnly ? "header" : "published")),
389
+ lastSeen: stat.mtimeMs,
390
+ };
391
+ if (cache.size >= 100000) {
392
+ cache.clear();
393
+ }
394
+ cache.set(file, { stamp, session });
395
+ return session;
396
+ } catch (error) {
397
+ cancelled(signal);
398
+ if (error instanceof Fault) {
399
+ throw error;
400
+ }
401
+ return undefined;
402
+ }
403
+ }
404
+ async resolve(
405
+ policy: LaunchPolicy,
406
+ selector: { readonly sessionId?: string; readonly name?: string; readonly target?: string },
407
+ signal: AbortSignal,
408
+ ): Promise<SavedSession> {
409
+ if (selector.target !== undefined) {
410
+ return this.resolveTarget(policy, selector.target, signal);
411
+ }
412
+ const matches: SavedSession[] = [];
413
+ for await (const file of this.files(policy, signal)) {
414
+ // Inspect native metadata, not a filename/prefix guess. Yield so request cancellation can run.
415
+ await yieldTurn(undefined, { signal });
416
+ const row = await this.read(policy, file, signal, selector.sessionId !== undefined);
417
+ if (
418
+ row &&
419
+ (selector.sessionId === undefined
420
+ ? row.name === selector.name
421
+ : row.sessionId === selector.sessionId || row.peerId === selector.sessionId)
422
+ ) {
423
+ matches.push(row);
424
+ }
425
+ if (matches.length > 1) {
426
+ throw new Fault(
427
+ 409,
428
+ "ambiguous_session",
429
+ "Multiple saved sessions match; choose an exact unique native ID.",
430
+ );
431
+ }
432
+ }
433
+ const session = uniqueSavedSession(matches);
434
+ return (await this.read(policy, session.sessionFile, signal)) ?? session;
435
+ }
436
+ private async resolveTarget(
437
+ policy: LaunchPolicy,
438
+ target: string,
439
+ signal: AbortSignal,
440
+ ): Promise<SavedSession> {
441
+ const ids: SavedSession[] = [];
442
+ const names: SavedSession[] = [];
443
+ for await (const file of this.files(policy, signal)) {
444
+ // Once an exact native ID is proven, only headers can affect its uniqueness.
445
+ const row = await this.read(policy, file, signal, ids.length > 0);
446
+ if (!row) {
447
+ continue;
448
+ }
449
+ if ((row.sessionId === target || row.peerId === target) && ids.length < 2) {
450
+ ids.push(row);
451
+ }
452
+ if (row.name === target && names.length < 2) {
453
+ names.push(row);
454
+ }
455
+ }
456
+ // Preserve exact-ID priority without paying for two complete archive scans.
457
+ const session = uniqueSavedSession(ids.length > 0 ? ids : names);
458
+ return (await this.read(policy, session.sessionFile, signal)) ?? session;
459
+ }
460
+ async page(
461
+ policy: LaunchPolicy,
462
+ query: CatalogQuery,
463
+ signal: AbortSignal,
464
+ ): Promise<{ readonly sessions: SavedSession[]; readonly nextOffset?: number }> {
465
+ const sessions: SavedSession[] = [];
466
+ let offset = 0;
467
+ let scanned = 0;
468
+ for await (const file of this.files(policy, signal)) {
469
+ if (offset++ < query.offset) {
470
+ continue;
471
+ }
472
+ if (scanned++ >= 1000 || sessions.length >= query.limit) {
473
+ return { sessions, nextOffset: offset - 1 };
474
+ }
475
+ // Each bounded metadata read yields to cancellation; no whole-corpus pathname array.
476
+ const row = await this.read(policy, file, signal);
477
+ if (
478
+ row &&
479
+ (query.q === undefined ||
480
+ `${row.sessionId} ${row.name ?? ""} ${row.cwd}`
481
+ .toLowerCase()
482
+ .includes(query.q.toLowerCase()))
483
+ ) {
484
+ sessions.push(row);
485
+ }
486
+ }
487
+ return { sessions };
488
+ }
489
+ }
@@ -1,27 +1,51 @@
1
- import {
2
- constants,
3
- closeSync,
4
- fstatSync,
5
- openSync,
6
- readFileSync,
7
- realpathSync,
8
- statSync,
9
- } from "node:fs";
1
+ import { realpathSync, statSync } from "node:fs";
10
2
  import { isAbsolute } from "node:path";
11
3
  import { createSecureContext } from "node:tls";
4
+ import { parseEnv } from "node:util";
12
5
  import { isUnknownArray } from "../shared/unknown.ts";
13
6
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
14
7
  import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
8
+ import { stateDirectory } from "./bridge-mailbox.ts";
9
+ import { readBridgeFile } from "./bridge-files.ts";
15
10
 
16
11
  const MAX_CLIENTS = 64;
17
12
 
13
+ export interface WakePolicy {
14
+ readonly envFile: string;
15
+ readonly url: string;
16
+ readonly key: string;
17
+ }
18
+
19
+ function wakePolicy(value: unknown): WakePolicy | undefined {
20
+ if (value === undefined) {
21
+ return;
22
+ }
23
+ const input = object(value, ["envFile"]);
24
+ const envFile = label(input.envFile, "wake credential file", 4096);
25
+ if (!isAbsolute(envFile)) {
26
+ throw new Error("Wake credential file must be absolute.");
27
+ }
28
+ const env = parseEnv(readBridgeFile(envFile, true, 16384).toString("utf8"));
29
+ const url = new URL(label(env.GROK_WAKE_WEBHOOK_URL, "wake webhook URL", 8192));
30
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") {
31
+ throw new Error("Wake webhook requires HTTPS without URL credentials or a fragment.");
32
+ }
33
+ return {
34
+ envFile,
35
+ url: url.href,
36
+ key: label(env.GROK_WAKE_WEBHOOK_KEY, "wake webhook key", 4096),
37
+ };
38
+ }
39
+
18
40
  export interface Identity {
19
41
  readonly fingerprint256: string;
20
42
  readonly name: string;
21
43
  readonly cwd: string;
22
44
  readonly launch?: LaunchPolicy;
45
+ readonly wake?: WakePolicy;
23
46
  }
24
47
  export interface Config {
48
+ readonly stateDir?: string;
25
49
  readonly port: number;
26
50
  readonly ca: string;
27
51
  readonly cert: string;
@@ -30,26 +54,6 @@ export interface Config {
30
54
  readonly tls: { readonly ca: Buffer; readonly cert: Buffer; readonly key: Buffer };
31
55
  }
32
56
 
33
- function readFile(path: string, privateFile: boolean, limit: number): Buffer {
34
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
35
- try {
36
- const stat = fstatSync(fd);
37
- if (!stat.isFile() || stat.size > limit) {
38
- throw new Error("Configuration/TLS file must be a bounded regular file.");
39
- }
40
- if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
41
- throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
42
- }
43
- const data = readFileSync(fd);
44
- if (data.length > limit) {
45
- throw new Error("Configuration/TLS file exceeds its size limit.");
46
- }
47
- return data;
48
- } finally {
49
- closeSync(fd);
50
- }
51
- }
52
-
53
57
  function identities(value: unknown): Map<string, Identity> {
54
58
  if (!isUnknownArray(value) || value.length > MAX_CLIENTS) {
55
59
  throw new Error(`clients must be an array of at most ${MAX_CLIENTS} identities.`);
@@ -57,7 +61,7 @@ function identities(value: unknown): Map<string, Identity> {
57
61
  const clients = new Map<string, Identity>();
58
62
  const names = new Set<string>();
59
63
  for (const raw of value) {
60
- const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
64
+ const entry = object(raw, ["fingerprint256", "name", "cwd", "launch", "wake"]);
61
65
  const fingerprint256 = fingerprint(entry.fingerprint256);
62
66
  const name = label(entry.name, "name", 128).trim();
63
67
  const cwd = label(entry.cwd, "cwd", 4096);
@@ -76,17 +80,35 @@ function identities(value: unknown): Map<string, Identity> {
76
80
  name,
77
81
  cwd: launch ? realpathSync(cwd) : cwd,
78
82
  launch,
83
+ wake: wakePolicy(entry.wake),
79
84
  });
80
85
  names.add(name.toLowerCase());
81
86
  }
82
87
  return clients;
83
88
  }
84
89
 
85
- export function loadConfig(path: string): Config {
90
+ function configInput(path: string): Record<string, unknown> {
86
91
  const raw: unknown = JSON.parse(
87
- new TextDecoder("utf-8", { fatal: true }).decode(readFile(path, true, BODY_LIMIT)),
92
+ new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)),
88
93
  );
89
- const input = object(raw, ["port", "ca", "cert", "key", "clients"]);
94
+ return object(raw, ["port", "ca", "cert", "key", "clients", "stateDir"]);
95
+ }
96
+
97
+ /** Recipient admission reads private policy and the public CA, never the server key. */
98
+ export function loadControlPolicy(path: string): {
99
+ readonly clients: ReadonlyMap<string, Identity>;
100
+ readonly ca: Buffer;
101
+ } {
102
+ const input = configInput(path);
103
+ const ca = label(input.ca, "ca path", 4096);
104
+ if (!isAbsolute(ca)) {
105
+ throw new Error("CA path must be absolute.");
106
+ }
107
+ return { clients: identities(input.clients), ca: readBridgeFile(ca, false, 1024 * 1024) };
108
+ }
109
+
110
+ export function loadConfig(path: string): Config {
111
+ const input = configInput(path);
90
112
  const port = input.port === undefined ? 9443 : input.port;
91
113
  if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
92
114
  throw new Error("Invalid HTTPS port.");
@@ -99,16 +121,21 @@ export function loadConfig(path: string): Config {
99
121
  }
100
122
  const clients = identities(input.clients);
101
123
  const tls = {
102
- ca: readFile(ca, false, 1024 * 1024),
103
- cert: readFile(cert, false, 1024 * 1024),
104
- key: readFile(key, true, 1024 * 1024),
124
+ ca: readBridgeFile(ca, false, 1024 * 1024),
125
+ cert: readBridgeFile(cert, false, 1024 * 1024),
126
+ key: readBridgeFile(key, true, 1024 * 1024),
105
127
  };
106
128
  createSecureContext({ ...tls, minVersion: "TLSv1.2" });
107
- return { port, ca, cert, key, clients, tls };
129
+ const stateDir = stateDirectory(input.stateDir);
130
+ if (stateDir === undefined && [...clients.values()].some((identity) => identity.wake)) {
131
+ throw new Error("Wake delivery requires a durable stateDir.");
132
+ }
133
+ return { port, ca, cert, key, clients, tls, stateDir };
108
134
  }
109
135
 
110
136
  export function sameListener(next: Config, current: Config): boolean {
111
137
  return (
138
+ next.stateDir === current.stateDir &&
112
139
  next.port === current.port &&
113
140
  next.ca === current.ca &&
114
141
  next.cert === current.cert &&