benchgo-plugin 0.0.0-stage → 0.0.2

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 (180) hide show
  1. package/DEEPSEEK-LICENSE +21 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +17 -0
  4. package/PASEO-LICENSE +211 -0
  5. package/README.md +256 -2
  6. package/TOOL-CONFIG.md +33 -0
  7. package/VERIFICATION.md +297 -0
  8. package/assets/fonts/README.md +13 -0
  9. package/assets/fonts/manifest.json +437 -0
  10. package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
  11. package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
  12. package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
  13. package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
  14. package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
  15. package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
  16. package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
  17. package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
  18. package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
  19. package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
  20. package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
  21. package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
  22. package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
  23. package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
  24. package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
  25. package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
  26. package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
  27. package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
  28. package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
  29. package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
  30. package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
  31. package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
  32. package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
  33. package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
  34. package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
  35. package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
  36. package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
  37. package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
  38. package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
  39. package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
  40. package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
  41. package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
  42. package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
  43. package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
  44. package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
  45. package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
  46. package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
  47. package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
  48. package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
  49. package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
  50. package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
  51. package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
  52. package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
  53. package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
  54. package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
  55. package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
  56. package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
  57. package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
  58. package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
  59. package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
  60. package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
  61. package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
  62. package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
  63. package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
  64. package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
  65. package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
  66. package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
  67. package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
  68. package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
  69. package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
  70. package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
  71. package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
  72. package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
  73. package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
  74. package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
  75. package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
  76. package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
  77. package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
  78. package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
  79. package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
  80. package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
  81. package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
  82. package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
  83. package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
  84. package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
  85. package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
  86. package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
  87. package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
  88. package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
  89. package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
  90. package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
  91. package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
  92. package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
  93. package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
  94. package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
  95. package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
  96. package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
  97. package/client/assets/iconsax/LICENSE +16 -0
  98. package/client/assets/iconsax/SOURCE.md +14 -0
  99. package/config/routergo-deepseek.example.yml +25 -0
  100. package/config.example.json +24 -0
  101. package/cordis.patch.yml +5 -0
  102. package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
  103. package/lib/client.js +3577 -0
  104. package/lib/link-web/app.css +92 -0
  105. package/lib/link-web/app.js +599 -0
  106. package/lib/link-web/index.html +1 -0
  107. package/lib/link-web/workbench.js +118 -0
  108. package/lib/link.mjs +244 -0
  109. package/lib/relay/ip-certificate.mjs +559 -0
  110. package/lib/relay/relay.example.json +8 -0
  111. package/lib/relay/run-relay-server.cjs +4100 -0
  112. package/lib/server/bootstrap.mjs +147 -0
  113. package/lib/server/bootstrap.sh +39 -0
  114. package/lib/server/manifest.json +8 -0
  115. package/lib/server/server-seed.tar.gz +0 -0
  116. package/locale/en.json +6 -0
  117. package/locale/zh-CN.json +6 -0
  118. package/openapi.json +629 -0
  119. package/package.json +104 -4
  120. package/shared/plugin-install.mjs +198 -0
  121. package/shared/tool-quick-settings.mjs +70 -0
  122. package/shared/update-feed.mjs +191 -0
  123. package/shared/update-public-key.pem +3 -0
  124. package/src/cli.mjs +102 -0
  125. package/src/commands.mjs +17 -0
  126. package/src/delegation.mjs +47 -0
  127. package/src/fonts.mjs +17 -0
  128. package/src/harness-adapter.mjs +228 -0
  129. package/src/harness-commands.mjs +39 -0
  130. package/src/harness-selection.mjs +5 -0
  131. package/src/harness-usage.mjs +31 -0
  132. package/src/isolated-launch.mjs +22 -0
  133. package/src/line-process.mjs +114 -0
  134. package/src/link-plugin.mjs +21 -0
  135. package/src/mcp.mjs +21 -0
  136. package/src/model-providers.mjs +125 -0
  137. package/src/models.mjs +37 -0
  138. package/src/native-auth.mjs +32 -0
  139. package/src/picker-catalog.mjs +82 -0
  140. package/src/plugin.mjs +138 -0
  141. package/src/projects.mjs +96 -0
  142. package/src/provider-access.mjs +11 -0
  143. package/src/providers/acp.mjs +138 -0
  144. package/src/providers/claude.mjs +115 -0
  145. package/src/providers/codex.mjs +134 -0
  146. package/src/providers/opencode.mjs +138 -0
  147. package/src/providers/pi-permissions.ts +24 -0
  148. package/src/providers/pi.mjs +97 -0
  149. package/src/questions.mjs +34 -0
  150. package/src/recent-workspace.mjs +36 -0
  151. package/src/runtime.mjs +421 -0
  152. package/src/session-routes.mjs +89 -0
  153. package/src/store.mjs +98 -0
  154. package/src/tool-catalog.mjs +22 -0
  155. package/src/tool-config.mjs +94 -0
  156. package/src/tool-install.mjs +31 -0
  157. package/src/tool-profiles.mjs +114 -0
  158. package/src/tooling.mjs +90 -0
  159. package/src/updates.mjs +156 -0
  160. package/src/usage-import-parsers.mjs +37 -0
  161. package/src/usage-import.mjs +114 -0
  162. package/src/usage-timeline.mjs +36 -0
  163. package/src/usage.mjs +142 -0
  164. package/src/web.mjs +274 -0
  165. package/voice/README.md +49 -0
  166. package/voice/STREAMING.md +141 -0
  167. package/voice/VERIFICATION.md +36 -0
  168. package/voice/client/audio-worklet.js +35 -0
  169. package/voice/locale/en.json +1 -0
  170. package/voice/locale/zh-CN.json +1 -0
  171. package/voice/src/asr.mjs +44 -0
  172. package/voice/src/calls.mjs +37 -0
  173. package/voice/src/config.mjs +139 -0
  174. package/voice/src/media.mjs +70 -0
  175. package/voice/src/plugin.mjs +10 -0
  176. package/voice/src/realtime-protocols.mjs +30 -0
  177. package/voice/src/realtime-socket.mjs +86 -0
  178. package/voice/src/realtime.mjs +130 -0
  179. package/voice/src/tts.mjs +16 -0
  180. package/voice/src/web.mjs +171 -0
@@ -0,0 +1,89 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, lstat, chmod } from 'node:fs/promises';
3
+ import { resolve, join, dirname, parse } from 'node:path';
4
+ import { validateQuickSettings, quickSettingsPatches } from '../shared/tool-quick-settings.mjs';
5
+ import { assertProviderToolAccess } from './provider-access.mjs';
6
+
7
+ const supported = new Set(['codex', 'claude']);
8
+ export const sessionRouteSupport = tool => supported.has(tool) ? { supported: true } : { supported: false, reason: '此工具尚未验证会话配置与历史隔离,请使用原生配置模式。' };
9
+ const fail = message => { const error = new Error(message); error.code = 'SESSION_ROUTE'; throw error; };
10
+ const text = (value, label) => { if (typeof value !== 'string' || !value.trim() || value.length > 1024 || /[\u0000-\u001f]/u.test(value)) fail('无效的' + label); return value.trim(); };
11
+ const endpoint = value => { let url; try { url = new URL(value); } catch { fail('供应商地址无效'); } if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('供应商地址无效'); return url.href.replace(/\/$/u, ''); };
12
+ const fingerprint = route => createHash('sha256').update(JSON.stringify([route.tool, route.providerId, route.model, route.baseURL, route.protocol, route.sourceProtocol ?? null, route.credentialRef ?? null, ...(route.mode === 'subscription' ? ['subscription'] : []), ...(route.quickSettings && Object.keys(route.quickSettings).length ? [route.quickSettings] : [])])).digest('hex');
13
+ const configArgs = config => {
14
+ const flatten = (value, path = []) => Object.entries(value).flatMap(([key, child]) => child && typeof child === 'object' ? flatten(child, [...path, key]) : [[...path, key].join('.') + '=' + JSON.stringify(child)]);
15
+ return flatten(config).flatMap(value => ['-c', value]);
16
+ };
17
+ async function privateDirectory(directory) {
18
+ const target = resolve(directory); let part = target;
19
+ while (part !== parse(part).root) { try { if ((await lstat(part)).isSymbolicLink()) fail('隔离目录不能经过符号链接'); } catch (error) { if (error.code !== 'ENOENT') throw error; } part = dirname(part); }
20
+ await mkdir(target, { recursive: true, mode: 0o700 }); await chmod(target, 0o700); return target;
21
+ }
22
+ export function createSessionRoutes({ directory, providers, profiles } = {}) {
23
+ if (!directory || !providers?.resolve) throw new TypeError('directory and providers.resolve are required');
24
+ const describe = async providerId => { try { return await (providers.describeRoute ? providers.describeRoute(providerId) : providers.resolve(providerId)); } catch { fail('无法读取绑定供应商,请检查供应商配置。'); } };
25
+ const checkBinding = async (tool, providerId) => {
26
+ const access = await providers.describeAccess?.(providerId);
27
+ assertProviderToolAccess(access, tool);
28
+ if (profiles?.list) {
29
+ const { bindings } = await profiles.list({ includeNative: false });
30
+ if (bindings?.[providerId] && !bindings[providerId].includes(tool)) fail('此供应商未授权用于当前工具。');
31
+ }
32
+ return access;
33
+ };
34
+ const descriptor = (tool, providerId, model, value, requestedProtocol) => {
35
+ const protocol = tool === 'codex' ? 'responses' : 'anthropic';
36
+ const aliases = { 'openai-responses': 'responses', 'anthropic-messages': 'anthropic' };
37
+ if (requestedProtocol && (aliases[requestedProtocol] || requestedProtocol) !== protocol) fail('所选协议不适用于此工具。');
38
+ return { version: 1, mode: 'managed', tool, providerId, model, baseURL: endpoint(value.baseURL), protocol, ...(value.protocol ? { sourceProtocol: text(value.protocol, '供应商协议') } : {}), ...(value.credentialRef ? { credentialRef: text(value.credentialRef, '凭据引用') } : {}) };
39
+ };
40
+ return {
41
+ supports: sessionRouteSupport,
42
+ async capture({ tool, providerId, model, protocol, overrides, quickSettings } = {}) {
43
+ if (!sessionRouteSupport(tool).supported) fail(sessionRouteSupport(tool).reason);
44
+ if (overrides && Object.keys(overrides).length) fail('会话隔离暂不支持高级配置覆盖;请移除覆盖项后重试。');
45
+ providerId = text(providerId, '供应商'); model = text(model, '模型');
46
+ const access = await checkBinding(tool, providerId);
47
+ if (access?.authMode === 'subscription' && access.configured !== true) fail('请先完成对应供应商及 Harness 工具的订阅登录。');
48
+ const route = access?.authMode === 'subscription'
49
+ ? { version: 1, mode: 'subscription', tool, providerId, model }
50
+ : descriptor(tool, providerId, model, await describe(providerId), protocol);
51
+ const settings = validateQuickSettings(tool, quickSettings);
52
+ if (Object.keys(settings).length) route.quickSettings = settings;
53
+ return { ...route, revision: randomUUID(), fingerprint: fingerprint(route) };
54
+ },
55
+ async launch({ tool, route, sessionId } = {}) {
56
+ if (!sessionRouteSupport(tool).supported) fail(sessionRouteSupport(tool).reason);
57
+ if (!route || route.tool !== tool || !['managed', 'subscription'].includes(route.mode) || route.version !== 1 || fingerprint(route) !== route.fingerprint || !/^[a-f0-9-]{36}$/iu.test(sessionId || '')) fail('会话绑定无效,拒绝使用当前默认配置替代。');
58
+ const access = await checkBinding(tool, route.providerId);
59
+ if (route.mode === 'subscription') {
60
+ if (access?.authMode !== 'subscription' || access.configured !== true) fail('绑定供应商的订阅登录已失效,请重新登录。');
61
+ const quick = quickSettingsPatches(tool, route.quickSettings), managedProfile = { configured: true, isolated: false, subscription: true, providerId: route.providerId, model: route.model };
62
+ if (tool === 'codex') return { managedProfile, commandArgs: [...configArgs({ ...quick.config, model: route.model, model_provider: 'openai', force_login_method: 'chatgpt' }), '-c', 'model_providers.openai={name="OpenAI",base_url="https://api.openai.com/v1",wire_api="responses",requires_openai_auth=true}'], env: { OPENAI_API_KEY: undefined, OPENAI_BASE_URL: undefined, CODEX_API_KEY: undefined, BENCHGO_SESSION_API_KEY: undefined } };
63
+ const env = { ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_MODEL: route.model, ANTHROPIC_DEFAULT_SONNET_MODEL: undefined, ANTHROPIC_DEFAULT_OPUS_MODEL: undefined, ANTHROPIC_DEFAULT_HAIKU_MODEL: undefined, CLAUDE_CODE_SUBAGENT_MODEL: undefined, CLAUDE_CODE_OAUTH_TOKEN: undefined, CLAUDE_CODE_USE_BEDROCK: undefined, CLAUDE_CODE_USE_VERTEX: undefined, CLAUDE_CODE_USE_FOUNDRY: undefined, ...quick.settings?.env };
64
+ return { managedProfile, env, settings: { ...quick.settings, forceLoginMethod: 'claudeai', model: route.model, env }, settingSources: [] };
65
+ }
66
+ const value = await describe(route.providerId), current = descriptor(tool, route.providerId, route.model, value);
67
+ if (route.quickSettings && Object.keys(route.quickSettings).length) current.quickSettings = validateQuickSettings(tool, route.quickSettings);
68
+ if (fingerprint(current) !== route.fingerprint) fail('绑定供应商的地址、协议或凭据引用已更改,请新建会话或恢复原配置。');
69
+ if (typeof value.apiKey !== 'string' || !value.apiKey || /[\r\n\u0000]/u.test(value.apiKey)) fail('绑定供应商没有可用密钥。');
70
+ const quick = quickSettingsPatches(tool, route.quickSettings);
71
+ const root = await privateDirectory(join(directory, 'managed-runs')), run = await privateDirectory(join(root, sessionId));
72
+ const managedProfile = { configured: true, isolated: true, providerId: route.providerId, model: route.model, directory: run };
73
+ if (tool === 'codex') {
74
+ const home = await privateDirectory(join(run, 'codex'));
75
+ const providerId = 'benchgo_' + sessionId.replaceAll('-', '');
76
+ const providerConfig = '{name="BenchGo Session",base_url=' + JSON.stringify(route.baseURL) + ',wire_api="responses",env_key="BENCHGO_SESSION_API_KEY",requires_openai_auth=false}';
77
+ const overrides = { model: route.model, model_provider: providerId };
78
+ const quickArgs = configArgs(quick.config || {});
79
+ const commandArgs = [...quickArgs, ...Object.entries(overrides).flatMap(([key, value]) => ['-c', key + '=' + JSON.stringify(value)]), '-c', 'model_providers.' + providerId + '=' + providerConfig];
80
+ return { env: { CODEX_HOME: home, BENCHGO_SESSION_API_KEY: value.apiKey }, commandArgs, managedProfile };
81
+ }
82
+ const home = await privateDirectory(join(run, 'claude'));
83
+ const env = { CLAUDE_CONFIG_DIR: home, CLAUDE_CODE_USE_BEDROCK: undefined, CLAUDE_CODE_USE_VERTEX: undefined, CLAUDE_CODE_USE_FOUNDRY: undefined, CLAUDE_CODE_OAUTH_TOKEN: undefined, CLAUDE_CODE_API_KEY_HELPER_TTL_MS: undefined, ANTHROPIC_BASE_URL: route.baseURL, ANTHROPIC_AUTH_TOKEN: value.apiKey, ANTHROPIC_API_KEY: value.apiKey, ANTHROPIC_MODEL: route.model, ANTHROPIC_DEFAULT_SONNET_MODEL: route.model, ANTHROPIC_DEFAULT_OPUS_MODEL: route.model, ANTHROPIC_DEFAULT_HAIKU_MODEL: route.model, CLAUDE_CODE_SUBAGENT_MODEL: route.model };
84
+ Object.assign(env, quick.settings?.env);
85
+ const settings = { ...quick.settings, env, model: route.model };
86
+ return { env, settings, settingSources: [], managedProfile };
87
+ },
88
+ };
89
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import { lstat, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
2
+ import { hostname } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { withFileLock } from '@deepseek-ai/dsh-atomic-write';
6
+
7
+ const failure = (code, message, cause) => Object.assign(new Error(message, { cause }), { code });
8
+
9
+ export class Store {
10
+ constructor(directory) { this.directory = directory; this.writes = Promise.resolve(); }
11
+
12
+ async open() {
13
+ if (this.lock) throw failure('BENCHGO_DATA_LOCKED', 'BenchGo store is already open');
14
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
15
+ this.lockPath = join(this.directory, 'owner.lock');
16
+ this.guardPath = join(this.directory, 'owner-guard');
17
+ // The short-lived official writer lock serializes recovery with acquisition
18
+ // and release, so competing recoverers cannot remove a successor's lock.
19
+ await withFileLock(this.guardPath, async () => {
20
+ let existing;
21
+ try {
22
+ const metadata = await lstat(this.lockPath);
23
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw failure('BENCHGO_LOCK_UNREADABLE', 'BenchGo owner lock is not a regular file; existing data was preserved');
24
+ existing = await readFile(this.lockPath, 'utf8');
25
+ } catch (error) {
26
+ if (error.code !== 'ENOENT') throw error;
27
+ }
28
+ if (existing !== undefined) {
29
+ let owner;
30
+ try { owner = JSON.parse(existing); }
31
+ catch (cause) { throw failure('BENCHGO_LOCK_UNREADABLE', 'BenchGo owner lock is unreadable; existing data was preserved', cause); }
32
+ if (!owner || typeof owner !== 'object' || !Number.isInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || typeof owner.host !== 'string' || !owner.host) {
33
+ throw failure('BENCHGO_LOCK_UNREADABLE', 'BenchGo owner lock has an invalid PID or host; existing data was preserved');
34
+ }
35
+ if (owner.host !== hostname()) throw failure('BENCHGO_LOCK_OTHER_HOST', 'BenchGo data directory is owned by another host');
36
+ let exited = false;
37
+ try { process.kill(owner.pid, 0); }
38
+ catch (cause) {
39
+ if (cause.code === 'ESRCH') exited = true;
40
+ else throw failure('BENCHGO_LOCK_PROBE_FAILED', 'BenchGo cannot verify the owner process; existing lock was preserved', cause);
41
+ }
42
+ if (!exited) throw failure('BENCHGO_DATA_LOCKED', 'BenchGo data directory is already in use');
43
+ await rm(this.lockPath);
44
+ }
45
+ const lock = await open(this.lockPath, 'wx', 0o600);
46
+ const record = JSON.stringify({ pid: process.pid, host: hostname(), token: randomUUID() });
47
+ try { await lock.writeFile(record); }
48
+ catch (error) { await lock.close(); await rm(this.lockPath, { force: true }); throw error; }
49
+ this.lock = lock;
50
+ this.lockRecord = record;
51
+ });
52
+ try {
53
+ return JSON.parse(await readFile(join(this.directory, 'sessions.json'), 'utf8'));
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') return { version: 1, sessions: [] };
56
+ await this.close();
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ save(sessions) {
62
+ const payload = JSON.stringify({ version: 1, sessions }, null, 2);
63
+ const write = this.writes.then(async () => {
64
+ const target = join(this.directory, 'sessions.json');
65
+ const temp = `${target}.${randomUUID()}.tmp`;
66
+ const file = await open(temp, 'wx', 0o600);
67
+ try { await file.writeFile(payload); await file.sync(); } finally { await file.close(); }
68
+ try { await rename(temp, target); } finally { await rm(temp, { force: true }); }
69
+ if (process.platform !== 'win32') {
70
+ const directory = await open(this.directory, 'r');
71
+ try { await directory.sync(); } finally { await directory.close(); }
72
+ }
73
+ });
74
+ this.writes = write.catch(() => {});
75
+ return write;
76
+ }
77
+
78
+ async close() {
79
+ await this.writes;
80
+ if (!this.lock) return;
81
+ await withFileLock(this.guardPath, async () => {
82
+ if (!this.lock) return;
83
+ await this.lock.close(); this.lock = undefined;
84
+ let record;
85
+ try { record = await readFile(this.lockPath, 'utf8'); }
86
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
87
+ if (record !== this.lockRecord) throw failure('BENCHGO_LOCK_OWNERSHIP_CHANGED', 'BenchGo owner lock changed; successor lock was preserved');
88
+ await rm(this.lockPath);
89
+ });
90
+ }
91
+ }
92
+
93
+ export async function workingDirectory(value) {
94
+ const { resolve } = await import('node:path');
95
+ const cwd = resolve(value ?? process.cwd());
96
+ if (!(await stat(cwd)).isDirectory()) throw new Error('cwd must be a directory');
97
+ return cwd;
98
+ }
@@ -0,0 +1,22 @@
1
+ /** Fixed sources audited against CC Switch 51ac86f. */
2
+ const npm = (id, name, packageName, command = id, extra = {}) => ({ id, name, command, packageName, installer: 'npm', platforms: ['linux', 'darwin', 'win32'], configSupported: true, chatSupported: ['claude', 'codex', 'pi', 'opencode', 'gemini', 'hermes', 'grok', 'mcode'].includes(id), ...extra });
3
+ export const TOOL_CATALOG = Object.freeze([
4
+ npm('claude', 'Claude Code', '@anthropic-ai/claude-code', 'claude', { installArgs: ['--ignore-scripts=false', '--allow-scripts=@anthropic-ai/claude-code'] }),
5
+ { id: 'claude-desktop', name: 'Claude Desktop', command: null, installer: null, platforms: ['darwin', 'win32'], configSupported: true, chatSupported: false, installReason: 'Claude Desktop 使用官方图形安装器;当前不提供自动安装或卸载。' },
6
+ npm('codex', 'Codex', '@openai/codex'),
7
+ npm('gemini', 'Gemini CLI', '@google/gemini-cli'),
8
+ npm('grok', 'Grok Build', '@xai-official/grok'),
9
+ npm('opencode', 'OpenCode', 'opencode-ai'),
10
+ npm('openclaw', 'OpenClaw', 'openclaw'),
11
+ { id: 'hermes', name: 'Hermes', command: 'hermes', installer: 'uv', platforms: ['linux', 'darwin'], configSupported: true, chatSupported: true, source: 'git+https://github.com/NousResearch/hermes-agent.git' },
12
+ npm('pi', 'Pi', '@earendil-works/pi-coding-agent'),
13
+ npm('mcode', 'MiniMax Code', '@minimax-ai/code', 'mcode', { configSupported: true, installArgs: ['--ignore-scripts=false', '--allow-scripts=@minimax-ai/code,better-sqlite3'] }),
14
+ ].map(tool => Object.freeze({ ...tool, platforms: Object.freeze([...tool.platforms]), installArgs: Object.freeze([...(tool.installArgs ?? [])]) })));
15
+ const aliases = { grokbuild: 'grok', 'grok-build': 'grok', minimax: 'mcode', 'minimax-code': 'mcode', 'claude-code': 'claude' };
16
+ export function normalizeToolId(value) {
17
+ if (typeof value !== 'string') throw new Error('Invalid tool id');
18
+ const id = Object.hasOwn(aliases, value) ? aliases[value] : value;
19
+ if (!TOOL_CATALOG.some(tool => tool.id === id)) throw new Error('Unknown tool: ' + String(value).slice(0, 80));
20
+ return id;
21
+ }
22
+ export function getTool(value) { return TOOL_CATALOG.find(tool => tool.id === normalizeToolId(value)); }
@@ -0,0 +1,94 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import * as TOML from 'smol-toml';
5
+ import { parseDocument } from 'yaml';
6
+ import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
7
+ import JSON5 from 'json5';
8
+ import { quickSettingsPatches } from '../shared/tool-quick-settings.mjs';
9
+
10
+ export class ToolConfigError extends Error { constructor(message, status = 400) { super(message); this.status = status; } }
11
+ export const TOOL_IDS = ['claude', 'claude-desktop', 'codex', 'gemini', 'grok', 'opencode', 'openclaw', 'hermes', 'pi', 'mcode'];
12
+ const protocols = { claude: ['anthropic'], 'claude-desktop': ['anthropic'], codex: ['responses'], gemini: ['gemini'], grok: ['responses', 'openai'], opencode: ['openai', 'anthropic', 'responses'], openclaw: ['openai', 'anthropic', 'responses'], hermes: ['openai', 'anthropic', 'responses'], pi: ['openai', 'anthropic', 'responses'], mcode: ['anthropic', 'openai', 'responses'] };
13
+ export const toolCapabilities = () => TOOL_IDS.map(tool => ({ tool, configSupported: true, sessionSupported: ['claude', 'codex', 'pi', 'opencode', 'gemini', 'hermes', 'grok', 'mcode'].includes(tool), protocols: protocols[tool], modelSelection: tool !== 'mcode' }));
14
+ export function toolId(value) { const id = ({ grokbuild: 'grok', 'grok-build': 'grok', minimax: 'mcode', 'minimax-code': 'mcode', claudecode: 'claude' })[value] || value; if (!TOOL_IDS.includes(id)) throw new ToolConfigError('不支持的工具'); return id; }
15
+ const plain = x => x && typeof x === 'object' && !Array.isArray(x);
16
+ export function validateObject(value, { overrides = false } = {}, depth = 0) {
17
+ if (depth > 24) throw new ToolConfigError('配置嵌套过深');
18
+ if (Array.isArray(value)) { for (const x of value) validateObject(x, { overrides }, depth + 1); return; }
19
+ if (!plain(value)) return;
20
+ for (const [key, child] of Object.entries(value)) {
21
+ if (['__proto__', 'prototype', 'constructor'].includes(key)) throw new ToolConfigError('配置含不安全字段');
22
+ if (overrides && /(?:api.?key|(?:auth|access|bearer|refresh|id)[_-]?token|^token$|secret|password|authorization|credential|base.?url|endpoint|url|headers|^env$|command|executable)/iu.test(key)) throw new ToolConfigError('高级配置不能保存密钥、端点或执行入口,请引用官方供应商');
23
+ validateObject(child, { overrides }, depth + 1);
24
+ }
25
+ }
26
+ export function mergeConfig(target, patch) {
27
+ validateObject(patch);
28
+ for (const [key, value] of Object.entries(patch)) {
29
+ if (plain(value)) { if (target[key] !== undefined && !plain(target[key])) throw new ToolConfigError('配置结构冲突,请检查高级配置'); target[key] ??= {}; mergeConfig(target[key], value); }
30
+ else if (Array.isArray(value) && Array.isArray(target[key]) && value.every(item => plain(item) && typeof item.id === 'string')) {
31
+ const previous = target[key]; target[key] = previous.map(item => structuredClone(item));
32
+ for (const item of value) { const index = target[key].findIndex(old => old?.id === item.id); if (index < 0) target[key].push(structuredClone(item)); else mergeConfig(target[key][index], item); }
33
+ } else target[key] = structuredClone(value);
34
+ }
35
+ return target;
36
+ }
37
+ const file = (role, path, format) => ({ role, path, format });
38
+ export function nativeConfigFiles(tool, { home = homedir(), env = process.env, platform = process.platform } = {}) {
39
+ tool = toolId(tool); const config = env.XDG_CONFIG_HOME || join(home, '.config');
40
+ switch (tool) {
41
+ case 'claude': return [file('settings', join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json'), 'jsonc')];
42
+ case 'codex': return [file('config', join(env.CODEX_HOME || join(home, '.codex'), 'config.toml'), 'toml')];
43
+ case 'gemini': { const dir = env.GEMINI_CLI_HOME ? join(env.GEMINI_CLI_HOME, '.gemini') : join(home, '.gemini'); return [file('settings', join(dir, 'settings.json'), 'jsonc'), file('env', join(dir, '.env'), 'env')]; }
44
+ case 'grok': return [file('config', join(env.GROK_HOME || join(home, '.grok'), 'config.toml'), 'toml')];
45
+ case 'opencode': { const dir = env.OPENCODE_CONFIG_DIR || join(config, 'opencode'); return [file('config', env.OPENCODE_CONFIG || join(dir, existsSync(join(dir, 'opencode.jsonc')) ? 'opencode.jsonc' : 'opencode.json'), 'jsonc')]; }
46
+ case 'openclaw': return [file('config', env.OPENCLAW_CONFIG_PATH || join(env.OPENCLAW_STATE_DIR || join(home, '.openclaw'), 'openclaw.json'), 'json5')];
47
+ case 'hermes': return [file('config', join(env.HERMES_HOME?.trim() || (platform === 'win32' ? join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'hermes') : join(home, '.hermes')), 'config.yaml'), 'yaml')];
48
+ case 'pi': { const dir = env.PI_CODING_AGENT_DIR || join(home, '.pi', 'agent'); return [file('config', join(dir, 'models.json'), 'jsonc'), file('settings', join(dir, 'settings.json'), 'jsonc')]; }
49
+ case 'mcode': return [file('config', join(env.MINIMAX_DATA_DIR || env.MAVIS_DATA_DIR || join(home, '.minimax'), 'config.yaml'), 'yaml')];
50
+ case 'claude-desktop': { const root = platform === 'darwin' ? join(home, 'Library', 'Application Support') : platform === 'win32' ? env.LOCALAPPDATA || join(home, 'AppData', 'Local') : config; const dir = join(root, 'Claude-3p', 'configLibrary'); return [file('settings', join(root, 'Claude', 'claude_desktop_config.json'), 'jsonc'), file('config', join(root, 'Claude-3p', 'claude_desktop_config.json'), 'jsonc'), file('profile', join(dir, '36da542a-1436-49c2-8426-66287831dc66.json'), 'jsonc'), file('meta', join(dir, '_meta.json'), 'jsonc')]; }
51
+ }
52
+ }
53
+ const api = protocol => ({ openai: 'openai-completions', responses: 'openai-responses', anthropic: 'anthropic-messages' })[protocol];
54
+ export function nativePatches(profile, provider, current = {}) {
55
+ const tool = toolId(profile.tool), protocol = profile.protocol || protocols[tool][0];
56
+ if (!protocols[tool].includes(protocol)) throw new ToolConfigError('工具不支持此 API 协议');
57
+ const { baseURL, apiKey } = provider, model = profile.model, id = 'benchgo', node = { id: model, name: model }; let patches;
58
+ switch (tool) {
59
+ case 'claude': patches = { settings: { env: { ANTHROPIC_BASE_URL: baseURL, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_API_KEY: apiKey, ANTHROPIC_MODEL: model } } }; break;
60
+ case 'codex': patches = { config: { model, model_provider: id, model_providers: { [id]: { name: 'BenchGo', base_url: baseURL, wire_api: 'responses', requires_openai_auth: false, experimental_bearer_token: apiKey } } } }; break;
61
+ case 'gemini': patches = { settings: { security: { auth: { selectedType: 'gemini-api-key' } }, model: { name: model } }, env: { GEMINI_API_KEY: apiKey, GOOGLE_GEMINI_BASE_URL: baseURL, GEMINI_MODEL: model } }; break;
62
+ case 'grok': patches = { config: { models: { default: id }, model: { [id]: { model, name: model, base_url: baseURL, api_key: apiKey, api_backend: protocol === 'openai' ? 'chat_completions' : 'responses', context_window: profile.contextWindow || 500000 } } } }; break;
63
+ case 'opencode': patches = { config: { model: id + '/' + model, provider: { [id]: { npm: protocol === 'anthropic' ? '@ai-sdk/anthropic' : protocol === 'responses' ? '@ai-sdk/openai' : '@ai-sdk/openai-compatible', name: 'BenchGo', options: { baseURL, apiKey }, models: { [model]: { name: model } } } } } }; break;
64
+ case 'openclaw': patches = { config: { models: { mode: 'merge', providers: { [id]: { baseUrl: baseURL, apiKey, api: api(protocol), models: [node] } } }, agents: { defaults: { model: { primary: id + '/' + model } } } } }; break;
65
+ case 'hermes': { const providers = current.config?.custom_providers || []; if (!Array.isArray(providers)) throw new ToolConfigError('Hermes custom_providers 应为数组'); const entry = mergeConfig(structuredClone(providers.find(x => x.name === id) || {}), { name: id, base_url: baseURL, api_key: apiKey, model, models: { [model]: {} }, api_mode: protocol === 'responses' ? 'codex_responses' : protocol === 'anthropic' ? 'anthropic_messages' : 'chat_completions' }); patches = { config: { model: { provider: id, default: model, base_url: baseURL }, custom_providers: [...providers.filter(x => x.name !== id), entry] } }; break; }
66
+ case 'pi': patches = { config: { providers: { [id]: { baseUrl: baseURL, apiKey, api: api(protocol), models: [node] } } }, settings: { defaultProvider: id, defaultModel: model } }; break;
67
+ case 'mcode': patches = { config: { custom_provider: { [id]: { kind: 'custom', name: 'BenchGo', api: api(protocol), options: { baseURL, apiKey }, models: { [model]: { name: model } } } } } }; break;
68
+ case 'claude-desktop': { const profileId = '36da542a-1436-49c2-8426-66287831dc66', entries = current.meta?.entries || []; if (!Array.isArray(entries)) throw new ToolConfigError('Desktop entries 应为数组'); patches = { settings: { deploymentMode: '3p' }, config: { deploymentMode: '3p' }, profile: { inferenceGatewayApiKey: apiKey, inferenceGatewayAuthScheme: 'bearer', inferenceGatewayBaseUrl: baseURL, inferenceProvider: 'gateway', inferenceModels: [{ name: model }] }, meta: { entries: [...entries.filter(x => x.id !== profileId), mergeConfig(structuredClone(entries.find(x => x.id === profileId) || {}), { id: profileId, name: 'BenchGo' })], appliedId: profileId } }; break; }
69
+ }
70
+ for (const [role, patch] of Object.entries(profile.overrides || {})) { if (!patches[role]) throw new ToolConfigError('无效的配置文件角色'); mergeConfig(patches[role], patch); }
71
+ for (const [role, patch] of Object.entries(quickSettingsPatches(tool, profile.quickSettings))) mergeConfig(patches[role] ??= {}, patch);
72
+ return patches;
73
+ }
74
+ export function parseConfig(raw, format) {
75
+ try { let value;
76
+ if (format === 'toml') value = TOML.parse(raw);
77
+ else if (format === 'yaml') { const doc = parseDocument(raw, { uniqueKeys: true }); if (doc.errors.length) throw Error(); value = doc.toJS({ maxAliasCount: 50 }) ?? {}; }
78
+ else if (format === 'jsonc') { const errors = []; value = raw.trim() ? parseJsonc(raw, errors, { allowTrailingComma: true }) : {}; if (errors.length) throw Error(); }
79
+ else if (format === 'json5') value = raw.trim() ? JSON5.parse(raw) : {};
80
+ else return {};
81
+ if (!plain(value)) throw Error(); validateObject(value); return value;
82
+ } catch { throw new ToolConfigError('原生配置格式无效,请先修复文件', 409); }
83
+ }
84
+ export function renderConfig(raw, format, patch) {
85
+ const current = parseConfig(raw, format), merged = mergeConfig(structuredClone(current), patch);
86
+ const preserveArrays = (value, before) => { for (const [key, child] of Object.entries(value)) { if (Array.isArray(child)) value[key] = mergedValue(before, key, child); else if (plain(child)) preserveArrays(child, before?.[key]); } };
87
+ const mergedValue = (before, key, child) => Array.isArray(child) && child.every(item => plain(item) && typeof item.id === 'string') ? mergeConfig({ value: structuredClone(before?.[key] || []) }, { value: child }).value : child;
88
+ patch = structuredClone(patch); preserveArrays(patch, current);
89
+ if (format === 'env') { const remaining = { ...patch }, lines = raw.split(/\r?\n/u).map(line => { const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/u); if (!match || !Object.hasOwn(patch, match[1])) return line; delete remaining[match[1]]; return match[1] + '=' + JSON.stringify(String(patch[match[1]])); }); for (const [key, value] of Object.entries(remaining)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || typeof value === 'object') throw new ToolConfigError('无效的环境配置'); lines.push(key + '=' + JSON.stringify(String(value))); } return lines.join('\n').replace(/\n*$/u, '\n'); }
90
+ if (format === 'toml') return TOML.stringify(merged);
91
+ if (format === 'json5') return JSON.stringify(merged, null, 2) + String.fromCharCode(10);
92
+ if (format === 'yaml') { const doc = parseDocument(raw || '{}\n', { uniqueKeys: true }); const visit = (value, path = []) => { for (const [key, child] of Object.entries(value)) { const next = [...path, key]; if (plain(child)) { if (!Object.keys(child).length && !doc.hasIn(next)) doc.setIn(next, {}); else visit(child, next); } else doc.setIn(next, child); } }; visit(patch); return doc.toString(); }
93
+ let result = raw.trim() ? raw : '{}\n'; const visit = (value, path = []) => { for (const [key, child] of Object.entries(value)) { const next = [...path, key]; if (plain(child) && Object.keys(child).length) visit(child, next); else result = applyEdits(result, modify(result, next, child, { formattingOptions: { insertSpaces: true, tabSize: 2 } })); } }; visit(patch); parseConfig(result, format); return result;
94
+ }
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { access, realpath } from 'node:fs/promises';
5
+ import { constants } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { delimiter, resolve } from 'node:path';
8
+ import { TOOL_CATALOG, getTool, normalizeToolId } from './tool-catalog.mjs';
9
+ const finished = new Set(['succeeded', 'failed', 'cancelled']); const clone = v => JSON.parse(JSON.stringify(v));
10
+ export function redactToolOutput(value, env = process.env) { let text = String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''); for (const [key, secret] of Object.entries(env)) if (/(?:key|token|secret|password|authorization|credential)/i.test(key) && typeof secret === 'string' && secret.length >= 4) text = text.split(secret).join('[REDACTED]'); return text.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@').replace(/\b(?:sk|dg|api)[_-][A-Za-z0-9_-]{12,}\b/g, '[REDACTED]').replace(/((?:api[_-]?key|access[_-]?token|authorization|password|secret)\s*[=:]\s*)(?:Bearer\s+)?[^\s,;]+/gi, '$1[REDACTED]'); }
11
+ export function executeToolCommand(command, args, { env = process.env, cwd, signal, onOutput } = {}) { return new Promise((resolvePromise, reject) => { if (signal?.aborted) return reject(new Error('Operation cancelled')); const child = spawn(command, args, { env, cwd, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); let tail = ''; const kill = () => { try { process.platform === 'win32' ? child.kill() : process.kill(-child.pid, 'SIGTERM'); } catch {} }; const cleanup = () => signal?.removeEventListener('abort', kill); const data = c => { const v = c.toString(); tail = (tail + v).slice(-8192); onOutput?.(v); }; child.stdout.on('data', data); child.stderr.on('data', data); signal?.addEventListener('abort', kill, { once: true }); child.once('error', e => { cleanup(); reject(e); }); child.once('close', code => { cleanup(); if (signal?.aborted) reject(new Error('Operation cancelled')); else if (code) reject(new Error('Command exited with code ' + code + ': ' + tail)); else resolvePromise({ code, stdout: tail }); }); }); }
12
+ export class ToolInstaller extends EventEmitter {
13
+ constructor({ platform = process.platform, env = process.env, executor = executeToolCommand, isRunning = () => false, maxConcurrent = 2, timeoutMs = 15 * 60_000 } = {}) { super(); this.platform = platform; this.env = { ...env }; this.executor = executor; this.isRunning = isRunning; this.maxConcurrent = maxConcurrent; this.timeoutMs = timeoutMs; this.records = new Map(); this.controllers = new Map(); this.pending = []; this.active = 0; this.tasks = new Set(); this.closed = false; }
14
+ async usable(path) { try { await access(path, this.platform === 'win32' ? constants.F_OK : constants.X_OK); return true; } catch { return false; } }
15
+ async findExecutables(command) { if (!command) return []; const dirs = String(this.env.PATH ?? this.env.Path ?? '').split(this.platform === 'win32' ? ';' : delimiter).filter(Boolean); dirs.push(resolve(homedir(), '.local/bin'), resolve(homedir(), '.npm-global/bin')); const suffixes = this.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']; const found = []; for (const dir of dirs) for (const suffix of suffixes) { const path = resolve(dir, command + suffix); if (await this.usable(path)) { const rp = await realpath(path).catch(() => path); if (!found.some(item => item.realpath === rp)) found.push({ path, realpath: rp }); } } return found; }
16
+ async systemExecutable(id) { return (await this.findExecutables(getTool(id).command))[0]?.path ?? null; }
17
+ async resolveExecutable(id) { return this.systemExecutable(id); }
18
+ async resolveLaunch(id) { const command = await this.systemExecutable(id); return command ? { command, args: [] } : null; }
19
+ async version(command) { try { const r = await this.executor(command, ['--version'], { env: this.env }); return r.stdout.match(/\b\d+\.\d+(?:\.\d+)?\b/)?.[0] ?? null; } catch { return null; } }
20
+ sourceFor(path, tool) { if (!path) return 'none'; const p = path.replaceAll('\\', '/'); if (p.includes('/homebrew/') || p.includes('/Cellar/')) return 'homebrew'; if (p.includes('/node_modules/.bin/') || p.includes('/npm/')) return 'npm'; if (tool.installer === 'uv' || p.includes('/.local/bin/')) return 'uv'; return 'unknown'; }
21
+ async npmRoot() { try { return (await this.executor('npm', ['root', '--global'], { env: this.env })).stdout.trim().replace(/[\\/]+$/, ''); } catch { return null; } }
22
+ async list() { return Promise.all(TOOL_CATALOG.map(async tool => { const installations = (await this.findExecutables(tool.command)).map(item => ({ ...item, source: this.sourceFor(item.realpath, tool), version: null })); for (const i of installations) { i.version = await this.version(i.path); if (i.source === 'npm') { const root = await this.npmRoot(); i.sourceVerified = !!root && i.realpath.startsWith(root + '/'); } } const job = [...this.records.values()].find(v => v.toolId === tool.id && !finished.has(v.status)); const running = await this.isRunning(tool.id); const locked = running ? '工具正在运行,请先结束会话。' : job ? '工具已有进行中的任务。' : null; const i = installations[0], source = i?.source ?? 'none'; const sourceSafe = source !== 'unknown' && (source !== 'npm' || i?.sourceVerified); return { ...tool, installations, installed: !!i, executable: i?.path ?? null, version: i?.version ?? null, source, systemExecutable: i?.path ?? null, canInstall: !!tool.installer && !i && !locked, canUpdate: !!i && sourceSafe && !locked, canReinstall: !!i && sourceSafe && !locked, canUninstall: !!i && sourceSafe && !locked, updateReason: !sourceSafe ? '安装来源未验证,无法自动更新。' : locked, uninstallReason: !sourceSafe ? '安装来源未验证,无法自动卸载。' : locked, ...(job ? { job: clone(job) } : {}) }; })); }
23
+ async previewUninstall(id) { const tool = getTool(id); const target = (await this.findExecutables(tool.command))[0]; if (!target) return { available: false, reason: '未找到系统安装。' }; const source = this.sourceFor(target.realpath, tool); const command = source === 'homebrew' ? 'brew uninstall' : source === 'uv' ? 'uv tool uninstall' : source === 'npm' ? 'npm uninstall --global' : null; return { available: !!command, path: target.path, realpath: target.realpath, source, version: await this.version(target.path), command, description: command ? `将使用 ${command} 移除该安装` : '安装来源未知,无法自动卸载。', snapshot: target.realpath }; }
24
+ install(id, options) { return this.enqueue(id, 'install', options); } updateTool(id, options) { return this.enqueue(id, 'update', options); } reinstall(id, options) { return this.enqueue(id, 'reinstall', options); } uninstall(id, options = {}) { return this.enqueue(id, 'uninstall', options); }
25
+ enqueue(id, action, options = {}) { id = normalizeToolId(id); if ([...this.records.values()].some(j => j.toolId === id && !finished.has(j.status))) throw new Error('工具已有进行中的任务。'); const job = { id: randomUUID(), toolId: id, action, source: options.source ?? null, snapshot: options.snapshot ?? null, status: 'queued', message: '等待执行', logs: [], error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null }; this.records.set(job.id, job); this.pending.push(job.id); this.emit('job', clone(job)); queueMicrotask(() => this.drain()); return clone(job); }
26
+ jobs() { return [...this.records.values()].map(clone); } job(id) { const j = this.records.get(id); if (!j) throw new Error('Unknown installation job'); return clone(j); }
27
+ cancel(id) { const j = this.records.get(id); if (!j) throw new Error('Unknown installation job'); if (!finished.has(j.status)) { this.controllers.get(id)?.abort(); Object.assign(j, { status: 'cancelled', message: '已取消', finishedAt: new Date().toISOString() }); this.emit('job', clone(j)); } return clone(j); }
28
+ drain() { while (!this.closed && this.active < this.maxConcurrent && this.pending.length) { const j = this.records.get(this.pending.shift()); if (!j || finished.has(j.status)) continue; this.active++; const task = this.run(j).finally(() => { this.active--; this.tasks.delete(task); this.drain(); }); this.tasks.add(task); } }
29
+ async run(job) { const controller = new AbortController(); this.controllers.set(job.id, controller); const timer = setTimeout(() => controller.abort(new Error('任务超时。')), this.timeoutMs); timer.unref(); const log = c => { job.logs = [...job.logs, ...String(c).split(/[\r\n]+/).filter(Boolean).map(x => redactToolOutput(x, this.env).slice(0, 1000))].slice(-80); this.emit('job', clone(job)); }; try { Object.assign(job, { status: 'running', message: '正在执行', startedAt: new Date().toISOString() }); const tool = getTool(job.toolId); if (await this.isRunning(tool.id)) throw new Error('工具正在运行,请先结束会话后重试。'); const target = (await this.findExecutables(tool.command))[0]; if (job.action === 'uninstall') { if (!target || (job.snapshot && job.snapshot !== target.realpath)) throw new Error('工具安装位置已变化,请刷新后重新确认。'); const source = this.sourceFor(target.realpath, tool); if (source === 'unknown') throw new Error('安装来源未知,拒绝自动卸载。'); await this.executor(source === 'homebrew' ? 'brew' : tool.installer === 'uv' ? 'uv' : 'npm', source === 'homebrew' ? ['uninstall', tool.packageName ?? tool.id] : tool.installer === 'uv' ? ['tool', 'uninstall', tool.packageName ?? tool.id] : ['uninstall', '--global', tool.packageName], { env: this.env, signal: controller.signal, onOutput: log }); } else { if (!tool.installer) throw new Error(tool.installReason ?? '该工具不支持自动安装。'); let command = tool.installer === 'uv' ? 'uv' : 'npm'; let args = tool.installer === 'uv' ? ['tool', 'install', '--force', ...(job.action === 'update' ? ['--upgrade'] : []), tool.source] : ['install', '--global', ...(job.action === 'update' ? ['--force'] : []), tool.packageName + '@latest']; if (job.action === 'update' && target && this.sourceFor(target.realpath, tool) === 'homebrew') { command = 'brew'; args = ['upgrade', tool.packageName ?? tool.id]; } await this.executor(command, args, { env: this.env, signal: controller.signal, onOutput: log }); if (!(await this.systemExecutable(tool.id))) throw new Error('操作完成但未发现系统工具入口。'); } if (!controller.signal.aborted) Object.assign(job, { status: 'succeeded', message: '操作完成,已重新探测系统安装', finishedAt: new Date().toISOString() }); } catch (e) { if (job.status !== 'cancelled') Object.assign(job, { status: 'failed', message: '操作失败', error: redactToolOutput(controller.signal.aborted ? controller.signal.reason?.message ?? '任务已中止' : e.message, this.env).slice(0, 1200), finishedAt: new Date().toISOString() }); } finally { clearTimeout(timer); this.controllers.delete(job.id); this.emit('job', clone(job)); } }
30
+ async close() { this.closed = true; for (const j of this.records.values()) if (!finished.has(j.status)) this.cancel(j.id); await Promise.allSettled([...this.tasks]); }
31
+ }
@@ -0,0 +1,114 @@
1
+ import { mkdir, open, readFile, rename, rm, lstat, chmod } from 'node:fs/promises';
2
+ import { join, dirname, resolve, parse } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { ToolConfigError, TOOL_IDS, toolId, toolCapabilities, nativeConfigFiles, nativePatches, parseConfig, renderConfig, validateObject } from './tool-config.mjs';
5
+
6
+ import { validateQuickSettings } from '../shared/tool-quick-settings.mjs';
7
+ import { assertProviderToolAccess } from './provider-access.mjs';
8
+
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
11
+ const text = (value, label) => { if (typeof value !== 'string' || !value.trim() || value.length > 512 || /[\u0000-\u001f]/u.test(value)) throw new ToolConfigError('无效的' + label); return value.trim(); };
12
+ async function safePath(path) { let part = resolve(path); while (part !== parse(part).root) { try { const info = await lstat(part); if (info.isSymbolicLink()) throw new ToolConfigError('配置路径不能经过符号链接', 409); } catch (e) { if (e.code !== 'ENOENT') throw e; } part = dirname(part); } }
13
+ async function read(path) { await safePath(path); try { const info = await lstat(path); if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new ToolConfigError('配置文件类型或大小不受支持', 409); return await readFile(path, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
14
+ async function atomic(path, value) {
15
+ await safePath(path); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const temporary = path + '.' + randomUUID() + '.tmp'; let handle;
16
+ try { handle = await open(temporary, 'wx', 0o600); await handle.writeFile(value); await handle.sync(); await handle.close(); handle = null; await safePath(path); await rename(temporary, path); await chmod(path, 0o600); }
17
+ finally { await handle?.close(); await rm(temporary, { force: true }); }
18
+ }
19
+ export function createToolProfiles({ directory, providers, ...paths }) {
20
+ if (!directory || !providers?.resolve) throw new TypeError('directory and official provider resolver are required');
21
+ const storePath = join(directory, 'tool-profiles.json'), backupDir = join(directory, 'tool-config-backups'); let queue = Promise.resolve();
22
+ const exclusive = operation => { const result = queue.then(async () => { await mkdir(directory, { recursive: true, mode: 0o700 }); const lock = join(directory, '.tool-profiles.lock'); try { await mkdir(lock, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') throw new ToolConfigError('工具配置正在由其他操作更新,请稍后重试', 409); throw e; } try { return await operation(); } finally { await rm(lock, { recursive: true, force: true }); } }); queue = result.catch(() => {}); return result; };
23
+ const state = async () => { const raw = await read(storePath); if (!raw) return { version: 1, profiles: {}, bindings: {}, backups: [] }; try { const data = JSON.parse(raw); if (data.version !== 1 || !object(data.profiles) || !object(data.bindings) || !Array.isArray(data.backups)) throw Error(); validateObject(data); return data; } catch { throw new ToolConfigError('工具配置存储损坏,原文件已保留', 409); } };
24
+ const persist = data => atomic(storePath, JSON.stringify(data, null, 2) + '\n');
25
+ const checkAccess = async (providerId, tool) => assertProviderToolAccess(await providers.describeAccess?.(providerId), tool);
26
+ const nativeStatus = async data => Promise.all(TOOL_IDS.map(async tool => {
27
+ const applied = data.applied?.[tool];
28
+ const files = await Promise.all(nativeConfigFiles(tool, paths).map(async spec => {
29
+ try { await safePath(spec.path); const info = await lstat(spec.path); const exists = info.isFile(); const checksum = exists ? hash(await readFile(spec.path, 'utf8')) : null; return { ...spec, exists, ...(applied?.nativeChecksums ? { externalChanged: (applied.nativeChecksums[spec.path] ?? null) !== checksum } : {}) }; }
30
+ catch (error) { return { ...spec, exists: false, ...(applied?.nativeChecksums ? { externalChanged: (applied.nativeChecksums[spec.path] ?? null) !== null } : {}), ...(error.code !== 'ENOENT' ? { unavailable: true } : {}) }; }
31
+ }));
32
+ return { tool, files, hasConfiguration: files.some(file => file.exists), ...(applied?.lastSyncAt ? { lastSyncAt: applied.lastSyncAt } : {}) };
33
+ }));
34
+ const sanitize = profile => profile ? structuredClone(profile) : null;
35
+ const build = async (input, data) => {
36
+ const tool = toolId(input.tool), profile = data.profiles[tool]; if (!profile) throw new ToolConfigError('请先保存工具配置', 409);
37
+ if (input.revision && input.revision !== profile.revision) throw new ToolConfigError('工具方案已变更,请重新预览', 409);
38
+ if (data.bindings[profile.providerId] && !data.bindings[profile.providerId].includes(tool)) throw new ToolConfigError('此供应商未绑定当前工具', 409);
39
+ await checkAccess(profile.providerId, tool);
40
+ const provider = await providers.resolve(profile.providerId);
41
+ let address; try { address = new URL(provider.baseURL); } catch { throw new ToolConfigError('供应商地址无效'); }
42
+ if (!['http:', 'https:'].includes(address.protocol) || address.username || address.password || address.search || address.hash || typeof provider.apiKey !== 'string' || !provider.apiKey || /[\r\n\u0000]/u.test(provider.apiKey)) throw new ToolConfigError('供应商连接配置无效');
43
+ if (JSON.stringify(profile.overrides).includes(provider.apiKey)) throw new ToolConfigError('高级配置不能包含供应商密钥');
44
+ const files = [], current = {};
45
+ for (const spec of nativeConfigFiles(tool, paths)) { const before = await read(spec.path); current[spec.role] = parseConfig(before || '', spec.format); files.push({ ...spec, before }); }
46
+ const patches = nativePatches(profile, provider, current);
47
+ for (const item of files) { item.after = renderConfig(item.before || '', item.format, patches[item.role]); item.checksum = item.before === null ? null : hash(item.before); }
48
+ return { tool, profile, files, warnings: [tool === 'mcode' ? 'MiniMax 的当前模型由原生工具选择;这里只登记供应商和模型。' : '正在运行的原生工具可能需要重启才能读取新配置。', ...(files.some(x => ['toml', 'json5'].includes(x.format)) ? ['TOML/JSON5 保留所有配置字段,格式与注释可能重排。'] : []), ...(tool === 'claude-desktop' ? ['需要支持第三方 gateway profile 的 Claude Desktop 版本。'] : [])] };
49
+ };
50
+ const publicFiles = files => files.map(x => ({ role: x.role, path: x.path, exists: x.before !== null, checksum: x.checksum, changed: x.before !== x.after }));
51
+ async function transact(data, tool, files, restoreOf, appliedProfile) {
52
+ const changed = files.filter(x => x.before !== x.after); if (!changed.length) { data.applied ??= {}; if (appliedProfile) data.applied[tool] = { ...structuredClone(appliedProfile), nativeChecksums: { ...(data.applied[tool]?.nativeChecksums || {}), ...Object.fromEntries(files.map(x => [x.path, x.after === null ? null : hash(x.after)])) }, lastSyncAt: new Date().toISOString() }; else delete data.applied[tool]; await persist(data); return { tool, backupId: null, files: files.map(x => ({ path: x.path, checksum: x.before === null ? null : hash(x.before) })), warnings: [] }; }
53
+ const id = randomUUID(), createdAt = new Date().toISOString(), backup = { version: 1, id, tool, appliedProfile: data.applied?.[tool] || null, files: changed.map(x => ({ path: x.path, before: x.before, afterChecksum: x.after === null ? null : hash(x.after) })) };
54
+ await atomic(join(backupDir, id + '.json'), JSON.stringify(backup));
55
+ const written = [];
56
+ try {
57
+ for (const item of changed) {
58
+ const actual = await read(item.path); if (actual !== item.before) throw new ToolConfigError('原生配置被其他程序修改,请重新预览', 409);
59
+ if (item.after === null) await rm(item.path); else await atomic(item.path, item.after); written.push(item);
60
+ }
61
+ data.applied ??= {}; if (appliedProfile) data.applied[tool] = { ...structuredClone(appliedProfile), nativeChecksums: { ...(data.applied[tool]?.nativeChecksums || {}), ...Object.fromEntries(files.map(x => [x.path, x.after === null ? null : hash(x.after)])) }, lastSyncAt: createdAt }; else delete data.applied[tool];
62
+ data.backups.push({ id, tool, createdAt, files: changed.map(x => x.path), ...(restoreOf ? { restoreOf } : {}) }); await persist(data);
63
+ } catch (error) {
64
+ let conflict = false;
65
+ for (const item of written.reverse()) { if (await read(item.path) !== item.after) { conflict = true; continue; } if (item.before === null) await rm(item.path, { force: true }); else await atomic(item.path, item.before); }
66
+ if (conflict) throw new ToolConfigError('写入失败且配置随后被外部修改,已保留备份供人工恢复', 409); throw error;
67
+ }
68
+ return { tool, backupId: id, files: files.map(x => ({ path: x.path, checksum: x.after === null ? null : hash(x.after) })), warnings: [] };
69
+ }
70
+ return {
71
+ async list({ includeNative = true } = {}) { const data = await state(); return { profiles: Object.values(data.profiles).map(sanitize), applied: Object.values(data.applied || {}).map(sanitize), backups: data.backups.map(x => structuredClone(x)), capabilities: toolCapabilities(), ...(includeNative ? { native: await nativeStatus(data) } : {}), bindings: structuredClone(data.bindings), defaults: Object.values(data.defaults || {}).map(sanitize) }; },
72
+ async get(tool) { return sanitize((await state()).profiles[toolId(tool)]); },
73
+ async runtime(tool) {
74
+ tool = toolId(tool); const data = await state(), profile = data.applied?.[tool]; if (!profile) return { configured: false, env: {} };
75
+ if (data.bindings[profile.providerId] && !data.bindings[profile.providerId].includes(tool)) throw new ToolConfigError('此供应商未绑定当前工具', 409);
76
+ await checkAccess(profile.providerId, tool);
77
+ const { baseURL, apiKey } = await providers.resolve(profile.providerId);
78
+ const env = tool === 'claude' ? { ANTHROPIC_BASE_URL: baseURL, ANTHROPIC_API_KEY: apiKey, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_MODEL: profile.model } : tool === 'gemini' ? { GEMINI_API_KEY: apiKey, GOOGLE_GEMINI_BASE_URL: baseURL, GEMINI_MODEL: profile.model } : {};
79
+ return { configured: true, providerId: profile.providerId, model: profile.model, provider: ['pi', 'opencode'].includes(tool) ? 'benchgo' : undefined, env };
80
+ },
81
+ setBindings(input) { return exclusive(async () => { const data = await state(), providerId = text(input.providerId, '供应商'); if (!Array.isArray(input.tools) || input.tools.length > TOOL_IDS.length + 1) throw new ToolConfigError('无效的工具绑定'); const tools = [...new Set(input.tools.map(id => id === 'harness' ? id : toolId(id)))]; for (const tool of tools) await checkAccess(providerId, tool); if (!providers.describeAccess) await providers.resolve(providerId); else await providers.describeAccess(providerId); data.bindings[providerId] = tools; await persist(data); return { bindings: structuredClone(data.bindings) }; }); },
82
+ save(input) { return exclusive(async () => {
83
+ const data = await state(), tool = toolId(input.tool), providerId = text(input.providerId, '供应商'), model = text(input.model, '模型'), overrides = input.overrides || {};
84
+ if (!object(overrides) || JSON.stringify(overrides).length > 256 * 1024) throw new ToolConfigError('高级配置须为 JSON 对象且不超过 256 KiB'); validateObject(overrides, { overrides: true });
85
+ if (input.expectedRevision && input.expectedRevision !== data.profiles[tool]?.revision) throw new ToolConfigError('工具方案被修改,请刷新', 409);
86
+ const capability = toolCapabilities().find(x => x.tool === tool), protocol = input.protocol || capability.protocols[0]; if (!capability.protocols.includes(protocol)) throw new ToolConfigError('工具不支持此协议');
87
+ if (input.contextWindow !== undefined && (!Number.isSafeInteger(input.contextWindow) || input.contextWindow <= 0)) throw new ToolConfigError('上下文窗口须为正整数');
88
+ await checkAccess(providerId, tool);
89
+ const quickSettings = validateQuickSettings(tool, input.quickSettings ?? data.profiles[tool]?.quickSettings ?? {});
90
+ const provider = await providers.resolve(providerId); if (JSON.stringify(overrides).includes(provider.apiKey)) throw new ToolConfigError('高级配置不能包含供应商密钥');
91
+ if (data.bindings[providerId] && !data.bindings[providerId].includes(tool)) throw new ToolConfigError('此供应商未绑定当前工具', 409);
92
+ const profile = { tool, providerId, model, protocol, quickSettings, overrides: structuredClone(overrides), ...(input.contextWindow ? { contextWindow: input.contextWindow } : {}), revision: randomUUID(), updatedAt: new Date().toISOString() };
93
+ data.profiles[tool] = profile; await persist(data); return sanitize(profile);
94
+ }); },
95
+ setDefault(input) { return exclusive(async () => {
96
+ const data = await state(), tool = toolId(input.tool), profile = data.profiles[tool];
97
+ if (!profile || input.revision !== profile.revision) throw new ToolConfigError('工具方案已变更,请刷新后保存默认', 409);
98
+ if (data.bindings[profile.providerId] && !data.bindings[profile.providerId].includes(tool)) throw new ToolConfigError('此供应商未绑定当前工具', 409);
99
+ await checkAccess(profile.providerId, tool);
100
+ await providers.resolve(profile.providerId);
101
+ data.defaults ??= {}; data.defaults[tool] = structuredClone(profile); await persist(data);
102
+ return { profile: sanitize(profile) };
103
+ }); },
104
+ async preview(input) { const result = await build(input, await state()); return { tool: result.tool, revision: result.profile.revision, files: publicFiles(result.files), warnings: result.warnings }; },
105
+ apply(input) { return exclusive(async () => { if (input.confirmNativeWrite !== true) throw new ToolConfigError('同步本机配置需要显式确认', 409); if (!input.revision || !object(input.checksums)) throw new ToolConfigError('请先预览并确认配置文件版本', 409); const data = await state(), result = await build(input, data); if (input.checksums) for (const item of result.files) if (!Object.hasOwn(input.checksums, item.path) || input.checksums[item.path] !== item.checksum) throw new ToolConfigError('原生配置已变更,请重新预览', 409); const applied = await transact(data, result.tool, result.files, undefined, result.profile); return { ...applied, warnings: result.warnings }; }); },
106
+ restore(input) { return exclusive(async () => {
107
+ if (input.confirmNativeWrite !== true) throw new ToolConfigError('恢复本机配置需要显式确认', 409);
108
+ const data = await state(), info = data.backups.find(x => x.id === input.backupId); if (!info || !/^[a-f0-9-]{36}$/u.test(info.id)) throw new ToolConfigError('备份不存在', 404);
109
+ const backup = JSON.parse(await read(join(backupDir, info.id + '.json'))), allowed = new Set(nativeConfigFiles(info.tool, paths).map(x => x.path)), files = [];
110
+ for (const item of backup.files) { if (!allowed.has(item.path)) throw new ToolConfigError('备份目标与当前工具路径不一致', 409); const before = await read(item.path), checksum = before === null ? null : hash(before); if (checksum !== item.afterChecksum || input.checksums && input.checksums[item.path] !== checksum) throw new ToolConfigError('原生配置在备份后已变更,拒绝覆盖;请先另存并手动合并', 409); files.push({ path: item.path, before, after: item.before }); }
111
+ return transact(data, info.tool, files, info.id, backup.appliedProfile);
112
+ }); },
113
+ };
114
+ }
@@ -0,0 +1,90 @@
1
+ import { ToolInstaller } from './tool-install.mjs';
2
+ import { createToolProfiles } from './tool-profiles.mjs';
3
+ import { createSessionRoutes } from './session-routes.mjs';
4
+ import { createPickerCatalog } from './picker-catalog.mjs';
5
+ import { assertProviderToolAccess } from './provider-access.mjs';
6
+ import { createNativeAuth } from './native-auth.mjs';
7
+ import { assertSameOrigin } from '../voice/src/web.mjs';
8
+ import { readLimited } from '../voice/src/media.mjs';
9
+
10
+ /** Owner-only management surface; credentials are resolved only inside adapters. */
11
+ export async function mountTooling(ctx, commander, providers, options = {}) {
12
+ const busy = tool => [...commander.sessions.values()].some(session => session.provider === tool &&
13
+ (commander.drivers.has(session.id) || commander.connecting.has(session.id) || ['running', 'connecting', 'waiting'].includes(session.status)));
14
+ const installer = options.installer ?? new ToolInstaller({ isRunning: busy });
15
+ providers.setNativeAuth?.(createNativeAuth({ installer }));
16
+ const profiles = options.profiles ?? createToolProfiles({ directory: commander.store.directory, providers });
17
+ commander.toolInstaller = installer; commander.toolProfiles = profiles;
18
+ const routeService = createSessionRoutes({ directory: commander.store.directory, providers, profiles });
19
+ const pickerCatalog = createPickerCatalog({ commander, installer, profiles, providers, routeService, llm: ctx.llm });
20
+ commander.routeService = routeService; commander.pickerCatalog = pickerCatalog;
21
+ // Direct official provider selections must obey the same subscription boundary
22
+ // as opaque native routes, including auxiliary requests and stored selections.
23
+ if (ctx.on && providers.describeAccess) ctx.on('llm/stream', async function* (options, next) {
24
+ if (!options.provider?.startsWith('commander-') && ctx.llm?.listConfigurableProviders?.().some(entry => entry.provider === options.provider)) {
25
+ const access = await providers.describeAccess(options.provider);
26
+ try { assertProviderToolAccess(access, 'harness'); }
27
+ catch (error) {
28
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: error.code, message: error.message + ' 请在模型菜单中选择对应的 Harness 工具。' } } };
29
+ return;
30
+ }
31
+ }
32
+ yield* next();
33
+ });
34
+ const dispatch = async (input, signal) => {
35
+ switch (input.action) {
36
+ case 'pickerCatalog': return pickerCatalog.list();
37
+ case 'catalog': return { tools: (await installer.list()).map(tool => ({ ...tool, routeSupported: routeService.supports(tool.id).supported, routeReason: routeService.supports(tool.id).reason })), ...await profiles.list(), providerAccess: await providers.accessList?.() ?? [] };
38
+ case 'profiles': return profiles.list();
39
+ case 'providers': return { providers: await providers.list() };
40
+ case 'models': return { models: await providers.models(input.providerId, signal) };
41
+ case 'job': return { job: installer.job(input.id) };
42
+ case 'install': return { job: installer.install(input.tool) };
43
+ case 'update': return { job: installer.updateTool(input.tool) };
44
+ case 'reinstall': return { job: installer.reinstall(input.tool) };
45
+ case 'uninstall': return { job: installer.uninstall(input.tool, { snapshot: input.snapshot }) };
46
+ case 'uninstallPreview': return { preview: await installer.previewUninstall(input.tool) };
47
+ case 'cancel': return { job: installer.cancel(input.id) };
48
+ case 'setDefault': {
49
+ const profile = (await profiles.list()).profiles?.find(item => item.tool === input.tool);
50
+ if (!profile || profile.revision !== input.revision) throw Object.assign(Error('工具方案已变更,请刷新'), { status: 409 });
51
+ await routeService.capture({ ...profile, tool: input.tool });
52
+ return profiles.setDefault(input);
53
+ }
54
+ case 'saveProfile': return { profile: await profiles.save(input) };
55
+ case 'setBindings': return profiles.setBindings(input);
56
+ case 'preview': return { preview: await profiles.preview(input) };
57
+ case 'apply': {
58
+ if (input.confirmNativeWrite !== true) throw Object.assign(Error('同步本机配置需要显式确认;设置新会话默认不会修改本机配置'), { status: 409 });
59
+ if (busy(input.tool)) throw Object.assign(Error('工具仍有连接中的会话,请先断开再应用配置'), { status: 409 });
60
+ if (!input.revision || !input.checksums || typeof input.checksums !== 'object' || Array.isArray(input.checksums)) throw Error('请先预览并确认配置文件版本');
61
+ return { result: await profiles.apply(input) };
62
+ }
63
+ case 'restore': {
64
+ if (input.confirmNativeWrite !== true) throw Object.assign(Error('恢复本机配置需要显式确认'), { status: 409 });
65
+ const { backups } = await profiles.list(), backup = backups.find(row => row.id === input.backupId);
66
+ if (backup && busy(backup.tool)) throw Object.assign(Error('工具仍有连接中的会话,请先断开再恢复'), { status: 409 });
67
+ return { result: await profiles.restore(input) };
68
+ }
69
+ default: throw Error('未知工具管理操作');
70
+ }
71
+ };
72
+ const fetch = async request => {
73
+ try {
74
+ assertSameOrigin(request);
75
+ if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw Object.assign(Error('Expected application/json'), { status: 415 });
76
+ const input = JSON.parse(new TextDecoder().decode(await readLimited(request, 300 * 1024)));
77
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw Error('请求必须为 JSON 对象');
78
+ return Response.json(await dispatch(input, request.signal), { headers: { 'cache-control': 'no-store' } });
79
+ } catch (error) {
80
+ // Provider/installer failures carry controlled messages; never serialize options or resolved secrets.
81
+ return Response.json({ error: error.message }, { status: Number.isInteger(error.status) ? error.status : 400, headers: { 'cache-control': 'no-store' } });
82
+ }
83
+ };
84
+ ctx.effect(() => ctx.connection.fetch.register({ path: '/api/commander/tooling', methods: ['POST'], requestBody: 'buffered', fetch }));
85
+ ctx.effect(() => async () => {
86
+ if (commander.toolInstaller === installer) { commander.toolInstaller = undefined; commander.toolProfiles = undefined; if (commander.routeService === routeService) commander.routeService = undefined; if (commander.pickerCatalog === pickerCatalog) commander.pickerCatalog = undefined; }
87
+ await installer.close();
88
+ });
89
+ return { installer, profiles, fetch };
90
+ }