dsh-connect-agnes-token-plan 0.5.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 +386 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/client.js +3921 -0
- package/cordis.patch.yml +147 -0
- package/lib/agnescode-llm-adapter-DDvQ-xvK.js +135 -0
- package/lib/agnescode-models-CUcL9UwK.js +713 -0
- package/lib/host-config-BNGgm_j7.js +456 -0
- package/lib/index.js +7648 -0
- package/lib/llm-adapter-qvvVg7Gb.js +168 -0
- package/lib/llm-error-fix-DQUng8AL.js +247 -0
- package/lib/llm-models-CRYZmwHg.js +1070 -0
- package/lib/raccoon-llm-adapter-n6ZdIdvk.js +143 -0
- package/lib/raccoon-models-DV1sQN4n.js +491 -0
- package/package.json +90 -0
- package/screenshots.json +4 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
//#region src/host/util.ts
|
|
2
|
+
/**
|
|
3
|
+
* The error `pluginError` actually produces at runtime: an `Error` with a
|
|
4
|
+
* stable `code` the panel branches on, plus optional structured fields the
|
|
5
|
+
* panel and trace read. The fields are attached, not inherited, so this is a
|
|
6
|
+
* structural annotation, not a subclass.
|
|
7
|
+
* @typedef {Error & {
|
|
8
|
+
* code: import("./codes.ts").CodeValue,
|
|
9
|
+
* retryAfterMs?: number,
|
|
10
|
+
* detail?: string,
|
|
11
|
+
* trace?: object[]
|
|
12
|
+
* }} PluginError
|
|
13
|
+
*/
|
|
14
|
+
/** Read a finite positive number, else the fallback. */
|
|
15
|
+
function num(value, fallback) {
|
|
16
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
/** Read a non-empty string, else the fallback. */
|
|
19
|
+
function str(value, fallback) {
|
|
20
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Redact credential-shaped strings from any text that may reach a log, an
|
|
24
|
+
* error message, or a panel-facing response.
|
|
25
|
+
*
|
|
26
|
+
* AGENTS.md's red line: "凭据不入库" — a credential never reaches a log or a
|
|
27
|
+
* response. The login trace already sanitizes in `Agnes-auth.ts`; this is
|
|
28
|
+
* the counterpart for the LLM route, where an HTTP error object's `message`
|
|
29
|
+
* often embeds the request headers it was built from (axios/fetch errors do),
|
|
30
|
+
* and a Agnes 4xx body may echo the `sk-` key back. Without this gate a
|
|
31
|
+
* registration failure would leak the key through `providerState.error` and
|
|
32
|
+
* `ctx.logger.warn`.
|
|
33
|
+
* @param {string} text - any string that might carry a credential.
|
|
34
|
+
* @returns {string} the text with credential patterns replaced by `[REDACTED]`.
|
|
35
|
+
*/
|
|
36
|
+
function redactSecrets(text) {
|
|
37
|
+
return (typeof text === "string" ? text : "").replace(/(["']?[Aa]uthorization["']?\s*[:=]\s*["']?)(?!Bearer\s)[^"',;\s]+/g, "$1[REDACTED]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [REDACTED]").replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "sk-[REDACTED]").replace(/(["']?(?:password|access_token|refresh_token|api[_-]?key|token)["']?\s*:\s*["'])[^"']+(?=["'])/gi, "$1[REDACTED]").replace(/\b(password|access_token|refresh_token|api[_-]?key|token)\s*=\s*[^&;\s]+/gi, "$1=[REDACTED]");
|
|
38
|
+
}
|
|
39
|
+
/** Read a plain object, else `{}`. */
|
|
40
|
+
function obj(value) {
|
|
41
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read a string exactly as it was given, else the fallback.
|
|
45
|
+
*
|
|
46
|
+
* The companion to `str()` for secrets: a password is stored, read back and
|
|
47
|
+
* sent as typed, because trimming it is a change the user cannot see. A
|
|
48
|
+
* password of only whitespace is still "not filled in", which the caller
|
|
49
|
+
* judges with `.trim()`.
|
|
50
|
+
*/
|
|
51
|
+
function verbatim(value, fallback) {
|
|
52
|
+
return typeof value === "string" ? value : fallback;
|
|
53
|
+
}
|
|
54
|
+
/** Read a finite number, else `null`. */
|
|
55
|
+
function numOrNull(value) {
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* An error carrying a stable code the panel can branch on.
|
|
61
|
+
*
|
|
62
|
+
* The single constructor for every failure this plugin produces. `extra`
|
|
63
|
+
* carries optional structured fields (retryAfterMs, detail) that the panel
|
|
64
|
+
* and the trace need; only defined extras are copied, so an absent field
|
|
65
|
+
* stays absent rather than reading as a zero.
|
|
66
|
+
*
|
|
67
|
+
* The runtime value is a plain `Error` with these fields attached; the
|
|
68
|
+
* {@link PluginError} type records that shape so a `catch (e)` downstream can
|
|
69
|
+
* read `e.code` as more than a hopeful guess.
|
|
70
|
+
* @param {import("./codes.ts").CodeValue} code - a {@link import("./codes.ts").CODE} wire value.
|
|
71
|
+
* @param {string} message - human-readable description.
|
|
72
|
+
* @param {{ retryAfterMs?: number, detail?: string }} [extra] - optional structured fields.
|
|
73
|
+
* @returns {PluginError}
|
|
74
|
+
*/
|
|
75
|
+
function pluginError(code, message, extra = {}) {
|
|
76
|
+
const error = new Error(message);
|
|
77
|
+
error.code = code;
|
|
78
|
+
if (extra.retryAfterMs !== void 0) error.retryAfterMs = extra.retryAfterMs;
|
|
79
|
+
if (extra.detail !== void 0) error.detail = extra.detail;
|
|
80
|
+
return error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/host/host-config.ts
|
|
85
|
+
/**
|
|
86
|
+
* The plugin's configuration contract and the Host trust fence.
|
|
87
|
+
*
|
|
88
|
+
* Everything the Host half reads from the row's patch config lives here, plus
|
|
89
|
+
* the `isAdmitted` check that keeps a foreign page from planting an account.
|
|
90
|
+
* `test/config.test.mjs` pins `CONFIG_DEFAULTS` and the resolvers against this
|
|
91
|
+
* file and `cordis.patch.yml`, so the code and the documented contract cannot
|
|
92
|
+
* silently drift.
|
|
93
|
+
* @module dsh-connect-agnes-token-plan/host-config
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* The one slug every addressable surface of this plugin derives from.
|
|
97
|
+
*
|
|
98
|
+
* Besides the name the Loader reports for the row, it is also the `/api` route
|
|
99
|
+
* prefix (`index.ts`), the credential record's scope (`token-store.ts`) and the
|
|
100
|
+
* state directory (`throttle-store.ts`) — so a rename has to carry the user's
|
|
101
|
+
* stored grant and parked throttle with it, not just the text. The two files
|
|
102
|
+
* that cannot import from here repeat it literally: `package.json#name` and the
|
|
103
|
+
* `id`/`name` pair in `cordis.patch.yml`. Neither is pinned against this
|
|
104
|
+
* constant by `test/config.test.mjs` (it pins `CONFIG_DEFAULTS` only), so a
|
|
105
|
+
* rename must check all three by hand.
|
|
106
|
+
*/
|
|
107
|
+
const name = "dsh-connect-agnes-token-plan";
|
|
108
|
+
/** Cordis services this plugin needs; without `webServer` it stays inactive. */
|
|
109
|
+
const inject = ["webServer"];
|
|
110
|
+
/**
|
|
111
|
+
* The plugin's configuration contract in one place.
|
|
112
|
+
*
|
|
113
|
+
* Every default the Host half reads lives here, so the code and the
|
|
114
|
+
* `cordis.patch.yml` that documents it cannot silently drift: `test/config.test.mjs`
|
|
115
|
+
* pins both against this object. The `auth` sub-object lists the operator-facing
|
|
116
|
+
* login-flow overrides (their patch.yml entries are commented by default, which is
|
|
117
|
+
* why they default to empty/zero and mean "use the platform default").
|
|
118
|
+
*/
|
|
119
|
+
const CONFIG_DEFAULTS = Object.freeze({
|
|
120
|
+
consoleBase: "https://platform-backend.agnes-ai.cn",
|
|
121
|
+
apiBase: "https://api.agnes-ai.cn/v1",
|
|
122
|
+
/**
|
|
123
|
+
* How many days of usage the panel's chart covers, inclusive of today.
|
|
124
|
+
*
|
|
125
|
+
* Days, not hours: `/api/usage/series` takes `start_date` / `end_date` as
|
|
126
|
+
* DATES, so an hour-granular window cannot be asked for. Snapping to whole
|
|
127
|
+
* days also keeps the request URL stable for the day, which is what lets the
|
|
128
|
+
* series cache actually hit.
|
|
129
|
+
*/
|
|
130
|
+
usageDays: 30,
|
|
131
|
+
/**
|
|
132
|
+
* Pseudo multipliers for the model roster, keyed by a case-insensitive
|
|
133
|
+
* SUBSTRING of a model id (first matching key wins, in insertion order).
|
|
134
|
+
*
|
|
135
|
+
* Empty by default, and that is not an oversight: the factor used to scale a
|
|
136
|
+
* per-model credit figure, and Agnes publishes no per-model usage at all
|
|
137
|
+
* (its console never reads a `model` field). What is left is the operator's
|
|
138
|
+
* own annotation — a `×N` badge on a roster row — so there is no defensible
|
|
139
|
+
* default to ship. Set it to tag models for your own routing notes; rows
|
|
140
|
+
* without a match get no badge, never a guessed 1.
|
|
141
|
+
*/
|
|
142
|
+
trendMultipliers: {},
|
|
143
|
+
cacheSeconds: 60,
|
|
144
|
+
pollSeconds: 30,
|
|
145
|
+
consoleTimeoutMs: 15e3,
|
|
146
|
+
tokenSkewSeconds: 120,
|
|
147
|
+
/**
|
|
148
|
+
* Login-flow overrides, in the shape `agnes-auth.ts` reads them.
|
|
149
|
+
*
|
|
150
|
+
* Agnes signs in with ONE request (`POST /api/user/login`), so the SenseNova
|
|
151
|
+
* OIDC knobs — `iamBase`, `tokenEndpoint`, `jwksEndpoint`, `redirectUri`,
|
|
152
|
+
* `clientId`, `scope`, `encKeyId`, `maxHops` — have no counterpart here and
|
|
153
|
+
* are gone. The three below are the ones that still mean something.
|
|
154
|
+
*/
|
|
155
|
+
auth: {
|
|
156
|
+
loginPath: "",
|
|
157
|
+
loginTimeoutMs: 0,
|
|
158
|
+
fallbackExpiresInSeconds: 0
|
|
159
|
+
},
|
|
160
|
+
/** Host names the Host answers as, by default. The operator's list is added. */
|
|
161
|
+
admittedHosts: [
|
|
162
|
+
"localhost",
|
|
163
|
+
"127.0.0.1",
|
|
164
|
+
"[::1]",
|
|
165
|
+
"::1"
|
|
166
|
+
],
|
|
167
|
+
/**
|
|
168
|
+
* Vision step two: whether the Host syncs the identified vision-capable
|
|
169
|
+
* model ids into THIS row's own settings namespace (`imageModelIds`,
|
|
170
|
+
* `visionModels`) on every catalog poll, for a later LLM connect plugin to
|
|
171
|
+
* read. Off by default - the read-only info layer is the safe shape. The
|
|
172
|
+
* writes go to this plugin's OWN settings row only, never another
|
|
173
|
+
* provider's, so a miscalculated list cannot reach DSH's model routing.
|
|
174
|
+
*/
|
|
175
|
+
writeImageModelIds: false,
|
|
176
|
+
/** The last published image-model id list (the reader's primary field). */
|
|
177
|
+
imageModelIds: [],
|
|
178
|
+
/** The last full vision identification (id + source marker per model). */
|
|
179
|
+
visionModels: [],
|
|
180
|
+
/**
|
|
181
|
+
* Step three ("one-stop service"): register the LLM provider DIRECTLY.
|
|
182
|
+
*
|
|
183
|
+
* When true, the plugin calls `ctx.llm.registerAdapter` itself with an
|
|
184
|
+
* OpenAI-compatible pi-ai adapter aimed at `apiBase`, the catalog poll feeds
|
|
185
|
+
* its model list, vision models carry image input automatically, and the
|
|
186
|
+
* panel-saved `AGNES_TOKEN_PLAN_API_KEY` reference authenticates requests. Off by
|
|
187
|
+
* default for the same reason `writeImageModelIds` is: registering a model
|
|
188
|
+
* source is a Host-wide change, not a read-only panel view, so it stays an
|
|
189
|
+
* explicit opt-in and an operator with the hand-written `llm-pi-ai` row is
|
|
190
|
+
* not suddenly offered two providers.
|
|
191
|
+
*/
|
|
192
|
+
registerProvider: false,
|
|
193
|
+
/**
|
|
194
|
+
* Draw absorption (ARCHITECTURE §5.4 route B): register the
|
|
195
|
+
* `agnes_draw_image` agent tool. When true AND the Host exposes a tools
|
|
196
|
+
* service, image-generation requests go to `{apiBase}/images/generations`
|
|
197
|
+
* with the panel-saved `AGNES_TOKEN_PLAN_API_KEY`, and the model list comes
|
|
198
|
+
* from `modality.ts` — the declared `output_modalities` field when the
|
|
199
|
+
* gateway sends one, the platform's own `agnes-image-*` family segment when
|
|
200
|
+
* it does not (the Agnes gateway sends no modality metadata at all; see that
|
|
201
|
+
* module's header). Off by
|
|
202
|
+
* default like every execution module: a tool the agent can call is a
|
|
203
|
+
* Host-wide change, and a Host without the tools service must simply never
|
|
204
|
+
* see it rather than fail.
|
|
205
|
+
*
|
|
206
|
+
* 0.4.2: this value is now the DEPLOYMENT DEFAULT only. The panel's draw
|
|
207
|
+
* tool switch (`POST /api/<name>/draw`, stored in `draw-store.ts`)
|
|
208
|
+
* overrides it live with no restart. See `docs/PROVIDER-HOT-RELOAD.md` §7.
|
|
209
|
+
*/
|
|
210
|
+
drawEnabled: false,
|
|
211
|
+
/** Preferred draw model id; empty means "first image-gen model of the catalog". */
|
|
212
|
+
drawModelId: "",
|
|
213
|
+
/** Deadline for one image request. Image models are slow; chat deadlines do not apply. */
|
|
214
|
+
drawTimeoutMs: 12e4,
|
|
215
|
+
/**
|
|
216
|
+
* Video absorption: register the `agnes_video_generate` agent tool
|
|
217
|
+
* (`video.ts`). A SEPARATE opt-in from `drawEnabled` on purpose — wanting
|
|
218
|
+
* image generation without video (or the reverse) is an ordinary
|
|
219
|
+
* preference, and one switch would force both on together.
|
|
220
|
+
*
|
|
221
|
+
* The protocol differs from drawing in the way that matters here: video is
|
|
222
|
+
* an ASYNCHRONOUS TASK (create, then poll until terminal), so this is the
|
|
223
|
+
* only module whose deadline is measured in MINUTES. Off by default, and
|
|
224
|
+
* degraded exactly like the draw tool: no tools service or a failing peer
|
|
225
|
+
* leaves the panel and the provider untouched.
|
|
226
|
+
*
|
|
227
|
+
* The panel's video switch (`POST /api/<name>/video`, stored in
|
|
228
|
+
* `video-store.ts`) overrides this live with no restart.
|
|
229
|
+
*/
|
|
230
|
+
videoEnabled: false,
|
|
231
|
+
/** Preferred video model id; empty means "first V2.0-family video model of the catalog". */
|
|
232
|
+
videoModelId: "",
|
|
233
|
+
/** The whole-generation poll budget (create + poll). Video tasks run for minutes. */
|
|
234
|
+
videoTimeoutMs: 6e5,
|
|
235
|
+
/** Default video width in pixels (16:9). */
|
|
236
|
+
videoWidth: 1152,
|
|
237
|
+
/** Default video height in pixels (16:9). */
|
|
238
|
+
videoHeight: 768,
|
|
239
|
+
/** Default frame count — must satisfy 8n+1 and be ≤441; 121 @ 24fps ≈ 5 seconds. */
|
|
240
|
+
videoNumFrames: 121,
|
|
241
|
+
/** Default frame rate (1–60). */
|
|
242
|
+
videoFrameRate: 24
|
|
243
|
+
});
|
|
244
|
+
/**
|
|
245
|
+
* Sanitize the operator's pseudo-multiplier map: keep only string keys and
|
|
246
|
+
* finite positive numbers, preserving insertion order (matching is
|
|
247
|
+
* first-key-wins). A non-object or empty input falls back to the shipped
|
|
248
|
+
* defaults; the operator sets `{}` explicitly to disable all multipliers.
|
|
249
|
+
* Exported so `test/config.test.mjs` drives the same sanitizer the resolve
|
|
250
|
+
* path uses, instead of a copy that could drift.
|
|
251
|
+
* @param {unknown} raw - the raw `trendMultipliers` config value.
|
|
252
|
+
* @returns {Record<string, number>} the sanitized map.
|
|
253
|
+
*/
|
|
254
|
+
function resolveTrendMultipliers(raw) {
|
|
255
|
+
const source = raw === void 0 || raw === null ? CONFIG_DEFAULTS.trendMultipliers : raw;
|
|
256
|
+
if (source === null || typeof source !== "object" || Array.isArray(source)) return { ...CONFIG_DEFAULTS.trendMultipliers };
|
|
257
|
+
const out = {};
|
|
258
|
+
for (const [key, value] of Object.entries(source)) if (typeof key === "string" && key !== "" && typeof value === "number" && Number.isFinite(value) && value > 0) out[key] = value;
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Clamp a raw numeric setting to its effective integer.
|
|
263
|
+
*
|
|
264
|
+
* Every numeric field in {@link resolveSettings} follows the same shape: floor
|
|
265
|
+
* the raw value, clamp it at a lower bound, then (optionally) at an upper bound;
|
|
266
|
+
* a non-positive or non-finite raw falls back to `def` (because `num` only
|
|
267
|
+
* accepts a positive finite number). The sequence — `Math.min(max, Math.max(min,
|
|
268
|
+
* Math.floor(raw)))` with `max` defaulting to `Infinity` — is exactly what the
|
|
269
|
+
* inline `Math.max`/`Math.min` chains used to spell out one field at a time, so
|
|
270
|
+
* this is a MOVE of that pattern into one tested place, not a behaviour change.
|
|
271
|
+
* @param {unknown} raw - the raw value read from the row.
|
|
272
|
+
* @param {number} def - the fallback when `raw` is not a positive finite number.
|
|
273
|
+
* @param {number} min - the lower clamp (inclusive) applied after flooring.
|
|
274
|
+
* @param {number} [max] - the upper clamp (inclusive); omit for no upper bound.
|
|
275
|
+
* @returns {number} the clamped integer.
|
|
276
|
+
*/
|
|
277
|
+
function clampInt(raw, def, min, max = Infinity) {
|
|
278
|
+
return Math.min(max, Math.max(min, Math.floor(num(raw, def))));
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolve the row's raw patch config into effective settings.
|
|
282
|
+
*
|
|
283
|
+
* A malformed row must not throw out of here: `apply` runs at mount, and an
|
|
284
|
+
* exception would take the whole plugin down instead of leaving a panel that
|
|
285
|
+
* explains itself. So problems are returned as `configError` and surfaced
|
|
286
|
+
* through the snapshot route.
|
|
287
|
+
* @param {object} config - the row's raw patch config.
|
|
288
|
+
* @returns {{settings: object, configError: string|null}}
|
|
289
|
+
*/
|
|
290
|
+
function resolveSettings(config) {
|
|
291
|
+
const source = obj(config);
|
|
292
|
+
const consoleBase = str(source.consoleBase, CONFIG_DEFAULTS.consoleBase).replace(/\/+$/, "");
|
|
293
|
+
const apiBase = str(source.apiBase, CONFIG_DEFAULTS.apiBase).replace(/\/+$/, "");
|
|
294
|
+
try {
|
|
295
|
+
return {
|
|
296
|
+
settings: {
|
|
297
|
+
consoleBase,
|
|
298
|
+
apiBase,
|
|
299
|
+
usageDays: clampInt(source.usageDays, CONFIG_DEFAULTS.usageDays, 1, 365),
|
|
300
|
+
trendMultipliers: resolveTrendMultipliers(source.trendMultipliers),
|
|
301
|
+
cacheSeconds: clampInt(source.cacheSeconds, CONFIG_DEFAULTS.cacheSeconds, 5),
|
|
302
|
+
pollSeconds: clampInt(source.pollSeconds, CONFIG_DEFAULTS.pollSeconds, 5),
|
|
303
|
+
consoleTimeoutMs: clampInt(source.consoleTimeoutMs, CONFIG_DEFAULTS.consoleTimeoutMs, 1e3),
|
|
304
|
+
allowedHosts: resolveAllowedHosts(source),
|
|
305
|
+
tokenSkewSeconds: clampInt(source.tokenSkewSeconds, CONFIG_DEFAULTS.tokenSkewSeconds, 0),
|
|
306
|
+
auth: resolveAuthOverrides(source, consoleBase),
|
|
307
|
+
writeImageModelIds: source.writeImageModelIds === true,
|
|
308
|
+
imageModelIds: Array.isArray(source.imageModelIds) ? source.imageModelIds.filter((id) => typeof id === "string") : CONFIG_DEFAULTS.imageModelIds,
|
|
309
|
+
visionModels: Array.isArray(source.visionModels) ? source.visionModels.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : CONFIG_DEFAULTS.visionModels,
|
|
310
|
+
registerProvider: source.registerProvider === true,
|
|
311
|
+
drawEnabled: source.drawEnabled === true,
|
|
312
|
+
drawModelId: str(source.drawModelId, ""),
|
|
313
|
+
drawTimeoutMs: clampInt(source.drawTimeoutMs, CONFIG_DEFAULTS.drawTimeoutMs, 5e3),
|
|
314
|
+
videoEnabled: source.videoEnabled === true,
|
|
315
|
+
videoModelId: str(source.videoModelId, ""),
|
|
316
|
+
videoTimeoutMs: clampInt(source.videoTimeoutMs, CONFIG_DEFAULTS.videoTimeoutMs, 3e4),
|
|
317
|
+
videoWidth: clampInt(source.videoWidth, CONFIG_DEFAULTS.videoWidth, 1),
|
|
318
|
+
videoHeight: clampInt(source.videoHeight, CONFIG_DEFAULTS.videoHeight, 1),
|
|
319
|
+
videoNumFrames: clampInt(source.videoNumFrames, CONFIG_DEFAULTS.videoNumFrames, 1),
|
|
320
|
+
videoFrameRate: clampInt(source.videoFrameRate, CONFIG_DEFAULTS.videoFrameRate, 1)
|
|
321
|
+
},
|
|
322
|
+
configError: null
|
|
323
|
+
};
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return {
|
|
326
|
+
settings: {
|
|
327
|
+
consoleBase,
|
|
328
|
+
apiBase,
|
|
329
|
+
usageDays: CONFIG_DEFAULTS.usageDays,
|
|
330
|
+
trendMultipliers: CONFIG_DEFAULTS.trendMultipliers,
|
|
331
|
+
cacheSeconds: CONFIG_DEFAULTS.cacheSeconds,
|
|
332
|
+
pollSeconds: CONFIG_DEFAULTS.pollSeconds,
|
|
333
|
+
consoleTimeoutMs: CONFIG_DEFAULTS.consoleTimeoutMs,
|
|
334
|
+
allowedHosts: new Set(CONFIG_DEFAULTS.admittedHosts),
|
|
335
|
+
tokenSkewSeconds: CONFIG_DEFAULTS.tokenSkewSeconds,
|
|
336
|
+
auth: { consoleOrigin: consoleBase }
|
|
337
|
+
},
|
|
338
|
+
configError: error instanceof Error ? error.message : String(error)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Collect just the auth keys the operator actually set.
|
|
344
|
+
*
|
|
345
|
+
* The keys are read from the TOP LEVEL of the row. That is not obvious, and
|
|
346
|
+
* getting it wrong is not a harmless typo: a nested `auth:` block is accepted
|
|
347
|
+
* by the loader, silently dropped here, and the panel then runs on its shipped
|
|
348
|
+
* defaults — which point at the REAL platform. An end-to-end run meant to talk
|
|
349
|
+
* to a local stub then posts a real login attempt, which is exactly how this
|
|
350
|
+
* plugin locked an account once already. So a nested `auth` key is reported as
|
|
351
|
+
* a configuration error rather than ignored.
|
|
352
|
+
*
|
|
353
|
+
* The emitted set is exactly what `agnes-auth.ts:resolveAuthConfig` reads. The
|
|
354
|
+
* SenseNova OIDC knobs (`iamBase` / `tokenEndpoint` / `jwksEndpoint` /
|
|
355
|
+
* `redirectUri` / `clientId` / `scope` / `encKeyId` / `maxHops`) are gone with
|
|
356
|
+
* the OIDC walk they configured — passing them through now would hand
|
|
357
|
+
* `createAuth` keys it never reads, which is the silent-drop failure this
|
|
358
|
+
* function exists to prevent.
|
|
359
|
+
* @param {object} source - the row's raw patch config.
|
|
360
|
+
* @param {string} consoleBase - the resolved console origin.
|
|
361
|
+
* @returns {object} the override object for `createAuth`.
|
|
362
|
+
* @throws {Error} when the row looks like it nests overrides it does not read.
|
|
363
|
+
*/
|
|
364
|
+
function resolveAuthOverrides(source, consoleBase) {
|
|
365
|
+
if (source.auth !== void 0 && source.auth !== null) {
|
|
366
|
+
const keys = Object.keys(obj(source.auth));
|
|
367
|
+
throw new Error(`auth overrides are top-level keys on this row, not a nested \`auth:\` block${keys.length === 0 ? "" : ` (found: ${keys.join(", ")})`}. Use \`loginPath\`, \`loginTimeoutMs\` or \`fallbackExpiresInSeconds\` at the top level; a nested block is ignored and the panel would keep using the real platform.`);
|
|
368
|
+
}
|
|
369
|
+
const overrides = { consoleOrigin: consoleBase };
|
|
370
|
+
const loginPath = str(source.loginPath, "");
|
|
371
|
+
if (loginPath !== "") overrides.loginPath = loginPath;
|
|
372
|
+
const fallbackExpiresInSeconds = Math.floor(num(source.fallbackExpiresInSeconds, 0));
|
|
373
|
+
if (fallbackExpiresInSeconds > 0) overrides.fallbackExpiresInSeconds = fallbackExpiresInSeconds;
|
|
374
|
+
const loginTimeoutMs = Math.floor(num(source.loginTimeoutMs, num(source.requestTimeoutMs, 0)));
|
|
375
|
+
if (loginTimeoutMs > 0) overrides.requestTimeoutMs = loginTimeoutMs;
|
|
376
|
+
return overrides;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Collect the host names this Host will answer as.
|
|
380
|
+
*
|
|
381
|
+
* The operator's list is ADDED to the defaults, never substituted: replacing
|
|
382
|
+
* them would let a typo lock the panel out of itself, and there is no console
|
|
383
|
+
* to fix it from.
|
|
384
|
+
* @param {object} source - the row's raw patch config.
|
|
385
|
+
* @returns {Set<string>} the admitted host names, lowercased.
|
|
386
|
+
*/
|
|
387
|
+
function resolveAllowedHosts(source) {
|
|
388
|
+
const admitted = new Set(CONFIG_DEFAULTS.admittedHosts);
|
|
389
|
+
const extra = Array.isArray(source.allowedHosts) ? source.allowedHosts : [];
|
|
390
|
+
for (const entry of extra) {
|
|
391
|
+
const name = str(entry, "").trim().toLowerCase();
|
|
392
|
+
if (name !== "") admitted.add(name);
|
|
393
|
+
}
|
|
394
|
+
return admitted;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The host name a `Host` header names, without its port.
|
|
398
|
+
* @param {string} host - the raw header value.
|
|
399
|
+
* @returns {string} the name; bracketed for IPv6 literals.
|
|
400
|
+
*/
|
|
401
|
+
function hostName(host) {
|
|
402
|
+
if (host.startsWith("[") && host.includes("]")) return host.slice(0, host.indexOf("]") + 1);
|
|
403
|
+
const colons = host.split(":");
|
|
404
|
+
if (colons.length > 2) {
|
|
405
|
+
if (/^\d+$/.test(colons[colons.length - 2]) && /^\d+$/.test(colons[colons.length - 1])) return host.slice(0, host.lastIndexOf(":"));
|
|
406
|
+
return host;
|
|
407
|
+
}
|
|
408
|
+
return colons.length > 1 ? host.slice(0, host.lastIndexOf(":")) : host;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Trust fence for a route the browser can reach.
|
|
412
|
+
*
|
|
413
|
+
* Two different attacks have to be turned away here, and they need two
|
|
414
|
+
* different facts:
|
|
415
|
+
*
|
|
416
|
+
* 1. DNS rebinding. The attacker's page rebinds its own name to 127.0.0.1 and
|
|
417
|
+
* POSTs an account. `Origin` and `Host` now AGREE on the attacker's name
|
|
418
|
+
* while the request lands on the Host, so comparing them to each other
|
|
419
|
+
* admits it. The `Host` header is the one thing a browser cannot forge, so
|
|
420
|
+
* it is checked against a whitelist instead of against the `Origin`.
|
|
421
|
+
* 2. Cross-site forgery. A page on another origin asks the browser to post to
|
|
422
|
+
* the loopback Host. Here the whitelist alone is worthless — the Host IS
|
|
423
|
+
* legitimate — and the `Origin` is what gives it away.
|
|
424
|
+
*
|
|
425
|
+
* So: the `Host` must be one this Host answers as, AND any stated `Origin`
|
|
426
|
+
* must agree with it. A request that states no `Origin` is the ordinary
|
|
427
|
+
* same-origin GET and is admitted.
|
|
428
|
+
*
|
|
429
|
+
* The boundary this draws is the BROWSER, not the machine. A process running
|
|
430
|
+
* as the user sets `Host` and `Origin` to whatever it likes, and there is no
|
|
431
|
+
* CSRF token here to tell it apart from the panel — so anything that can open
|
|
432
|
+
* a socket to this port can also plant an account. That is the same trust the
|
|
433
|
+
* Host places in the user's own processes generally, but it is worth saying
|
|
434
|
+
* plainly: an `Origin` check reads like more protection than it is, and a
|
|
435
|
+
* reader who believes otherwise will build something on top of it. Closing
|
|
436
|
+
* that gap needs a token the Host serves in its own page and the POST carries
|
|
437
|
+
* back, not a header a client can choose.
|
|
438
|
+
* @param request - the incoming HTTP request.
|
|
439
|
+
* @param {Set<string>} allowedHosts - the host names this Host answers as.
|
|
440
|
+
* @returns {boolean} whether the request may be served.
|
|
441
|
+
*/
|
|
442
|
+
function isAdmitted(request, allowedHosts) {
|
|
443
|
+
const host = str(request.headers?.host, "").toLowerCase();
|
|
444
|
+
if (host === "" || !allowedHosts.has(hostName(host))) return false;
|
|
445
|
+
const origin = request.headers.origin;
|
|
446
|
+
if (typeof origin !== "string" || origin === "") return true;
|
|
447
|
+
if (origin === "null") return false;
|
|
448
|
+
try {
|
|
449
|
+
return new URL(origin).host === host;
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
//#endregion
|
|
456
|
+
export { name as a, num as c, pluginError as d, redactSecrets as f, isAdmitted as i, numOrNull as l, verbatim as m, hostName as n, resolveAuthOverrides as o, str as p, inject as r, resolveSettings as s, CONFIG_DEFAULTS as t, obj as u };
|