@goodandready/dsh-model-sync 0.5.8 → 0.5.10
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/lib/adapter-registry.js +26 -3
- package/lib/client.js +2 -0
- package/lib/credentials.js +10 -1
- package/lib/diff-notifier.js +65 -14
- package/lib/generic-adapter.js +15 -2
- package/lib/history.js +7 -1
- package/lib/http.js +28 -19
- package/lib/index.js +19 -16
- package/lib/models.js +1 -0
- package/lib/policy.js +6 -0
- package/lib/reporting.js +2 -4
- package/lib/synchronizer-helpers.js +2 -1
- package/lib/synchronizer.js +21 -8
- package/lib/updater.js +39 -10
- package/package.json +1 -1
package/lib/adapter-registry.js
CHANGED
|
@@ -97,14 +97,32 @@ function mappedRows(provider, rows, descriptor) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function parseRows(provider, payload, descriptor) {
|
|
100
|
+
if (payload && (payload.error || payload.errors)) {
|
|
101
|
+
const message = typeof payload.error === 'string'
|
|
102
|
+
? payload.error
|
|
103
|
+
: payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
|
|
104
|
+
const err = new Error(`endpoint returned error payload: ${message}`)
|
|
105
|
+
err.adapterCode = 'schema'
|
|
106
|
+
throw err
|
|
107
|
+
}
|
|
100
108
|
const parserKind = descriptor.parse || descriptor.parser
|
|
101
109
|
if (parserKind === 'clinebot-plan') {
|
|
102
110
|
const plan = payload?.data?.plan || payload?.data || payload?.plan || payload
|
|
103
111
|
const included = plan?.features?.included || plan?.includedModels || []
|
|
112
|
+
if (!Array.isArray(included)) {
|
|
113
|
+
const err = new Error('model catalog response does not contain plan features')
|
|
114
|
+
err.adapterCode = 'schema'
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
104
117
|
return parseClinePassModels(included)
|
|
105
118
|
}
|
|
106
119
|
if (parserKind === 'google') {
|
|
107
|
-
const rows =
|
|
120
|
+
const rows = payload?.models
|
|
121
|
+
if (!Array.isArray(rows)) {
|
|
122
|
+
const err = new Error('model catalog response does not contain models array')
|
|
123
|
+
err.adapterCode = 'schema'
|
|
124
|
+
throw err
|
|
125
|
+
}
|
|
108
126
|
return normalizeModels(provider, rows.map((row) => ({
|
|
109
127
|
...row,
|
|
110
128
|
id: String(row.name ?? '').replace(/^models\//, ''),
|
|
@@ -113,8 +131,13 @@ function parseRows(provider, payload, descriptor) {
|
|
|
113
131
|
maxTokens: row.outputTokenLimit,
|
|
114
132
|
})))
|
|
115
133
|
}
|
|
116
|
-
const rows = pathValue(payload, descriptor.modelsPath) ?? payload?.data ?? payload?.models ?? payload?.availableModels
|
|
117
|
-
|
|
134
|
+
const rows = pathValue(payload, descriptor.modelsPath) ?? (Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels))
|
|
135
|
+
if (!Array.isArray(rows)) {
|
|
136
|
+
const err = new Error('model catalog response does not contain models array')
|
|
137
|
+
err.adapterCode = 'schema'
|
|
138
|
+
throw err
|
|
139
|
+
}
|
|
140
|
+
return normalizeModels(provider, mappedRows(provider, rows, descriptor))
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
function privateHttpHost(hostname) {
|
package/lib/client.js
CHANGED
|
@@ -600,6 +600,7 @@ window.__ModuleLoader__.load({
|
|
|
600
600
|
setBusy(true); setWebhookErr(''); setWebhookMsg('')
|
|
601
601
|
try {
|
|
602
602
|
await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
|
|
603
|
+
await scope.set('diffWebhookSecretRef', webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '')
|
|
603
604
|
await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
|
|
604
605
|
await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
|
|
605
606
|
await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
|
|
@@ -622,6 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
622
623
|
body: JSON.stringify({
|
|
623
624
|
url: webhookDraft.diffWebhookUrl,
|
|
624
625
|
secret: webhookDraft.diffWebhookSecret || '',
|
|
626
|
+
secretRef: webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '',
|
|
625
627
|
}),
|
|
626
628
|
})
|
|
627
629
|
const body = await res.json()
|
package/lib/credentials.js
CHANGED
|
@@ -38,9 +38,18 @@ function safeCode(error) {
|
|
|
38
38
|
return 'credential-error'
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function sanitizeSecretString(value) {
|
|
42
|
+
if (value === null || value === undefined) return ''
|
|
43
|
+
return String(value)
|
|
44
|
+
.replace(/(authorization)\s*[:=]\s*(?:bearer|basic|token)\s+[^\s,;&]+/gi, '$1=[redacted]')
|
|
45
|
+
.replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;&]+/gi, '$1=[redacted]')
|
|
46
|
+
.replace(/\b(bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, '$1[redacted]')
|
|
47
|
+
.replace(/([?&](?:key|token|api[_-]?key|access[_-]?token|secret)=)[^&#\s]+/gi, '$1[redacted]')
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
function safeMessage(error) {
|
|
42
51
|
const source = error instanceof Error ? error.message : String(error ?? '')
|
|
43
|
-
return source
|
|
52
|
+
return sanitizeSecretString(source).slice(0, 160)
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
export function createCredentialResolver(ctx) {
|
package/lib/diff-notifier.js
CHANGED
|
@@ -104,6 +104,43 @@ export function extractDiffEvents(results, options = {}) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
export function isLoopbackIPv4(address) {
|
|
108
|
+
if (typeof address !== 'string' || !address.startsWith('127.')) return false
|
|
109
|
+
const parts = address.split('.')
|
|
110
|
+
if (parts.length !== 4) return false
|
|
111
|
+
return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isForbiddenWebhookHost(rawHostname) {
|
|
115
|
+
const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
116
|
+
if (host === '169.254.169.254' || host.startsWith('169.254.')) return true
|
|
117
|
+
if (host.startsWith('::ffff:')) {
|
|
118
|
+
const v4 = host.slice('::ffff:'.length)
|
|
119
|
+
if (v4.startsWith('169.254.') || v4.startsWith('a9fe:')) return true
|
|
120
|
+
if (v4 === '100.100.100.200' || v4 === '6464:64c8') return true
|
|
121
|
+
}
|
|
122
|
+
if (host.startsWith('fe80:') || host.startsWith('fe80::')) return true
|
|
123
|
+
if (host === '100.100.100.200') return true
|
|
124
|
+
if (host === '0.0.0.0' || host === '255.255.255.255' || host === '::' || host === '0:0:0:0:0:0:0:0') return true
|
|
125
|
+
if (
|
|
126
|
+
host === 'metadata.google.internal' ||
|
|
127
|
+
host === 'instance-data' ||
|
|
128
|
+
host === 'metadata' ||
|
|
129
|
+
host.endsWith('.metadata.google.internal') ||
|
|
130
|
+
host.endsWith('.metadata')
|
|
131
|
+
) return true
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isLoopbackHost(rawHostname) {
|
|
136
|
+
const host = String(rawHostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
137
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === 'localhost.' || host === '::1') return true
|
|
138
|
+
if (host.startsWith('::ffff:')) {
|
|
139
|
+
return isLoopbackIPv4(host.slice('::ffff:'.length))
|
|
140
|
+
}
|
|
141
|
+
return isLoopbackIPv4(host)
|
|
142
|
+
}
|
|
143
|
+
|
|
107
144
|
export async function sendWebhookNotification(url, secret, payload, { fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
|
|
108
145
|
if (typeof url !== "string" || !url.trim()) {
|
|
109
146
|
return { ok: false, error: "URL is required" }
|
|
@@ -115,16 +152,10 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
|
|
|
115
152
|
return { ok: false, error: "URL must use http or https protocol" }
|
|
116
153
|
}
|
|
117
154
|
const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
118
|
-
if (
|
|
119
|
-
hostname === '169.254.169.254' ||
|
|
120
|
-
hostname.startsWith('169.254.') ||
|
|
121
|
-
hostname === 'metadata.google.internal' ||
|
|
122
|
-
hostname === '0.0.0.0' ||
|
|
123
|
-
hostname === '255.255.255.255'
|
|
124
|
-
) {
|
|
155
|
+
if (isForbiddenWebhookHost(hostname)) {
|
|
125
156
|
return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
|
|
126
157
|
}
|
|
127
|
-
const isLoopback =
|
|
158
|
+
const isLoopback = isLoopbackHost(hostname)
|
|
128
159
|
if (parsedUrl.protocol === "http:" && !isLoopback) {
|
|
129
160
|
return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
|
|
130
161
|
}
|
|
@@ -195,17 +226,37 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
|
|
|
195
226
|
|
|
196
227
|
let webhookResult = null
|
|
197
228
|
if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
|
|
198
|
-
let secret =
|
|
199
|
-
const secretRef = config.diffWebhookSecretRef ||
|
|
229
|
+
let secret = ''
|
|
230
|
+
const secretRef = (config.diffWebhookSecretRef || '').trim()
|
|
200
231
|
if (secretRef) {
|
|
232
|
+
let resolvedSecret = ''
|
|
201
233
|
try {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
234
|
+
const creds = ctx?.credentials || ctx?.get?.('credentials')
|
|
235
|
+
if (creds?.resolve) {
|
|
236
|
+
const res = await creds.resolve(secretRef)
|
|
237
|
+
resolvedSecret = typeof res === 'string' ? res : res?.value || ''
|
|
238
|
+
}
|
|
204
239
|
} catch (err) {
|
|
205
|
-
if (typeof ctx?.logger?.
|
|
206
|
-
ctx.logger.
|
|
240
|
+
if (typeof ctx?.logger?.warn === 'function') {
|
|
241
|
+
ctx.logger.warn('[dsh-model-sync] webhook secret resolution failed:', err)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!resolvedSecret) {
|
|
245
|
+
// FAIL-CLOSED: do not dispatch unsigned webhook when secret was expected (#204)
|
|
246
|
+
if (typeof ctx?.logger?.warn === 'function') {
|
|
247
|
+
ctx.logger.warn(`[dsh-model-sync] webhook secret resolution failed for '${secretRef}', skipping webhook dispatch (fail-closed)`)
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: 'credential_resolution_failed',
|
|
252
|
+
message: `Failed to resolve webhook secret credential reference '${secretRef}'`,
|
|
253
|
+
events,
|
|
254
|
+
dispatched: false,
|
|
207
255
|
}
|
|
208
256
|
}
|
|
257
|
+
secret = resolvedSecret
|
|
258
|
+
} else if (config.diffWebhookSecret) {
|
|
259
|
+
secret = String(config.diffWebhookSecret).trim()
|
|
209
260
|
}
|
|
210
261
|
webhookResult = await sendWebhookNotification(
|
|
211
262
|
config.diffWebhookUrl,
|
package/lib/generic-adapter.js
CHANGED
|
@@ -75,8 +75,21 @@ async function readBody(response) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function rowsFrom(payload) {
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if (payload && (payload.error || payload.errors)) {
|
|
79
|
+
const message = typeof payload.error === 'string'
|
|
80
|
+
? payload.error
|
|
81
|
+
: payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
|
|
82
|
+
const err = new Error(`endpoint returned error payload: ${message}`)
|
|
83
|
+
err.adapterCode = 'schema'
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
const rows = Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels)
|
|
87
|
+
if (!Array.isArray(rows)) {
|
|
88
|
+
const err = new Error('model catalog response does not contain models array')
|
|
89
|
+
err.adapterCode = 'schema'
|
|
90
|
+
throw err
|
|
91
|
+
}
|
|
92
|
+
return rows
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
export function canUseGenericAdapter(profile) {
|
package/lib/history.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeSecretString } from './credentials.js'
|
|
1
2
|
import { normalizeModels } from './models.js'
|
|
2
3
|
|
|
3
4
|
export const DEFAULT_HISTORY_LIMIT = 50
|
|
@@ -27,6 +28,11 @@ function cloneModel(provider, model) {
|
|
|
27
28
|
if (normalized.pricing) compact.pricing = normalized.pricing
|
|
28
29
|
if (normalized.contextWindow !== undefined) compact.contextWindow = normalized.contextWindow
|
|
29
30
|
if (normalized.maxTokens !== undefined) compact.maxTokens = normalized.maxTokens
|
|
31
|
+
if (normalized.input !== undefined) compact.input = normalized.input
|
|
32
|
+
if (normalized.compat !== undefined) compact.compat = normalized.compat
|
|
33
|
+
if (normalized.tags !== undefined) compact.tags = normalized.tags
|
|
34
|
+
if (normalized.aliases !== undefined && normalized.aliases.length > 0) compact.aliases = normalized.aliases
|
|
35
|
+
if (normalized.lifecycle !== undefined) compact.lifecycle = normalized.lifecycle
|
|
30
36
|
return compact
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -36,7 +42,7 @@ function catalog(provider, models) {
|
|
|
36
42
|
|
|
37
43
|
function message(value) {
|
|
38
44
|
if (value === undefined || value === null) return undefined
|
|
39
|
-
return
|
|
45
|
+
return sanitizeSecretString(value).slice(0, MAX_MESSAGE_LENGTH)
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
function diffFor(provider, diff) {
|
package/lib/http.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isForbiddenWebhookHost, isLoopbackHost } from './diff-notifier.js'
|
|
1
2
|
import crypto from 'node:crypto'
|
|
2
3
|
import { validateAliasName } from './alias-resolver.js'
|
|
3
4
|
import { validatePolicy } from './policy.js'
|
|
@@ -15,22 +16,17 @@ function normalizeWebhookTestRequest(body) {
|
|
|
15
16
|
throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
|
|
16
17
|
}
|
|
17
18
|
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
18
|
-
|
|
19
|
+
if (isForbiddenWebhookHost(hostname)) {
|
|
20
|
+
throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
|
|
21
|
+
}
|
|
22
|
+
const isLoopback = isLoopbackHost(hostname)
|
|
19
23
|
if (parsed.protocol === "http:" && !isLoopback) {
|
|
20
24
|
throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
|
|
21
25
|
}
|
|
22
|
-
if (
|
|
23
|
-
hostname === '169.254.169.254' ||
|
|
24
|
-
hostname.startsWith('169.254.') ||
|
|
25
|
-
hostname === 'metadata.google.internal' ||
|
|
26
|
-
hostname === '0.0.0.0' ||
|
|
27
|
-
hostname === '255.255.255.255'
|
|
28
|
-
) {
|
|
29
|
-
throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
|
|
30
|
-
}
|
|
31
26
|
return {
|
|
32
27
|
url: parsed.toString(),
|
|
33
28
|
secret: typeof body.secret === "string" ? body.secret.trim() : "",
|
|
29
|
+
...(body.secretRef && typeof body.secretRef === "string" ? { secretRef: body.secretRef.trim() } : {}),
|
|
34
30
|
}
|
|
35
31
|
}
|
|
36
32
|
|
|
@@ -42,14 +38,17 @@ function json(res, status, body) {
|
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
export function isLoopbackAddress(ip) {
|
|
45
|
-
|
|
46
|
-
const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
|
|
47
|
-
if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
|
|
48
|
-
if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
|
|
49
|
-
return false
|
|
41
|
+
return isLoopbackHost(ip)
|
|
50
42
|
}
|
|
51
43
|
|
|
52
|
-
export function trusted(req, { requireLoopback = false } = {}) {
|
|
44
|
+
export function trusted(req, { requireLoopback = false, ctx = null } = {}) {
|
|
45
|
+
// 1. Core connection rejection check (#237)
|
|
46
|
+
const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
|
|
47
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
48
|
+
const rejection = conn.requestRejection(req)
|
|
49
|
+
if (rejection !== undefined) return false
|
|
50
|
+
}
|
|
51
|
+
|
|
53
52
|
const remoteAddress = req?.socket?.remoteAddress
|
|
54
53
|
if (requireLoopback || req?.method === 'POST') {
|
|
55
54
|
if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
|
|
@@ -66,7 +65,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
|
|
|
66
65
|
const url = new URL(origin)
|
|
67
66
|
if (url.host !== host) return false
|
|
68
67
|
if (requireLoopback || req?.method === 'POST') {
|
|
69
|
-
if (!isLoopbackAddress(url.hostname)
|
|
68
|
+
if (!isLoopbackAddress(url.hostname)) return false
|
|
70
69
|
}
|
|
71
70
|
} catch { return false }
|
|
72
71
|
}
|
|
@@ -77,7 +76,7 @@ export function trusted(req, { requireLoopback = false } = {}) {
|
|
|
77
76
|
const url = new URL(referer)
|
|
78
77
|
if (url.host !== host) return false
|
|
79
78
|
if (requireLoopback || req?.method === 'POST') {
|
|
80
|
-
if (!isLoopbackAddress(url.hostname)
|
|
79
|
+
if (!isLoopbackAddress(url.hostname)) return false
|
|
81
80
|
}
|
|
82
81
|
} catch { return false }
|
|
83
82
|
}
|
|
@@ -273,8 +272,18 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
273
272
|
return []
|
|
274
273
|
}
|
|
275
274
|
const makeHandler = (route) => async (req, res) => {
|
|
275
|
+
// 1. Core connection check on all routes (#237)
|
|
276
|
+
const conn = ctx?.connection || ctx?.get?.('connection') || req?.connection
|
|
277
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
278
|
+
const rejection = conn.requestRejection(req)
|
|
279
|
+
if (rejection !== undefined) {
|
|
280
|
+
const statusCode = typeof rejection === 'number' ? rejection : 401
|
|
281
|
+
return json(res, statusCode, { error: { code: 'unauthorized', message: 'dsh-model-sync: core connection rejected request' } })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
276
285
|
const isPublicStatus = route === 'status' && req.method === 'GET'
|
|
277
|
-
if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
286
|
+
if (!trusted(req, { requireLoopback: !isPublicStatus, ctx })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
278
287
|
if (route === 'status') {
|
|
279
288
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
280
289
|
const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
|
package/lib/index.js
CHANGED
|
@@ -44,7 +44,7 @@ export const Config = Schema.object({
|
|
|
44
44
|
title: Schema.string().required(),
|
|
45
45
|
message: Schema.string().required(),
|
|
46
46
|
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
47
|
-
}).loose()).default([]),
|
|
47
|
+
}).loose()).default([]).volatile(),
|
|
48
48
|
staleGraceRuns: Schema.number().default(2).volatile(),
|
|
49
49
|
lifecycleRetentionRuns: Schema.number().default(20).volatile(),
|
|
50
50
|
lifecycleRevision: Schema.number().default(0).volatile(),
|
|
@@ -54,14 +54,14 @@ export const Config = Schema.object({
|
|
|
54
54
|
firstMissingAt: Schema.number().default(0),
|
|
55
55
|
lastSeenAt: Schema.number().default(0),
|
|
56
56
|
lastObservedRun: Schema.number().default(0),
|
|
57
|
-
}).loose())).default({}),
|
|
57
|
+
}).loose())).default({}).volatile(),
|
|
58
58
|
history: Schema.array(Schema.object({
|
|
59
59
|
id: Schema.string().required(),
|
|
60
60
|
version: Schema.number().required(),
|
|
61
61
|
startedAt: Schema.number().required(),
|
|
62
62
|
finishedAt: Schema.number().required(),
|
|
63
63
|
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
64
|
-
}).loose()).default([]),
|
|
64
|
+
}).loose()).default([]).volatile(),
|
|
65
65
|
providers: Schema.array(Schema.object({
|
|
66
66
|
provider: Schema.string().required(),
|
|
67
67
|
enabled: Schema.boolean().default(true),
|
|
@@ -69,7 +69,7 @@ export const Config = Schema.object({
|
|
|
69
69
|
jitterMinutes: Schema.number().default(0),
|
|
70
70
|
ttlMinutes: Schema.number().default(0),
|
|
71
71
|
autoApply: Schema.boolean().default(false),
|
|
72
|
-
})).default([]),
|
|
72
|
+
})).default([]).volatile(),
|
|
73
73
|
aliases: Schema.dict(Schema.object({
|
|
74
74
|
provider: Schema.string(),
|
|
75
75
|
model: Schema.string(),
|
|
@@ -79,7 +79,7 @@ export const Config = Schema.object({
|
|
|
79
79
|
})),
|
|
80
80
|
strategy: Schema.string(),
|
|
81
81
|
updatedAt: Schema.number().default(0),
|
|
82
|
-
}).loose()).default({}),
|
|
82
|
+
}).loose()).default({}).volatile(),
|
|
83
83
|
modelSelections: Schema.dict(Schema.array(Schema.string())).default({}).volatile(),
|
|
84
84
|
adapterRegistry: Schema.dict(Schema.object({
|
|
85
85
|
enabled: Schema.boolean().default(true),
|
|
@@ -90,7 +90,7 @@ export const Config = Schema.object({
|
|
|
90
90
|
fields: Schema.any().default({}),
|
|
91
91
|
capabilityMap: Schema.any().default({}),
|
|
92
92
|
headers: Schema.dict(Schema.string()).default({}),
|
|
93
|
-
}).loose()).default({}),
|
|
93
|
+
}).loose()).default({}).volatile(),
|
|
94
94
|
modelPolicies: Schema.dict(Schema.object({
|
|
95
95
|
include: Schema.array(Schema.string()).default([]),
|
|
96
96
|
exclude: Schema.array(Schema.string()).default([]),
|
|
@@ -100,7 +100,7 @@ export const Config = Schema.object({
|
|
|
100
100
|
maxPricePerMillion: Schema.number(),
|
|
101
101
|
enableContextFilter: Schema.boolean().default(false),
|
|
102
102
|
minContextTokens: Schema.number(),
|
|
103
|
-
}).loose()).default({}),
|
|
103
|
+
}).loose()).default({}).volatile(),
|
|
104
104
|
// Full catalogs are cached separately from the allowlist applied to DSH.
|
|
105
105
|
// This keeps the model picker useful after a restart without widening the
|
|
106
106
|
// active llm-pi-ai catalog.
|
|
@@ -108,7 +108,7 @@ export const Config = Schema.object({
|
|
|
108
108
|
provider: Schema.string().required(),
|
|
109
109
|
id: Schema.string().required(),
|
|
110
110
|
name: Schema.string().required(),
|
|
111
|
-
}).loose())).default({}),
|
|
111
|
+
}).loose())).default({}).volatile(),
|
|
112
112
|
})
|
|
113
113
|
|
|
114
114
|
// DSH serves a namespace's settings form from the volatile fields of its profile
|
|
@@ -123,6 +123,7 @@ export function plainConfig(value) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function apply(ctx, config) {
|
|
126
|
+
let liveConfig = undefined
|
|
126
127
|
const baseConfig = structuredClone(plainConfig(config || {}))
|
|
127
128
|
const resolveConfig = (value) => {
|
|
128
129
|
try {
|
|
@@ -134,7 +135,7 @@ export function apply(ctx, config) {
|
|
|
134
135
|
return liveConfig ?? baseConfig
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
|
-
|
|
138
|
+
liveConfig = resolveConfig(baseConfig)
|
|
138
139
|
let reconfigure = () => {}
|
|
139
140
|
let saveConfigImpl = async (patch) => {
|
|
140
141
|
const plainPatch = plainConfig(patch)
|
|
@@ -146,14 +147,16 @@ export function apply(ctx, config) {
|
|
|
146
147
|
ctx.logger.debug('[dsh-model-sync] settings service read failed:', err)
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
|
-
if (settings
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
if (!settings || typeof settings.update !== 'function') {
|
|
151
|
+
throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await settings.update(NS, plainPatch)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (typeof ctx.logger?.warn === 'function') {
|
|
157
|
+
ctx.logger.warn('[dsh-model-sync] settings.update failed:', err)
|
|
156
158
|
}
|
|
159
|
+
throw err
|
|
157
160
|
}
|
|
158
161
|
liveConfig = resolveConfig({ ...liveConfig, ...plainPatch })
|
|
159
162
|
reconfigure()
|
package/lib/models.js
CHANGED
|
@@ -273,6 +273,7 @@ function normalizePricing(row) {
|
|
|
273
273
|
}
|
|
274
274
|
if (typeof source.currency === 'string' && source.currency.trim()) pricing.currency = source.currency.trim()
|
|
275
275
|
if (typeof source.unit === 'string' && source.unit.trim()) pricing.unit = source.unit.trim()
|
|
276
|
+
if (typeof source.source === 'string' && source.source.trim()) pricing.source = source.source.trim()
|
|
276
277
|
return pricing
|
|
277
278
|
}
|
|
278
279
|
|
package/lib/policy.js
CHANGED
|
@@ -41,6 +41,12 @@ function isDangerousPattern(pattern) {
|
|
|
41
41
|
if (/\([^\)]*([+*]|\{[0-9]+,[0-9]*\})[^\)]*\)([+*]|\{[0-9]+,[0-9]*\})/.test(pattern)) return true
|
|
42
42
|
// Check for multiple consecutive open-ended wildcards (e.g. .*.*)
|
|
43
43
|
if (/(\.\*|\.\+){2,}/.test(pattern)) return true
|
|
44
|
+
// Check for quantified alternation groups like (a|aa)+, (a|b)+, (a|b)*, (a|aa){2,}
|
|
45
|
+
let simplified = pattern
|
|
46
|
+
while (/\(\(([^()]+)\)\)/.test(simplified)) {
|
|
47
|
+
simplified = simplified.replace(/\(\(([^()]+)\)\)/g, '($1)')
|
|
48
|
+
}
|
|
49
|
+
if (/\((?:[^()]*\|)+[^()]*\)\s*([+*]|\{[0-9]+,[0-9]*\})/.test(simplified)) return true
|
|
44
50
|
return false
|
|
45
51
|
}
|
|
46
52
|
|
package/lib/reporting.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeSecretString } from './credentials.js'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
3
|
|
|
3
4
|
export const DEFAULT_NOTIFICATION_LIMIT = 50
|
|
@@ -14,10 +15,7 @@ export function notificationLimit(value) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
function safeMessage(value) {
|
|
17
|
-
return
|
|
18
|
-
.replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
19
|
-
.replace(/([?&](?:key|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi, '$1[redacted]')
|
|
20
|
-
.slice(0, 240)
|
|
18
|
+
return sanitizeSecretString(value).slice(0, 240)
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
function stable(value) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeSecretString } from './credentials.js'
|
|
1
2
|
import { statusOfError } from './reliability.js'
|
|
2
3
|
import { historyEntry } from './history.js'
|
|
3
4
|
import { normalizeModels } from './models.js'
|
|
@@ -89,7 +90,7 @@ export function errorMessage(error) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function diagnosticMessage(error) {
|
|
92
|
-
return errorMessage(error)
|
|
93
|
+
return sanitizeSecretString(errorMessage(error)).slice(0, 160)
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export function adapterErrorType(error) {
|
package/lib/synchronizer.js
CHANGED
|
@@ -159,12 +159,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
159
159
|
recordCredentialOutcome(row.provider, { status: 'ok', retries })
|
|
160
160
|
if (result && result.notModified) {
|
|
161
161
|
const config = getConfig() ?? {}
|
|
162
|
-
const
|
|
162
|
+
const existing = providerHttpMeta.get(row.provider)
|
|
163
|
+
if (existing && (result.etag || result.lastModified)) {
|
|
164
|
+
if (result.etag) existing.etag = result.etag
|
|
165
|
+
if (result.lastModified) existing.lastModified = result.lastModified
|
|
166
|
+
}
|
|
167
|
+
const memoryModels = existing?.models
|
|
168
|
+
const cached = normalizeModels(row.provider, memoryModels ?? config.modelCatalogs?.[row.provider] ?? row.models ?? [])
|
|
163
169
|
return { provider: row.provider, status: 'ok', models: cached, retries, notModified: true }
|
|
164
170
|
}
|
|
165
171
|
const models = Array.isArray(result) ? result : []
|
|
166
172
|
if (result?.__httpMeta) {
|
|
167
|
-
providerHttpMeta.set(row.provider, result.__httpMeta)
|
|
173
|
+
providerHttpMeta.set(row.provider, { ...result.__httpMeta, models })
|
|
168
174
|
}
|
|
169
175
|
return { provider: row.provider, status: 'ok', models, retries }
|
|
170
176
|
} catch (error) {
|
|
@@ -332,7 +338,10 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
332
338
|
const advertised = selectedModels.length
|
|
333
339
|
? policyModels.filter((model) => selectedModels.includes(model.id))
|
|
334
340
|
: policyModels
|
|
335
|
-
const
|
|
341
|
+
const policyExcludedIds = current
|
|
342
|
+
.filter((model) => !filterModels([model], policy).length || (selectedModels.length && !selectedModels.includes(model.id)))
|
|
343
|
+
.map((model) => model.id)
|
|
344
|
+
const explicitRemoveIds = new Set([...lifecycle.removedIds, ...policyExcludedIds])
|
|
336
345
|
const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
|
|
337
346
|
results.push({
|
|
338
347
|
...discovered,
|
|
@@ -562,14 +571,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
562
571
|
notifications: notificationLedger,
|
|
563
572
|
updateNotification: updateNotificationState,
|
|
564
573
|
rollbackHistory,
|
|
565
|
-
testWebhook: async ({ url, secret }) => {
|
|
574
|
+
testWebhook: async ({ url, secret, secretRef }) => {
|
|
566
575
|
let resolvedSecret = secret || ''
|
|
567
|
-
|
|
576
|
+
const targetRef = (secretRef || (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret) ? secret : '')).trim()
|
|
577
|
+
if (targetRef) {
|
|
568
578
|
try {
|
|
569
|
-
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(
|
|
570
|
-
if (res) resolvedSecret = typeof res === 'string' ? res : res?.value ||
|
|
579
|
+
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(targetRef) : ctx?.get?.('credentials')?.resolve?.(targetRef))
|
|
580
|
+
if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || ''
|
|
571
581
|
} catch (err) {
|
|
572
|
-
ctx?.logger?.
|
|
582
|
+
ctx?.logger?.warn?.('[dsh-model-sync] secret ref resolution failed in testWebhook:', err)
|
|
583
|
+
}
|
|
584
|
+
if (!resolvedSecret && secretRef) {
|
|
585
|
+
return { ok: false, error: `Failed to resolve webhook secret credential reference '${secretRef}'` }
|
|
573
586
|
}
|
|
574
587
|
}
|
|
575
588
|
return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
|
package/lib/updater.js
CHANGED
|
@@ -20,19 +20,33 @@ function header(request, name) {
|
|
|
20
20
|
return Array.isArray(value) ? value[0] : value
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function isLoopbackIPv4(address) {
|
|
24
|
+
if (typeof address !== 'string' || !address.startsWith('127.')) return false
|
|
25
|
+
const parts = address.split('.')
|
|
26
|
+
if (parts.length !== 4) return false
|
|
27
|
+
return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export function isLoopback(value) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
address
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
31
|
+
if (typeof value !== 'string') return false
|
|
32
|
+
const address = value.toLowerCase().replace(/^\[|\]$/g, '')
|
|
33
|
+
if (address === 'localhost' || address === 'localhost.' || address === '::1') return true
|
|
34
|
+
if (address.startsWith('::ffff:')) {
|
|
35
|
+
return isLoopbackIPv4(address.slice('::ffff:'.length))
|
|
36
|
+
}
|
|
37
|
+
return isLoopbackIPv4(address)
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
export function isTrustedUpdateRequest(request) {
|
|
40
|
+
export function isTrustedUpdateRequest(request, targetCtx = null) {
|
|
35
41
|
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
42
|
+
|
|
43
|
+
// 1. Core connection rejection check (#237)
|
|
44
|
+
const conn = targetCtx?.connection || targetCtx?.get?.('connection') || request?.connection
|
|
45
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
46
|
+
const rejection = conn.requestRejection(request)
|
|
47
|
+
if (rejection !== undefined) return false
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
const remote = request.socket?.remoteAddress
|
|
37
51
|
if (!isLoopback(remote)) return false
|
|
38
52
|
const site = header(request, 'sec-fetch-site')
|
|
@@ -295,6 +309,21 @@ export function registerPluginUpdater(ctx, options = {}) {
|
|
|
295
309
|
|
|
296
310
|
const handler = async (request, response) => {
|
|
297
311
|
try {
|
|
312
|
+
// 1. Core connection rejection check on all updater routes (#237)
|
|
313
|
+
const conn = ctx?.connection || ctx?.get?.('connection') || request?.connection
|
|
314
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
315
|
+
const rejection = conn.requestRejection(request)
|
|
316
|
+
if (rejection !== undefined) {
|
|
317
|
+
const status = typeof rejection === 'number' ? rejection : 401
|
|
318
|
+
response.writeHead(status, {
|
|
319
|
+
'content-type': 'application/json; charset=utf-8',
|
|
320
|
+
'cache-control': 'no-store',
|
|
321
|
+
})
|
|
322
|
+
response.end(JSON.stringify({ error: 'unauthorized', code: status }))
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
298
327
|
const target = runtime()
|
|
299
328
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
300
329
|
const isFresh = Boolean(
|
|
@@ -317,7 +346,7 @@ export function registerPluginUpdater(ctx, options = {}) {
|
|
|
317
346
|
response.end()
|
|
318
347
|
return
|
|
319
348
|
}
|
|
320
|
-
if (!isTrustedUpdateRequest(request)) {
|
|
349
|
+
if (!isTrustedUpdateRequest(request, ctx)) {
|
|
321
350
|
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
322
351
|
return
|
|
323
352
|
}
|