@huynle/hub 0.6.0 → 0.7.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/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Hub-minted session tokens (design §1.3): a bearer credential for one session of one machine, which the hub
|
|
2
|
+
// pushes to the spoke reporting the session (sessionTokens op, see ../session-token-push.ts) and the spoke hands
|
|
3
|
+
// to the session's shells.
|
|
4
|
+
//
|
|
5
|
+
// Format: `hst1.<b64url(payload JSON)>.<b64url(HMAC-SHA256(secret of kid, "hst1." + payload part))>`, where the
|
|
6
|
+
// payload is {typ, uid, mid, sid, exp (ms since the epoch), kid, ue (user epoch), me (machine epoch), aud (the
|
|
7
|
+
// hub's public URL)}. One algorithm, one prefix; anything else is not a session token.
|
|
8
|
+
//
|
|
9
|
+
// typ "session" is the shell token (HUB_SESSION_TOKEN; any code in the session's shells can read it): its actor
|
|
10
|
+
// is via "shell" and can never act. typ "act" (phase 3, decision #1) is the act token, pushed only to spokes
|
|
11
|
+
// announcing "actTokens" (actTokens op), which never put it in any environment; its actor is via "act", the only
|
|
12
|
+
// session actor act verbs may admit (canActAsSession). The signature covers typ, so one cannot be turned into the other.
|
|
13
|
+
//
|
|
14
|
+
// A token is accepted while all of this holds: signature (constant-time) under a known kid, audience, expiry,
|
|
15
|
+
// the user's and machine's current token epochs, the machine exists, is not revoked and still belongs to uid,
|
|
16
|
+
// the user exists (the resolver refuses disabled users), and that machine still reports the session for uid.
|
|
17
|
+
// Tokens and secrets are never logged and never appear in this object's JSON or inspect output.
|
|
18
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
19
|
+
import { SESSION_TOKEN_MAX_CHARS } from "../../protocol"
|
|
20
|
+
import type { Store } from "../store"
|
|
21
|
+
import type { TokenKeys } from "../token-keys"
|
|
22
|
+
import type { BearerVerifier, SessionActor, VerifyResult } from "./actor"
|
|
23
|
+
|
|
24
|
+
export const SESSION_TOKEN_PREFIX = "hst1"
|
|
25
|
+
/** How long a session token is valid. */
|
|
26
|
+
export const SESSION_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/** What the verifier needs to know about live sessions (implemented by the registry). */
|
|
29
|
+
export interface SessionLiveness {
|
|
30
|
+
/** Tenant (user ID) of a reported session (Registry.sessionTenant). */
|
|
31
|
+
sessionTenant(sessionID: string): string | undefined
|
|
32
|
+
/** Whether a connected process of the machine reports the session (for the machine's tenant). */
|
|
33
|
+
machineReports(machineID: string, sessionID: string): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionTokensOptions {
|
|
37
|
+
readonly keys: TokenKeys
|
|
38
|
+
readonly store: Store
|
|
39
|
+
readonly sessions: SessionLiveness
|
|
40
|
+
/** The hub's public URL: the tokens' audience. */
|
|
41
|
+
readonly publicURL: string
|
|
42
|
+
readonly now?: () => number
|
|
43
|
+
readonly lifetimeMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MintedSessionToken {
|
|
47
|
+
readonly token: string
|
|
48
|
+
/** Expiry, ms since the epoch. */
|
|
49
|
+
readonly exp: number
|
|
50
|
+
/** The machine's stamp when minted (see SessionTokens.stamp). */
|
|
51
|
+
readonly stamp: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Token types: "session" for shells, "act" for act verbs (the hub tool). */
|
|
55
|
+
export type SessionTokenType = "session" | "act"
|
|
56
|
+
|
|
57
|
+
interface Claims {
|
|
58
|
+
readonly typ: SessionTokenType
|
|
59
|
+
readonly uid: string
|
|
60
|
+
readonly mid: string
|
|
61
|
+
readonly sid: string
|
|
62
|
+
readonly exp: number
|
|
63
|
+
readonly kid: string
|
|
64
|
+
readonly ue: number
|
|
65
|
+
readonly me: number
|
|
66
|
+
readonly aud: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CLAIM_KEYS = ["typ", "uid", "mid", "sid", "exp", "kid", "ue", "me", "aud"].sort().join()
|
|
70
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
71
|
+
/** HMAC-SHA256 output length. */
|
|
72
|
+
const SIG_BYTES = 32
|
|
73
|
+
|
|
74
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.length > 0
|
|
75
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
|
|
76
|
+
|
|
77
|
+
/** Canonical base64url only (no padding, no other alphabet, no non-zero trailing bits). */
|
|
78
|
+
function decode(part: string): Buffer | undefined {
|
|
79
|
+
if (!B64URL.test(part)) return undefined
|
|
80
|
+
const bytes = Buffer.from(part, "base64url")
|
|
81
|
+
return bytes.toString("base64url") === part ? bytes : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseClaims(bytes: Buffer): Claims | undefined {
|
|
85
|
+
let raw: unknown
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(bytes.toString("utf8"))
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
|
|
92
|
+
const c = raw as Record<string, unknown>
|
|
93
|
+
if (Object.keys(c).sort().join() !== CLAIM_KEYS) return undefined
|
|
94
|
+
if ((c.typ !== "session" && c.typ !== "act") || !text(c.uid) || !text(c.mid) || !text(c.sid) || !text(c.kid) || !text(c.aud)) return undefined
|
|
95
|
+
if (!count(c.exp) || !count(c.ue) || !count(c.me)) return undefined
|
|
96
|
+
return c as unknown as Claims
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sign = (secret: Buffer, payload: string): Buffer => createHmac("sha256", secret).update(`${SESSION_TOKEN_PREFIX}.${payload}`).digest()
|
|
100
|
+
|
|
101
|
+
export class SessionTokens {
|
|
102
|
+
readonly lifetimeMs: number
|
|
103
|
+
readonly verifier: BearerVerifier
|
|
104
|
+
readonly #keys: TokenKeys
|
|
105
|
+
readonly #store: Store
|
|
106
|
+
readonly #sessions: SessionLiveness
|
|
107
|
+
readonly #aud: string
|
|
108
|
+
readonly #now: () => number
|
|
109
|
+
|
|
110
|
+
constructor(options: SessionTokensOptions) {
|
|
111
|
+
this.#keys = options.keys
|
|
112
|
+
this.#store = options.store
|
|
113
|
+
this.#sessions = options.sessions
|
|
114
|
+
this.#aud = options.publicURL
|
|
115
|
+
this.#now = options.now ?? Date.now
|
|
116
|
+
this.lifetimeMs = options.lifetimeMs ?? SESSION_TOKEN_LIFETIME_MS
|
|
117
|
+
this.verifier = { kind: "session", verify: (token) => this.verifyFresh(token) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The owner of an enrolled, unrevoked machine whose user exists and is enabled. */
|
|
121
|
+
#owner(machineID: string): string | undefined {
|
|
122
|
+
const machine = this.#store.machines().find((m) => m.id === machineID)
|
|
123
|
+
if (!machine || machine.revoked) return undefined
|
|
124
|
+
const user = this.#store.user(machine.ownerID)
|
|
125
|
+
return user && !user.disabled ? user.id : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a machine's tokens depend on besides the session: owner, active kid, user and machine epochs. Tokens
|
|
130
|
+
* minted under another stamp are stale (resend them); undefined when the machine cannot get tokens.
|
|
131
|
+
*/
|
|
132
|
+
stamp(machineID: string): string | undefined {
|
|
133
|
+
const uid = this.#owner(machineID)
|
|
134
|
+
if (!uid) return undefined
|
|
135
|
+
return JSON.stringify([uid, this.#keys.active().kid, this.#store.userEpoch(uid), this.#store.machineEpoch(machineID)])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A token for a session of the machine's owner (undefined when the machine is unknown, revoked or its owner
|
|
140
|
+
* disabled, or the session belongs to another tenant or to nobody).
|
|
141
|
+
*/
|
|
142
|
+
mint(machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
143
|
+
return this.#mint("session", machineID, sessionID)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** An act token (typ "act") for the session, under the same rules as mint. Only for the actTokens op. */
|
|
147
|
+
mintAct(machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
148
|
+
return this.#mint("act", machineID, sessionID)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#mint(typ: SessionTokenType, machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
152
|
+
const uid = this.#owner(machineID)
|
|
153
|
+
if (!uid || this.#sessions.sessionTenant(sessionID) !== uid) return undefined
|
|
154
|
+
const { kid, secret } = this.#keys.active()
|
|
155
|
+
const exp = this.#now() + this.lifetimeMs
|
|
156
|
+
const claims: Claims = {
|
|
157
|
+
typ,
|
|
158
|
+
uid,
|
|
159
|
+
mid: machineID,
|
|
160
|
+
sid: sessionID,
|
|
161
|
+
exp,
|
|
162
|
+
kid,
|
|
163
|
+
ue: this.#store.userEpoch(uid),
|
|
164
|
+
me: this.#store.machineEpoch(machineID),
|
|
165
|
+
aud: this.#aud,
|
|
166
|
+
}
|
|
167
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
|
|
168
|
+
const stamp = JSON.stringify([uid, kid, claims.ue, claims.me])
|
|
169
|
+
return { token: `${SESSION_TOKEN_PREFIX}.${payload}.${sign(secret, payload).toString("base64url")}`, exp, stamp }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The session actor of a valid token, "expired" for a genuine token past its expiry, else undefined (from memory). */
|
|
173
|
+
verify(token: string): VerifyResult {
|
|
174
|
+
const claims = this.#genuine(token)
|
|
175
|
+
return claims && claims !== "expired" ? this.#current(claims) : claims
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* verify, after reading the token's user and machine again (a shared store's security re-check: a revocation,
|
|
180
|
+
* epoch bump or disabled user on another pod applies at once). The bearer verifier uses this.
|
|
181
|
+
*/
|
|
182
|
+
async verifyFresh(token: string): Promise<VerifyResult> {
|
|
183
|
+
const claims = this.#genuine(token)
|
|
184
|
+
if (!claims || claims === "expired") return claims
|
|
185
|
+
await this.#store.recheck({ users: [claims.uid], machines: [claims.mid] })
|
|
186
|
+
return this.#current(claims)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The claims of a token signed with a known key for this hub, "expired" past its expiry, else undefined. */
|
|
190
|
+
#genuine(token: string): Claims | "expired" | undefined {
|
|
191
|
+
if (typeof token !== "string" || token.length > SESSION_TOKEN_MAX_CHARS) return undefined
|
|
192
|
+
const parts = token.split(".")
|
|
193
|
+
if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX) return undefined
|
|
194
|
+
const [, payload, sigPart] = parts as [string, string, string]
|
|
195
|
+
const body = decode(payload)
|
|
196
|
+
const sig = decode(sigPart)
|
|
197
|
+
if (!body || !sig || sig.length !== SIG_BYTES) return undefined
|
|
198
|
+
const claims = parseClaims(body)
|
|
199
|
+
if (!claims) return undefined
|
|
200
|
+
const secret = this.#keys.key(claims.kid)
|
|
201
|
+
if (!secret) return undefined
|
|
202
|
+
const expected = sign(secret, payload)
|
|
203
|
+
if (expected.length !== sig.length || !timingSafeEqual(expected, sig)) return undefined
|
|
204
|
+
if (claims.aud !== this.#aud) return undefined
|
|
205
|
+
if (this.#now() >= claims.exp) return "expired"
|
|
206
|
+
return claims
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The session actor while the token's epochs, machine, user and session are all still current. */
|
|
210
|
+
#current(claims: Claims): VerifyResult {
|
|
211
|
+
if (claims.ue !== this.#store.userEpoch(claims.uid) || claims.me !== this.#store.machineEpoch(claims.mid)) return undefined
|
|
212
|
+
const machine = this.#store.machines().find((m) => m.id === claims.mid)
|
|
213
|
+
if (!machine || machine.revoked || machine.ownerID !== claims.uid) return undefined
|
|
214
|
+
if (!this.#store.user(claims.uid)) return undefined
|
|
215
|
+
if (this.#sessions.sessionTenant(claims.sid) !== claims.uid || !this.#sessions.machineReports(claims.mid, claims.sid)) return undefined
|
|
216
|
+
const actor: SessionActor = { kind: "session", userID: claims.uid, machineID: claims.mid, sessionID: claims.sid, via: claims.typ === "act" ? "act" : "shell" }
|
|
217
|
+
return actor
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
toJSON(): { audience: string; lifetimeMs: number } {
|
|
221
|
+
return { audience: this.#aud, lifetimeMs: this.lifetimeMs }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
225
|
+
return `SessionTokens(${this.#aud})`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Read-modify-write over a StateStore key with optimistic versions: retried when another writer got there first.
|
|
2
|
+
import type { StateStore } from "../backend/types"
|
|
3
|
+
|
|
4
|
+
/** Attempts before a contended update gives up (each one re-reads the key). */
|
|
5
|
+
const ATTEMPTS = 16
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Applies `change` to the key's current value (undefined: missing) and writes the result with ifVersion.
|
|
9
|
+
* `change` returning undefined leaves the key alone. Resolves with the written value (or undefined).
|
|
10
|
+
*/
|
|
11
|
+
export async function updateVersioned<T>(
|
|
12
|
+
state: StateStore,
|
|
13
|
+
userID: string,
|
|
14
|
+
collection: string,
|
|
15
|
+
key: string,
|
|
16
|
+
change: (current: T | undefined) => T | undefined,
|
|
17
|
+
): Promise<T | undefined> {
|
|
18
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
19
|
+
const current = await state.get<T>(userID, collection, key)
|
|
20
|
+
const next = change(current?.value)
|
|
21
|
+
if (next === undefined) return undefined
|
|
22
|
+
const result = await state.put(userID, collection, key, next, { ifVersion: current?.version ?? 0 })
|
|
23
|
+
if (result.ok) return next
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`state update of ${collection}/${key} kept conflicting`)
|
|
26
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// GET /api/agent/watch (design §4): a lite SSE stream of compact JSON events about one user's sessions, for
|
|
2
|
+
// orchestrator loops. Not the console's /api/events (cookie/device keyed, with replay and streamed text): this one
|
|
3
|
+
// is per actor, has no replay and never carries text deltas.
|
|
4
|
+
//
|
|
5
|
+
// ?blocked=1 blocker events only
|
|
6
|
+
// ?session=<addr> only that session's tree (an address resolved for the caller; several for a @@role)
|
|
7
|
+
//
|
|
8
|
+
// Events (each `data:` line one JSON object with `type` and `at`):
|
|
9
|
+
// {type: "ready"} first, once subscribed
|
|
10
|
+
// {type: "state", sessionID, rootID, state, prev?} a session's own state changed
|
|
11
|
+
// {type: "blocker.opened", sessionID, rootID, blocker: {kind, id, summary}}
|
|
12
|
+
// {type: "blocker.closed", sessionID, rootID, id} answered, cancelled or gone
|
|
13
|
+
// {type: "session.added", sessionID, title} / {type: "session.removed", sessionID} catalog changes
|
|
14
|
+
// {type: "message", id, kind, state, from, to, sessionID?, senderID?, replyTo?, delivery?} an agent message was
|
|
15
|
+
// stored (no text): sessionID is the recipient session, senderID the sending one. The user sees all of them,
|
|
16
|
+
// a session or app (messages:read) only those it sent or received; ?session= matches either session.
|
|
17
|
+
// {type: "launch", id, state, reason, outcome?, sessionID?, name?, labels} a launch changed (the user sees all of
|
|
18
|
+
// theirs, an app or session those it started); ?session= matches its session.
|
|
19
|
+
// When the hub drains (shutting down), the stream ends with `retry: 2000` (reconnect, to another pod, after it).
|
|
20
|
+
// A `ping` event every heartbeat (default 20 s). Only status messages of the caller's tenant are read; catalog
|
|
21
|
+
// changes are diffed against the caller's own sessions.
|
|
22
|
+
import type { LiveStatus, RegistryMessage } from "../registry"
|
|
23
|
+
import { partyKey } from "./envelope"
|
|
24
|
+
import { addressOf, type Message } from "./messages"
|
|
25
|
+
import type { Launch } from "./launch-store"
|
|
26
|
+
import type { ReadRegistry } from "./read-routes"
|
|
27
|
+
|
|
28
|
+
export const WATCH_HEARTBEAT_MS = 20_000
|
|
29
|
+
/** Draining: the `retry:` (ms) an ending watch stream sends. */
|
|
30
|
+
export const WATCH_RETRY_MS = 2_000
|
|
31
|
+
const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
|
|
32
|
+
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
|
|
33
|
+
|
|
34
|
+
export interface WatchRegistry extends Pick<ReadRegistry, "catalog" | "sessionTenant"> {
|
|
35
|
+
subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stored agent messages as they are sent (MessageFeed). */
|
|
39
|
+
export interface MessageSource {
|
|
40
|
+
subscribe(listener: (userID: string, message: Message) => void): () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Launch changes as the engine makes them (LaunchFeed). */
|
|
44
|
+
export interface LaunchSource {
|
|
45
|
+
subscribe(listener: (userID: string, launch: Launch) => void): () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The compact watch event of a launch change (never its prompt). */
|
|
49
|
+
function launchEvent(launch: Launch): WatchEvent {
|
|
50
|
+
return {
|
|
51
|
+
type: "launch",
|
|
52
|
+
id: launch.id,
|
|
53
|
+
state: launch.state,
|
|
54
|
+
reason: launch.reason,
|
|
55
|
+
...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
|
|
56
|
+
...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
|
|
57
|
+
...(launch.name !== undefined ? { name: launch.name } : {}),
|
|
58
|
+
labels: launch.spec.labels,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WatchOptions {
|
|
63
|
+
readonly registry: WatchRegistry
|
|
64
|
+
readonly userID: string
|
|
65
|
+
readonly signal: AbortSignal
|
|
66
|
+
/** Only blocker events. */
|
|
67
|
+
readonly blocked?: boolean
|
|
68
|
+
/** Only events of these sessions or of trees rooted at them. */
|
|
69
|
+
readonly sessions?: ReadonlySet<string>
|
|
70
|
+
/** Message events: from this feed, only those of the party key (undefined: all of the user's). */
|
|
71
|
+
readonly messages?: { readonly feed: MessageSource; readonly party?: string }
|
|
72
|
+
/** Launch events: from this feed, only those launched by `by` (undefined: all of the user's). */
|
|
73
|
+
readonly launches?: { readonly feed: LaunchSource; readonly by?: string }
|
|
74
|
+
readonly heartbeatMs?: number
|
|
75
|
+
readonly now?: () => number
|
|
76
|
+
/** Aborted when the hub drains: the stream ends with a `retry:` hint. */
|
|
77
|
+
readonly draining?: AbortSignal
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The compact watch event of a message (never its text). */
|
|
81
|
+
function messageEvent(message: Message): WatchEvent {
|
|
82
|
+
const { id, kind, state, from, recipient, replyTo, delivery } = message
|
|
83
|
+
return {
|
|
84
|
+
type: "message",
|
|
85
|
+
id,
|
|
86
|
+
kind,
|
|
87
|
+
state,
|
|
88
|
+
from: addressOf(from),
|
|
89
|
+
to: message.to,
|
|
90
|
+
...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
|
|
91
|
+
...(from.kind === "session" ? { senderID: from.sessionID } : {}),
|
|
92
|
+
...(replyTo ? { replyTo } : {}),
|
|
93
|
+
...(delivery ? { delivery } : {}),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Known {
|
|
98
|
+
state: string
|
|
99
|
+
blockers: Set<string>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type WatchEvent = { type: string; sessionID?: string; rootID?: string; senderID?: string } & Record<string, unknown>
|
|
103
|
+
|
|
104
|
+
export function watchStream(options: WatchOptions): Response {
|
|
105
|
+
if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
|
|
106
|
+
const { registry, userID } = options
|
|
107
|
+
const now = options.now ?? Date.now
|
|
108
|
+
const encoder = new TextEncoder()
|
|
109
|
+
/** Per session (roots and subagents): its own state and open blocker IDs. */
|
|
110
|
+
const known = new Map<string, Known>()
|
|
111
|
+
/** Catalog sessions of the user, ID → title. */
|
|
112
|
+
let catalog = new Map<string, string>()
|
|
113
|
+
|
|
114
|
+
const own = () => {
|
|
115
|
+
const out = new Map<string, { title: string; status: LiveStatus[] }>()
|
|
116
|
+
for (const machine of registry.catalog()) {
|
|
117
|
+
for (const process of machine.processes) {
|
|
118
|
+
for (const s of process.sessions) {
|
|
119
|
+
if (!out.has(s.id) && registry.sessionTenant(s.id) === userID) out.set(s.id, { title: s.title, status: s.status })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
const remember = (entry: LiveStatus) => known.set(entry.id, { state: entry.state, blockers: new Set(entry.blockers.map((b) => b.id)) })
|
|
126
|
+
const seed = () => {
|
|
127
|
+
const sessions = own()
|
|
128
|
+
for (const { status } of sessions.values()) for (const entry of status) if (!known.has(entry.id)) remember(entry)
|
|
129
|
+
return sessions
|
|
130
|
+
}
|
|
131
|
+
catalog = new Map([...seed()].map(([id, s]) => [id, s.title]))
|
|
132
|
+
|
|
133
|
+
const wanted = (event: WatchEvent) => {
|
|
134
|
+
if (options.blocked && !event.type.startsWith("blocker.")) return false
|
|
135
|
+
if (!options.sessions) return true
|
|
136
|
+
return [event.sessionID, event.rootID, event.senderID].some((id) => id !== undefined && options.sessions!.has(id))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Events for a status tree (root first), against what was known. */
|
|
140
|
+
const statusEvents = (rootID: string, sessions: readonly LiveStatus[]): WatchEvent[] => {
|
|
141
|
+
const out: WatchEvent[] = []
|
|
142
|
+
for (const entry of sessions) {
|
|
143
|
+
const before = known.get(entry.id)
|
|
144
|
+
const open = new Set(entry.blockers.map((b) => b.id))
|
|
145
|
+
for (const b of entry.blockers) {
|
|
146
|
+
if (!before?.blockers.has(b.id)) out.push({ type: "blocker.opened", sessionID: entry.id, rootID, blocker: { kind: b.kind, id: b.id, summary: b.summary } })
|
|
147
|
+
}
|
|
148
|
+
for (const id of before?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", sessionID: entry.id, rootID, id })
|
|
149
|
+
if (before?.state !== entry.state) out.push({ type: "state", sessionID: entry.id, rootID, state: entry.state, ...(before ? { prev: before.state } : {}) })
|
|
150
|
+
remember(entry)
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const catalogEvents = (): WatchEvent[] => {
|
|
156
|
+
const sessions = seed()
|
|
157
|
+
const out: WatchEvent[] = []
|
|
158
|
+
for (const [id, s] of sessions) if (!catalog.has(id)) out.push({ type: "session.added", sessionID: id, title: s.title })
|
|
159
|
+
for (const id of catalog.keys()) if (!sessions.has(id)) out.push({ type: "session.removed", sessionID: id })
|
|
160
|
+
catalog = new Map([...sessions].map(([id, s]) => [id, s.title]))
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let cleanup = () => {}
|
|
165
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
166
|
+
start(controller) {
|
|
167
|
+
const write = (chunk: string) => {
|
|
168
|
+
try {
|
|
169
|
+
controller.enqueue(encoder.encode(chunk))
|
|
170
|
+
} catch {
|
|
171
|
+
cleanup()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const send = (event: WatchEvent) => write(`data: ${JSON.stringify({ ...event, at: now() })}\n\n`)
|
|
175
|
+
const unsubscribe = registry.subscribe((message, tenant) => {
|
|
176
|
+
let events: WatchEvent[] = []
|
|
177
|
+
if (message.type === "catalog") events = catalogEvents()
|
|
178
|
+
else if (message.type === "status" && tenant === userID) events = statusEvents(message.sessionID, message.sessions)
|
|
179
|
+
for (const event of events) if (wanted(event)) send(event)
|
|
180
|
+
})
|
|
181
|
+
const messages = options.messages
|
|
182
|
+
const unsubscribeMessages = messages?.feed.subscribe((user, message) => {
|
|
183
|
+
if (user !== userID) return
|
|
184
|
+
if (messages.party !== undefined && partyKey(message.from) !== messages.party && partyKey(message.recipient) !== messages.party) return
|
|
185
|
+
const event = messageEvent(message)
|
|
186
|
+
if (wanted(event)) send(event)
|
|
187
|
+
})
|
|
188
|
+
const launches = options.launches
|
|
189
|
+
const unsubscribeLaunches = launches?.feed.subscribe((user, launch) => {
|
|
190
|
+
if (user !== userID || (launches.by !== undefined && launch.by !== launches.by)) return
|
|
191
|
+
const event = launchEvent(launch)
|
|
192
|
+
if (wanted(event)) send(event)
|
|
193
|
+
})
|
|
194
|
+
// A named event (not a comment) so clients can tell a live stream from a dead one.
|
|
195
|
+
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), options.heartbeatMs ?? WATCH_HEARTBEAT_MS)
|
|
196
|
+
cleanup = () => {
|
|
197
|
+
clearInterval(heartbeat)
|
|
198
|
+
unsubscribe()
|
|
199
|
+
unsubscribeMessages?.()
|
|
200
|
+
unsubscribeLaunches?.()
|
|
201
|
+
}
|
|
202
|
+
options.signal.addEventListener("abort", () => cleanup(), { once: true })
|
|
203
|
+
send({ type: "ready" })
|
|
204
|
+
options.draining?.addEventListener(
|
|
205
|
+
"abort",
|
|
206
|
+
() => {
|
|
207
|
+
write(RETRY)
|
|
208
|
+
cleanup()
|
|
209
|
+
try {
|
|
210
|
+
controller.close()
|
|
211
|
+
} catch {}
|
|
212
|
+
},
|
|
213
|
+
{ once: true },
|
|
214
|
+
)
|
|
215
|
+
},
|
|
216
|
+
cancel() {
|
|
217
|
+
cleanup()
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
return new Response(stream, { headers: SSE_HEADERS })
|
|
221
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The webhook API on /api/agent (P4-T10; decision znbg8xa5 #8), over Webhooks (webhooks.ts):
|
|
2
|
+
//
|
|
3
|
+
// POST /api/agent/webhooks {url, events?, label?} → 201 {webhook, secret} (the secret only here)
|
|
4
|
+
// GET /api/agent/webhooks → {webhooks}
|
|
5
|
+
// GET /api/agent/webhooks/:id → {webhook, deliveries} (recent deliveries, oldest first)
|
|
6
|
+
// DELETE /api/agent/webhooks/:id → 204 (its pending deliveries are dropped)
|
|
7
|
+
//
|
|
8
|
+
// Who: users (all of their registrations, their own and their app tokens'); app tokens with sessions:launch (the
|
|
9
|
+
// scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
|
|
10
|
+
// the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
|
|
11
|
+
// one the token did not make, is 404 not_found.
|
|
12
|
+
import type { Store } from "../store"
|
|
13
|
+
import type { AgentActor } from "./actor"
|
|
14
|
+
import { AgentError } from "./errors"
|
|
15
|
+
import type { AgentRoute } from "./routes"
|
|
16
|
+
import type { WebhookOwner, Webhooks } from "./webhooks"
|
|
17
|
+
|
|
18
|
+
// A literal: routes.ts imports this module.
|
|
19
|
+
const PATH = "/api/agent/webhooks"
|
|
20
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
21
|
+
/** Users; apps with sessions:launch. */
|
|
22
|
+
export const WEBHOOK_SCOPE = "sessions:launch"
|
|
23
|
+
const ALLOW = { scope: WEBHOOK_SCOPE } as const
|
|
24
|
+
|
|
25
|
+
export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
|
|
26
|
+
|
|
27
|
+
/** The registrations an actor manages: an app token its own, a user all (undefined). */
|
|
28
|
+
const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind === "app" ? `app:${actor.tokenID}` : undefined)
|
|
29
|
+
|
|
30
|
+
const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
|
|
31
|
+
|
|
32
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
33
|
+
const raw = JSON.parse(await request.text()) as unknown
|
|
34
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
35
|
+
return raw as Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audit">): AgentRoute[] {
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
method: "POST",
|
|
42
|
+
path: PATH,
|
|
43
|
+
allow: ALLOW,
|
|
44
|
+
async handle({ request, actor, user }) {
|
|
45
|
+
const input = await bodyOf(request)
|
|
46
|
+
const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
|
|
47
|
+
await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
|
|
48
|
+
return Response.json(result, { status: 201, headers: NO_STORE })
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: PATH,
|
|
54
|
+
allow: ALLOW,
|
|
55
|
+
handle: async ({ actor, user }) => Response.json({ webhooks: await webhooks.list(user.id, ownerOf(actor)) }, { headers: NO_STORE }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
method: "GET",
|
|
59
|
+
path: `${PATH}/:id`,
|
|
60
|
+
allow: ALLOW,
|
|
61
|
+
async handle({ params, actor, user }) {
|
|
62
|
+
const webhook = await webhooks.get(user.id, params.id!, ownerOf(actor))
|
|
63
|
+
if (!webhook) throw notFound(params.id!)
|
|
64
|
+
return Response.json({ webhook, deliveries: await webhooks.deliveries(user.id, webhook.id) }, { headers: NO_STORE })
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
method: "DELETE",
|
|
69
|
+
path: `${PATH}/:id`,
|
|
70
|
+
allow: ALLOW,
|
|
71
|
+
async handle({ params, actor, user }) {
|
|
72
|
+
if (!(await webhooks.remove(user.id, params.id!, ownerOf(actor)))) throw notFound(params.id!)
|
|
73
|
+
await store?.audit({ actor: user.id, action: "webhook.delete", target: params.id! })
|
|
74
|
+
return new Response(null, { status: 204, headers: NO_STORE })
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
}
|