@gehennawu/dsh-service 2.0.5 → 2.0.7
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/README.en.md +20 -8
- package/README.md +20 -8
- package/backup-integrity.js +215 -24
- package/backup-routes.js +17 -1
- package/client.js +1 -1
- package/index.js +110 -35
- package/package.json +2 -2
- package/quota-adapters.js +39 -3
- package/quota-routes.js +13 -7
- package/screenshots/backup-management.png +0 -0
- package/screenshots/backup-management_en.png +0 -0
- package/screenshots/configuration.png +0 -0
- package/screenshots/configuration_en.png +0 -0
- package/screenshots/health-diagnostics.png +0 -0
- package/screenshots/health-diagnostics_en.png +0 -0
- package/screenshots/mobile-adaptation.png +0 -0
- package/screenshots/mobile-adaptation_en.png +0 -0
- package/screenshots/model-usage.png +0 -0
- package/screenshots/model-usage_en.png +0 -0
- package/screenshots/overview.png +0 -0
- package/screenshots/overview_en.png +0 -0
- package/screenshots/plugin-config.png +0 -0
- package/screenshots/plugin-config_en.png +0 -0
- package/screenshots/quota-lookup.png +0 -0
- package/screenshots/quota-lookup_en.png +0 -0
- package/screenshots/session-manager.png +0 -0
- package/screenshots/session-manager_en.png +0 -0
- package/screenshots/subagent-model.png +0 -0
- package/screenshots/subagent-model_en.png +0 -0
package/index.js
CHANGED
|
@@ -238,6 +238,11 @@ const QUOTA_MIN_INTERVAL_MS = 15000
|
|
|
238
238
|
const QUOTA_MANUAL_COOLDOWN_MS = 5000
|
|
239
239
|
const QUOTA_BACKOFF_BASE_MS = 30000
|
|
240
240
|
const QUOTA_BACKOFF_MAX_MS = 15 * 60 * 1000
|
|
241
|
+
// 确定性失败矩阵:自动重试不会改变结果,只会白打上游(CPA 还会累计 fail2ban 失败次数、把 IP 打进 30 分钟封禁)。
|
|
242
|
+
// 命中即把该 provider 转入「手动模式」——此后只认手动重试这一条路,直到一次成功落定或用户改配置/凭据。
|
|
243
|
+
// 表外(network / network-transient / timeout / 5xx / upstream-error / cancelled)是可能自愈的瞬时失败,
|
|
244
|
+
// 保留指数退避自动重试。与 QUOTA_UNUSABLE_ERROR_RE 无关:这里只决定「还要不要自动打上游」。
|
|
245
|
+
const QUOTA_MANUAL_ONLY_ERROR_RE = /^(credential-missing|credential-rejected|credentials-unavailable|no-base-url|no-subscription|host-not-pinned|mgmt-disabled|mgmt-banned|transport-unavailable|bad-payload|http-status:4\d\d|upstream-status:4\d\d)/i
|
|
241
246
|
const QUOTA_CONFIG_STAT_TTL_MS = 5000
|
|
242
247
|
const QUOTA_MAX_CONCURRENCY = 4
|
|
243
248
|
const MAX_QUOTA_RESPONSE_BYTES = 1024 * 1024
|
|
@@ -601,9 +606,42 @@ function backupMetadataPayload(version, createdAt) {
|
|
|
601
606
|
dshVersion: typeof version === 'string' ? version : '',
|
|
602
607
|
pluginVersion: typeof pluginVersion === 'string' ? pluginVersion : '',
|
|
603
608
|
createdAt: createdAt instanceof Date ? createdAt.toISOString() : new Date().toISOString(),
|
|
609
|
+
// 来源平台/架构(v0.47):跨设备恢复时用来提示「这份快照出自哪个系统」。
|
|
610
|
+
// 与其它字段一样全部来自宿主常量,浏览器不参与;旧版插件读新归档时忽略这两个键。
|
|
611
|
+
platform: process.platform,
|
|
612
|
+
arch: process.arch,
|
|
604
613
|
}, null, 2)}\n`, 'utf8')
|
|
605
614
|
}
|
|
606
615
|
|
|
616
|
+
// 目标平台的事实来源:执行恢复的那台机器的进程平台。声明式覆盖只服务测试与验证
|
|
617
|
+
// (与 DSH_SERVICE_RUNTIME_ENV 同规),它只能影响只读提示与错误措辞,**不放宽**任何
|
|
618
|
+
// 归档校验——否则「声明成 Linux」就成了把 Windows 非法文件名塞进写入路径的后门。
|
|
619
|
+
function readBackupPlatform() {
|
|
620
|
+
const declared = process.env.DSH_SERVICE_BACKUP_PLATFORM
|
|
621
|
+
return typeof declared === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(declared) ? declared : process.platform
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 目标机可用于承载恢复内容的 profile 名清单(宿主侧 readdir 事实,浏览器只在该清单内
|
|
625
|
+
// 选名字)。名字口径与备份侧白名单同规:单一路径段、长度封顶。
|
|
626
|
+
async function listLocalProfiles(dshHome) {
|
|
627
|
+
const root = join(dshHome, 'profiles')
|
|
628
|
+
let entries
|
|
629
|
+
try { entries = await readdir(root, { withFileTypes: true }) } catch (error) {
|
|
630
|
+
if (error?.code === 'ENOENT') return []
|
|
631
|
+
throw error
|
|
632
|
+
}
|
|
633
|
+
const profiles = []
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
if (!entry.isDirectory() || !/^[A-Za-z0-9._-]{1,64}$/.test(entry.name) || entry.name === '.' || entry.name === '..') continue
|
|
636
|
+
profiles.push({
|
|
637
|
+
name: entry.name,
|
|
638
|
+
hasManifest: await pathExists(join(root, entry.name, 'package.json')),
|
|
639
|
+
hasPatch: await pathExists(join(root, entry.name, 'cordis.patch.yml')),
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
return profiles.sort((a, b) => a.name.localeCompare(b.name))
|
|
643
|
+
}
|
|
644
|
+
|
|
607
645
|
// 从归档名解析版本段(列表展示用,零 I/O)。只有 Host 自己签发的名字会走到这里,
|
|
608
646
|
// 但正则本身也保证不会把任意文本当版本号回给浏览器。
|
|
609
647
|
function backupVersionFromName(name) {
|
|
@@ -1258,7 +1296,11 @@ async function exportBackup(dshHome, downloadTokens, id) {
|
|
|
1258
1296
|
// 版本事实以归档内的 meta/backup.json 为准,不改名、不重写。
|
|
1259
1297
|
async function importBackup(dshHome, name, encoded, validatePath) {
|
|
1260
1298
|
if (typeof name !== 'string' || !BACKUP_NAME.test(name) || typeof encoded !== 'string' || encoded.length === 0) return undefined
|
|
1261
|
-
|
|
1299
|
+
// 只线性扫描字符;对大文件使用重复分组正则会耗尽 V8 正则调用栈。
|
|
1300
|
+
// 长度、填充位置和解码后重新编码的逐字节一致性仍共同约束规范 Base64。
|
|
1301
|
+
if (encoded.length > Math.ceil(MAX_BACKUP_TRANSFER_BYTES / 3) * 4 + 8 || encoded.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(encoded)) return undefined
|
|
1302
|
+
const padding = encoded.indexOf('=')
|
|
1303
|
+
if (padding !== -1 && (padding < encoded.length - 2 || !/^[A-Za-z0-9+/]{2,3}={1,2}$/.test(encoded.slice(-4)))) return undefined
|
|
1262
1304
|
const data = Buffer.from(encoded, 'base64')
|
|
1263
1305
|
if (data.length === 0 || data.length > MAX_BACKUP_TRANSFER_BYTES || data.toString('base64') !== encoded) return undefined
|
|
1264
1306
|
const backupDir = join(dshHome, 'backups')
|
|
@@ -2237,8 +2279,9 @@ async function requestQuotaJson(endpoint, options = {}) {
|
|
|
2237
2279
|
}
|
|
2238
2280
|
|
|
2239
2281
|
// 每 provider 节流状态机(内存态,重启清零)。一切来源共用同一判定,优先序:
|
|
2240
|
-
// 单飞去重 > 失败指数退避(30s×2 封顶 15min)> 成功 TTL 60s >
|
|
2241
|
-
//
|
|
2282
|
+
// 单飞去重 > 确定性失败后的手动模式 > 失败指数退避(30s×2 封顶 15min)> 成功 TTL 60s >
|
|
2283
|
+
// 最小上游间隔 15s(与 attempt() 判定序一致);手动模式只由「一次成功落定」或用户写操作
|
|
2284
|
+
// (resetGates)解除,中途不会因时间流逝自动恢复。now 由调用方注入,测试可推进假时钟。
|
|
2242
2285
|
function createQuotaThrottle(options = {}) {
|
|
2243
2286
|
const successTtlMs = options.successTtlMs ?? QUOTA_SUCCESS_TTL_MS
|
|
2244
2287
|
const minIntervalMs = options.minIntervalMs ?? QUOTA_MIN_INTERVAL_MS
|
|
@@ -2249,7 +2292,7 @@ function createQuotaThrottle(options = {}) {
|
|
|
2249
2292
|
const entryOf = (provider) => {
|
|
2250
2293
|
let entry = entries.get(provider)
|
|
2251
2294
|
if (entry === undefined) {
|
|
2252
|
-
entry = { lastSuccessAt: 0, lastUpstreamAt: 0, lastManualAt: 0, backoffUntil: 0, failures: 0, inflight: false, windows: undefined, fetchedAt: 0, lastError: undefined, lastErrorDetail: undefined, lastErrorEndpoint: undefined, lastErrorAccount: undefined }
|
|
2295
|
+
entry = { lastSuccessAt: 0, lastUpstreamAt: 0, lastManualAt: 0, backoffUntil: 0, failures: 0, inflight: false, manualOnly: false, windows: undefined, fetchedAt: 0, lastError: undefined, lastErrorDetail: undefined, lastErrorEndpoint: undefined, lastErrorAccount: undefined }
|
|
2253
2296
|
entries.set(provider, entry)
|
|
2254
2297
|
}
|
|
2255
2298
|
return entry
|
|
@@ -2269,7 +2312,7 @@ function createQuotaThrottle(options = {}) {
|
|
|
2269
2312
|
lastErrorAccount: entry.lastErrorAccount,
|
|
2270
2313
|
}
|
|
2271
2314
|
},
|
|
2272
|
-
/**
|
|
2315
|
+
/** 只读快照:缓存窗口、是否刷新中、是否已转手动、下次允许自动发起上游的时间(null=进行中/手动模式)。 */
|
|
2273
2316
|
view(provider, now = Date.now()) {
|
|
2274
2317
|
const entry = entryOf(provider)
|
|
2275
2318
|
return {
|
|
@@ -2280,21 +2323,28 @@ function createQuotaThrottle(options = {}) {
|
|
|
2280
2323
|
lastErrorDetail: entry.lastErrorDetail,
|
|
2281
2324
|
lastErrorEndpoint: entry.lastErrorEndpoint,
|
|
2282
2325
|
lastErrorAccount: entry.lastErrorAccount,
|
|
2283
|
-
|
|
2326
|
+
// 手动模式:自动路径一律拒发,故没有「下次自动重试时刻」可报(客户端据此换文案)。
|
|
2327
|
+
manualOnly: entry.manualOnly === true,
|
|
2328
|
+
nextAllowedAt: entry.inflight || entry.manualOnly
|
|
2284
2329
|
? null
|
|
2285
2330
|
: Math.max(entry.backoffUntil, entry.lastUpstreamAt + minIntervalMs, entry.lastSuccessAt + successTtlMs),
|
|
2286
2331
|
}
|
|
2287
2332
|
},
|
|
2288
|
-
/** 申请一次上游调用。允许则置单飞并返回 ok;拒绝时给出稳定原因与 nextAllowedAt
|
|
2289
|
-
|
|
2333
|
+
/** 申请一次上游调用。允许则置单飞并返回 ok;拒绝时给出稳定原因与 nextAllowedAt
|
|
2334
|
+
* (优先序:单飞 > 手动模式 > 退避 > 成功 TTL > 最小间隔)。
|
|
2335
|
+
* options.manual=true 是「用户手动连接」:跳过手动模式与三道时间闸,只受 force() 的冷却约束。 */
|
|
2336
|
+
attempt(provider, now = Date.now(), options = {}) {
|
|
2290
2337
|
const entry = entryOf(provider)
|
|
2291
2338
|
if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
|
|
2292
|
-
if (
|
|
2293
|
-
|
|
2294
|
-
return { ok: false, reason: '
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2339
|
+
if (options.manual !== true) {
|
|
2340
|
+
if (entry.manualOnly) return { ok: false, reason: 'manual-only', nextAllowedAt: null }
|
|
2341
|
+
if (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
|
|
2342
|
+
if (now - entry.lastSuccessAt < successTtlMs) {
|
|
2343
|
+
return { ok: false, reason: 'fresh', nextAllowedAt: entry.lastSuccessAt + successTtlMs }
|
|
2344
|
+
}
|
|
2345
|
+
if (entry.lastUpstreamAt > 0 && now - entry.lastUpstreamAt < minIntervalMs) {
|
|
2346
|
+
return { ok: false, reason: 'interval', nextAllowedAt: entry.lastUpstreamAt + minIntervalMs }
|
|
2347
|
+
}
|
|
2298
2348
|
}
|
|
2299
2349
|
entry.inflight = true
|
|
2300
2350
|
entry.lastUpstreamAt = now
|
|
@@ -2308,6 +2358,8 @@ function createQuotaThrottle(options = {}) {
|
|
|
2308
2358
|
entry.lastSuccessAt = now
|
|
2309
2359
|
entry.failures = 0
|
|
2310
2360
|
entry.backoffUntil = 0
|
|
2361
|
+
// 唯一解锁点(除 resetGates 的用户写操作外):一次成功落定即回归自动模式。
|
|
2362
|
+
entry.manualOnly = false
|
|
2311
2363
|
entry.windows = outcome.windows ?? []
|
|
2312
2364
|
entry.fetchedAt = now
|
|
2313
2365
|
entry.lastError = undefined
|
|
@@ -2320,27 +2372,29 @@ function createQuotaThrottle(options = {}) {
|
|
|
2320
2372
|
const delay = Math.min(backoffBaseMs * 2 ** (entry.failures - 1), backoffMaxMs)
|
|
2321
2373
|
entry.backoffUntil = now + delay
|
|
2322
2374
|
entry.lastError = typeof outcome.code === 'string' ? outcome.code : 'unknown'
|
|
2375
|
+
// 确定性失败(换凭据/改配置才可能恢复)→ 转手动模式:自动快照不再重打上游。
|
|
2376
|
+
// 瞬时失败保持自动退避,等下一轮快照接续。
|
|
2377
|
+
if (QUOTA_MANUAL_ONLY_ERROR_RE.test(entry.lastError)) entry.manualOnly = true
|
|
2323
2378
|
entry.lastErrorDetail = typeof outcome.detail === 'string' && outcome.detail !== '' ? outcome.detail : undefined
|
|
2324
2379
|
// 渠道特有事实(多候选链的失败端点 / CPA 的失败账号):只作附注,与错误码、上游原话分开存。
|
|
2325
2380
|
entry.lastErrorEndpoint = sanitizeQuotaErrorDetail(outcome.endpoint)
|
|
2326
2381
|
entry.lastErrorAccount = sanitizeQuotaErrorDetail(outcome.account)
|
|
2327
2382
|
},
|
|
2328
|
-
/**
|
|
2383
|
+
/** 手动重试的准入判定:只受单飞与不可绕过的硬冷却约束——手动模式与失败退避都由调用方的
|
|
2384
|
+
* attempt({ manual: true }) 跳过(用户明确要求「仅可手动连接报错的渠道」,手动不该比自动更受限)。 */
|
|
2329
2385
|
force(provider, now = Date.now()) {
|
|
2330
2386
|
const entry = entryOf(provider)
|
|
2331
2387
|
if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
|
|
2332
|
-
if (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
|
|
2333
2388
|
if (entry.lastManualAt > 0 && now - entry.lastManualAt < manualCooldownMs) {
|
|
2334
2389
|
return { ok: false, reason: 'cooldown', nextAllowedAt: entry.lastManualAt + manualCooldownMs }
|
|
2335
2390
|
}
|
|
2336
2391
|
entry.lastManualAt = now
|
|
2337
|
-
entry.lastSuccessAt = 0
|
|
2338
|
-
entry.lastUpstreamAt = 0
|
|
2339
2392
|
return { ok: true }
|
|
2340
2393
|
},
|
|
2341
2394
|
// 凭据/适配变更后的闸门重置(保存 key/Cookie、改适配类型时调用):旧失败是旧配置造成的,
|
|
2342
2395
|
// 填完就该立刻重试,不傻等最长 15min 的指数退避(GUI 反馈点名);单飞在途不抢占。
|
|
2343
|
-
//
|
|
2396
|
+
// 这也是手动模式唯一的「非成功解锁」路径——保存凭据/改适配本身就是用户的手动动作,
|
|
2397
|
+
// 且凭据类错误的「手动连接」正是保存后立刻重试。与 force() 的区别:连硬冷却一并清掉。
|
|
2344
2398
|
resetGates(provider) {
|
|
2345
2399
|
const entry = entryOf(provider)
|
|
2346
2400
|
if (entry.inflight) return { ok: false, reason: 'inflight', nextAllowedAt: null }
|
|
@@ -2349,6 +2403,7 @@ function createQuotaThrottle(options = {}) {
|
|
|
2349
2403
|
entry.lastManualAt = 0
|
|
2350
2404
|
entry.lastUpstreamAt = 0
|
|
2351
2405
|
entry.lastSuccessAt = 0
|
|
2406
|
+
entry.manualOnly = false
|
|
2352
2407
|
return { ok: true }
|
|
2353
2408
|
},
|
|
2354
2409
|
prune(activeProviders) {
|
|
@@ -5266,6 +5321,11 @@ function apply(ctx, featureConfig) {
|
|
|
5266
5321
|
runtimeEnv,
|
|
5267
5322
|
previousInstanceId: instanceId,
|
|
5268
5323
|
scheduleRestart: () => scheduleRestart(ctx, runtimeEnv),
|
|
5324
|
+
// 目标平台与目标 profile 清单都由宿主侧事实提供:恢复发生在哪台机器上,
|
|
5325
|
+
// 就以哪台机器的进程平台与 profiles 目录为准,浏览器不参与判定。
|
|
5326
|
+
platform: readBackupPlatform(),
|
|
5327
|
+
// 工厂只要名字清单(映射校验用);端点另取带 hasManifest/hasPatch 的完整形态。
|
|
5328
|
+
listProfiles: async () => (await listLocalProfiles(dshHome)).map((profile) => profile.name),
|
|
5269
5329
|
})
|
|
5270
5330
|
// 会话管理(v0.35):删除两段式计划(planId → {id, path, bytes}),TTL 过期自动驱逐。
|
|
5271
5331
|
const sessionDeletePlans = new Map()
|
|
@@ -5365,12 +5425,13 @@ function apply(ctx, featureConfig) {
|
|
|
5365
5425
|
const subagentSeamRef = { current: false }
|
|
5366
5426
|
// 子代理派发记录环(v1.2):apply 级持有,RPC 端点与 seam 共同读写(seam 写入、端点只读)。
|
|
5367
5427
|
const dispatchRing = { order: [], byChild: new Map() }
|
|
5368
|
-
//
|
|
5369
|
-
//
|
|
5370
|
-
// 原方法——包装挂在服务实例上,disposer 期间新派生恢复原生行为。
|
|
5428
|
+
// 按宿主能力包装子代理启动入口:新版 startActivation,旧版 start/startContinuable。
|
|
5429
|
+
// 未显式指定模型的派生按配置注入 agentOptions,其余原样透传;Fiber 销毁时还原原方法。
|
|
5371
5430
|
ctx.inject(['subagents'], (scope) => {
|
|
5372
5431
|
const subagents = scope.subagents
|
|
5373
|
-
|
|
5432
|
+
const hasActivation = typeof subagents?.startActivation === 'function'
|
|
5433
|
+
const hasLegacyStarts = typeof subagents?.start === 'function' && typeof subagents?.startContinuable === 'function'
|
|
5434
|
+
if (!hasActivation && !hasLegacyStarts) return
|
|
5374
5435
|
const isRoutable = (provider) => {
|
|
5375
5436
|
const llm = ctx.get('llm')
|
|
5376
5437
|
if (llm === undefined || typeof llm.listProviders !== 'function') return false
|
|
@@ -5460,15 +5521,24 @@ function apply(ctx, featureConfig) {
|
|
|
5460
5521
|
if (dispatch === undefined) return work()
|
|
5461
5522
|
return pendingDispatchStorage.run(dispatch, work)
|
|
5462
5523
|
}
|
|
5524
|
+
const originalStartActivation = subagents.startActivation
|
|
5463
5525
|
const originalStart = subagents.start
|
|
5464
5526
|
const originalStartContinuable = subagents.startContinuable
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5527
|
+
if (hasActivation) {
|
|
5528
|
+
subagents.startActivation = (spec) => {
|
|
5529
|
+
const { request: decorated, dispatch } = applyInjection(spec.request)
|
|
5530
|
+
if (dispatch === undefined) return originalStartActivation.call(subagents, spec)
|
|
5531
|
+
return runWithDispatch(dispatch, () => originalStartActivation.call(subagents, { ...spec, request: decorated }))
|
|
5532
|
+
}
|
|
5533
|
+
} else {
|
|
5534
|
+
subagents.start = (name, request) => {
|
|
5535
|
+
const { request: decorated, dispatch } = applyInjection(request)
|
|
5536
|
+
return runWithDispatch(dispatch, () => originalStart.call(subagents, name, decorated))
|
|
5537
|
+
}
|
|
5538
|
+
subagents.startContinuable = (spec) => {
|
|
5539
|
+
const { request: decorated, dispatch } = applyInjection(spec.request)
|
|
5540
|
+
return runWithDispatch(dispatch, () => originalStartContinuable.call(subagents, { ...spec, request: decorated }))
|
|
5541
|
+
}
|
|
5472
5542
|
}
|
|
5473
5543
|
// agent/created(0.1.6 起由 announce await ctx.serial 异步串行派发;0.1.5 及之前同步派发在创建栈内):
|
|
5474
5544
|
// 记录派发路由 + 绑定等级(仅当确有非空等级)。监听器内部全 try/catch 严格保证绝不抛错,
|
|
@@ -5548,8 +5618,11 @@ function apply(ctx, featureConfig) {
|
|
|
5548
5618
|
}, { prepend: true }) : null
|
|
5549
5619
|
subagentSeamRef.current = true
|
|
5550
5620
|
scope.effect(() => () => {
|
|
5551
|
-
subagents.
|
|
5552
|
-
|
|
5621
|
+
if (hasActivation) subagents.startActivation = originalStartActivation
|
|
5622
|
+
else {
|
|
5623
|
+
subagents.start = originalStart
|
|
5624
|
+
subagents.startContinuable = originalStartContinuable
|
|
5625
|
+
}
|
|
5553
5626
|
if (typeof disposeCreated === 'function') disposeCreated()
|
|
5554
5627
|
if (typeof disposeRequest === 'function') disposeRequest()
|
|
5555
5628
|
if (typeof disposeRequestError === 'function') disposeRequestError()
|
|
@@ -5661,8 +5734,10 @@ function apply(ctx, featureConfig) {
|
|
|
5661
5734
|
}
|
|
5662
5735
|
// 远端额度:后台补拉一次。index.js 只提供节流/生命周期/受控传输 context;所有方言、
|
|
5663
5736
|
// 端点、凭据策略与编排都通过同一 Adapter interface 执行,不再区分 parser/fetcher 形状。
|
|
5664
|
-
|
|
5665
|
-
|
|
5737
|
+
// options.manual=true 只由 quota-refresh(手动重试/连接)传入:跳过手动模式与三道时间闸,
|
|
5738
|
+
// 是「确定性失败转手动」之后唯一还能打上游的路径。
|
|
5739
|
+
const kickQuotaRefresh = (profile, adapter, config, options = {}) => {
|
|
5740
|
+
const decision = quotaThrottle.attempt(profile.name, Date.now(), { manual: options.manual === true })
|
|
5666
5741
|
if (!decision.ok) return
|
|
5667
5742
|
const queued = enqueueQuotaWork(async () => {
|
|
5668
5743
|
const controller = new AbortController()
|
|
@@ -6127,7 +6202,7 @@ function apply(ctx, featureConfig) {
|
|
|
6127
6202
|
createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
|
|
6128
6203
|
createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, canonicalResetCardExpiresAt, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
|
|
6129
6204
|
createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
|
|
6130
|
-
createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, dshVersion, setBackupProgress, withBackupLock, backupNameVersionSuffix, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
|
|
6205
|
+
createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, dshVersion, setBackupProgress, withBackupLock, backupNameVersionSuffix, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, listLocalProfiles, name, rpcFailure }),
|
|
6131
6206
|
])
|
|
6132
6207
|
|
|
6133
6208
|
const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gehennawu/dsh-service",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"homepage": "https://github.com/gehennawu/dsh-service#readme",
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=22",
|
|
55
|
-
"dsh": ">=0.1.1-rc.2 <0.2.1-alpha.
|
|
55
|
+
"dsh": ">=0.1.1-rc.2 <0.2.1-alpha.3"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@deepseek-ai/schemastery": "^3.18.3"
|
package/quota-adapters.js
CHANGED
|
@@ -1110,6 +1110,37 @@ function parseCliproxyUpstream(provider, payload) {
|
|
|
1110
1110
|
return normalizeGeminiBuckets(payload?.buckets)
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
+
// 管理面 403 的三种语义(上游 `internal/api/handlers/management/handler.go` 原文):
|
|
1114
|
+
// · `IP banned due to too many failed attempts. Try again in 29m10s` —— fail2ban 封禁(实测 2026-10-10)
|
|
1115
|
+
// · `remote management disabled` / `remote management key not set` —— 管理面没开或没配密钥
|
|
1116
|
+
// 三者与「密钥填错」(401 `invalid management key`)完全不同,必须分开归类,否则用户对着一句
|
|
1117
|
+
// 状态码猜:填错要给重填入口,封禁/未开启给重填入口是误导。
|
|
1118
|
+
const CLIPROXY_BANNED_DETAIL_RE = /IP banned due to too many failed attempts/i
|
|
1119
|
+
const CLIPROXY_MGMT_OFF_DETAIL_RE = /remote management (?:disabled|key not set)/i
|
|
1120
|
+
|
|
1121
|
+
/** CPA 管理面失败归类:401/403 = 管理密钥被拒(与其余 11 个 kind 同款,行回 unconfigured 就地重填);
|
|
1122
|
+
* 封禁 → mgmt-banned(专属码,给等待/换出口 IP 的指引);远程管理未开启 → mgmt-disabled。 */
|
|
1123
|
+
function cliproxyManagementFailure(error) {
|
|
1124
|
+
const detail = sanitizeQuotaErrorDetail(error?.detail)
|
|
1125
|
+
if (detail !== undefined && CLIPROXY_BANNED_DETAIL_RE.test(detail)) {
|
|
1126
|
+
const banned = new Error('mgmt-banned')
|
|
1127
|
+
banned.detail = detail
|
|
1128
|
+
return banned
|
|
1129
|
+
}
|
|
1130
|
+
if (detail !== undefined && CLIPROXY_MGMT_OFF_DETAIL_RE.test(detail)) {
|
|
1131
|
+
const disabled = new Error('mgmt-disabled')
|
|
1132
|
+
disabled.detail = detail
|
|
1133
|
+
return disabled
|
|
1134
|
+
}
|
|
1135
|
+
const message = typeof error?.message === 'string' ? error.message : ''
|
|
1136
|
+
if (message === 'http-status:401' || message === 'http-status:403') {
|
|
1137
|
+
const rejected = new Error('credential-rejected')
|
|
1138
|
+
if (detail !== undefined) rejected.detail = detail
|
|
1139
|
+
return rejected
|
|
1140
|
+
}
|
|
1141
|
+
return error
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1113
1144
|
async function fetchCliproxyUsage({ profile, config, credential, signal, requestJson }) {
|
|
1114
1145
|
if (typeof requestJson !== 'function') throw new Error('transport-unavailable')
|
|
1115
1146
|
const pinned = Array.isArray(config?.allowedHosts?.[profile.name]) ? config.allowedHosts[profile.name] : []
|
|
@@ -1119,8 +1150,9 @@ async function fetchCliproxyUsage({ profile, config, credential, signal, request
|
|
|
1119
1150
|
try {
|
|
1120
1151
|
filesPayload = await requestJson(`${origin}/v0/management/auth-files`, { authorization: credential, signal })
|
|
1121
1152
|
} catch (error) {
|
|
1153
|
+
// secret-key 为空时管理路由整体不注册 → 404(既有口径,先于其余归类)。
|
|
1122
1154
|
if (error?.message === 'http-status:404') throw new Error('mgmt-disabled')
|
|
1123
|
-
throw error
|
|
1155
|
+
throw cliproxyManagementFailure(error)
|
|
1124
1156
|
}
|
|
1125
1157
|
const files = Array.isArray(filesPayload?.files) ? filesPayload.files : []
|
|
1126
1158
|
const accounts = []
|
|
@@ -1162,8 +1194,12 @@ async function fetchCliproxyUsage({ profile, config, credential, signal, request
|
|
|
1162
1194
|
body: JSON.stringify({ auth_index: account.authIndex, method: call.method, url: call.url, header: call.header, data: call.data }),
|
|
1163
1195
|
})
|
|
1164
1196
|
} catch (error) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1197
|
+
// 管理面自身失败(密钥被拒 / 封禁 / 管理面未开):与账号级上游失败(HTTP 200 信封里的
|
|
1198
|
+
// status_code)严格分开。密钥在 api-call 途中失效或撞上封禁时,整行必须给出可行动指引,
|
|
1199
|
+
// 而不是一个 upstream-status 家族码。
|
|
1200
|
+
const failure = cliproxyManagementFailure(error)
|
|
1201
|
+
const detail = sanitizeQuotaErrorDetail(failure?.detail)
|
|
1202
|
+
pushFailure(accountIndex, quotaErrorCode(failure), detail !== undefined ? { detail } : {})
|
|
1167
1203
|
const fallback = cliproxFallbackWindows(account)
|
|
1168
1204
|
if (fallback.length > 0) {
|
|
1169
1205
|
accountResults.set(accountIndex, fallback)
|
package/quota-routes.js
CHANGED
|
@@ -66,10 +66,12 @@ export function createQuotaRoutes({
|
|
|
66
66
|
// 漏掉它会把卡片锁死在错误态,用户找不到任何入口(GUI 反馈「失效后无法再次填入」)。
|
|
67
67
|
const credentialClass = view.lastError === 'credential-missing' || view.lastError === 'no-base-url' || view.lastError === 'credentials-unavailable' || view.lastError === 'credential-rejected'
|
|
68
68
|
const providerResetCards = resetCardsByProvider.get(profile.name) ?? []
|
|
69
|
-
//
|
|
70
|
-
//
|
|
69
|
+
// 凭据窗口的数据源:**已适配行一律附带**候选线索名的配置状态,不再以「未配置」为门槛——
|
|
70
|
+
// 已保存的凭据(密钥轮换、换账号、填错想改)此前没有任何 GUI 修改入口(用户点名)。
|
|
71
|
+
// describe 只回配置与否/来源/可写,绝不带值,且是内存查询;凭据服务缺席时省略字段,
|
|
72
|
+
// 客户端随之隐藏入口、退回文案指引。
|
|
71
73
|
let credentialHints
|
|
72
|
-
|
|
74
|
+
{
|
|
73
75
|
const credentials = ctx.get('credentials')
|
|
74
76
|
if (credentials !== undefined && typeof credentials.describe === 'function') {
|
|
75
77
|
const described = []
|
|
@@ -103,6 +105,8 @@ export function createQuotaRoutes({
|
|
|
103
105
|
...(view.lastErrorEndpoint !== undefined ? { errorEndpoint: view.lastErrorEndpoint } : {}),
|
|
104
106
|
...(view.lastErrorAccount !== undefined ? { errorAccount: view.lastErrorAccount } : {}),
|
|
105
107
|
nextAllowedAt: view.nextAllowedAt,
|
|
108
|
+
// 确定性失败后的手动模式:客户端据此把「{time} 后可重试」换成「自动重试已暂停」。
|
|
109
|
+
...(view.manualOnly === true ? { manualOnly: true } : {}),
|
|
106
110
|
...(providerResetCards.length > 0 ? { resetCards: providerResetCards } : {}),
|
|
107
111
|
...(credentialHints !== undefined ? { credentialHints } : {}),
|
|
108
112
|
// 凭据入口语义由 Adapter policy 下发稳定键,客户端只负责本地化,不再对 kind 重复分支。
|
|
@@ -119,8 +123,10 @@ export function createQuotaRoutes({
|
|
|
119
123
|
} },
|
|
120
124
|
'quota-refresh': { feature: 'quotaLookup', audit: true, handle: async (payload, rpcEndpoint) => {
|
|
121
125
|
try {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
126
|
+
// 手动重试/连接入口:provider 过白名单且 kind 已适配;这是「确定性失败转手动模式」之后
|
|
127
|
+
// 唯一还能打上游的路径(kick 带 manual 标记,跳过手动模式与退避/TTL/间隔三道闸)。
|
|
128
|
+
// 单飞仍生效(在途时本次点击为 no-op);只受不可绕过的手动冷却约束;上游结果经后续
|
|
129
|
+
// quota 快照带出,不在此等待。
|
|
124
130
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
125
131
|
const profile = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
|
|
126
132
|
if (profile === undefined) return { ok: false, error: 'unknown-provider' }
|
|
@@ -130,9 +136,9 @@ export function createQuotaRoutes({
|
|
|
130
136
|
const forced = quotaThrottle.force(providerName)
|
|
131
137
|
if (!forced.ok) {
|
|
132
138
|
if (forced.reason === 'inflight') return { ok: true }
|
|
133
|
-
return { ok: false, error:
|
|
139
|
+
return { ok: false, error: 'refresh-cooldown', nextAllowedAt: forced.nextAllowedAt }
|
|
134
140
|
}
|
|
135
|
-
kickQuotaRefresh(profile, adapter, config)
|
|
141
|
+
kickQuotaRefresh(profile, adapter, config, { manual: true })
|
|
136
142
|
return { ok: true }
|
|
137
143
|
} catch (error) {
|
|
138
144
|
return rpcTechnicalFailure(error)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/screenshots/overview.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|