@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/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/index.d.ts +422 -0
- package/dist/index.js +2514 -0
- package/dist/testing.d.ts +30 -0
- package/dist/testing.js +131 -0
- package/package.json +48 -4
- package/src/bridge.ts +642 -0
- package/src/client.ts +336 -0
- package/src/index.ts +6 -0
- package/src/judge-gate.ts +552 -0
- package/src/lock.ts +113 -0
- package/src/ndjson.ts +95 -0
- package/src/paths.ts +132 -0
- package/src/server.ts +1124 -0
- package/src/socket.ts +57 -0
- package/src/sqlite.ts +90 -0
- package/src/testing.ts +168 -0
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
|
+
}
|