@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/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"
|