@goodandready/dsh-clinebot 0.6.21 → 0.6.23
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 +19 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/adapter-body.js +26 -0
- package/lib/adapter-messages.js +7 -2
- package/lib/adapter.js +43 -23
- package/lib/csv.js +5 -0
- package/lib/key-discovery.js +9 -5
- package/lib/models.js +2 -4
- package/lib/multimodal-budget.js +4 -0
- package/lib/path-safety.js +23 -0
- package/lib/price-sync.js +32 -0
- package/lib/routes/accounts.js +68 -10
- package/lib/routes/settings.js +10 -1
- package/lib/stats-storage.js +4 -7
- package/lib/transport/cline-transport.js +40 -21
- package/lib/updater.js +12 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.23] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Profile-file path guard** (#269): data paths must be `.json`, must stay inside `$DSH_HOME`, and cannot point at `profiles/**/package.json`, settings, credentials or TypeScript configs.
|
|
12
|
+
- **Explicit credential rollback failure** (#272): if settings persistence and credential rollback both fail, the API now reports `partial`/`credentialOrphan` with the env name and keeps the key available for cleanup/retry instead of hiding it.
|
|
13
|
+
- **Settings merge on account add** (#280): account additions use `settingsApi.update({ accounts })` and a fresh config read, preserving concurrent `timeoutMs` and other setting changes.
|
|
14
|
+
|
|
15
|
+
## [0.6.22] - 2026-10-28
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Updater env scrub** (#267): child install processes no longer inherit API keys, tokens, secrets, passwords, DSH/CommandCode credentials or npm auth tokens.
|
|
19
|
+
- **Committed-stream failover** (#273): the native adapter no longer retries on another account after a client-visible chunk, so two responses cannot be mixed.
|
|
20
|
+
- **Atomic account mutations** (#274, #272): additions are serialized, credentials are rolled back when settings persistence fails, and discovered-key imports keep the opaque id until success for retry.
|
|
21
|
+
- **SSRF and path hardening** (#268, #269): `priceCatalogUrl` is https models.dev-only with manual redirects; data paths must stay inside `$DSH_HOME`.
|
|
22
|
+
- **CSV export** (#270): RFC 4180 escaping and formula neutralization for model/session keys.
|
|
23
|
+
- **Multimodal payload** (#275, #276): the serialized request (images, text and tools) is measured against `maxPayloadMb`; failed image reads become text blocks instead of fake `image_url` values.
|
|
24
|
+
- **Transport timeouts and limits** (#277, #278): non-SSE bodies have an idle timeout and cancellation; oversized SSE events and response bodies cancel the reader.
|
|
25
|
+
- **FREE catalog diff** (#271): `isCatalogDifferent()` now detects free-status changes.
|
|
26
|
+
|
|
8
27
|
## [0.6.21] - 2026-10-28
|
|
9
28
|
|
|
10
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.23</b> closes residual path/rollback/settings-merge races: profile files cannot be targeted through data paths, orphan credentials are reported explicitly, and account additions merge with concurrent settings. It keeps automatic model/free/dated catalog backfill, DSH <code>0.2.1-rc.4</code>, model-scoped credit cooldowns and 402/413 errors. Chat runs on the native <code>ctx.llm</code> adapter; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
package/README.ru.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.23</b> закрывает остаточные гонки путей, rollback и настроек: файлы профиля нельзя выбрать через data paths, orphan-credential явно репортится, а добавление аккаунта мержится с параллельными настройками. Сохраняются автодобор каталога моделей/FREE/dated, DSH <code>0.2.1-rc.4</code>, cooldown недостатка кредитов для конкретной модели и ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
|
|
6
6
|
|
|
7
|
-
<p><b>0.6.
|
|
7
|
+
<p><b>0.6.23</b> 修复剩余的路径、回滚和设置合并竞态:数据路径不能指向配置文件,回滚失败会明确报告孤立凭据,添加账户会与并发设置合并。保留模型/免费/日期变体自动补齐、DSH <code>0.2.1-rc.4</code>、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function makeRequestBody({
|
|
2
|
+
model,
|
|
3
|
+
payload,
|
|
4
|
+
maxTokens,
|
|
5
|
+
temperature,
|
|
6
|
+
stop,
|
|
7
|
+
reasoningEffort,
|
|
8
|
+
sessionId,
|
|
9
|
+
}) {
|
|
10
|
+
return {
|
|
11
|
+
model,
|
|
12
|
+
messages: payload.messages,
|
|
13
|
+
stream: true,
|
|
14
|
+
stream_options: { include_usage: true },
|
|
15
|
+
...(payload.tools.length > 0 ? { tools: payload.tools, tool_choice: 'auto' } : {}),
|
|
16
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
17
|
+
...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
|
|
18
|
+
...(Array.isArray(stop) && stop.length > 0 ? { stop } : {}),
|
|
19
|
+
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
|
20
|
+
...(sessionId ? {
|
|
21
|
+
session_id: sessionId,
|
|
22
|
+
conversation_id: sessionId,
|
|
23
|
+
prompt_cache_key: sessionId,
|
|
24
|
+
} : {}),
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/adapter-messages.js
CHANGED
|
@@ -103,8 +103,13 @@ async function userContent(blocks, attachments, signal) {
|
|
|
103
103
|
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
|
104
104
|
content.push({ type: 'text', text: block.text })
|
|
105
105
|
} else if (block.type === 'image') {
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
const url = await imageToUrl(block, attachments, signal)
|
|
107
|
+
if (url.startsWith('data:image/')) {
|
|
108
|
+
hasImage = true
|
|
109
|
+
content.push({ type: 'image_url', image_url: { url } })
|
|
110
|
+
} else {
|
|
111
|
+
content.push({ type: 'text', text: url })
|
|
112
|
+
}
|
|
108
113
|
} else if (block.type === 'file') {
|
|
109
114
|
content.push({ type: 'text', text: fileText(block) })
|
|
110
115
|
} else if (block.type === 'tool-result') {
|
package/lib/adapter.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getBudgetSpend } from './stats-storage.js'
|
|
|
16
16
|
import { budgetStatus, budgetErrorMessage } from './budget.js'
|
|
17
17
|
import { buildOpenAiPayload } from './adapter-messages.js'
|
|
18
18
|
import { optimizePromptCacheAndBudget } from './multimodal-budget.js'
|
|
19
|
+
import { makeRequestBody } from './adapter-body.js'
|
|
19
20
|
import { streamJsonCompletion, streamOpenAiChunks } from './adapter-stream.js'
|
|
20
21
|
import {
|
|
21
22
|
abortError,
|
|
@@ -265,12 +266,13 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
265
266
|
const attachments = this.resolveAttachments
|
|
266
267
|
? this.resolveAttachments()
|
|
267
268
|
: attachmentStore(this.ctx)
|
|
268
|
-
const
|
|
269
|
+
const maxPayloadBytes = Number(pub.maxPayloadMb || 40) * 1024 * 1024
|
|
270
|
+
let optimized = optimizePromptCacheAndBudget(options.messages, {
|
|
269
271
|
offloadSeenImages: pub.offloadSeenImages,
|
|
270
|
-
maxPayloadBytes
|
|
272
|
+
maxPayloadBytes,
|
|
271
273
|
})
|
|
272
274
|
const requestOptions = { ...options, offloadedImages: optimized.offloadedCount }
|
|
273
|
-
|
|
275
|
+
let payload = await buildOpenAiPayload({
|
|
274
276
|
...options,
|
|
275
277
|
messages: optimized.messages,
|
|
276
278
|
model,
|
|
@@ -298,21 +300,26 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
298
300
|
? String(options.reasoningEffort)
|
|
299
301
|
: (pub.modelReasoningDefaults?.[model] || undefined)
|
|
300
302
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
303
|
+
let body = makeRequestBody({ model, payload, maxTokens, temperature: options.temperature, stop: options.stop, reasoningEffort, sessionId })
|
|
304
|
+
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > maxPayloadBytes) {
|
|
305
|
+
const allImagesOffloaded = optimizePromptCacheAndBudget(optimized.messages, {
|
|
306
|
+
offloadSeenImages: false,
|
|
307
|
+
maxPayloadBytes: 0,
|
|
308
|
+
})
|
|
309
|
+
if (allImagesOffloaded.offloadedCount > 0) {
|
|
310
|
+
optimized = allImagesOffloaded
|
|
311
|
+
requestOptions.offloadedImages += allImagesOffloaded.offloadedCount
|
|
312
|
+
payload = await buildOpenAiPayload({
|
|
313
|
+
...options,
|
|
314
|
+
messages: optimized.messages,
|
|
315
|
+
model,
|
|
316
|
+
signal,
|
|
317
|
+
}, { attachments })
|
|
318
|
+
body = makeRequestBody({ model, payload, maxTokens, temperature: options.temperature, stop: options.stop, reasoningEffort, sessionId })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > maxPayloadBytes) {
|
|
322
|
+
throw new LlmError('ClinePass request is too large after image offload. Start a new session or reduce messages and tools.', 'REQUEST_TOO_LARGE')
|
|
316
323
|
}
|
|
317
324
|
|
|
318
325
|
const extraHeaders = sessionId
|
|
@@ -363,7 +370,9 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
363
370
|
}
|
|
364
371
|
|
|
365
372
|
if (!response.ok) {
|
|
366
|
-
const text = await readResponseText(response
|
|
373
|
+
const text = await readResponseText(response, {
|
|
374
|
+
idleTimeoutMs: Math.min(pub.timeoutMs || 5000, 5000),
|
|
375
|
+
}).catch(() => '')
|
|
367
376
|
const classified = classifyUpstreamStatus(response.status, text)
|
|
368
377
|
const retryAfter = retryAfterMs(response.headers)
|
|
369
378
|
lastError = new LlmError(classified.message, classified.code, {
|
|
@@ -391,6 +400,7 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
391
400
|
if (ttftMs === null) ttftMs = Date.now() - startedAt
|
|
392
401
|
}
|
|
393
402
|
|
|
403
|
+
let clientCommitted = false
|
|
394
404
|
try {
|
|
395
405
|
if (isEventStream(response)) {
|
|
396
406
|
const events = readSseEvents(response, {
|
|
@@ -443,8 +453,14 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
443
453
|
)
|
|
444
454
|
}
|
|
445
455
|
|
|
446
|
-
for (const chunk of buffered)
|
|
447
|
-
|
|
456
|
+
for (const chunk of buffered) {
|
|
457
|
+
clientCommitted = true
|
|
458
|
+
yield chunk
|
|
459
|
+
}
|
|
460
|
+
for await (const chunk of iterator) {
|
|
461
|
+
clientCommitted = true
|
|
462
|
+
yield chunk
|
|
463
|
+
}
|
|
448
464
|
|
|
449
465
|
this.recordAttempt({
|
|
450
466
|
pub,
|
|
@@ -460,7 +476,10 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
460
476
|
}
|
|
461
477
|
|
|
462
478
|
// Fallback for deployments that ignore `stream: true`.
|
|
463
|
-
const text = await readResponseText(response
|
|
479
|
+
const text = await readResponseText(response, {
|
|
480
|
+
idleTimeoutMs: pub.streamIdleTimeoutMs,
|
|
481
|
+
signal,
|
|
482
|
+
})
|
|
464
483
|
let json
|
|
465
484
|
try {
|
|
466
485
|
json = JSON.parse(text)
|
|
@@ -470,6 +489,7 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
470
489
|
let committed = false
|
|
471
490
|
for await (const chunk of streamJsonCompletion(json, { onUsage, onFirstToken })) {
|
|
472
491
|
if (isContentChunk(chunk)) committed = true
|
|
492
|
+
clientCommitted = true
|
|
473
493
|
yield chunk
|
|
474
494
|
}
|
|
475
495
|
if (!committed) {
|
|
@@ -511,7 +531,7 @@ export class ClineBotAdapter extends LlmAdapter {
|
|
|
511
531
|
|| normalized.code === 'INSUFFICIENT_CREDITS'
|
|
512
532
|
|| normalized.code === 'QUOTA_EXCEEDED'
|
|
513
533
|
lastError = normalized
|
|
514
|
-
if (retryable && attempt + 1 < maxAttempts) {
|
|
534
|
+
if (retryable && !clientCommitted && attempt + 1 < maxAttempts) {
|
|
515
535
|
const isCredit = normalized.code === 'INSUFFICIENT_CREDITS'
|
|
516
536
|
markAccountCooldown(
|
|
517
537
|
accountId,
|
package/lib/csv.js
ADDED
package/lib/key-discovery.js
CHANGED
|
@@ -18,13 +18,17 @@ function cleanupExpired() {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function
|
|
21
|
+
export function peekDiscoveredKey(id) {
|
|
22
22
|
if (!id || typeof id !== 'string') return null
|
|
23
23
|
cleanupExpired()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
return discoveredKeysCache.get(id)?.key || null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function consumeDiscoveredKey(id) {
|
|
28
|
+
if (!id || typeof id !== 'string') return null
|
|
29
|
+
const key = peekDiscoveredKey(id)
|
|
30
|
+
if (key) discoveredKeysCache.delete(id)
|
|
31
|
+
return key
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export function maskApiKey(key) {
|
package/lib/models.js
CHANGED
|
@@ -572,10 +572,7 @@ export async function loadModelsDiskCache(cachePath, options = {}) {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
-
|
|
576
|
-
* Detects whether the authoritative dynamic models catalog differs from the current catalog
|
|
577
|
-
* in model count, IDs, or metadata (e.g. contextLength, maxTokens, category, etc.).
|
|
578
|
-
*/
|
|
575
|
+
/* Compare authoritative model count, IDs and metadata. */
|
|
579
576
|
export function isCatalogDifferent(currentList = [], authoritativeList = []) {
|
|
580
577
|
if (!Array.isArray(currentList) || !Array.isArray(authoritativeList)) return true
|
|
581
578
|
if (currentList.length !== authoritativeList.length) return true
|
|
@@ -589,6 +586,7 @@ export function isCatalogDifferent(currentList = [], authoritativeList = []) {
|
|
|
589
586
|
cur.maxTokens !== auth.maxTokens ||
|
|
590
587
|
cur.category !== auth.category ||
|
|
591
588
|
cur.description !== auth.description ||
|
|
589
|
+
Boolean(cur.free) !== Boolean(auth.free) ||
|
|
592
590
|
JSON.stringify(cur.input || []) !== JSON.stringify(auth.input || []) ||
|
|
593
591
|
JSON.stringify(cur.reasoningEfforts || []) !== JSON.stringify(auth.reasoningEfforts || [])
|
|
594
592
|
) {
|
package/lib/multimodal-budget.js
CHANGED
|
@@ -17,6 +17,10 @@ function estimateImageBlockBytes(block) {
|
|
|
17
17
|
if (block.type === 'image' && typeof block.source?.data === 'string') {
|
|
18
18
|
return block.source.data.length
|
|
19
19
|
}
|
|
20
|
+
if (block.type === 'image' && block.attachment && typeof block.attachment === 'object') {
|
|
21
|
+
const bytes = Number(block.attachment.bytes || block.attachment.size || 0)
|
|
22
|
+
if (bytes > 0) return Math.ceil(bytes * 4 / 3) + 64
|
|
23
|
+
}
|
|
20
24
|
return 0
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export function dshHome() {
|
|
5
|
+
return path.resolve(process.env.DSH_HOME || path.join(process.env.HOME || os.homedir(), '.dsh'))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isSafeDataPath(value) {
|
|
9
|
+
const raw = String(value ?? '').trim()
|
|
10
|
+
if (!raw) return true
|
|
11
|
+
if (raw.includes('\0')) return false
|
|
12
|
+
const resolved = raw === '~' || raw.startsWith('~/')
|
|
13
|
+
? path.resolve(process.env.HOME || os.homedir(), raw.slice(1))
|
|
14
|
+
: path.resolve(raw)
|
|
15
|
+
const home = dshHome()
|
|
16
|
+
if (resolved === home || !resolved.startsWith(`${home}${path.sep}`)) return false
|
|
17
|
+
const relative = path.relative(home, resolved).toLowerCase()
|
|
18
|
+
const segments = relative.split(/[\\/]+/)
|
|
19
|
+
const base = path.basename(resolved).toLowerCase()
|
|
20
|
+
if (segments[0] === 'profiles' && !base.startsWith('clinebot-')) return false
|
|
21
|
+
if (path.extname(base) !== '.json') return false
|
|
22
|
+
return !['package.json', 'package-lock.json', 'tsconfig.json', 'settings.json', '.credentials.json'].includes(base)
|
|
23
|
+
}
|
package/lib/price-sync.js
CHANGED
|
@@ -4,6 +4,35 @@ import path from 'node:path'
|
|
|
4
4
|
import { normalizeModelKey, getPublicPriceCatalog, setPublicPriceCatalog } from './model-prices.js'
|
|
5
5
|
|
|
6
6
|
const DEFAULT_URL = 'https://models.dev/api.json'
|
|
7
|
+
|
|
8
|
+
function isPrivateHost(host) {
|
|
9
|
+
const value = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
10
|
+
if (!value || value === 'localhost' || value.endsWith('.localhost')) return true
|
|
11
|
+
if (value === '::1' || value.startsWith('fc') || value.startsWith('fd') || value.startsWith('fe80:')) return true
|
|
12
|
+
if (value.startsWith('::ffff:')) return isPrivateHost(value.slice(7))
|
|
13
|
+
const parts = value.split('.')
|
|
14
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return false
|
|
15
|
+
const octets = parts.map(Number)
|
|
16
|
+
if (octets.some((part) => part > 255)) return false
|
|
17
|
+
return octets[0] === 0 || octets[0] === 10 || octets[0] === 127
|
|
18
|
+
|| (octets[0] === 169 && octets[1] === 254)
|
|
19
|
+
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
|
|
20
|
+
|| (octets[0] === 192 && octets[1] === 168)
|
|
21
|
+
|| (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAllowedPriceCatalogUrl(value) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(String(value || ''))
|
|
27
|
+
if (url.protocol !== 'https:' || url.username || url.password) return false
|
|
28
|
+
const host = url.hostname.toLowerCase()
|
|
29
|
+
if (isPrivateHost(host)) return false
|
|
30
|
+
return host === 'models.dev' || host.endsWith('.models.dev')
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
const PROVIDER_PRIORITY = {
|
|
8
37
|
opencode: 0,
|
|
9
38
|
cline: 0,
|
|
@@ -131,6 +160,7 @@ export async function syncPublicPrices({
|
|
|
131
160
|
fetchImpl = fetch,
|
|
132
161
|
timeoutMs = 15000,
|
|
133
162
|
} = {}) {
|
|
163
|
+
if (!isAllowedPriceCatalogUrl(url)) throw new Error('Price catalog URL is not allowed')
|
|
134
164
|
const controller = new AbortController()
|
|
135
165
|
const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 15000))
|
|
136
166
|
let data
|
|
@@ -139,7 +169,9 @@ export async function syncPublicPrices({
|
|
|
139
169
|
signal: controller.signal,
|
|
140
170
|
headers: { accept: 'application/json' },
|
|
141
171
|
cache: 'no-store',
|
|
172
|
+
redirect: 'manual',
|
|
142
173
|
})
|
|
174
|
+
if (response?.status >= 300 && response?.status < 400) throw new Error('Price catalog redirect is not allowed')
|
|
143
175
|
if (!response?.ok) throw new Error(`HTTP ${response?.status || 0}`)
|
|
144
176
|
data = await response.json()
|
|
145
177
|
} finally {
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeJson, readBody, publicUsage } from '../http.js'
|
|
2
|
-
import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
|
|
2
|
+
import { discoverLocalClineKeys, consumeDiscoveredKey, peekDiscoveredKey } from '../key-discovery.js'
|
|
3
|
+
import { resolveKeyValue } from '../credential-refs.js'
|
|
3
4
|
import { assertTrustedSettingsRequest } from '../access.js'
|
|
4
5
|
import { publicConfig, plainConfig, Config } from '../config.js'
|
|
5
6
|
import { resolveActiveAccountKey } from '../provider-sync.js'
|
|
@@ -17,8 +18,19 @@ import {
|
|
|
17
18
|
|
|
18
19
|
export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
|
|
19
20
|
const pendingSecretCleanupAccounts = new Set()
|
|
21
|
+
let accountMutationTail = Promise.resolve()
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
function runAccountMutation(task) {
|
|
24
|
+
const run = accountMutationTail.then(task)
|
|
25
|
+
accountMutationTail = run.catch(() => {})
|
|
26
|
+
return run
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function addAccountInternal(input) {
|
|
30
|
+
return runAccountMutation(() => addAccountInternalUnlocked(input))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function addAccountInternalUnlocked({ apiKey, label, apiKeyEnv }) {
|
|
22
34
|
const curr = plainConfig(live())
|
|
23
35
|
const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
|
|
24
36
|
|
|
@@ -49,20 +61,53 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
|
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
const accountLabel = String(label || '').trim() || targetEnv
|
|
64
|
+
const previous = await resolveKeyValue(ctx, targetEnv)
|
|
65
|
+
const restoreCredential = async () => {
|
|
66
|
+
try {
|
|
67
|
+
if (previous.source === 'credentials' && previous.value) {
|
|
68
|
+
await saveCredentialKey(ctx, targetEnv, previous.value)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
const result = await deleteCredentialKey(ctx, targetEnv)
|
|
72
|
+
return Boolean(result?.ok)
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
52
77
|
|
|
53
78
|
await saveCredentialKey(ctx, targetEnv, apiKey)
|
|
54
79
|
|
|
55
|
-
const
|
|
80
|
+
const fresh = plainConfig(live())
|
|
81
|
+
const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
|
|
82
|
+
const idx = freshAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
|
|
56
83
|
const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
|
|
57
84
|
if (idx >= 0) {
|
|
58
|
-
|
|
85
|
+
freshAccounts[idx] = accountEntry
|
|
59
86
|
} else {
|
|
60
|
-
|
|
87
|
+
freshAccounts.push(accountEntry)
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
const next = plainConfig(Config({ ...
|
|
90
|
+
const next = plainConfig(Config({ ...fresh, accounts: freshAccounts }))
|
|
64
91
|
const settingsApi = getSettingsApi()
|
|
65
|
-
|
|
92
|
+
try {
|
|
93
|
+
if (typeof settingsApi.update === 'function') {
|
|
94
|
+
await settingsApi.update({ accounts: freshAccounts })
|
|
95
|
+
} else {
|
|
96
|
+
await settingsApi.replace(next)
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const restored = await restoreCredential()
|
|
100
|
+
if (!restored) {
|
|
101
|
+
pendingSecretCleanupAccounts.add(targetEnv)
|
|
102
|
+
const partial = new Error(`Account settings were not persisted and credential rollback failed for "${targetEnv}". Use account delete with secret cleanup to retry.`)
|
|
103
|
+
partial.status = 500
|
|
104
|
+
partial.partial = true
|
|
105
|
+
partial.credentialOrphan = true
|
|
106
|
+
partial.apiKeyEnv = targetEnv
|
|
107
|
+
throw partial
|
|
108
|
+
}
|
|
109
|
+
throw err
|
|
110
|
+
}
|
|
66
111
|
await syncProviderState(next)
|
|
67
112
|
|
|
68
113
|
let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
|
|
@@ -121,7 +166,13 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
|
|
|
121
166
|
writeJson(res, 200, { ok: true, ...result })
|
|
122
167
|
} catch (err) {
|
|
123
168
|
const status = typeof err?.status === 'number' ? err.status : 500
|
|
124
|
-
writeJson(res, status, {
|
|
169
|
+
writeJson(res, status, {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: String(err?.message || err),
|
|
172
|
+
...(err?.partial ? { partial: true } : {}),
|
|
173
|
+
...(err?.credentialOrphan ? { credentialOrphan: true } : {}),
|
|
174
|
+
...(err?.apiKeyEnv ? { apiKeyEnv: err.apiKeyEnv } : {}),
|
|
175
|
+
})
|
|
125
176
|
}
|
|
126
177
|
},
|
|
127
178
|
}), 'dsh-clinebot: /accounts')
|
|
@@ -351,7 +402,7 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
|
|
|
351
402
|
return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
|
|
352
403
|
}
|
|
353
404
|
|
|
354
|
-
const apiKey =
|
|
405
|
+
const apiKey = peekDiscoveredKey(id)
|
|
355
406
|
if (!apiKey) {
|
|
356
407
|
return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
|
|
357
408
|
}
|
|
@@ -361,10 +412,17 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
|
|
|
361
412
|
label: body.label,
|
|
362
413
|
apiKeyEnv: body.apiKeyEnv,
|
|
363
414
|
})
|
|
415
|
+
consumeDiscoveredKey(id)
|
|
364
416
|
writeJson(res, 200, { ok: true, ...result })
|
|
365
417
|
} catch (err) {
|
|
366
418
|
const status = typeof err?.status === 'number' ? err.status : 500
|
|
367
|
-
writeJson(res, status, {
|
|
419
|
+
writeJson(res, status, {
|
|
420
|
+
ok: false,
|
|
421
|
+
error: String(err?.message || err),
|
|
422
|
+
...(err?.partial ? { partial: true } : {}),
|
|
423
|
+
...(err?.credentialOrphan ? { credentialOrphan: true } : {}),
|
|
424
|
+
...(err?.apiKeyEnv ? { apiKeyEnv: err.apiKeyEnv } : {}),
|
|
425
|
+
})
|
|
368
426
|
}
|
|
369
427
|
},
|
|
370
428
|
}), 'dsh-clinebot: /keys/discovered/import')
|
package/lib/routes/settings.js
CHANGED
|
@@ -6,7 +6,8 @@ import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-clie
|
|
|
6
6
|
import { getStatsSummary, getSessionStats, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
|
|
7
7
|
import { exportStatsAsJson } from '../stats-export.js'
|
|
8
8
|
import { priceTable, getPublicPriceCatalog, setPublicPriceCatalog } from '../model-prices.js'
|
|
9
|
-
import { syncPublicPrices } from '../price-sync.js'
|
|
9
|
+
import { syncPublicPrices, isAllowedPriceCatalogUrl } from '../price-sync.js'
|
|
10
|
+
import { isSafeDataPath } from '../path-safety.js'
|
|
10
11
|
|
|
11
12
|
export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
|
|
12
13
|
// 1. GET /dsh-clinebot/status
|
|
@@ -92,6 +93,14 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
92
93
|
return writeJson(res, 400, { ok: false, error: 'Invalid baseUrl' })
|
|
93
94
|
}
|
|
94
95
|
}
|
|
96
|
+
if (rawPayload.priceCatalogUrl !== undefined && !isAllowedPriceCatalogUrl(rawPayload.priceCatalogUrl)) {
|
|
97
|
+
return writeJson(res, 400, { ok: false, error: 'priceCatalogUrl must be an https models.dev URL' })
|
|
98
|
+
}
|
|
99
|
+
for (const key of ['statsPath', 'modelsCachePath', 'priceCatalogPath', 'eventLogPath']) {
|
|
100
|
+
if (rawPayload[key] !== undefined && !isSafeDataPath(rawPayload[key])) {
|
|
101
|
+
return writeJson(res, 400, { ok: false, error: `${key} must stay inside the DSH home directory` })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
95
104
|
try {
|
|
96
105
|
const base = plainConfig(live()) || {}
|
|
97
106
|
delete base.enabledModels
|
package/lib/stats-storage.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
percentileFromHistogram,
|
|
15
15
|
pruneRetentionOnStats,
|
|
16
16
|
} from './stats-metrics.js'
|
|
17
|
+
import { csvCell } from './csv.js'
|
|
17
18
|
|
|
18
19
|
const MAX_SESSIONS = 500
|
|
19
20
|
const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
|
|
@@ -561,11 +562,7 @@ export function getAnalytics(query = {}) {
|
|
|
561
562
|
}
|
|
562
563
|
}
|
|
563
564
|
|
|
564
|
-
|
|
565
|
-
* Export CSV. The compact form keeps the historical schema; `detailed`
|
|
566
|
-
* appends cache, cost, TTFT and latency columns.
|
|
567
|
-
* @param {boolean} [detailed]
|
|
568
|
-
*/
|
|
565
|
+
/* Export the compact or detailed CSV schema. */
|
|
569
566
|
export function exportStatsAsCsv(detailed = false) {
|
|
570
567
|
const header = detailed
|
|
571
568
|
? 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,CacheReadTokens,CacheWriteTokens,TotalTokens,CostUsd,TtftMs,LatencyMs'
|
|
@@ -574,9 +571,9 @@ export function exportStatsAsCsv(detailed = false) {
|
|
|
574
571
|
const addRows = (dimension, obj) => {
|
|
575
572
|
for (const [key, val] of Object.entries(obj || {})) {
|
|
576
573
|
if (detailed) {
|
|
577
|
-
lines.push(`${dimension}
|
|
574
|
+
lines.push(`${dimension},${csvCell(key)},${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
|
|
578
575
|
} else {
|
|
579
|
-
lines.push(`${dimension}
|
|
576
|
+
lines.push(`${dimension},${csvCell(key)},${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
|
|
580
577
|
}
|
|
581
578
|
}
|
|
582
579
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
10
|
|
|
11
11
|
const require = createRequire(import.meta.url)
|
|
12
|
-
let packageVersion = '0.6.
|
|
12
|
+
let packageVersion = '0.6.23'
|
|
13
13
|
try {
|
|
14
14
|
packageVersion = require('../../package.json').version || packageVersion
|
|
15
15
|
} catch {
|
|
@@ -20,6 +20,7 @@ const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
|
20
20
|
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
|
21
21
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
|
|
22
22
|
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
23
|
+
const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Build the abort-shaped error every transport caller understands.
|
|
@@ -131,7 +132,30 @@ export function classifyUpstreamStatus(status, bodyText = '') {
|
|
|
131
132
|
* @param {{ maxBytes?: number }} [options]
|
|
132
133
|
* @returns {Promise<string>}
|
|
133
134
|
*/
|
|
134
|
-
|
|
135
|
+
async function readChunkWithIdle(reader, idleTimeoutMs) {
|
|
136
|
+
let timer = null
|
|
137
|
+
try {
|
|
138
|
+
return await Promise.race([
|
|
139
|
+
reader.read(),
|
|
140
|
+
new Promise((_, reject) => {
|
|
141
|
+
timer = setTimeout(() => {
|
|
142
|
+
const error = new Error(`Upstream body idle timeout after ${idleTimeoutMs}ms`)
|
|
143
|
+
error.code = 'STREAM_IDLE_TIMEOUT'
|
|
144
|
+
reject(error)
|
|
145
|
+
}, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
|
146
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
} finally {
|
|
150
|
+
if (timer) clearTimeout(timer)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readResponseText(response, {
|
|
155
|
+
maxBytes = DEFAULT_MAX_BODY_BYTES,
|
|
156
|
+
idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
157
|
+
signal,
|
|
158
|
+
} = {}) {
|
|
135
159
|
if (!response?.body) return ''
|
|
136
160
|
const reader = response.body.getReader()
|
|
137
161
|
const decoder = new TextDecoder('utf8')
|
|
@@ -139,16 +163,21 @@ export async function readResponseText(response, { maxBytes = DEFAULT_MAX_BODY_B
|
|
|
139
163
|
let out = ''
|
|
140
164
|
try {
|
|
141
165
|
while (true) {
|
|
142
|
-
|
|
166
|
+
if (signal?.aborted) throw abortError()
|
|
167
|
+
const { done, value } = await readChunkWithIdle(reader, idleTimeoutMs)
|
|
143
168
|
if (done) break
|
|
144
169
|
size += value.byteLength
|
|
145
170
|
if (size > maxBytes) {
|
|
171
|
+
await reader.cancel(`body exceeded ${maxBytes} bytes`).catch(() => {})
|
|
146
172
|
throw new Error(`Upstream response exceeded ${maxBytes} bytes`)
|
|
147
173
|
}
|
|
148
174
|
out += decoder.decode(value, { stream: true })
|
|
149
175
|
}
|
|
150
176
|
out += decoder.decode()
|
|
151
177
|
return out
|
|
178
|
+
} catch (error) {
|
|
179
|
+
await reader.cancel(error?.message || 'body read failed').catch(() => {})
|
|
180
|
+
throw error
|
|
152
181
|
} finally {
|
|
153
182
|
try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
|
|
154
183
|
}
|
|
@@ -263,30 +292,14 @@ function parseSseBlock(raw) {
|
|
|
263
292
|
export async function* readSseEvents(response, {
|
|
264
293
|
signal,
|
|
265
294
|
idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
295
|
+
maxEventBytes = DEFAULT_MAX_SSE_EVENT_BYTES,
|
|
266
296
|
} = {}) {
|
|
267
297
|
if (!response?.body) return
|
|
268
298
|
const reader = response.body.getReader()
|
|
269
299
|
const decoder = new TextDecoder('utf8')
|
|
270
300
|
let buffer = ''
|
|
271
301
|
|
|
272
|
-
|
|
273
|
-
let timer = null
|
|
274
|
-
try {
|
|
275
|
-
return await Promise.race([
|
|
276
|
-
reader.read(),
|
|
277
|
-
new Promise((_, reject) => {
|
|
278
|
-
timer = setTimeout(() => {
|
|
279
|
-
const error = new Error(`Stream idle timeout after ${idleTimeoutMs}ms`)
|
|
280
|
-
error.code = 'STREAM_IDLE_TIMEOUT'
|
|
281
|
-
reject(error)
|
|
282
|
-
}, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
|
283
|
-
if (typeof timer.unref === 'function') timer.unref()
|
|
284
|
-
}),
|
|
285
|
-
])
|
|
286
|
-
} finally {
|
|
287
|
-
if (timer) clearTimeout(timer)
|
|
288
|
-
}
|
|
289
|
-
}
|
|
302
|
+
const readWithIdle = () => readChunkWithIdle(reader, idleTimeoutMs)
|
|
290
303
|
|
|
291
304
|
try {
|
|
292
305
|
while (true) {
|
|
@@ -301,11 +314,17 @@ export async function* readSseEvents(response, {
|
|
|
301
314
|
let boundary = buffer.indexOf('\n\n')
|
|
302
315
|
while (boundary >= 0) {
|
|
303
316
|
const block = buffer.slice(0, boundary)
|
|
317
|
+
if (block.length > maxEventBytes) {
|
|
318
|
+
throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
|
|
319
|
+
}
|
|
304
320
|
buffer = buffer.slice(boundary + 2)
|
|
305
321
|
const parsed = parseSseBlock(block)
|
|
306
322
|
if (parsed !== undefined) yield parsed
|
|
307
323
|
boundary = buffer.indexOf('\n\n')
|
|
308
324
|
}
|
|
325
|
+
if (buffer.length > maxEventBytes) {
|
|
326
|
+
throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
|
|
327
|
+
}
|
|
309
328
|
}
|
|
310
329
|
buffer += decoder.decode()
|
|
311
330
|
const tail = parseSseBlock(buffer)
|
package/lib/updater.js
CHANGED
|
@@ -14,8 +14,19 @@ import { fileURLToPath } from 'node:url'
|
|
|
14
14
|
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
15
15
|
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
16
16
|
const VERSION_CACHE_MS = 5 * 60_000
|
|
17
|
+
const SECRET_ENV_RE = /(?:^|_)(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:_|$)/i
|
|
17
18
|
let latestCache = undefined
|
|
18
19
|
|
|
20
|
+
export function sanitizeChildEnv(env = process.env) {
|
|
21
|
+
const out = { ...env, NO_COLOR: '1' }
|
|
22
|
+
for (const key of Object.keys(out)) {
|
|
23
|
+
if (/^CLINEBOT_/i.test(key) || /^COMMANDCODE_/i.test(key) || SECRET_ENV_RE.test(key) || /AUTH[_-]?TOKEN/i.test(key)) {
|
|
24
|
+
delete out[key]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
function header(request, name) {
|
|
20
31
|
const value = request?.headers?.[name]
|
|
21
32
|
return Array.isArray(value) ? value[0] : value
|
|
@@ -263,11 +274,7 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
263
274
|
cwd: target.profileDir,
|
|
264
275
|
windowsHide: true,
|
|
265
276
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
266
|
-
env: (
|
|
267
|
-
const childEnv = { ...process.env, NO_COLOR: '1' }
|
|
268
|
-
delete childEnv.CLINEBOT_LOCAL_PROXY_TOKEN
|
|
269
|
-
return childEnv
|
|
270
|
-
})(),
|
|
277
|
+
env: sanitizeChildEnv(),
|
|
271
278
|
detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
|
|
272
279
|
})
|
|
273
280
|
let detail = ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|