@goodandready/dsh-model-sync 0.5.4 → 0.5.6

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.
@@ -12,7 +12,7 @@ const PROVIDER_ADAPTERS = Object.freeze({
12
12
  clinebot: {
13
13
  endpoint: 'https://api.cline.bot/api/v1/users/me/plan',
14
14
  auth: 'bearer',
15
- parser: 'clinebot-plan',
15
+ parse: 'clinebot-plan',
16
16
  defaultModels: [
17
17
  'cline-pass/deepseek-v4-flash',
18
18
  'cline-pass/deepseek-v4-pro',
@@ -99,12 +99,13 @@ function mappedRows(provider, rows, descriptor) {
99
99
  }
100
100
 
101
101
  function parseRows(provider, payload, descriptor) {
102
- if (descriptor.parse === 'clinebot-plan') {
102
+ const parserKind = descriptor.parse || descriptor.parser
103
+ if (parserKind === 'clinebot-plan') {
103
104
  const plan = payload?.data?.plan || payload?.data || payload?.plan || payload
104
105
  const included = plan?.features?.included || plan?.includedModels || []
105
106
  return parseClinePassModels(included)
106
107
  }
107
- if (descriptor.parse === 'google') {
108
+ if (parserKind === 'google') {
108
109
  const rows = Array.isArray(payload?.models) ? payload.models : []
109
110
  return normalizeModels(provider, rows.map((row) => ({
110
111
  ...row,
@@ -136,6 +137,24 @@ export function normalizeAdapterConfig(config, profile = {}) {
136
137
  if (endpoint.protocol === 'http:' && !privateHttpHost(endpoint.hostname)) {
137
138
  throw Object.assign(new Error('adapter endpoint must use https outside loopback'), { code: 'ADAPTER_CONFIG_INVALID' })
138
139
  }
140
+ if (profile?.provider && PROVIDER_ADAPTERS[profile.provider]) {
141
+ const defaultEndpoint = PROVIDER_ADAPTERS[profile.provider].endpoint
142
+ let defaultUrl
143
+ try { defaultUrl = new URL(defaultEndpoint) } catch (_err) { defaultUrl = null }
144
+ if (defaultUrl) {
145
+ const knownHost = defaultUrl.hostname.toLowerCase()
146
+ const targetHost = endpoint.hostname.toLowerCase()
147
+ const isDomainMatch = targetHost === knownHost || targetHost.endsWith('.' + knownHost.replace(/^api\./, ''))
148
+ const isLoopback = privateHttpHost(targetHost)
149
+ const isExplicitlyAllowed = source.allowCustomHost === true || source.allowCustomEndpoint === true
150
+ if (!isDomainMatch && !isLoopback && !isExplicitlyAllowed) {
151
+ throw Object.assign(
152
+ new Error(`adapter endpoint host "${endpoint.hostname}" does not match known provider domain for "${profile.provider}"`),
153
+ { code: 'ADAPTER_HOST_MISMATCH' }
154
+ )
155
+ }
156
+ }
157
+ }
139
158
  const auth = source.auth === undefined ? 'bearer' : String(source.auth)
140
159
  if (!SAFE_AUTH.has(auth)) throw Object.assign(new Error('adapter auth mode is unsupported'), { code: 'ADAPTER_CONFIG_INVALID' })
141
160
  const parse = source.parser === undefined ? 'openai' : String(source.parser)
@@ -224,9 +243,6 @@ async function fetchSpecific(profile, descriptor, options) {
224
243
  return rows
225
244
  }
226
245
  } catch (error) {
227
- if (descriptor.defaultModels && Array.isArray(descriptor.defaultModels) && descriptor.defaultModels.length > 0) {
228
- return normalizeModels(profile.provider, descriptor.defaultModels.map((id) => ({ id })))
229
- }
230
246
  throw error
231
247
  }
232
248
  if (descriptor.defaultModels && Array.isArray(descriptor.defaultModels) && descriptor.defaultModels.length > 0) {
package/lib/client.js CHANGED
@@ -785,6 +785,20 @@ window.__ModuleLoader__.load({
785
785
  .finally(() => setBusy(false))
786
786
  }
787
787
 
788
+ const resolveSmartAlias = (aliasName) => {
789
+ setResolvingKey(aliasName)
790
+ fetch(`${API}/aliases/resolve?alias=${encodeURIComponent(aliasName)}`)
791
+ .then(async (r) => {
792
+ const res = await r.json()
793
+ if (!r.ok) throw new Error(res.error?.message ?? `HTTP ${r.status}`)
794
+ setResolvedAliases((prev) => ({ ...prev, [aliasName]: res }))
795
+ })
796
+ .catch((e) => {
797
+ setResolvedAliases((prev) => ({ ...prev, [aliasName]: { error: String(e?.message ?? e) } }))
798
+ })
799
+ .finally(() => setResolvingKey(''))
800
+ }
801
+
788
802
  const load = () => {
789
803
  if (typeof document !== 'undefined' && document.hidden) return
790
804
  setError('')
@@ -114,6 +114,20 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
114
114
  if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
115
115
  return { ok: false, error: "URL must use http or https protocol" }
116
116
  }
117
+ 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
+ ) {
125
+ return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
126
+ }
127
+ const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
128
+ if (parsedUrl.protocol === "http:" && !isLoopback) {
129
+ return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
130
+ }
117
131
  } catch (err) {
118
132
  return { ok: false, error: "Invalid URL: " + (err?.message || String(err)) }
119
133
  }
@@ -181,9 +195,21 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
181
195
 
182
196
  let webhookResult = null
183
197
  if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
198
+ let secret = config.diffWebhookSecret || ''
199
+ const secretRef = config.diffWebhookSecretRef || (typeof config.diffWebhookSecret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(config.diffWebhookSecret) ? config.diffWebhookSecret : '')
200
+ if (secretRef) {
201
+ try {
202
+ const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secretRef) : ctx?.get?.('credentials')?.resolve?.(secretRef))
203
+ if (res) secret = typeof res === 'string' ? res : res?.value || secret
204
+ } catch (err) {
205
+ if (typeof ctx?.logger?.debug === 'function') {
206
+ ctx.logger.debug('[dsh-model-sync] webhook secret resolution fallback:', err)
207
+ }
208
+ }
209
+ }
184
210
  webhookResult = await sendWebhookNotification(
185
211
  config.diffWebhookUrl,
186
- config.diffWebhookSecret,
212
+ secret,
187
213
  {
188
214
  event: "model-sync.diff",
189
215
  ...events,
package/lib/http.js CHANGED
@@ -2,8 +2,31 @@ import { validateAliasName } from './alias-resolver.js'
2
2
  function normalizeWebhookTestRequest(body) {
3
3
  if (!body || typeof body !== "object") throw Object.assign(new Error("body is required"), { code: "INVALID_REQUEST" })
4
4
  if (typeof body.url !== "string" || !body.url.trim()) throw Object.assign(new Error("url is required"), { code: "INVALID_REQUEST" })
5
+ let parsed
6
+ try {
7
+ parsed = new URL(body.url.trim())
8
+ } catch (err) {
9
+ throw Object.assign(new Error("Invalid URL: " + (err?.message || String(err))), { code: "INVALID_REQUEST" })
10
+ }
11
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
12
+ throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
13
+ }
14
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
15
+ const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
16
+ if (parsed.protocol === "http:" && !isLoopback) {
17
+ throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
18
+ }
19
+ if (
20
+ hostname === '169.254.169.254' ||
21
+ hostname.startsWith('169.254.') ||
22
+ hostname === 'metadata.google.internal' ||
23
+ hostname === '0.0.0.0' ||
24
+ hostname === '255.255.255.255'
25
+ ) {
26
+ throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
27
+ }
5
28
  return {
6
- url: body.url.trim(),
29
+ url: parsed.toString(),
7
30
  secret: typeof body.secret === "string" ? body.secret.trim() : "",
8
31
  }
9
32
  }
@@ -28,7 +51,9 @@ export function isLoopbackAddress(ip) {
28
51
  export function trusted(req, { requireLoopback = false } = {}) {
29
52
  const remoteAddress = req?.socket?.remoteAddress
30
53
  if (requireLoopback || req?.method === 'POST') {
31
- if (!isLoopbackAddress(remoteAddress)) return false
54
+ if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
55
+ if (!remoteAddress && req?.socket) return false
56
+ if (!remoteAddress && !isLoopbackAddress(req?.headers?.host?.split(':')[0])) return false
32
57
  }
33
58
  const fetchSite = req.headers?.['sec-fetch-site']
34
59
  if (fetchSite === 'cross-site') return false
@@ -59,8 +84,10 @@ export function trusted(req, { requireLoopback = false } = {}) {
59
84
  if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
60
85
  // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
61
86
  if (req.headers?.['sec-fetch-mode'] && !host) return false
62
- // For POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
63
- if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
87
+ // For requests requiring loopback or POST without any browser origin/referer headers, require explicit loopback
88
+ if ((requireLoopback || req?.method === 'POST') && !origin && !referer) {
89
+ if (!isLoopbackAddress(remoteAddress ?? (req?.headers?.host ? req.headers.host.split(':')[0] : ''))) return false
90
+ }
64
91
  return true
65
92
  }
66
93
 
@@ -247,7 +274,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
247
274
  return []
248
275
  }
249
276
  const makeHandler = (route) => async (req, res) => {
250
- if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
277
+ const isPublicStatus = route === 'status' && req.method === 'GET'
278
+ if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
251
279
  if (route === 'status') {
252
280
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
253
281
  const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
package/lib/index.js CHANGED
@@ -27,7 +27,8 @@ export const Config = Schema.object({
27
27
  historyLimit: Schema.number().default(50).volatile(),
28
28
  notificationLimit: Schema.number().default(50).volatile(),
29
29
  diffWebhookUrl: Schema.string().default().volatile(),
30
- diffWebhookSecret: Schema.string().default().volatile(),
30
+ diffWebhookSecretRef: Schema.string().role('credential-ref').default('').volatile(),
31
+ diffWebhookSecret: Schema.string().role('credential-ref').default('').volatile(),
31
32
  notifyOnModelAdded: Schema.boolean().default(true).volatile(),
32
33
  notifyOnModelDeprecated: Schema.boolean().default(true).volatile(),
33
34
  notifyOnPriceChanged: Schema.boolean().default(true).volatile(),
@@ -123,7 +124,16 @@ export function plainConfig(value) {
123
124
 
124
125
  export function apply(ctx, config) {
125
126
  const baseConfig = structuredClone(plainConfig(config || {}))
126
- const resolveConfig = (value) => plainConfig(Config(plainConfig(value) || {}))
127
+ const resolveConfig = (value) => {
128
+ try {
129
+ return plainConfig(Config(plainConfig(value) || {}))
130
+ } catch (err) {
131
+ if (typeof ctx?.logger?.warn === "function") {
132
+ ctx.logger.warn("[dsh-model-sync] invalid config payload, fallback to current config:", err?.message || err)
133
+ }
134
+ return liveConfig ?? baseConfig
135
+ }
136
+ }
127
137
  let liveConfig = resolveConfig(baseConfig)
128
138
  let reconfigure = () => {}
129
139
  let saveConfigImpl = async (patch) => {
@@ -165,12 +175,16 @@ export function apply(ctx, config) {
165
175
  reconfigure = () => scheduler.reconfigure()
166
176
  scheduler.start()
167
177
 
168
- if (typeof ctx.on === 'function') {
169
- ctx.on('loader/volatile-update', () => {
178
+ ctx.effect(() => {
179
+ if (typeof ctx.on !== 'function') return () => {}
180
+ const dispose = ctx.on('loader/volatile-update', () => {
170
181
  liveConfig = resolveConfig(config)
171
182
  reconfigure()
172
183
  })
173
- }
184
+ return () => {
185
+ if (typeof dispose === 'function') dispose()
186
+ }
187
+ }, 'dsh-model-sync: volatile-update listener')
174
188
 
175
189
  // settings.register was removed before 0.1.7-rc.2. The host hands the profile entry
176
190
  // config to apply() directly, and liveConfig above is derived from it; the
@@ -189,7 +203,6 @@ export function apply(ctx, config) {
189
203
  manifestUrl: new URL('../package.json', import.meta.url),
190
204
  }), 'dsh-model-sync: plugin updater')
191
205
  ctx.effect(() => {
192
- if (!liveConfig.enabled) return () => {}
193
206
  ctx.provide('modelSync', service)
194
207
  ctx.provide('modelSyncRunner', synchronizer)
195
208
  return () => {}
package/lib/inventory.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizeModels } from './models.js'
2
+ import { sectionOf } from './synchronizer-helpers.js'
2
3
 
3
4
  export const API_KEY_PROVIDER_IDS = Object.freeze([
4
5
  'anthropic',
@@ -42,7 +43,7 @@ const API_KEY_PROVIDER_SET = new Set(API_KEY_PROVIDER_IDS)
42
43
 
43
44
  function settingsProfiles(settings) {
44
45
  try {
45
- return settings?.get('llm-pi-ai')?.providers ?? {}
46
+ return sectionOf(settings, 'llm-pi-ai')?.providers ?? {}
46
47
  } catch {
47
48
  return {}
48
49
  }
@@ -66,7 +67,7 @@ export function listApiKeyProviders(llm, settings) {
66
67
  for (const entry of directory) {
67
68
  let profile = profiles[entry.provider]
68
69
  if (!profile && (entry.provider === 'commandcode' || entry.settingsNs === 'llm-commandcode')) {
69
- const cc = settings?.get?.('llm-commandcode')
70
+ const cc = sectionOf(settings, 'llm-commandcode')
70
71
  profile = {
71
72
  apiKeyEnv: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
72
73
  credentialRef: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
@@ -55,12 +55,23 @@ export function descriptorOf(settings, ns) {
55
55
  catch { return undefined }
56
56
  }
57
57
 
58
+ export function sectionOf(settings, ns) {
59
+ try {
60
+ const desc = descriptorOf(settings, ns)
61
+ if (desc && desc.value !== undefined) return desc.value
62
+ if (typeof settings?.get === 'function') return settings.get(ns)
63
+ return undefined
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
58
69
  export function profileOf(settings, provider) {
59
70
  try {
60
- const piai = settings?.get(PIAI_NS)?.providers?.[provider]
71
+ const piai = sectionOf(settings, PIAI_NS)?.providers?.[provider]
61
72
  if (piai) return piai
62
73
  if (provider === 'commandcode') {
63
- const cc = settings?.get?.('llm-commandcode')
74
+ const cc = sectionOf(settings, 'llm-commandcode')
64
75
  return {
65
76
  apiKeyEnv: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
66
77
  credentialRef: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
@@ -97,7 +108,7 @@ export function adapterErrorType(error) {
97
108
  export async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
98
109
  let lastError
99
110
  for (let i = 0; i < attempts; i++) {
100
- const section = settings?.get?.(ns)
111
+ const section = sectionOf(settings, ns)
101
112
  const descriptor = descriptorOf(settings, ns)
102
113
  if (!section || !descriptor || typeof settings.replace !== 'function') {
103
114
  throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
@@ -120,7 +131,7 @@ export async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
120
131
 
121
132
  export function rotationRefs(settings, provider) {
122
133
  try {
123
- const rows = settings?.get?.('dsh-key-rotation')?.providers
134
+ const rows = sectionOf(settings, 'dsh-key-rotation')?.providers
124
135
  if (!Array.isArray(rows)) return []
125
136
  return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
126
137
  } catch {
@@ -149,7 +160,7 @@ export async function performHistoryRollback({ ctx, config, providers = [], hist
149
160
  if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
150
161
  if (!providers.some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
151
162
  const settings = settingsOf(ctx)
152
- const section = settings?.get?.(PIAI_NS)
163
+ const section = sectionOf(settings, PIAI_NS)
153
164
  const descriptor = descriptorOf(settings, PIAI_NS)
154
165
  if (!section || !descriptor || typeof settings.replace !== 'function') {
155
166
  throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
@@ -18,6 +18,7 @@ import {
18
18
  settingsOf,
19
19
  llmOf,
20
20
  descriptorOf,
21
+ sectionOf,
21
22
  profileOf,
22
23
  errorMessage,
23
24
  diagnosticMessage,
@@ -488,7 +489,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
488
489
  if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
489
490
 
490
491
  const settings = settingsOf(ctx)
491
- const section = settings?.get?.(PIAI_NS)
492
+ const section = sectionOf(settings, PIAI_NS)
492
493
  const descriptor = descriptorOf(settings, PIAI_NS)
493
494
  const current = normalizeModels(provider, profileOf(settings, provider).models ?? row.models)
494
495
  const nextModels = selectedModels.length
@@ -553,7 +554,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
553
554
  notifications: notificationLedger,
554
555
  updateNotification: updateNotificationState,
555
556
  rollbackHistory,
556
- testWebhook: async ({ url, secret }) => sendWebhookNotification(url, secret, { event: 'model-sync.test', timestamp: Date.now(), test: true }),
557
+ testWebhook: async ({ url, secret }) => {
558
+ let resolvedSecret = secret || ''
559
+ if (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret)) {
560
+ try {
561
+ const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secret) : ctx?.get?.('credentials')?.resolve?.(secret))
562
+ if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || resolvedSecret
563
+ } catch (err) {
564
+ ctx?.logger?.debug?.('[dsh-model-sync] secret ref resolution fallback:', err)
565
+ }
566
+ }
567
+ return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
568
+ },
557
569
  status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
558
570
  })
559
571
  }
package/lib/updater.js CHANGED
@@ -237,7 +237,6 @@ export async function installExact(target, packageSpec, options = {}) {
237
237
  '--profile',
238
238
  target.profileName,
239
239
  'add',
240
- '--config.minimumReleaseAge=0',
241
240
  packageSpec,
242
241
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
243
242
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",