@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 +34 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/lib/http-bridge.js +8 -3
- package/lib/model-quota.js +1 -5
- package/lib/ops-keys.js +3 -2
- package/lib/ops-webhook.js +97 -33
- package/lib/plugin-updater.js +109 -13
- package/lib/quota-window.js +3 -2
- package/package.json +1 -1
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-
|
|
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-
|
|
306
|
+
| `/dsh-key-rotation/webhook-action` | `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack (`Authorization: Bearer` или `X-Telegram-Bot-Api-Secret-Token`). |
|
|
307
307
|
|
|
308
308
|
---
|
|
309
309
|
|
package/lib/http-bridge.js
CHANGED
|
@@ -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
|
|
175
|
-
|
|
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 =
|
|
238
|
+
const findings = scanForLiveSecrets(section);
|
|
234
239
|
if (findings.length > 0) {
|
|
235
240
|
json(res, 400, {
|
|
236
241
|
error: {
|
package/lib/model-quota.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/lib/ops-webhook.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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',
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
|
|
118
182
|
return;
|
|
119
183
|
}
|
|
120
|
-
|
|
184
|
+
await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
|
|
121
185
|
} catch (e) {
|
|
122
|
-
|
|
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');
|
package/lib/plugin-updater.js
CHANGED
|
@@ -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 =
|
|
64
|
-
|
|
65
|
-
if (
|
|
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
|
|
69
|
+
return undefined
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
function findDshCliEntry() {
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
}
|
package/lib/quota-window.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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.
|
|
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": [
|