@goodandready/dsh-key-rotation 0.8.74 → 0.8.76

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
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.76 (2026-10-30)
4
+
5
+ ### Security Hardening, DNS Pinning & Mutation Integrity (Block 4 Release)
6
+ - **SSRF DNS Rebinding Prevention (Issue #557)**: Replaced single-shot DNS precheck with connect-time socket IP validation (`createSafeLookup`) via native HTTPS request handling (`fetchNativeJson`). Enforces loopback and private IP rejection at the exact moment the socket establishes connection across all attempts and redirects, completely neutralizing TOCTOU DNS rebinding with zero runtime dependencies.
7
+ - **Write-Origin Guard & Desktop Spoofing Elimination (Issue #516)**: Added `hasNonLoopbackForwarding` to detect requests proxied through LAN reverse proxies (`x-forwarded-for`, `x-real-ip`). Refused mutating requests (POST/PUT/DELETE/PATCH) lacking Origin or attempting to spoof Desktop headers (`@deepseek-ai/dsh-desktop/` User-Agent, `dsh-auth-` cookies, or `dsh-app://app` Origin) over LAN; ensured `/auto-import` strictly validates bodies and fails closed.
8
+ - **Config Import Optimistic Concurrency & Revision Integrity (Issue #553)**: End-to-end optimistic revision tracking in config import; dry-run preview returns settings revision, WebUI client (`50-card-main.js`) stores preview revision and transmits `expectedRevision` upon apply; backend validates revision match and converts `SETTINGS_CONFLICT` during `settings.replace` into HTTP 409 Conflict with clear preview invalidation guidance instead of generic 500 errors.
9
+
10
+ ## 0.8.75 (2026-10-30)
11
+
12
+ ### Dependencies, Cleanup & Audit Finalization (Block 3 Release)
13
+ - **Zero Runtime Dependencies & Security Hardening (Issues #520, #535)**: Completely removed `undici` dependency and eliminated 5 high/moderate vulnerabilities (`pnpm audit --prod` reports 0 vulnerabilities); `safe-fetch.js` rewritten using standard `globalThis.fetch` and pure Node.js DNS/IP validation.
14
+ - **Atomic I/O Cleanup (Issue #521)**: Crash-safe file writes now safely clean up `.tmp` files on write/sync/rename errors, and `cleanupStaleTmpFiles` purges abandoned temporary files on startup.
15
+ - **Request Budget Inheritance & Signal Management (Issue #464)**: `maxAttempts` and `maxDurationMs` properly propagated from settings to `buildRuntime()` and `pool-builder`; `createRequestBudget` removes caller `AbortSignal` listeners upon `dispose()`; guaranteed cleanup in `rotate.js` `finally` block; traffic smoother now receives `budget.signal`.
16
+ - **Selection Trace Diagnosability (Issue #461)**: Candidate evaluation now logs detailed `SKIPPED` steps (`PAUSED`, `REVOKED`, `BROKEN`, `DRAINING`, `COOLDOWN`, `CAPABILITY_MISMATCH`, `QUOTA_EXHAUSTED`); added interactive Selection Trace UI inspector in settings card.
17
+ - **Managed Key Drain UI & Concurrency Robustness (Issue #463)**: Added interactive Managed Key Drain section in settings card; enhanced `ConcurrencyTracker` with `touch()` and prolonged 30m stale threshold for long-running streaming generation.
18
+ - **Offline Simulator Parity (Issue #462)**: Audit verification finalized following Block 2 quarantine cloning, Map lookup, and per-Context isolation fixes.
19
+
3
20
  ## 0.8.74 (2026-10-30)
4
21
 
5
22
  ### Rotation & Routing Reliability (Block 2 Release)
package/lib/atomic-io.js CHANGED
@@ -1,5 +1,6 @@
1
- // lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264).
1
+ // lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264, #521).
2
2
  // Never overwrite a non-empty destination with empty/corrupt parse results.
3
+ // Clean up orphaned temporary files on failure or during startup.
3
4
 
4
5
  import { promises as fs } from 'node:fs';
5
6
  import path from 'node:path';
@@ -8,6 +9,7 @@ import crypto from 'node:crypto';
8
9
 
9
10
  /**
10
11
  * Atomically write data to filePath (temp + rename in same directory).
12
+ * Cleans up temporary file if open, write, sync, or rename fails (#521).
11
13
  * @param {string} filePath
12
14
  * @param {string|Buffer} data
13
15
  */
@@ -15,14 +17,55 @@ export async function atomicWriteFile(filePath, data) {
15
17
  const dir = path.dirname(filePath);
16
18
  await fs.mkdir(dir, { recursive: true });
17
19
  const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
18
- const fh = await fs.open(tmp, 'w');
20
+ let fh = null;
19
21
  try {
22
+ fh = await fs.open(tmp, 'w');
20
23
  await fh.writeFile(data);
21
24
  await fh.sync();
22
- } finally {
23
25
  await fh.close();
26
+ fh = null;
27
+ await fs.rename(tmp, filePath);
28
+ } catch (err) {
29
+ if (fh) {
30
+ try { await fh.close(); } catch {}
31
+ }
32
+ try { await fs.unlink(tmp); } catch {}
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Clean up stale temporary files left behind by crashed or interrupted writes (#521).
39
+ * @param {string} filePath
40
+ * @param {number} [maxAgeMs=3600000]
41
+ * @returns {Promise<number>} number of files unlinked
42
+ */
43
+ export async function cleanupStaleTmpFiles(filePath, maxAgeMs = 3600000) {
44
+ if (!filePath || typeof filePath !== 'string') return 0;
45
+ try {
46
+ const dir = path.dirname(filePath);
47
+ const base = path.basename(filePath);
48
+ const prefix = `.${base}.`;
49
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
50
+ const now = Date.now();
51
+ let cleaned = 0;
52
+ for (const entry of entries) {
53
+ if (!entry.isFile()) continue;
54
+ if (entry.name.startsWith(prefix) && entry.name.endsWith('.tmp')) {
55
+ const fullPath = path.join(dir, entry.name);
56
+ try {
57
+ const st = await fs.stat(fullPath);
58
+ if (now - st.mtimeMs > maxAgeMs) {
59
+ await fs.unlink(fullPath);
60
+ cleaned++;
61
+ }
62
+ } catch {}
63
+ }
64
+ }
65
+ return cleaned;
66
+ } catch {
67
+ return 0;
24
68
  }
25
- await fs.rename(tmp, filePath);
26
69
  }
27
70
 
28
71
  /**
package/lib/client.js CHANGED
@@ -61,6 +61,24 @@ window.__ModuleLoader__.load({
61
61
  sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
62
62
  sectionFailover: 'Failover Triggers & Error Codes',
63
63
  sectionFailoverDesc: 'Select error conditions that trigger automatic switching to the next available key.',
64
+ maxAttemptsLabel: 'Max Attempts (Budget)',
65
+ maxDurationLabel: 'Max Duration ms (Budget)',
66
+ traceTitle: 'Key Selection Traces',
67
+ traceDesc: 'Candidate evaluation history, skips and selection decisions for requests.',
68
+ traceRefresh: 'Refresh Traces',
69
+ traceClear: 'Clear History',
70
+ traceEmpty: 'No selection traces recorded yet.',
71
+ traceSelected: 'Selected Key',
72
+ traceOutcome: 'Outcome',
73
+ drainTitle: 'Managed Key Drain',
74
+ drainDesc: 'Safely transition a key out of rotation waiting for active requests to finish.',
75
+ drainKeyPlaceholder: 'Key ref (e.g. DEEPSEEK_API_KEY_2)',
76
+ drainStart: 'Start Drain',
77
+ drainCancel: 'Cancel Drain',
78
+ drainActiveStreams: 'Active requests',
79
+ drainCanComplete: 'Ready to remove',
80
+ drainDraining: 'Draining Keys',
81
+ drainEmpty: 'No keys currently draining.',
64
82
  sectionTiming: 'Timing & Rotation Schedule',
65
83
  sectionTimingDesc: 'Configure cooldown durations and scheduled periodic rotation windows.',
66
84
  sectionBackup: 'Backup & Snapshots',
@@ -317,6 +335,24 @@ window.__ModuleLoader__.load({
317
335
  sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
318
336
  sectionFailover: '故障转移触发器与错误代码',
319
337
  sectionFailoverDesc: '选择触发自动切换至下一个可用密钥的错误条件。',
338
+ maxAttemptsLabel: '最大重试次数 (预算)',
339
+ maxDurationLabel: '最大超时毫秒 (预算)',
340
+ traceTitle: '密钥选择追踪 (Trace)',
341
+ traceDesc: '查看请求的候选密钥评估、跳过原因和最终选择决策。',
342
+ traceRefresh: '刷新追踪',
343
+ traceClear: '清空历史',
344
+ traceEmpty: '暂无选择追踪记录。',
345
+ traceSelected: '所选密钥',
346
+ traceOutcome: '结果',
347
+ drainTitle: '安全下线密钥 (Drain)',
348
+ drainDesc: '平滑下线密钥并等待当前在途请求完成,避免中途断流。',
349
+ drainKeyPlaceholder: '密钥标识 (例如 DEEPSEEK_API_KEY_2)',
350
+ drainStart: '开始下线',
351
+ drainCancel: '取消下线',
352
+ drainActiveStreams: '在途请求数',
353
+ drainCanComplete: '可安全移除',
354
+ drainDraining: '下线中的密钥',
355
+ drainEmpty: '当前没有正在下线的密钥。',
320
356
  sectionTiming: '时间与轮换计划',
321
357
  sectionTimingDesc: '配置冷却持续时间和定期轮换计划窗口。',
322
358
  sectionBackup: '备份与快照',
@@ -1257,6 +1293,16 @@ window.__ModuleLoader__.load({
1257
1293
  // Auto-Discovery State (#491)
1258
1294
  const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
1259
1295
  const [autoImporting, setAutoImporting] = React.useState(false);
1296
+
1297
+ // Selection Trace State (#461)
1298
+ const [selectionTraces, setSelectionTraces] = React.useState([]);
1299
+ const [loadingTraces, setLoadingTraces] = React.useState(false);
1300
+
1301
+ // Managed Key Drain State (#463)
1302
+ const [drainingKeys, setDrainingKeys] = React.useState([]);
1303
+ const [drainRefInput, setDrainRefInput] = React.useState('');
1304
+ const [loadingDrain, setLoadingDrain] = React.useState(false);
1305
+ const [drainActionError, setDrainActionError] = React.useState('');
1260
1306
  const [autoImportMsg, setAutoImportMsg] = React.useState('');
1261
1307
 
1262
1308
  // Test Matrix State (#490)
@@ -1270,6 +1316,7 @@ window.__ModuleLoader__.load({
1270
1316
  const [importPassphrase, setImportPassphrase] = React.useState('');
1271
1317
  const [importMode, setImportMode] = React.useState('merge');
1272
1318
  const [importPreview, setImportPreview] = React.useState(null);
1319
+ const [importRevision, setImportRevision] = React.useState(null);
1273
1320
  const [importError, setImportError] = React.useState('');
1274
1321
  const [importRunning, setImportRunning] = React.useState(false);
1275
1322
 
@@ -1312,6 +1359,7 @@ window.__ModuleLoader__.load({
1312
1359
  const runImportPreview = async () => {
1313
1360
  setImportError('');
1314
1361
  setImportPreview(null);
1362
+ setImportRevision(null);
1315
1363
  setImportRunning(true);
1316
1364
  try {
1317
1365
  let parsed;
@@ -1335,6 +1383,7 @@ window.__ModuleLoader__.load({
1335
1383
  const data = await res.json();
1336
1384
  if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
1337
1385
  setImportPreview(data.preview);
1386
+ setImportRevision(data.revision ?? null);
1338
1387
  } catch (err) {
1339
1388
  setImportError(String(err?.message ?? err));
1340
1389
  } finally {
@@ -1360,14 +1409,23 @@ window.__ModuleLoader__.load({
1360
1409
  encrypted: parsed.encrypted,
1361
1410
  passphrase: importPassphrase.trim() || undefined,
1362
1411
  mode: importMode,
1412
+ expectedRevision: importRevision !== null ? importRevision : undefined,
1363
1413
  dryRun: false,
1364
1414
  }),
1365
1415
  signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1366
1416
  });
1367
1417
  const data = await res.json();
1368
- if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import apply failed');
1418
+ if (!res.ok || !data.ok) {
1419
+ if (res.status === 409 || data?.error?.code === 'conflict') {
1420
+ setImportPreview(null);
1421
+ setImportRevision(null);
1422
+ throw new Error(data?.error?.message || 'Configuration was modified concurrently. Please preview again before applying.');
1423
+ }
1424
+ throw new Error(data?.error?.message || 'Import apply failed');
1425
+ }
1369
1426
  setImportModal(false);
1370
1427
  setImportPreview(null);
1428
+ setImportRevision(null);
1371
1429
  setImportText('');
1372
1430
  void load();
1373
1431
  } catch (err) {
@@ -1472,6 +1530,70 @@ window.__ModuleLoader__.load({
1472
1530
  }
1473
1531
  };
1474
1532
 
1533
+ const loadTraces = async () => {
1534
+ setLoadingTraces(true);
1535
+ try {
1536
+ const res = await fetch('/dsh-key-rotation/trace?limit=20');
1537
+ const data = await res.json();
1538
+ if (data && data.ok) {
1539
+ setSelectionTraces(data.traces || []);
1540
+ }
1541
+ } catch (_) {}
1542
+ finally {
1543
+ setLoadingTraces(false);
1544
+ }
1545
+ };
1546
+
1547
+ const clearTraces = async () => {
1548
+ try {
1549
+ await fetch('/dsh-key-rotation/trace', { method: 'DELETE' });
1550
+ setSelectionTraces([]);
1551
+ } catch (_) {}
1552
+ };
1553
+
1554
+ const loadDrainStatus = async () => {
1555
+ setLoadingDrain(true);
1556
+ try {
1557
+ const res = await fetch('/dsh-key-rotation/drain');
1558
+ const data = await res.json();
1559
+ if (data && data.ok) {
1560
+ setDrainingKeys(data.draining || []);
1561
+ }
1562
+ } catch (_) {}
1563
+ finally {
1564
+ setLoadingDrain(false);
1565
+ }
1566
+ };
1567
+
1568
+ const startKeyDrain = async (ref) => {
1569
+ if (!ref || !ref.trim()) return;
1570
+ setDrainActionError('');
1571
+ try {
1572
+ const res = await fetch('/dsh-key-rotation/drain', {
1573
+ method: 'POST',
1574
+ headers: { 'Content-Type': 'application/json' },
1575
+ body: JSON.stringify({ ref: ref.trim() }),
1576
+ });
1577
+ const data = await res.json();
1578
+ if (data && data.ok) {
1579
+ setDrainRefInput('');
1580
+ await loadDrainStatus();
1581
+ } else {
1582
+ setDrainActionError(data?.error?.message || 'Drain request failed');
1583
+ }
1584
+ } catch (e) {
1585
+ setDrainActionError(String(e?.message || e));
1586
+ }
1587
+ };
1588
+
1589
+ const cancelKeyDrain = async (ref) => {
1590
+ if (!ref) return;
1591
+ try {
1592
+ await fetch(`/dsh-key-rotation/drain?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' });
1593
+ await loadDrainStatus();
1594
+ } catch (_) {}
1595
+ };
1596
+
1475
1597
  const [optimisticReset, setOptimisticReset] = React.useState({});
1476
1598
  const [selected, setSelected] = React.useState(new Set());
1477
1599
  const [bulkCooldown, setBulkCooldown] = React.useState('');
@@ -2928,6 +3050,10 @@ window.__ModuleLoader__.load({
2928
3050
  field(t('rpmLimitLabel'), textInput(String(val.rpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, rpmLimit: Number(v) || 0 })))),
2929
3051
  field(t('tpmLimitLabel'), textInput(String(val.tpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, tpmLimit: Number(v) || 0 }))))
2930
3052
  ),
3053
+ h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
3054
+ field(t('maxAttemptsLabel'), textInput(String(val.maxAttempts ?? 0), (v) => setField((cur) => ({ ...cur, maxAttempts: Number(v) || 0 })))),
3055
+ field(t('maxDurationLabel'), textInput(String(val.maxDurationMs ?? 0), (v) => setField((cur) => ({ ...cur, maxDurationMs: Number(v) || 0 }))))
3056
+ ),
2931
3057
  h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
2932
3058
  field(t('routingStrategyLabel'), h('select', {
2933
3059
  className: 'krot-in',
@@ -3017,6 +3143,68 @@ window.__ModuleLoader__.load({
3017
3143
  ) : null
3018
3144
  );
3019
3145
 
3146
+ const drainSection = h('div', { className: 'krot-section-card' },
3147
+ h('div', { className: 'krot-section-title' },
3148
+ h('span', null, '⏳ ' + t('drainTitle')),
3149
+ h('span', { className: 'krot-badge ' + (drainingKeys.length > 0 ? 'krot-badge-warn' : 'krot-badge-ok') },
3150
+ drainingKeys.length > 0 ? (drainingKeys.length + ' ' + t('drainDraining')) : t('drainEmpty')
3151
+ )
3152
+ ),
3153
+ h('div', { className: 'krot-section-desc' }, t('drainDesc')),
3154
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '10px' } },
3155
+ h('input', {
3156
+ className: 'krot-in',
3157
+ placeholder: t('drainKeyPlaceholder'),
3158
+ value: drainRefInput,
3159
+ onChange: (e) => setDrainRefInput(e.target.value),
3160
+ style: { maxWidth: '240px' },
3161
+ }),
3162
+ btn(t('drainStart'), () => startKeyDrain(drainRefInput), { primary: true, disabled: !drainRefInput.trim() }),
3163
+ btn(t('traceRefresh'), loadDrainStatus, { disabled: loadingDrain })
3164
+ ),
3165
+ drainActionError ? h('div', { className: 'krot-warn', style: { marginTop: '8px' } }, '⚠ ' + drainActionError) : null,
3166
+ drainingKeys.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '6px' } },
3167
+ drainingKeys.map((dk) => h('div', { key: dk.ref, className: 'krot-stat-box', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px' } },
3168
+ h('span', { style: { fontWeight: 600 } }, dk.ref),
3169
+ h('span', { className: 'krot-badge ' + (dk.canComplete ? 'krot-badge-ok' : 'krot-badge-warn') },
3170
+ dk.canComplete ? t('drainCanComplete') : (t('drainActiveStreams') + ': ' + dk.inFlight)
3171
+ ),
3172
+ btn(t('drainCancel'), () => cancelKeyDrain(dk.ref), { danger: true })
3173
+ ))
3174
+ ) : null
3175
+ );
3176
+
3177
+ const traceSection = h('div', { className: 'krot-section-card' },
3178
+ h('div', { className: 'krot-section-title' },
3179
+ h('span', null, '🧭 ' + t('traceTitle')),
3180
+ h('span', { className: 'krot-badge krot-badge-ok' }, selectionTraces.length + ' traces')
3181
+ ),
3182
+ h('div', { className: 'krot-section-desc' }, t('traceDesc')),
3183
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '10px' } },
3184
+ btn(loadingTraces ? '...' : t('traceRefresh'), loadTraces, { disabled: loadingTraces }),
3185
+ btn(t('traceClear'), clearTraces, { disabled: selectionTraces.length === 0 })
3186
+ ),
3187
+ selectionTraces.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '280px', overflowY: 'auto' } },
3188
+ selectionTraces.slice().reverse().map((tr) => h('div', { key: tr.requestId, style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', padding: '8px', fontSize: '11px' } },
3189
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' } },
3190
+ h('span', { style: { fontWeight: 600 } }, (tr.provider || 'default') + (tr.model ? ' / ' + tr.model : '')),
3191
+ h('span', { className: 'krot-badge ' + (tr.outcome === 'SUCCESS' ? 'krot-badge-ok' : 'krot-badge-bad') }, tr.outcome)
3192
+ ),
3193
+ h('div', { style: { color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } },
3194
+ tr.selectedRef ? (t('traceSelected') + ': ' + tr.selectedRef) : ''
3195
+ ),
3196
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', paddingLeft: '8px', borderLeft: '2px solid var(--dsw-alias-border-l2)' } },
3197
+ (tr.steps || []).map((st, i) => h('div', { key: i, style: { display: 'flex', gap: '6px' } },
3198
+ h('span', { style: { color: st.decision === 'SELECTED' ? 'var(--dsw-alias-state-success-primary)' : st.decision === 'SKIPPED' ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-state-danger-primary)', fontWeight: 600 } }, st.decision),
3199
+ h('span', { style: { fontWeight: 600 } }, st.ref || '-'),
3200
+ h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, st.reasonCode ? ('[' + st.reasonCode + ']') : ''),
3201
+ h('span', null, st.details || '')
3202
+ ))
3203
+ )
3204
+ ))
3205
+ ) : h('div', { className: 'krot-hint', style: { marginTop: '8px' } }, t('traceEmpty'))
3206
+ );
3207
+
3020
3208
  const backupSection = h('div', { className: 'krot-section-card' },
3021
3209
  h('div', { className: 'krot-section-title' },
3022
3210
  h('span', null, '💾 ' + t('sectionBackup')),
@@ -3305,6 +3493,8 @@ window.__ModuleLoader__.load({
3305
3493
  poolsSection,
3306
3494
  failoverSection,
3307
3495
  timingSection,
3496
+ drainSection,
3497
+ traceSection,
3308
3498
  backupSection,
3309
3499
  simulatorSection,
3310
3500
  ),
@@ -1,7 +1,8 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
2
2
 
3
3
  const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
4
+ // Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
5
+ const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
5
6
 
6
7
  export class ConcurrencyTracker {
7
8
  constructor(opts) {
@@ -42,6 +43,14 @@ export class ConcurrencyTracker {
42
43
  return true;
43
44
  }
44
45
 
46
+ touch(ref, now) {
47
+ now = now || Date.now();
48
+ const e = this._inFlight.get(ref);
49
+ if (e && e.count > 0) {
50
+ e.lastAcquired = now;
51
+ }
52
+ }
53
+
45
54
  release(ref, now) {
46
55
  now = now || Date.now();
47
56
  const e = this._inFlight.get(ref);
@@ -55,13 +64,24 @@ export class ConcurrencyTracker {
55
64
  if (!ref) return 0;
56
65
  const e = this._inFlight.get(ref);
57
66
  if (!e) return 0;
58
- if (now - e.lastAcquired > this._staleMs) return 0;
67
+ if (now - e.lastAcquired > this._staleMs) {
68
+ e.count = 0;
69
+ return 0;
70
+ }
59
71
  return e.count;
60
72
  }
61
73
 
62
- snapshot() {
74
+ snapshot(now) {
75
+ now = now || Date.now();
63
76
  const out = {};
64
- for (const [k, v] of this._inFlight) out[k] = { count: v.count };
77
+ for (const [k, v] of this._inFlight) {
78
+ let count = v.count;
79
+ if (now - v.lastAcquired > this._staleMs) {
80
+ count = 0;
81
+ v.count = 0;
82
+ }
83
+ out[k] = { count };
84
+ }
65
85
  return out;
66
86
  }
67
87
 
package/lib/index.js CHANGED
@@ -373,6 +373,8 @@ export function apply(ctx, config = {}) {
373
373
  showChatHeaderChip: cfg.showChatHeaderChip !== false,
374
374
  switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
375
375
  warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
376
+ maxAttempts: typeof cfg.maxAttempts === 'number' && cfg.maxAttempts > 0 ? Math.floor(cfg.maxAttempts) : 0,
377
+ maxDurationMs: typeof cfg.maxDurationMs === 'number' && cfg.maxDurationMs > 0 ? Math.floor(cfg.maxDurationMs) : 0,
376
378
  index, pools,
377
379
  breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
378
380
  providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
package/lib/lifecycle.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
3
3
  import { decryptSecret } from './crypto-storage.js';
4
4
  import { StatePersistence, resolveStatePath } from './persistence.js';
5
+ import { cleanupStaleTmpFiles } from './atomic-io.js';
5
6
  import path from 'node:path';
6
7
  import { envValue } from './pool.js';
7
8
  import { clearAllDraining } from './key-drain.js';
@@ -97,6 +98,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
97
98
  dataDir: hostDirs[0],
98
99
  });
99
100
  if (cfg0.persistenceEnabled !== false && resolvedPath) {
101
+ cleanupStaleTmpFiles(resolvedPath).catch(() => {});
100
102
  statePersistence = new StatePersistence({ filePath: resolvedPath });
101
103
  statePersistence.load().then((snap) => {
102
104
  if (!snap) return;
@@ -349,15 +349,24 @@ export function registerConfigIoRoutes(ctx, deps) {
349
349
  }
350
350
  const rawCurrent = plainConfig(currentConfig);
351
351
 
352
- // Concurrency control: if client provided expectedRevision, reject if stale (#553)
353
- if (typeof body.expectedRevision === 'number' && currentRevision !== null) {
354
- if (body.expectedRevision !== currentRevision) {
352
+ // Concurrency control: validate expectedRevision format and match (#553)
353
+ if (body.expectedRevision !== undefined && body.expectedRevision !== null) {
354
+ if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
355
+ json(res, 400, {
356
+ error: {
357
+ code: 'bad-request',
358
+ message: 'expectedRevision must be a non-negative integer',
359
+ },
360
+ });
361
+ return;
362
+ }
363
+ if (currentRevision !== null && body.expectedRevision !== currentRevision) {
355
364
  json(res, 409, {
356
365
  error: {
357
366
  code: 'conflict',
358
367
  message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
359
368
  currentRevision,
360
- }
369
+ },
361
370
  });
362
371
  return;
363
372
  }
@@ -406,8 +415,26 @@ export function registerConfigIoRoutes(ctx, deps) {
406
415
  }
407
416
 
408
417
  try {
409
- await settings.replace(NS, mergedConfig, currentRevision ?? undefined);
418
+ await settings.replace(NS, mergedConfig, body.expectedRevision ?? currentRevision ?? undefined);
410
419
  } catch (err) {
420
+ const isConflict = err?.code === 'SETTINGS_CONFLICT' ||
421
+ err?.status === 409 ||
422
+ err?.code === 'CONFLICT' ||
423
+ /conflict|revision|stale/i.test(err?.message || '');
424
+ if (isConflict) {
425
+ let latestRevision = null;
426
+ try {
427
+ latestRevision = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS)?.revision ?? null;
428
+ } catch (_) {}
429
+ json(res, 409, {
430
+ error: {
431
+ code: 'conflict',
432
+ message: `Configuration has been modified concurrently: ${err.message}`,
433
+ currentRevision: latestRevision ?? currentRevision,
434
+ },
435
+ });
436
+ return;
437
+ }
411
438
  json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
412
439
  return;
413
440
  }
@@ -174,14 +174,15 @@ ctx.effect(() => ctx.webServer.register({
174
174
  json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
175
175
  return;
176
176
  }
177
+ const traceStore = deps.selectionTraceStore || null;
177
178
  if (req.method === 'DELETE') {
178
- clearSelectionTraces();
179
+ clearSelectionTraces(traceStore);
179
180
  json(res, 200, { ok: true, message: 'trace history cleared' });
180
181
  return;
181
182
  }
182
183
  const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
183
184
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
184
- json(res, 200, { ok: true, traces: getSelectionTraces(limit) });
185
+ json(res, 200, { ok: true, traces: getSelectionTraces(limit, traceStore) });
185
186
  },
186
187
  }), 'dsh-key-rotation: trace route');
187
188
 
@@ -39,6 +39,8 @@ export function buildPoolItem({
39
39
  useKeyRef = null,
40
40
  trafficSmoothingBurst = null,
41
41
  maxQueueWaitMs = null,
42
+ maxAttempts = null,
43
+ maxDurationMs = null,
42
44
  }) {
43
45
  const seen = new Set();
44
46
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -143,6 +145,12 @@ const weightsMap = {};
143
145
  maxQueueWaitMs: typeof maxQueueWaitMs === 'number' && Number.isFinite(maxQueueWaitMs) && maxQueueWaitMs >= 0
144
146
  ? Math.floor(maxQueueWaitMs)
145
147
  : 800,
148
+ maxAttempts: typeof maxAttempts === 'number' && Number.isFinite(maxAttempts) && maxAttempts > 0
149
+ ? Math.floor(maxAttempts)
150
+ : 0,
151
+ maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
152
+ ? Math.floor(maxDurationMs)
153
+ : 0,
146
154
  };
147
155
  }
148
156
 
@@ -178,6 +186,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
178
186
  const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
179
187
  const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
180
188
  const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
189
+ const poolAttempts = typeof p.maxAttempts === 'number' ? p.maxAttempts : (cfg.maxAttempts ?? 0);
190
+ const poolDuration = typeof p.maxDurationMs === 'number' ? p.maxDurationMs : (cfg.maxDurationMs ?? 0);
181
191
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
182
192
  provider: p.provider,
183
193
  model: null,
@@ -186,6 +196,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
186
196
  pinnedRef: p.pinnedRef ?? p.useKeyRef,
187
197
  trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
188
198
  maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
199
+ maxAttempts: poolAttempts,
200
+ maxDurationMs: poolDuration,
189
201
  }, provRpm, provTpm, provConcurrency);
190
202
  if (pool) {
191
203
  for (const ref of pool.refs) {
@@ -216,6 +228,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
216
228
  pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
217
229
  trafficSmoothingBurst: mp.trafficSmoothingBurst ?? p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
218
230
  maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
231
+ maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
232
+ maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
219
233
  }, modelRpm, modelTpm, modelConcurrency);
220
234
  if (!mpool) continue;
221
235
  mpool.basePool = pool;
package/lib/pool.js CHANGED
@@ -39,8 +39,23 @@ export function isLoopbackAddress(ip) {
39
39
  return false;
40
40
  }
41
41
 
42
- /** Check if an incoming HTTP request is from the Desktop shell (#516). */
42
+ /** Check if request contains non-loopback forwarding headers (e.g. proxied from LAN/Tailscale) (#516). */
43
+ export function hasNonLoopbackForwarding(req) {
44
+ const xff = req?.headers?.['x-forwarded-for'];
45
+ if (xff && typeof xff === 'string') {
46
+ const ips = xff.split(',').map((s) => s.trim());
47
+ if (ips.some((ip) => ip && !isLoopbackAddress(ip))) return true;
48
+ }
49
+ const xRealIp = req?.headers?.['x-real-ip'];
50
+ if (xRealIp && typeof xRealIp === 'string' && !isLoopbackAddress(xRealIp.trim())) {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /** Check if an incoming HTTP request is from an authentic local Desktop shell (#516). */
43
57
  export function isDesktopShellRequest(req) {
58
+ if (hasNonLoopbackForwarding(req)) return false;
44
59
  const agent = req?.headers?.['user-agent'] || '';
45
60
  const cookie = req?.headers?.cookie || '';
46
61
  return typeof agent === 'string' && agent.includes('@deepseek-ai/dsh-desktop/') &&
@@ -69,9 +84,9 @@ export function isTrustedBridgeRequest(req) {
69
84
  if (!origin) return false; // Explicit empty string or null-like falsy
70
85
  try {
71
86
  const originUrl = new URL(origin);
72
- // Official Desktop build shell origin (#22, #465, #516)
87
+ // Official Desktop build shell origin (#22, #465, #516) - requires authentic local Desktop shell (rejects LAN forwarding)
73
88
  if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
74
- return true;
89
+ return !hasNonLoopbackForwarding(req);
75
90
  }
76
91
  if (originUrl.host !== hostHeader) return false;
77
92
  const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
@@ -89,7 +104,7 @@ export function isTrustedBridgeRequest(req) {
89
104
  return true;
90
105
  }
91
106
 
92
- // Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465) or valid Referer.
107
+ // Mutating requests without Origin: allow authentic local Desktop shell requests (#516, #465)
93
108
  if (isDesktopShellRequest(req)) {
94
109
  return true;
95
110
  }
@@ -98,7 +113,7 @@ export function isTrustedBridgeRequest(req) {
98
113
  try {
99
114
  const refUrl = new URL(referer);
100
115
  if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
101
- return true;
116
+ return !hasNonLoopbackForwarding(req);
102
117
  }
103
118
  if (refUrl.host !== hostHeader) return false;
104
119
  const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
@@ -22,13 +22,14 @@ export function createRequestBudget(options = {}) {
22
22
 
23
23
  const controller = new AbortController();
24
24
  let timerId = null;
25
+ let onCallerAbort = null;
25
26
 
26
27
  // Chain caller signal if provided
27
28
  if (options.signal) {
28
29
  if (options.signal.aborted) {
29
30
  controller.abort(options.signal.reason);
30
31
  } else {
31
- const onCallerAbort = () => {
32
+ onCallerAbort = () => {
32
33
  controller.abort(options.signal.reason);
33
34
  cleanup();
34
35
  };
@@ -52,6 +53,12 @@ export function createRequestBudget(options = {}) {
52
53
  clearTimeout(timerId);
53
54
  timerId = null;
54
55
  }
56
+ if (options.signal && onCallerAbort) {
57
+ try {
58
+ options.signal.removeEventListener('abort', onCallerAbort);
59
+ } catch {}
60
+ onCallerAbort = null;
61
+ }
55
62
  }
56
63
 
57
64
  return {