@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,264 @@
1
+ // Launches per user (design §6.1, §9; decision znbg8xa5), stored in the StateStore collection "launches" under
2
+ // their id. The launch engine (P4-T8) drives them; this module owns the records, the state machine and idempotency.
3
+ // - States: queued → placing → provisioning → running → ended{outcome}. placing may fall back to queued (no node
4
+ // could take it yet); every live state may end; ended is final. Each step records its reason and time (history,
5
+ // bounded: the first step and the latest ones). An illegal step throws LaunchTransitionError (code gone when the
6
+ // launch already ended, else invalid) and changes nothing; writes are version-checked, so of two racing steps
7
+ // only one applies.
8
+ // - provisioning needs the machine, running the session; ended needs an outcome (idle, needs_user, failed,
9
+ // timeout, stopped, lost), and only ended may carry one.
10
+ // - Idempotency: a spec with a key claims it in the StateStore (namespace `launch-keys:<userID>`, owner the launch
11
+ // id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
12
+ // ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
13
+ // (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
14
+ // - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
15
+ // their keys released. Live launches are never pruned.
16
+ import { randomBytes } from "node:crypto"
17
+ import type { Clock, StateStore } from "../backend/types"
18
+ import { AgentError } from "./errors"
19
+ import { type LaunchSpec, launchBodyKey } from "./launch-spec"
20
+ import { updateVersioned } from "./versioned"
21
+
22
+ export const LAUNCHES = "launches"
23
+ export const REASON_MAX = 500
24
+ export const HISTORY_MAX = 20
25
+ export const ENDED_KEEP_MS = 7 * 86_400_000
26
+ export const ENDED_MAX = 500
27
+ /** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
28
+ export const RESERVATION_MS = 60_000
29
+ /** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
30
+ const READS = 5
31
+ /** Claim attempts (a stale claim is taken over between them). */
32
+ const CLAIMS = 3
33
+
34
+ export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
35
+ export type LaunchState = (typeof LAUNCH_STATES)[number]
36
+ export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
37
+ export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
38
+
39
+ /** The states each state may move to. */
40
+ const NEXT: Record<LaunchState, readonly LaunchState[]> = {
41
+ queued: ["placing", "ended"],
42
+ placing: ["provisioning", "queued", "ended"],
43
+ provisioning: ["running", "ended"],
44
+ running: ["ended"],
45
+ ended: [],
46
+ }
47
+
48
+ export interface LaunchStep {
49
+ readonly state: LaunchState
50
+ readonly reason: string
51
+ readonly at: number
52
+ readonly outcome?: LaunchOutcome
53
+ }
54
+
55
+ export interface Launch {
56
+ readonly id: string
57
+ /** The user it belongs to. */
58
+ readonly owner: string
59
+ readonly spec: LaunchSpec
60
+ /** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
61
+ readonly by?: string
62
+ readonly state: LaunchState
63
+ readonly reason: string
64
+ /** When it entered its state. */
65
+ readonly at: number
66
+ readonly outcome?: LaunchOutcome
67
+ readonly machineID?: string
68
+ readonly sessionID?: string
69
+ readonly name?: string
70
+ readonly artifacts?: Readonly<Record<string, unknown>>
71
+ readonly created: number
72
+ readonly history: readonly LaunchStep[]
73
+ }
74
+
75
+ export interface TransitionInput {
76
+ readonly reason: string
77
+ readonly outcome?: LaunchOutcome
78
+ readonly machineID?: string
79
+ readonly sessionID?: string
80
+ readonly name?: string
81
+ readonly artifacts?: Readonly<Record<string, unknown>>
82
+ /** Apply only when the launch is in one of these states (else LaunchTransitionError). */
83
+ readonly from?: readonly LaunchState[]
84
+ }
85
+
86
+ export interface LaunchFilter {
87
+ /** Every label must match the spec's. */
88
+ readonly labels?: Readonly<Record<string, string>>
89
+ readonly states?: readonly LaunchState[]
90
+ }
91
+
92
+ export interface LaunchStoreDeps {
93
+ readonly state: StateStore
94
+ readonly now?: Clock
95
+ readonly newID?: () => string
96
+ readonly endedKeepMs?: number
97
+ readonly endedMax?: number
98
+ }
99
+
100
+ /** A step the state machine does not allow (or not from the current state); the launch is unchanged. */
101
+ export class LaunchTransitionError extends AgentError {
102
+ constructor(
103
+ readonly from: LaunchState,
104
+ readonly to: LaunchState,
105
+ ) {
106
+ super(from === "ended" ? "gone" : "invalid", from === "ended" ? "The launch already ended" : `A launch cannot go from ${from} to ${to}`)
107
+ this.name = "LaunchTransitionError"
108
+ }
109
+ }
110
+
111
+ /** Control characters become spaces; trimmed and bounded. */
112
+ const cleanReason = (reason: unknown): string => {
113
+ const text = typeof reason === "string" ? reason.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, REASON_MAX).trim() : ""
114
+ if (!text) throw new Error("A launch transition needs a reason")
115
+ return text
116
+ }
117
+
118
+ /** The first step (how it began) and the latest ones. */
119
+ const bounded = (history: readonly LaunchStep[]): LaunchStep[] =>
120
+ history.length <= HISTORY_MAX ? [...history] : [history[0]!, ...history.slice(history.length - HISTORY_MAX + 1)]
121
+
122
+ const byCreation = (a: Launch, b: Launch) => a.created - b.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
123
+
124
+ export class LaunchStore {
125
+ private readonly now: Clock
126
+ private readonly newID: () => string
127
+ private readonly endedKeepMs: number
128
+ private readonly endedMax: number
129
+
130
+ constructor(private readonly deps: LaunchStoreDeps) {
131
+ this.now = deps.now ?? Date.now
132
+ this.newID = deps.newID ?? (() => `l_${randomBytes(8).toString("hex")}`)
133
+ this.endedKeepMs = deps.endedKeepMs ?? ENDED_KEEP_MS
134
+ this.endedMax = deps.endedMax ?? ENDED_MAX
135
+ }
136
+
137
+ /**
138
+ * Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
139
+ * Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
140
+ */
141
+ async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
142
+ const id = this.newID()
143
+ if (spec.key !== undefined) {
144
+ const existing = await this.claimKey(userID, spec.key, id)
145
+ if (existing) {
146
+ if (launchBodyKey(existing.spec) !== launchBodyKey(spec)) {
147
+ throw new AgentError("invalid", `Launch key ${spec.key} was used for a different launch (${existing.id})`, {
148
+ hint: "Use a new key for a different launch, or resend the original body to get that launch",
149
+ })
150
+ }
151
+ return { launch: existing, created: false }
152
+ }
153
+ }
154
+ const at = this.now()
155
+ const launch: Launch = {
156
+ id,
157
+ owner: userID,
158
+ spec,
159
+ ...(options.by !== undefined ? { by: options.by } : {}),
160
+ state: "queued",
161
+ reason: "Queued",
162
+ at,
163
+ created: at,
164
+ history: [{ state: "queued", reason: "Queued", at }],
165
+ }
166
+ const written = await this.deps.state.put(userID, LAUNCHES, id, launch, { ifVersion: 0 })
167
+ if (!written.ok) throw new Error(`launch id ${id} already exists`)
168
+ return { launch, created: true }
169
+ }
170
+
171
+ async get(userID: string, id: string): Promise<Launch | undefined> {
172
+ return (await this.deps.state.get<Launch>(userID, LAUNCHES, id))?.value
173
+ }
174
+
175
+ /** The user's launches by creation, filtered. */
176
+ async list(userID: string, filter: LaunchFilter = {}): Promise<Launch[]> {
177
+ const launches = (await this.deps.state.list<Launch>(userID, LAUNCHES)).map((entry) => entry.value)
178
+ return launches
179
+ .filter((launch) => !filter.states || filter.states.includes(launch.state))
180
+ .filter((launch) => !filter.labels || Object.entries(filter.labels).every(([key, value]) => launch.spec.labels[key] === value))
181
+ .sort(byCreation)
182
+ }
183
+
184
+ /** Moves the launch to `to`; throws not_found, LaunchTransitionError, or Error for a malformed step. */
185
+ async transition(userID: string, id: string, to: LaunchState, input: TransitionInput): Promise<Launch> {
186
+ const reason = cleanReason(input.reason)
187
+ const at = this.now()
188
+ const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
189
+ if (!current) throw new AgentError("not_found", `No launch ${id}`)
190
+ if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
191
+ checkDetails(to, input)
192
+ const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
193
+ return {
194
+ ...current,
195
+ state: to,
196
+ reason,
197
+ at,
198
+ ...(input.outcome !== undefined ? { outcome: input.outcome } : {}),
199
+ ...(input.machineID !== undefined ? { machineID: input.machineID } : {}),
200
+ ...(input.sessionID !== undefined ? { sessionID: input.sessionID } : {}),
201
+ ...(input.name !== undefined ? { name: input.name } : {}),
202
+ ...(input.artifacts !== undefined ? { artifacts: input.artifacts } : {}),
203
+ history: bounded([...current.history, step]),
204
+ }
205
+ })
206
+ return next!
207
+ }
208
+
209
+ /** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
210
+ async prune(userID: string): Promise<string[]> {
211
+ const { state } = this.deps
212
+ const ended = (await state.list<Launch>(userID, LAUNCHES)).filter((entry) => entry.value.state === "ended")
213
+ ended.sort((a, b) => b.value.at - a.value.at || byCreation(b.value, a.value))
214
+ const cutoff = this.now() - this.endedKeepMs
215
+ const removed: string[] = []
216
+ for (const [index, entry] of ended.entries()) {
217
+ if (index < this.endedMax && entry.value.at > cutoff) continue
218
+ const deleted = await state.delete(userID, LAUNCHES, entry.key, { ifVersion: entry.version })
219
+ if (!deleted.ok) continue
220
+ if (entry.value.spec.key !== undefined) await state.release(keyNamespace(userID), entry.value.spec.key, entry.value.id)
221
+ removed.push(entry.value.id)
222
+ }
223
+ return removed
224
+ }
225
+
226
+ // ---- internals ----------------------------------------------------------------------------------------
227
+
228
+ private async launchOf(userID: string, id: string): Promise<Launch | undefined> {
229
+ for (let read = 0; read < READS; read++) {
230
+ const launch = await this.get(userID, id)
231
+ if (launch) return launch
232
+ }
233
+ return undefined
234
+ }
235
+
236
+ /** Claims the key for `id`: undefined when claimed (create it), else the launch that holds it. */
237
+ private async claimKey(userID: string, key: string, id: string): Promise<Launch | undefined> {
238
+ const { state } = this.deps
239
+ const namespace = keyNamespace(userID)
240
+ for (let attempt = 0; attempt < CLAIMS; attempt++) {
241
+ const claimed = await state.claim(namespace, key, id)
242
+ if (claimed.ok) return undefined
243
+ const holder = claimed.conflict
244
+ const existing = await this.launchOf(userID, holder.owner)
245
+ if (existing) return existing
246
+ if (this.now() - holder.at < RESERVATION_MS) {
247
+ throw new AgentError("rate_limited", `Launch key ${key} is being created`, { extra: { retryAfter: 1 } })
248
+ }
249
+ // Its creator crashed between the claim and the write: take the key over.
250
+ await state.release(namespace, key, holder.owner)
251
+ }
252
+ throw new AgentError("rate_limited", `Launch key ${key} is contended`, { extra: { retryAfter: 1 } })
253
+ }
254
+ }
255
+
256
+ const keyNamespace = (userID: string) => `launch-keys:${userID}`
257
+
258
+ function checkDetails(to: LaunchState, input: TransitionInput): void {
259
+ if (to === "ended") {
260
+ if (!LAUNCH_OUTCOMES.includes(input.outcome as LaunchOutcome)) throw new Error(`Ending a launch needs an outcome: ${LAUNCH_OUTCOMES.join(", ")}`)
261
+ } else if (input.outcome !== undefined) throw new Error("Only an ended launch has an outcome")
262
+ if (to === "provisioning" && !input.machineID) throw new Error("Provisioning a launch needs its machine")
263
+ if (to === "running" && !input.sessionID) throw new Error("A running launch needs its session")
264
+ }
@@ -0,0 +1,206 @@
1
+ // Launch-bound tokens (phase 5, decision #6): the credential a harness inside a sandbox (OpenShell) uses to
2
+ // connect back to /spoke/ws for exactly one launch. The node passes it into the sandbox it provisions; it is
3
+ // accepted only while that launch is provisioning or running.
4
+ //
5
+ // Format: `hlt1.<b64url(payload JSON)>.<b64url(HMAC-SHA256(secret of kid, "hlt1." + payload part))>`, where the
6
+ // payload is {uid, mid, lid, exp (ms since the epoch), kid, ue (user epoch), me (machine epoch), aud (the hub's
7
+ // public URL)}. Signed with the same TokenKeys as session tokens (hst1); the prefix is part of the signed text,
8
+ // so neither kind can pass for the other.
9
+ //
10
+ // The verifier depends only on a LaunchLookup (state and machine of a user's launch), not on the launch engine;
11
+ // LaunchStore fits it as `{ launch: (userID, launchID) => launchStore.get(userID, launchID) }`. Holding a valid
12
+ // token is all this module checks: one connection per launch, and keeping launch-bound connections out of
13
+ // serviceFor/exposeTarget/launchService, are /spoke/ws's and the registry's job.
14
+ // Tokens and secrets are never logged and never appear in this object's JSON or inspect output.
15
+ import { createHmac, timingSafeEqual } from "node:crypto"
16
+ import type { Store } from "../store"
17
+ import type { TokenKeys } from "../token-keys"
18
+ import type { LaunchState } from "./launch-store"
19
+
20
+ export const LAUNCH_TOKEN_PREFIX = "hlt1"
21
+ /** How long a launch token is valid. */
22
+ export const LAUNCH_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
23
+ /** Longest launch token accepted (the same bound as session tokens). */
24
+ export const LAUNCH_TOKEN_MAX_CHARS = 4096
25
+ /** The launch states in which a launch token is minted and accepted. */
26
+ export const LAUNCH_TOKEN_STATES: readonly LaunchState[] = ["provisioning", "running"]
27
+
28
+ /** What the tokens need to know about a launch (implemented by the launch store). */
29
+ export interface LaunchView {
30
+ readonly state: LaunchState
31
+ /** The machine (node) it is placed on. */
32
+ readonly machineID?: string
33
+ }
34
+
35
+ /** Looks up a user's launch (undefined when the user has no such launch). */
36
+ export interface LaunchLookup {
37
+ launch(userID: string, launchID: string): Promise<LaunchView | undefined> | LaunchView | undefined
38
+ }
39
+
40
+ export interface LaunchTokensOptions {
41
+ readonly keys: TokenKeys
42
+ readonly store: Store
43
+ readonly launches: LaunchLookup
44
+ /** The hub's public URL: the tokens' audience. */
45
+ readonly publicURL: string
46
+ readonly now?: () => number
47
+ readonly lifetimeMs?: number
48
+ }
49
+
50
+ export interface MintedLaunchToken {
51
+ readonly token: string
52
+ /** Expiry, ms since the epoch. */
53
+ readonly exp: number
54
+ }
55
+
56
+ /** What a valid launch token is bound to. */
57
+ export interface LaunchBinding {
58
+ readonly userID: string
59
+ readonly machineID: string
60
+ readonly launchID: string
61
+ /** The token's expiry, ms since the epoch. */
62
+ readonly exp: number
63
+ }
64
+
65
+ /** The binding of a valid token, "expired" for a genuine token past its expiry, else undefined. */
66
+ export type LaunchVerifyResult = LaunchBinding | "expired" | undefined
67
+
68
+ interface Claims {
69
+ readonly uid: string
70
+ readonly mid: string
71
+ readonly lid: string
72
+ readonly exp: number
73
+ readonly kid: string
74
+ readonly ue: number
75
+ readonly me: number
76
+ readonly aud: string
77
+ }
78
+
79
+ const CLAIM_KEYS = ["uid", "mid", "lid", "exp", "kid", "ue", "me", "aud"].sort().join()
80
+ const B64URL = /^[A-Za-z0-9_-]+$/
81
+ /** HMAC-SHA256 output length. */
82
+ const SIG_BYTES = 32
83
+
84
+ const text = (v: unknown): v is string => typeof v === "string" && v.length > 0
85
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
86
+
87
+ /** Canonical base64url only (no padding, no other alphabet, no non-zero trailing bits). */
88
+ function decode(part: string): Buffer | undefined {
89
+ if (!B64URL.test(part)) return undefined
90
+ const bytes = Buffer.from(part, "base64url")
91
+ return bytes.toString("base64url") === part ? bytes : undefined
92
+ }
93
+
94
+ function parseClaims(bytes: Buffer): Claims | undefined {
95
+ let raw: unknown
96
+ try {
97
+ raw = JSON.parse(bytes.toString("utf8"))
98
+ } catch {
99
+ return undefined
100
+ }
101
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
102
+ const c = raw as Record<string, unknown>
103
+ if (Object.keys(c).sort().join() !== CLAIM_KEYS) return undefined
104
+ if (!text(c.uid) || !text(c.mid) || !text(c.lid) || !text(c.kid) || !text(c.aud)) return undefined
105
+ if (!count(c.exp) || !count(c.ue) || !count(c.me)) return undefined
106
+ return c as unknown as Claims
107
+ }
108
+
109
+ const sign = (secret: Buffer, payload: string): Buffer => createHmac("sha256", secret).update(`${LAUNCH_TOKEN_PREFIX}.${payload}`).digest()
110
+
111
+ export class LaunchTokens {
112
+ readonly lifetimeMs: number
113
+ readonly #keys: TokenKeys
114
+ readonly #store: Store
115
+ readonly #launches: LaunchLookup
116
+ readonly #aud: string
117
+ readonly #now: () => number
118
+
119
+ constructor(options: LaunchTokensOptions) {
120
+ this.#keys = options.keys
121
+ this.#store = options.store
122
+ this.#launches = options.launches
123
+ this.#aud = options.publicURL
124
+ this.#now = options.now ?? Date.now
125
+ this.lifetimeMs = options.lifetimeMs ?? LAUNCH_TOKEN_LIFETIME_MS
126
+ }
127
+
128
+ /** The owner of an enrolled, unrevoked machine whose user exists and is enabled. */
129
+ #owner(machineID: string): string | undefined {
130
+ const machine = this.#store.machines().find((m) => m.id === machineID)
131
+ if (!machine || machine.revoked) return undefined
132
+ const user = this.#store.user(machine.ownerID)
133
+ return user && !user.disabled ? user.id : undefined
134
+ }
135
+
136
+ /** Whether the user's launch is provisioning or running on the machine (false when the lookup fails). */
137
+ async #live(userID: string, launchID: string, machineID: string): Promise<boolean> {
138
+ let launch: LaunchView | undefined
139
+ try {
140
+ launch = await this.#launches.launch(userID, launchID)
141
+ } catch {
142
+ return false
143
+ }
144
+ return launch !== undefined && LAUNCH_TOKEN_STATES.includes(launch.state) && launch.machineID === machineID
145
+ }
146
+
147
+ /**
148
+ * A token for a launch of the machine's owner that is provisioning or running on that machine (undefined when
149
+ * the machine is unknown, revoked or its owner disabled, or the launch is someone else's, elsewhere or not live).
150
+ */
151
+ async mint(machineID: string, launchID: string): Promise<MintedLaunchToken | undefined> {
152
+ const uid = this.#owner(machineID)
153
+ if (!uid || !(await this.#live(uid, launchID, machineID))) return undefined
154
+ const { kid, secret } = this.#keys.active()
155
+ const exp = this.#now() + this.lifetimeMs
156
+ const claims: Claims = {
157
+ uid,
158
+ mid: machineID,
159
+ lid: launchID,
160
+ exp,
161
+ kid,
162
+ ue: this.#store.userEpoch(uid),
163
+ me: this.#store.machineEpoch(machineID),
164
+ aud: this.#aud,
165
+ }
166
+ const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
167
+ return { token: `${LAUNCH_TOKEN_PREFIX}.${payload}.${sign(secret, payload).toString("base64url")}`, exp }
168
+ }
169
+
170
+ /**
171
+ * What a token is bound to while all of this holds: signature (constant-time) under a known kid, audience,
172
+ * expiry, the user's and machine's current token epochs, the machine exists, is not revoked and belongs to uid,
173
+ * the user exists and is enabled, and uid's launch is provisioning or running on that machine.
174
+ */
175
+ async verify(token: string): Promise<LaunchVerifyResult> {
176
+ if (typeof token !== "string" || token.length > LAUNCH_TOKEN_MAX_CHARS) return undefined
177
+ const parts = token.split(".")
178
+ if (parts.length !== 3 || parts[0] !== LAUNCH_TOKEN_PREFIX) return undefined
179
+ const [, payload, sigPart] = parts as [string, string, string]
180
+ const body = decode(payload)
181
+ const sig = decode(sigPart)
182
+ if (!body || !sig || sig.length !== SIG_BYTES) return undefined
183
+ const claims = parseClaims(body)
184
+ if (!claims) return undefined
185
+ const secret = this.#keys.key(claims.kid)
186
+ if (!secret) return undefined
187
+ const expected = sign(secret, payload)
188
+ if (expected.length !== sig.length || !timingSafeEqual(expected, sig)) return undefined
189
+ if (claims.aud !== this.#aud) return undefined
190
+ if (this.#now() >= claims.exp) return "expired"
191
+ // A shared store reads the user and machine again first (revocations and epoch bumps on other pods).
192
+ await this.#store.recheck({ users: [claims.uid], machines: [claims.mid] })
193
+ if (claims.ue !== this.#store.userEpoch(claims.uid) || claims.me !== this.#store.machineEpoch(claims.mid)) return undefined
194
+ if (this.#owner(claims.mid) !== claims.uid) return undefined
195
+ if (!(await this.#live(claims.uid, claims.lid, claims.mid))) return undefined
196
+ return { userID: claims.uid, machineID: claims.mid, launchID: claims.lid, exp: claims.exp }
197
+ }
198
+
199
+ toJSON(): { audience: string; lifetimeMs: number } {
200
+ return { audience: this.#aud, lifetimeMs: this.lifetimeMs }
201
+ }
202
+
203
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
204
+ return `LaunchTokens(${this.#aud})`
205
+ }
206
+ }
@@ -0,0 +1,123 @@
1
+ // Loop protection for agent messages (design §3.4), over the backend interfaces with an injected clock.
2
+ //
3
+ // - HopTracker: every message carries a hop. A session's (or app's) depth is the deepest hop that reached it
4
+ // since its last human turn; what it sends carries depth + 1. The user sends hop 0, and a hop-0 message (or a
5
+ // human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and the user
6
+ // is alerted (the delivery engine does both).
7
+ // - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
8
+ // pair and per user (refused with rate_limited), and wakes per recipient (exhausted: deliver without waking).
9
+ import type { Clock, Counters, StateStore } from "../backend/types"
10
+ import { type Party, partyKey } from "./envelope"
11
+ import { AgentError } from "./errors"
12
+ import { updateVersioned } from "./versioned"
13
+
14
+ /** Hops a reply chain may take without a human turn. */
15
+ export const HOP_CAP = 8
16
+ /** A depth no message refreshed for this long is forgotten. */
17
+ export const HOP_TTL_MS = 24 * 60 * 60_000
18
+
19
+ export const overHopCap = (hop: number): boolean => hop > HOP_CAP
20
+
21
+ /** StateStore collection: partyKey → HopDepth. */
22
+ const HOPS = "message-hops"
23
+
24
+ interface HopDepth {
25
+ readonly depth: number
26
+ readonly at: number
27
+ }
28
+
29
+ export class HopTracker {
30
+ private readonly state: StateStore
31
+ private readonly now: Clock
32
+
33
+ constructor(deps: { readonly state: StateStore; readonly now?: Clock }) {
34
+ this.state = deps.state
35
+ this.now = deps.now ?? Date.now
36
+ }
37
+
38
+ /** The hop a new message from `from` carries. */
39
+ async next(userID: string, from: Party): Promise<number> {
40
+ return from.kind === "user" ? 0 : (await this.depth(userID, from)) + 1
41
+ }
42
+
43
+ /** The deepest hop that reached the party since its last human turn (0: none, or forgotten). */
44
+ async depth(userID: string, party: Party): Promise<number> {
45
+ return this.live((await this.state.get<HopDepth>(userID, HOPS, partyKey(party)))?.value)
46
+ }
47
+
48
+ /** A message with `hop` reached the party. */
49
+ async received(userID: string, to: Party, hop: number): Promise<void> {
50
+ if (to.kind === "user") return
51
+ const at = this.now()
52
+ await updateVersioned<HopDepth>(this.state, userID, HOPS, partyKey(to), (current) => ({ depth: hop === 0 ? 0 : Math.max(this.live(current), hop), at }))
53
+ }
54
+
55
+ /** A human turn in the session (console prompt, voice, typed in the harness): the chain starts over. */
56
+ async humanTurn(userID: string, sessionID: string): Promise<void> {
57
+ await this.state.delete(userID, HOPS, partyKey({ kind: "session", sessionID }))
58
+ }
59
+
60
+ private live(entry: HopDepth | undefined): number {
61
+ return entry && this.now() - entry.at < HOP_TTL_MS ? entry.depth : 0
62
+ }
63
+ }
64
+
65
+ /** The length of every limiter window. */
66
+ export const WINDOW_MS = 10 * 60_000
67
+ /** Messages per sender→recipient pair per window. */
68
+ export const PAIR_LIMIT = 20
69
+ /** Messages per user per window, across all pairs. */
70
+ export const USER_LIMIT = 300
71
+ /** Wakes per recipient per window. */
72
+ export const WAKE_BUDGET = 30
73
+
74
+ export interface MessageLimits {
75
+ readonly pair: number
76
+ readonly user: number
77
+ readonly wake: number
78
+ }
79
+
80
+ export class MessageLimiter {
81
+ private readonly counters: Counters
82
+ private readonly now: Clock
83
+ private readonly limits: MessageLimits
84
+
85
+ constructor(deps: { readonly counters: Counters; readonly now?: Clock; readonly limits?: Partial<MessageLimits> }) {
86
+ this.counters = deps.counters
87
+ this.now = deps.now ?? Date.now
88
+ this.limits = { pair: PAIR_LIMIT, user: USER_LIMIT, wake: WAKE_BUDGET, ...deps.limits }
89
+ }
90
+
91
+ /** Counts a message from → to; throws rate_limited ({retryAfter, limit}) when a cap is reached (not counted). */
92
+ async admit(userID: string, from: Party, to: Party): Promise<void> {
93
+ const window = this.window()
94
+ const keys = [
95
+ { limit: "pair" as const, key: `msg:pair:${userID}:${partyKey(from)}:${partyKey(to)}:${window.index}`, max: this.limits.pair },
96
+ { limit: "user" as const, key: `msg:user:${userID}:${window.index}`, max: this.limits.user },
97
+ ]
98
+ for (const { limit, key, max } of keys) {
99
+ if ((await this.counters.get(key)) >= max) {
100
+ throw new AgentError("rate_limited", `Too many messages (${limit} limit)`, {
101
+ hint: "Wait and try again",
102
+ extra: { retryAfter: Math.ceil(window.remaining / 1000), limit },
103
+ })
104
+ }
105
+ }
106
+ for (const { key } of keys) await this.counters.incr(key, window.remaining)
107
+ }
108
+
109
+ /** Uses one of the recipient's wakes; false when the budget is spent (deliver without waking). */
110
+ async wake(userID: string, to: Party): Promise<boolean> {
111
+ const window = this.window()
112
+ const key = `msg:wake:${userID}:${partyKey(to)}:${window.index}`
113
+ if ((await this.counters.get(key)) >= this.limits.wake) return false
114
+ await this.counters.incr(key, window.remaining)
115
+ return true
116
+ }
117
+
118
+ private window(): { index: number; remaining: number } {
119
+ const t = this.now()
120
+ const index = Math.floor(t / WINDOW_MS)
121
+ return { index, remaining: (index + 1) * WINDOW_MS - t }
122
+ }
123
+ }