@goodandready/dsh-clinebot 0.6.25 → 0.6.27
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 +17 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/account-pool.js +2 -1
- package/lib/adapter-telemetry.js +2 -1
- package/lib/cline-client.js +4 -0
- package/lib/config.js +3 -2
- package/lib/credential-refs.js +22 -4
- package/lib/http.js +10 -0
- package/lib/index.js +14 -10
- package/lib/path-safety.js +7 -1
- package/lib/price-sync.js +3 -2
- package/lib/provider-sync.js +3 -2
- package/lib/routes/models.js +3 -2
- package/lib/routes/settings.js +6 -0
- package/lib/safe-dict.js +10 -0
- package/lib/transport/cline-transport.js +5 -1
- package/lib/updater.js +20 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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.27] - 2026-11-05
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **Updater Windows environment (#289)**: the environment allowlist is case-insensitive, so Windows `Path` and `ProgramFiles(x86)` survive into the child installer.
|
|
12
|
+
- **Updater registry credentials (#290)**: npm registry/proxy URL userinfo is stripped before the child installer starts.
|
|
13
|
+
- **Complete write-path guard (#288)**: adapter telemetry and the models sync route no longer write stats or price caches outside `$DSH_HOME`; invalid paths fall back instead of being used.
|
|
14
|
+
- **Dangling symlink guard (#291)**: `isSafeDataPath()` rejects symlinks whose target does not exist, closing the remaining symlink escape from #269.
|
|
15
|
+
- **Native settings HTTPS gate (#292)**: remote chat and usage requests reject plain HTTP `baseUrl` even when the value came from native settings.
|
|
16
|
+
- **Credential name allowlist (#293)**: `apiKeyEnv`, account env names and credential resolution accept only `CLINEBOT_API_KEY` names; invalid names cannot read arbitrary environment credentials.
|
|
17
|
+
|
|
18
|
+
## [0.6.26] - 2026-11-05
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
- **Updater proxy credentials (#285)**: proxy env values are stripped of URL userinfo before being passed to install scripts.
|
|
22
|
+
- **Native settings path guard (#286)**: unsafe `statsPath`, `eventLogPath`, `modelsCachePath` and `priceCatalogPath` values are ignored by startup/sync and rejected by write routes, not only by the HTTP config handler.
|
|
23
|
+
- **Prototype-safe price/config dictionaries (#287)**: price catalogs, config snapshots and account snapshots keep `__proto__` as own data instead of changing object prototypes.
|
|
24
|
+
|
|
8
25
|
## [0.6.25] - 2026-11-04
|
|
9
26
|
|
|
10
27
|
### Added
|
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.27</b> completes the native-settings security boundary: HTTPS-only remote endpoints, allowlisted credential names, path-safe telemetry/price writes, dangling-symlink rejection and a Windows-safe updater environment. It keeps DSH <code>0.2.2-rc.1</code> support, automatic model/free/dated catalog backfill, 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.27</b> завершает security-границу native settings: HTTPS-only внешние endpoints, allowlist имён credentials, безопасные пути telemetry/price, отклонение dangling symlink и Windows-safe окружение updater. Сохраняются поддержка DSH <code>0.2.2-rc.1</code>, автодобор каталога моделей/FREE/dated, 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.27</b> 完善 native settings 安全边界:远程端点仅允许 HTTPS、凭据名称白名单、遥测/价格安全路径、拒绝悬空符号链接,以及 Windows 安全的 updater 环境。保留 DSH <code>0.2.2-rc.1</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>
|
package/lib/account-pool.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveKeyValue, usageCache, clearUsageCache, clearProbeCache, DEFAULT_API_KEY_ENV, DEFAULT_BASE_URL, buildUsageCacheKey } from './cline-client.js'
|
|
2
|
+
import { safeDict } from './safe-dict.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Resolve all accounts in pool with their status and keys.
|
|
@@ -21,7 +22,7 @@ function arraySetting(value) {
|
|
|
21
22
|
|
|
22
23
|
function toPlainSnapshot(cfg) {
|
|
23
24
|
if (!cfg || typeof cfg !== 'object') return cfg
|
|
24
|
-
const out =
|
|
25
|
+
const out = safeDict()
|
|
25
26
|
for (const [k, v] of Object.entries(cfg)) {
|
|
26
27
|
let cur = v
|
|
27
28
|
while (cur && typeof cur === 'object' && !Array.isArray(cur) && typeof cur.get === 'function') {
|
package/lib/adapter-telemetry.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { estimateCost } from './model-prices.js'
|
|
2
2
|
import { recordSessionRequest } from './telemetry.js'
|
|
3
3
|
import { recordUsage } from './stats-storage.js'
|
|
4
|
+
import { isSafeDataPath } from './path-safety.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Record one attempt's session and persistent telemetry.
|
|
@@ -59,7 +60,7 @@ export function recordAttemptTelemetry({
|
|
|
59
60
|
totalTokens,
|
|
60
61
|
isError,
|
|
61
62
|
is429,
|
|
62
|
-
statsPath: pub.statsPath,
|
|
63
|
+
statsPath: isSafeDataPath(pub.statsPath) ? pub.statsPath : undefined,
|
|
63
64
|
sessionId: options.sessionId ? String(options.sessionId) : undefined,
|
|
64
65
|
purpose: options.purpose,
|
|
65
66
|
ttftMs: ttftMs ?? undefined,
|
package/lib/cline-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import { isSecureRemoteBaseUrl } from './http.js'
|
|
2
3
|
export {
|
|
3
4
|
resolveApiKey,
|
|
4
5
|
saveCredentialKey,
|
|
@@ -172,6 +173,9 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
172
173
|
if (!apiKey) {
|
|
173
174
|
return { ok: false, error: 'API key is missing' }
|
|
174
175
|
}
|
|
176
|
+
if (!isSecureRemoteBaseUrl(base)) {
|
|
177
|
+
return { ok: false, error: 'Insecure protocol: remote usage requests require https:' }
|
|
178
|
+
}
|
|
175
179
|
|
|
176
180
|
const cacheKey = buildUsageCacheKey(base, apiKey)
|
|
177
181
|
const now = Date.now()
|
package/lib/config.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getActiveModelIds,
|
|
14
14
|
migrateModelId,
|
|
15
15
|
} from './models.js'
|
|
16
|
+
import { setOwn } from './safe-dict.js'
|
|
16
17
|
|
|
17
18
|
// DSH settings.describe only publishes fields marked volatile. Without them the
|
|
18
19
|
// namespace is omitted and the configuration page stays unavailable.
|
|
@@ -150,7 +151,7 @@ export function plainConfig(cfg) {
|
|
|
150
151
|
}
|
|
151
152
|
const out = {}
|
|
152
153
|
for (const key of Object.keys(cfg)) {
|
|
153
|
-
out
|
|
154
|
+
setOwn(out, key, plainConfig(cfg[key]))
|
|
154
155
|
}
|
|
155
156
|
return out
|
|
156
157
|
}
|
|
@@ -161,7 +162,7 @@ export function volatileConfig(cfg) {
|
|
|
161
162
|
const out = {}
|
|
162
163
|
for (const [key, field] of Object.entries(Config.dict || {})) {
|
|
163
164
|
if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
|
|
164
|
-
out
|
|
165
|
+
setOwn(out, key, plain[key])
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
return out
|
package/lib/credential-refs.js
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
|
|
7
|
+
const API_KEY_ENV_RE = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/
|
|
8
|
+
|
|
9
|
+
export function isAllowedApiKeyEnv(value) {
|
|
10
|
+
return API_KEY_ENV_RE.test(String(value || '').trim())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isAllowedCredentialDeletion(value) {
|
|
14
|
+
const name = String(value || '').trim()
|
|
15
|
+
return isAllowedApiKeyEnv(name) || name === 'CLINEBOT_LOCAL_PROXY_TOKEN'
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Resolve the optional credentials service without tripping Cordis's
|
|
@@ -38,10 +48,11 @@ async function toCredentialRef(name) {
|
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
export function resolveApiKey(apiKeyEnv, env = process.env) {
|
|
41
|
-
const
|
|
51
|
+
const requested = String(apiKeyEnv || '').trim()
|
|
52
|
+
const name = requested || DEFAULT_API_KEY_ENV
|
|
42
53
|
return {
|
|
43
54
|
envName: name,
|
|
44
|
-
value: String(env[name] || ''),
|
|
55
|
+
value: isAllowedApiKeyEnv(name) ? String(env[name] || '') : '',
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -49,6 +60,9 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
|
|
|
49
60
|
const name = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
50
61
|
const value = String(apiKey || '').trim()
|
|
51
62
|
|
|
63
|
+
if (!isAllowedApiKeyEnv(name)) {
|
|
64
|
+
throw new Error('Disallowed apiKeyEnv: only CLINEBOT_API_KEY names are allowed')
|
|
65
|
+
}
|
|
52
66
|
if (!value) {
|
|
53
67
|
throw new Error('API key cannot be empty')
|
|
54
68
|
}
|
|
@@ -65,7 +79,7 @@ export async function saveCredentialKey(ctx, apiKeyEnv, apiKey) {
|
|
|
65
79
|
|
|
66
80
|
export async function deleteCredentialKey(ctx, apiKeyEnv) {
|
|
67
81
|
const name = String(apiKeyEnv || '').trim()
|
|
68
|
-
if (!name) return { ok: false }
|
|
82
|
+
if (!name || !isAllowedCredentialDeletion(name)) return { ok: false }
|
|
69
83
|
const credentials = credentialsService(ctx)
|
|
70
84
|
if (!credentials) return { ok: false }
|
|
71
85
|
try {
|
|
@@ -81,7 +95,11 @@ export async function deleteCredentialKey(ctx, apiKeyEnv) {
|
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
84
|
-
const
|
|
98
|
+
const requested = String(apiKeyEnv || '').trim()
|
|
99
|
+
const refName = requested || DEFAULT_API_KEY_ENV
|
|
100
|
+
if (!isAllowedApiKeyEnv(refName)) {
|
|
101
|
+
return { envName: refName, value: '', source: 'invalid' }
|
|
102
|
+
}
|
|
85
103
|
const creds = credentialsService(ctx)
|
|
86
104
|
if (creds && typeof creds.resolve === 'function') {
|
|
87
105
|
try {
|
package/lib/http.js
CHANGED
|
@@ -53,6 +53,16 @@ function isLoopbackAddress(value) {
|
|
|
53
53
|
|| address.startsWith('::ffff:127.')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function isSecureRemoteBaseUrl(value) {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(String(value || ''))
|
|
59
|
+
return url.protocol === 'https:'
|
|
60
|
+
|| (url.protocol === 'http:' && isLoopbackAddress(url.hostname))
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
export function getConnection(ctx) {
|
|
57
67
|
try {
|
|
58
68
|
const fromCtx = ctx?.reflect?.get
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,8 @@ import { loadAccountEvents, flushAccountEvents, recordAccountEvent } from './acc
|
|
|
27
27
|
import { setAccountEventSink } from './session-router.js'
|
|
28
28
|
import { service, migrateLegacyPiAiProvider } from './services.js'
|
|
29
29
|
import { applyTuiSettings } from './tui-settings.js'
|
|
30
|
+
import { isSafeDataPath } from './path-safety.js'
|
|
31
|
+
import { safeDict, setOwn } from './safe-dict.js'
|
|
30
32
|
|
|
31
33
|
export const name = '@goodandready/dsh-clinebot'
|
|
32
34
|
export const inject = []
|
|
@@ -42,7 +44,7 @@ export { NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rota
|
|
|
42
44
|
|
|
43
45
|
export function apply(ctx, config) {
|
|
44
46
|
let currentConfig = configReader(config)() || {}
|
|
45
|
-
const overrides =
|
|
47
|
+
const overrides = safeDict()
|
|
46
48
|
const getConfig = () => {
|
|
47
49
|
if (!currentConfig) return currentConfig
|
|
48
50
|
if (Object.isFrozen(currentConfig)) {
|
|
@@ -108,10 +110,11 @@ export function apply(ctx, config) {
|
|
|
108
110
|
})
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
const initial = publicConfig(live())
|
|
114
|
+
let activeStatsPath = resolvePath(isSafeDataPath(initial.statsPath) ? initial.statsPath : '~/.dsh/clinebot-stats.json')
|
|
115
|
+
setStatsRetentionDays(initial.statsRetentionDays)
|
|
116
|
+
setPublicPriceCatalog(loadPublicPriceCache(isSafeDataPath(initial.priceCatalogPath) ? initial.priceCatalogPath : '~/.dsh/clinebot-prices.json'))
|
|
117
|
+
let activeEventPath = resolvePath(isSafeDataPath(initial.eventLogPath) ? initial.eventLogPath : '~/.dsh/clinebot-events.json')
|
|
115
118
|
loadAccountEvents(activeEventPath)
|
|
116
119
|
setAccountEventSink(recordAccountEvent)
|
|
117
120
|
loadStats(activeStatsPath)
|
|
@@ -119,14 +122,15 @@ export function apply(ctx, config) {
|
|
|
119
122
|
const syncStatsStorage = (cfg) => {
|
|
120
123
|
const pub = publicConfig(cfg)
|
|
121
124
|
setStatsRetentionDays(pub.statsRetentionDays)
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
const priceCatalogPath = isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : '~/.dsh/clinebot-prices.json'
|
|
126
|
+
setPublicPriceCatalog(loadPublicPriceCache(priceCatalogPath))
|
|
127
|
+
const nextEventPath = resolvePath(isSafeDataPath(pub.eventLogPath) ? pub.eventLogPath : '~/.dsh/clinebot-events.json')
|
|
124
128
|
if (nextEventPath !== activeEventPath) {
|
|
125
129
|
flushAccountEvents()
|
|
126
130
|
activeEventPath = nextEventPath
|
|
127
131
|
loadAccountEvents(activeEventPath)
|
|
128
132
|
}
|
|
129
|
-
const nextPath = resolvePath(pub.statsPath)
|
|
133
|
+
const nextPath = resolvePath(isSafeDataPath(pub.statsPath) ? pub.statsPath : '~/.dsh/clinebot-stats.json')
|
|
130
134
|
if (nextPath !== activeStatsPath) {
|
|
131
135
|
switchStatsStorage(nextPath)
|
|
132
136
|
activeStatsPath = nextPath
|
|
@@ -184,7 +188,7 @@ export function apply(ctx, config) {
|
|
|
184
188
|
if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
|
|
185
189
|
continue
|
|
186
190
|
}
|
|
187
|
-
overrides
|
|
191
|
+
setOwn(overrides, key, value)
|
|
188
192
|
}
|
|
189
193
|
return
|
|
190
194
|
}
|
|
@@ -202,7 +206,7 @@ export function apply(ctx, config) {
|
|
|
202
206
|
if (desc?.set) {
|
|
203
207
|
targetConfig[key] = value
|
|
204
208
|
} else {
|
|
205
|
-
targetConfig
|
|
209
|
+
setOwn(targetConfig, key, value)
|
|
206
210
|
}
|
|
207
211
|
} catch {
|
|
208
212
|
/* ignore read-only */
|
package/lib/path-safety.js
CHANGED
|
@@ -10,6 +10,11 @@ function realPath(target) {
|
|
|
10
10
|
let current = target
|
|
11
11
|
const missing = []
|
|
12
12
|
while (!fs.existsSync(current)) {
|
|
13
|
+
try {
|
|
14
|
+
if (fs.lstatSync(current).isSymbolicLink()) return null
|
|
15
|
+
} catch {
|
|
16
|
+
/* path does not exist */
|
|
17
|
+
}
|
|
13
18
|
const parent = path.dirname(current)
|
|
14
19
|
if (parent === current) break
|
|
15
20
|
missing.unshift(path.basename(current))
|
|
@@ -18,7 +23,7 @@ function realPath(target) {
|
|
|
18
23
|
try {
|
|
19
24
|
return path.join(fs.realpathSync(current), ...missing)
|
|
20
25
|
} catch {
|
|
21
|
-
return
|
|
26
|
+
return null
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -31,6 +36,7 @@ export function isSafeDataPath(value) {
|
|
|
31
36
|
: path.resolve(raw)
|
|
32
37
|
const home = realPath(dshHome())
|
|
33
38
|
const realResolved = realPath(resolved)
|
|
39
|
+
if (!home || !realResolved) return false
|
|
34
40
|
if (realResolved === home || !realResolved.startsWith(`${home}${path.sep}`)) return false
|
|
35
41
|
const relative = path.relative(home, realResolved).toLowerCase()
|
|
36
42
|
const segments = relative.split(/[\\/]+/)
|
package/lib/price-sync.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from '
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { normalizeModelKey, getPublicPriceCatalog, setPublicPriceCatalog } from './model-prices.js'
|
|
5
|
+
import { safeDict } from './safe-dict.js'
|
|
5
6
|
|
|
6
7
|
const DEFAULT_URL = 'https://models.dev/api.json'
|
|
7
8
|
|
|
@@ -63,7 +64,7 @@ function priority(provider) {
|
|
|
63
64
|
* @param {Record<string, any>} data
|
|
64
65
|
*/
|
|
65
66
|
export function buildPublicCatalog(data) {
|
|
66
|
-
const catalog =
|
|
67
|
+
const catalog = safeDict()
|
|
67
68
|
for (const provider of Object.values(data || {})) {
|
|
68
69
|
if (!provider || typeof provider !== 'object') continue
|
|
69
70
|
const providerId = String(provider.id || '')
|
|
@@ -102,7 +103,7 @@ export function loadPublicPriceCache(cachePath) {
|
|
|
102
103
|
const file = expandHome(cachePath)
|
|
103
104
|
if (!existsSync(file)) return {}
|
|
104
105
|
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
105
|
-
return parsed?.models && typeof parsed.models === 'object' ? parsed.models :
|
|
106
|
+
return parsed?.models && typeof parsed.models === 'object' ? safeDict(parsed.models) : safeDict()
|
|
106
107
|
} catch {
|
|
107
108
|
return {}
|
|
108
109
|
}
|
package/lib/provider-sync.js
CHANGED
|
@@ -22,6 +22,7 @@ import { publicConfig, plainConfig, Config } from './config.js'
|
|
|
22
22
|
import { publicUsage } from './http.js'
|
|
23
23
|
import { resolveKeyValue } from './credential-refs.js'
|
|
24
24
|
import { resolveAccountPool, getLastRotation } from './account-pool.js'
|
|
25
|
+
import { isSafeDataPath } from './path-safety.js'
|
|
25
26
|
|
|
26
27
|
export function resolvePathWithHome(p) {
|
|
27
28
|
let guard = 0
|
|
@@ -193,7 +194,7 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
193
194
|
try {
|
|
194
195
|
const cfg = live()
|
|
195
196
|
const pub = publicConfig(cfg)
|
|
196
|
-
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
197
|
+
const cacheFile = isSafeDataPath(pub.modelsCachePath) ? resolvePathWithHome(pub.modelsCachePath) : ''
|
|
197
198
|
const settingsApi = getSettingsApi()
|
|
198
199
|
|
|
199
200
|
// Auto-migrate obsolete IDs (e.g. deepseek-v41-flash -> deepseek-v4.1-flash)
|
|
@@ -248,7 +249,7 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
248
249
|
if (isDifferent) {
|
|
249
250
|
await ensurePublicPricesForModels(usageData.dynamicModels.map((model) => model.id), {
|
|
250
251
|
url: pub.priceCatalogUrl,
|
|
251
|
-
cachePath: pub.priceCatalogPath,
|
|
252
|
+
cachePath: isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : undefined,
|
|
252
253
|
enabled: pub.priceAutoSync !== false,
|
|
253
254
|
}).catch(() => {})
|
|
254
255
|
}
|
package/lib/routes/models.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getAllModels, saveModelsDiskCache, getOriginalModelContext, getOriginal
|
|
|
6
6
|
import { fetchUsageLimits, smokeChat } from '../cline-client.js'
|
|
7
7
|
import { publicUsage } from '../http.js'
|
|
8
8
|
import { ensurePublicPricesForModels } from '../price-sync.js'
|
|
9
|
+
import { isSafeDataPath } from '../path-safety.js'
|
|
9
10
|
|
|
10
11
|
export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
11
12
|
// POST /dsh-clinebot/models/sync — dynamically load models from plan
|
|
@@ -51,7 +52,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
51
52
|
}))
|
|
52
53
|
await settingsApi.replace(next)
|
|
53
54
|
await syncProviderState(next)
|
|
54
|
-
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
55
|
+
const cacheFile = isSafeDataPath(pub.modelsCachePath) ? resolvePathWithHome(pub.modelsCachePath) : ''
|
|
55
56
|
if (cacheFile) {
|
|
56
57
|
const activeAcc = await resolveActiveAccountKey(ctx, live())
|
|
57
58
|
const accountRef = activeAcc?.envName || activeAcc?.apiKeyEnv || pub.activeAccount || pub.apiKeyEnv || 'CLINEBOT_API_KEY'
|
|
@@ -66,7 +67,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
66
67
|
try {
|
|
67
68
|
priceSync = await ensurePublicPricesForModels(addedModels.map((model) => model.id), {
|
|
68
69
|
url: pub.priceCatalogUrl,
|
|
69
|
-
cachePath: pub.priceCatalogPath,
|
|
70
|
+
cachePath: isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : undefined,
|
|
70
71
|
enabled: pub.priceAutoSync !== false,
|
|
71
72
|
})
|
|
72
73
|
} catch {
|
package/lib/routes/settings.js
CHANGED
|
@@ -235,6 +235,9 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
235
235
|
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
236
236
|
try {
|
|
237
237
|
const pub = publicConfig(live())
|
|
238
|
+
if (!isSafeDataPath(pub.statsPath)) {
|
|
239
|
+
return writeJson(res, 400, { ok: false, error: 'statsPath must stay inside the DSH home directory' })
|
|
240
|
+
}
|
|
238
241
|
const stats = resetStats(pub.statsPath)
|
|
239
242
|
writeJson(res, 200, { ok: true, stats })
|
|
240
243
|
} catch (err) {
|
|
@@ -341,6 +344,9 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
341
344
|
if (!assertTrustedSettingsRequest(req, res, ctx)) return
|
|
342
345
|
try {
|
|
343
346
|
const pub = publicConfig(live())
|
|
347
|
+
if (!isSafeDataPath(pub.priceCatalogPath)) {
|
|
348
|
+
return writeJson(res, 400, { ok: false, error: 'priceCatalogPath must stay inside the DSH home directory' })
|
|
349
|
+
}
|
|
344
350
|
const result = await syncPublicPrices({
|
|
345
351
|
url: pub.priceCatalogUrl,
|
|
346
352
|
cachePath: pub.priceCatalogPath,
|
package/lib/safe-dict.js
CHANGED
|
@@ -13,3 +13,13 @@ export function safeNestedDict(source) {
|
|
|
13
13
|
}
|
|
14
14
|
return out
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
export function setOwn(target, key, value) {
|
|
18
|
+
Object.defineProperty(target, key, {
|
|
19
|
+
value,
|
|
20
|
+
writable: true,
|
|
21
|
+
enumerable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
})
|
|
24
|
+
return target
|
|
25
|
+
}
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
|
+
import { isSecureRemoteBaseUrl } from '../http.js'
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url)
|
|
12
|
-
let packageVersion = '0.6.
|
|
13
|
+
let packageVersion = '0.6.27'
|
|
13
14
|
try {
|
|
14
15
|
packageVersion = require('../../package.json').version || packageVersion
|
|
15
16
|
} catch {
|
|
@@ -216,6 +217,9 @@ export async function requestChatCompletion({
|
|
|
216
217
|
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
217
218
|
extraHeaders = {},
|
|
218
219
|
}) {
|
|
220
|
+
if (!isSecureRemoteBaseUrl(baseUrl)) {
|
|
221
|
+
throw new Error('Insecure protocol: remote chat requests require https:')
|
|
222
|
+
}
|
|
219
223
|
const connectCtrl = new AbortController()
|
|
220
224
|
let timedOut = false
|
|
221
225
|
const timer = setTimeout(() => {
|
package/lib/updater.js
CHANGED
|
@@ -22,13 +22,32 @@ const SAFE_ENV_KEYS = new Set([
|
|
|
22
22
|
'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'USER', 'USERNAME',
|
|
23
23
|
'LOGNAME', 'HOSTNAME', 'DSH_HOME', 'DSH_PROFILE_DIR', 'NODE_EXTRA_CA_CERTS',
|
|
24
24
|
])
|
|
25
|
+
const SAFE_ENV_KEYS_LOWER = new Set([...SAFE_ENV_KEYS].map((key) => key.toLowerCase()))
|
|
25
26
|
const SAFE_NPM_ENV_RE = /^npm_config_(?:registry|proxy|https_proxy|strict_ssl|userconfig|globalconfig|cache|prefix)$/i
|
|
27
|
+
const NPM_URL_ENV_RE = /^npm_config_(?:registry|proxy|https_proxy)$/i
|
|
26
28
|
let latestCache = undefined
|
|
27
29
|
|
|
30
|
+
function sanitizeUrlCredentials(value) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(String(value))
|
|
33
|
+
url.username = ''
|
|
34
|
+
url.password = ''
|
|
35
|
+
return url.toString()
|
|
36
|
+
} catch {
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
export function sanitizeChildEnv(env = process.env) {
|
|
29
42
|
const out = {}
|
|
30
43
|
for (const [key, value] of Object.entries(env)) {
|
|
31
|
-
if (
|
|
44
|
+
if (!SAFE_ENV_KEYS_LOWER.has(key.toLowerCase()) && !SAFE_NPM_ENV_RE.test(key)) continue
|
|
45
|
+
if (NPM_URL_ENV_RE.test(key)) {
|
|
46
|
+
const clean = sanitizeUrlCredentials(value)
|
|
47
|
+
if (clean) out[key] = clean
|
|
48
|
+
} else {
|
|
49
|
+
out[key] = value
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
out.NO_COLOR = '1'
|
|
34
53
|
return out
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.27",
|
|
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",
|