@scp-dev/broker 0.1.2 → 0.1.4

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/server.ts CHANGED
@@ -26,11 +26,12 @@ import {
26
26
  stripControl,
27
27
  } from "@scp-dev/core"
28
28
  import { FederatedCore } from "@scp-dev/hub"
29
- import { type Bridge, type BridgeConfig, remoteSend, startBridge } from "./bridge"
29
+ import { type Bridge, type BridgeConfig, type BridgeHooks, remoteSend, startBridge } from "./bridge"
30
30
  import { type Deliver, type JudgeClientFactory, JudgeGate } from "./judge-gate"
31
31
  import { releaseBrokerLock, takeBrokerLock } from "./lock"
32
32
  import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
33
33
  import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, privateDir, shortHostname, socketPath } from "./paths"
34
+ import { writeSessionMarker } from "./sessions"
34
35
  import { Conn } from "./socket"
35
36
  import { type Database, openDatabase } from "./sqlite"
36
37
 
@@ -59,6 +60,8 @@ export interface BrokerOptions {
59
60
  bridge?: BridgeConfig | undefined
60
61
  /** reads the bridge configuration again for bridge_relink; one that appeared or names another hub or machine restarts the broker */
61
62
  bridgeConfig?: (() => Promise<BridgeConfig | undefined>) | undefined
63
+ /** experimental: another link to other machines in place of the hub's, started only when `bridge` is unset */
64
+ link?: LinkFactory | undefined
62
65
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
63
66
  presenceEveryMs?: number | undefined
64
67
  /** where a judge model's key_env is read (default the broker's environment) */
@@ -67,6 +70,15 @@ export interface BrokerOptions {
67
70
  judgeClient?: JudgeClientFactory | undefined
68
71
  }
69
72
 
73
+ /** what a link gets from the broker: its core, the remote hooks a bridge gets, and a send as a local endpoint or a mirrored one through the judge */
74
+ export interface LinkContext {
75
+ core: FederatedCore
76
+ hooks: BridgeHooks
77
+ send: Deliver
78
+ }
79
+
80
+ export type LinkFactory = (context: LinkContext) => Bridge
81
+
70
82
  export interface Broker {
71
83
  socket: string
72
84
  core: FederatedCore
@@ -87,6 +99,8 @@ interface Connection {
87
99
  tty?: boolean
88
100
  /** the connection holds the human inbox */
89
101
  human?: boolean
102
+ /** a human hello bound to the agent of this shell's lineage said a terminal was attached: the human at that agent's shell */
103
+ terminal?: boolean
90
104
  /**
91
105
  * the lineage endpoint of an observer, or of a hello bound to it: the connection sees and acts with that endpoint's view,
92
106
  * never widening unless `own`, and never counts as connected
@@ -134,7 +148,7 @@ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"
134
148
  const ASK_RECHECK_MS = 1000
135
149
  /** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
136
150
  const STOP_RECHECK_MS = 1000
137
- const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"])
151
+ const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted", "title_infer"])
138
152
  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)`
139
153
 
140
154
  /** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
@@ -260,6 +274,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
260
274
  const known = new Map<string, Presence>()
261
275
  for (const view of localViews()) known.set(view.id, view.presence)
262
276
  const presenceTimer = setInterval(() => {
277
+ core.endExited()
263
278
  const live = new Set<string>()
264
279
  for (const view of localViews()) {
265
280
  live.add(view.id)
@@ -267,21 +282,17 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
267
282
  }
268
283
  for (const id of known.keys()) if (!live.has(id)) known.delete(id)
269
284
  }, options.presenceEveryMs ?? PRESENCE_MS)
270
- if (options.bridge) {
271
- bridge = startBridge({
272
- ...options.bridge,
273
- core,
274
- onRemote: {
275
- wake,
276
- fleet: notifyFleet,
277
- policyError: () => {
278
- loadPolicy()
279
- return policyError
280
- },
281
- log,
282
- },
283
- })
285
+ const remoteHooks: BridgeHooks = {
286
+ wake,
287
+ fleet: notifyFleet,
288
+ policyError: () => {
289
+ loadPolicy()
290
+ return policyError
291
+ },
292
+ log,
284
293
  }
294
+ if (options.bridge) bridge = startBridge({ ...options.bridge, core, onRemote: remoteHooks })
295
+ else if (options.link) bridge = options.link({ core, hooks: remoteHooks, send: (from, params, origin) => judge.send(from, params, origin, deliver) })
285
296
 
286
297
  /** links the bridge again with the credential on disk; a configuration this broker cannot take without a restart stops it */
287
298
  async function relink(): Promise<object> {
@@ -383,6 +394,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
383
394
  bridge?.changed(view.id)
384
395
  }
385
396
 
397
+ /** after a join, rename or leave: the session's marker, the fleet and the hub's mirror learn it; `was` is the handle before */
398
+ function membership(view: EndpointView, session: string | null, joined: boolean, was?: string) {
399
+ const handle = view.address.slice(view.address.lastIndexOf("/") + 1)
400
+ if (session && !session.startsWith("sub:")) {
401
+ try {
402
+ writeSessionMarker(dir, session, { joined, handle })
403
+ } catch (error) {
404
+ log(`relay broker: the session marker was not written: ${error instanceof Error ? error.message : String(error)}`)
405
+ }
406
+ }
407
+ known.set(view.id, view.presence)
408
+ notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
409
+ if (was !== undefined && was !== handle) bridge?.renamed(view.id, was)
410
+ else bridge?.changed(view.id)
411
+ }
412
+
386
413
  function notifyFleet(event: Record<string, unknown>) {
387
414
  const at = new Date((options.now ?? Date.now)()).toISOString()
388
415
  let line: Uint8Array | undefined
@@ -677,13 +704,27 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
677
704
  if (params.human === true || params.observe === true || params.resume !== undefined) {
678
705
  throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
679
706
  }
680
- const before = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]))
681
- const { endpoint, subagent, created } = core.claim((params.claim ?? {}) as Claim, { transient: true })
707
+ const join = params.join
708
+ if (join !== undefined && (typeof join !== "object" || join === null || Array.isArray(join)))
709
+ throw new RelayError("invalid_params", "join is {handle?, under?}")
710
+ const under = (join as { under?: unknown } | undefined)?.under
711
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view]))
712
+ const { endpoint, subagent, created } =
713
+ join === undefined
714
+ ? core.claim((params.claim ?? {}) as Claim, { transient: true })
715
+ : {
716
+ endpoint: core.joinSession((params.claim ?? {}) as Claim, (join as { handle?: unknown }).handle, under),
717
+ subagent: undefined,
718
+ created: undefined,
719
+ }
682
720
  s.data.endpoint = endpoint.id
683
721
  s.data.transient = true
684
722
  const key = (params.claim as Claim | undefined)?.process
685
723
  if (typeof key === "string") s.data.process = key
686
- if ((before.get(endpoint.id) ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
724
+ const was = before.get(endpoint.id)?.address
725
+ if (join !== undefined) membership(endpoint, core.sessionOf(endpoint.id), true, was?.slice(was.lastIndexOf("/") + 1))
726
+ if (under !== undefined && before.get(endpoint.id)?.parent !== endpoint.parent) bridge?.resync()
727
+ else if ((before.get(endpoint.id)?.presence ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
687
728
  return {
688
729
  endpoint,
689
730
  ...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
@@ -715,7 +756,10 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
715
756
  const claim = params.claim as Claim | undefined
716
757
  if (params.human === true && params.resume === undefined && params.lineage !== undefined) {
717
758
  const placed = core.placeLineage(params.lineage)
718
- if (placed) return bindLineage(s, placed, params)
759
+ if (placed) {
760
+ s.data.terminal = params.tty === true
761
+ return bindLineage(s, placed, params)
762
+ }
719
763
  } else if (params.human !== true && params.resume === undefined && claim?.process === undefined && typeof claim?.handle === "string") {
720
764
  const placed = claim.lineage === undefined ? null : core.placeLineage(claim.lineage, claim.handle)
721
765
  if (placed && (claim.grant === undefined || core.grantBinds(claim.grant, placed))) {
@@ -822,6 +866,37 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
822
866
  bridge?.changed(me)
823
867
  return view
824
868
  }
869
+ case "title_infer": {
870
+ const changed = core.inferTitle(me, params, hookOf(s))
871
+ if (changed) bridge?.changed(me)
872
+ return { changed }
873
+ }
874
+ case "rename": {
875
+ const renamed = core.rename(actorOf(s), { ...(params.endpoint !== undefined ? { endpoint: params.endpoint } : {}), handle: params.handle })
876
+ membership(renamed.endpoint, renamed.session, true, renamed.from)
877
+ return renamed.endpoint
878
+ }
879
+ case "adopt": {
880
+ const actor = actorOf(s)
881
+ const adopted = core.adopt(
882
+ actor,
883
+ { endpoint: params.endpoint, ...(params.parent !== undefined ? { parent: params.parent } : {}) },
884
+ { tty: actor.kind === "endpoint" && s.data.terminal === true },
885
+ )
886
+ bridge?.resync()
887
+ return adopted
888
+ }
889
+ case "unlink": {
890
+ const unlinked = core.unlink(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
891
+ bridge?.resync()
892
+ return unlinked
893
+ }
894
+ case "leave": {
895
+ const left = core.leaveSession(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
896
+ for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end()
897
+ membership(left.endpoint, left.session, false)
898
+ return left.endpoint
899
+ }
825
900
  case "status": {
826
901
  core.status(me, params.state, params.note)
827
902
  const view = core.whoami(me)
@@ -837,14 +912,25 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
837
912
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
838
913
  return row
839
914
  }
840
- case "inject":
841
- return core.inject(me, {
915
+ case "inject": {
916
+ const waitMs = waitSeconds(params.wait_s)
917
+ const options = {
842
918
  stop: params.stop === true,
843
919
  count: params.count === true,
844
920
  holdUnread: params.hold_unread === true,
845
921
  directives: params.directives === true,
922
+ wake: waitMs > 0 && params.stop !== true && params.count !== true,
846
923
  ...hookOf(s),
847
- })
924
+ }
925
+ const deadline = Date.now() + (options.wake ? waitMs : 0)
926
+ let result = core.inject(me, options)
927
+ while (!result.offered.length && !s.data.closed && Date.now() < deadline) {
928
+ await waitFor(me, deadline - Date.now(), s)
929
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
930
+ result = core.inject(me, options)
931
+ }
932
+ return result
933
+ }
848
934
  case "stop_wait": {
849
935
  const waitMs = waitSeconds(params.wait_s)
850
936
  const deadline = Date.now() + waitMs
@@ -863,7 +949,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
863
949
  case "prompted": {
864
950
  const prompted = core.prompted(me, hookOf(s))
865
951
  if (prompted) wake(me)
866
- return { prompted }
952
+ const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every)
953
+ return { prompted, ...(tldr ? { tldr } : {}) }
867
954
  }
868
955
  case "injected":
869
956
  return {
@@ -0,0 +1,39 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ /**
6
+ * What the broker last decided about a harness session, by its session key: whether it joined or left, and the handle it goes by. Hooks and
7
+ * the MCP shim read it before they claim, so a session that never joined (manual join mode) or left stays off the relay without a broker
8
+ * round trip, and a renamed session claims its own name. The broker writes it; the endpoint rows stay the source of truth.
9
+ */
10
+ export interface SessionMarker {
11
+ joined: boolean
12
+ handle?: string
13
+ }
14
+
15
+ function markerPath(dir: string, session: string): string {
16
+ return join(dir, "sessions", `${createHash("sha256").update(session).digest("hex").slice(0, 32)}.json`)
17
+ }
18
+
19
+ export function readSessionMarker(dir: string, session: string): SessionMarker | undefined {
20
+ try {
21
+ const value = JSON.parse(readFileSync(markerPath(dir, session), "utf8")) as Partial<SessionMarker>
22
+ if (typeof value?.joined !== "boolean") return undefined
23
+ return { joined: value.joined, ...(typeof value.handle === "string" ? { handle: value.handle } : {}) }
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export function writeSessionMarker(dir: string, session: string, marker: SessionMarker): void {
30
+ const path = markerPath(dir, session)
31
+ mkdirSync(join(dir, "sessions"), { recursive: true, mode: 0o700 })
32
+ const temp = `${path}.${randomUUID()}.tmp`
33
+ try {
34
+ writeFileSync(temp, JSON.stringify(marker), { mode: 0o600 })
35
+ renameSync(temp, path)
36
+ } finally {
37
+ rmSync(temp, { force: true })
38
+ }
39
+ }
package/src/testing.ts CHANGED
@@ -7,6 +7,8 @@ export interface FakeToken {
7
7
  tokenId: string
8
8
  /** the account handle the token acts as */
9
9
  user: string
10
+ /** the org member the token belongs to; none is the personal account's owner */
11
+ actor?: string
10
12
  }
11
13
 
12
14
  export interface FakeHubServer {
@@ -88,7 +90,14 @@ export function startFakeHubServer(options: {
88
90
  if (!token) return null
89
91
  return {
90
92
  account: token.user,
91
- grant: { tokenId: token.tokenId, userId: `usr_${token.user}`, actorId: null, expiresAt: Date.now() + 3_600_000, scoped: false, role: null },
93
+ grant: {
94
+ tokenId: token.tokenId,
95
+ userId: `usr_${token.user}`,
96
+ actorId: token.actor ?? null,
97
+ expiresAt: Date.now() + 3_600_000,
98
+ scoped: false,
99
+ role: token.actor ? "member" : null,
100
+ },
92
101
  }
93
102
  }
94
103