@goodandready/dsh-key-rotation 0.8.16 → 0.8.17

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 CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.17 - 2026-09-21
7
+
8
+ ### Fixed
9
+ - **Provider catalog decoupling in settings UI (#333, GitHub #11 / PR #12)**: `useProviderCatalog` in `lib/client.js` now loads the active provider catalog (`/dsh-key-rotation/config`) independently of `settingsScope` readiness, using `AbortController` and `window.focus` refresh. Eliminates false-negative `(not registered)` badge on existing routes when the settings snapshot was already ready on initial render. Fallback configuration loading is protected against overwriting newer settings values.
10
+ - **Modern provider probe endpoint discovery (#333, GitHub #11 / PR #12)**: extracted dedicated resolver `resolveProbeBaseUrl(ctx, provider)` in `lib/probe-endpoint.js`. Inspects `llm.listConfigurableProviders()` and resolves connection URLs from `settingsNs` + `settingsPath` in effective settings (`settings.describe()`). Supports `baseURL`, `baseUrl`, `apiBase`, `endpoint`, and `url`, eliminating false `no-baseurl` errors when probing SenseNova and modern dictionary-configured pi-ai / standalone adapters. Implements safe path traversal and fail-closed validation.
11
+
6
12
  ## 0.8.16 - 2026-09-20
7
13
 
8
14
  ### Fixed
package/lib/client.js CHANGED
@@ -72,6 +72,7 @@ window.__ModuleLoader__.load({
72
72
  cardDesc: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
73
73
  loading: 'Loading…',
74
74
  notRegistered: 'not registered',
75
+ providerCatalogUnavailable: 'Provider list unavailable',
75
76
  removeKey: 'Remove key',
76
77
  removeProvider: 'Remove provider',
77
78
  addKey: '+ Add key',
@@ -211,6 +212,7 @@ window.__ModuleLoader__.load({
211
212
  cardDesc: '按提供商 API 密钥轮换:密钥池、配额/限流错误自动故障转移、冷却与恢复。',
212
213
  loading: '加载中…',
213
214
  notRegistered: '未注册',
215
+ providerCatalogUnavailable: '提供商列表暂不可用',
214
216
  removeKey: '移除密钥',
215
217
  removeProvider: '移除提供商',
216
218
  addKey: '+ 添加密钥',
@@ -642,6 +644,47 @@ window.__ModuleLoader__.load({
642
644
  );
643
645
  }
644
646
 
647
+ // Provider registration is not part of settingsScope's value. Fetch it
648
+ // independently, and never turn an unavailable catalog into proof of absence.
649
+ function useProviderCatalog() {
650
+ const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
651
+ const requestRef = React.useRef(null);
652
+ const reload = React.useCallback(() => {
653
+ requestRef.current?.abort();
654
+ const controller = new AbortController();
655
+ requestRef.current = controller;
656
+ setCatalog((s) => ({ ...s, status: 'loading' }));
657
+ return fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal })
658
+ .then(async (response) => {
659
+ if (!response.ok) throw new Error('provider catalog unavailable');
660
+ const data = await response.json();
661
+ if (!Array.isArray(data?.providers) || !data.providers.every((p) =>
662
+ p && typeof p.id === 'string' && p.id.length > 0)) {
663
+ throw new Error('invalid provider catalog');
664
+ }
665
+ if (controller.signal.aborted || requestRef.current !== controller) return;
666
+ setCatalog({ status: 'ready', providers: data.providers.map((p) => ({
667
+ id: p.id, name: typeof p.name === 'string' ? p.name : p.id,
668
+ })) });
669
+ })
670
+ .catch(() => {
671
+ if (!controller.signal.aborted && requestRef.current === controller) {
672
+ setCatalog((s) => ({ ...s, status: 'error' }));
673
+ }
674
+ });
675
+ }, []);
676
+ React.useEffect(() => {
677
+ reload();
678
+ // A provider may have been installed/enabled while this page was open.
679
+ window.addEventListener?.('focus', reload);
680
+ return () => {
681
+ requestRef.current?.abort();
682
+ window.removeEventListener?.('focus', reload);
683
+ };
684
+ }, [reload]);
685
+ return { ...catalog, reload };
686
+ }
687
+
645
688
  function KeyRotationSection(props) {
646
689
  const t = resolveT(props);
647
690
  const settingsScope = React.useMemo(() => {
@@ -681,6 +724,9 @@ window.__ModuleLoader__.load({
681
724
  getScopeSnapshot
682
725
  );
683
726
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
727
+ const catalog = useProviderCatalog();
728
+ const latestScopeSnapshot = React.useRef(scopeSnapshot);
729
+ latestScopeSnapshot.current = scopeSnapshot;
684
730
  // #290: surface settingsScope availability as card state
685
731
  React.useEffect(() => {
686
732
  if (!settingsScope) {
@@ -807,10 +853,15 @@ window.__ModuleLoader__.load({
807
853
  };
808
854
 
809
855
  const load = React.useCallback(() => {
856
+ const scopeIsReady = () => latestScopeSnapshot.current?.status === 'ready'
857
+ && latestScopeSnapshot.current.value;
810
858
  setState((s) => ({ ...s, status: 'loading', error: '' }));
811
859
  fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
812
860
  .then((r) => r.json())
813
861
  .then((data) => {
862
+ // The scope may have become ready while this fallback was in flight.
863
+ // A bridge snapshot must not replace that newer value or its revision.
864
+ if (scopeIsReady()) return;
814
865
  setState({
815
866
  status: 'ready',
816
867
  value: data.value ?? null,
@@ -818,9 +869,10 @@ window.__ModuleLoader__.load({
818
869
  providers: Array.isArray(data.providers) ? data.providers : [],
819
870
  error: data.error ? data.error.message : '',
820
871
  });
821
- setDraft(null);
822
872
  })
823
- .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
873
+ .catch((e) => {
874
+ if (!scopeIsReady()) setState((s) => ({ ...s, status: 'error', error: String(e) }));
875
+ });
824
876
  }, []);
825
877
 
826
878
  React.useEffect(() => {
@@ -962,7 +1014,7 @@ window.__ModuleLoader__.load({
962
1014
  );
963
1015
  }
964
1016
 
965
- const providers = state.providers;
1017
+ const providers = catalog.providers;
966
1018
  const providerById = new Map(providers.map((p) => [p.id, p.name]));
967
1019
 
968
1020
  const setField = (fn) => setDraft(fn(val));
@@ -1131,7 +1183,8 @@ window.__ModuleLoader__.load({
1131
1183
  const providerRows = providerList.map((entry, pIndex) => {
1132
1184
  const options = [];
1133
1185
  if (entry.provider && !providerById.has(entry.provider)) {
1134
- options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
1186
+ options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
1187
+ : catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
1135
1188
  }
1136
1189
  options.push(...providers.map((prov) =>
1137
1190
  h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
@@ -1466,7 +1519,10 @@ window.__ModuleLoader__.load({
1466
1519
  );
1467
1520
  });
1468
1521
 
1469
- const noProviders = providers.length === 0
1522
+ const noProviders = catalog.status === 'error'
1523
+ ? h('div', { className: 'krot-hint', role: 'status' }, t('providerCatalogUnavailable'),
1524
+ btn(t('retry'), catalog.reload))
1525
+ : catalog.status === 'ready' && providers.length === 0
1470
1526
  ? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
1471
1527
  h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
1472
1528
  h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
@@ -0,0 +1,99 @@
1
+ // Resolve only the selected provider's connection; never infer a vendor URL.
2
+ import { bestEffort } from './best-effort.js';
3
+
4
+ const own = (object, key) => object != null && typeof object === 'object'
5
+ && Object.prototype.hasOwnProperty.call(object, key);
6
+
7
+ function endpointOf(profile) {
8
+ for (const field of ['baseURL', 'baseUrl', 'apiBase', 'endpoint', 'url']) {
9
+ if (!own(profile, field) || profile[field] == null || profile[field] === '') continue;
10
+ const value = profile[field];
11
+ if (typeof value !== 'string' || !value.trim()) return null;
12
+ try {
13
+ const url = new URL(value.trim());
14
+ // /models is appended by the runner. Query/fragment/userinfo bases do
15
+ // not compose safely; HTTP loopback remains valid for local providers.
16
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
17
+ || url.search || url.hash) return null;
18
+ return value.trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function atPath(value, path) {
27
+ if (!Array.isArray(path)) return undefined;
28
+ for (const segment of path) {
29
+ if (typeof segment !== 'string' || !segment
30
+ || ['__proto__', 'constructor', 'prototype'].includes(segment) || !own(value, segment)) return undefined;
31
+ value = value[segment];
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function legacyProfile(providers, provider) {
37
+ if (Array.isArray(providers)) {
38
+ // A declared alias is routing metadata; a display name is not an identity.
39
+ const matches = providers.filter((p) => p && (p.id === provider || p.provider === provider
40
+ || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
41
+ return matches.length === 1 ? matches[0] : undefined;
42
+ }
43
+ return own(providers, provider) ? providers[provider] : undefined;
44
+ }
45
+
46
+ /** Resolve current effective settings on every probe (including after edits).
47
+ * LlmProviderInfo is display-only. Modern hosts declare the exact settings
48
+ * address via listConfigurableProviders(); no adapter/model call is necessary.
49
+ */
50
+ export async function resolveProbeBaseUrl(ctx, provider) {
51
+ if (typeof provider !== 'string' || !provider) return null;
52
+ const llm = await bestEffort('probe.llm', () => ctx?.get?.('llm') ?? ctx?.llm);
53
+ const readSections = async () => {
54
+ const settings = await bestEffort('probe.settings', () => ctx?.get?.('settings'));
55
+ const sections = await bestEffort('probe.settings.describe', () => settings?.describe?.({ redactSecrets: true }));
56
+ return Array.isArray(sections) ? sections : [];
57
+ };
58
+ if (typeof llm?.listConfigurableProviders === 'function') {
59
+ const directory = await bestEffort('probe.directory', () => llm.listConfigurableProviders());
60
+ // Do not fall through to a stale endpoint if the authoritative directory
61
+ // is unavailable or ambiguous. No exception/connection detail is logged.
62
+ if (!Array.isArray(directory)) return null;
63
+ const matches = directory.filter((entry) => entry?.provider === provider);
64
+ if (matches.length > 1) return null;
65
+ if (matches.length === 1) {
66
+ const entry = matches[0];
67
+ if (typeof entry.settingsNs !== 'string' || !entry.settingsNs) return null;
68
+ const sections = (await readSections()).filter((section) => section?.ns === entry.settingsNs);
69
+ if (sections.length !== 1) return null;
70
+ return endpointOf(atPath(sections[0].value, entry.settingsPath));
71
+ }
72
+ }
73
+
74
+ // Older integrations may still expose endpoints directly. Only exact route
75
+ // identities are accepted; never match a provider by its human-readable name.
76
+ const info = await bestEffort('probe.provider', () => llm?.getProvider?.(provider));
77
+ if (info && (info.id == null || info.id === provider)) {
78
+ const url = endpointOf(info);
79
+ if (url) return url;
80
+ }
81
+ const providers = await bestEffort('probe.providers', () => llm?.listProviders?.());
82
+ const match = Array.isArray(providers) ? providers.filter((p) => p?.id === provider) : [];
83
+ if (match.length > 1) return null;
84
+ if (match.length === 1) {
85
+ const url = endpointOf(match[0]);
86
+ if (url) return url;
87
+ }
88
+
89
+ // Compatibility for hosts predating the configurable-provider directory.
90
+ // llm-pi-ai is a known settings namespace, not necessarily a Cordis service.
91
+ const sections = (await readSections()).filter((section) => section?.ns === 'llm-pi-ai');
92
+ if (sections.length > 1) return null;
93
+ if (sections.length === 1) {
94
+ const profile = legacyProfile(sections[0].value?.providers, provider);
95
+ if (profile !== undefined) return endpointOf(profile);
96
+ }
97
+ const legacy = await bestEffort('probe.legacy', () => ctx?.get?.('llm-pi-ai'));
98
+ return endpointOf(legacyProfile(legacy?.providers ?? legacy?.config?.providers, provider));
99
+ }
@@ -1,5 +1,7 @@
1
1
  // lib/sandbox-service.js — sandbox runner and cache service
2
2
  import { LastTestCache, SandboxRunner } from './sandbox.js';
3
+ import { bestEffort } from './best-effort.js';
4
+ import { resolveProbeBaseUrl } from './probe-endpoint.js';
3
5
 
4
6
  export function createSandboxService({ getRuntime }) {
5
7
  let sandboxRunner = null;
@@ -9,36 +11,11 @@ export function createSandboxService({ getRuntime }) {
9
11
  function ensureSandboxRunner(ctx) {
10
12
  if (ctx) defaultCtx = ctx;
11
13
  if (sandboxRunner) return sandboxRunner;
12
- function resolveBaseUrl(providerOrRef) {
13
- try {
14
- let provider = providerOrRef;
15
- const rt = typeof getRuntime === 'function' ? getRuntime() : null;
16
- const pool = rt?.poolByRef?.get(providerOrRef);
17
- if (pool?.base) provider = pool.base;
18
- else if (pool?.provider) provider = pool.provider;
19
-
20
- const c = ctx || defaultCtx;
21
- const pInfo = c?.llm?.getProvider?.(provider);
22
- if (pInfo && (pInfo.baseUrl || pInfo.endpoint || pInfo.url)) {
23
- return String(pInfo.baseUrl || pInfo.endpoint || pInfo.url);
24
- }
25
- for (const info of (c?.llm?.listProviders?.() || [])) {
26
- if (info && (info.id === provider || info.name === provider)) {
27
- const u = info.baseUrl || info.endpoint || info.url;
28
- if (u) return String(u);
29
- }
30
- }
31
- const ns = c?.get ? c.get('llm-pi-ai') : null;
32
- const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
33
- if (Array.isArray(list)) {
34
- const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
35
- const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
36
- if (base) return String(base);
37
- }
38
- return null;
39
- } catch (_) {
40
- return null;
41
- }
14
+ async function resolveBaseUrl(providerOrRef) {
15
+ const rt = await bestEffort('probe.runtime', () => typeof getRuntime === 'function' ? getRuntime() : null);
16
+ const pool = rt?.poolByRef?.get(providerOrRef);
17
+ const provider = pool?.base || pool?.provider || providerOrRef;
18
+ return resolveProbeBaseUrl(defaultCtx, provider);
42
19
  }
43
20
  sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
44
21
  return sandboxRunner;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [