@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
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync } from "node:fs"
|
|
2
|
+
import {
|
|
3
|
+
applyRedactions,
|
|
4
|
+
DEFAULT_JUDGE_BUDGET,
|
|
5
|
+
findSecret,
|
|
6
|
+
HttpJudgeClient,
|
|
7
|
+
JUDGE_PROMPT_VERSION,
|
|
8
|
+
type JudgeAuditDetail,
|
|
9
|
+
judgeBoundary,
|
|
10
|
+
type JudgeClient,
|
|
11
|
+
type JudgeModelConfig,
|
|
12
|
+
type JudgePolicy,
|
|
13
|
+
LIMITS,
|
|
14
|
+
mergeVerdicts,
|
|
15
|
+
newId,
|
|
16
|
+
newJudgeStats,
|
|
17
|
+
one,
|
|
18
|
+
RelayError,
|
|
19
|
+
runJudge,
|
|
20
|
+
type SendParams,
|
|
21
|
+
type SendResult,
|
|
22
|
+
sha256Hex,
|
|
23
|
+
type Sql,
|
|
24
|
+
type Verdict,
|
|
25
|
+
VerdictCache,
|
|
26
|
+
} from "@scp-dev/core"
|
|
27
|
+
import type { FederatedCore } from "@scp-dev/hub"
|
|
28
|
+
|
|
29
|
+
export type JudgeClientFactory = (model: JudgeModelConfig, key: () => Promise<string>) => JudgeClient
|
|
30
|
+
type Origin = { tty?: boolean; unverified?: boolean }
|
|
31
|
+
/** sends as `from` through the same path an unjudged send takes, waking recipients */
|
|
32
|
+
export type Deliver = (from: string, params: SendParams, origin: Origin) => Promise<SendResult>
|
|
33
|
+
|
|
34
|
+
export interface JudgeGateOptions {
|
|
35
|
+
sql: Sql
|
|
36
|
+
core: FederatedCore
|
|
37
|
+
now: () => number
|
|
38
|
+
/** where key_env is read */
|
|
39
|
+
env: Record<string, string | undefined>
|
|
40
|
+
client?: JudgeClientFactory | undefined
|
|
41
|
+
log: (line: string) => void
|
|
42
|
+
/** an endpoint got a notice */
|
|
43
|
+
wake: (endpoint: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface HoldRow {
|
|
47
|
+
id: string
|
|
48
|
+
from_ep: string
|
|
49
|
+
params: string
|
|
50
|
+
origin: string
|
|
51
|
+
verdict: string
|
|
52
|
+
created_at: number
|
|
53
|
+
expires_at: number
|
|
54
|
+
state: "held" | "releasing" | "released" | "refused" | "expired"
|
|
55
|
+
by: string | null
|
|
56
|
+
resolved_at: number | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type StoredVerdict = Verdict & { boundary: string; to: string[] }
|
|
60
|
+
|
|
61
|
+
/** a hold as the human's review lists it: never the body */
|
|
62
|
+
export interface HoldView {
|
|
63
|
+
id: string
|
|
64
|
+
from: string
|
|
65
|
+
to: string[]
|
|
66
|
+
state: HoldRow["state"]
|
|
67
|
+
created_at: string
|
|
68
|
+
expires_at: string
|
|
69
|
+
categories: string[]
|
|
70
|
+
reason: string
|
|
71
|
+
boundary: string
|
|
72
|
+
source: Verdict["source"]
|
|
73
|
+
bytes: number
|
|
74
|
+
by: string | null
|
|
75
|
+
resolved_at: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** held sends never reach the send's rate limits, so a sender's waiting holds are capped */
|
|
79
|
+
const HELD_PER_SENDER = 100
|
|
80
|
+
|
|
81
|
+
const iso = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString())
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* the broker's local judge (policy `judge`): shadow judges after delivery and only audits; enforce judges before delivery and passes,
|
|
85
|
+
* redacts, refuses or holds. Holds wait in `judge_holds` for the human at a terminal; their body is purged once resolved or expired
|
|
86
|
+
*/
|
|
87
|
+
export class JudgeGate {
|
|
88
|
+
readonly stats = newJudgeStats()
|
|
89
|
+
private policy: JudgePolicy | undefined
|
|
90
|
+
private version = ""
|
|
91
|
+
private client: JudgeClient | null = null
|
|
92
|
+
private readonly cache = new VerdictCache()
|
|
93
|
+
private readonly shadows = new Set<Promise<void>>()
|
|
94
|
+
/** judged sends in the last hour per budget: `ep:<id>`, `tree:<root id>` and `*` for the whole broker */
|
|
95
|
+
private readonly spent = new Map<string, number[]>()
|
|
96
|
+
|
|
97
|
+
constructor(private readonly o: JudgeGateOptions) {
|
|
98
|
+
o.sql.all(
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS judge_holds (id TEXT PRIMARY KEY, from_ep TEXT NOT NULL, params TEXT NOT NULL, origin TEXT NOT NULL, verdict TEXT NOT NULL,
|
|
100
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, by TEXT, resolved_at INTEGER)`,
|
|
101
|
+
)
|
|
102
|
+
o.sql.all(
|
|
103
|
+
`CREATE TABLE IF NOT EXISTS judge_sent (from_ep TEXT NOT NULL, client_id TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
104
|
+
PRIMARY KEY (from_ep, client_id))`,
|
|
105
|
+
)
|
|
106
|
+
o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE state = 'releasing'`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** the policy's judge section; a model client only when its key is readable, else the prefilter alone */
|
|
110
|
+
configure(policy: JudgePolicy | undefined): void {
|
|
111
|
+
this.policy = policy
|
|
112
|
+
this.version = policy ? sha256Hex(JSON.stringify(policy)) : ""
|
|
113
|
+
this.client = null
|
|
114
|
+
const model = policy?.model
|
|
115
|
+
if (!policy || policy.mode === "off" || !model) return
|
|
116
|
+
const key = this.keyReader(model)
|
|
117
|
+
if (!key) {
|
|
118
|
+
this.o.log(
|
|
119
|
+
`relay broker: the judge model's key (${model.key_env ? `key_env ${model.key_env}` : `key_file ${model.key_file}`}) is not readable; judging with the prefilter only`,
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
this.client = (this.o.client ?? ((m, k) => new HttpJudgeClient({ model: m.id, effort: m.effort, key: k })))(model, key)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private keyReader(model: JudgeModelConfig): (() => Promise<string>) | null {
|
|
127
|
+
if (model.key_env) {
|
|
128
|
+
const name = model.key_env
|
|
129
|
+
return this.o.env[name] ? async () => this.o.env[name] ?? "" : null
|
|
130
|
+
}
|
|
131
|
+
const file = model.key_file!
|
|
132
|
+
try {
|
|
133
|
+
accessSync(file, constants.R_OK)
|
|
134
|
+
} catch {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
return async () => readFileSync(file, "utf8").trim()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** resolves once every shadow judgement started so far finished */
|
|
141
|
+
async settled(): Promise<void> {
|
|
142
|
+
await Promise.all([...this.shadows])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** a send or ask through the judge; an unjudged one goes straight to `deliver` */
|
|
146
|
+
async send(from: string, params: SendParams, origin: Origin, deliver: Deliver): Promise<SendResult> {
|
|
147
|
+
const policy = this.policy
|
|
148
|
+
const body = params.body
|
|
149
|
+
if (!policy || policy.mode === "off" || typeof body !== "string" || new TextEncoder().encode(body).length > LIMITS.bodyBytes || findSecret(body))
|
|
150
|
+
return deliver(from, params, origin)
|
|
151
|
+
const targets = Array.isArray(params.to) ? params.to : [params.to]
|
|
152
|
+
if (targets.some((to) => this.o.core.foreignAccount(to) !== null)) {
|
|
153
|
+
if (policy.mode === "enforce") {
|
|
154
|
+
this.o.core.auditJudge("judge_skipped", from, null, {
|
|
155
|
+
side: "local",
|
|
156
|
+
boundary: "other account",
|
|
157
|
+
prompt_version: JUDGE_PROMPT_VERSION,
|
|
158
|
+
categories: [],
|
|
159
|
+
sha256: sha256Hex(body),
|
|
160
|
+
verdict: "skipped",
|
|
161
|
+
reason: "other_account_judged_by_hub",
|
|
162
|
+
to: targets.map(String),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
return deliver(from, params, origin)
|
|
166
|
+
}
|
|
167
|
+
if (policy.mode === "shadow") {
|
|
168
|
+
const result = await deliver(from, params, origin)
|
|
169
|
+
if (!result.duplicate) this.shadow(policy, from, params, origin, result.id)
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin)
|
|
173
|
+
if (!groups.size) return deliver(from, params, origin)
|
|
174
|
+
const ttl = params.ttl_s ?? LIMITS.ttlDefaultS
|
|
175
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS.ttlMaxS) throw new RelayError("invalid_params", `ttl_s must be 1 to ${LIMITS.ttlMaxS}`)
|
|
176
|
+
const seen = params.client_id === undefined ? undefined : this.seen(from, params)
|
|
177
|
+
if (seen) return seen
|
|
178
|
+
const waiting = one<{ n: number }>(this.o.sql, `SELECT COUNT(*) AS n FROM judge_holds WHERE from_ep = ? AND state IN ('held', 'releasing')`, from)!.n
|
|
179
|
+
if (waiting >= HELD_PER_SENDER)
|
|
180
|
+
throw new RelayError("rate_limited", `${waiting} of your messages wait for review by your human; send more once they released or refused some`, 3600)
|
|
181
|
+
this.spend(policy, from, tree)
|
|
182
|
+
const verdict = await this.judge(policy, from, body, groups)
|
|
183
|
+
const detail = this.detail(verdict, body, groups)
|
|
184
|
+
switch (verdict.verdict) {
|
|
185
|
+
case "refuse":
|
|
186
|
+
this.o.core.auditJudge("judge_refuse", from, null, detail)
|
|
187
|
+
throw new RelayError(
|
|
188
|
+
"judge_refused",
|
|
189
|
+
`the relay judge refused this message (local; ${verdict.categories.join(", ") || "no category"}): ${verdict.reason}`,
|
|
190
|
+
undefined,
|
|
191
|
+
{
|
|
192
|
+
side: "local",
|
|
193
|
+
categories: verdict.categories,
|
|
194
|
+
reason: verdict.reason,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
case "hold":
|
|
198
|
+
return this.hold(from, params, origin, verdict, groups, detail)
|
|
199
|
+
case "redact": {
|
|
200
|
+
const applied = applyRedactions(body, verdict.redactions)
|
|
201
|
+
const result = await deliver(from, { ...params, body: applied.text }, origin)
|
|
202
|
+
const redacted = { n: applied.n, kinds: applied.kinds }
|
|
203
|
+
this.o.core.auditJudge(verdict.source === "error" ? "judge_skipped" : "judge_redact", from, result.id, { ...detail, redacted })
|
|
204
|
+
return this.remember(from, params, { ...result, redacted })
|
|
205
|
+
}
|
|
206
|
+
case "pass": {
|
|
207
|
+
const result = await deliver(from, params, origin)
|
|
208
|
+
if (verdict.source === "error") this.o.core.auditJudge("judge_skipped", from, result.id, detail)
|
|
209
|
+
return this.remember(from, params, result)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** an earlier judged send or waiting hold under the send's client_id: the same message is answered as before, another one refused as core refuses it */
|
|
215
|
+
private seen(from: string, params: SendParams): SendResult | undefined {
|
|
216
|
+
const clientId = params.client_id!
|
|
217
|
+
const held = one<{ id: string; params: string }>(
|
|
218
|
+
this.o.sql,
|
|
219
|
+
`SELECT id, params FROM judge_holds WHERE from_ep = ? AND json_extract(params, '$.client_id') = ? AND state IN ('held', 'releasing')`,
|
|
220
|
+
from,
|
|
221
|
+
clientId,
|
|
222
|
+
)
|
|
223
|
+
const sent = held
|
|
224
|
+
? undefined
|
|
225
|
+
: one<{ fingerprint: string; result: string }>(
|
|
226
|
+
this.o.sql,
|
|
227
|
+
`SELECT fingerprint, result FROM judge_sent WHERE from_ep = ? AND client_id = ? AND created_at > ?`,
|
|
228
|
+
from,
|
|
229
|
+
clientId,
|
|
230
|
+
this.o.now() - LIMITS.idempotencyS * 1000,
|
|
231
|
+
)
|
|
232
|
+
const before = held ? fingerprint(JSON.parse(held.params) as SendParams) : sent?.fingerprint
|
|
233
|
+
if (before === undefined) return undefined
|
|
234
|
+
if (before !== fingerprint(params)) throw new RelayError("invalid_params", `client_id ${clientId} was already used for a different message`)
|
|
235
|
+
return held ? heldResult(held.id, params) : { ...(JSON.parse(sent!.result) as SendResult), duplicate: true }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private remember(from: string, params: SendParams, result: SendResult): SendResult {
|
|
239
|
+
if (params.client_id !== undefined && !result.duplicate) {
|
|
240
|
+
this.o.sql.all(
|
|
241
|
+
`INSERT OR REPLACE INTO judge_sent (from_ep, client_id, fingerprint, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
242
|
+
from,
|
|
243
|
+
params.client_id,
|
|
244
|
+
fingerprint(params),
|
|
245
|
+
JSON.stringify(result),
|
|
246
|
+
this.o.now(),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* a judged send counts before any model call against its sender's, its lineage tree's and the whole broker's budget, since refused and
|
|
254
|
+
* held sends never reach the send's own limits, and a new handle or a helper process must not bring a fresh budget
|
|
255
|
+
*/
|
|
256
|
+
private spend(policy: JudgePolicy, from: string, tree: string): void {
|
|
257
|
+
const now = this.o.now()
|
|
258
|
+
const global = policy.budget ?? DEFAULT_JUDGE_BUDGET
|
|
259
|
+
const budgets: [key: string, perMinute: number, perHour: number, what: string][] = [
|
|
260
|
+
[`ep:${from}`, LIMITS.pairPerMinute, LIMITS.pairPerHour, "from this endpoint"],
|
|
261
|
+
[`tree:${tree}`, LIMITS.pairPerMinute * 2, LIMITS.pairPerHour * 2, "from this lineage tree"],
|
|
262
|
+
["*", global.per_minute, global.per_hour, "on this broker"],
|
|
263
|
+
]
|
|
264
|
+
const recent = budgets.map(([key]) => (this.spent.get(key) ?? []).filter((at) => at > now - 3_600_000))
|
|
265
|
+
budgets.forEach(([key, perMinute, perHour, what], i) => {
|
|
266
|
+
for (const [limit, windowS] of [
|
|
267
|
+
[perMinute, 60],
|
|
268
|
+
[perHour, 3600],
|
|
269
|
+
] as const) {
|
|
270
|
+
const inWindow = recent[i]!.filter((at) => at > now - windowS * 1000)
|
|
271
|
+
if (inWindow.length >= limit) {
|
|
272
|
+
this.spent.set(key, recent[i]!)
|
|
273
|
+
throw new RelayError(
|
|
274
|
+
"rate_limited",
|
|
275
|
+
`${limit} judged messages ${what} in ${windowS} s`,
|
|
276
|
+
Math.max(1, Math.ceil((inWindow[0]! + windowS * 1000 - now) / 1000)),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
for (const [i, [key]] of budgets.entries()) this.spent.set(key, [...recent[i]!, now])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** how many budgets are tracked (idle ones are evicted by the sweep) */
|
|
285
|
+
budgetKeys(): number {
|
|
286
|
+
return this.spent.size
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** the held messages, newest first, without bodies */
|
|
290
|
+
holds(state?: string): HoldView[] {
|
|
291
|
+
const rows = state
|
|
292
|
+
? this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds WHERE state = ? ORDER BY created_at DESC, rowid DESC LIMIT 200`, state)
|
|
293
|
+
: this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds ORDER BY created_at DESC, rowid DESC LIMIT 200`)
|
|
294
|
+
return rows.map((row) => this.view(row))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** one hold with its body and the judge's redactions while it waits */
|
|
298
|
+
show(id: string): HoldView & { body?: string; redactions?: { quote?: string; kind: string }[] } {
|
|
299
|
+
const row = this.row(id)
|
|
300
|
+
const params = JSON.parse(row.params) as Partial<SendParams>
|
|
301
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
302
|
+
return { ...this.view(row), ...(typeof params.body === "string" ? { body: params.body } : {}), redactions: verdict.redactions }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** delivers a held message as its sender, as stored or redacted: scope, policy and limits are checked again by the send itself */
|
|
306
|
+
async release(id: string, options: { redacted?: boolean; by: string }, deliver: Deliver): Promise<SendResult> {
|
|
307
|
+
const row = one<HoldRow>(
|
|
308
|
+
this.o.sql,
|
|
309
|
+
`UPDATE judge_holds SET state = 'releasing' WHERE id = ? AND state = 'held' AND expires_at > ? RETURNING *`,
|
|
310
|
+
id,
|
|
311
|
+
this.o.now(),
|
|
312
|
+
)
|
|
313
|
+
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
314
|
+
const params = JSON.parse(row.params) as SendParams
|
|
315
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
316
|
+
let sent = params
|
|
317
|
+
let redacted: { n: number; kinds: string[] } | undefined
|
|
318
|
+
if (options.redacted) {
|
|
319
|
+
const applied = applyRedactions(params.body, verdict.redactions)
|
|
320
|
+
if (!applied.n) {
|
|
321
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id)
|
|
322
|
+
throw new RelayError("invalid_params", `hold ${id} has no redactions: release it as it is or refuse it`)
|
|
323
|
+
}
|
|
324
|
+
sent = { ...params, body: applied.text }
|
|
325
|
+
redacted = { n: applied.n, kinds: applied.kinds }
|
|
326
|
+
}
|
|
327
|
+
let result: SendResult
|
|
328
|
+
try {
|
|
329
|
+
result = await deliver(row.from_ep, sent, JSON.parse(row.origin) as Origin)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id)
|
|
332
|
+
throw error
|
|
333
|
+
}
|
|
334
|
+
this.remember(row.from_ep, params, { ...result, ...(redacted ? { redacted } : {}) })
|
|
335
|
+
this.resolve(row, "released", options.by)
|
|
336
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, {
|
|
337
|
+
...this.storedDetail(row, verdict),
|
|
338
|
+
by: options.by,
|
|
339
|
+
action: redacted ? "release_redacted" : "release",
|
|
340
|
+
...(redacted ? { redacted } : {}),
|
|
341
|
+
})
|
|
342
|
+
this.tell(
|
|
343
|
+
row.from_ep,
|
|
344
|
+
`relay: your message held for review as ${id} was released by your human${redacted ? " with redactions" : ""} and delivered as ${result.id}`,
|
|
345
|
+
)
|
|
346
|
+
return { ...result, ...(redacted ? { redacted } : {}) }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** refuses a held message: it is never delivered, and its sender hears the human's reason */
|
|
350
|
+
refuse(id: string, reason: string, by: string): HoldView {
|
|
351
|
+
this.expire(this.o.now())
|
|
352
|
+
const row = one<HoldRow>(this.o.sql, `SELECT * FROM judge_holds WHERE id = ? AND state = 'held'`, id)
|
|
353
|
+
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
354
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
355
|
+
this.resolve(row, "refused", by)
|
|
356
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, { ...this.storedDetail(row, verdict), by, action: "refuse", reason })
|
|
357
|
+
this.tell(row.from_ep, `relay: your message held for review as ${id} was refused by your human: ${reason}`)
|
|
358
|
+
return this.view(this.row(id))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** expires holds past their message TTL (body purged, sender told) and drops resolved ones past the audit window */
|
|
362
|
+
sweep(): void {
|
|
363
|
+
const now = this.o.now()
|
|
364
|
+
this.expire(now)
|
|
365
|
+
this.o.sql.all(`DELETE FROM judge_holds WHERE state IN ('released', 'refused', 'expired') AND resolved_at <= ?`, now - LIMITS.auditKeepS * 1000)
|
|
366
|
+
this.o.sql.all(`DELETE FROM judge_sent WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
|
|
367
|
+
for (const [key, times] of this.spent) if (!times.length || times[times.length - 1]! <= now - 3_600_000) this.spent.delete(key)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private expire(now: number): void {
|
|
371
|
+
for (const row of this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds WHERE state = 'held' AND expires_at <= ?`, now)) {
|
|
372
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
373
|
+
this.resolve(row, "expired", null)
|
|
374
|
+
this.o.core.auditJudge("judge_expire", row.from_ep, row.id, this.storedDetail(row, verdict))
|
|
375
|
+
this.tell(row.from_ep, `relay: your message held for review as ${row.id} expired without review and was not delivered`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** the recipients by the boundary their message crosses; throws as send does for a target the sender cannot reach */
|
|
380
|
+
private boundaries(policy: JudgePolicy, from: string, params: SendParams, origin: Origin): { groups: Map<string, string[]>; tree: string } {
|
|
381
|
+
const { sender, recipients, tree } = this.o.core.judgeSubjects(from, params, origin)
|
|
382
|
+
const groups = new Map<string, string[]>()
|
|
383
|
+
for (const recipient of recipients) {
|
|
384
|
+
const label = judgeBoundary(policy, sender, recipient)
|
|
385
|
+
if (label !== null) groups.set(label, [...(groups.get(label) ?? []), recipient.address])
|
|
386
|
+
}
|
|
387
|
+
return { groups, tree }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async judge(policy: JudgePolicy, from: string, body: string, groups: Map<string, string[]>): Promise<Verdict> {
|
|
391
|
+
const verdicts = await Promise.all(
|
|
392
|
+
[...groups].map(([boundary, to]) =>
|
|
393
|
+
runJudge({
|
|
394
|
+
body,
|
|
395
|
+
side: "local",
|
|
396
|
+
boundary,
|
|
397
|
+
configVersion: this.version,
|
|
398
|
+
client: this.client,
|
|
399
|
+
cache: this.cache,
|
|
400
|
+
timeoutMs: policy.timeout_s * 1000,
|
|
401
|
+
failMode: policy.fail,
|
|
402
|
+
...(this.client && policy.model ? { model: policy.model.id } : {}),
|
|
403
|
+
from: this.o.core.addressOf(from),
|
|
404
|
+
to,
|
|
405
|
+
now: this.o.now,
|
|
406
|
+
stats: this.stats,
|
|
407
|
+
}),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
return mergeVerdicts(verdicts)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private shadow(policy: JudgePolicy, from: string, params: SendParams, origin: Origin, ref: string): void {
|
|
414
|
+
const task = (async () => {
|
|
415
|
+
try {
|
|
416
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin)
|
|
417
|
+
if (!groups.size) return
|
|
418
|
+
this.spend(policy, from, tree)
|
|
419
|
+
const body = params.body
|
|
420
|
+
const verdict = await this.judge(policy, from, body, groups)
|
|
421
|
+
this.o.core.auditJudge("judge_shadow", from, ref, this.detail(verdict, body, groups))
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.o.log(`relay broker: a shadow judgement failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
424
|
+
}
|
|
425
|
+
})()
|
|
426
|
+
this.shadows.add(task)
|
|
427
|
+
void task.finally(() => this.shadows.delete(task))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private hold(from: string, params: SendParams, origin: Origin, verdict: Verdict, groups: Map<string, string[]>, detail: JudgeAuditDetail): SendResult {
|
|
431
|
+
const now = this.o.now()
|
|
432
|
+
const id = newId("hold", now)
|
|
433
|
+
const stored: SendParams = { ...params, client_id: params.client_id ?? id }
|
|
434
|
+
const to = [...groups.values()].flat()
|
|
435
|
+
const record: StoredVerdict = { ...verdict, boundary: [...groups.keys()].join(" | "), to }
|
|
436
|
+
this.o.sql.all(
|
|
437
|
+
`INSERT INTO judge_holds (id, from_ep, params, origin, verdict, created_at, expires_at, state) VALUES (?, ?, ?, ?, ?, ?, ?, 'held')`,
|
|
438
|
+
id,
|
|
439
|
+
from,
|
|
440
|
+
JSON.stringify(stored),
|
|
441
|
+
JSON.stringify(origin),
|
|
442
|
+
JSON.stringify(record),
|
|
443
|
+
now,
|
|
444
|
+
now + (params.ttl_s ?? LIMITS.ttlDefaultS) * 1000,
|
|
445
|
+
)
|
|
446
|
+
this.o.core.auditJudge("judge_hold", from, id, detail)
|
|
447
|
+
return heldResult(id, params)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private detail(verdict: Verdict, body: string, groups: Map<string, string[]>): JudgeAuditDetail {
|
|
451
|
+
return {
|
|
452
|
+
side: "local",
|
|
453
|
+
boundary: [...groups.keys()].join(" | "),
|
|
454
|
+
prompt_version: verdict.prompt_version,
|
|
455
|
+
categories: verdict.categories,
|
|
456
|
+
sha256: sha256Hex(body),
|
|
457
|
+
verdict: verdict.verdict,
|
|
458
|
+
source: verdict.source,
|
|
459
|
+
reason: verdict.reason,
|
|
460
|
+
to: [...groups.values()].flat(),
|
|
461
|
+
...(verdict.model ? { model: verdict.model } : {}),
|
|
462
|
+
...(verdict.ms !== undefined ? { ms: verdict.ms } : {}),
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private storedDetail(row: HoldRow, verdict: StoredVerdict): JudgeAuditDetail {
|
|
467
|
+
const params = JSON.parse(row.params) as { body?: string; sha256?: string }
|
|
468
|
+
return {
|
|
469
|
+
side: "local",
|
|
470
|
+
boundary: verdict.boundary,
|
|
471
|
+
prompt_version: verdict.prompt_version,
|
|
472
|
+
categories: verdict.categories,
|
|
473
|
+
sha256: params.sha256 ?? sha256Hex(params.body ?? ""),
|
|
474
|
+
verdict: verdict.verdict,
|
|
475
|
+
to: verdict.to,
|
|
476
|
+
...(verdict.model ? { model: verdict.model } : {}),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** marks a hold resolved and purges its body and the judge's quotes of it */
|
|
481
|
+
private resolve(row: HoldRow, state: "released" | "refused" | "expired", by: string | null): void {
|
|
482
|
+
const { body, ...params } = JSON.parse(row.params) as SendParams
|
|
483
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
484
|
+
const purged = { ...params, bytes: new TextEncoder().encode(body).length, sha256: sha256Hex(body) }
|
|
485
|
+
const kinds = { ...verdict, redactions: verdict.redactions.map((r) => ({ kind: r.kind })) }
|
|
486
|
+
this.o.sql.all(
|
|
487
|
+
`UPDATE judge_holds SET state = ?, by = ?, resolved_at = ?, params = ?, verdict = ? WHERE id = ?`,
|
|
488
|
+
state,
|
|
489
|
+
by,
|
|
490
|
+
this.o.now(),
|
|
491
|
+
JSON.stringify(purged),
|
|
492
|
+
JSON.stringify(kinds),
|
|
493
|
+
row.id,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private tell(endpoint: string, text: string): void {
|
|
498
|
+
try {
|
|
499
|
+
this.o.core.judgeNotice(endpoint, text)
|
|
500
|
+
this.o.wake(endpoint)
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (!(error instanceof RelayError)) throw error
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private row(id: string): HoldRow {
|
|
507
|
+
const row = one<HoldRow>(this.o.sql, `SELECT * FROM judge_holds WHERE id = ?`, id)
|
|
508
|
+
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
509
|
+
return row
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private view(row: HoldRow): HoldView {
|
|
513
|
+
const params = JSON.parse(row.params) as Partial<SendParams> & { bytes?: number }
|
|
514
|
+
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
515
|
+
return {
|
|
516
|
+
id: row.id,
|
|
517
|
+
from: this.o.core.addressOf(row.from_ep),
|
|
518
|
+
to: verdict.to,
|
|
519
|
+
state: row.state,
|
|
520
|
+
created_at: iso(row.created_at)!,
|
|
521
|
+
expires_at: iso(row.expires_at)!,
|
|
522
|
+
categories: verdict.categories,
|
|
523
|
+
reason: verdict.reason,
|
|
524
|
+
boundary: verdict.boundary,
|
|
525
|
+
source: verdict.source,
|
|
526
|
+
bytes: params.bytes ?? new TextEncoder().encode(params.body ?? "").length,
|
|
527
|
+
by: row.by,
|
|
528
|
+
resolved_at: iso(row.resolved_at),
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** what makes two sends under one client_id the same message, close to what core's idempotency compares */
|
|
534
|
+
const fingerprint = (p: SendParams): string =>
|
|
535
|
+
sha256Hex(
|
|
536
|
+
JSON.stringify([
|
|
537
|
+
[p.to].flat().map(String).sort(),
|
|
538
|
+
p.body,
|
|
539
|
+
p.kind ?? "message",
|
|
540
|
+
p.thread ?? null,
|
|
541
|
+
p.reply_to ?? null,
|
|
542
|
+
p.data_class ?? "public",
|
|
543
|
+
(p.attachments ?? []).map((a) => a?.path).sort(),
|
|
544
|
+
p.mime ?? null,
|
|
545
|
+
p.ttl_s ?? LIMITS.ttlDefaultS,
|
|
546
|
+
p.require_reply ?? null,
|
|
547
|
+
]),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
function heldResult(id: string, params: SendParams): SendResult {
|
|
551
|
+
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } }
|
|
552
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { closeSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs"
|
|
3
|
+
import { uptime } from "node:os"
|
|
4
|
+
|
|
5
|
+
export class BrokerRunning extends Error {}
|
|
6
|
+
|
|
7
|
+
/** a held broker lock: the file and the `pid:started` line this process wrote to it */
|
|
8
|
+
export interface BrokerLock {
|
|
9
|
+
path: string
|
|
10
|
+
content: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ATTEMPTS = 8
|
|
14
|
+
/** how far before this boot a lock may claim to start and still count, for clock steps at boot */
|
|
15
|
+
const BOOT_SLACK_S = 60
|
|
16
|
+
|
|
17
|
+
function readLock(path: string): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return readFileSync(path, "utf8")
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** whether the process a lock line names runs (one of another user's counts too) and took the lock since this boot */
|
|
27
|
+
function holderAlive(content: string): boolean {
|
|
28
|
+
const [pid, started] = content.split(":").map(Number)
|
|
29
|
+
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return false
|
|
30
|
+
if (started !== undefined && Number.isFinite(started) && started < Date.now() / 1000 - uptime() - BOOT_SLACK_S) return false
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0)
|
|
33
|
+
return true
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return (error as { code?: string }).code === "EPERM"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** moves a stale lock aside, and back when it turns out another broker had replaced it with a live one meanwhile */
|
|
40
|
+
function clearStale(path: string, stale: string): void {
|
|
41
|
+
const aside = `${path}.${process.pid}.${randomBytes(4).toString("hex")}`
|
|
42
|
+
try {
|
|
43
|
+
renameSync(path, aside)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
if (readLock(aside) !== stale) linkSync(aside, path)
|
|
50
|
+
} catch {}
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(aside)
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const held = new Set<BrokerLock>()
|
|
57
|
+
let exitHook = false
|
|
58
|
+
|
|
59
|
+
/** the broker's single-instance lock, without waiting: a second broker gets BrokerRunning, a dead broker's lock is taken over */
|
|
60
|
+
export function takeBrokerLock(path: string): BrokerLock {
|
|
61
|
+
const content = `${process.pid}:${Math.floor(Date.now() / 1000)}`
|
|
62
|
+
let holder: string | undefined
|
|
63
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
64
|
+
let fd: number
|
|
65
|
+
try {
|
|
66
|
+
fd = openSync(path, "wx", 0o600)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
69
|
+
holder = readLock(path)
|
|
70
|
+
if (holder === undefined) continue
|
|
71
|
+
if (holderAlive(holder)) break
|
|
72
|
+
clearStale(path, holder)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
writeSync(fd, content, 0)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
unlinkSync(path)
|
|
79
|
+
throw error
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd)
|
|
82
|
+
}
|
|
83
|
+
const lock = { path, content }
|
|
84
|
+
held.add(lock)
|
|
85
|
+
if (!exitHook) {
|
|
86
|
+
exitHook = true
|
|
87
|
+
process.on("exit", () => {
|
|
88
|
+
for (const lock of held) releaseBrokerLock(lock)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return lock
|
|
92
|
+
}
|
|
93
|
+
const pid = holder?.split(":")[0] ?? ""
|
|
94
|
+
throw new BrokerRunning(`a relay broker already runs${/^\d+$/.test(pid) ? ` (pid ${pid})` : ""}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** whether a live broker holds the lock at `path`; undefined when the file cannot be read */
|
|
98
|
+
export function brokerLockHeld(path: string): boolean | undefined {
|
|
99
|
+
try {
|
|
100
|
+
const content = readLock(path)
|
|
101
|
+
return content !== undefined && holderAlive(content)
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** removes the lock file, unless another broker took it over meanwhile */
|
|
108
|
+
export function releaseBrokerLock(lock: BrokerLock): void {
|
|
109
|
+
if (!held.delete(lock)) return
|
|
110
|
+
try {
|
|
111
|
+
if (readLock(lock.path) === lock.content) unlinkSync(lock.path)
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|