@huynle/hub 0.6.1 → 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,193 @@
1
+ // The credential resolver: every /api request resolves to at most one AgentActor, from exactly one credential.
2
+ //
3
+ // Credentials (design §1.3, §14.2):
4
+ // - device cookie (`voice_device`, browsers): a DeviceRecord token; same-origin writes only (checked by callers)
5
+ // - device token (`Authorization: Bearer <device token>`, native apps): the same secret as the cookie,
6
+ // resolved through the same device lookup, with the same powers; bearer requests skip the same-origin check
7
+ // - session token (P1-T5) and app token (P1-T7): bearer tokens resolved by a BearerVerifier
8
+ //
9
+ // A request with both a device cookie and a bearer token the hub recognises is refused ("mixed"): credentials
10
+ // never mix. A bearer the hub does not recognise next to the cookie (e.g. one an auth proxy adds) is ignored: the
11
+ // request is a cookie request, same-origin check included.
12
+ // Tokens are never logged; the device lookup is constant-time (Store.findDevice).
13
+ import type { DeviceRecord, UserRecord } from "../store"
14
+
15
+ /** The user, signed in as one of their devices (cookie in a browser, device token in an app). */
16
+ export interface UserActor {
17
+ readonly kind: "user"
18
+ readonly userID: string
19
+ readonly deviceID: string
20
+ readonly via: "cookie" | "device-token"
21
+ }
22
+
23
+ /** A session, through the session token the hub minted for it (P1-T5). */
24
+ export interface SessionActor {
25
+ readonly kind: "session"
26
+ readonly userID: string
27
+ readonly machineID: string
28
+ readonly sessionID: string
29
+ /**
30
+ * Which of the session's tokens: "shell" (typ session, in its shells' environment) or "act" (typ act, held by
31
+ * the spoke for the hub tool only). The hub's verifier always sets it; absent counts as "shell" (canActAsSession).
32
+ */
33
+ readonly via?: "shell" | "act"
34
+ }
35
+
36
+ /** A user-created app/agent token (P1-T7). */
37
+ export interface AppActor {
38
+ readonly kind: "app"
39
+ readonly userID: string
40
+ readonly tokenID: string
41
+ readonly scopes: readonly string[]
42
+ readonly label?: string
43
+ }
44
+
45
+ export type AgentActor = UserActor | SessionActor | AppActor
46
+
47
+ export type Credential = "cookie" | "device-token" | "session-token" | "app-token"
48
+
49
+ /**
50
+ * Resolves one kind of bearer token (session tokens: P1-T5, app tokens: P1-T7). `verify` answers the actor for
51
+ * a token it recognises and accepts, "expired" for one of its tokens that expired, and undefined otherwise
52
+ * (not its token, bad signature, revoked). It must compare secrets in constant time and never log them. The
53
+ * resolver still refuses actors of unknown or disabled users, and actors of another kind than `kind`.
54
+ */
55
+ export interface BearerVerifier {
56
+ readonly kind: "session" | "app"
57
+ verify(token: string): VerifyResult | Promise<VerifyResult>
58
+ }
59
+
60
+ export type VerifyResult = SessionActor | AppActor | "expired" | undefined
61
+
62
+ export type Resolution =
63
+ | { readonly ok: true; readonly credential: Credential; readonly actor: AgentActor; readonly user: UserRecord; readonly device?: DeviceRecord }
64
+ | {
65
+ readonly ok: false
66
+ /** none: no credential; invalid: unknown, revoked or disabled; expired: a verifier's token expired; mixed: cookie + bearer. */
67
+ readonly reason: "none" | "invalid" | "expired" | "mixed"
68
+ /** The request carried a bearer token (callers answer with token wording). */
69
+ readonly bearer: boolean
70
+ }
71
+
72
+ export interface ResolverDeps {
73
+ readonly cookieName: string
74
+ /** Device of a token (cookie value or device token); constant-time. Async on a shared store (it re-reads the device and its owner). */
75
+ findDevice(token: string): DeviceRecord | undefined | Promise<DeviceRecord | undefined>
76
+ user(id: string): UserRecord | undefined
77
+ /** Session and app token verifiers, tried in order after the device lookup. */
78
+ readonly verifiers?: readonly BearerVerifier[]
79
+ }
80
+
81
+ /** How a request authenticates: decides the same-origin check (cookie requests) and the mixed refusal. */
82
+ export type CredentialMode = "none" | "cookie" | "bearer" | "mixed"
83
+
84
+ export type Resolver = ((request: Request) => Promise<Resolution>) & {
85
+ /** none, cookie (incl. a cookie next to a foreign bearer), bearer, or mixed (cookie + a hub bearer). */
86
+ mode(request: Request): Promise<CredentialMode>
87
+ }
88
+
89
+ /** `Bearer <token>`; a Bearer header whose value is not one token still counts as a (invalid, empty) bearer. */
90
+ const BEARER = /^\s*Bearer(?:\s+(.*))?$/i
91
+
92
+ /** The device cookie and the bearer token of a request (an Authorization header with another scheme is neither). */
93
+ export function readCredentials(request: Request, cookieName: string): { cookie?: string; bearer?: string } {
94
+ const out: { cookie?: string; bearer?: string } = {}
95
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
96
+ const [name, ...rest] = part.trim().split("=")
97
+ if (name === cookieName) {
98
+ out.cookie = rest.join("=")
99
+ break
100
+ }
101
+ }
102
+ const match = BEARER.exec(request.headers.get("authorization") ?? "")
103
+ if (match) {
104
+ const token = (match[1] ?? "").trim()
105
+ out.bearer = /\s/.test(token) ? "" : token
106
+ }
107
+ return out
108
+ }
109
+
110
+ const CREDENTIAL: Record<"session" | "app", Credential> = { session: "session-token", app: "app-token" }
111
+
112
+ export function createResolver(deps: ResolverDeps): Resolver {
113
+ const verifiers = deps.verifiers ?? []
114
+ const activeUser = (id: string) => {
115
+ const user = deps.user(id)
116
+ return user && !user.disabled ? user : undefined
117
+ }
118
+ const byDevice = async (token: string, via: UserActor["via"]): Promise<Resolution | undefined> => {
119
+ const device = token ? await deps.findDevice(token) : undefined
120
+ const user = device && activeUser(device.ownerID)
121
+ if (!device || !user) return undefined
122
+ return { ok: true, credential: via, actor: { kind: "user", userID: user.id, deviceID: device.id, via }, user, device }
123
+ }
124
+
125
+ /** The bearer is a hub credential (a device token, or a token a verifier answers for, expired included). */
126
+ const recognised = async (token: string): Promise<boolean> => {
127
+ if (!token) return false
128
+ if (await deps.findDevice(token)) return true
129
+ for (const verifier of verifiers) if ((await verifier.verify(token)) !== undefined) return true
130
+ return false
131
+ }
132
+ const mode = async (request: Request): Promise<CredentialMode> => {
133
+ const { cookie, bearer } = readCredentials(request, deps.cookieName)
134
+ if (bearer === undefined) return cookie === undefined ? "none" : "cookie"
135
+ if (cookie === undefined) return "bearer"
136
+ return (await recognised(bearer)) ? "mixed" : "cookie"
137
+ }
138
+
139
+ const resolve = async (request: Request): Promise<Resolution> => {
140
+ const { cookie, bearer } = readCredentials(request, deps.cookieName)
141
+ if (cookie !== undefined && (bearer === undefined || !(await recognised(bearer)))) {
142
+ return (await byDevice(cookie, "cookie")) ?? { ok: false, reason: "invalid", bearer: false }
143
+ }
144
+ if (bearer === undefined) return { ok: false, reason: "none", bearer: false }
145
+ if (cookie !== undefined) return { ok: false, reason: "mixed", bearer: true }
146
+ if (!bearer) return { ok: false, reason: "invalid", bearer: true }
147
+ const device = await byDevice(bearer, "device-token")
148
+ if (device) return device
149
+ for (const verifier of verifiers) {
150
+ const result = await verifier.verify(bearer)
151
+ if (result === undefined) continue
152
+ if (result === "expired") return { ok: false, reason: "expired", bearer: true }
153
+ if (result.kind !== verifier.kind) continue
154
+ const user = activeUser(result.userID)
155
+ if (!user) return { ok: false, reason: "invalid", bearer: true }
156
+ return { ok: true, credential: CREDENTIAL[verifier.kind], actor: result, user }
157
+ }
158
+ return { ok: false, reason: "invalid", bearer: true }
159
+ }
160
+ return Object.assign(resolve, { mode })
161
+ }
162
+
163
+ /** Which actors an /api/agent route admits besides user actors (cookie and device token reach every route). */
164
+ export interface AgentAllow {
165
+ /** Session tokens may call it (phase-limited verbs). */
166
+ readonly session?: boolean
167
+ /** App tokens holding this scope may call it. */
168
+ readonly scope?: string
169
+ /** Any app token may call it (introspection such as whoami). */
170
+ readonly app?: boolean
171
+ /**
172
+ * An act verb: sessions may call it only through their act token (canActAsSession), never their shell token,
173
+ * whatever `session` says. App tokens still need `scope`.
174
+ */
175
+ readonly act?: boolean
176
+ }
177
+
178
+ /** Whether a session actor came with the session's act token; shell tokens (and anything else) never act. */
179
+ export function canActAsSession(actor: AgentActor): boolean {
180
+ return actor.kind === "session" && actor.via === "act"
181
+ }
182
+
183
+ /** Route classes on /api/agent: users everywhere (incl. token management), sessions where allowed, apps by scope. */
184
+ export function agentAllows(allow: AgentAllow, actor: AgentActor): boolean {
185
+ switch (actor.kind) {
186
+ case "user":
187
+ return true
188
+ case "session":
189
+ return allow.act === true ? canActAsSession(actor) : allow.session === true
190
+ case "app":
191
+ return allow.app === true || (allow.scope !== undefined && actor.scopes.includes(allow.scope))
192
+ }
193
+ }
@@ -0,0 +1,142 @@
1
+ // App/agent tokens (P1-T7, design §1.3, §7): bearer tokens a user creates for an app, CLI or external agent.
2
+ //
3
+ // Format: `hat_<id>_<secret>`; id is 16 hex characters (the AppToken id, no `_`), secret is 32 random bytes in
4
+ // base64url. Only sha256(token) is stored, on UserRecord.agent.appTokens (see ../agent-data.ts); the token is
5
+ // shown once, in the creation response. Tokens are global: the verifier finds the owner by id, then compares
6
+ // the hashes in constant time. Revoking deletes the entry; the resolver refuses tokens of disabled users.
7
+ //
8
+ // Management (create, list, revoke) is for user actors only (allow {}): a session or app token cannot mint or
9
+ // see tokens (escalation guard).
10
+ import { randomBytes, timingSafeEqual } from "node:crypto"
11
+ import type { AppToken } from "../agent-data"
12
+ import { hashToken, type Store } from "../store"
13
+ import type { AppActor, BearerVerifier } from "./actor"
14
+ import { AgentError } from "./errors"
15
+ import type { AgentRoute } from "./routes"
16
+
17
+ /**
18
+ * Scopes an app token may hold: reading sessions; sending (msg, ask, reply, decline, mute) and reading messages;
19
+ * acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
20
+ * needs a grant held by the token (with the act verb, or `launch` covering the repo).
21
+ */
22
+ export const APP_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read", "sessions:act", "sessions:launch"]
23
+ export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
24
+ export const MAX_APP_TOKENS = 50
25
+ export const MAX_APP_TOKEN_LABEL = 100
26
+
27
+ const PREFIX = "/api/agent/app-tokens"
28
+ const TOKEN = /^hat_([0-9a-f]{16})_([A-Za-z0-9_-]{43,})$/
29
+ const NO_STORE = { "cache-control": "no-store" }
30
+
31
+ /** Constant-time comparison of two hex digests. */
32
+ function sameHash(a: string, b: string): boolean {
33
+ const x = Buffer.from(a, "hex")
34
+ const y = Buffer.from(b, "hex")
35
+ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y)
36
+ }
37
+
38
+ const scopesOf = (token: AppToken): string[] => (Array.isArray(token.scopes) ? token.scopes.filter((s): s is string => typeof s === "string") : [...DEFAULT_APP_SCOPES])
39
+
40
+ /** The app token verifier: `hat_<id>_<secret>` → the owner's app actor; anything else → undefined. */
41
+ export function appTokenVerifier(store: Store): BearerVerifier {
42
+ return {
43
+ kind: "app",
44
+ async verify(token: string): Promise<AppActor | undefined> {
45
+ const match = TOKEN.exec(token)
46
+ if (!match) return undefined
47
+ const id = match[1]!
48
+ const owner = store.users().find((user) => user.agent?.appTokens?.some((t) => t.id === id))
49
+ if (!owner) return undefined
50
+ // A revocation on another pod applies at once: read the owner again (a no-op for the file store).
51
+ await store.recheck({ users: [owner.id] })
52
+ const record = store.user(owner.id)?.agent?.appTokens?.find((t) => t.id === id)
53
+ if (!record || !sameHash(record.tokenHash, hashToken(token))) return undefined
54
+ const label = typeof record.label === "string" ? record.label : undefined
55
+ return { kind: "app", userID: owner.id, tokenID: record.id, scopes: scopesOf(record), ...(label ? { label } : {}) }
56
+ },
57
+ }
58
+ }
59
+
60
+ interface CreateInput {
61
+ label?: string
62
+ scopes: string[]
63
+ }
64
+
65
+ function parseCreate(body: unknown): CreateInput {
66
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
67
+ const { label, scopes } = body as Record<string, unknown>
68
+ const out: CreateInput = { scopes: [...DEFAULT_APP_SCOPES] }
69
+ if (label !== undefined && label !== null) {
70
+ if (typeof label !== "string") throw new AgentError("invalid", "label must be a string")
71
+ const trimmed = label.trim()
72
+ if (trimmed.length > MAX_APP_TOKEN_LABEL) throw new AgentError("invalid", `label must be at most ${MAX_APP_TOKEN_LABEL} characters`)
73
+ if (trimmed) out.label = trimmed
74
+ }
75
+ if (scopes !== undefined) {
76
+ if (!Array.isArray(scopes) || !scopes.every((s): s is string => typeof s === "string")) throw new AgentError("invalid", "scopes must be an array of strings")
77
+ const unknown = scopes.filter((s) => !APP_SCOPES.includes(s))
78
+ if (unknown.length) throw new AgentError("invalid", `Unknown scope: ${unknown[0]}`, { extra: { scopes: APP_SCOPES } })
79
+ out.scopes = [...new Set(scopes)]
80
+ }
81
+ return out
82
+ }
83
+
84
+ const view = (token: AppToken) => ({
85
+ id: token.id,
86
+ ...(typeof token.label === "string" ? { label: token.label } : {}),
87
+ scopes: scopesOf(token),
88
+ ...(typeof token.created === "number" ? { created: token.created } : {}),
89
+ })
90
+
91
+ /** POST, GET and DELETE /api/agent/app-tokens: user actors only. */
92
+ export function appTokenRoutes(store: Store): AgentRoute[] {
93
+ return [
94
+ {
95
+ method: "POST",
96
+ path: PREFIX,
97
+ allow: {},
98
+ async handle({ request, user }) {
99
+ const input = parseCreate(await request.json())
100
+ const id = randomBytes(8).toString("hex")
101
+ const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
102
+ const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: input.scopes, created: Date.now() }
103
+ let full = false
104
+ await store.updateAgentData(user.id, (data) => {
105
+ const tokens = data.appTokens ?? []
106
+ full = false
107
+ if (tokens.length >= MAX_APP_TOKENS) {
108
+ full = true
109
+ return
110
+ }
111
+ data.appTokens = [...tokens, record]
112
+ })
113
+ if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
114
+ await store.audit({ actor: user.id, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
115
+ return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
116
+ },
117
+ },
118
+ {
119
+ method: "GET",
120
+ path: PREFIX,
121
+ allow: {},
122
+ handle: ({ user }) => Response.json({ tokens: (store.agentData(user.id).appTokens ?? []).map(view) }, { headers: NO_STORE }),
123
+ },
124
+ {
125
+ method: "DELETE",
126
+ path: `${PREFIX}/:id`,
127
+ allow: {},
128
+ async handle({ params, user }) {
129
+ const id = params.id!
130
+ let found = false
131
+ await store.updateAgentData(user.id, (data) => {
132
+ const tokens = data.appTokens ?? []
133
+ found = tokens.some((t) => t.id === id)
134
+ if (found) data.appTokens = tokens.filter((t) => t.id !== id)
135
+ })
136
+ if (!found) throw new AgentError("not_found", "No such app token")
137
+ await store.audit({ actor: user.id, action: "app-token.revoke", target: id })
138
+ return new Response(null, { status: 204, headers: NO_STORE })
139
+ },
140
+ },
141
+ ]
142
+ }
@@ -0,0 +1,185 @@
1
+ // The agent audit (design §5, §7, §11.4): every agent action, {seq, at, actor, verb, target, grant?, decision,
2
+ // reason?, outcome}, in a per-user ring (AGENT_AUDIT_LIMIT, oldest dropped first) in the StateStore. The acting
3
+ // code (approve, deny, answer, prompt, stop, launch, roles) records each attempt, allowed or not.
4
+ //
5
+ // - One key per entry (collection "agent-audit", keyed by the zero-padded sequence) and a per-user counter
6
+ // ("agent-audit-seq"), so an append is a few small writes in every backend.
7
+ // - Text is sanitised: the reason (bounded), target ids and the grant id (dropped unless short and plain). The verb
8
+ // is the caller's constant; a malformed one is a bug and throws.
9
+ // - Each new entry goes to `recorded` (the hub sends the owner an SSE 'agent' event); a throwing listener never
10
+ // fails the record.
11
+ // - Admins see only a hub-wide summary in the Store's audit (who: the user ID; kind: agent.<verb> and the actor
12
+ // kind; when), never targets, reasons or text (design §7).
13
+ import type { Clock, StateStore } from "../backend/types"
14
+ import type { AuditEntry } from "../store"
15
+ import { type Party, sanitizeText } from "./envelope"
16
+ import { AgentError } from "./errors"
17
+ import { AGENT_PREFIX, type AgentRoute } from "./routes"
18
+ import { updateVersioned } from "./versioned"
19
+
20
+ /** Entries each user keeps (the oldest are dropped first). */
21
+ export const AGENT_AUDIT_LIMIT = 2000
22
+ /** Characters of an entry's reason. */
23
+ export const AUDIT_REASON_MAX = 500
24
+ /** Entries a list returns by default, and at most. */
25
+ export const AUDIT_PAGE_DEFAULT = 100
26
+ export const AUDIT_PAGE_MAX = 500
27
+
28
+ /** The policy's decision: allowed, or why not. */
29
+ export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited"
30
+ /** What happened: done, failed, raced (gone), the spoke could not (offline, unsupported), escalated or refused. */
31
+ export type AuditOutcome = "ok" | "failed" | "gone" | "offline" | "unsupported" | "escalated" | "refused"
32
+
33
+ /** What the action was on: the session, and the permission request or form, or the launch. */
34
+ export interface AuditTarget {
35
+ readonly sessionID?: string
36
+ readonly launchID?: string
37
+ readonly requestID?: string
38
+ readonly formID?: string
39
+ }
40
+
41
+ export interface AuditInput {
42
+ /** Who acted (from the authenticated actor). */
43
+ readonly actor: Party
44
+ /** approve, deny, answer, prompt, stop, launch, roles, grant.request, … */
45
+ readonly verb: string
46
+ readonly target: AuditTarget
47
+ /** The grant it acted under. */
48
+ readonly grant?: string
49
+ readonly decision: AuditDecision
50
+ /** The agent's reason (`--why`) or the policy's. */
51
+ readonly reason?: string
52
+ readonly outcome: AuditOutcome
53
+ }
54
+
55
+ export interface AgentAuditEntry extends AuditInput {
56
+ /** Per user, from 1; newer entries have higher numbers (the list cursor). */
57
+ readonly seq: number
58
+ readonly at: number
59
+ }
60
+
61
+ export interface AuditPage {
62
+ /** Newest first. */
63
+ readonly entries: AgentAuditEntry[]
64
+ /** `before` for the next (older) page; absent on the last one. */
65
+ readonly next?: number
66
+ }
67
+
68
+ export interface AuditListOptions {
69
+ readonly limit?: number
70
+ /** Only entries older than this seq. */
71
+ readonly before?: number
72
+ }
73
+
74
+ export interface AgentAuditDeps {
75
+ readonly state: StateStore
76
+ readonly now?: Clock
77
+ /** The hub-wide admin audit (Store): gets the summary of every entry. */
78
+ readonly admin?: { audit(entry: Omit<AuditEntry, "at">): void | Promise<void> }
79
+ /** A new entry, for its owner (the hub's SSE 'agent' event). */
80
+ readonly recorded?: (userID: string, entry: AgentAuditEntry) => void
81
+ /** Ring size (default AGENT_AUDIT_LIMIT). */
82
+ readonly limit?: number
83
+ }
84
+
85
+ const ENTRIES = "agent-audit"
86
+ const SEQ = "agent-audit-seq"
87
+ const SEQ_KEY = "seq"
88
+ const VERB = /^[a-z][a-z0-9._-]{0,31}$/
89
+ const ID = /^[A-Za-z0-9_.:@-]{1,200}$/
90
+
91
+ const keyOf = (seq: number) => String(seq).padStart(12, "0")
92
+ const cleanID = (value: string | undefined) => (value !== undefined && ID.test(value) ? value : undefined)
93
+
94
+ function cleanReason(reason: string | undefined): string | undefined {
95
+ if (reason === undefined) return undefined
96
+ const clean = Array.from(sanitizeText(reason).trim()).slice(0, AUDIT_REASON_MAX).join("").trimEnd()
97
+ return clean || undefined
98
+ }
99
+
100
+ function cleanTarget(target: AuditTarget): AuditTarget {
101
+ const out: { -readonly [K in keyof AuditTarget]: AuditTarget[K] } = {}
102
+ for (const key of ["sessionID", "launchID", "requestID", "formID"] as const) {
103
+ const id = cleanID(target[key])
104
+ if (id) out[key] = id
105
+ }
106
+ return out
107
+ }
108
+
109
+ export class AgentAudit {
110
+ private readonly now: Clock
111
+ private readonly limit: number
112
+
113
+ constructor(private readonly deps: AgentAuditDeps) {
114
+ this.now = deps.now ?? Date.now
115
+ this.limit = deps.limit ?? AGENT_AUDIT_LIMIT
116
+ }
117
+
118
+ /** Appends an entry to the user's log (dropping the oldest past the limit) and returns it. */
119
+ async record(userID: string, input: AuditInput): Promise<AgentAuditEntry> {
120
+ if (!VERB.test(input.verb)) throw new Error(`agent audit: bad verb ${JSON.stringify(input.verb)}`)
121
+ const { state } = this.deps
122
+ const seq = (await updateVersioned<number>(state, userID, SEQ, SEQ_KEY, (current) => (current ?? 0) + 1))!
123
+ const grant = cleanID(input.grant)
124
+ const reason = cleanReason(input.reason)
125
+ const entry: AgentAuditEntry = {
126
+ seq,
127
+ at: this.now(),
128
+ actor: input.actor,
129
+ verb: input.verb,
130
+ target: cleanTarget(input.target),
131
+ ...(grant ? { grant } : {}),
132
+ decision: input.decision,
133
+ ...(reason ? { reason } : {}),
134
+ outcome: input.outcome,
135
+ }
136
+ await state.put(userID, ENTRIES, keyOf(seq), entry)
137
+ if (seq > this.limit) await state.delete(userID, ENTRIES, keyOf(seq - this.limit))
138
+ await this.deps.admin?.audit({ actor: userID, action: `agent.${entry.verb}`, detail: entry.actor.kind })
139
+ try {
140
+ this.deps.recorded?.(userID, entry)
141
+ } catch (cause) {
142
+ console.error(`[hub] agent audit listener failed: ${(cause as Error).message}`)
143
+ }
144
+ return entry
145
+ }
146
+
147
+ /** The user's entries, newest first, a page at a time. */
148
+ async list(userID: string, options: AuditListOptions = {}): Promise<AuditPage> {
149
+ const limit = Math.min(Math.max(Math.floor(options.limit ?? AUDIT_PAGE_DEFAULT), 1), AUDIT_PAGE_MAX)
150
+ const all = (await this.deps.state.list<AgentAuditEntry>(userID, ENTRIES)).map((e) => e.value).sort((a, b) => b.seq - a.seq)
151
+ // Entries an interrupted record left past the ring: dropped now.
152
+ for (const stale of all.splice(this.limit)) await this.deps.state.delete(userID, ENTRIES, keyOf(stale.seq))
153
+ const older = options.before === undefined ? all : all.filter((e) => e.seq < options.before!)
154
+ const entries = older.slice(0, limit)
155
+ return { entries, ...(older.length > limit ? { next: entries[entries.length - 1]!.seq } : {}) }
156
+ }
157
+ }
158
+
159
+ const NO_STORE = { "cache-control": "no-store" }
160
+
161
+ /** A whole number query parameter in [min, max], or undefined when absent (throws invalid). */
162
+ function intQuery(url: URL, name: string, min: number, max: number): number | undefined {
163
+ const raw = url.searchParams.get(name)
164
+ if (raw === null) return undefined
165
+ const value = /^\d{1,15}$/.test(raw) ? Number(raw) : NaN
166
+ if (!(value >= min && value <= max)) throw new AgentError("invalid", `${name} must be a whole number from ${min} to ${max}`)
167
+ return value
168
+ }
169
+
170
+ /** GET /api/agent/audit?limit&before: the caller's own log (users only; sessions and apps never read it). */
171
+ export function auditRoutes(audit: AgentAudit): AgentRoute[] {
172
+ return [
173
+ {
174
+ method: "GET",
175
+ path: `${AGENT_PREFIX}/audit`,
176
+ allow: {},
177
+ handle: async ({ url, user }) => {
178
+ const limit = intQuery(url, "limit", 1, AUDIT_PAGE_MAX)
179
+ const before = intQuery(url, "before", 1, Number.MAX_SAFE_INTEGER)
180
+ const page = await audit.list(user.id, { ...(limit !== undefined ? { limit } : {}), ...(before !== undefined ? { before } : {}) })
181
+ return Response.json(page, { headers: NO_STORE })
182
+ },
183
+ },
184
+ ]
185
+ }