dsh-tap 0.20.0

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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * providers/codebuddy/images.js — dsh 原生 `image_generate` 工具的
3
+ * CodeBuddy 后端:POST /v2/images/generations(2026-08-17 探测可用,
4
+ * hunyuan-image-v3.0-art 约 22s/张,samples 在 docs/probes/)。
5
+ *
6
+ * 路由事实(docs/rules/routing.md):/v2/3d/generations 路由形状存在但
7
+ * 当前账号无 3D 模型注册(14407 "route config not found",注册表层拒绝)
8
+ * ——3D 记为不可用,不接入。
9
+ *
10
+ * 计量说明:images 响应体的 usage 字段**未经探测证伪**(quota 探测只覆盖
11
+ * agenttool),按咒语红线保留原计量路径,不删不改。
12
+ */
13
+
14
+ import { mkdirSync, writeFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { CREDENTIAL_UNAVAILABLE_MESSAGE } from './errors.js'
17
+
18
+ /**
19
+ * @param {{ withKeyRotation: Function, meter: { record: Function }, dshHome: string }} deps
20
+ */
21
+ export function createImageTool({ withKeyRotation, meter, dshHome }) {
22
+ /** Where generated images land: the session workspace when the agent loop
23
+ * exposes one, else <dshHome>/generated-images. */
24
+ function resolveImageSaveDir(exec) {
25
+ const a = exec?.agent
26
+ const dir = a?.workspaceDir ?? a?.workDir ?? a?.cwd ?? a?.workspace?.dir ?? null
27
+ return dir && typeof dir === 'string'
28
+ ? join(dir, 'generated-images')
29
+ : join(dshHome, 'generated-images')
30
+ }
31
+
32
+ /** dsh tool definition for `image_generate` (plain object — the plugin must
33
+ * not import @deepseek-ai/*; the registry accepts the structural shape). */
34
+ function makeImageGenTool(settings) {
35
+ return {
36
+ name: 'image_generate',
37
+ description:
38
+ 'Generate an image from a text prompt (CodeBuddy hunyuan-image backend). ' +
39
+ 'Returns the local file path of the saved image and its source URL. ' +
40
+ 'Takes ~20s per image; one image per call.',
41
+ // Schemas here are FINAL JSON Schema (the registry's defineTool shorthand
42
+ // converter is not importable from a plugin — see docs/pitfalls.md #9).
43
+ parameters: {
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ properties: {
47
+ prompt: { type: 'string', description: 'What to draw; be concrete about subject, style, and colors.' },
48
+ size: { type: 'string', description: 'WxH pixels, e.g. "1024x1024" (default), "768x768", "1280x720".' },
49
+ },
50
+ required: ['prompt'],
51
+ },
52
+ output: {
53
+ schema: {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ path: { type: 'string' },
58
+ url: { type: 'string' },
59
+ model: { type: 'string' },
60
+ ms: { type: 'number' },
61
+ },
62
+ required: ['path', 'model', 'ms'],
63
+ },
64
+ render: (_args, value) => [{
65
+ type: 'text',
66
+ text: `image generated (${value.model}, ${(value.ms / 1000).toFixed(1)}s)\nsaved: ${value.path}\nsource: ${value.url ?? 'n/a'}`,
67
+ }],
68
+ },
69
+ // Generation measured at ~22s end-to-end; keep headroom for slow runs.
70
+ timeoutMs: 180_000,
71
+ isConcurrencySafe: () => true,
72
+ async execute(args, exec) {
73
+ const prompt = typeof args?.prompt === 'string' ? args.prompt.trim() : ''
74
+ if (!prompt) throw new Error('image_generate: prompt 不能为空')
75
+ const size = typeof args?.size === 'string' && /^\d{3,4}x\d{3,4}$/.test(args.size)
76
+ ? args.size : '1024x1024'
77
+ const s = settings()
78
+ const t0 = Date.now()
79
+ const { res, err } = await withKeyRotation(settings, (cred) => fetch(`${s.baseURL}/v2/images/generations`, {
80
+ method: 'POST',
81
+ signal: exec?.signal,
82
+ headers: {
83
+ Authorization: cred.authorization,
84
+ ...cred.headers,
85
+ 'Content-Type': 'application/json',
86
+ Accept: 'application/json',
87
+ },
88
+ body: JSON.stringify({ model: s.imageGenModel, prompt, size, n: 1 }),
89
+ }))
90
+ if (err) {
91
+ if (err.message === CREDENTIAL_UNAVAILABLE_MESSAGE) throw err
92
+ if (exec?.signal?.aborted) throw err
93
+ const causes = []
94
+ for (let e = err; e; e = e.cause) causes.push(e.code ?? e.message ?? String(e))
95
+ throw new Error(`image_generate network error: ${causes.join(' ← ')}`)
96
+ }
97
+ const body = await res.json().catch(() => null)
98
+ if (!res.ok) {
99
+ throw new Error(`image_generate HTTP ${res.status}${body?.code != null ? ` code ${body.code}: ${body.msg ?? ''}` : ''}`)
100
+ }
101
+ if (!body || body.code !== 0) {
102
+ throw new Error(`image_generate error ${body?.code ?? '?'}: ${body?.msg ?? 'empty or malformed response'}`)
103
+ }
104
+ if (body.data?.usage) meter.record({ ts: t0, kind: 'image', model: s.imageGenModel, usage: body.data.usage })
105
+ const item = body.data?.data?.[0]
106
+ const url = typeof item?.url === 'string' ? item.url : null
107
+ const b64 = typeof item?.b64_json === 'string' ? item.b64_json : null
108
+ if (!url && !b64) throw new Error('image_generate: 响应既无 url 也无 b64_json')
109
+ const dir = resolveImageSaveDir(exec)
110
+ mkdirSync(dir, { recursive: true })
111
+ const file = join(dir, `image-${t0}.png`)
112
+ if (url) {
113
+ const img = await fetch(url, { signal: exec?.signal })
114
+ if (!img.ok) throw new Error(`image_generate: 下载图片失败 HTTP ${img.status}`)
115
+ writeFileSync(file, Buffer.from(await img.arrayBuffer()))
116
+ } else {
117
+ writeFileSync(file, Buffer.from(b64, 'base64'))
118
+ }
119
+ return { path: file, url: url ?? undefined, model: s.imageGenModel, ms: Date.now() - t0 }
120
+ },
121
+ }
122
+ }
123
+
124
+ return { makeImageGenTool }
125
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * providers/codebuddy/index.js — CodeBuddy(copilot.tencent.com)薄适配器。
3
+ *
4
+ * 组合各特化件并向 core/ 暴露 provider 钩子。core/bridge.js 只依赖这里
5
+ * 暴露的结构钩子,不认识任何 CodeBuddy 事实;接第二个 OpenAI 兼容上游时
6
+ * 照本目录形态另写一个适配器即可,core/ 零改动(证伪测试:
7
+ * scripts/verify-core-generic.mjs)。
8
+ *
9
+ * 规则文档是适配器行为的裁判依据:
10
+ * - docs/rules/ua-validation.md §3 → headers.js 逐字段规则/迷信判定
11
+ * - docs/rules/content-moderation.md → transformChatPayload 的 developer→system
12
+ * - docs/rules/routing.md → errors.js 14401/14407/11102/11103
13
+ * - docs/rules/quota-signals.md → catalog.js 额度方言 + agenttool 死路径删除
14
+ * - docs/rules/oauth-handshake.md → oauth.js 设备流与迷信头标注
15
+ */
16
+
17
+ import { CLIENT_HEADERS } from './headers.js'
18
+ import { createOAuth } from './oauth.js'
19
+ import { createCatalog } from './catalog.js'
20
+ import { createAgentTool } from './agenttool.js'
21
+ import { createImageTool } from './images.js'
22
+
23
+ /**
24
+ * 桥取证日志允许记录的头名白名单;authorization 单独分类(sentinel/caller-set),
25
+ * 永不逐字落盘。
26
+ */
27
+ const LOG_HEADER_NAMES = [
28
+ 'user-agent', 'content-type',
29
+ 'x-conversation-id', 'x-session-id', 'session_id', 'x-client-request-id', 'x-session-affinity',
30
+ 'x-ide-type', 'x-ide-name', 'x-ide-version', 'x-product-version',
31
+ 'x-requested-with', 'x-private-data',
32
+ ]
33
+
34
+ /**
35
+ * @param {{
36
+ * meter: { record: Function },
37
+ * readAuth: () => object,
38
+ * writeAuth: (v: object) => void,
39
+ * envKey: (envName: string|undefined) => string|null,
40
+ * withKeyRotation: (settingsFn: () => object, attempt: Function) => Promise<object>,
41
+ * resolveCredential: (settingsFn: () => object) => Promise<object|null>,
42
+ * effortWireFor: (model: string) => string|undefined, // G8 思考强度线值(可无)
43
+ * dshHome: string,
44
+ * }} deps 组合根注入的 core 原语与凭据编排(迟绑定箭头,循环引用靠它解开)
45
+ */
46
+ export function createCodeBuddyProvider(deps) {
47
+ const oauth = createOAuth({ readAuth: deps.readAuth, writeAuth: deps.writeAuth })
48
+ const catalog = createCatalog({ resolveCredential: deps.resolveCredential, envKey: deps.envKey })
49
+ const agenttool = createAgentTool({ withKeyRotation: deps.withKeyRotation })
50
+ const images = createImageTool({
51
+ withKeyRotation: deps.withKeyRotation,
52
+ meter: deps.meter,
53
+ dshHome: deps.dshHome,
54
+ })
55
+
56
+ return {
57
+ id: 'codebuddy',
58
+
59
+ // ---------------------------------------------------------------
60
+ // core/bridge.js 的适配钩子
61
+ // ---------------------------------------------------------------
62
+
63
+ logPrefix: '[dsh-tap]',
64
+
65
+ /** 桥出站静态头组(逐字段规则/迷信判定见 headers.js 文件头)。 */
66
+ bridgeHeaders: () => ({ ...CLIENT_HEADERS }),
67
+
68
+ /**
69
+ * Chat 出站重写:developer → system。
70
+ *
71
+ * 规则(docs/rules/content-moderation.md):网关入口通道校验层对含字面量
72
+ * `role:"developer"` 消息的 chat payload 单列拒绝(当前拒绝面
73
+ * 500/11128 "unapproved channel";模型无关、位置无关、计费前)。触发源
74
+ * 是 pi-ai 把推理模型的 system prompt 序列化为 developer 角色。网关对
75
+ * developer/system 指令语义等价,桥直接重写——回归锁在 verify-bridge §9。
76
+ */
77
+ transformChatPayload(payload) {
78
+ if (Array.isArray(payload.messages)) {
79
+ for (const m of payload.messages) {
80
+ if (m?.role === 'developer') m.role = 'system'
81
+ }
82
+ }
83
+ // G8 思考强度注入:effortWireFor 只在档位线值非空时返回(off 的线值
84
+ // 是 null = 省略参数;无表模型/非法档位同)。调用方(pi-ai / dsh 核心
85
+ // UI)显式携带 reasoning_effort 时绝不覆盖。
86
+ if (payload && payload.reasoning_effort == null) {
87
+ const wire = deps.effortWireFor?.(payload.model)
88
+ if (wire) payload.reasoning_effort = wire
89
+ }
90
+ },
91
+
92
+ /** SSE chunk → usage 对象(网关每 chunk 重复携带,含 credit 与缓存计数)。 */
93
+ extractUsage: (chunk) => chunk.usage ?? null,
94
+
95
+ /** SSE chunk → 错误判定:OpenAI 形态 chunk.error,或网关信封 code !== 0。 */
96
+ extractStreamError: (chunk) =>
97
+ (chunk.error || (chunk.code != null && chunk.code !== 0)) ? chunk : null,
98
+
99
+ /** 聚合 chat.completion 响应的 id(保持 0.7.4 线上值不变)。 */
100
+ bridgeResponseId: 'codebuddy-stream-bridge',
101
+
102
+ logHeaderNames: LOG_HEADER_NAMES,
103
+
104
+ /** 静态哨兵 Authorization(cordis.patch.yml 主聊天路由携带;桥逐请求替换)。 */
105
+ sentinelAuth: 'Bearer dsh-codebuddy-bridge',
106
+
107
+ texts: {
108
+ credentialUnavailable: 'codebuddy credential unavailable',
109
+ },
110
+
111
+ // ---------------------------------------------------------------
112
+ // 组合根直接消费的特化能力
113
+ // ---------------------------------------------------------------
114
+
115
+ oauth,
116
+ catalog,
117
+ agenttool,
118
+ images,
119
+ makeSearchProvider: agenttool.makeSearchProvider,
120
+ makeFetchProvider: agenttool.makeFetchProvider,
121
+ makeImageGenTool: images.makeImageGenTool,
122
+ }
123
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * providers/codebuddy/oauth.js — CodeBuddy 浏览器 OAuth 设备流。
3
+ *
4
+ * 流程(与官方 CLI 对齐;规则化结论见 docs/rules/oauth-handshake.md,
5
+ * 证据 docs/probes/oauth-2026-08-19.jsonl,15 条):
6
+ * POST /v2/plugin/auth/state?platform=CLI → {state, authUrl} (创建无认证;
7
+ * X-No-* 三头实测是迷信——P-O3:不携带也 200;platform 必填但任意值皆可)
8
+ * GET /v2/plugin/auth/token?state=… → 11217 pending,0 → tokens
9
+ * (核心否定发现:pending/bogus/过期一律 11217,三态不可区分,state TTL 不可观测)
10
+ * GET /v2/plugin/login/account?state=… → {uid, nickname, …}
11
+ * POST /v2/plugin/auth/token/refresh → 刷新(bogus refresh → 401+12153)
12
+ *
13
+ * 实例状态(refresh 单飞锁、poll 代际号、pending 视图、relogin 粘性信号)
14
+ * 在本工厂闭包内——组合根每个插件模块实例创建一个 provider 实例,隔离语义
15
+ * 与重构前模块全局一致。
16
+ */
17
+
18
+ const AUTH_PENDING_CODE = 11217 // ERROR_CODES[11217]:三态同码,勿当"未完成"以外含义用
19
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000
20
+ const LOGIN_POLL_INTERVAL_MS = 1000
21
+
22
+ // 授权页允许的站点族:CodeBuddy(copilot.tencent.com)与 WorkBuddy
23
+ // (www.workbuddy.cn)同账户体系(docs/rules/gateway-facts.md);codebuddy.cn
24
+ // 依据 docs/probes/oauth-token-2026-08-19.jsonl 的账户 domain 证据一并放行。
25
+ const LOGIN_SITE_SUFFIXES = ['tencent.com', 'workbuddy.cn', 'codebuddy.cn']
26
+ const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '[::1]'
27
+
28
+ /**
29
+ * 授权页出宿主前的门禁:authUrl 由上游响应原样给出,浏览器侧直送
30
+ * window.open / location.href(lib/client.js 两个汇)。上游被劫持/投毒时
31
+ * 必须在此响亮失败,而不是把用户导去钓鱼页或 javascript: 串:
32
+ * - scheme 一律 https(authUrl 与 baseURL 双双回环时例外——离线 verify
33
+ * 的 mock 上游走 http://127.0.0.1);
34
+ * - host 必须是发起 auth/state 的 baseURL 本身或官方登录站点族(子域放行,
35
+ * 兼容代理转发不改写 body 的部署)。
36
+ */
37
+ function assertSafeAuthUrl(authUrl, baseURL) {
38
+ let parsed
39
+ try { parsed = new URL(String(authUrl)) } catch { throw new Error('authUrl 不是合法 URL') }
40
+ let base
41
+ try { base = new URL(baseURL) } catch { throw new Error('baseURL 不是合法 URL') }
42
+ const loopbackPair = isLoopbackHost(parsed.hostname) && isLoopbackHost(base.hostname)
43
+ if (parsed.protocol !== 'https:' && !loopbackPair) {
44
+ throw new Error(`authUrl 必须使用 https(收到 ${parsed.protocol}//)`)
45
+ }
46
+ const hostOk = parsed.hostname === base.hostname || loopbackPair
47
+ || LOGIN_SITE_SUFFIXES.some((s) => parsed.hostname === s || parsed.hostname.endsWith(`.${s}`))
48
+ if (!hostOk) {
49
+ throw new Error(`authUrl 域名 ${parsed.hostname} 不在预期登录域(${base.hostname} 或 ${LOGIN_SITE_SUFFIXES.join(' / ')} 站点族)`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {{ readAuth: () => object, writeAuth: (v: object) => void }} deps
55
+ * 令牌存储 IO(组合根绑定到 ~/.dsh/codebuddy-plugin-auth.json;令牌永不
56
+ * 回传浏览器——oauthStatus() 只出视图字段)。
57
+ */
58
+ export function createOAuth({ readAuth, writeAuth }) {
59
+ let refreshInFlight = null
60
+ // 进行中的 poll 代际号:logout 令其失效(poll 循环每步复查),logout 后
61
+ // 浏览器才完成的授权不再被写回——旧实现 while 只看 deadline,logout 后
62
+ // 10 分钟内授权仍会被重新登进。
63
+ let pollGeneration = 0
64
+ // 令牌存储代际号:logout 令其失效。refresh 的落盘是读-改-写(拿到新令牌后
65
+ // writeAuth({...readAuth(), auth})——与 logout 的整体覆写竞态时,在飞刷新
66
+ // 会把已登出的令牌写回(登出失效)。刷新落盘前比对代际,过期即丢弃结果。
67
+ let storeGeneration = 0
68
+ // refresh 实败(refreshOAuth 返回 undefined)的粘性信号:accessToken 还在
69
+ // 但聊天已全 503 时,oauthStatus 必须暴露"需重新登录"而不是只报已登录。
70
+ // 下次 refresh 成功自愈;logout 清除。
71
+ let reloginNeeded = false
72
+ const oauthPending = { active: false, authUrl: '', error: '' }
73
+
74
+ /**
75
+ * Exchange the refresh token for a fresh access token (single-flight).
76
+ * Mirrors the official CLI: Bearer <old access>, X-Refresh-Token, plus the
77
+ * identity headers. Returns the updated auth store or undefined on refusal.
78
+ */
79
+ async function refreshOAuth(baseURL, auth) {
80
+ if (refreshInFlight) return refreshInFlight
81
+ const generation = storeGeneration
82
+ refreshInFlight = (async () => {
83
+ try {
84
+ const headers = {
85
+ Accept: 'application/json',
86
+ Authorization: `Bearer ${auth.accessToken}`,
87
+ 'X-Domain': auth.domain ?? '',
88
+ 'X-Refresh-Token': auth.refreshToken ?? '',
89
+ }
90
+ if (auth.uid) headers['X-User-Id'] = auth.uid
91
+ if (auth.enterpriseId) headers['X-Enterprise-Id'] = auth.enterpriseId
92
+ const res = await fetch(`${baseURL}/v2/plugin/auth/token/refresh`, {
93
+ method: 'POST',
94
+ headers,
95
+ })
96
+ if (!res.ok) { reloginNeeded = true; return undefined }
97
+ const body = await res.json().catch(() => null)
98
+ if (!body || body.code !== 0 || !body.data?.accessToken) { reloginNeeded = true; return undefined }
99
+ // logout 竞态守卫:代际在飞期间已变(用户点了退出)→ 丢弃刷新结果
100
+ if (generation !== storeGeneration) return undefined
101
+ const store = readAuth()
102
+ store.auth = {
103
+ accessToken: body.data.accessToken,
104
+ expiresAt: Date.now() + (body.data.expiresIn ?? 3600) * 1000,
105
+ refreshToken: body.data.refreshToken ?? auth.refreshToken,
106
+ refreshExpiresAt: body.data.refreshExpiresAt != null
107
+ ? Date.now() + body.data.refreshExpiresAt * 1000
108
+ : auth.refreshExpiresAt,
109
+ domain: body.data.domain ?? auth.domain,
110
+ }
111
+ writeAuth(store)
112
+ reloginNeeded = false // 刷新成功自愈
113
+ return store.auth
114
+ } catch {
115
+ reloginNeeded = true
116
+ return undefined
117
+ } finally {
118
+ refreshInFlight = null
119
+ }
120
+ })()
121
+ return refreshInFlight
122
+ }
123
+
124
+ /** OAuth credential branch, shared by every outbound flavor. */
125
+ async function resolveOAuthCredential(s) {
126
+ const store = readAuth()
127
+ const auth = store.auth
128
+ if (!auth?.accessToken) return null
129
+ let current = auth
130
+ if (auth.expiresAt && auth.expiresAt - Date.now() < 60_000) {
131
+ const refreshed = await refreshOAuth(s.baseURL, auth)
132
+ if (!refreshed) return null
133
+ current = refreshed
134
+ }
135
+ const headers = { 'X-Domain': current.domain ?? '' }
136
+ if (store.account?.uid) headers['X-User-Id'] = store.account.uid
137
+ if (store.account?.enterpriseId) headers['X-Enterprise-Id'] = store.account.enterpriseId
138
+ return { authorization: `Bearer ${current.accessToken}`, headers }
139
+ }
140
+
141
+ async function startOAuth(baseURL) {
142
+ if (oauthPending.active) return { started: true, authUrl: oauthPending.authUrl }
143
+ const res = await fetch(`${baseURL}/v2/plugin/auth/state?platform=CLI`, {
144
+ method: 'POST',
145
+ headers: {
146
+ Accept: 'application/json',
147
+ // X-No-* 三头:实测迷信(oauth-handshake.md P-O3,不携带也 200),
148
+ // 为零行为变更保留,不作为能力依赖。
149
+ 'X-No-Authorization': 'true',
150
+ 'X-No-User-Id': 'true',
151
+ 'X-No-Enterprise-Id': 'true',
152
+ },
153
+ })
154
+ if (!res.ok) throw new Error(`auth state HTTP ${res.status}`)
155
+ const body = await res.json()
156
+ if (body.code !== 0 || !body.data?.state) {
157
+ throw new Error(`auth state error: ${body.code} ${body.msg ?? ''}`)
158
+ }
159
+ const { state, authUrl } = body.data
160
+ // 门禁在置位 oauthPending 之前:拒绝时 pending 不激活(oauthStatus 不再
161
+ // 外泄该 URL),组合根 oauth-start 的 catch 回 502 + 错误信息。缓存的
162
+ // oauthPending.authUrl(上方早退分支与 oauthStatus 视图)因此必已过检
163
+ // ——浏览器的两个导航汇(window.open / location.href)被同一单点覆盖。
164
+ assertSafeAuthUrl(authUrl, baseURL)
165
+ oauthPending.active = true
166
+ oauthPending.authUrl = authUrl
167
+ oauthPending.error = ''
168
+
169
+ const poll = async () => {
170
+ const gen = pollGeneration
171
+ // 代际守卫:logout(或新一轮 start)后本 poll 已失效——循环每步复查,
172
+ // 拿到令牌写回前再复查一次,logout 后完成的授权绝不落盘。
173
+ const alive = () => gen === pollGeneration && oauthPending.active
174
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS
175
+ try {
176
+ while (alive() && Date.now() < deadline) {
177
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS))
178
+ if (!alive()) return
179
+ let response
180
+ try {
181
+ response = await fetch(`${baseURL}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
182
+ headers: { Accept: 'application/json', 'X-No-Authorization': 'true' },
183
+ })
184
+ } catch {
185
+ continue
186
+ }
187
+ if (!response.ok) continue
188
+ const body = await response.json().catch(() => null)
189
+ if (!body) continue
190
+ if (body.code === AUTH_PENDING_CODE) continue
191
+ if (body.code !== 0 || !body.data?.accessToken) {
192
+ oauthPending.error = `登录失败:${body.code} ${body.msg ?? ''}`
193
+ return
194
+ }
195
+ const token = body.data
196
+ if (!alive()) return // 令牌在飞期间用户已退出
197
+ // Fetch the account facts before persisting.
198
+ let account = {}
199
+ try {
200
+ const accRes = await fetch(`${baseURL}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
201
+ headers: {
202
+ Accept: 'application/json',
203
+ Authorization: `Bearer ${token.accessToken}`,
204
+ 'X-No-User-Id': 'true',
205
+ 'X-No-Enterprise-Id': 'true',
206
+ 'X-Domain': token.domain ?? '',
207
+ },
208
+ })
209
+ const accBody = await accRes.json().catch(() => null)
210
+ if (accBody?.code === 0 && accBody.data) account = accBody.data
211
+ } catch {
212
+ // account facts are best-effort; tokens alone still work
213
+ }
214
+ if (!alive()) return // 账户信息在飞期间用户已退出
215
+ writeAuth({
216
+ auth: {
217
+ accessToken: token.accessToken,
218
+ expiresAt: Date.now() + (token.expiresIn ?? 3600) * 1000,
219
+ refreshToken: token.refreshToken,
220
+ refreshExpiresAt: token.refreshExpiresAt != null
221
+ ? Date.now() + token.refreshExpiresAt * 1000
222
+ : undefined,
223
+ domain: token.domain,
224
+ },
225
+ account,
226
+ })
227
+ reloginNeeded = false // 新登录落成,旧的 refresh 失败信号作废
228
+ return
229
+ }
230
+ if (alive()) oauthPending.error = '登录超时(10 分钟未完成)'
231
+ } finally {
232
+ // 只清自己这一代的 pending:代际已过时(logout 后用户可能又开始了
233
+ // 新一轮登录),老 poll 不得动新 poll 的状态。
234
+ if (gen === pollGeneration) oauthPending.active = false
235
+ }
236
+ }
237
+ // Fire-and-forget, but never unhandled: writeAuth (json-store) throws
238
+ // synchronously on ENOSPC/EACCES/EROFS, and an unhandled rejection here
239
+ // would take the whole dsh process down (Node ≥15 default).
240
+ poll().catch((err) => {
241
+ oauthPending.error = String(err?.message ?? err)
242
+ })
243
+ return { started: true, authUrl }
244
+ }
245
+
246
+ /** OAuth view for the card — tokens never leave the host. */
247
+ function oauthStatus() {
248
+ const store = readAuth()
249
+ const auth = store.auth
250
+ // 需重新登录信号:refresh 令牌本身已过期(refreshExpiresAt 存了就要查),
251
+ // 或最近一次 refresh 实败。accessToken 还在 ≠ 可用——此时聊天全 503,
252
+ // signedIn 照常报(令牌确实在),needsRelogin 单独暴露给 UI。
253
+ const refreshExpired = typeof auth?.refreshExpiresAt === 'number' && auth.refreshExpiresAt <= Date.now()
254
+ return {
255
+ pending: oauthPending.active,
256
+ authUrl: oauthPending.active ? oauthPending.authUrl : '',
257
+ error: oauthPending.error,
258
+ signedIn: Boolean(auth?.accessToken),
259
+ needsRelogin: Boolean(auth?.accessToken) && (refreshExpired || reloginNeeded),
260
+ account: store.account?.nickname ? {
261
+ nickname: store.account.nickname,
262
+ uid: store.account.uid,
263
+ enterpriseName: store.account.enterpriseName ?? '',
264
+ } : null,
265
+ accessTokenExpiresAt: auth?.expiresAt ?? null,
266
+ }
267
+ }
268
+
269
+ function logout() {
270
+ pollGeneration++ // 终止进行中的 poll:代际失效,授权晚到也不落盘
271
+ storeGeneration++ // 终止在飞 refresh:其落盘前的代际校验必然失败
272
+ writeAuth({})
273
+ reloginNeeded = false
274
+ oauthPending.active = false
275
+ oauthPending.error = ''
276
+ }
277
+
278
+ return { resolveOAuthCredential, startOAuth, oauthStatus, logout }
279
+ }
@@ -0,0 +1,11 @@
1
+ import { createOpenAICompatProvider } from '../openai-compat.js'
2
+
3
+ /**
4
+ * DeepSeek 官方 preset。2026-09-11 实测:GET /models 假 key 401
5
+ * (authentication_error),标准 OpenAI 目录,验 key 即拿目录。
6
+ */
7
+ export default createOpenAICompatProvider({
8
+ id: 'deepseek',
9
+ displayName: 'DeepSeek 官方',
10
+ baseURL: 'https://api.deepseek.com/v1',
11
+ })
@@ -0,0 +1,11 @@
1
+ import { createOpenAICompatProvider } from '../openai-compat.js'
2
+
3
+ /**
4
+ * Moonshot AI(Kimi)preset。2026-09-11 实测:GET /models 假 key 401
5
+ * (invalid_authentication_error),标准 OpenAI 目录。
6
+ */
7
+ export default createOpenAICompatProvider({
8
+ id: 'moonshot',
9
+ displayName: 'Moonshot AI',
10
+ baseURL: 'https://api.moonshot.cn/v1',
11
+ })