@goodandready/dsh-key-rotation 0.8.32 → 0.8.34

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,40 @@
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.34 - 2026-09-28
7
+
8
+ ### Security & Hardening
9
+ - **Credentials Encryption at Rest on PUT (#395)**:
10
+ - Wired `encryptSecret` in `lib/ops-keys.js` (`PUT /dsh-key-rotation/key`).
11
+ - When `DSH_KEY_SECRET` is set in the environment, new keys are automatically encrypted at rest via AES-256-GCM (`enc:v1:...`).
12
+ - Key tail calculation and leak shape analysis continue to operate on raw values for uninterrupted UI diagnostics.
13
+ - Added dedicated integration test suite `test/crypto-storage-keys.test.mjs`.
14
+
15
+ ### Refactored & Tech-Debt
16
+ - **Preflight Cleanliness & Safe Diagnostics (#393)**:
17
+ - Eliminated 5 empty `catch {}` blocks across `lib/plugin-updater.js` and `lib/ops-webhook.js`.
18
+ - Replaced unhandled exceptions with `bestEffort(label, fn, logger)` utilizing structured debug logging to `ctx?.logger`.
19
+ - Resolved preflight check blocker (`FAIL=0`).
20
+ - **Dead Export Wiring & Core Cohesion (#394)**:
21
+ - Reused `scanForLiveSecrets` in `lib/http-bridge.js` to parse patch ops arrays directly in `handleConfigBridge`.
22
+ - Connected `filterQuotaEligible` to `anyModelQuotaAvailable` in `lib/model-quota.js`.
23
+ - Integrated `nowWall()` in `lib/quota-window.js` for default calendar reset calculations.
24
+
25
+ ## 0.8.33 - 2026-09-28
26
+
27
+ ### Fixed & Hardened
28
+ - **Telegram Webhook Interactive Actions (#388)**:
29
+ - Route `/dsh-key-rotation/webhook-action` now accepts `X-Telegram-Bot-Api-Secret-Token` via constant-time comparison (`crypto.timingSafeEqual`) alongside `Authorization: Bearer`.
30
+ - Added native Telegram `answerCallbackQuery` webhook response (`{ method: 'answerCallbackQuery', callback_query_id, text }`) dismissing the spinner immediately on button presses.
31
+ - Enforced mandatory explicit HTTPS URL requirement in `setWebhook` helper and transmission of `secret_token`.
32
+ - **Plugin Self-Updater Resiliency & Supply Chain Protection (#390)**:
33
+ - Removed `--config.minimumReleaseAge=0` override from `dsh plugin add` arguments, restoring release age policies and package verification.
34
+ - Implemented live PID detection via `process.kill(pid, 0)` in `package.json.lock`, returning 409 Conflict if installation is already in progress.
35
+ - Added automatic cleanup of orphaned/stale lockfiles left by dead processes.
36
+ - Ensured child process timeout immediately cleans up its own lockfile.
37
+ - **Repository & Worktree Hygiene (#389)**:
38
+ - Pruned obsolete worktrees and deleted old merged feature branches.
39
+
6
40
  ## 0.8.32 - 2026-09-28
7
41
 
8
42
  ### Added & Enhanced
package/README.md CHANGED
@@ -311,7 +311,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
311
311
  | `/dsh-key-rotation/reset` | `POST` | Instantly resets all cooldowns and restores all keys to `ready`. |
312
312
  | `/dsh-key-rotation/test-matrix` | `POST` | Triggers parallel health check across all configured keys and models. |
313
313
  | `/dsh-key-rotation/usage-report` | `GET` | Returns aggregated usage metrics in JSON or CSV format (`?format=csv`). |
314
- | `/dsh-key-rotation/webhook-callback`| `POST` | Receives and executes interactive actions from Telegram/Slack callbacks. |
314
+ | `/dsh-key-rotation/webhook-action` | `POST` | Receives and executes interactive actions from Telegram/Slack callbacks (`Authorization: Bearer` or `X-Telegram-Bot-Api-Secret-Token`). |
315
315
 
316
316
  ---
317
317
 
package/README.ru.md CHANGED
@@ -303,7 +303,7 @@ dsh-key-rotation:
303
303
  | `/dsh-key-rotation/reset` | `POST` | Мгновенный сброс всех кулдаунов и возврат ключей в статус `ready`. |
304
304
  | `/dsh-key-rotation/test-matrix` | `POST` | Запуск параллельного тестирования всей матрицы ключей и моделей. |
305
305
  | `/dsh-key-rotation/usage-report` | `GET` | Получение сводного отчета использования в формате JSON или CSV (`?format=csv`). |
306
- | `/dsh-key-rotation/webhook-callback`| `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack. |
306
+ | `/dsh-key-rotation/webhook-action` | `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack (`Authorization: Bearer` или `X-Telegram-Bot-Api-Secret-Token`). |
307
307
 
308
308
  ---
309
309
 
@@ -170,9 +170,14 @@ export function providerCatalog(ctx, cloneIds) {
170
170
 
171
171
 
172
172
  export function scanForLiveSecrets(section) {
173
+ if (Array.isArray(section)) {
174
+ return findSecrets(JSON.stringify(section.filter(op => !['webhookActionToken', 'notifyWebhook'].includes(op?.path?.[0]))));
175
+ }
173
176
  const masked = structuredClone(section);
174
- if (masked.webhookActionToken) masked.webhookActionToken = '***';
175
- if (masked.notifyWebhook) masked.notifyWebhook = '***';
177
+ if (masked && typeof masked === 'object') {
178
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
179
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
180
+ }
176
181
  return findSecrets(JSON.stringify(masked));
177
182
  }
178
183
 
@@ -230,7 +235,7 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
230
235
  return;
231
236
  }
232
237
  try {
233
- const findings = findSecrets(JSON.stringify(section.filter(op => !['webhookActionToken', 'notifyWebhook'].includes(op.path[0]))));
238
+ const findings = scanForLiveSecrets(section);
234
239
  if (findings.length > 0) {
235
240
  json(res, 400, {
236
241
  error: {
@@ -187,11 +187,7 @@ export function isModelQuotaAvailable(pool, ref, now) {
187
187
 
188
188
  /** True when at least one ref of `pool` still has local quota left. */
189
189
  export function anyModelQuotaAvailable(pool, now) {
190
- const refs = pool?.refs ?? [];
191
- for (const ref of refs) {
192
- if (isModelQuotaAvailable(pool, ref, now)) return true;
193
- }
194
- return false;
190
+ return filterQuotaEligible(pool, pool?.refs ?? [], now).length > 0;
195
191
  }
196
192
 
197
193
  /**
package/lib/ops-keys.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decryptSecret } from './crypto-storage.js';
1
+ import { decryptSecret, encryptSecret } from './crypto-storage.js';
2
2
  // lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
3
3
  import {
4
4
  json,
@@ -89,7 +89,8 @@ ctx.effect(() => ctx.webServer.register({
89
89
  json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
90
90
  return;
91
91
  }
92
- await credentialsService.set(ref, value);
92
+ const storedValue = encryptSecret(value);
93
+ await credentialsService.set(ref, storedValue);
93
94
  // #200: leak-detector hint - stored value should look like a credential
94
95
  const secretShape = looksLikeApiSecret(value);
95
96
  json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
@@ -1,11 +1,28 @@
1
1
  import crypto from 'node:crypto';
2
- // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
+ // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
3
3
  import {
4
4
  json,
5
5
  readJson,
6
6
  } from './http-bridge.js';
7
+ import { bestEffort } from './best-effort.js';
7
8
  import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
8
9
 
10
+ function constantTimeEqual(a, b) {
11
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
12
+ const aBuf = Buffer.from(a);
13
+ const bBuf = Buffer.from(b);
14
+ return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
15
+ }
16
+
17
+ function getHeader(headers, name) {
18
+ if (!headers || typeof headers !== 'object') return '';
19
+ const target = name.toLowerCase();
20
+ for (const [k, v] of Object.entries(headers)) {
21
+ if (k.toLowerCase() === target) return String(v ?? '');
22
+ }
23
+ return '';
24
+ }
25
+
9
26
  /**
10
27
  * @param {object} ctx cordis context
11
28
  * @param {object} deps live dependencies from apply()
@@ -26,27 +43,20 @@ ctx.effect(() => ctx.webServer.register({
26
43
  const runtime = buildRuntime();
27
44
  const expected = runtime.webhookActionToken;
28
45
  if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
29
- const auth = String(req.headers.authorization ?? '');
30
- const expectedAuth = `Bearer ${expected}`;
31
- const authBuf = Buffer.from(auth);
32
- const expBuf = Buffer.from(expectedAuth);
33
- const tokenMatch = authBuf.length === expBuf.length && crypto.timingSafeEqual(authBuf, expBuf);
34
- if (!tokenMatch) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
46
+
47
+ const authHeader = getHeader(req.headers, 'authorization');
48
+ const secretHeader = getHeader(req.headers, 'x-telegram-bot-api-secret-token');
49
+ const bearerMatch = constantTimeEqual(authHeader, `Bearer ${expected}`);
50
+ const secretMatch = constantTimeEqual(secretHeader, expected);
51
+ if (!bearerMatch && !secretMatch) {
52
+ json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } });
53
+ return;
54
+ }
55
+
35
56
  let body;
36
57
  try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
37
- // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
38
- let action = typeof body?.action === 'string' ? body.action : '';
39
- if (!action && typeof body?.data === 'string') {
40
- try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
41
- }
42
- if (!action && typeof body?.callback_data === 'string') {
43
- try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
44
- }
45
- // #222: Telegram update envelope {update_id, callback_query:{data}}
46
- if (!action && typeof body?.callback_query?.data === 'string') {
47
- try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
48
- }
49
- // #222: Telegram setWebhook registration helper
58
+
59
+ // #222 / #388: Telegram setWebhook registration helper
50
60
  if (typeof body?.setWebhook === 'object' && body.setWebhook) {
51
61
  const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
52
62
  if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
@@ -55,38 +65,92 @@ ctx.effect(() => ctx.webServer.register({
55
65
  json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
56
66
  return;
57
67
  }
58
- // derive the public URL from request headers; explicit URL wins
59
- const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
68
+ const rawUrl = typeof body.setWebhook.url === 'string' ? body.setWebhook.url.trim() : '';
69
+ if (!rawUrl || !rawUrl.startsWith('https://')) {
70
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: explicit public https url required in setWebhook.url' } });
71
+ return;
72
+ }
60
73
  try {
61
74
  const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
62
- method: 'POST', headers: { 'content-type': 'application/json' },
63
- body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({
78
+ url: rawUrl,
79
+ secret_token: expected,
80
+ allowed_updates: ['callback_query'],
81
+ }),
64
82
  signal: AbortSignal.timeout(10000),
65
83
  });
66
84
  const hookData = await hookRes.json().catch(() => ({}));
67
- json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
85
+ json(res, 200, { ok: hookRes.ok, url: rawUrl, secret_token_set: true, telegram: hookData });
68
86
  } catch (e) {
69
87
  json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
70
88
  }
71
89
  return;
72
90
  }
73
- if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
91
+
92
+ const callbackQueryId = typeof body?.callback_query?.id === 'string'
93
+ ? body.callback_query.id
94
+ : (typeof body?.callback_query?.id === 'number' ? String(body.callback_query.id) : '');
95
+ const botToken = typeof body?.botToken === 'string' ? body.botToken : '';
96
+
97
+ const respond = async (statusCode, payload, answerText) => {
98
+ if (callbackQueryId) {
99
+ payload.method = 'answerCallbackQuery';
100
+ payload.callback_query_id = callbackQueryId;
101
+ payload.text = answerText || payload.error?.message || 'OK';
102
+ if (botToken) {
103
+ await bestEffort('telegram-answerCallbackQuery', () => fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
107
+ signal: AbortSignal.timeout(5000),
108
+ }), ctx?.logger);
109
+ }
110
+ }
111
+ json(res, statusCode, payload);
112
+ };
113
+
114
+ // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
115
+ let action = typeof body?.action === 'string' ? body.action : '';
116
+ if (!action && typeof body?.data === 'string') {
117
+ try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
118
+ }
119
+ if (!action && typeof body?.callback_data === 'string') {
120
+ try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
121
+ }
122
+ // #222 / #388: Telegram update envelope {update_id, callback_query:{id, data}}
123
+ if (!action && typeof body?.callback_query?.data === 'string') {
124
+ try {
125
+ const parsed = JSON.parse(body.callback_query.data);
126
+ action = typeof parsed?.id === 'string' ? parsed.id : String(parsed ?? '');
127
+ } catch {
128
+ action = body.callback_query.data;
129
+ }
130
+ }
131
+ if (!action) {
132
+ await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
133
+ return;
134
+ }
74
135
  const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
75
136
  try {
76
137
  if (action === 'disable-rotation') {
77
138
  setRotationDisabled(true);
78
139
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
79
- json(res, 200, { ok: true, action });
140
+ await respond(200, { ok: true, action }, 'Rotation disabled');
80
141
  return;
81
142
  }
82
143
  if (action === 'enable-rotation') {
83
144
  setRotationDisabled(false);
84
- json(res, 200, { ok: true, action });
145
+ await respond(200, { ok: true, action }, 'Rotation enabled');
85
146
  return;
86
147
  }
87
148
  if (action.startsWith('pause-') || action.startsWith('reset-')) {
88
149
  const st = poolState.get(provider);
89
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
150
+ if (!st) {
151
+ await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
152
+ return;
153
+ }
90
154
  if (action.startsWith('pause-')) {
91
155
  const until = Date.now() + 3600000; // 1h pause
92
156
  for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
@@ -96,7 +160,7 @@ ctx.effect(() => ctx.webServer.register({
96
160
  for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
97
161
  }
98
162
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
99
- json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
163
+ await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
100
164
  return;
101
165
  }
102
166
  const cleared = st.failedUntil.size;
@@ -114,12 +178,12 @@ ctx.effect(() => ctx.webServer.register({
114
178
  else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
115
179
  }
116
180
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
117
- json(res, 200, { ok: true, action, provider, cleared, circuitReset });
181
+ await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
118
182
  return;
119
183
  }
120
- json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
184
+ await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
121
185
  } catch (e) {
122
- json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
186
+ await respond(500, { error: { code: 'action-failed', message: String(e?.message ?? e) } }, 'Action failed');
123
187
  }
124
188
  },
125
189
  }), 'dsh-key-rotation: webhook-action');
@@ -1,9 +1,10 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { bestEffort } from './best-effort.js'
7
8
 
8
9
  /**
9
10
  * Host-side one-click updater for a DSH Web plugin.
@@ -60,17 +61,17 @@ function validProfileName(value) {
60
61
  }
61
62
 
62
63
  function profileNameFromArgv(argv) {
63
- for (let index = 2; index < argv.length; index += 1) {
64
- if (argv[index] === '--profile') return argv[index + 1]
65
- if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
64
+ for (let index = 0; index < argv.length; index += 1) {
65
+ const item = argv[index]
66
+ if (item === '--profile' || item === '-p') return argv[index + 1]
67
+ if (typeof item === 'string' && item.startsWith('--profile=')) return item.slice('--profile='.length)
66
68
  }
67
- return argv[2] === 'web' ? 'web' : undefined
69
+ return undefined
68
70
  }
69
71
 
70
72
  function findDshCliEntry() {
71
- const value = process.argv[1]
72
- if (value === undefined || value === '') return undefined
73
- const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
73
+ const entry = process.argv[1]
74
+ if (typeof entry !== 'string' || entry === '') return undefined
74
75
  if (!existsSync(entry)) return undefined
75
76
  for (let directory = dirname(entry); ; directory = dirname(directory)) {
76
77
  const manifestPath = resolve(directory, 'package.json')
@@ -187,13 +188,88 @@ export function isValidPackageSpec(packageSpec) {
187
188
  return typeof packageSpec === 'string' && PACKAGE_SPEC_PATTERN.test(packageSpec)
188
189
  }
189
190
 
190
- async function installExact(target, packageSpec, options) {
191
+ export function readLockPid(lockPath, logger) {
192
+ try {
193
+ if (!existsSync(lockPath)) return undefined
194
+ const stat = statSync(lockPath)
195
+ if (stat.isDirectory()) return undefined
196
+ const content = readFileSync(lockPath, 'utf8').trim()
197
+ if (!content) return undefined
198
+ const parsed = bestEffort('readLockPid-json', () => JSON.parse(content), logger)
199
+ if (parsed && typeof parsed.pid === 'number') return parsed.pid
200
+ const num = parseInt(content, 10)
201
+ if (!isNaN(num) && num > 0) return num
202
+ const match = /\b(\d{1,8})\b/.exec(content)
203
+ if (match) return Number(match[1])
204
+ return undefined
205
+ } catch {
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ export function isPidAlive(pid) {
211
+ if (typeof pid !== 'number' || isNaN(pid) || pid <= 0) return false
212
+ try {
213
+ process.kill(pid, 0)
214
+ return true
215
+ } catch (err) {
216
+ return err?.code === 'EPERM'
217
+ }
218
+ }
219
+
220
+ export function checkAndCleanLock(profileDir, childPid, logger) {
221
+ const lockPath = resolve(profileDir, 'package.json.lock')
222
+ if (!existsSync(lockPath)) return { locked: false }
223
+ const pid = readLockPid(lockPath, logger)
224
+ if (pid !== undefined) {
225
+ if (isPidAlive(pid)) {
226
+ if (childPid !== undefined && pid === childPid) {
227
+ try {
228
+ rmSync(lockPath, { recursive: true, force: true })
229
+ return { locked: false, cleaned: true }
230
+ } catch {
231
+ return { locked: true, pid }
232
+ }
233
+ }
234
+ return { locked: true, pid }
235
+ }
236
+ // PID is dead, remove stale lock
237
+ try {
238
+ rmSync(lockPath, { recursive: true, force: true })
239
+ return { locked: false, cleaned: true }
240
+ } catch {
241
+ return { locked: true, pid }
242
+ }
243
+ }
244
+ // Unknown or empty lockfile format
245
+ if (childPid !== undefined) {
246
+ const cleaned = bestEffort('clean-unknown-lock', () => {
247
+ rmSync(lockPath, { recursive: true, force: true })
248
+ return true
249
+ }, logger)
250
+ if (cleaned) {
251
+ return { locked: false, cleaned: true }
252
+ }
253
+ }
254
+ return { locked: true }
255
+ }
256
+
257
+ export async function installExact(target, packageSpec, options = {}) {
191
258
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
192
259
  if (!isValidPackageSpec(packageSpec)) throw new Error(`Invalid package spec: ${packageSpec}`)
260
+
261
+ const lockStatus = checkAndCleanLock(target.profileDir)
262
+ if (lockStatus.locked) {
263
+ const error = new Error('Another plugin installation is currently in progress.')
264
+ error.status = 409
265
+ error.code = 'ELOCKED'
266
+ throw error
267
+ }
268
+
193
269
  await new Promise((resolvePromise, reject) => {
194
270
  const child = spawn(process.execPath, [
195
271
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
196
- '--config.minimumReleaseAge=0', packageSpec,
272
+ packageSpec,
197
273
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
198
274
  ], {
199
275
  cwd: target.profileDir,
@@ -204,13 +280,24 @@ async function installExact(target, packageSpec, options) {
204
280
  let detail = ''
205
281
  child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
206
282
  child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
283
+ let timedOut = false
284
+ const timeoutMs = options.timeoutMs ?? UPDATE_TIMEOUT_MS
207
285
  const timer = setTimeout(() => {
208
- child.kill()
286
+ timedOut = true
287
+ bestEffort('timeout-sigterm', () => child.kill('SIGTERM'), options.logger)
288
+ setTimeout(() => {
289
+ bestEffort('timeout-sigkill', () => child.kill('SIGKILL'), options.logger)
290
+ checkAndCleanLock(target.profileDir, child.pid, options.logger)
291
+ }, 500).unref?.()
209
292
  reject(new Error('Update timed out; use the normal DSH update flow.'))
210
- }, UPDATE_TIMEOUT_MS)
293
+ }, timeoutMs)
211
294
  child.once('error', error => { clearTimeout(timer); reject(error) })
212
- child.once('exit', code => {
295
+ child.once('exit', (code, signal) => {
213
296
  clearTimeout(timer)
297
+ if (timedOut) {
298
+ checkAndCleanLock(target.profileDir, child.pid)
299
+ return
300
+ }
214
301
  if (code === 0) resolvePromise()
215
302
  else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
216
303
  })
@@ -255,6 +342,11 @@ export function registerPluginUpdater(ctx, options) {
255
342
  json(response, 409, { error: 'This plugin is already updating.' })
256
343
  return
257
344
  }
345
+ const profileLock = checkAndCleanLock(target.profileDir)
346
+ if (profileLock.locked) {
347
+ json(response, 409, { error: 'Another plugin installation is currently in progress in this profile.' })
348
+ return
349
+ }
258
350
  installing = true
259
351
  try {
260
352
  const before = await status(options, target)
@@ -276,6 +368,10 @@ export function registerPluginUpdater(ctx, options) {
276
368
  installing = false
277
369
  }
278
370
  } catch (error) {
371
+ if (error?.status === 409 || error?.code === 'ELOCKED') {
372
+ json(response, 409, { error: error.message || 'Another plugin installation is currently in progress.' })
373
+ return
374
+ }
279
375
  ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
280
376
  json(response, 503, { error: 'Plugin update failed; see server logs.' })
281
377
  }
@@ -1,9 +1,10 @@
1
+ import { nowWall } from './clock.js';
1
2
  // quota-window.js — calendar-based quota reset windows (issue #197).
2
3
 
3
4
  export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'];
4
5
 
5
6
  export function nextQuotaReset(quotaResetWindow, now) {
6
- now = now || Date.now();
7
+ now = now || nowWall();
7
8
  const win = typeof quotaResetWindow === 'string' ? { type: quotaResetWindow === 'daily' ? 'midnight_utc' : quotaResetWindow } : quotaResetWindow;
8
9
  if (!win || typeof win !== 'object') return null;
9
10
  const type = win.type === 'daily' ? 'midnight_utc' : win.type;
@@ -39,7 +40,7 @@ export function poolResetAt(pool, quotaResetWindow, now) {
39
40
  }
40
41
 
41
42
  export function isBlockedUntilReset(failedUntil, resetAt, now) {
42
- now = now || Date.now();
43
+ now = now || nowWall();
43
44
  if (!Number.isFinite(failedUntil)) return false;
44
45
  if (resetAt === null || resetAt === undefined) return false;
45
46
  return failedUntil >= resetAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.32",
3
+ "version": "0.8.34",
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": [