@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/dist/index.d.ts +45 -114
- package/dist/index.js +200 -529
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +8 -1
- package/package.json +3 -3
- package/src/bridge.ts +19 -3
- package/src/client.ts +2 -0
- package/src/index.ts +4 -2
- package/src/judge-gate.ts +14 -545
- package/src/server.ts +111 -24
- package/src/sessions.ts +39 -0
- package/src/testing.ts +10 -1
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
681
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/sessions.ts
ADDED
|
@@ -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: {
|
|
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
|
|