@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/client.ts ADDED
@@ -0,0 +1,336 @@
1
+ import {
2
+ type Claim,
3
+ type EndpointView,
4
+ type Envelope,
5
+ type ErrorCode,
6
+ type PeerView,
7
+ PROTOCOL,
8
+ RelayError,
9
+ type ScopeChange,
10
+ type ScopePreset,
11
+ type ScopeView,
12
+ type StopWaitResult,
13
+ type WhyResult,
14
+ } from "@scp-dev/core"
15
+ import { existsSync, readFileSync } from "node:fs"
16
+ import { createConnection } from "node:net"
17
+ import { dirname, join } from "node:path"
18
+ import { setTimeout as sleep } from "node:timers/promises"
19
+ import { brokerLockHeld } from "./lock"
20
+ import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
21
+ import { ownPrivateDir, socketPath } from "./paths"
22
+ import { Conn } from "./socket"
23
+
24
+ type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void }
25
+
26
+ export interface Hello {
27
+ claim?: Claim
28
+ resume?: string
29
+ /** the human inbox me/; `tty` says whether a terminal is attached, and is recorded on what it sends */
30
+ human?: boolean
31
+ tty?: boolean
32
+ /** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
33
+ transient?: boolean
34
+ /** a one-shot command: the broker announces no presence change when it connects or goes */
35
+ brief?: boolean
36
+ /** no identity: list, status, watch, log and policy only */
37
+ observe?: boolean
38
+ /** an observer's process lineage: when it lands on an endpoint, the observer gets that endpoint's view and may narrow */
39
+ lineage?: string[]
40
+ /** an observer's lineage up to its nearest harness process: when it lands on an agent endpoint, the connection acts as it with lineage auth */
41
+ self?: string[]
42
+ /** a spawn-env grant from a shell with no name: the hello binds only to the endpoint that spent it, or one below it */
43
+ grant?: string
44
+ cwd?: string
45
+ }
46
+
47
+ export interface BrokerInfo {
48
+ socket: string
49
+ dir: string
50
+ pid: number
51
+ policy: { file: string; state: "open" | "loaded" | "error"; error?: string }
52
+ }
53
+
54
+ export interface HelloResult {
55
+ endpoint: EndpointView | null
56
+ token?: string
57
+ /** the broker checked the hello's grant against the endpoint it bound */
58
+ grant_bound?: boolean
59
+ /** a hook's subagent claim the broker honoured, and whether it made the endpoint */
60
+ subagent?: true
61
+ created?: true
62
+ capabilities: string[]
63
+ preamble: string
64
+ broker: BrokerInfo
65
+ }
66
+
67
+ export interface GrantParams {
68
+ name?: string
69
+ labels?: Record<string, string>
70
+ title?: string
71
+ under?: string
72
+ scope?: ScopePreset
73
+ in?: string[]
74
+ out?: string[]
75
+ allow?: string[]
76
+ groups?: string[]
77
+ ttl_s?: number
78
+ /** a full process key: a claim from that process's tree with no grant of its own takes this grant (`swarm spawn` binds its own) */
79
+ bind_process?: string
80
+ /** `swarm spawn`'s, which waits on the harness it runs: the spawner's stop may wait for that worker */
81
+ spawn?: boolean
82
+ /** `swarm spawn --no-push`'s: the spawner's stop never waits on that worker, whose messages and handback stay ordinary unread ones */
83
+ no_push?: boolean
84
+ /** with peek on, the spawn's prompt: kept scrubbed as the worker's first peek turn once it joins */
85
+ prompt?: string
86
+ }
87
+
88
+ export interface GroupParams {
89
+ action: "create" | "add" | "remove" | "rules" | "ls" | "show" | "drop" | "pin" | "unpin"
90
+ name?: string
91
+ endpoint?: string
92
+ in?: string[]
93
+ out?: string[]
94
+ rules?: string[]
95
+ }
96
+
97
+ /** one NDJSON JSON-RPC connection to the local broker */
98
+ export class RelayClient {
99
+ private nextId = 1
100
+ private readonly pending = new Map<number, Pending>()
101
+ private readonly reader = new LineReader(64 * 1024 * 1024)
102
+ private readonly writer = new LineWriter()
103
+ private socket!: Conn<undefined>
104
+ private closedError: Error | undefined
105
+ private listeners: ((method: string, params: unknown) => void)[] = []
106
+
107
+ static connect(path: string): Promise<RelayClient> {
108
+ const client = new RelayClient()
109
+ return new Promise((resolve, reject) => {
110
+ const socket = createConnection({ path })
111
+ socket.once("error", reject)
112
+ socket.once("connect", () => {
113
+ socket.off("error", reject)
114
+ client.socket = new Conn(socket, undefined, {
115
+ data: (_s, chunk) => client.onData(chunk),
116
+ drain: () => client.flush(),
117
+ close: (_s, error) => client.onClose(error ?? new Error("the relay broker closed the connection")),
118
+ })
119
+ resolve(client)
120
+ })
121
+ })
122
+ }
123
+
124
+ onNotification(fn: (method: string, params: unknown) => void): void {
125
+ this.listeners.push(fn)
126
+ }
127
+
128
+ request<T = unknown>(method: string, params: object = {}): Promise<T> {
129
+ if (this.closedError) return Promise.reject(this.closedError)
130
+ const id = this.nextId++
131
+ const line = encodeLine({ jsonrpc: "2.0", id, method, params })
132
+ const bytes = line.length - 1
133
+ if (bytes > MAX_LINE) {
134
+ return Promise.reject(
135
+ new RelayError(
136
+ "too_large",
137
+ `the request is ${bytes} bytes as JSON, over the broker's ${MAX_LINE} byte line limit (control characters count 6 bytes each once escaped)`,
138
+ ),
139
+ )
140
+ }
141
+ return new Promise<T>((resolve, reject) => {
142
+ this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject })
143
+ this.writer.pushBytes(line)
144
+ this.flush()
145
+ })
146
+ }
147
+
148
+ hello(hello: Hello): Promise<HelloResult> {
149
+ return this.request<HelloResult>("hello", { protocol: PROTOCOL, capabilities: [], ...hello })
150
+ }
151
+
152
+ rules(endpoint?: string): Promise<ScopeView> {
153
+ return this.request<ScopeView>("rules", endpoint ? { endpoint } : {})
154
+ }
155
+
156
+ rulesSet(params: { endpoint?: string; in?: string[]; out?: string[]; scope?: ScopePreset; allow?: string[] }): Promise<ScopeView> {
157
+ return this.request<ScopeView>("rules_set", params)
158
+ }
159
+
160
+ rulesWiden(params: { endpoint: string; in?: string[]; out?: string[] }): Promise<ScopeChange> {
161
+ return this.request<ScopeChange>("rules_widen", params)
162
+ }
163
+
164
+ rulesLift(params: { endpoint: string; cap: string }): Promise<ScopeView> {
165
+ return this.request<ScopeView>("rules_lift", params)
166
+ }
167
+
168
+ /** a `krt_` grant for a spawn: `env` holds SCP_GRANT, which is a secret */
169
+ grant(params: GrantParams): Promise<{ token: string; env: Record<string, string>; steer?: { key: string; parent: string } }> {
170
+ return this.request("grant", params)
171
+ }
172
+
173
+ /**
174
+ * the harness a spawn ran with `grant` exited: its endpoint goes done and offline, or a grant no one spent is revoked, and the spawner gets
175
+ * `output` back in the handback message, unless the harness never `started`
176
+ */
177
+ spawnExit(params: {
178
+ grant: string
179
+ code: number
180
+ output?: string
181
+ stderr?: string
182
+ unapplied?: string[]
183
+ exits?: number[]
184
+ started?: boolean
185
+ }): Promise<{ joined: boolean; name: string | null; endpoint: EndpointView | null; handback?: string | null }> {
186
+ return this.request("spawn_exit", params)
187
+ }
188
+
189
+ /**
190
+ * a spawned worker that exited: its harness session id, the transcript its hooks named with it and its spawner's unread messages to it;
191
+ * with `take` and a session they are marked read, for a resume to carry them
192
+ */
193
+ spawnSteers(params: { grant: string; take?: boolean }): Promise<{ session: string | null; steers: Envelope[]; transcript?: string | null }> {
194
+ return this.request("spawn_steers", params)
195
+ }
196
+
197
+ /**
198
+ * at a stop: the spawned workers' unread messages, held for the hook to print, waiting up to `wait_s` while workers still run, none came,
199
+ * pushes are left and no prompt moved the `prompts` mark inject handed out; with `wait_s` above 0 and other workers still running, the
200
+ * broker gathers more of their results into the same push for up to ${LIMITS.pushGraceS} s, which may run past `wait_s`
201
+ */
202
+ stopWait(waitS = 0, prompts?: number): Promise<StopWaitResult> {
203
+ return this.request<StopWaitResult>("stop_wait", { wait_s: waitS, ...(prompts !== undefined ? { prompts } : {}) })
204
+ }
205
+
206
+ group(params: GroupParams): Promise<unknown> {
207
+ return this.request("group", params)
208
+ }
209
+
210
+ admin(params: { action: "grant" | "revoke" | "ls"; subject?: string; tree?: string; group?: string }): Promise<unknown> {
211
+ return this.request("admin", params)
212
+ }
213
+
214
+ async peers(): Promise<PeerView[]> {
215
+ return (await this.request<{ peers: PeerView[] }>("peers")).peers
216
+ }
217
+
218
+ why(from: string, to: string): Promise<WhyResult> {
219
+ return this.request<WhyResult>("why", { from, to })
220
+ }
221
+
222
+ close(): void {
223
+ this.socket.end()
224
+ }
225
+
226
+ private flush() {
227
+ this.writer.flush((bytes) => this.socket.write(bytes))
228
+ }
229
+
230
+ private onData(chunk: Uint8Array) {
231
+ const { lines, tooLong } = this.reader.push(chunk)
232
+ this.dispatchLines(lines)
233
+ if (tooLong) this.fail(new Error("the relay broker sent a line over the size cap"))
234
+ }
235
+
236
+ private dispatchLines(lines: string[]) {
237
+ for (const line of lines) {
238
+ let parsed: unknown
239
+ try {
240
+ parsed = JSON.parse(line)
241
+ } catch {
242
+ this.fail(new Error("the relay broker sent a line that is not JSON"))
243
+ return
244
+ }
245
+ const message = parsed as {
246
+ id?: number
247
+ method?: string
248
+ params?: unknown
249
+ result?: unknown
250
+ error?: { message: string; data?: { code?: ErrorCode; retry_after?: number } }
251
+ }
252
+ if (message.method) {
253
+ for (const fn of this.listeners) fn(message.method, message.params)
254
+ continue
255
+ }
256
+ const pending = message.id !== undefined ? this.pending.get(message.id) : undefined
257
+ if (!pending) continue
258
+ this.pending.delete(message.id!)
259
+ if (message.error) pending.reject(new RelayError(message.error.data?.code ?? "invalid_params", message.error.message, message.error.data?.retry_after))
260
+ else pending.resolve(message.result)
261
+ }
262
+ }
263
+
264
+ private fail(error: Error) {
265
+ this.onClose(error)
266
+ this.socket.end()
267
+ }
268
+
269
+ private onClose(error: Error) {
270
+ if (this.closedError) return
271
+ this.closedError = error
272
+ for (const pending of this.pending.values()) pending.reject(error)
273
+ this.pending.clear()
274
+ }
275
+ }
276
+
277
+ export class BrokerUnavailable extends Error {}
278
+
279
+ /** whether the broker for `dir` is running: its lock is held, or, when the lock cannot be probed, its pid is alive */
280
+ function brokerLive(dir: string): boolean {
281
+ const lock = join(dir, "broker.lock")
282
+ const held = brokerLockHeld(lock)
283
+ if (held !== undefined) return held
284
+ let pid: number
285
+ try {
286
+ pid = Number(readFileSync(lock, "utf8").split(":")[0])
287
+ } catch {
288
+ return false
289
+ }
290
+ if (!Number.isInteger(pid) || pid <= 0) return false
291
+ try {
292
+ process.kill(pid, 0)
293
+ return true
294
+ } catch (error) {
295
+ return (error as { code?: unknown }).code === "EPERM"
296
+ }
297
+ }
298
+
299
+ /**
300
+ * A connection to the broker for `dir`. With `start`, a missing broker is started (detached)
301
+ * and the connection retried for up to `timeoutMs`. A socket whose directory is a link, someone
302
+ * else's, or writable by others is refused before anything is sent: in /tmp anyone could make it first.
303
+ */
304
+ export async function connectBroker(dir: string, start?: () => void, timeoutMs = 5000): Promise<RelayClient> {
305
+ const path = socketPath(dir)
306
+ const refuse = (code: unknown) => new BrokerUnavailable(`the sandbox blocks the relay socket (${code}); allow ${path} or run outside the sandbox`)
307
+ const owned = (client: RelayClient) => {
308
+ if (ownPrivateDir(dirname(path))) return client
309
+ client.close()
310
+ throw new BrokerUnavailable(`${dirname(path)} is a link, not this user's, or writable by others; refusing the relay socket in it`)
311
+ }
312
+ const sandboxed = (error: unknown) => {
313
+ if (error instanceof BrokerUnavailable) throw error
314
+ const code = (error as { code?: unknown } | null)?.code
315
+ if (code === "EPERM" || code === "EACCES") throw refuse(code)
316
+ }
317
+ try {
318
+ return owned(await RelayClient.connect(path))
319
+ } catch (error) {
320
+ sandboxed(error)
321
+ if (!start) throw new BrokerUnavailable(`no relay broker at ${path} (${error instanceof Error ? error.message : String(error)})`)
322
+ if (existsSync(path) && brokerLive(dir)) throw refuse((error as { code?: unknown } | null)?.code ?? "refused")
323
+ }
324
+ start()
325
+ const deadline = Date.now() + timeoutMs
326
+ for (;;) {
327
+ await sleep(50)
328
+ try {
329
+ return owned(await RelayClient.connect(path))
330
+ } catch (error) {
331
+ sandboxed(error)
332
+ if (Date.now() >= deadline)
333
+ throw new BrokerUnavailable(`the relay broker did not start within ${timeoutMs} ms (${error instanceof Error ? error.message : String(error)})`)
334
+ }
335
+ }
336
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { type BrokerInfo, BrokerUnavailable, connectBroker, type GrantParams, type GroupParams, RelayClient, type Hello, type HelloResult } from "./client"
2
+ export { relaySocketUrl, type Bridge, type BridgeConfig, type BridgeStatus } from "./bridge"
3
+ export { BrokerRunning } from "./lock"
4
+ export { MAX_LINE } from "./ndjson"
5
+ export { accountHome, localHostName, policyPath, privateDir, relayDir, shortHostname, socketPath, systemHostname, thisHostname } from "./paths"
6
+ export { startBroker, type Broker, type BrokerOptions } from "./server"