@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/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
- if (encoded.length > Math.ceil(MAX_BACKUP_TRANSFER_BYTES / 3) * 4 + 8 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) return undefined
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 > 最小上游间隔 15s(与 attempt()
2241
- // 判定序一致);now 由调用方注入,测试可推进假时钟。
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
- /** 只读快照:缓存窗口、是否刷新中、下次允许发起上游的时间(null=进行中未知)。 */
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
- nextAllowedAt: entry.inflight
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(优先序:单飞 > 退避 > 成功 TTL > 最小间隔)。 */
2289
- attempt(provider, now = Date.now()) {
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 (now < entry.backoffUntil) return { ok: false, reason: 'backoff', nextAllowedAt: entry.backoffUntil }
2293
- if (now - entry.lastSuccessAt < successTtlMs) {
2294
- return { ok: false, reason: 'fresh', nextAllowedAt: entry.lastSuccessAt + successTtlMs }
2295
- }
2296
- if (entry.lastUpstreamAt > 0 && now - entry.lastUpstreamAt < minIntervalMs) {
2297
- return { ok: false, reason: 'interval', nextAllowedAt: entry.lastUpstreamAt + minIntervalMs }
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
- /** 手动刷新:允许绕过成功 TTL,但保留失败退避,并有不可绕过的硬冷却;单飞仍优先。 */
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
- // 与 force() 的区别:本方法只由宿主写入口触发,可以连硬冷却一并清掉。
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
- // 只包装宿主 subagents 注册表的两个入口(start / startContinuable,spawn/fork/acp 全走这
5369
- // 两个口):未显式指定模型的派生按配置注入 agentOptions,其余原样透传。Fiber 销毁还原
5370
- // 原方法——包装挂在服务实例上,disposer 期间新派生恢复原生行为。
5428
+ // 按宿主能力包装子代理启动入口:新版 startActivation,旧版 start/startContinuable。
5429
+ // 未显式指定模型的派生按配置注入 agentOptions,其余原样透传;Fiber 销毁时还原原方法。
5371
5430
  ctx.inject(['subagents'], (scope) => {
5372
5431
  const subagents = scope.subagents
5373
- if (subagents === undefined || typeof subagents.start !== 'function' || typeof subagents.startContinuable !== 'function') return
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
- subagents.start = (name, request) => {
5466
- const { request: decorated, dispatch } = applyInjection(request)
5467
- return runWithDispatch(dispatch, () => originalStart.call(subagents, name, decorated))
5468
- }
5469
- subagents.startContinuable = (spec) => {
5470
- const { request: decorated, dispatch } = applyInjection(spec.request)
5471
- return runWithDispatch(dispatch, () => originalStartContinuable.call(subagents, { ...spec, request: decorated }))
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.start = originalStart
5552
- subagents.startContinuable = originalStartContinuable
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
- const kickQuotaRefresh = (profile, adapter, config) => {
5665
- const decision = quotaThrottle.attempt(profile.name)
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.5",
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.2"
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
- const detail = sanitizeQuotaErrorDetail(error?.detail)
1166
- pushFailure(accountIndex, quotaErrorCode(error), detail !== undefined ? { detail } : {})
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
- // 凭据填写窗口的数据源:仅对「缺凭据」的未配置行附带候选线索名的配置状态(describe 只回
70
- // 配置与否/来源/可写,绝不带值);凭据服务缺席时省略字段——客户端隐藏窗口退回文案指引。
69
+ // 凭据窗口的数据源:**已适配行一律附带**候选线索名的配置状态,不再以「未配置」为门槛——
70
+ // 已保存的凭据(密钥轮换、换账号、填错想改)此前没有任何 GUI 修改入口(用户点名)。
71
+ // describe 只回配置与否/来源/可写,绝不带值,且是内存查询;凭据服务缺席时省略字段,
72
+ // 客户端随之隐藏入口、退回文案指引。
71
73
  let credentialHints
72
- if (credentialClass && !view.refreshing && view.lastError !== 'no-base-url') {
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
- // 手动刷新入口:provider 过白名单且 kind 已适配;清掉节流闸后立即 kick。
123
- // 单飞仍生效(在途时本次点击为 no-op);上游结果经后续 quota 快照带出,不在此等待。
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: forced.reason === 'cooldown' ? 'refresh-cooldown' : 'refresh-backoff', nextAllowedAt: forced.nextAllowedAt }
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
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file