@huynle/hub 0.9.1 → 0.10.0
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 +309 -0
- package/Dockerfile +5 -1
- package/README.md +153 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +88 -5
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +517 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/push-target.ts +11 -2
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// Session findability (Brain decisions iaspcry7, qqmworx1): per user and root session, the user's last action (a
|
|
2
|
+
// prompt, Voice, an answer) and last explicit open, plus a small histogram of human and agent activity, so the console
|
|
3
|
+
// can find "the sessions I work with" among hundreds (Touched by me, the sidebar's Working set, the activity meter).
|
|
4
|
+
//
|
|
5
|
+
// Feeds (wired in http.ts; every session ID here is a root session's):
|
|
6
|
+
// human(…, "prompt-web") a prompt the hub carried for the user (console Send, /api/agent prompt by the user); never
|
|
7
|
+
// a launch's first prompt (intent "first") or a Concierge proposal (intent false)
|
|
8
|
+
// human(…, "prompt-tui") a prompt typed in the terminal: Registry.onTerminalPrompt (exact: an OpenCode spoke marks
|
|
9
|
+
// the user items the hub carried, `carried`; other harnesses report the hub's prompts as
|
|
10
|
+
// origin "hub"; an older OpenCode spoke's user items are not counted, never guessed)
|
|
11
|
+
// human(…, "voice") a Voice utterance
|
|
12
|
+
// human(…, "answer") the user's permission or form answer on a card
|
|
13
|
+
// human(…, "view") an explicit open (POST /api/sessions/:id/read {open: true}): moves `last`, never counted,
|
|
14
|
+
// and never replaces an action as what the session shows
|
|
15
|
+
// agent(…, turn | tool) a turn started (session.execution.started) or a tool was called
|
|
16
|
+
//
|
|
17
|
+
// Storage: StateStore collection `human-activity` (tenant = user, key = root session ID):
|
|
18
|
+
// {a?, k?, v?, b: [[bucketStart, human, turns, tools], …], u}
|
|
19
|
+
// `a`/`k`: the last action and its kind; `v`: the last open; `b`: 30-minute buckets, the last 48 (24 h); `u`: the last
|
|
20
|
+
// update. What a session shows (at, kind): its last action while it is within a day of the latest touch, else the
|
|
21
|
+
// open. Records not updated for 30 days are left out of reads and deleted by a background sweep (at most hourly per
|
|
22
|
+
// user), and at most 2000 are kept per user.
|
|
23
|
+
//
|
|
24
|
+
// Writes are fire-and-forget: recording never waits on the store (a console prompt's request does not). Events are
|
|
25
|
+
// aggregated in memory per session and written at most once a minute per session by this process (an action of a
|
|
26
|
+
// session whose last write was not one, or the first touch, goes out at once); agent counts wait for the throttle
|
|
27
|
+
// (a timer). A write is a version-checked read-modify-write, so pods add their counts. A failed write (the store, or
|
|
28
|
+
// five version conflicts in a row) puts the activity back in the queue and backs the session off (30 s, doubling to
|
|
29
|
+
// 10 min). At most `inFlight` store operations are pending at once. A touch written out at once takes no buffer slot.
|
|
30
|
+
// lastAt (the context cards' gate) gives up on the store after readTimeoutMs: unknown, so not active.
|
|
31
|
+
import type { Actions, PromptInput } from "./actions"
|
|
32
|
+
import type { Clock, StateStore } from "./backend/types"
|
|
33
|
+
|
|
34
|
+
export const HUMAN_ACTIVITY_COLLECTION = "human-activity"
|
|
35
|
+
|
|
36
|
+
export const HUMAN_ACTIVITY_LIMITS = {
|
|
37
|
+
/** Histogram bucket width. */
|
|
38
|
+
bucketMs: 30 * 60_000,
|
|
39
|
+
/** Buckets kept per session (48 × 30 min = 24 h). */
|
|
40
|
+
buckets: 48,
|
|
41
|
+
/** At most one write per session per this long (the first action after an open excepted). */
|
|
42
|
+
writeEveryMs: 60_000,
|
|
43
|
+
/** How often the timer looks for buffered sessions due a write. */
|
|
44
|
+
flushEveryMs: 15_000,
|
|
45
|
+
/** Records not updated for this long are dropped. */
|
|
46
|
+
keepMs: 30 * 86_400_000,
|
|
47
|
+
/** Records kept per user (the least recently updated go first). */
|
|
48
|
+
perUser: 2000,
|
|
49
|
+
/** Sessions this process buffers or remembers (oldest dropped first). */
|
|
50
|
+
tracked: 10_000,
|
|
51
|
+
/** lastAt's store read gives up after this long (a hanging store never blocks the context cards' gate). */
|
|
52
|
+
readTimeoutMs: 2_000,
|
|
53
|
+
/** Store operations pending at once; past it, writes wait in the queue. */
|
|
54
|
+
inFlight: 100,
|
|
55
|
+
/** A user's records are swept at most this often. */
|
|
56
|
+
sweepEveryMs: 3_600_000,
|
|
57
|
+
/** An action keeps speaking for its session while it is within this long of the latest touch. */
|
|
58
|
+
actionShownMs: 86_400_000,
|
|
59
|
+
/** Backoff after a failed write (doubling per failure, up to maxRetryMs). */
|
|
60
|
+
retryMs: 30_000,
|
|
61
|
+
maxRetryMs: 600_000,
|
|
62
|
+
} as const
|
|
63
|
+
|
|
64
|
+
const L = HUMAN_ACTIVITY_LIMITS
|
|
65
|
+
|
|
66
|
+
export const HUMAN_KINDS = ["prompt-tui", "prompt-web", "voice", "answer", "view"] as const
|
|
67
|
+
export type HumanKind = (typeof HUMAN_KINDS)[number]
|
|
68
|
+
type ActionKind = Exclude<HumanKind, "view">
|
|
69
|
+
|
|
70
|
+
/** [bucket start (ms), human actions, agent turns, agent tool calls]. */
|
|
71
|
+
export type Bucket = [number, number, number, number]
|
|
72
|
+
|
|
73
|
+
/** What the console gets per session (GET /api/activity/mine, SSE `activity`). */
|
|
74
|
+
export interface ActivityEntry {
|
|
75
|
+
/** What the session shows: the last action (within a day of `last`), else the last open. */
|
|
76
|
+
at?: number
|
|
77
|
+
kind?: HumanKind
|
|
78
|
+
/** The latest touch of any kind (sorts and filters). */
|
|
79
|
+
last?: number
|
|
80
|
+
buckets: Bucket[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ActivityRecord {
|
|
84
|
+
readonly a?: number
|
|
85
|
+
readonly k?: ActionKind
|
|
86
|
+
readonly v?: number
|
|
87
|
+
readonly b: Bucket[]
|
|
88
|
+
readonly u: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Pending {
|
|
92
|
+
readonly userID: string
|
|
93
|
+
readonly sessionID: string
|
|
94
|
+
acted?: number
|
|
95
|
+
actKind?: ActionKind
|
|
96
|
+
viewed?: number
|
|
97
|
+
/** bucket start → [human, turns, tools] */
|
|
98
|
+
readonly deltas: Map<number, [number, number, number]>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface HumanActivityOptions {
|
|
102
|
+
readonly state: StateStore
|
|
103
|
+
readonly now?: Clock
|
|
104
|
+
/** How often the timer looks for due writes (default flushEveryMs). */
|
|
105
|
+
readonly flushEveryMs?: number
|
|
106
|
+
/** At most one write per session per this long (default writeEveryMs; tests shorten it). */
|
|
107
|
+
readonly writeEveryMs?: number
|
|
108
|
+
/** Sessions buffered at once (default tracked; tests shorten it). */
|
|
109
|
+
readonly tracked?: number
|
|
110
|
+
/** lastAt gives up on a store read after this long (default readTimeoutMs): unknown, not active. */
|
|
111
|
+
readonly readTimeoutMs?: number
|
|
112
|
+
/** A session's record changed (after the write): the owner's consoles get it. */
|
|
113
|
+
readonly onChange?: (userID: string, sessionID: string, entry: ActivityEntry) => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const slotOf = (at: number) => Math.floor(at / L.bucketMs) * L.bucketMs
|
|
117
|
+
const isAction = (kind: HumanKind): kind is ActionKind => kind !== "view"
|
|
118
|
+
|
|
119
|
+
export class HumanActivity {
|
|
120
|
+
private readonly state: StateStore
|
|
121
|
+
private readonly now: Clock
|
|
122
|
+
private readonly onChange: HumanActivityOptions["onChange"]
|
|
123
|
+
private readonly flushEveryMs: number
|
|
124
|
+
private readonly writeEveryMs: number
|
|
125
|
+
private readonly tracked: number
|
|
126
|
+
private readonly readTimeoutMs: number
|
|
127
|
+
private readonly pending = new Map<string, Pending>()
|
|
128
|
+
/** `userID\nsessionID` → when this process last wrote it and whether that write held an action. */
|
|
129
|
+
private readonly written = new Map<string, { at: number; action: boolean }>()
|
|
130
|
+
/** `userID\nsessionID` → backoff after failed writes. */
|
|
131
|
+
private readonly backoff = new Map<string, { until: number; failures: number }>()
|
|
132
|
+
private readonly swept = new Map<string, number>()
|
|
133
|
+
private readonly running = new Set<Promise<unknown>>()
|
|
134
|
+
private readonly touchListeners = new Set<(userID: string, sessionID: string, kind: HumanKind) => void>()
|
|
135
|
+
private inFlight = 0
|
|
136
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
137
|
+
private lastLogged = Number.NEGATIVE_INFINITY
|
|
138
|
+
|
|
139
|
+
constructor(options: HumanActivityOptions) {
|
|
140
|
+
this.state = options.state
|
|
141
|
+
this.now = options.now ?? Date.now
|
|
142
|
+
this.onChange = options.onChange
|
|
143
|
+
this.flushEveryMs = options.flushEveryMs ?? L.flushEveryMs
|
|
144
|
+
this.writeEveryMs = options.writeEveryMs ?? L.writeEveryMs
|
|
145
|
+
this.tracked = options.tracked ?? L.tracked
|
|
146
|
+
this.readTimeoutMs = options.readTimeoutMs ?? L.readTimeoutMs
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The user touched the (root) session. Queued: never waits on the store, never throws. */
|
|
150
|
+
human(userID: string, sessionID: string, kind: HumanKind): void {
|
|
151
|
+
try {
|
|
152
|
+
const now = this.now()
|
|
153
|
+
const key = this.key(userID, sessionID)
|
|
154
|
+
const last = this.written.get(key)
|
|
155
|
+
const due = !last || now - last.at >= this.writeEveryMs || (isAction(kind) && !last.action)
|
|
156
|
+
// Written out at once (flush takes it out of the buffer before its first await): it needs no buffer slot.
|
|
157
|
+
const p = this.pendingOf(userID, sessionID, due && this.writable(key, now) ? "passing" : "human")
|
|
158
|
+
if (!p) return
|
|
159
|
+
if (isAction(kind)) {
|
|
160
|
+
p.acted = now
|
|
161
|
+
p.actKind = kind
|
|
162
|
+
this.count(p, now, 0)
|
|
163
|
+
} else p.viewed = now
|
|
164
|
+
if (due) this.track(this.flush(key))
|
|
165
|
+
for (const listener of [...this.touchListeners]) listener(userID, sessionID, kind)
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
this.log(cause)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Listens to every human touch as it is recorded (the context cards' user-return). Returns an unsubscribe. */
|
|
172
|
+
onTouch(listener: (userID: string, sessionID: string, kind: HumanKind) => void): () => void {
|
|
173
|
+
this.touchListeners.add(listener)
|
|
174
|
+
return () => this.touchListeners.delete(listener)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** When the user last touched the (root) session: buffered here, else persisted (`last`); undefined when never. */
|
|
178
|
+
async lastAt(userID: string, sessionID: string): Promise<number | undefined> {
|
|
179
|
+
const p = this.pending.get(this.key(userID, sessionID))
|
|
180
|
+
const buffered = Math.max(p?.acted ?? Number.NEGATIVE_INFINITY, p?.viewed ?? Number.NEGATIVE_INFINITY)
|
|
181
|
+
const stored = await this.storedRecord(userID, sessionID)
|
|
182
|
+
const last = stored && typeof stored.u === "number" && this.now() - stored.u <= L.keepMs ? entryOf(stored, this.now()).last : undefined
|
|
183
|
+
const at = Math.max(buffered, last ?? Number.NEGATIVE_INFINITY)
|
|
184
|
+
return Number.isFinite(at) ? at : undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The stored record, or undefined when the store fails or does not answer within readTimeoutMs (unknown). */
|
|
188
|
+
private async storedRecord(userID: string, sessionID: string): Promise<ActivityRecord | undefined> {
|
|
189
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
190
|
+
const late = new Promise<undefined>((resolve) => (timer = setTimeout(() => (this.log(new Error(`activity read of ${sessionID} took over ${this.readTimeoutMs} ms`)), resolve(undefined)), this.readTimeoutMs)))
|
|
191
|
+
try {
|
|
192
|
+
return await Promise.race([this.state.get<ActivityRecord>(userID, HUMAN_ACTIVITY_COLLECTION, sessionID).then((r) => r?.value), late])
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
this.log(cause)
|
|
195
|
+
return undefined
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The agent worked in the session: counted in memory, written by the throttle. */
|
|
202
|
+
agent(userID: string, sessionID: string, what: "turn" | "tool"): void {
|
|
203
|
+
const p = this.pendingOf(userID, sessionID)
|
|
204
|
+
if (p) this.count(p, this.now(), what === "turn" ? 1 : 2)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Waits until every queued write and sweep has finished (stop, tests). */
|
|
208
|
+
async settled(): Promise<void> {
|
|
209
|
+
while (this.running.size) await Promise.allSettled([...this.running])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Writes the buffered sessions whose throttle (and backoff) has passed, and waits for them. */
|
|
213
|
+
async flushDue(): Promise<void> {
|
|
214
|
+
const now = this.now()
|
|
215
|
+
for (const key of [...this.pending.keys()]) {
|
|
216
|
+
const last = this.written.get(key)
|
|
217
|
+
if (!last || now - last.at >= this.writeEveryMs) this.track(this.flush(key))
|
|
218
|
+
}
|
|
219
|
+
await this.settled()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Writes everything buffered (on stop), backoffs aside. */
|
|
223
|
+
async flushAll(): Promise<void> {
|
|
224
|
+
this.backoff.clear()
|
|
225
|
+
for (const key of [...this.pending.keys()]) this.track(this.flush(key))
|
|
226
|
+
await this.settled()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
start(): void {
|
|
230
|
+
if (this.timer) return
|
|
231
|
+
this.timer = setInterval(() => void this.flushDue().catch((cause) => this.log(cause)), this.flushEveryMs)
|
|
232
|
+
;(this.timer as { unref?: () => void }).unref?.()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Stops the timer and writes what is buffered, giving up after `timeoutMs` (a store that hangs never holds shutdown). */
|
|
236
|
+
async stop(timeoutMs = 5_000): Promise<void> {
|
|
237
|
+
if (this.timer) clearInterval(this.timer)
|
|
238
|
+
this.timer = undefined
|
|
239
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
240
|
+
const late = new Promise<void>((resolve) => (timer = setTimeout(() => (this.log(new Error(`not all activity written within ${timeoutMs} ms of stop`)), resolve()), timeoutMs)))
|
|
241
|
+
await Promise.race([this.flushAll().catch((cause) => this.log(cause)), late])
|
|
242
|
+
clearTimeout(timer)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The user's sessions touched within 30 days, with buckets of the last 24 h. Reads only: expired ones are swept later. */
|
|
246
|
+
async list(userID: string): Promise<Record<string, ActivityEntry>> {
|
|
247
|
+
const now = this.now()
|
|
248
|
+
const { live, stale } = await this.scan(userID, now)
|
|
249
|
+
if (stale) this.track(this.sweepThrottled(userID, now))
|
|
250
|
+
const out: Record<string, ActivityEntry> = {}
|
|
251
|
+
for (const { key, value } of live) out[key] = entryOf(value, now)
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Deletes the user's expired records, and the least recently updated past perUser. */
|
|
256
|
+
async sweep(userID: string): Promise<void> {
|
|
257
|
+
const { drop } = await this.scan(userID, this.now())
|
|
258
|
+
for (const { key, version } of drop) await this.state.delete(userID, HUMAN_ACTIVITY_COLLECTION, key, { ifVersion: version }).catch(() => {})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async sweepThrottled(userID: string, now: number): Promise<void> {
|
|
262
|
+
const last = this.swept.get(userID)
|
|
263
|
+
if (last !== undefined && now - last < L.sweepEveryMs) return
|
|
264
|
+
remember(this.swept, userID, now, L.tracked)
|
|
265
|
+
await this.sweep(userID).catch((cause) => this.log(cause))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async scan(userID: string, now: number): Promise<{ live: Array<{ key: string; value: ActivityRecord }>; drop: Array<{ key: string; version: number }>; stale: boolean }> {
|
|
269
|
+
const cutoff = now - L.keepMs
|
|
270
|
+
const live: Array<{ key: string; version: number; value: ActivityRecord }> = []
|
|
271
|
+
const drop: Array<{ key: string; version: number }> = []
|
|
272
|
+
for (const entry of await this.state.list<ActivityRecord>(userID, HUMAN_ACTIVITY_COLLECTION)) {
|
|
273
|
+
const u = entry.value?.u
|
|
274
|
+
if (typeof u === "number" && u >= cutoff && Array.isArray(entry.value.b)) live.push(entry)
|
|
275
|
+
else drop.push(entry)
|
|
276
|
+
}
|
|
277
|
+
live.sort((a, b) => b.value.u - a.value.u)
|
|
278
|
+
if (live.length > L.perUser) drop.push(...live.splice(L.perUser))
|
|
279
|
+
return { live, drop, stale: drop.length > 0 }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private track(work: Promise<unknown>): void {
|
|
283
|
+
const done: Promise<unknown> = work.catch((cause) => this.log(cause)).finally(() => this.running.delete(done))
|
|
284
|
+
this.running.add(done)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private key(userID: string, sessionID: string): string {
|
|
288
|
+
return `${userID}\n${sessionID}`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The session's buffer entry. When the buffer is full, a human touch that stays buffered takes the place of the
|
|
293
|
+
* oldest entry that holds only agent work (its counts are lost); agent work, or a human touch with no such entry, is
|
|
294
|
+
* dropped. A "passing" touch (written out at once, so it leaves the buffer right away) evicts nothing.
|
|
295
|
+
*/
|
|
296
|
+
private pendingOf(userID: string, sessionID: string, mode: "agent" | "human" | "passing" = "agent"): Pending | undefined {
|
|
297
|
+
const key = this.key(userID, sessionID)
|
|
298
|
+
let p = this.pending.get(key)
|
|
299
|
+
if (!p) {
|
|
300
|
+
if (this.pending.size >= this.tracked && mode !== "passing") {
|
|
301
|
+
if (mode === "agent") return undefined
|
|
302
|
+
const agentOnly = [...this.pending].find(([, q]) => q.acted === undefined && q.viewed === undefined)
|
|
303
|
+
if (!agentOnly) return undefined
|
|
304
|
+
this.pending.delete(agentOnly[0])
|
|
305
|
+
}
|
|
306
|
+
p = { userID, sessionID, deltas: new Map() }
|
|
307
|
+
this.pending.set(key, p)
|
|
308
|
+
}
|
|
309
|
+
return p
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private count(p: Pending, at: number, lane: 0 | 1 | 2): void {
|
|
313
|
+
const slot = slotOf(at)
|
|
314
|
+
const counts = p.deltas.get(slot) ?? [0, 0, 0]
|
|
315
|
+
counts[lane]++
|
|
316
|
+
p.deltas.set(slot, counts)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** flush would write the session now (no backoff, the store not busy) rather than leave it queued. */
|
|
320
|
+
private writable(key: string, now: number): boolean {
|
|
321
|
+
const backoff = this.backoff.get(key)
|
|
322
|
+
return !(backoff && now < backoff.until) && this.inFlight < L.inFlight
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Writes one session's buffered activity, merged into the stored record; on failure it goes back to the queue. */
|
|
326
|
+
private async flush(key: string): Promise<void> {
|
|
327
|
+
const p = this.pending.get(key)
|
|
328
|
+
if (!p) return
|
|
329
|
+
const now = this.now()
|
|
330
|
+
if (!this.writable(key, now)) return // backing off, or the store is busy: stays queued for the timer
|
|
331
|
+
this.pending.delete(key)
|
|
332
|
+
remember(this.written, key, { at: now, action: p.acted !== undefined || this.written.get(key)?.action === true }, L.tracked)
|
|
333
|
+
this.inFlight++
|
|
334
|
+
let wrote = false
|
|
335
|
+
try {
|
|
336
|
+
for (let attempt = 0; attempt < 5 && !wrote; attempt++) {
|
|
337
|
+
const stored = await this.state.get<ActivityRecord>(p.userID, HUMAN_ACTIVITY_COLLECTION, p.sessionID)
|
|
338
|
+
const next = merge(stored?.value, p, now)
|
|
339
|
+
const result = await this.state.put<ActivityRecord>(p.userID, HUMAN_ACTIVITY_COLLECTION, p.sessionID, next, { ifVersion: stored?.version ?? 0 })
|
|
340
|
+
if (!result.ok) continue
|
|
341
|
+
wrote = true
|
|
342
|
+
const written = this.written.get(key)
|
|
343
|
+
if (written && next.a !== undefined) written.action = true
|
|
344
|
+
this.backoff.delete(key)
|
|
345
|
+
this.onChange?.(p.userID, p.sessionID, entryOf(next, now))
|
|
346
|
+
}
|
|
347
|
+
if (!wrote) throw new Error(`version conflicts on ${p.sessionID}`)
|
|
348
|
+
} catch (cause) {
|
|
349
|
+
this.requeue(key, p, now)
|
|
350
|
+
this.log(cause)
|
|
351
|
+
} finally {
|
|
352
|
+
this.inFlight--
|
|
353
|
+
}
|
|
354
|
+
if (wrote) this.track(this.sweepThrottled(p.userID, now))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Puts a failed write's activity back (merged with what came meanwhile) and backs the session off. */
|
|
358
|
+
private requeue(key: string, failed: Pending, now: number): void {
|
|
359
|
+
this.written.delete(key)
|
|
360
|
+
const failures = (this.backoff.get(key)?.failures ?? 0) + 1
|
|
361
|
+
remember(this.backoff, key, { until: now + Math.min(L.maxRetryMs, L.retryMs * 2 ** (failures - 1)), failures }, L.tracked)
|
|
362
|
+
const p = this.pendingOf(failed.userID, failed.sessionID, failed.acted !== undefined || failed.viewed !== undefined ? "human" : "agent")
|
|
363
|
+
if (!p) return
|
|
364
|
+
if (failed.acted !== undefined && (p.acted === undefined || failed.acted > p.acted)) {
|
|
365
|
+
p.acted = failed.acted
|
|
366
|
+
p.actKind = failed.actKind
|
|
367
|
+
}
|
|
368
|
+
if (failed.viewed !== undefined && (p.viewed === undefined || failed.viewed > p.viewed)) p.viewed = failed.viewed
|
|
369
|
+
for (const [slot, [h, t, x]] of failed.deltas) {
|
|
370
|
+
const counts = p.deltas.get(slot) ?? [0, 0, 0]
|
|
371
|
+
counts[0] += h
|
|
372
|
+
counts[1] += t
|
|
373
|
+
counts[2] += x
|
|
374
|
+
p.deltas.set(slot, counts)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private log(cause: unknown): void {
|
|
379
|
+
const now = Date.now()
|
|
380
|
+
if (now - this.lastLogged < 60_000) return
|
|
381
|
+
this.lastLogged = now
|
|
382
|
+
console.error(`[hub] human activity: ${cause instanceof Error ? cause.message : String(cause)} (kept queued, retried later)`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The stored record with a session's buffered activity added. */
|
|
387
|
+
function merge(stored: ActivityRecord | undefined, p: Pending, now: number): ActivityRecord {
|
|
388
|
+
const buckets = new Map<number, Bucket>()
|
|
389
|
+
for (const b of Array.isArray(stored?.b) ? stored.b : []) if (Array.isArray(b) && b.length === 4) buckets.set(b[0], [...b] as Bucket)
|
|
390
|
+
for (const [slot, [h, t, x]] of p.deltas) {
|
|
391
|
+
const b = buckets.get(slot) ?? [slot, 0, 0, 0]
|
|
392
|
+
b[1] += h
|
|
393
|
+
b[2] += t
|
|
394
|
+
b[3] += x
|
|
395
|
+
buckets.set(slot, b)
|
|
396
|
+
}
|
|
397
|
+
let a = stored?.a
|
|
398
|
+
let k = stored?.k
|
|
399
|
+
if (p.acted !== undefined && (a === undefined || p.acted >= a)) {
|
|
400
|
+
a = p.acted
|
|
401
|
+
k = p.actKind
|
|
402
|
+
}
|
|
403
|
+
const v = Math.max(stored?.v ?? Number.NEGATIVE_INFINITY, p.viewed ?? Number.NEGATIVE_INFINITY)
|
|
404
|
+
return { ...(a !== undefined && k ? { a, k } : {}), ...(Number.isFinite(v) ? { v } : {}), b: recent([...buckets.values()], now), u: now }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The buckets of the last 24 h, oldest first. */
|
|
408
|
+
function recent(buckets: Bucket[], now: number): Bucket[] {
|
|
409
|
+
const from = slotOf(now) - (L.buckets - 1) * L.bucketMs
|
|
410
|
+
return buckets.filter((b) => b[0] >= from).sort((a, b) => a[0] - b[0])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function entryOf(record: ActivityRecord, now: number): ActivityEntry {
|
|
414
|
+
const a = typeof record.a === "number" && record.k && (HUMAN_KINDS as readonly string[]).includes(record.k) ? record.a : undefined
|
|
415
|
+
const v = typeof record.v === "number" ? record.v : undefined
|
|
416
|
+
const last = Math.max(a ?? Number.NEGATIVE_INFINITY, v ?? Number.NEGATIVE_INFINITY)
|
|
417
|
+
const shown: { at: number; kind: HumanKind } | undefined =
|
|
418
|
+
a !== undefined && (v === undefined || last - a <= L.actionShownMs) ? { at: a, kind: record.k! } : v !== undefined ? { at: v, kind: "view" } : undefined
|
|
419
|
+
return { ...(shown ? { at: shown.at, kind: shown.kind, last } : {}), buckets: recent(record.b.map((b) => [...b] as Bucket), now) }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function remember<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
|
|
423
|
+
map.delete(key)
|
|
424
|
+
map.set(key, value)
|
|
425
|
+
if (map.size > max) map.delete(map.keys().next().value!)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export interface HumanActivityRouting {
|
|
429
|
+
/** The session's owner (its tenant); undefined when unknown. */
|
|
430
|
+
readonly tenant: (sessionID: string) => string | undefined
|
|
431
|
+
/** The root of the session's tree (activity is per root session). */
|
|
432
|
+
readonly root: (sessionID: string) => string
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The shared action executor with "touched by me": the user's own prompts the hub carries (the console's Send, a
|
|
437
|
+
* user's /api/agent prompt) are prompt-web actions on the root session, recorded once the prompt reached the session;
|
|
438
|
+
* a launch's first prompt (intent "first") and a Concierge proposal the user only approved (intent false) are not.
|
|
439
|
+
* The spoke's report of the same prompt is marked carried (or origin hub), so it is never counted again.
|
|
440
|
+
*/
|
|
441
|
+
export function withHumanActivity<A extends Pick<Actions, "prompt">>(actions: A, activity: Pick<HumanActivity, "human">, routing: HumanActivityRouting): A {
|
|
442
|
+
return {
|
|
443
|
+
...actions,
|
|
444
|
+
async prompt(input: PromptInput) {
|
|
445
|
+
const result = await actions.prompt(input)
|
|
446
|
+
if (result.outcome === "done" && input.intent !== "first" && input.intent !== false) {
|
|
447
|
+
const userID = routing.tenant(input.sessionID)
|
|
448
|
+
if (userID !== undefined) activity.human(userID, routing.root(input.sessionID), "prompt-web")
|
|
449
|
+
}
|
|
450
|
+
return result
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The hub's own small model client (context cards, decision 9604b0a1): ONE OpenAI-compatible chat completion
|
|
2
|
+
// (POST {baseURL}/chat/completions), deterministic (temperature 0), bounded (max_tokens, a timeout), optionally
|
|
3
|
+
// asking for a JSON object. Provider-agnostic: OpenRouter and any OpenAI-compatible server work. The key is read
|
|
4
|
+
// per call (settings changes apply at once) and never appears in errors. fetch is injectable (tests never touch
|
|
5
|
+
// the network).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What failed: "provider" (the model service: 401/403/408/429/5xx, network, timeout, no key), "request" (another 4xx:
|
|
9
|
+
* this request), "content" (the answer: none, or cut by the token limit). Only provider errors trip the hub breaker.
|
|
10
|
+
*/
|
|
11
|
+
export type ChatErrorKind = "provider" | "request" | "content"
|
|
12
|
+
|
|
13
|
+
const PROVIDER_STATUSES: ReadonlySet<number> = new Set([401, 403, 408, 429])
|
|
14
|
+
|
|
15
|
+
export class ChatError extends Error {
|
|
16
|
+
readonly kind: ChatErrorKind
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
readonly status?: number,
|
|
20
|
+
/** What the failed call used, when the upstream said (counted against the budget). */
|
|
21
|
+
readonly usage?: ChatUsage,
|
|
22
|
+
kind?: ChatErrorKind,
|
|
23
|
+
) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = "ChatError"
|
|
26
|
+
this.kind = kind ?? (status === undefined || status >= 500 || PROVIDER_STATUSES.has(status) ? "provider" : "request")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChatRequest {
|
|
31
|
+
readonly model: string
|
|
32
|
+
readonly system: string
|
|
33
|
+
readonly user: string
|
|
34
|
+
readonly maxTokens: number
|
|
35
|
+
/** Ask for a JSON object (response_format json_object). */
|
|
36
|
+
readonly json?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChatUsage {
|
|
40
|
+
readonly inputTokens: number
|
|
41
|
+
readonly outputTokens: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChatResult {
|
|
45
|
+
readonly text: string
|
|
46
|
+
/** The model that answered (the upstream's, else the one asked for). */
|
|
47
|
+
readonly model: string
|
|
48
|
+
readonly usage: ChatUsage
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the context cards need of a model: one completion. Tests inject fakes. */
|
|
52
|
+
export interface ChatClient {
|
|
53
|
+
complete(request: ChatRequest, signal?: AbortSignal): Promise<ChatResult>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OpenAICompatibleChatOptions {
|
|
57
|
+
readonly baseURL: string
|
|
58
|
+
readonly apiKey: () => Promise<string | undefined>
|
|
59
|
+
readonly fetch?: typeof globalThis.fetch
|
|
60
|
+
/** Per request (default 30 s). */
|
|
61
|
+
readonly timeoutMs?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const CHAT_TIMEOUT_MS = 30_000
|
|
65
|
+
/** Upstream error detail kept in a ChatError (bounded; keys scrubbed). */
|
|
66
|
+
const DETAIL_MAX = 300
|
|
67
|
+
|
|
68
|
+
export class OpenAICompatibleChat implements ChatClient {
|
|
69
|
+
private readonly fetch: typeof globalThis.fetch
|
|
70
|
+
private readonly baseURL: string
|
|
71
|
+
|
|
72
|
+
constructor(private readonly options: OpenAICompatibleChatOptions) {
|
|
73
|
+
this.fetch = options.fetch ?? globalThis.fetch
|
|
74
|
+
this.baseURL = options.baseURL.replace(/\/+$/, "")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async complete(request: ChatRequest, signal?: AbortSignal): Promise<ChatResult> {
|
|
78
|
+
const key = await this.options.apiKey()
|
|
79
|
+
if (!key) throw new ChatError("No API key for the context card model")
|
|
80
|
+
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? CHAT_TIMEOUT_MS)
|
|
81
|
+
const send = (json: boolean) => this.send(request, key, json, signal ? AbortSignal.any([signal, timeout]) : timeout)
|
|
82
|
+
let response = await send(request.json === true)
|
|
83
|
+
// Some OpenAI-compatible servers refuse response_format: once more without it (the prompt still asks for JSON).
|
|
84
|
+
if (request.json && response.status === 400) {
|
|
85
|
+
await response.text().catch(() => "")
|
|
86
|
+
response = await send(false)
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const detail = await response.text().catch(() => "")
|
|
90
|
+
throw new ChatError(`Model request failed (${response.status}): ${scrub(detail, key)}`, response.status)
|
|
91
|
+
}
|
|
92
|
+
const json = (await response.json().catch(() => undefined)) as
|
|
93
|
+
| { model?: unknown; choices?: Array<{ message?: { content?: unknown }; finish_reason?: unknown }>; usage?: { prompt_tokens?: unknown; completion_tokens?: unknown } }
|
|
94
|
+
| undefined
|
|
95
|
+
const usage = { inputTokens: count(json?.usage?.prompt_tokens), outputTokens: count(json?.usage?.completion_tokens) }
|
|
96
|
+
const choice = json?.choices?.[0]
|
|
97
|
+
const content = choice?.message?.content
|
|
98
|
+
if (typeof content !== "string") throw new ChatError("The model answered without a message", undefined, usage, "content")
|
|
99
|
+
// A reasoning model can spend every token before it answers.
|
|
100
|
+
if (!content.trim() && choice?.finish_reason === "length") throw new ChatError("The model ran out of tokens before answering", undefined, usage, "content")
|
|
101
|
+
return { text: content, model: typeof json?.model === "string" && json.model ? json.model : request.model, usage }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async send(request: ChatRequest, key: string, json: boolean, signal: AbortSignal): Promise<Response> {
|
|
105
|
+
const body = {
|
|
106
|
+
model: request.model,
|
|
107
|
+
messages: [
|
|
108
|
+
{ role: "system", content: request.system },
|
|
109
|
+
{ role: "user", content: request.user },
|
|
110
|
+
],
|
|
111
|
+
max_tokens: request.maxTokens,
|
|
112
|
+
temperature: 0,
|
|
113
|
+
...(json ? { response_format: { type: "json_object" } } : {}),
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return await this.fetch(`${this.baseURL}/chat/completions`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
signal,
|
|
121
|
+
})
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
throw new ChatError(`Model request failed: ${scrub(cause instanceof Error ? cause.message : String(cause), key)}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const count = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0)
|
|
129
|
+
|
|
130
|
+
/** An upstream detail without the key, on one line, bounded. */
|
|
131
|
+
function scrub(text: string, key: string): string {
|
|
132
|
+
return text.split(key).join("[key]").replace(/\s+/g, " ").trim().slice(0, DETAIL_MAX)
|
|
133
|
+
}
|
|
@@ -82,6 +82,12 @@ export interface PublicURLOptions {
|
|
|
82
82
|
* addresses and local names pass without a lookup. Other schemes and credentials still fail.
|
|
83
83
|
*/
|
|
84
84
|
readonly allowPrivate?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* The narrow admin opt-in (hub.json agents.webhooks.allowPrivateHosts): these exact hostnames, normalised (lowercase,
|
|
87
|
+
* no trailing dot), may resolve to private addresses, on port 443 only. Still https only, no credentials, and the
|
|
88
|
+
* name must resolve; a listed name also skips the local-suffix rule (.local, .lan, …).
|
|
89
|
+
*/
|
|
90
|
+
readonly privateHosts?: readonly string[]
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/** Why the hub must not send to a URL (SSRF), or undefined when it may. */
|
|
@@ -100,7 +106,10 @@ export async function publicURLProblem(raw: string, options: PublicURLOptions):
|
|
|
100
106
|
if (options.allowPrivate) return undefined
|
|
101
107
|
const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
|
|
102
108
|
if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? `${label} is a private or local address` : undefined
|
|
103
|
-
|
|
109
|
+
const listed = options.privateHosts?.some((h) => h.toLowerCase().replace(/\.$/, "") === host) === true
|
|
110
|
+
// A listed host is reachable on the https port only, so the exception never opens other services on that address.
|
|
111
|
+
if (listed && url.port !== "" && url.port !== "443") return `${label} must use port 443 for a host in allowPrivateHosts`
|
|
112
|
+
if (!listed && (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix)))) {
|
|
104
113
|
return `${label} is a local hostname`
|
|
105
114
|
}
|
|
106
115
|
let addresses: readonly string[]
|
|
@@ -110,7 +119,7 @@ export async function publicURLProblem(raw: string, options: PublicURLOptions):
|
|
|
110
119
|
return `${label} host ${host} does not resolve`
|
|
111
120
|
}
|
|
112
121
|
if (addresses.length === 0) return `${label} host ${host} does not resolve`
|
|
113
|
-
if (addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
|
|
122
|
+
if (!listed && addresses.some(isPrivateAddress)) return `${label} resolves to a private or local address`
|
|
114
123
|
return undefined
|
|
115
124
|
}
|
|
116
125
|
|