@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.
- 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,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
|
+
}
|