@scp-dev/broker 0.0.0-stage → 0.1.1

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/server.ts ADDED
@@ -0,0 +1,1124 @@
1
+ import { createHash } from "node:crypto"
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, unlinkSync } from "node:fs"
3
+ import { createServer, type Server } from "node:net"
4
+ import { homedir } from "node:os"
5
+ import { dirname, isAbsolute, join, sep } from "node:path"
6
+ import { setTimeout as sleep } from "node:timers/promises"
7
+ import {
8
+ type Attachment,
9
+ BROKER_CAPABILITIES,
10
+ type Claim,
11
+ CLI_NAME,
12
+ type EndpointView,
13
+ LIMITS,
14
+ parsePolicy,
15
+ PREAMBLE,
16
+ type Presence,
17
+ PROTOCOL,
18
+ RelayError,
19
+ type ScopeActor,
20
+ type ScopePreset,
21
+ type SendParams,
22
+ type SendResult,
23
+ SERVER_FIELDS,
24
+ type Sql,
25
+ type StopWaitResult,
26
+ stripControl,
27
+ } from "@scp-dev/core"
28
+ import { FederatedCore } from "@scp-dev/hub"
29
+ import { type Bridge, type BridgeConfig, remoteSend, startBridge } from "./bridge"
30
+ import { type Deliver, type JudgeClientFactory, JudgeGate } from "./judge-gate"
31
+ import { releaseBrokerLock, takeBrokerLock } from "./lock"
32
+ import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
33
+ import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, privateDir, shortHostname, socketPath } from "./paths"
34
+ import { Conn } from "./socket"
35
+ import { type Database, openDatabase } from "./sqlite"
36
+
37
+ export interface BrokerOptions {
38
+ dir: string
39
+ /** the policy file; missing means open */
40
+ policyPath?: string | undefined
41
+ /** the user part of addresses */
42
+ user?: string | undefined
43
+ /** exit after this long with no connection and nothing waiting (default 30 min); 0 never */
44
+ idleMs?: number | undefined
45
+ now?: (() => number) | undefined
46
+ /** extra roots attachments may point into, besides the sender's cwd */
47
+ roots?: string[] | undefined
48
+ /** bytes queued for one connection before fleet events to it are dropped and replies end it (default 4 MiB) */
49
+ outboundCapBytes?: number | undefined
50
+ /** requests one connection may have in flight (default 32) */
51
+ maxPending?: number | undefined
52
+ /** how long a request line may stay unfinished (default 30 s) */
53
+ partialLineMs?: number | undefined
54
+ /** where the broker reports its own failures (default stderr) */
55
+ log?: ((line: string) => void) | undefined
56
+ /** this host's machine name on the hub (default the bridge's), and for `machine:` selectors (default the short hostname) */
57
+ machine?: string | undefined
58
+ /** the link to the hub; without it the broker never opens a network connection */
59
+ bridge?: BridgeConfig | undefined
60
+ /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
61
+ presenceEveryMs?: number | undefined
62
+ /** where a judge model's key_env is read (default the broker's environment) */
63
+ env?: Record<string, string | undefined> | undefined
64
+ /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
65
+ judgeClient?: JudgeClientFactory | undefined
66
+ }
67
+
68
+ export interface Broker {
69
+ socket: string
70
+ core: FederatedCore
71
+ bridge: Bridge | null
72
+ judge: JudgeGate
73
+ policyFile: string
74
+ stopped: Promise<void>
75
+ stop(): Promise<void>
76
+ }
77
+
78
+ interface Connection {
79
+ reader: LineReader
80
+ writer: LineWriter
81
+ endpoint?: string | undefined
82
+ /** a connection that only looks: list, status, watch, log and policy */
83
+ observer: boolean
84
+ /** set when the connection claimed the human inbox: whether it said a terminal was attached */
85
+ tty?: boolean
86
+ /** the connection holds the human inbox */
87
+ human?: boolean
88
+ /**
89
+ * the lineage endpoint of an observer, or of a hello bound to it: the connection sees and acts with that endpoint's view,
90
+ * never widening unless `own`, and never counts as connected
91
+ */
92
+ lineage?: string | undefined
93
+ /** the lineage endpoint is the one of the harness whose shell runs the command, bound with no grant: it acts with the endpoint's session reach */
94
+ own?: boolean
95
+ /** a hook's connection: it posts activity and status, and never counts as connected */
96
+ transient?: boolean
97
+ /** a hook connection's harness process key, from its claim */
98
+ process?: string
99
+ /** a one-shot CLI command's connection: its comings and goings are not fleet presence events */
100
+ brief?: boolean
101
+ cwd?: string | undefined
102
+ watching: boolean
103
+ closed: boolean
104
+ inFlight: number
105
+ /** lines read but not started while `inFlight` is at the cap; the socket is paused meanwhile */
106
+ queue: string[]
107
+ queueHead: number
108
+ lineTimer?: ReturnType<typeof setTimeout> | undefined
109
+ }
110
+
111
+ const SWEEP_MS = 10 * 60_000
112
+ const PRESENCE_MS = 60_000
113
+ const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"])
114
+ const SCOPE_METHODS = new Set([
115
+ "rules",
116
+ "rules_set",
117
+ "rules_widen",
118
+ "rules_lift",
119
+ "grant",
120
+ "group",
121
+ "admin",
122
+ "peers",
123
+ "why",
124
+ "spawn_exit",
125
+ "spawn_steers",
126
+ "peek",
127
+ "peek_set",
128
+ "peek_who",
129
+ ])
130
+ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"])
131
+ /** how often a waiting ask rechecks whether its recipients ended, since only deliveries to the asker wake it */
132
+ const ASK_RECHECK_MS = 1000
133
+ /** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
134
+ const STOP_RECHECK_MS = 1000
135
+ const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"])
136
+ const GRANT_UNBOUND = `a spawn-env grant binds only a new harness process: run the harness with it, or name the child (${CLI_NAME} spawn-env --name / --as)`
137
+
138
+ /** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
139
+ function workerResults(result: StopWaitResult): number {
140
+ return result.messages.length - (result.others_shown ?? 0) + result.more
141
+ }
142
+
143
+ /** the workers still running that sent none of the messages a stop's push would show, and those not joined yet */
144
+ function othersRunning(result: StopWaitResult): number {
145
+ const senders = new Set(result.messages.slice(0, result.messages.length - (result.others_shown ?? 0)).map((m) => m.from.address))
146
+ return result.workers.filter((address) => !senders.has(address)).length + result.starting
147
+ }
148
+
149
+ type Request = { jsonrpc?: string; id?: string | number | null; method?: unknown; params?: unknown }
150
+
151
+ /** whether a process runs (one of another user's counts too) */
152
+ function running(pid: number): boolean {
153
+ try {
154
+ process.kill(pid, 0)
155
+ return true
156
+ } catch (error) {
157
+ return (error as { code?: string }).code === "EPERM"
158
+ }
159
+ }
160
+
161
+ /** who a connection acts for, for the calls only the harness process holding an endpoint may make through its hooks */
162
+ function hookOf(s: Conn<Connection>): { transient: boolean; process?: string } {
163
+ return { transient: s.data.transient === true, ...(s.data.process ? { process: s.data.process } : {}) }
164
+ }
165
+
166
+ export async function startBroker(options: BrokerOptions): Promise<Broker> {
167
+ const dir = privateDir(options.dir)
168
+ const lock = takeBrokerLock(join(dir, "broker.lock"))
169
+ const socket = socketPath(dir)
170
+ const connections = new Set<Conn<Connection>>()
171
+ const waiters = new Map<string, Set<() => void>>()
172
+ const policyFile = options.policyPath ?? defaultPolicyPath()
173
+ if (!options.policyPath && !accountHomeVerified())
174
+ (options.log ?? console.error)(`relay broker: the user database has no home for this account; the policy path ${policyFile} comes from HOME`)
175
+ let policyText: string | null | undefined
176
+ let policyError: string | null = null
177
+ const outboundCap = options.outboundCapBytes ?? 4 * 1024 * 1024
178
+ const maxPending = options.maxPending ?? 32
179
+ const partialLineMs = options.partialLineMs ?? 30_000
180
+ const log = options.log ?? ((line: string) => console.error(line))
181
+ const idleMs = options.idleMs ?? 30 * 60_000
182
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
183
+ let pending = 0
184
+ let closing = false
185
+ let stopping: Promise<void> | undefined
186
+ let resolveStopped!: () => void
187
+ const stopped = new Promise<void>((resolve) => (resolveStopped = resolve))
188
+ const previousUmask = process.umask(0o077)
189
+ let db!: Database
190
+ let core!: FederatedCore
191
+ let judge!: JudgeGate
192
+ let bridge: Bridge | null = null
193
+ let listener!: Server
194
+ try {
195
+ privateDir(dirname(socket))
196
+ db = openDatabase(join(dir, "relay.db"))
197
+ db.exec("PRAGMA journal_mode = WAL")
198
+ db.exec("PRAGMA busy_timeout = 5000")
199
+ chmodSync(join(dir, "relay.db"), 0o600)
200
+ const sql: Sql = db
201
+ const machine = options.machine ?? options.bridge?.machine
202
+ core = new FederatedCore(sql, {
203
+ ...compact({ user: options.user, machine, now: options.now }),
204
+ hostname: shortHostname(),
205
+ hash: (value) => createHash("sha256").update(value).digest("hex"),
206
+ transaction: (fn) => db.transaction(fn),
207
+ alive: running,
208
+ })
209
+ core.migrate()
210
+ core.resetConnections()
211
+ core.sweep()
212
+ judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake })
213
+ judge.sweep()
214
+ if (existsSync(socket)) unlinkSync(socket)
215
+ listener = createServer((raw) => {
216
+ const s = new Conn<Connection>(
217
+ raw,
218
+ {
219
+ reader: new LineReader(MAX_LINE),
220
+ writer: new LineWriter(),
221
+ observer: false,
222
+ watching: false,
223
+ closed: false,
224
+ inFlight: 0,
225
+ queue: [],
226
+ queueHead: 0,
227
+ },
228
+ { data: onData, drain: flush, close: (s) => onClose(s) },
229
+ )
230
+ if (closing) {
231
+ s.end()
232
+ return
233
+ }
234
+ connections.add(s)
235
+ activity()
236
+ })
237
+ await new Promise<void>((resolve, reject) => {
238
+ listener.once("error", reject)
239
+ listener.listen(socket, () => {
240
+ listener.off("error", reject)
241
+ resolve()
242
+ })
243
+ })
244
+ listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`))
245
+ chmodSync(socket, 0o600)
246
+ } catch (error) {
247
+ listener?.close()
248
+ db?.close()
249
+ releaseBrokerLock(lock)
250
+ throw error
251
+ } finally {
252
+ process.umask(previousUmask)
253
+ }
254
+ const sweeper = setInterval(() => {
255
+ core.sweep()
256
+ judge.sweep()
257
+ }, SWEEP_MS)
258
+ const known = new Map<string, Presence>()
259
+ for (const view of localViews()) known.set(view.id, view.presence)
260
+ const presenceTimer = setInterval(() => {
261
+ const live = new Set<string>()
262
+ for (const view of localViews()) {
263
+ live.add(view.id)
264
+ if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view)
265
+ }
266
+ for (const id of known.keys()) if (!live.has(id)) known.delete(id)
267
+ }, options.presenceEveryMs ?? PRESENCE_MS)
268
+ if (options.bridge) {
269
+ bridge = startBridge({
270
+ ...options.bridge,
271
+ core,
272
+ onRemote: {
273
+ wake,
274
+ fleet: notifyFleet,
275
+ policyError: () => {
276
+ loadPolicy()
277
+ return policyError
278
+ },
279
+ log,
280
+ },
281
+ })
282
+ }
283
+
284
+ function activity() {
285
+ if (idleTimer) clearTimeout(idleTimer)
286
+ idleTimer = undefined
287
+ if (!closing && idleMs > 0 && connections.size === 0 && pending === 0) idleTimer = setTimeout(() => void stop(), idleMs)
288
+ }
289
+
290
+ function loadPolicy() {
291
+ let text: string | null
292
+ try {
293
+ text = readFileSync(policyFile, "utf8")
294
+ } catch (error) {
295
+ const code = (error as NodeJS.ErrnoException).code
296
+ const dangling = code === "ENOENT" && lstatSync(policyFile, { throwIfNoEntry: false }) !== undefined
297
+ if (code !== "ENOENT" || dangling) {
298
+ policyText = undefined
299
+ policyError = dangling
300
+ ? `the relay policy at ${policyFile} is a link to a missing file; messages are refused until it is fixed`
301
+ : `the relay policy at ${policyFile} cannot be read (${code ?? String(error)}); messages are refused until it is fixed`
302
+ return
303
+ }
304
+ text = null
305
+ }
306
+ if (text === policyText) return
307
+ policyText = text
308
+ if (text === null) {
309
+ core.setPolicy(null)
310
+ judge.configure(undefined)
311
+ policyError = null
312
+ bridge?.resync()
313
+ return
314
+ }
315
+ try {
316
+ const policy = parsePolicy(JSON.parse(text))
317
+ core.setPolicy(policy)
318
+ judge.configure(policy.judge)
319
+ policyError = null
320
+ bridge?.resync()
321
+ } catch (error) {
322
+ policyError = `the relay policy at ${policyFile} is invalid (${error instanceof Error ? error.message : String(error)}); messages are refused until it is fixed`
323
+ }
324
+ }
325
+
326
+ function policyInfo(): { file: string; state: "open" | "loaded" | "error"; error?: string } {
327
+ loadPolicy()
328
+ if (policyError) return { file: policyFile, state: "error", error: policyError }
329
+ return { file: policyFile, state: policyText === null ? "open" : "loaded" }
330
+ }
331
+
332
+ function brokerInfo() {
333
+ return { socket, dir, pid: process.pid, policy: policyInfo() }
334
+ }
335
+
336
+ /** a response; a connection whose unread bytes, this response included, would pass the cap is ended (one response alone always goes) */
337
+ function write(s: Conn<Connection>, value: unknown) {
338
+ if (s.data.closed) return
339
+ const line = encodeLine(value)
340
+ const queued = unread(s)
341
+ if (queued > 0 && queued + line.length > outboundCap) {
342
+ log(`relay broker: ended a connection with ${queued} bytes it did not read and a ${line.length} byte response`)
343
+ onClose(s)
344
+ s.end()
345
+ return
346
+ }
347
+ s.data.writer.pushBytes(line)
348
+ flush(s)
349
+ }
350
+
351
+ /** bytes queued for a connection that its peer has not taken yet */
352
+ function unread(s: Conn<Connection>): number {
353
+ return s.data.writer.pending + s.unsent
354
+ }
355
+
356
+ function flush(s: Conn<Connection>) {
357
+ if (!s.data.closed) s.data.writer.flush((bytes) => s.write(bytes))
358
+ }
359
+
360
+ /** a fleet event to every watcher; a watcher that falls more than the cap behind gets one lagged notice and is unsubscribed */
361
+ /** this host's own endpoints, mirrored rows of other machines left out */
362
+ function localViews(): EndpointView[] {
363
+ return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
364
+ }
365
+
366
+ /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
367
+ function presenceChanged(view: EndpointView) {
368
+ known.set(view.id, view.presence)
369
+ notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
370
+ bridge?.changed(view.id)
371
+ }
372
+
373
+ function notifyFleet(event: Record<string, unknown>) {
374
+ const at = new Date((options.now ?? Date.now)()).toISOString()
375
+ let line: Uint8Array | undefined
376
+ const views = new Map<string, Set<string>>()
377
+ for (const s of connections) {
378
+ if (!s.data.watching || s.data.closed) continue
379
+ const viewer = viewerOf(s)
380
+ let bytes: Uint8Array
381
+ if (viewer === undefined) bytes = line ??= encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
382
+ else {
383
+ let seen = views.get(viewer)
384
+ if (!seen) {
385
+ seen = core.visibleTo(viewer)
386
+ views.set(viewer, seen)
387
+ }
388
+ const shown = filterEvent(event, seen)
389
+ if (!shown) continue
390
+ bytes = encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...shown } })
391
+ }
392
+ const queued = unread(s)
393
+ if (queued + bytes.length > outboundCap) {
394
+ s.data.watching = false
395
+ s.data.writer.push({ jsonrpc: "2.0", method: "fleet", params: { at, type: "lagged", queued_bytes: queued } })
396
+ } else {
397
+ s.data.writer.pushBytes(bytes)
398
+ }
399
+ flush(s)
400
+ }
401
+ }
402
+
403
+ /** a fleet event cut to what a scoped viewer may see, or null when it may see none of it */
404
+ function filterEvent(event: Record<string, unknown>, seen: Set<string>): Record<string, unknown> | null {
405
+ if (event.type === "message") {
406
+ const to = (event.to as string[]).filter((address) => seen.has(address))
407
+ return seen.has(String(event.from)) && to.length ? { ...event, to } : null
408
+ }
409
+ return typeof event.endpoint !== "string" || seen.has(event.endpoint) ? event : null
410
+ }
411
+
412
+ /** whether the connection is the human at a terminal, the only one exempt from scopes and shown everything */
413
+ function atTerminal(s: Conn<Connection>): boolean {
414
+ return s.data.human === true && s.data.tty === true
415
+ }
416
+
417
+ /**
418
+ * the endpoint whose view a connection gets; undefined for the human at a terminal, which sees everything. The human without a terminal, and an
419
+ * observer with no lineage endpoint, see what the human may message with no exemption
420
+ */
421
+ function viewerOf(s: Conn<Connection>): string | undefined {
422
+ if (s.data.lineage) return s.data.lineage
423
+ if (s.data.observer || s.data.human) return atTerminal(s) ? undefined : core.humanId()
424
+ return s.data.endpoint
425
+ }
426
+
427
+ function actorOf(s: Conn<Connection>): ScopeActor {
428
+ if (s.data.lineage) return { kind: "endpoint", id: s.data.lineage, auth: s.data.own ? "self" : "lineage" }
429
+ if (s.data.observer || s.data.human) return { kind: "human", tty: atTerminal(s) }
430
+ return { kind: "endpoint", id: s.data.endpoint!, auth: "session" }
431
+ }
432
+
433
+ /** binds a hello to the agent endpoint its lineage lands on, acting with lineage auth, or with `own` as that endpoint (self auth) */
434
+ function bindLineage(s: Conn<Connection>, endpoint: string, params: Record<string, unknown>, own = false) {
435
+ s.data.endpoint = endpoint
436
+ s.data.lineage = endpoint
437
+ s.data.own = own
438
+ s.data.brief = true
439
+ s.data.cwd = typeof params.cwd === "string" ? params.cwd : undefined
440
+ return {
441
+ endpoint: core.whoami(endpoint),
442
+ capabilities: BROKER_CAPABILITIES,
443
+ server_time: new Date().toISOString(),
444
+ limits: LIMITS,
445
+ preamble: PREAMBLE,
446
+ broker: brokerInfo(),
447
+ }
448
+ }
449
+
450
+ function wake(endpoint: string) {
451
+ const set = waiters.get(endpoint)
452
+ if (!set) return
453
+ waiters.delete(endpoint)
454
+ for (const fn of set) fn()
455
+ }
456
+
457
+ function waitFor(endpoint: string, ms: number, s: Conn<Connection>): Promise<void> {
458
+ return new Promise((resolve) => {
459
+ const done = () => {
460
+ clearTimeout(timer)
461
+ waiters.get(endpoint)?.delete(done)
462
+ resolve()
463
+ }
464
+ const timer = setTimeout(done, ms)
465
+ let set = waiters.get(endpoint)
466
+ if (!set) {
467
+ set = new Set()
468
+ waiters.set(endpoint, set)
469
+ }
470
+ set.add(done)
471
+ if (s.data.closed) done()
472
+ })
473
+ }
474
+
475
+ /**
476
+ * a stop's push with worker results to show while other workers still run: waits on, peeking, for more of theirs to show with them until
477
+ * none came for ${LIMITS.pushQuietS} s, ${LIMITS.pushGraceS} s after it began (which may run past the stop's wait), the push budget is
478
+ * full (`more`), no other worker runs ({@link othersRunning}), a prompt came or the connection closed
479
+ */
480
+ async function gather(
481
+ me: string,
482
+ result: StopWaitResult,
483
+ peek: ReturnType<typeof hookOf> & { prompts?: number; peek: boolean },
484
+ s: Conn<Connection>,
485
+ ): Promise<void> {
486
+ const gathering = (r: StopWaitResult) => workerResults(r) > 0 && !r.more && !r.prompted && othersRunning(r) > 0
487
+ const cap = Date.now() + LIMITS.pushGraceS * 1000
488
+ let quiet = Date.now() + LIMITS.pushQuietS * 1000
489
+ let results = workerResults(result)
490
+ while (gathering(result) && !s.data.closed && Date.now() < Math.min(quiet, cap)) {
491
+ await waitFor(me, Math.min(quiet, cap) - Date.now(), s)
492
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
493
+ result = core.stopWait(me, peek)
494
+ if (workerResults(result) > results) {
495
+ results = workerResults(result)
496
+ quiet = Date.now() + LIMITS.pushQuietS * 1000
497
+ }
498
+ }
499
+ }
500
+
501
+ function onData(s: Conn<Connection>, chunk: Uint8Array) {
502
+ const { lines, tooLong } = s.data.reader.push(chunk)
503
+ for (const line of lines) s.data.queue.push(line)
504
+ pump(s)
505
+ if (tooLong) {
506
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "request too large", data: { code: "too_large" } } })
507
+ s.end()
508
+ return
509
+ }
510
+ if (!s.data.reader.buffered || lines.length) {
511
+ clearTimeout(s.data.lineTimer)
512
+ s.data.lineTimer = undefined
513
+ }
514
+ if (s.data.reader.buffered && !s.data.lineTimer) armLineTimer(s)
515
+ }
516
+
517
+ /** a line must end within partialLineMs of its first byte, not counting time the broker itself paused reading */
518
+ function armLineTimer(s: Conn<Connection>) {
519
+ if (partialLineMs <= 0) return
520
+ s.data.lineTimer = setTimeout(() => {
521
+ s.data.lineTimer = undefined
522
+ if (s.data.closed || !s.data.reader.buffered) return
523
+ if (s.data.queueHead < s.data.queue.length) return armLineTimer(s)
524
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "the request line was not finished in time", data: { code: "invalid_params" } } })
525
+ s.end()
526
+ }, partialLineMs)
527
+ }
528
+
529
+ function onClose(s: Conn<Connection>) {
530
+ if (s.data.closed) return
531
+ s.data.closed = true
532
+ clearTimeout(s.data.lineTimer)
533
+ connections.delete(s)
534
+ if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
535
+ const before = core.whoami(s.data.endpoint).presence
536
+ const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
537
+ wake(s.data.endpoint)
538
+ const view = core.whoami(s.data.endpoint)
539
+ known.set(s.data.endpoint, view.presence)
540
+ if (!s.data.brief && view.presence !== before) notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence })
541
+ if (settled) notifyFleet({ type: "status", endpoint: view.address, status: null })
542
+ if (!s.data.brief || view.presence !== before || settled) bridge?.changed(s.data.endpoint)
543
+ }
544
+ activity()
545
+ }
546
+
547
+ /** starts queued lines while fewer than maxPending run; reading pauses while any wait */
548
+ function pump(s: Conn<Connection>) {
549
+ const data = s.data
550
+ while (data.queueHead < data.queue.length && data.inFlight < maxPending && !data.closed) void handleLine(s, data.queue[data.queueHead++]!)
551
+ if (data.queueHead === data.queue.length) {
552
+ data.queue = []
553
+ data.queueHead = 0
554
+ if (!data.closed) s.resume()
555
+ } else if (!data.closed) {
556
+ s.pause()
557
+ }
558
+ }
559
+
560
+ async function handleLine(s: Conn<Connection>, line: string) {
561
+ s.data.inFlight++
562
+ try {
563
+ await answer(s, line)
564
+ } catch (error) {
565
+ log(`relay broker: a request failed outside its handler: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
566
+ } finally {
567
+ s.data.inFlight--
568
+ pump(s)
569
+ }
570
+ }
571
+
572
+ function invalidRequest(s: Conn<Connection>, id: string | number | null, message: string) {
573
+ write(s, { jsonrpc: "2.0", id, error: { code: -32600, message, data: { code: "invalid_params" } } })
574
+ }
575
+
576
+ async function answer(s: Conn<Connection>, line: string) {
577
+ if (closing) return
578
+ let parsed: unknown
579
+ try {
580
+ parsed = JSON.parse(line)
581
+ } catch {
582
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })
583
+ return
584
+ }
585
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return invalidRequest(s, null, "a request is a JSON object")
586
+ const request = parsed as Request
587
+ const hasId = "id" in request
588
+ const id = request.id ?? null
589
+ if (id !== null && typeof id !== "string" && typeof id !== "number") return invalidRequest(s, null, "a request id is a string or a number")
590
+ if (typeof request.method !== "string") return invalidRequest(s, id, "a request needs a method")
591
+ const params = request.params ?? {}
592
+ if (typeof params !== "object" || params === null || Array.isArray(params)) return invalidRequest(s, id, "params is an object")
593
+ pending++
594
+ try {
595
+ const result = await dispatch(s, request.method, params as Record<string, unknown>)
596
+ if (hasId) write(s, { jsonrpc: "2.0", id, result })
597
+ } catch (error) {
598
+ if (!hasId || closing) return
599
+ if (error instanceof RelayError) {
600
+ const code = error.code === "method_not_found" ? -32601 : error.code === "invalid_params" ? -32602 : -32000
601
+ write(s, {
602
+ jsonrpc: "2.0",
603
+ id,
604
+ error: {
605
+ code,
606
+ message: error.message,
607
+ data: { ...error.detail, code: error.code, ...(error.retryAfterS ? { retry_after: error.retryAfterS } : {}) },
608
+ },
609
+ })
610
+ } else {
611
+ log(`relay broker: ${request.method} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
612
+ write(s, { jsonrpc: "2.0", id, error: { code: -32603, message: "internal error" } })
613
+ }
614
+ } finally {
615
+ pending--
616
+ activity()
617
+ }
618
+ }
619
+
620
+ function endpointOf(s: Conn<Connection>): string {
621
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
622
+ if (!s.data.endpoint)
623
+ throw new RelayError("unauthorized", s.data.observer ? "this connection only observes: say hello as an agent or the human" : "call hello first")
624
+ core.touch(s.data.endpoint)
625
+ return s.data.endpoint
626
+ }
627
+
628
+ function waitSeconds(value: unknown): number {
629
+ if (value === undefined) return 0
630
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new RelayError("invalid_params", "wait_s must be a number of seconds")
631
+ return Math.min(value, LIMITS.waitMaxS) * 1000
632
+ }
633
+
634
+ function checkAttachments(attachments: unknown, s: Conn<Connection>): Attachment[] | undefined {
635
+ if (attachments === undefined) return undefined
636
+ if (!Array.isArray(attachments)) throw new RelayError("invalid_params", "attachments must be a list")
637
+ const tooWide = new Set(["/", realOrSelf(homedir()), realOrSelf(accountHome())])
638
+ const roots = [s.data.cwd, ...(options.roots ?? [])]
639
+ .filter((r): r is string => typeof r === "string" && isAbsolute(r))
640
+ .map(realOrSelf)
641
+ .filter((r) => !tooWide.has(r))
642
+ return attachments.map((attachment: Attachment) => {
643
+ if (attachment?.kind !== "path" || typeof attachment.path !== "string" || !isAbsolute(attachment.path)) {
644
+ throw new RelayError("invalid_params", "an attachment is {kind: path, path: an absolute path}")
645
+ }
646
+ let real: string
647
+ try {
648
+ real = realpathSync(attachment.path)
649
+ } catch {
650
+ throw new RelayError("not_found", `no file at ${attachment.path}`)
651
+ }
652
+ if (!roots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep))) {
653
+ throw new RelayError("invalid_params", `${attachment.path} is outside the sender's working directory and the relay's allowed roots`)
654
+ }
655
+ return { kind: "path", path: real }
656
+ })
657
+ }
658
+
659
+ async function dispatch(s: Conn<Connection>, method: string, params: Record<string, unknown>): Promise<unknown> {
660
+ if (method === "hello") {
661
+ if (s.data.endpoint || s.data.observer) throw new RelayError("invalid_params", "hello was already called on this connection")
662
+ if (params.protocol !== PROTOCOL) throw new RelayError("invalid_params", `this broker speaks ${PROTOCOL}`)
663
+ if (params.transient === true) {
664
+ if (params.human === true || params.observe === true || params.resume !== undefined) {
665
+ throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
666
+ }
667
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]))
668
+ const { endpoint, subagent, created } = core.claim((params.claim ?? {}) as Claim, { transient: true })
669
+ s.data.endpoint = endpoint.id
670
+ s.data.transient = true
671
+ const key = (params.claim as Claim | undefined)?.process
672
+ if (typeof key === "string") s.data.process = key
673
+ if ((before.get(endpoint.id) ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
674
+ return {
675
+ endpoint,
676
+ ...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
677
+ capabilities: BROKER_CAPABILITIES,
678
+ server_time: new Date().toISOString(),
679
+ limits: LIMITS,
680
+ preamble: PREAMBLE,
681
+ broker: brokerInfo(),
682
+ }
683
+ }
684
+ if (params.observe === true) {
685
+ const self = params.self === undefined ? null : core.placeLineage(params.self)
686
+ if (params.grant !== undefined) {
687
+ if (!core.grantBinds(params.grant, self)) throw new RelayError("unauthorized", GRANT_UNBOUND)
688
+ return { ...bindLineage(s, self!, params, true), grant_bound: true }
689
+ }
690
+ if (self) return bindLineage(s, self, params, true)
691
+ s.data.observer = true
692
+ if (params.lineage !== undefined) s.data.lineage = core.placeLineage(params.lineage) ?? undefined
693
+ return {
694
+ endpoint: null,
695
+ capabilities: BROKER_CAPABILITIES,
696
+ server_time: new Date().toISOString(),
697
+ limits: LIMITS,
698
+ preamble: PREAMBLE,
699
+ broker: brokerInfo(),
700
+ }
701
+ }
702
+ const claim = params.claim as Claim | undefined
703
+ if (params.human === true && params.resume === undefined && params.lineage !== undefined) {
704
+ const placed = core.placeLineage(params.lineage)
705
+ if (placed) return bindLineage(s, placed, params)
706
+ } else if (params.human !== true && params.resume === undefined && claim?.process === undefined && typeof claim?.handle === "string") {
707
+ const placed = claim.lineage === undefined ? null : core.placeLineage(claim.lineage, claim.handle)
708
+ if (placed && (claim.grant === undefined || core.grantBinds(claim.grant, placed))) {
709
+ return bindLineage(s, placed, { cwd: claim.cwd, ...params }, params.self !== undefined && core.placeLineage(params.self, claim.handle) === placed)
710
+ }
711
+ }
712
+ let token: string | undefined
713
+ let endpoint: EndpointView
714
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view]))
715
+ if (typeof params.resume === "string") endpoint = core.resume(params.resume)
716
+ else if (params.human === true) {
717
+ s.data.tty = params.tty === true
718
+ endpoint = core.claimHuman(s.data.tty)
719
+ } else {
720
+ const claimed = core.claim((params.claim ?? {}) as Claim, { self: params.self })
721
+ endpoint = claimed.endpoint
722
+ token = claimed.token
723
+ }
724
+ s.data.endpoint = endpoint.id
725
+ s.data.human = endpoint.kind === "human"
726
+ s.data.brief = params.brief === true
727
+ s.data.cwd = typeof params.cwd === "string" ? params.cwd : (endpoint.cwd ?? undefined)
728
+ known.set(endpoint.id, endpoint.presence)
729
+ const was = before.get(endpoint.id)
730
+ const moved = (was?.presence ?? "offline") !== endpoint.presence || (was !== undefined && was.address !== endpoint.address)
731
+ if (!s.data.brief && moved) {
732
+ notifyFleet({ type: "presence", endpoint: endpoint.address, presence: endpoint.presence, harness: endpoint.harness, labels: endpoint.labels })
733
+ }
734
+ if (!s.data.brief || moved) bridge?.changed(endpoint.id)
735
+ return {
736
+ endpoint,
737
+ ...(token ? { token } : {}),
738
+ capabilities: BROKER_CAPABILITIES,
739
+ server_time: new Date().toISOString(),
740
+ limits: LIMITS,
741
+ preamble: PREAMBLE,
742
+ broker: brokerInfo(),
743
+ }
744
+ }
745
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
746
+ if (s.data.observer && OBSERVER_METHODS.has(method)) return observe(s, method, params)
747
+ if (s.data.observer && SCOPE_METHODS.has(method) && (s.data.lineage || method === "why")) return scope(s, method, params)
748
+ if (s.data.transient && SCOPE_METHODS.has(method)) throw new RelayError("scope_refused", "a hook connection cannot read or change scopes, groups or grants")
749
+ const me = endpointOf(s)
750
+ if (s.data.transient && !TRANSIENT_METHODS.has(method)) throw new RelayError("unauthorized", "a hook connection only posts activity and status")
751
+ if (OBSERVER_METHODS.has(method)) return observe(s, method, params)
752
+ if (SCOPE_METHODS.has(method)) return scope(s, method, params)
753
+ switch (method) {
754
+ case "whoami":
755
+ return s.data.transient ? core.whoami(me) : { ...core.whoami(me), ...core.peekState(me) }
756
+ case "peek_turn":
757
+ if (!s.data.transient || !core.hookOwns(me, s.data.process))
758
+ throw new RelayError("unauthorized", "only a hook of the endpoint's own harness process posts digest turns")
759
+ return core.peekTurn(me, { role: params.role, text: params.text })
760
+ case "send":
761
+ case "ask": {
762
+ for (const field of SERVER_FIELDS) if (field in params) throw new RelayError("invalid_params", `${field} is set by the relay, not the sender`)
763
+ loadPolicy()
764
+ if (policyError) throw new RelayError("policy_refused", policyError)
765
+ const attachments = checkAttachments(params.attachments, s)
766
+ const send: SendParams = { ...(params as unknown as SendParams), ...(attachments ? { attachments } : {}) }
767
+ if (method === "ask") send.kind = "request"
768
+ const origin = s.data.tty === undefined ? {} : { tty: s.data.tty, ...(atTerminal(s) ? {} : { unverified: true }) }
769
+ const result = await judge.send(me, send, origin, deliver)
770
+ if (method === "send" || result.held) return result
771
+ return awaitReply(s, me, result.id, waitSeconds(params.wait_s), result)
772
+ }
773
+ case "holds":
774
+ return { holds: judge.holds(optional(reviewer(s, params), "state")) }
775
+ case "hold_show":
776
+ return judge.show(required(reviewer(s, params), "id"))
777
+ case "hold_release": {
778
+ const id = required(reviewer(s, params), "id")
779
+ loadPolicy()
780
+ if (policyError) throw new RelayError("policy_refused", policyError)
781
+ return judge.release(id, { redacted: params.redacted === true, by: core.addressOf(me) }, deliver)
782
+ }
783
+ case "hold_refuse": {
784
+ const reason = required(reviewer(s, params), "reason")
785
+ if (reason.length > LIMITS.noteChars) throw new RelayError("invalid_params", `reason takes at most ${LIMITS.noteChars} characters`)
786
+ return judge.refuse(required(params, "id"), stripControl(reason), core.addressOf(me))
787
+ }
788
+ case "ask_wait": {
789
+ if (typeof params.id !== "string") throw new RelayError("invalid_params", "id is the request's message id")
790
+ return awaitReply(s, me, params.id, waitSeconds(params.wait_s))
791
+ }
792
+ case "inbox": {
793
+ const query = compact({ after: params.after as number | undefined, limit: params.limit as number | undefined })
794
+ const read = () => (params.offer === true ? core.offer(me, query) : { messages: core.inbox(me, query) })
795
+ const deadline = Date.now() + waitSeconds(params.wait_s)
796
+ let page = read()
797
+ while (!page.messages.length && !s.data.closed && Date.now() < deadline) {
798
+ await waitFor(me, deadline - Date.now(), s)
799
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
800
+ page = read()
801
+ }
802
+ const { messages } = page
803
+ return { ...page, next: messages.length ? messages[messages.length - 1]!.seq : (query.after ?? 0) }
804
+ }
805
+ case "ack":
806
+ return { acked: core.ack(me, params.seqs) }
807
+ case "title": {
808
+ const view = core.title(me, params.text)
809
+ bridge?.changed(me)
810
+ return view
811
+ }
812
+ case "status": {
813
+ core.status(me, params.state, params.note)
814
+ const view = core.whoami(me)
815
+ notifyFleet({ type: "status", endpoint: view.address, status: view.status })
816
+ bridge?.changed(me)
817
+ return view
818
+ }
819
+ case "event": {
820
+ const row = core.event(me, params, hookOf(s))
821
+ notifyFleet({ type: "activity", ...row })
822
+ bridge?.activity(me, row)
823
+ const after = core.whoami(me)
824
+ if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
825
+ return row
826
+ }
827
+ case "inject":
828
+ return core.inject(me, {
829
+ stop: params.stop === true,
830
+ count: params.count === true,
831
+ holdUnread: params.hold_unread === true,
832
+ directives: params.directives === true,
833
+ ...hookOf(s),
834
+ })
835
+ case "stop_wait": {
836
+ const waitMs = waitSeconds(params.wait_s)
837
+ const deadline = Date.now() + waitMs
838
+ const options = { ...hookOf(s), ...(typeof params.prompts === "number" ? { prompts: params.prompts } : {}) }
839
+ const peek = { ...options, peek: true }
840
+ let result = core.stopWait(me, peek)
841
+ const idle = (r: StopWaitResult) => !r.messages.length && !r.more && r.workers.length + r.starting > 0 && r.pushes_left > 0 && !r.prompted
842
+ while (idle(result) && !s.data.closed && Date.now() < deadline) {
843
+ await waitFor(me, Math.min(STOP_RECHECK_MS, deadline - Date.now()), s)
844
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
845
+ result = core.stopWait(me, peek)
846
+ }
847
+ if (waitMs > 0) await gather(me, result, peek, s)
848
+ return core.stopWait(me, options)
849
+ }
850
+ case "prompted": {
851
+ const prompted = core.prompted(me, hookOf(s))
852
+ if (prompted) wake(me)
853
+ return { prompted }
854
+ }
855
+ case "injected":
856
+ return {
857
+ injected: core.injected(params.seqs, params.hold, { endpoint: me, read: params.read, deferred: params.deferred === true }),
858
+ ...(params.stop === true ? { held: core.stopHeld(me, hookOf(s)) } : {}),
859
+ ...(params.push === true ? { pushed: core.pushed(me, params.read, hookOf(s)) } : {}),
860
+ ...(params.nudged !== undefined ? { nudged: core.nudged(me, params.nudged, hookOf(s)) } : {}),
861
+ ...(params.restated !== undefined ? { restated: core.restated(me, params.restated, params.read, hookOf(s)) } : {}),
862
+ ...(params.preamble === true ? { briefed: core.briefed(me, hookOf(s)) } : {}),
863
+ ...(params.intro === true ? { introduced: core.introduced(me, hookOf(s)) } : {}),
864
+ }
865
+ case "message":
866
+ return { message: core.delivered(me, params.id) ?? (params.member === true ? core.threadMessage(me, params.id) : null) }
867
+ case "receipts":
868
+ return core.receipts(me, params.id)
869
+ }
870
+ throw new RelayError("method_not_found", `no method ${method}`)
871
+ }
872
+
873
+ /** a send as `from` to local endpoints or, through the bridge, other machines; wakes the recipients and tells the watchers */
874
+ const deliver: Deliver = async (from, send, origin) => {
875
+ let result: SendResult
876
+ if (remoteSend(core, send.to)) {
877
+ if (!bridge)
878
+ throw new RelayError("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`)
879
+ result = await bridge.forward(from, send, origin)
880
+ } else {
881
+ result = core.send(from, send, origin)
882
+ }
883
+ if (!result.duplicate) {
884
+ for (const delivery of result.deliveries) wake(delivery.endpoint)
885
+ notifyFleet({
886
+ type: "message",
887
+ id: result.id,
888
+ thread: result.thread,
889
+ kind: send.kind ?? "message",
890
+ from: core.addressOf(from),
891
+ to: result.deliveries.map((d) => d.address),
892
+ hop: result.hop,
893
+ })
894
+ }
895
+ return result
896
+ }
897
+
898
+ /** held messages are reviewed only by the human at a terminal: never an agent, a script or a relay message */
899
+ function reviewer(s: Conn<Connection>, params: Record<string, unknown>): Record<string, unknown> {
900
+ if (!atTerminal(s)) throw new RelayError("unauthorized", "only your human at a terminal reviews held messages")
901
+ return params
902
+ }
903
+
904
+ /**
905
+ * what any connection that said hello may read; an agent, or an observer on its lineage endpoint, sees only what that endpoint could message or
906
+ * be messaged by, checks the policy only for pairs it could ask why about, and reads the policy's state but not its rules
907
+ */
908
+ function observe(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
909
+ const viewer = viewerOf(s)
910
+ switch (method) {
911
+ case "list":
912
+ if (params.contacts === true) {
913
+ if (!bridge) throw new RelayError("unavailable", `this broker is not linked to a hub (${CLI_NAME} cloud enable), so it reaches no contacts`)
914
+ return bridge.contacts().then((contacts) => ({ contacts }))
915
+ }
916
+ return { endpoints: core.list({ ...(params as Parameters<FederatedCore["list"]>[0]), viewer, peekable: viewer === undefined }) }
917
+ case "snapshot":
918
+ return {
919
+ ...core.snapshot({ all: params.all === true, activity: typeof params.activity === "number" ? params.activity : undefined, viewer }),
920
+ broker: brokerInfo(),
921
+ }
922
+ case "watch":
923
+ s.data.watching = true
924
+ return { ...core.snapshot({ activity: 20, viewer }), broker: brokerInfo() }
925
+ case "log": {
926
+ return {
927
+ rows: core.log({
928
+ since: params.since as number | undefined,
929
+ limit: params.limit as number | undefined,
930
+ scope: params.scope === true,
931
+ judge: params.judge === true,
932
+ peek: params.peek === true,
933
+ viewer,
934
+ }),
935
+ }
936
+ }
937
+ case "policy": {
938
+ const info = policyInfo()
939
+ const text = actorOf(s).kind === "human" && info.state === "loaded" ? policyText : null
940
+ return { ...info, policy: typeof text === "string" ? (JSON.parse(text) as unknown) : null }
941
+ }
942
+ case "policy_check": {
943
+ if (viewer !== undefined) core.why(actorOf(s), String(params.from), String(params.to))
944
+ const info = policyInfo()
945
+ if (info.error) return { allowed: false, reason: info.error, file: policyFile }
946
+ const reason = core.checkPolicy(String(params.from), String(params.to))
947
+ return { allowed: reason === null, reason, file: policyFile }
948
+ }
949
+ case "cloud_status":
950
+ return bridge?.status() ?? { enabled: false }
951
+ }
952
+ throw new RelayError("method_not_found", `no method ${method}`)
953
+ }
954
+
955
+ /** rules, groups, grants, admins, peers, why and peeks, acting as the connection's actor */
956
+ function scope(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
957
+ const result = scopeCall(s, method, params)
958
+ if (CHANGES_SCOPE.has(method) && params.action !== "ls" && params.action !== "show") bridge?.resync()
959
+ return result
960
+ }
961
+
962
+ function scopeCall(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
963
+ const actor = actorOf(s)
964
+ switch (method) {
965
+ case "rules":
966
+ return core.scopeView(actor, optional(params, "endpoint"))
967
+ case "rules_set":
968
+ return core.rulesSet(
969
+ actor,
970
+ compact({
971
+ endpoint: optional(params, "endpoint"),
972
+ in: selectors(params, "in"),
973
+ out: selectors(params, "out"),
974
+ scope: optional(params, "scope") as ScopePreset | undefined,
975
+ allow: selectors(params, "allow"),
976
+ }),
977
+ )
978
+ case "rules_widen":
979
+ return core.rulesWiden(actor, compact({ endpoint: required(params, "endpoint"), in: selectors(params, "in"), out: selectors(params, "out") }))
980
+ case "rules_lift":
981
+ return core.rulesLift(actor, { endpoint: required(params, "endpoint"), cap: required(params, "cap") })
982
+ case "grant":
983
+ return core.grant(actor, params as Parameters<FederatedCore["grant"]>[1])
984
+ case "group":
985
+ return core.group(actor, params as Parameters<FederatedCore["group"]>[1])
986
+ case "admin":
987
+ return core.admin(actor, params as Parameters<FederatedCore["admin"]>[1])
988
+ case "peers":
989
+ return { peers: core.peers(viewerOf(s) ?? s.data.endpoint!, { unverified: !atTerminal(s) }) }
990
+ case "why":
991
+ return core.why(actor, required(params, "from"), required(params, "to"))
992
+ case "peek":
993
+ return core.peek(actor, { endpoint: params.endpoint, turns: params.turns, full: params.full })
994
+ case "peek_set":
995
+ return core.peekSet(actor, { ...compact({ endpoint: optional(params, "endpoint") }), level: params.level })
996
+ case "peek_who":
997
+ return core.peekWho(actor, compact({ endpoint: optional(params, "endpoint") }))
998
+ case "spawn_steers":
999
+ return core.spawnSteers(actor, { grant: params.grant as string, take: params.take === true })
1000
+ case "spawn_exit": {
1001
+ const { spawner, ...result } = core.spawnExit(
1002
+ actor,
1003
+ compact({
1004
+ grant: params.grant as string,
1005
+ code: params.code as number,
1006
+ output: params.output as string | undefined,
1007
+ stderr: params.stderr as string | undefined,
1008
+ unapplied: params.unapplied as string[] | undefined,
1009
+ exits: params.exits as number[] | undefined,
1010
+ started: params.started as boolean | undefined,
1011
+ }),
1012
+ )
1013
+ if (result.handback) {
1014
+ const message = core.delivered(spawner, result.handback)!
1015
+ wake(spawner)
1016
+ notifyFleet({
1017
+ type: "message",
1018
+ id: message.id,
1019
+ thread: message.thread,
1020
+ kind: message.kind,
1021
+ from: message.from.address,
1022
+ to: [message.to],
1023
+ hop: message.hop,
1024
+ })
1025
+ }
1026
+ if (result.endpoint) {
1027
+ notifyFleet({ type: "status", endpoint: result.endpoint.address, status: result.endpoint.status })
1028
+ if ((known.get(result.endpoint.id) ?? "offline") !== result.endpoint.presence) presenceChanged(result.endpoint)
1029
+ else bridge?.changed(result.endpoint.id)
1030
+ }
1031
+ return result
1032
+ }
1033
+ }
1034
+ throw new RelayError("method_not_found", `no method ${method}`)
1035
+ }
1036
+
1037
+ function realOrSelf(path: string): string {
1038
+ try {
1039
+ return realpathSync(path)
1040
+ } catch {
1041
+ return path
1042
+ }
1043
+ }
1044
+
1045
+ /** the reply, or pending at the deadline, or pending with `ended` once every recipient present at the send went offline without replying */
1046
+ async function awaitReply(s: Conn<Connection>, me: string, request: string, ms: number, sent?: SendResult): Promise<unknown> {
1047
+ const deadline = Date.now() + ms
1048
+ const present = sent?.deliveries.filter((d) => !d.offline).map((d) => d.endpoint)
1049
+ for (;;) {
1050
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
1051
+ const reply = core.replyTo(me, request)
1052
+ if (reply) {
1053
+ core.ack(me, [reply.seq])
1054
+ return { ...sent, id: request, reply }
1055
+ }
1056
+ if (core.requestExpired(request)) return { ...sent, id: request, expired: true }
1057
+ const ended = core.requestEnded(me, request, present)
1058
+ if (ended) return { ...sent, id: request, pending: true, ended }
1059
+ if (s.data.closed || Date.now() >= deadline) return { ...sent, id: request, pending: true }
1060
+ await waitFor(me, Math.min(ASK_RECHECK_MS, deadline - Date.now()), s)
1061
+ }
1062
+ }
1063
+
1064
+ async function stop(): Promise<void> {
1065
+ if (stopping) return stopping
1066
+ stopping = (async () => {
1067
+ closing = true
1068
+ clearInterval(sweeper)
1069
+ clearInterval(presenceTimer)
1070
+ await bridge?.stop()
1071
+ if (idleTimer) clearTimeout(idleTimer)
1072
+ for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
1073
+ const deadline = Date.now() + 2000
1074
+ while (pending > 0 && Date.now() < deadline) await sleep(5)
1075
+ await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
1076
+ for (const s of connections) s.end()
1077
+ listener.close()
1078
+ try {
1079
+ unlinkSync(socket)
1080
+ } catch {}
1081
+ if (socket !== join(dir, "relay.sock")) {
1082
+ try {
1083
+ rmdirSync(dirname(socket))
1084
+ } catch {}
1085
+ }
1086
+ db.close()
1087
+ releaseBrokerLock(lock)
1088
+ resolveStopped()
1089
+ })()
1090
+ return stopping
1091
+ }
1092
+
1093
+ activity()
1094
+ return { socket, core, bridge, judge, policyFile, stopped, stop }
1095
+ }
1096
+
1097
+ type Compact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1098
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>
1099
+ }
1100
+
1101
+ /** `fields` without the ones that are undefined, for the optional parameters the core takes */
1102
+ function compact<T extends object>(fields: T): Compact<T> {
1103
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Compact<T>
1104
+ }
1105
+
1106
+ function optional(params: Record<string, unknown>, key: string): string | undefined {
1107
+ const value = params[key]
1108
+ if (value === undefined || value === null) return undefined
1109
+ if (typeof value !== "string") throw new RelayError("invalid_params", `${key} must be a string`)
1110
+ return value
1111
+ }
1112
+
1113
+ function required(params: Record<string, unknown>, key: string): string {
1114
+ const value = optional(params, key)
1115
+ if (!value) throw new RelayError("invalid_params", `${key} is required`)
1116
+ return value
1117
+ }
1118
+
1119
+ function selectors(params: Record<string, unknown>, key: string): string[] | undefined {
1120
+ const value = params[key]
1121
+ if (value === undefined || value === null) return undefined
1122
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new RelayError("invalid_params", `${key} must be a list of selectors`)
1123
+ return value as string[]
1124
+ }