@goodandready/dsh-key-rotation 0.8.51 → 0.8.53
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 +32 -0
- package/lib/client.js +6 -0
- package/lib/index.js +2 -0
- package/lib/ops-keys.js +27 -2
- package/lib/ops-paths.js +1 -0
- package/lib/ops-status.js +2 -0
- package/lib/ops-telemetry.js +49 -0
- package/lib/pool-builder.js +24 -0
- package/lib/pool.js +10 -3
- package/lib/resolver.js +10 -1
- package/lib/rotate.js +7 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.53 - 2026-10-09
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Official DSH Desktop Build Compatibility (#465, GitHub #22)**:
|
|
7
|
+
- Fixed issue where all mutating routes (POST, PUT, DELETE) answered `403 "forbidden"` inside the official DSH Desktop application (reported by @onech1cken in GitHub #22).
|
|
8
|
+
- The Desktop shell (`dsh-app://app/`) uses `forwardWebRequest` proxying which strips the `Origin` header before forwarding requests to the in-process server.
|
|
9
|
+
- `isTrustedBridgeRequest` now permits absent `Origin` headers (`origin === undefined`) for mutating requests when the remote peer address is verified loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) and the `Host` header is verified loopback/localhost with no cross-site fetch metadata (`sec-fetch-site !== cross-site`). Real web browsers unconditionally attach `Origin` to cross-origin writes, preventing CSRF.
|
|
10
|
+
- Explicitly allowed `dsh-app://app` origin when forwarded by Desktop shell.
|
|
11
|
+
- Preserved strict fail-closed security for non-loopback clients, DNS rebinding, malformed origins, and cross-site requests.
|
|
12
|
+
|
|
13
|
+
## 0.8.52 - 2026-10-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Key Pinning (`pinnedRef`) with Resilient Failover Bypass (#455)**:
|
|
17
|
+
- Added `pinnedRef` (alias `useKeyRef`) to provider settings schema and pool builder.
|
|
18
|
+
- In key selection (`lib/resolver.js` and `lib/rotate.js`), healthy pinned keys are prioritized first.
|
|
19
|
+
- When a pinned key enters cooldown, is paused/revoked, or runs out of token quota, requests automatically and seamlessly bypass the pin to rotate among remaining healthy keys, preventing agent stalls.
|
|
20
|
+
- **User-Defined Key Labels (#456)**:
|
|
21
|
+
- Added `labels` string array aligned with provider `keys` in settings schema and pool models.
|
|
22
|
+
- Exposed per-key in `GET /dsh-key-rotation/status` (`label` field) and provider overview.
|
|
23
|
+
- Displayed as visual tag badges in WebUI settings card.
|
|
24
|
+
- **Secure Plaintext Key Reveal Endpoint (#457)**:
|
|
25
|
+
- Added `GET /dsh-key-rotation/key?ref=...` in `lib/ops-keys.js` with loopback and same-origin validation (`isTrustedBridgeRequest`).
|
|
26
|
+
- Decrypts and returns `{ ref, plaintext, tail }` for operator auditing and copying without raw disk inspection.
|
|
27
|
+
- **In-Memory Client Diagnostics Ring Buffer (#458)**:
|
|
28
|
+
- Added bounded 200-entry diagnostics ring buffer and `/dsh-key-rotation/diag` route in `lib/ops-telemetry.js`.
|
|
29
|
+
- Ingests frontend runtime errors and fetch failures via `POST /diag` and retrieves log entries via `GET /diag`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **PeerDependencies Triplet SemVer Range (#459)**:
|
|
33
|
+
- Updated `@deepseek-ai/dsh-llm` peer dependency range to `^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0` in `package.json` to prevent peer dependency warnings across DSH `0.2.0-rc.*` releases.
|
|
34
|
+
|
|
3
35
|
## 0.8.51 - 2026-10-06
|
|
4
36
|
|
|
5
37
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -1886,6 +1886,12 @@ window.__ModuleLoader__.load({
|
|
|
1886
1886
|
h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
|
|
1887
1887
|
h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
|
|
1888
1888
|
];
|
|
1889
|
+
if (info && info.pinned) {
|
|
1890
|
+
meta.push(h('span', { key: 'pin', className: 'krot-state', title: 'Pinned key' }, '📌'));
|
|
1891
|
+
}
|
|
1892
|
+
if (info && info.label) {
|
|
1893
|
+
meta.push(h('span', { key: 'lbl', className: 'krot-tag', title: 'Key label' }, info.label));
|
|
1894
|
+
}
|
|
1889
1895
|
if (fromEnv) {
|
|
1890
1896
|
meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
|
|
1891
1897
|
info.tail ? '••••' + info.tail : t('keyFromEnv')));
|
package/lib/index.js
CHANGED
|
@@ -118,6 +118,8 @@ const SettingsSchema = Schema.object({
|
|
|
118
118
|
revoked: Schema.array(Schema.boolean()).default([]),
|
|
119
119
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
120
120
|
tags: Schema.array(Schema.string()).default([]),
|
|
121
|
+
labels: Schema.array(Schema.string()).default([]),
|
|
122
|
+
pinnedRef: Schema.string().default(''),
|
|
121
123
|
costBudgetDaily: Schema.number(),
|
|
122
124
|
costBudgetWeekly: Schema.number(),
|
|
123
125
|
pauseOnBudget: Schema.boolean().default(false),
|
package/lib/ops-keys.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isTrustedBridgeRequest,
|
|
11
11
|
isValidRef,
|
|
12
12
|
keyTail,
|
|
13
|
+
envValue,
|
|
13
14
|
} from './pool.js';
|
|
14
15
|
import { looksLikeApiSecret } from './keycheck.js';
|
|
15
16
|
import { mutateSettings, validateProviders } from './settings-write.js';
|
|
@@ -43,8 +44,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
43
44
|
kind: 'exact',
|
|
44
45
|
path: KEY_PATH,
|
|
45
46
|
handler: async (req, res) => {
|
|
46
|
-
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
47
|
-
json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
|
|
47
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE' && req.method !== 'GET') {
|
|
48
|
+
json(res, 405, { error: { code: 'method', message: 'GET, PUT or DELETE only' } });
|
|
48
49
|
return;
|
|
49
50
|
}
|
|
50
51
|
if (!isTrustedBridgeRequest(req)) {
|
|
@@ -52,6 +53,30 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
55
|
const credentialsService = ctx.get('credentials');
|
|
56
|
+
if (req.method === 'GET') {
|
|
57
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
58
|
+
const ref = url.searchParams.get('ref')?.trim();
|
|
59
|
+
if (!isValidRef(ref)) {
|
|
60
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
|
|
65
|
+
let raw = hit?.value;
|
|
66
|
+
if (!raw) {
|
|
67
|
+
raw = envValue(ref);
|
|
68
|
+
}
|
|
69
|
+
if (!raw) {
|
|
70
|
+
json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const plain = decryptSecret(raw);
|
|
74
|
+
json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
55
80
|
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
|
56
81
|
json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
|
|
57
82
|
return;
|
package/lib/ops-paths.js
CHANGED
|
@@ -10,3 +10,4 @@ export const TEST_PATH = '/dsh-key-rotation/test';
|
|
|
10
10
|
export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
11
11
|
export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
|
|
12
12
|
export const METRICS_PATH = '/dsh-key-rotation/metrics';
|
|
13
|
+
export const DIAG_PATH = '/dsh-key-rotation/diag';
|
package/lib/ops-status.js
CHANGED
|
@@ -127,6 +127,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
127
127
|
providerName: pool.provider ?? providerOfBase(pool.base),
|
|
128
128
|
basePool: basePool?.base ?? null,
|
|
129
129
|
model: pool.model ?? null,
|
|
130
|
+
pinnedRef: pool.pinnedRef ?? null,
|
|
131
|
+
labels: pool.labels ?? {},
|
|
130
132
|
quotasConfigured: hasModelQuotaConfig(pool),
|
|
131
133
|
keys,
|
|
132
134
|
tags: providerTags?.get?.(pool.base) ?? [],
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
USAGE_PATH,
|
|
15
15
|
SNAPSHOT_PATH,
|
|
16
|
+
DIAG_PATH,
|
|
16
17
|
} from './ops-paths.js';
|
|
17
18
|
import { findSecrets } from './keycheck.js';
|
|
18
19
|
import { mutateSettings, sectionOps } from './settings-write.js';
|
|
@@ -21,6 +22,24 @@ import { mutateSettings, sectionOps } from './settings-write.js';
|
|
|
21
22
|
* @param {object} ctx cordis context
|
|
22
23
|
* @param {object} deps live dependencies from apply()
|
|
23
24
|
*/
|
|
25
|
+
|
|
26
|
+
const DIAG_BUFFER_LIMIT = 200;
|
|
27
|
+
const diagBuffer = [];
|
|
28
|
+
|
|
29
|
+
export function pushDiag(entry) {
|
|
30
|
+
if (diagBuffer.length >= DIAG_BUFFER_LIMIT) {
|
|
31
|
+
diagBuffer.shift();
|
|
32
|
+
}
|
|
33
|
+
diagBuffer.push({
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
...entry,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getDiagBuffer() {
|
|
40
|
+
return diagBuffer.slice();
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export function registerTelemetryRoutes(ctx, deps) {
|
|
25
44
|
const {
|
|
26
45
|
buildRuntime,
|
|
@@ -111,4 +130,34 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
111
130
|
},
|
|
112
131
|
}), 'dsh-key-rotation: snapshot route');
|
|
113
132
|
|
|
133
|
+
ctx.effect(() => ctx.webServer.register({
|
|
134
|
+
kind: 'exact',
|
|
135
|
+
path: DIAG_PATH,
|
|
136
|
+
handler: async (req, res) => {
|
|
137
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
138
|
+
json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
142
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: diag is local-only' } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (req.method === 'POST') {
|
|
146
|
+
let body;
|
|
147
|
+
try { body = await readJson(req); } catch (e) {
|
|
148
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
pushDiag({
|
|
152
|
+
level: typeof body?.level === 'string' ? body.level : 'info',
|
|
153
|
+
message: typeof body?.message === 'string' ? body.message.slice(0, 500) : 'unknown',
|
|
154
|
+
details: body?.details ?? null,
|
|
155
|
+
});
|
|
156
|
+
json(res, 200, { ok: true, count: diagBuffer.length });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
json(res, 200, { diag: getDiagBuffer() });
|
|
160
|
+
},
|
|
161
|
+
}), 'dsh-key-rotation: diag route');
|
|
162
|
+
|
|
114
163
|
}
|
package/lib/pool-builder.js
CHANGED
|
@@ -33,6 +33,9 @@ export function buildPoolItem({
|
|
|
33
33
|
model = null,
|
|
34
34
|
quotas = null,
|
|
35
35
|
quotaResetWindow = null,
|
|
36
|
+
labels = null,
|
|
37
|
+
pinnedRef = null,
|
|
38
|
+
useKeyRef = null,
|
|
36
39
|
}) {
|
|
37
40
|
const seen = new Set();
|
|
38
41
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -84,6 +87,21 @@ export function buildPoolItem({
|
|
|
84
87
|
for (let i = 0; i < refs.length; i++) {
|
|
85
88
|
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
86
89
|
}
|
|
90
|
+
const labelsMap = {};
|
|
91
|
+
if (Array.isArray(labels)) {
|
|
92
|
+
for (let i = 0; i < refs.length; i++) {
|
|
93
|
+
const lbl = labels[indexed[i].index];
|
|
94
|
+
if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
|
|
95
|
+
}
|
|
96
|
+
} else if (labels && typeof labels === 'object') {
|
|
97
|
+
for (let i = 0; i < refs.length; i++) {
|
|
98
|
+
const lbl = labels[refs[i]];
|
|
99
|
+
if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const cleanPinned = typeof pinnedRef === 'string' && pinnedRef.trim()
|
|
103
|
+
? pinnedRef.trim()
|
|
104
|
+
: (typeof useKeyRef === 'string' && useKeyRef.trim() ? useKeyRef.trim() : null);
|
|
87
105
|
const normalizedQuotas = normalizeModelQuotas(quotas);
|
|
88
106
|
return {
|
|
89
107
|
base,
|
|
@@ -107,6 +125,8 @@ export function buildPoolItem({
|
|
|
107
125
|
concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
|
|
108
126
|
routingStrategy: poolStrategy,
|
|
109
127
|
proactiveRateLimitGuard: poolGuard,
|
|
128
|
+
labels: labelsMap,
|
|
129
|
+
pinnedRef: cleanPinned,
|
|
110
130
|
};
|
|
111
131
|
}
|
|
112
132
|
|
|
@@ -145,6 +165,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
145
165
|
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
146
166
|
provider: p.provider,
|
|
147
167
|
model: null,
|
|
168
|
+
labels: p.labels,
|
|
169
|
+
pinnedRef: p.pinnedRef ?? p.useKeyRef,
|
|
148
170
|
}, provRpm, provTpm, provConcurrency);
|
|
149
171
|
if (pool) {
|
|
150
172
|
for (const ref of pool.refs) {
|
|
@@ -171,6 +193,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
171
193
|
provider: p.provider,
|
|
172
194
|
model,
|
|
173
195
|
quotas: mp.quotas,
|
|
196
|
+
labels: mp.labels ?? p.labels,
|
|
197
|
+
pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
|
|
174
198
|
}, modelRpm, modelTpm, modelConcurrency);
|
|
175
199
|
if (!mpool) continue;
|
|
176
200
|
mpool.basePool = pool;
|
package/lib/pool.js
CHANGED
|
@@ -42,12 +42,19 @@ export function isTrustedBridgeRequest(req) {
|
|
|
42
42
|
if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
|
|
43
43
|
|
|
44
44
|
const origin = req?.headers?.origin;
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (origin === undefined) {
|
|
46
|
+
// Official Desktop build forwards requests via forwardWebRequest which strips Origin (#465 / GitHub #22).
|
|
47
|
+
// Real browsers unconditionally send Origin on cross-origin mutations, so an absent Origin cannot originate
|
|
48
|
+
// from a malicious website. Peer address and Host header are already verified loopback above.
|
|
49
|
+
return true;
|
|
48
50
|
}
|
|
51
|
+
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
49
52
|
try {
|
|
50
53
|
const originUrl = new URL(origin);
|
|
54
|
+
// Official Desktop build shell origin
|
|
55
|
+
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app')) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
51
58
|
if (originUrl.host !== hostHeader) return false;
|
|
52
59
|
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
53
60
|
return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
|
package/lib/resolver.js
CHANGED
|
@@ -125,7 +125,16 @@ export function createResolver({
|
|
|
125
125
|
// original credential must never be used as a bypass. Decided once, before
|
|
126
126
|
// the loop, so it cannot be re-evaluated mid-iteration.
|
|
127
127
|
const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
|
|
128
|
-
|
|
128
|
+
let start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
|
|
129
|
+
if (pool.pinnedRef && list.includes(pool.pinnedRef)) {
|
|
130
|
+
const pIdx = list.indexOf(pool.pinnedRef);
|
|
131
|
+
if (pIdx > 0) {
|
|
132
|
+
list = list.slice();
|
|
133
|
+
list.splice(pIdx, 1);
|
|
134
|
+
list.unshift(pool.pinnedRef);
|
|
135
|
+
}
|
|
136
|
+
start = 0;
|
|
137
|
+
}
|
|
129
138
|
|
|
130
139
|
for (let i = 0; i < list.length; i++) {
|
|
131
140
|
const index = (start + i) % list.length;
|
package/lib/rotate.js
CHANGED
|
@@ -89,6 +89,13 @@ export function createRotate(deps) {
|
|
|
89
89
|
const quotaManaged = hasModelQuotaConfig(pool);
|
|
90
90
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
91
91
|
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow));
|
|
92
|
+
if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
|
|
93
|
+
const pIdx = attemptList.indexOf(pool.pinnedRef);
|
|
94
|
+
if (pIdx > 0) {
|
|
95
|
+
attemptList.splice(pIdx, 1);
|
|
96
|
+
attemptList.unshift(pool.pinnedRef);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
92
99
|
if (quotaManaged) {
|
|
93
100
|
attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
|
|
94
101
|
if (attemptList.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.53",
|
|
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": [
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
60
|
-
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 ||
|
|
60
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0",
|
|
61
61
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|