@scp-dev/hub 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Swarm Communication Protocol federation and account hub: mirrored endpoints across machines, bridges, direct clients and cross-account delivery.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc -p ."
30
30
  },
31
31
  "dependencies": {
32
- "@scp-dev/core": "0.1.2"
32
+ "@scp-dev/core": "0.1.4"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  validHandle,
44
44
  validHarness,
45
45
  validSelector,
46
+ type Wake,
47
+ WAKES,
46
48
  } from "@scp-dev/core"
47
49
 
48
50
  export type RelayAuth = Envelope["from"]["auth"]
@@ -121,6 +123,8 @@ export interface MirrorView extends MirrorScope {
121
123
  presence?: Presence
122
124
  last_seen?: string | null
123
125
  title?: string | null
126
+ /** an agent's {@link Wake}; a value this hub does not know is kept as none */
127
+ wake?: string | null
124
128
  /** the handle the endpoint had before a rename: its mirror is renamed in place */
125
129
  was?: string
126
130
  }
@@ -142,6 +146,7 @@ interface ParsedScope {
142
146
  rules?: MirrorRules | null
143
147
  caps?: ScopeCaps | null
144
148
  title?: string | null
149
+ wake?: Wake | null
145
150
  incarnation?: string | null
146
151
  }
147
152
 
@@ -1038,6 +1043,22 @@ export class FederatedCore extends RelayCore {
1038
1043
  })
1039
1044
  }
1040
1045
 
1046
+ /**
1047
+ * records that a forwarded message reached mirrored endpoints of other machines, unacknowledged, as a hub's delivery rows would: their
1048
+ * replies then join its thread here. For links that deliver machine to machine; a hub bridge never needs it
1049
+ */
1050
+ recordRemoteDelivery(messageId: string, endpoints: string[]): void {
1051
+ this.tx(() => {
1052
+ const message = one<{ expires_at: number }>(this.sql, `SELECT expires_at FROM messages WHERE id = ?`, messageId)
1053
+ if (!message) return
1054
+ for (const endpoint of endpoints) {
1055
+ if (this.machineOf(endpoint) === null) continue
1056
+ if (one(this.sql, `SELECT 1 FROM deliveries WHERE message_id = ? AND endpoint = ?`, messageId, endpoint)) continue
1057
+ this.insertDelivery(messageId, endpoint, message.expires_at)
1058
+ }
1059
+ })
1060
+ }
1061
+
1041
1062
  /** the earlier result of a forwarded send with this client_id, or null; a different message under the same client_id is refused */
1042
1063
  forwarded(from: string, params: SendParams): SendResult | null {
1043
1064
  const clientId = cleanText(params.client_id, "client_id", 128)
@@ -1593,7 +1614,7 @@ export class FederatedCore extends RelayCore {
1593
1614
  const was = this.view(before)
1594
1615
  const presence = was.presence !== view.presence
1595
1616
  const status = before.status_state !== after.status_state || before.status_note !== after.status_note
1596
- const other = (["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
1617
+ const other = (["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
1597
1618
  (field) => before[field] !== (after as FederatedRow)[field],
1598
1619
  )
1599
1620
  return presence || status || other ? { view, added: false, presence, status } : null
@@ -1666,6 +1687,7 @@ export class FederatedCore extends RelayCore {
1666
1687
  rules: rules === undefined ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1667
1688
  caps,
1668
1689
  title: scope.title !== undefined ? scope.title : fresh ? null : row.title,
1690
+ wake: scope.wake !== undefined ? scope.wake : fresh ? null : row.wake,
1669
1691
  incarnation: scope.incarnation ?? row.incarnation,
1670
1692
  }
1671
1693
  }
@@ -1844,6 +1866,7 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
1844
1866
  throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
1845
1867
  if (given("title"))
1846
1868
  out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
1869
+ if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake as Wake) ? (view.wake as Wake) : null
1847
1870
  if (given("incarnation")) {
1848
1871
  if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
1849
1872
  out.incarnation = view.incarnation ?? null
package/src/hub.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  honestSender,
19
19
  joinRedacted,
20
20
  judgeDetailForAgents,
21
+ type JudgePolicy,
21
22
  LIMITS,
22
23
  MESSAGE_KINDS,
23
24
  type MessageKind,
@@ -37,6 +38,7 @@ import {
37
38
  SYSTEM_HANDLE,
38
39
  sameSecret,
39
40
  untilTomorrow,
41
+ type Wake,
40
42
  type XContact,
41
43
  XCounters,
42
44
  type XDelivered,
@@ -48,6 +50,7 @@ import {
48
50
  xlimits,
49
51
  } from "@scp-dev/core"
50
52
  import { FederatedCore, MACHINE_NAME, type MirrorChange, type XEndpoint } from "./federation"
53
+ import { type Deliver, type HoldView, type JudgeClientFactory, JudgeGate, type JudgeLimits } from "./judge-gate"
51
54
 
52
55
  export type HubAuth = "account" | "account+bridge"
53
56
 
@@ -102,7 +105,7 @@ export interface GraphNode {
102
105
  status: EndpointView["status"]
103
106
  last_seen: string | null
104
107
  parent: string | null
105
- wake?: string | null
108
+ wake?: Wake | null
106
109
  }
107
110
 
108
111
  /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
@@ -145,6 +148,16 @@ export interface HubDeps {
145
148
  crossAccount?: CrossAccountLink
146
149
  }
147
150
 
151
+ /** what a configured judge needs besides its policy */
152
+ export interface HubJudgeOptions {
153
+ /** where the model's key_env is read */
154
+ env: Record<string, string | undefined>
155
+ /** the model client (default the provider's HTTP API) */
156
+ client?: JudgeClientFactory | undefined
157
+ /** overrides of the judge's own caps; `Infinity` disables one */
158
+ limits?: Partial<JudgeLimits> | undefined
159
+ }
160
+
148
161
  export const HUB_LIMITS = {
149
162
  machines: 16,
150
163
  endpoints: 512,
@@ -276,6 +289,8 @@ export class RelayHub {
276
289
  private xcounters: XCounters | null = null
277
290
  private xkey: string | null = null
278
291
  private readonly limits: Readonly<HubLimits>
292
+ private judgeConfig: { policy: JudgePolicy; options: HubJudgeOptions } | null = null
293
+ private gate: JudgeGate | null = null
279
294
 
280
295
  constructor(private readonly deps: HubDeps) {
281
296
  this.now = deps.now ?? Date.now
@@ -303,6 +318,7 @@ export class RelayHub {
303
318
  })
304
319
  core.migrate()
305
320
  this.core = core
321
+ this.gate = null
306
322
  this.account = account.user
307
323
  if (this.deps.crossAccount) {
308
324
  this.xkey = core.xstate("key", () => randomHex(32))
@@ -318,10 +334,40 @@ export class RelayHub {
318
334
  this.denied = null
319
335
  this.xcounters = null
320
336
  this.xkey = null
337
+ this.gate = null
321
338
  this.buckets.clear()
322
339
  this.gone.clear()
323
340
  }
324
341
 
342
+ /**
343
+ * judges this account's own sends (client send and ask, bridge forward) before delivery; sends to other accounts are left to the
344
+ * cross-account gate. Without a policy, or with mode off, the hub holds no judge and creates no judge tables
345
+ */
346
+ configureJudge(policy: JudgePolicy | undefined, options: HubJudgeOptions = { env: {} }): void {
347
+ this.gate = null
348
+ this.judgeConfig = policy && policy.mode !== "off" ? { policy, options } : null
349
+ }
350
+
351
+ /** the held messages, newest first, without bodies */
352
+ holds(state?: string): HoldView[] {
353
+ return this.judged().holds(state)
354
+ }
355
+
356
+ /** one hold with its body while it waits */
357
+ holdShow(id: string): ReturnType<JudgeGate["show"]> {
358
+ return this.judged().show(id)
359
+ }
360
+
361
+ /** delivers a held message as its sender, every send check run again */
362
+ holdRelease(id: string, options: { redacted?: boolean; by: string }): Promise<SendResult> {
363
+ return this.judged().release(id, options, this.deliverJudged)
364
+ }
365
+
366
+ /** refuses a held message: never delivered, its sender told why */
367
+ holdRefuse(id: string, reason: string, by: string): HoldView {
368
+ return this.judged().refuse(id, reason, by)
369
+ }
370
+
325
371
  /** a fresh connection state for an accepted socket */
326
372
  static connection(grant: HubGrant): HubConn {
327
373
  return { id: newId("conn"), grant, role: "new", machine: null, endpoint: null, watching: false, subscribed: false }
@@ -483,6 +529,7 @@ export class RelayHub {
483
529
  this.gone.clear()
484
530
  for (const [key, bucket] of this.buckets) if (bucket.tokens + ((now - bucket.at) * bucket.rate) / 60_000 >= bucket.rate) this.buckets.delete(key)
485
531
  this.xcounters?.prune()
532
+ this.judgeGate()?.sweep()
486
533
  this.setState("last_sweep", now)
487
534
  }
488
535
  if (this.idle(now)) {
@@ -835,11 +882,12 @@ export class RelayHub {
835
882
  const send = { ...(params as unknown as SendParams) }
836
883
  delete (send as Params).wait_s
837
884
  if (method === "ask") send.kind = "request"
838
- const bytes = this.room(me, send.body)
839
- const result = core.within(conn.machine, () => core.send(me, send, conn.tty === undefined ? {} : { tty: conn.tty }))
840
- if (!result.duplicate) this.stash(bytes)
841
- if (!result.duplicate) this.delivered(me, result, send.kind ?? "message")
842
- if (method === "send") return result
885
+ const origin = conn.tty === undefined ? {} : { tty: conn.tty }
886
+ const gate = this.judgeGate()
887
+ const result = gate
888
+ ? await gate.send(me, send, { ...origin, machine: conn.machine }, this.deliverJudged)
889
+ : this.deliverOn(conn.machine, me, send, origin)
890
+ if (method === "send" || result.held) return result
843
891
  return this.awaitReply(socket, me, result.id, this.waitMs(socket, params.wait_s), result)
844
892
  }
845
893
  case "ask_wait": {
@@ -915,11 +963,10 @@ export class RelayHub {
915
963
  delete send.attachments
916
964
  const floor = Math.min(asked as number, LIMITS.hopMax + 1)
917
965
  if (floor > 0) core.markReceived(sender, floor - 1)
918
- const bytes = this.room(sender, send.body)
919
- const result = core.within(machine, () => core.send(sender, send as unknown as SendParams, { tty: params.tty === true }))
920
- if (!result.duplicate) this.stash(bytes)
921
- if (!result.duplicate) this.delivered(sender, result, (send.kind as string | undefined) ?? "message")
922
- return result
966
+ const gate = this.judgeGate()
967
+ const origin = { tty: params.tty === true }
968
+ if (gate) return gate.send(sender, send as unknown as SendParams, { ...origin, machine }, this.deliverJudged)
969
+ return this.deliverOn(machine, sender, send as unknown as SendParams, origin)
923
970
  }
924
971
  case "subscribe": {
925
972
  const after = params.after ?? 0
@@ -1273,6 +1320,49 @@ export class RelayHub {
1273
1320
  )
1274
1321
  }
1275
1322
 
1323
+ // judge
1324
+
1325
+ /** the judge, made on first use once configured; null when it is not */
1326
+ private judgeGate(): JudgeGate | null {
1327
+ if (!this.judgeConfig) return null
1328
+ if (this.gate) return this.gate
1329
+ const core = this.relay
1330
+ const { policy, options } = this.judgeConfig
1331
+ const gate = new JudgeGate({
1332
+ sql: this.deps.sql,
1333
+ core,
1334
+ now: this.now,
1335
+ env: options.env,
1336
+ client: options.client,
1337
+ limits: options.limits,
1338
+ log: (line) => this.deps.log?.("relay_judge", { account: this.account, line }),
1339
+ wake: (endpoint) => this.wake(endpoint),
1340
+ notice: (endpoint, text) => this.xnotice(endpoint, text),
1341
+ within: (origin, fn) => core.within(origin.machine ?? null, fn),
1342
+ })
1343
+ gate.configure(policy)
1344
+ this.gate = gate
1345
+ return gate
1346
+ }
1347
+
1348
+ private judged(): JudgeGate {
1349
+ const gate = this.judgeGate()
1350
+ if (!gate) throw new RelayError("not_found", "this relay has no judge")
1351
+ return gate
1352
+ }
1353
+
1354
+ /** a send of this account as it runs unjudged: room for the body, the core send on the sender's machine, then push */
1355
+ private deliverOn(machine: string | null, from: string, send: SendParams, origin: { tty?: boolean }): SendResult {
1356
+ const core = this.relay
1357
+ const bytes = this.room(from, send.body)
1358
+ const result = core.within(machine, () => core.send(from, send, origin))
1359
+ if (!result.duplicate) this.stash(bytes)
1360
+ if (!result.duplicate) this.delivered(from, result, send.kind ?? "message")
1361
+ return result
1362
+ }
1363
+
1364
+ private readonly deliverJudged: Deliver = async (from, send, { machine, ...origin }) => this.deliverOn(machine ?? null, from, send, origin)
1365
+
1276
1366
  // push and wait
1277
1367
 
1278
1368
  private delivered(from: string, result: SendResult, kind: string): void {
@@ -1504,7 +1594,7 @@ export class RelayHub {
1504
1594
  /** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
1505
1595
  graph(minutes = GRAPH_MINUTES): HubGraph {
1506
1596
  const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX)
1507
- const endpoints = this.relay.list() as (EndpointView & { wake?: string | null })[]
1597
+ const endpoints = this.relay.list()
1508
1598
  const byId = new Map(endpoints.map((e) => [e.id, e.address]))
1509
1599
  const listed = new Set(byId.values())
1510
1600
  const nodes: GraphNode[] = endpoints.map((e) => ({
package/src/index.ts CHANGED
@@ -23,9 +23,20 @@ export {
23
23
  type HubAuth,
24
24
  type HubConn,
25
25
  type HubDeps,
26
+ type HubJudgeOptions,
26
27
  type HubGrant,
27
28
  type HubLimits,
28
29
  type HubRole,
29
30
  type HubSocket,
30
31
  type MachineRow,
31
32
  } from "./hub"
33
+ export {
34
+ type Deliver,
35
+ type HoldView,
36
+ JUDGE_LIMITS,
37
+ type JudgeClientFactory,
38
+ JudgeGate,
39
+ type JudgeGateOptions,
40
+ type JudgeLimits,
41
+ type Origin as JudgeOrigin,
42
+ } from "./judge-gate"