codebee 0.1.101 → 0.1.103

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 (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -1,2385 +1,2385 @@
1
- # -*- coding: utf-8 -*-
2
- """智能体管理器:安装检测、版本、安装/升级、模型配置读写。
3
-
4
- 安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
5
- 防止相对路径穿越到预期之外的系统位置。
6
- """
7
- from __future__ import annotations
8
-
9
- import glob
10
- import hashlib
11
- import json
12
- import os
13
- import re
14
- import shlex
15
- import shutil
16
- import signal
17
- import socket
18
- import subprocess
19
- import sys
20
- import tempfile
21
- import threading
22
- import time
23
- import webbrowser
24
- import zlib
25
- from pathlib import Path
26
-
27
- from . import catalog, paths, runner
28
-
29
- # 非 Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(runner 同款守卫)
30
- CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
31
- VERSION_TTL = 300 # 版本缓存 5 分钟
32
-
33
- _LOCK = threading.RLock()
34
- _STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
35
-
36
- # 能自动写入默认模型的 config.format(其余格式只能手动编辑)
37
- _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
38
- "yaml-line")
39
-
40
-
41
- def _read_text(path, preserve_newlines=False):
42
- """读取文本并及时关闭句柄;改写配置前可保留原始换行符。"""
43
- newline = "" if preserve_newlines else None
44
- with open(path, encoding="utf-8", errors="replace", newline=newline) as fh:
45
- return fh.read()
46
-
47
-
48
- def _expand(p):
49
- return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
50
-
51
-
52
- def _under(path, root):
53
- try:
54
- return os.path.commonpath([os.path.realpath(path), root]) == root
55
- except ValueError:
56
- return False
57
-
58
-
59
- def tmp_data_no_home_write():
60
- """测试实例防毒闸:TUTTI_DATA 指向系统临时目录、且进程主目录仍是真实
61
- 主目录时,一切对真实 CLI 配置的写入都应拒绝。
62
-
63
- 一次性测试服务的 models.json 里全是 a.test/sk-test 夹具,忘设假 HOME 的
64
- 测试一旦触发 launch/自愈/autobind 同步,就会把夹具毒进真实
65
- ~/.claude/settings.json(2026-09-22 实案:claude 步骤全体 ENOTFOUND,
66
- 下一次测试又覆盖回来,形成「越测越坏」循环)。已设假 HOME 的测试不受
67
- 影响——假 HOME 本身就在临时目录下。
68
- """
69
- td = (os.environ.get("TUTTI_DATA") or "").strip()
70
- if not td:
71
- return False
72
- tmp = tempfile.gettempdir()
73
- home = os.path.abspath(os.path.expanduser("~"))
74
- return _under(td, tmp) and not _under(home, tmp)
75
-
76
-
77
- def _safe_config_path(raw):
78
- """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
79
- if not raw:
80
- return None
81
- full = _expand(raw)
82
- home = os.path.abspath(os.path.expanduser("~"))
83
- try:
84
- if os.path.commonpath([full, home]) != home:
85
- return None
86
- except ValueError:
87
- return None
88
- return full
89
-
90
-
91
- # ---------------------------------------------------------------- 检测
92
-
93
- def _which_cli(cli):
94
- """PATH 找 CLI;落空再按平台兜底磁盘已知安装位。
95
-
96
- Windows 兜 winget 便携包两处标准落点:winget 装的 CLI(如 Claude Code)
97
- 把 exe 放进 %LOCALAPPDATA%\\Microsoft\\WinGet\\Packages\\<PkgId>_*\\,并把
98
- 该目录写进注册表用户 PATH——注册表更新后已在跑的进程(本服务)不继承,
99
- shutil.which 落空 → 卡片「未安装」假象,点安装又被 winget「已装无可升级」
100
- 非零退出拒掉(2026-09-30 实案)。Links 是便携包符号链接标准目录,Packages
101
- 按 <cli>.exe 通配兜另一形态。POSIX 兜 runner 同款三落点(~/.local/bin 与
102
- brew bin,Mac 服务吃不到 shell rc 里的 PATH 时同样假「未安装」)。
103
- 两条都只兜 which 落空的场景,零额外开销。"""
104
- path = shutil.which(cli)
105
- if path:
106
- return path
107
- if os.name != "nt":
108
- return runner._fallback_probe_cli(cli)
109
- local = os.environ.get("LOCALAPPDATA")
110
- if not local:
111
- return None
112
- links = os.path.join(local, "Microsoft", "WinGet", "Links", cli + ".exe")
113
- if os.path.isfile(links):
114
- return links
115
- for hit in sorted(glob.glob(os.path.join(
116
- local, "Microsoft", "WinGet", "Packages", "*", cli + ".exe"))):
117
- return hit
118
- return None
119
-
120
-
121
- def detect_entry(entry):
122
- d = entry.get("detect") or {}
123
- if d.get("cli"):
124
- path = _which_cli(d["cli"])
125
- return {"installed": bool(path), "detail": path or ""}
126
- if d.get("exe"):
127
- full = _expand(d["exe"])
128
- return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
129
- if d.get("uwp"):
130
- base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
131
- return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
132
- if d.get("dir"):
133
- full = _expand(d["dir"])
134
- return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
135
- return {"installed": False, "detail": ""}
136
-
137
-
138
- def _orphan_signature(cl):
139
- """「Tutti 专属调用签名」判定(两个平台的清扫共用)。"""
140
- return (("opencode" in cl and "--model" in cl)
141
- or ("codex" in cl and "--skip-git-repo-check" in cl)
142
- or ("kimi-code" in cl and "main.mjs" in cl))
143
-
144
-
145
- def _sweep_orphans_posix():
146
- """POSIX 版清扫:ps 一次拉全量,签名同 Windows。孤儿判据不同——POSIX 的
147
- 孤儿进程会被内核过继给 1 号进程(launchd/init),Windows 则保留死掉的
148
- ppid,所以这里认 ppid==1;用户手动在终端里跑的同名 CLI 父进程是活着的
149
- shell(ppid!=1),不会被误杀。"""
150
- try:
151
- r = subprocess.run(["ps", "-eo", "pid=,ppid=,command="],
152
- capture_output=True, timeout=60)
153
- killed = 0
154
- for ln in r.stdout.decode("utf-8", "replace").splitlines():
155
- parts = ln.strip().split(None, 2)
156
- if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
157
- continue
158
- pid, ppid, cmd = int(parts[0]), int(parts[1]), parts[2]
159
- if pid == 1 or ppid != 1 or not _orphan_signature(cmd.lower()):
160
- continue
161
- try:
162
- os.killpg(pid, signal.SIGKILL) # 服务 spawn 用了 start_new_session,pgid==pid
163
- killed += 1
164
- except Exception:
165
- try:
166
- os.kill(pid, signal.SIGKILL)
167
- killed += 1
168
- except Exception:
169
- pass
170
- return killed
171
- except Exception:
172
- return 0
173
-
174
-
175
- def sweep_orphan_cli_processes():
176
- """启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
177
- /T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
178
- `opencode run` 连上僵尸实例后 shell 全在僵尸的项目根里跑(2026-09-17
179
- mo-so 实测:agent 在 Temp 里找代码,汇报「工作目录没有源码」)。
180
-
181
- 按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
182
- opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
183
- codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
184
- kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
185
- 僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
186
- claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
187
- if os.name != "nt":
188
- return _sweep_orphans_posix()
189
- ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
190
- "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
191
- if not os.path.isfile(ps_exe):
192
- ps_exe = "powershell"
193
- ps = ("Get-CimInstance Win32_Process | "
194
- "Where-Object { $_.Name -match '^(opencode|codex|node|cmd)\\.exe$' } | "
195
- "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
196
- try:
197
- r = subprocess.run([ps_exe, "-NoProfile", "-Command", ps],
198
- capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=60)
199
- import json as _json
200
- raw = r.stdout.decode("utf-8", "replace").strip()
201
- items = _json.loads(raw) if raw else []
202
- if isinstance(items, dict):
203
- items = [items]
204
- live = {i.get("ProcessId") for i in items}
205
- killed = 0
206
- for i in items:
207
- cl = str(i.get("CommandLine") or "").lower()
208
- ppid = i.get("ParentProcessId")
209
- if not _orphan_signature(cl) or ppid in live or not i.get("ProcessId"):
210
- continue
211
- try:
212
- subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
213
- capture_output=True, creationflags=CREATE_NO_WINDOW,
214
- timeout=15)
215
- killed += 1
216
- except Exception:
217
- pass
218
- return killed
219
- except Exception:
220
- return 0
221
-
222
-
223
- def detect_all(force=False):
224
- """检测全部条目。快照用途(force=False)热路径永不等检测:缓存新鲜直接
225
- 返回;过期返回旧快照、同时把刷新踢到后台线程(stale-while-revalidate)。
226
-
227
- 检测是慢磁盘 IO:shutil.which/isfile 在 Windows 上遇到断链的 PATH 项
228
- 可能卡数秒。此前「领队请求自己跑检测、其余请求 ev.wait(30) 排队」,每
229
- 60s 缓存周期都有一个 /api/catalog、/api/state(含 SSE 推送)一起卡数秒
230
- 的窗口;前端无超时轮询把浏览器 6 连接池塞满后整个界面假死(2026-09-22
231
- 用户实测「左栏点不动、右栏不刷新」)。冷启动首轮与 force=True(显式
232
- 重载/安装回调)仍同步跑拿新鲜结果。"""
233
- with _LOCK:
234
- if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
235
- return _STATE["detected"]
236
- ev = _STATE["detect_ev"]
237
- lead = ev is None # 我是本次刷新的发起人
238
- if lead:
239
- ev = _STATE["detect_ev"] = threading.Event()
240
- cold = not _STATE["detected"] # 冷启动首轮:还没有任何快照
241
- if not lead:
242
- if not cold:
243
- return dict(_STATE["detected"]) # 过期快照也比卡请求强
244
- ev.wait(30) # 冷启动窗口内并发首查:等首轮检测完成(一次性)
245
- with _LOCK:
246
- return dict(_STATE["detected"])
247
- if not cold and not force:
248
- threading.Thread(target=_detect_refresh, args=(ev,),
249
- name="catalog-detect", daemon=True).start()
250
- return dict(_STATE["detected"])
251
- return _detect_refresh(ev)
252
-
253
-
254
- def _detect_refresh(ev):
255
- """跑一轮全量检测并落快照;完成后置时间戳、清在飞标记、放行等待者。"""
256
- try:
257
- detected = {}
258
- for entry in catalog.load():
259
- try:
260
- detected[entry["id"]] = detect_entry(entry)
261
- except Exception as e:
262
- detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
263
- if detected:
264
- with _LOCK:
265
- _STATE["detected"] = detected
266
- return dict(_STATE["detected"])
267
- finally:
268
- with _LOCK:
269
- _STATE["detect_ts"] = time.time()
270
- _STATE["detect_ev"] = None
271
- ev.set()
272
-
273
-
274
- def _uwp_version(package_dir):
275
- import xml.etree.ElementTree as ET
276
- mf = os.path.join(package_dir, "AppxManifest.xml")
277
- if not os.path.isfile(mf):
278
- return None
279
- try:
280
- tree = ET.parse(mf)
281
- for el in tree.iter():
282
- if el.tag.endswith("}Identity") or el.tag == "Identity":
283
- return el.get("Version")
284
- except Exception:
285
- pass
286
- return None
287
-
288
-
289
- def _exe_version(path):
290
- if os.name != "nt":
291
- return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
292
- try:
293
- r = subprocess.run(
294
- ["powershell", "-NoProfile", "-Command",
295
- "(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
296
- capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
297
- out = r.stdout.decode("utf-8", "replace").strip()
298
- return out or None
299
- except Exception:
300
- return None
301
-
302
-
303
- def version_of(entry):
304
- """版本探测:CLI 走 --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
305
- eid = entry["id"]
306
- with _LOCK:
307
- cached = _STATE["versions"].get(eid)
308
- if cached and time.time() - cached[0] < VERSION_TTL:
309
- return cached[1]
310
- det = (detect_all() or {}).get(eid) or {}
311
- version = None
312
- cli = (entry.get("detect") or {}).get("cli")
313
- if cli and det.get("installed"):
314
- try:
315
- # Windows 下 CLI 可能是 npm .cmd 垫片,须经 cmd /c 才能直接点名跑;
316
- # POSIX 没有垫片,符号链接直接跑即可
317
- probe = ["cmd", "/c", cli, "--version"] if os.name == "nt" \
318
- else [cli, "--version"]
319
- r = subprocess.run(probe, capture_output=True,
320
- creationflags=CREATE_NO_WINDOW, timeout=20)
321
- out = (r.stdout or b"").decode("utf-8", "replace").strip()
322
- if not out:
323
- out = (r.stderr or b"").decode("utf-8", "replace").strip()
324
- version = out.splitlines()[0][:60] if out else None
325
- except Exception:
326
- version = None
327
- elif det.get("detail") and os.path.isdir(det["detail"]):
328
- version = _uwp_version(det["detail"])
329
- elif det.get("detail") and os.path.isfile(det["detail"]):
330
- version = _exe_version(det["detail"])
331
- with _LOCK:
332
- _STATE["versions"][eid] = (time.time(), version or "-")
333
- return version or "-"
334
-
335
-
336
- # ---------------------------------------------------------------- TOML 表内键
337
-
338
- def _toml_span(lines, table):
339
- """定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
340
- 只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
341
- 子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
342
- header = re.compile(r"^\[([^\[\]]+)\]\s*$")
343
- start = None
344
- for i, ln in enumerate(lines):
345
- if ln.lstrip().startswith("["):
346
- m = header.match(ln.strip())
347
- if not m:
348
- continue
349
- if start is not None:
350
- return start, i
351
- if m.group(1).strip() == table:
352
- start = i
353
- if start is None:
354
- return None, None
355
- return start, len(lines)
356
-
357
-
358
- def _toml_read_value(text, table, leaf):
359
- m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
360
- if table is None else None
361
- if table is None:
362
- return m.group(1) if m else None
363
- start, end = _toml_span(text.splitlines(), table)
364
- if start is None:
365
- return None
366
- for ln in text.splitlines()[start + 1:end]:
367
- m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
368
- if m:
369
- return m.group(1)
370
- return None
371
-
372
-
373
- def _toml_write_value(text, table, leaf, value):
374
- """就地写入 TOML 的 [table] leaf(双引号标量),保留其余内容与换行风格。"""
375
- eol = "\r\n" if "\r\n" in text else "\n"
376
- lines = text.splitlines()
377
- new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
378
- # 换值不换行:保留行尾注释等其余内容
379
- pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
380
- if table is None:
381
- for i, ln in enumerate(lines):
382
- m = pat.match(ln)
383
- if m:
384
- lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
385
- break
386
- else:
387
- lines.append(new_line)
388
- return eol.join(lines).rstrip("\r\n") + eol
389
- start, end = _toml_span(lines, table)
390
- if start is None: # 表不存在:整段追加
391
- if lines and lines[-1].strip():
392
- lines.append("")
393
- lines.append("[%s]" % table)
394
- lines.append(new_line)
395
- return eol.join(lines).rstrip("\r\n") + eol
396
- for i in range(start + 1, end):
397
- m = pat.match(lines[i])
398
- if m:
399
- lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
400
- return eol.join(lines).rstrip("\r\n") + eol
401
- lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
402
- return eol.join(lines).rstrip("\r\n") + eol
403
-
404
-
405
- # ---------------------------------------------------------------- 模型配置
406
-
407
- def _config_path(entry):
408
- cfg = entry.get("config") or {}
409
- return _safe_config_path(cfg.get("path"))
410
-
411
-
412
- def _yaml_model_path(cfg):
413
- """把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
414
- return _dotted_key(cfg)
415
-
416
-
417
- def _dotted_key(cfg):
418
- """model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
419
- key = (cfg.get("model_key") or "model").strip()
420
- if "." in key:
421
- section, leaf = key.split(".", 1)
422
- return section.strip(), leaf.strip()
423
- return None, key
424
-
425
-
426
- def _yaml_quote(value):
427
- """YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
428
- return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
429
-
430
-
431
- def _yaml_unquote(raw):
432
- """取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
433
- s = (raw or "").strip()
434
- if not s:
435
- return ""
436
- if s[0] in ("'", '"'):
437
- q = s[0]
438
- i = 1
439
- buf = []
440
- while i < len(s):
441
- ch = s[i]
442
- if q == '"' and ch == "\\" and i + 1 < len(s):
443
- nxt = s[i + 1]
444
- buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
445
- i += 2
446
- continue
447
- if ch == q:
448
- if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
449
- buf.append("'")
450
- i += 2
451
- continue
452
- break
453
- buf.append(ch)
454
- i += 1
455
- return "".join(buf).strip()
456
- # 无引号:截断行尾注释(# 前的空白才算注释起始)
457
- return re.split(r"\s+#", s, 1)[0].strip()
458
-
459
-
460
- def _yaml_span(lines, section):
461
- """定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
462
- start = None
463
- for i, ln in enumerate(lines):
464
- m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
465
- if not m:
466
- continue
467
- if m.group(1).strip() == section:
468
- start = i
469
- continue
470
- if start is not None:
471
- return start, i
472
- if start is None:
473
- return None, None
474
- return start, len(lines)
475
-
476
-
477
- def _yaml_read_value(text, section, leaf):
478
- lines = text.splitlines()
479
- if section is None:
480
- m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
481
- return _yaml_unquote(m.group(1)) or None if m else None
482
- start, end = _yaml_span(lines, section)
483
- if start is None:
484
- return None
485
- for ln in lines[start + 1:end]:
486
- m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
487
- if m:
488
- return _yaml_unquote(m.group(1)) or None
489
- return None
490
-
491
-
492
- def _yaml_write_value(text, section, leaf, value):
493
- """就地写入 YAML 的 section.leaf,保留其余内容、缩进与换行风格。"""
494
- eol = "\r\n" if "\r\n" in text else "\n"
495
- lines = text.splitlines()
496
- quoted = _yaml_quote(value)
497
- if section is None:
498
- for i, ln in enumerate(lines):
499
- if re.match(r"^%s\s*:" % re.escape(leaf), ln):
500
- lines[i] = "%s: %s" % (leaf, quoted)
501
- break
502
- else:
503
- lines.append("%s: %s" % (leaf, quoted))
504
- return eol.join(lines).rstrip("\r\n") + eol
505
- start, end = _yaml_span(lines, section)
506
- if start is None: # 段不存在:整段追加
507
- if lines and lines[-1].strip():
508
- lines.append("")
509
- lines.append("%s:" % section)
510
- lines.append(" %s: %s" % (leaf, quoted))
511
- return eol.join(lines).rstrip("\r\n") + eol
512
- for i in range(start + 1, end):
513
- m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
514
- if m: # 键已存在:只换值,保留原缩进
515
- lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
516
- return eol.join(lines).rstrip("\r\n") + eol
517
- indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
518
- for i in range(start + 1, end):
519
- if not lines[i].strip():
520
- continue
521
- if indent == " ":
522
- m = re.match(r"^(\s+)\S", lines[i])
523
- if m:
524
- indent = m.group(1)
525
- last = i
526
- lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
527
- return eol.join(lines).rstrip("\r\n") + eol
528
-
529
-
530
- def _json_path_get(data, keys):
531
- """沿点号路径下钻 JSON 嵌套;终点必须是字符串。"""
532
- cur = data
533
- for k in keys:
534
- if isinstance(cur, dict) and k in cur:
535
- cur = cur[k]
536
- else:
537
- return None
538
- return cur if isinstance(cur, str) else None
539
-
540
-
541
- def _json_path_set(data, keys, value):
542
- """沿点号路径写入 JSON 嵌套,缺中间对象就地创建;
543
- 中途遇到非 dict(如 string 简写形式)升级为对象。"""
544
- cur = data
545
- for k in keys[:-1]:
546
- if not isinstance(cur.get(k), dict):
547
- cur[k] = {}
548
- cur = cur[k]
549
- cur[keys[-1]] = value
550
-
551
-
552
- def read_model(entry):
553
- path = _config_path(entry)
554
- cfg = entry.get("config") or {}
555
- if not path or not os.path.isfile(path) or not cfg.get("format"):
556
- return None
557
- try:
558
- text = _read_text(path)
559
- except Exception:
560
- return None
561
- if cfg["format"] == "toml-line":
562
- m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
563
- return m.group(1) if m else None
564
- if cfg["format"] == "toml-section":
565
- table, leaf = _dotted_key(cfg)
566
- return _toml_read_value(text, table, leaf)
567
- if cfg["format"] == "json":
568
- try:
569
- v = json.loads(text).get("model")
570
- if v:
571
- return v
572
- except Exception:
573
- pass
574
- m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
575
- return m.group(1) if m else None
576
- if cfg["format"] == "jsonc":
577
- keys = (cfg.get("model_key") or "model").split(".")
578
- try:
579
- data = json.loads(_jsonc_strip_comments(text) or "{}")
580
- except Exception:
581
- return None
582
- return _json_path_get(data, keys)
583
- if cfg["format"] == "json-path":
584
- try:
585
- data = json.loads(text or "{}")
586
- except Exception:
587
- return None
588
- return _json_path_get(data, (cfg.get("model_key") or "model").split("."))
589
- if cfg["format"] == "yaml-line":
590
- return _yaml_read_value(text, *_yaml_model_path(cfg))
591
- return None
592
-
593
-
594
- def _atomic_write_bytes(path, data):
595
- """CLI 自家配置写盘统一走临时文件 + os.replace 原子替换。
596
-
597
- 就地 write_bytes 有「截断→重写」窗口:并行评审爆发期多个 CLI 同时被
598
- 拉起(bind_agent 先同步配置再 spawn),正在启动的 CLI 恰好读配置就会
599
- 读到半截文件——opencode 对残缺 jsonc 的反应正是打一行横幅就退出码 1
600
- (2026-09-30 14:56 实案)。os.replace 原地换名,读者要么见旧要么见新。
601
- """
602
- path = str(path)
603
- d = os.path.dirname(path) or "."
604
- fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".",
605
- suffix=".tmp", dir=d)
606
- try:
607
- with os.fdopen(fd, "wb") as fh:
608
- fh.write(data)
609
- os.replace(tmp, path)
610
- except Exception:
611
- try:
612
- os.remove(tmp)
613
- except OSError:
614
- pass
615
- raise
616
-
617
-
618
- def write_model(entry, model):
619
- """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
620
- json / json-path / jsonc / yaml-line。"""
621
- if tmp_data_no_home_write():
622
- return {"ok": False,
623
- "error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
624
- path = _config_path(entry)
625
- cfg = entry.get("config") or {}
626
- fmt = cfg.get("format")
627
- if not path:
628
- return {"ok": False,
629
- "error": "配置路径无效或不在用户主目录内,已拒绝写入"}
630
- if fmt not in _WRITABLE_FORMATS:
631
- return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
632
- model = (model or "").strip()
633
- if not model:
634
- return {"ok": False, "error": "模型名不能为空"}
635
- # 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
636
- # 校验通过后统一改用解析路径写入
637
- home = os.path.abspath(os.path.expanduser("~"))
638
- path = os.path.realpath(path)
639
- try:
640
- if os.path.commonpath([path, home]) != home:
641
- return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
642
- except ValueError:
643
- return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
644
- try:
645
- if os.path.isfile(path):
646
- shutil.copyfile(path, path + ".bak")
647
- else:
648
- # 各 CLI 首次运行都未必建主配置(grok 不建 config.toml、pi 不建
649
- # settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
650
- # 内置默认」);它正是用户层覆盖的落点,缺了按需创建
651
- Path(path).parent.mkdir(parents=True, exist_ok=True)
652
- _atomic_write_bytes(path, b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
653
- if fmt == "jsonc":
654
- # mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
655
- # _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
656
- keys = (cfg.get("model_key") or "model").split(".")
657
- text = _read_text(path, preserve_newlines=True)
658
- new_text, ok = _jsonc_set(text, tuple(keys),
659
- json.dumps(model, ensure_ascii=False))
660
- if not ok:
661
- return {"ok": False,
662
- "error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
663
- _atomic_write_bytes(path, new_text.encode("utf-8"))
664
- elif fmt == "toml-line":
665
- text = _read_text(path)
666
- new_line = 'model = "%s"' % model
667
- if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
668
- text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
669
- else:
670
- text = text.rstrip("\n") + "\n" + new_line + "\n"
671
- _atomic_write_bytes(path, text.encode("utf-8"))
672
- elif fmt == "toml-section":
673
- text = _read_text(path, preserve_newlines=True)
674
- text = _toml_write_value(text, *_dotted_key(cfg), value=model)
675
- _atomic_write_bytes(path, text.encode("utf-8"))
676
- elif fmt == "yaml-line":
677
- # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
678
- # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
679
- text = _read_text(path, preserve_newlines=True)
680
- text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
681
- _atomic_write_bytes(path, text.encode("utf-8"))
682
- elif fmt == "json-path":
683
- # openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
684
- # 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
685
- text = _read_text(path, preserve_newlines=True)
686
- try:
687
- data = json.loads(text) if text.strip() else {}
688
- except Exception:
689
- return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
690
- _json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
691
- _atomic_write_bytes(path,
692
- json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
693
- else:
694
- try:
695
- data = json.loads(_read_text(path))
696
- except FileNotFoundError:
697
- return {"ok": False,
698
- "error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
699
- except Exception:
700
- return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
701
- data["model"] = model
702
- _atomic_write_bytes(path,
703
- json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
704
- return {"ok": True, "model": read_model(entry)}
705
- except Exception as e:
706
- return {"ok": False, "error": repr(e)}
707
-
708
-
709
- # ---------------------------------------------------------------- 一键打开
710
-
711
- def _port_open(port, timeout=0.5):
712
- try:
713
- with socket.create_connection(("127.0.0.1", port), timeout=timeout):
714
- return True
715
- except OSError:
716
- return False
717
-
718
-
719
- def launch_env(entry):
720
- """打开交互/网页版时注入的子进程环境变量:与编排同源的绑定凭据
721
- (dsh=DEEPSEEK_*,claude=ANTHROPIC_*,codex=ORCH_API_KEY)。交互进程
722
- 脱离了编排链路,没有这层注入就拿不到 API key。无绑定时返回 {}。"""
723
- try:
724
- from . import modelhub # 惰性导入:modelhub 体量大且避免潜在环
725
- b = modelhub.resolve_binding(entry["id"]) or {}
726
- except Exception:
727
- return {}
728
- return dict(b.get("env") or {})
729
-
730
-
731
- def _launch_log_path(entry):
732
- """web 类启动日志的落点:id 白名单化后仅作文件名成分,最终路径必须仍围栏
733
- 在数据目录内(catalog.json 用户可编辑,id 不可信;非白名单 id 用 crc32
734
- 稳定代称——跨进程重启不变,「已在运行」回读上次日志才找得到)。"""
735
- raw_id = str(entry.get("id") or "")
736
- if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw_id):
737
- stem = raw_id
738
- else:
739
- stem = "agent-%d" % (zlib.crc32(raw_id.encode("utf-8")) & 0xFFFFFFFF)
740
- data_root = os.path.abspath(str(paths.DATA_DIR))
741
- p = Path(data_root, "launch", stem + ".log")
742
- try:
743
- if os.path.commonpath([os.path.abspath(str(p)), data_root]) != data_root:
744
- return None
745
- except ValueError:
746
- return None
747
- return p
748
-
749
-
750
- def _best_url(log_path, port):
751
- """从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
752
- try:
753
- text = _read_text(str(log_path))
754
- except Exception:
755
- return None
756
- urls = re.findall(r"https?://[^\s\"'<>]+", text)
757
- same = [u for u in urls if ":%d" % port in u]
758
- if not same:
759
- return None
760
- tokened = [u for u in same if "token=" in u]
761
- return (tokened or same)[0]
762
-
763
-
764
- def _yaml_model_ids(text, section):
765
- """收集 section.models 序列里的全部模型 id(保持顺序)。段/键缺失返回 []。"""
766
- lines = text.splitlines()
767
- start, end = _yaml_span(lines, section)
768
- if start is None:
769
- return []
770
- m_indent = None
771
- for i in range(start + 1, end):
772
- m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
773
- if m:
774
- m_indent = len(m.group(1))
775
- start = i
776
- break
777
- if m_indent is None:
778
- return []
779
- ids = []
780
- for ln in lines[start + 1:end]:
781
- if not ln.strip():
782
- continue
783
- if len(ln) - len(ln.lstrip(" ")) <= m_indent:
784
- break # models 列表结束(遇到同级或更浅缩进的键)
785
- m = re.match(r"^\s*-\s+id\s*:\s*(.+?)\s*$", ln)
786
- if m:
787
- ids.append(_yaml_unquote(m.group(1)))
788
- return ids
789
-
790
-
791
- def _yaml_ensure_model_entry(text, section, model, context_window=1000000):
792
- """确保 section.models 序列里有 id==model 的条目,缺则按现有条目形状追加
793
- (id/name/contextWindow——dsh 的 catalog 校验要求 id 与 name 非空)。
794
- 已存在或段/列表结构不完整时原文返回。返回 (new_text, added)。"""
795
- eol = "\r\n" if "\r\n" in text else "\n"
796
- lines = text.splitlines()
797
- start, end = _yaml_span(lines, section)
798
- if start is None:
799
- return text, False
800
- m_idx = m_indent = None
801
- for i in range(start + 1, end):
802
- m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
803
- if m:
804
- m_idx, m_indent = i, len(m.group(1))
805
- break
806
- if m_idx is None:
807
- return text, False # 段内没有 models 键:不凭空造结构(保守)
808
- item_indent = None
809
- last_item = m_idx
810
- i = m_idx + 1
811
- while i < end:
812
- ln = lines[i]
813
- if not ln.strip():
814
- i += 1
815
- continue
816
- if len(ln) - len(ln.lstrip(" ")) <= m_indent:
817
- break # models 列表结束
818
- m = re.match(r"^(\s*)-\s+id\s*:\s*(.+?)\s*$", ln)
819
- if m:
820
- item_indent = m.group(1)
821
- last_item = i
822
- if _yaml_unquote(m.group(2)) == model:
823
- return text, False
824
- elif re.match(r"^\s+\S", ln):
825
- last_item = i # 条目的续属性行(name/contextWindow…)
826
- i += 1
827
- item_indent = item_indent or (" " * (m_indent + 2))
828
- block = ["%s- id: %s" % (item_indent, _yaml_quote(model)),
829
- "%s name: %s" % (item_indent, _yaml_quote(model)),
830
- "%s contextWindow: %d" % (item_indent, context_window)]
831
- lines[last_item + 1:last_item + 1] = block
832
- return eol.join(lines).rstrip("\r\n") + eol, True
833
-
834
-
835
- def _sync_dsh_settings(entry, model, base_url):
836
- """dsh 专属:把绑定模型的端点与模型目录写进 ~/.dsh/settings.yaml 的
837
- llm-deepseek 段(agent-default-model.model 由 write_model 负责)。
838
- 端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
839
- models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
840
- 返回错误串或 None。"""
841
- if tmp_data_no_home_write():
842
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
843
- path = _config_path(entry)
844
- if not path:
845
- return "dsh 配置路径无效"
846
- path = os.path.realpath(path)
847
- home = os.path.abspath(os.path.expanduser("~"))
848
- try:
849
- if os.path.commonpath([path, home]) != home:
850
- return "dsh 配置路径越出用户主目录,已拒绝"
851
- except ValueError:
852
- return "dsh 配置路径越出用户主目录,已拒绝"
853
- try:
854
- if os.path.isfile(path):
855
- text = _read_text(path, preserve_newlines=True)
856
- else:
857
- Path(path).parent.mkdir(parents=True, exist_ok=True)
858
- text = ""
859
- text2 = _yaml_write_value(text, "llm-deepseek", "baseURL", base_url)
860
- text2, _added = _yaml_ensure_model_entry(text2, "llm-deepseek", model)
861
- if text2 != text:
862
- if os.path.isfile(path):
863
- shutil.copyfile(path, path + ".bak")
864
- _atomic_write_bytes(path, text2.encode("utf-8"))
865
- return None
866
- except Exception as e:
867
- return repr(e)
868
-
869
-
870
- def _dsh_selfcheck_model(entry):
871
- """dsh 无绑定时自检:agent-default-model.model 必须在端点 models 列表内,
872
- 否则 web UI 打开就是一个选不中的模型(glm-5.3-flash vs V4 端点的实况)。
873
- 不在列表则改选列表第一个并写回。返回 (生效模型, note)。"""
874
- path = _config_path(entry)
875
- if not path or not os.path.isfile(path):
876
- return None, ""
877
- try:
878
- text = _read_text(path, preserve_newlines=True)
879
- except Exception:
880
- return None, ""
881
- cur = _yaml_read_value(text, "agent-default-model", "model")
882
- ids = _yaml_model_ids(text, "llm-deepseek")
883
- if not ids:
884
- return cur, "dsh 端点未登记任何模型,请先在 dsh 侧配置模型目录"
885
- if cur in ids:
886
- return cur, ""
887
- first = ids[0]
888
- w = write_model(entry, first)
889
- fixed = w.get("model") if w.get("ok") else None
890
- note = "dsh 默认模型 %s 不在端点模型列表,已改选 %s" % (cur or "(空)", first)
891
- if not fixed:
892
- note += "(写入失败:%s)" % w.get("error")
893
- return fixed, note
894
-
895
-
896
- def _dsh_key_present():
897
- """dsh 的密钥是否有着落:进程 env 或它自己的 ~/.dsh/.env(credentials-local)。"""
898
- if os.environ.get("DEEPSEEK_API_KEY"):
899
- return True
900
- try:
901
- envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
902
- return "DEEPSEEK_API_KEY" in _read_text(envfile)
903
- except Exception:
904
- return False
905
-
906
-
907
- def _toml_section_set(text, section, pairs):
908
- """就地写 TOML 段([section] 下多键)。段存在则逐键替换,缺则整段追加在文末。
909
- 只处理 codex config.toml 这种顶层简单段;返回 (new_text, changed)。"""
910
- eol = "\r\n" if "\r\n" in text else "\n"
911
- lines = text.splitlines()
912
- start = end = None
913
- header = "[%s]" % section
914
- for i, ln in enumerate(lines):
915
- if ln.strip() == header:
916
- start = i
917
- elif start is not None and ln.startswith("[") and ln.rstrip().endswith("]"):
918
- end = i
919
- break
920
- if start is None:
921
- block = [header] + ["%s = %s" % (k, v) for k, v in pairs]
922
- if lines and lines[-1].strip():
923
- lines.append("")
924
- lines.extend(block)
925
- return eol.join(lines).rstrip("\r\n") + eol, True
926
- end = end if end is not None else len(lines)
927
- changed = False
928
- todo = dict(pairs)
929
- for i in range(start + 1, end):
930
- m = re.match(r"^(\s*)([A-Za-z0-9_.-]+)\s*=", lines[i])
931
- if m and m.group(2) in todo:
932
- lines[i] = "%s%s = %s" % (m.group(1), m.group(2), todo.pop(m.group(2)))
933
- changed = True
934
- if todo:
935
- ins = start + 1
936
- while ins < end and not lines[ins].strip():
937
- ins += 1
938
- for k, v in list(todo.items())[::-1]:
939
- lines.insert(ins, "%s = %s" % (k, v))
940
- changed = True
941
- return eol.join(lines).rstrip("\r\n") + eol, changed
942
-
943
-
944
- def _toml_top_set(text, key, value):
945
- """写 TOML 顶层键(第一个 [段] 之前的区域)。返回 (new_text, changed)。"""
946
- eol = "\r\n" if "\r\n" in text else "\n"
947
- lines = text.splitlines()
948
- pat = re.compile(r"^(\s*)%s\s*=\s*.+$" % re.escape(key))
949
- for i, ln in enumerate(lines):
950
- if pat.match(ln):
951
- lines[i] = "%s%s = %s" % (pat.match(ln).group(1), key, value)
952
- return eol.join(lines).rstrip("\r\n") + eol, True
953
- first_section = next((i for i, ln in enumerate(lines)
954
- if ln.startswith("[") and ln.rstrip().endswith("]")), len(lines))
955
- lines.insert(first_section, "%s = %s" % (key, value))
956
- return eol.join(lines).rstrip("\r\n") + eol, True
957
-
958
-
959
- _TEST_HOST_SUFFIXES = (".test", ".example", ".invalid", ".localhost")
960
-
961
-
962
- def _is_dead_endpoint(base):
963
- """明显打不通的测试/保留地址(*.test、*.example、example.com 等)。
964
- 这类端点落进 config.toml 后 codex 每次请求都撞死墙——2026-09-20
965
- orch/p1.test 残留反复劫持全局配置的事故教训:宁可不写,不写必死端点。"""
966
- host = (base or "").split("://", 1)[-1].split("/", 1)[0].split("@")[-1]
967
- host = host.split(":")[0].lower().rstrip(".")
968
- if not host:
969
- return True
970
- return (host in ("example.com", "example.org", "example.net", "localhost")
971
- or host.endswith(_TEST_HOST_SUFFIXES))
972
-
973
-
974
- def _sync_codex_settings(entry, model, cp):
975
- """codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
976
- ([model_providers.orch] 段 + 顶层 model_provider/model)。
977
-
978
- codex 交互 TUI 不认编排的 -c 一次性覆盖,也不认 ORCH_API_KEY env——
979
- 没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
980
- 不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
981
- _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
982
- if _is_dead_endpoint(cp.get("base_url")):
983
- return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
984
- if tmp_data_no_home_write():
985
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
986
- path = _config_path(entry)
987
- if not path:
988
- return "codex 配置路径无效"
989
- path = os.path.realpath(path)
990
- home = os.path.abspath(os.path.expanduser("~"))
991
- try:
992
- if os.path.commonpath([path, home]) != home:
993
- return "codex 配置路径越出用户主目录,已拒绝"
994
- except ValueError:
995
- return "codex 配置路径越出用户主目录,已拒绝"
996
- name = cp.get("name", "orch")
997
- def q(v):
998
- return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
999
- if (cp.get("wire_api") or "responses") == "chat":
1000
- # codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
1001
- # 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
1002
- return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
1003
- " config.toml——请为 codex 绑定 responses 兼容的供应商")
1004
- pairs = [("name", q(cp.get("name", name))),
1005
- ("base_url", q(cp.get("base_url", ""))),
1006
- ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
1007
- ("wire_api", q(cp.get("wire_api", "responses")))]
1008
- try:
1009
- if os.path.isfile(path):
1010
- text = _read_text(path, preserve_newlines=True)
1011
- else:
1012
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1013
- text = ""
1014
- text2, _ = _toml_section_set(text, "model_providers.%s" % name, pairs)
1015
- text2, _ = _toml_top_set(text2, "model_provider", q(name))
1016
- if model:
1017
- text2, _ = _toml_top_set(text2, "model", q(model))
1018
- if text2 != text:
1019
- if os.path.isfile(path):
1020
- shutil.copyfile(path, path + ".bak")
1021
- _atomic_write_bytes(path, text2.encode("utf-8"))
1022
- return None
1023
- except Exception as e:
1024
- return repr(e)
1025
-
1026
-
1027
- # ---------------------------------------------------------------- 打开前凭据注入
1028
-
1029
- def _jsonc_scan_object(text, start):
1030
- """扫描 text[start](须为 '{')起的 JSON(C) 对象:返回 (闭合偏移, 键表)。
1031
- 键表为 [key, key_start, key_end, value_start, value_end](偏移相对整个 text,
1032
- value_end 指向值结束后一格)。跳过字符串转义、// 与 /* */ 注释、嵌套括号,
1033
- 未闭合时容错返回文末。"""
1034
- n = len(text)
1035
- i = start + 1
1036
- keys = []
1037
- state = "key"
1038
- depth = 0
1039
-
1040
- def skip_string(j):
1041
- while j < n:
1042
- if text[j] == "\\":
1043
- j += 2
1044
- continue
1045
- if text[j] == '"':
1046
- return j + 1
1047
- j += 1
1048
- return n
1049
-
1050
- def skip_comment(j):
1051
- if j + 1 < n and text[j + 1] == "/":
1052
- e = text.find("\n", j)
1053
- return n if e < 0 else e
1054
- if j + 1 < n and text[j + 1] == "*":
1055
- e = text.find("*/", j + 2)
1056
- return n if e < 0 else e + 2
1057
- return j + 1 # 非注释的孤立斜杠:当普通字符
1058
-
1059
- while i < n:
1060
- ch = text[i]
1061
- if ch in " \t\r\n":
1062
- i += 1
1063
- continue
1064
- if ch == "/":
1065
- i = skip_comment(i)
1066
- continue
1067
- if state == "key":
1068
- if ch == "}":
1069
- return i, keys
1070
- if ch == ",":
1071
- i += 1
1072
- continue
1073
- if ch == '"':
1074
- j = skip_string(i + 1)
1075
- keys.append([text[i + 1:j - 1], i, j, 0, 0])
1076
- state = "colon"
1077
- i = j
1078
- continue
1079
- i += 1
1080
- continue
1081
- if state == "colon":
1082
- if ch == ":":
1083
- state = "value"
1084
- i += 1
1085
- continue
1086
- # state == value
1087
- if not keys:
1088
- return n, keys # 结构异常:放弃扫描
1089
- keys[-1][3] = i
1090
- if ch in "{[":
1091
- depth = 0
1092
- j = i
1093
- while j < n:
1094
- c = text[j]
1095
- if c == '"':
1096
- j = skip_string(j + 1)
1097
- continue
1098
- if c == "/":
1099
- j = skip_comment(j)
1100
- continue
1101
- if c in "{[":
1102
- depth += 1
1103
- elif c in "}]":
1104
- depth -= 1
1105
- if depth == 0:
1106
- break
1107
- j += 1
1108
- keys[-1][4] = j + 1
1109
- i = j + 1
1110
- state = "key"
1111
- elif ch == '"':
1112
- j = skip_string(i + 1)
1113
- keys[-1][4] = j
1114
- i = j
1115
- state = "key"
1116
- else: # 数字 / true / false / null(或值后直接撞上 , } 换行的容错)
1117
- j = i
1118
- while j < n and text[j] not in ",}\r\n":
1119
- j += 1
1120
- keys[-1][4] = j
1121
- # j==i 说明值位置直接是分隔符/换行(空值或状态残留)——必须前进一格,
1122
- # 否则 while i < n 永远停在原地(死循环)
1123
- i = j if j > i else j + 1
1124
- state = "key"
1125
- return n, keys
1126
-
1127
-
1128
- def _jsonc_strip_comments(text):
1129
- """剥掉 jsonc 的 // 行注释与 /* */ 块注释,供 json.loads 解析。
1130
- 字符串字面量内部的 //($schema 的 https:// 等)不剥——逐字符扫描;
1131
- 简单的 re.sub(r"//[^\\n]*") 会把 URL 截断导致解析失败。"""
1132
- out = []
1133
- i, n = 0, len(text)
1134
- while i < n:
1135
- ch = text[i]
1136
- if ch == '"':
1137
- j = i + 1
1138
- while j < n:
1139
- if text[j] == "\\":
1140
- j += 2
1141
- continue
1142
- if text[j] == '"':
1143
- break
1144
- j += 1
1145
- out.append(text[i:min(j + 1, n)])
1146
- i = j + 1
1147
- continue
1148
- if ch == "/" and i + 1 < n and text[i + 1] == "/":
1149
- e = text.find("\n", i)
1150
- i = n if e < 0 else e # 行注释:换行符本身保留
1151
- continue
1152
- if ch == "/" and i + 1 < n and text[i + 1] == "*":
1153
- e = text.find("*/", i + 2)
1154
- i = n if e < 0 else e + 2
1155
- out.append(" ")
1156
- continue
1157
- out.append(ch)
1158
- i += 1
1159
- return "".join(out)
1160
-
1161
-
1162
- def _jsonc_set(text, path, value_json):
1163
- """JSON(C) 顶层就地写键:path=("provider","orch") 或 ("model",)。
1164
- 只动目标片段,其余文本(含注释与原格式)原样保留。返回 (new_text, ok)。
1165
- 空文件按空对象起笔(write_model 缺文件按需创建的产物)。"""
1166
- brace = text.find("{")
1167
- if brace < 0 and not text.strip():
1168
- return ('{\n "%s": %s\n}' % (path[0], value_json), True)
1169
- if brace < 0:
1170
- return text, False
1171
- end, keys = _jsonc_scan_object(text, brace)
1172
- head = path[0]
1173
- if len(path) == 1:
1174
- for _k, _ks, _ke, vs, ve in keys:
1175
- if _k == head:
1176
- return text[:vs] + value_json + text[ve:], True
1177
- return _jsonc_insert_entry(text, brace, end, head, value_json), True
1178
- # 二级路径:先定位一级键的值对象
1179
- tgt = None
1180
- for k, _ks, _ke, vs, ve in keys:
1181
- if k == head:
1182
- tgt = (vs, ve)
1183
- break
1184
- if tgt is None: # 一级键不存在:整体插入
1185
- block = json.dumps({path[1]: json.loads(value_json)}, ensure_ascii=False)
1186
- return _jsonc_insert_entry(text, brace, end, head, block), True
1187
- vs, ve = tgt
1188
- if text[vs:ve].lstrip()[0:1] != "{":
1189
- return text, False # 一级值不是对象:保守放弃(不覆盖用户的非标结构)
1190
- end2, keys2 = _jsonc_scan_object(text, vs)
1191
- for k, _ks, _ke, v2s, v2e in keys2:
1192
- if k == path[1]:
1193
- return text[:v2s] + value_json + text[v2e:], True
1194
- seg = text[vs:end2 + 1]
1195
- new_seg = _jsonc_insert_entry(seg, 0, end2 - vs, path[1], value_json)
1196
- if new_seg is None:
1197
- return text, False
1198
- return text[:vs] + new_seg + text[end2 + 1:], True
1199
-
1200
-
1201
- def _jsonc_insert_entry(text, brace, end, key, value_json):
1202
- """在 {brace..end} 对象的开头插入 "key": value(带尾逗号,不依赖原文件的
1203
- 逗号风格);对象为空时去掉多余逗号。"""
1204
- m = re.match(r"\{([ \t\r\n]*)", text[brace:end + 1])
1205
- first = brace + (m.end(1) if m else 1) # m 从 brace 起 match:end(1) 已含 '{'
1206
- if first >= end: # 空对象 {}
1207
- return text[:brace + 1] + ' "%s": %s ' % (key, value_json) + text[end:]
1208
- return text[:first] + '"%s": %s,\n ' % (key, value_json) + text[first:]
1209
-
1210
-
1211
- def _sync_settings_env(path, updates, remove_keys=()):
1212
- """把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
1213
- 启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
1214
- 不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
1215
- if tmp_data_no_home_write():
1216
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1217
- try:
1218
- if os.path.isfile(path):
1219
- text = _read_text(path)
1220
- try:
1221
- data = json.loads(text)
1222
- except Exception:
1223
- return "settings.json 不是合法 JSON,已中止(避免覆盖)"
1224
- else:
1225
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1226
- data = {}
1227
- if not isinstance(data, dict):
1228
- return "settings.json 结构异常(顶层不是对象),已中止"
1229
- env = data.get("env")
1230
- if not isinstance(env, dict):
1231
- env = {}
1232
- for k in remove_keys:
1233
- env.pop(k, None)
1234
- env.update(updates)
1235
- data["env"] = env
1236
- if os.path.isfile(path):
1237
- shutil.copyfile(path, path + ".bak")
1238
- _atomic_write_bytes(path, json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1239
- return None
1240
- except Exception as e:
1241
- return repr(e)
1242
-
1243
-
1244
- def _guard_home(path):
1245
- """展开并校验配置路径必须在用户主目录内;通过则返回 realpath,否则 None。"""
1246
- if not path:
1247
- return None
1248
- path = os.path.realpath(path)
1249
- home = os.path.abspath(os.path.expanduser("~"))
1250
- try:
1251
- if os.path.commonpath([path, home]) != home:
1252
- return None
1253
- except ValueError:
1254
- return None
1255
- return path
1256
-
1257
-
1258
- def _sync_claude_settings(entry, model, prov):
1259
- """claude-code 专属:把 anthropic 供应商的端点+密钥+模型写进
1260
- ~/.claude/settings.json 的 env 段(交互 TUI 与无头共用该文件,只认
1261
- ANTHROPIC_*;编排降级链给的 ORCH_API_KEY 对它等于没 key)。
1262
- 返回错误串或 None。"""
1263
- path = _guard_home(_config_path(entry))
1264
- if not path:
1265
- return "claude 配置路径无效或越出用户主目录,已拒绝"
1266
- updates = {"ANTHROPIC_BASE_URL": prov.get("base_url") or "",
1267
- # AUTH_TOKEN 走 Bearer 头(Z.ai 等原生 anthropic 网关的用法);
1268
- # 与 x-api-key 互斥,清掉可能残留的 ANTHROPIC_API_KEY 防止带错头
1269
- "ANTHROPIC_AUTH_TOKEN": prov.get("api_key") or ""}
1270
- if model:
1271
- updates["ANTHROPIC_MODEL"] = model
1272
- return _sync_settings_env(path, updates, remove_keys=("ANTHROPIC_API_KEY",))
1273
-
1274
-
1275
- def _sync_qwen_settings(entry, model, prov):
1276
- """qwencode 专属:openai 兼容供应商写进 ~/.qwen/settings.json 的 env 段
1277
- (qwen-code 实测认 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL,存在即
1278
- 优先走 openai 兼容通道——2026-09-15 真机对维云端点实测请求到达并鉴权)。
1279
- anthropic 协议未实证,不开(协议不匹配时宁可提示)。返回错误串或 None。"""
1280
- path = _guard_home(_config_path(entry))
1281
- if not path:
1282
- return "qwen 配置路径无效或越出用户主目录,已拒绝"
1283
- # 内闸防 anthropic 面混进 OPENAI_BASE_URL(2026-09-24 戍边骑奴案:史上
1284
- # 某版把 .../api/anthropic 写进去,qwen 拿 openai 协议打 anthropic 路由,
1285
- # 网关恒回 404 被误判「网关限流」,每轮 run 起点白烧 2-3 次重试)。
1286
- # auto 协议不拦——适配层探出的 openai 面由外层 launch_pick 负责把关。
1287
- if str(prov.get("protocol") or "").lower() == "anthropic":
1288
- return ("qwen 只认 OpenAI 兼容端点,拒绝把 anthropic 面 %s 写进 OPENAI_BASE_URL"
1289
- % (prov.get("base_url") or "?"))
1290
- updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
1291
- "OPENAI_BASE_URL": prov.get("base_url") or ""}
1292
- if model:
1293
- updates["OPENAI_MODEL"] = model
1294
- return _sync_settings_env(path, updates)
1295
-
1296
-
1297
- def _opencode_config_candidates(entry):
1298
- """opencode 配置的候选路径:catalog 登记的 jsonc 优先,其次同目录的
1299
- opencode.json(opencode 两种文件名都认,用户现有安装多用 json)。"""
1300
- out = []
1301
- primary = _config_path(entry)
1302
- if primary:
1303
- out.append(primary)
1304
- alt = os.path.join(os.path.abspath(os.path.expanduser("~/.config/opencode")),
1305
- "opencode.json")
1306
- if alt not in out:
1307
- out.append(alt)
1308
- return out
1309
-
1310
-
1311
- def _sync_mimo_settings(entry, model, prov):
1312
- """mimo(opencode 系)专属:把绑定供应商落进 mimocode.jsonc 的
1313
- provider.codebee 块。mimo 不读任何 env key(内嵌文档明示
1314
- MIMO_API_KEY is never read),唯一凭据通道是配置内 provider.options.apiKey
1315
- ——此前只有模型名提示,绑定后照样裸奔:2026-09-24 禅道双单实案,评审器
1316
- 连续 20+ 轮全灭在 Invalid API Key,而 Key 本身是好的。
1317
- 只吃 OpenAI 兼容端点;anthropic 协议供应商明确拒绝(不猜 wire)。"""
1318
- if tmp_data_no_home_write():
1319
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1320
- if str(prov.get("protocol") or "").lower() != "openai":
1321
- return ("mimo 只吃 OpenAI 兼容端点,供应商 %s 是 %s 协议——请绑 openai 协议供应商"
1322
- % (prov.get("name") or prov.get("id") or "?", prov.get("protocol") or "?"))
1323
- key = str(prov.get("api_key") or "")
1324
- if not key:
1325
- ks = prov.get("keys") or []
1326
- key = str((ks[0] or {}).get("key") or "") if ks else ""
1327
- base = str(prov.get("base_url") or "").rstrip("/")
1328
- if not key or not base:
1329
- return "供应商缺少 API 地址或密钥,无法写入 mimo 配置"
1330
- path = _config_path(entry)
1331
- if not path:
1332
- return "mimo 配置路径无效"
1333
- try:
1334
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1335
- text = ""
1336
- if os.path.isfile(path):
1337
- text = _read_text(path, preserve_newlines=True)
1338
- shutil.copyfile(path, path + ".bak")
1339
- block = {"npm": "@ai-sdk/openai-compatible",
1340
- "options": {"baseURL": base, "apiKey": key},
1341
- "models": {model: {}}}
1342
- new_text, ok = _jsonc_set(text, ("provider", "codebee"),
1343
- json.dumps(block, ensure_ascii=False))
1344
- if not ok:
1345
- return "mimocode.jsonc 结构异常,未能写入 provider 块(已避免覆盖)"
1346
- new_text, ok = _jsonc_set(new_text, ("model",),
1347
- json.dumps("codebee/" + model))
1348
- if not ok:
1349
- return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
1350
- _atomic_write_bytes(path, new_text.encode("utf-8"))
1351
- return None
1352
- except OSError as e:
1353
- return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
1354
-
1355
-
1356
- def _sync_opencode_settings(entry, model, prov):
1357
- """opencode 专属:把绑定供应商写进其配置的 provider.orch 段 + 顶层 model
1358
- (opencode 交互 TUI 只认自家配置文件里的凭据,ORCH_API_KEY env 对它等于
1359
- 没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
1360
- 全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1361
- 文本级就地 patch(保留注释)。返回错误串或 None。"""
1362
- if tmp_data_no_home_write():
1363
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1364
- npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1365
- base = prov.get("base_url") or ""
1366
- if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
1367
- # @ai-sdk/anthropic 在 baseURL 后只拼 /messages(官方默认 baseURL 本身带
1368
- # /v1),而 models.json 里 anthropic 供应商的 base 不带 /v1(modelhub
1369
- # 发请求时自己补)——不补会打到 <host>/messages,网关回 "Not Allowed"
1370
- # (2026-09-17 公司Anthropic 实测)。
1371
- base = base.rstrip("/") + "/v1"
1372
- block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
1373
- "options": {"baseURL": base,
1374
- "apiKey": prov.get("api_key") or ""},
1375
- "models": {model: {"name": model}} if model else {}}
1376
- top_perms = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
1377
- top_model = ("orch/" + model) if model else ""
1378
- targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
1379
- or [_opencode_config_candidates(entry)[0]]
1380
- errs = []
1381
- for path in targets:
1382
- try:
1383
- path = os.path.realpath(path)
1384
- home = os.path.abspath(os.path.expanduser("~"))
1385
- try:
1386
- if os.path.commonpath([path, home]) != home:
1387
- errs.append("路径越出主目录:%s" % path)
1388
- continue
1389
- except ValueError:
1390
- errs.append("路径越出主目录:%s" % path)
1391
- continue
1392
- if os.path.isfile(path):
1393
- text = _read_text(path, preserve_newlines=True)
1394
- else:
1395
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1396
- text = ""
1397
- try:
1398
- data = json.loads(text) if text.strip() else {}
1399
- except Exception:
1400
- data = None
1401
- if data is not None: # 纯 JSON:整体读改写(保序)
1402
- if not isinstance(data, dict):
1403
- errs.append("结构异常(顶层不是对象):%s" % path)
1404
- continue
1405
- provs = data.get("provider")
1406
- if not isinstance(provs, dict):
1407
- provs = {}
1408
- provs["orch"] = block
1409
- data["provider"] = provs
1410
- if top_model:
1411
- data["model"] = top_model
1412
- # 无人值守必配:headless 下 opencode 工具调用默认要审批,全部被
1413
- # 拒("The user rejected permission...",2026-09-17 实测)——
1414
- # 按用户拍板的「默认给全部权限」写入放行段
1415
- data["permission"] = top_perms
1416
- new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1417
- else: # JSONC(带注释):文本级 patch
1418
- block_json = json.dumps(block, ensure_ascii=False)
1419
- new_text, ok = _jsonc_set(text, ("provider", "orch"), block_json)
1420
- if not ok:
1421
- errs.append("JSONC 就地改写失败:%s" % path)
1422
- continue
1423
- if top_model:
1424
- new_text, _ = _jsonc_set(new_text, ("model",),
1425
- json.dumps(top_model))
1426
- new_text, _ = _jsonc_set(new_text, ("permission",),
1427
- json.dumps(top_perms))
1428
- if os.path.isfile(path):
1429
- shutil.copyfile(path, path + ".bak")
1430
- _atomic_write_bytes(path, new_text.encode("utf-8"))
1431
- except Exception as e:
1432
- errs.append("%s: %r" % (path, e))
1433
- return ";".join(errs) or None
1434
-
1435
-
1436
- def _kimi_render(prov, model):
1437
- """渲染 kimi-code 的 CodeBee 托管块(顶层键在前,表在后——TOML 语义)。"""
1438
- import re as _re
1439
- ptype = "anthropic" if prov.get("protocol") == "anthropic" else "openai"
1440
- base = prov.get("base_url") or ""
1441
- alias = model or "default"
1442
- pname = (prov.get("name") or "CodeBee").replace("\"", "")
1443
- return (
1444
- "# >>> CodeBee managed (do not edit between markers) >>>\n"
1445
- "defaultProvider = \"orch\"\n"
1446
- "defaultModel = \"%s\"\n"
1447
- "yolo = true\n"
1448
- "defaultPermissionMode = \"yolo\"\n"
1449
- "\n"
1450
- "[providers.orch]\n"
1451
- "type = \"%s\"\n"
1452
- "name = \"%s\"\n"
1453
- "baseUrl = \"%s\"\n"
1454
- "apiKey = \"%s\"\n"
1455
- "\n"
1456
- "[models.\"%s\"]\n"
1457
- "provider = \"orch\"\n"
1458
- "model = \"%s\"\n"
1459
- "maxContextSize = 131072\n"
1460
- "displayName = \"%s · %s\"\n"
1461
- "# <<< CodeBee managed <<<\n"
1462
- % (alias, ptype, pname, base,
1463
- (prov.get("api_key") or "").replace("\"", ""),
1464
- alias, alias, pname, alias))
1465
-
1466
-
1467
- def _sync_kimi_settings(entry, model, prov):
1468
- """kimi-code 专属:把绑定供应商写进 ~/.kimi-code/config.toml。
1469
-
1470
- schema 从官方 bundle 反推(2026-09-17):顶层 defaultProvider/defaultModel/
1471
- yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
1472
- baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
1473
- 要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
1474
- 之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
1475
- if tmp_data_no_home_write():
1476
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1477
- import re as _re
1478
- top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
1479
- targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
1480
- errs = []
1481
- for path in targets:
1482
- try:
1483
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1484
- text = ""
1485
- if os.path.isfile(path):
1486
- text = _read_text(path, preserve_newlines=True)
1487
- shutil.copyfile(path, path + ".bak")
1488
- # 移除旧托管块与旧顶层键(防重复/防键落进别的表)
1489
- text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
1490
- "", text, flags=_re.S)
1491
- for k in top_keys:
1492
- text = _re.sub(r"(?m)^%s\s*=.*$\n?" % k, "", text)
1493
- body = _re.sub(r"\n+$", "\n", text).lstrip("\n")
1494
- managed = _kimi_render(prov, model)
1495
- has_table = _re.search(r"(?m)^\[", body) is not None
1496
- top = "".join("%s\n" % ln for ln in managed.split("\n")
1497
- if _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1498
- tables = "\n".join(ln for ln in managed.split("\n")
1499
- if not _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1500
- if has_table:
1501
- new_text = top + "\n" + body + "\n" + tables
1502
- else:
1503
- new_text = (body + "\n" if body else "") + managed
1504
- _atomic_write_bytes(path, new_text.encode("utf-8"))
1505
- except Exception as e:
1506
- errs.append("%s: %r" % (path, e))
1507
- return ";".join(errs) or None
1508
-
1509
-
1510
- # pi 的自定义供应商注册表与 settings.json 同目录(pi 的 getModelsPath()),
1511
- # 供应商名固定为一个托管位,绑定怎么换都只重写这一块。
1512
- _PI_PROVIDER = "codebee"
1513
- _PI_API_BY_PROTOCOL = {"anthropic": "anthropic-messages", "openai": "openai-completions"}
1514
- # 各协议下「编排注入的那把密钥」env 名:models.json 用 $VAR 引用取值,
1515
- # 配置文件里不落明文(与 dsh 走 DEEPSEEK_API_KEY 同一原则)
1516
- _PI_KEY_ENV = {"anthropic": ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"),
1517
- "openai": ("ORCH_API_KEY", "OPENAI_API_KEY")}
1518
-
1519
-
1520
- def _pi_models_path(entry):
1521
- """由 catalog 里 settings.json 的路径推导同目录 models.json。
1522
-
1523
- 不另存一份绝对路径:pi 用同一个 getAgentDir() 取这两件,硬编码第二份
1524
- 路径会在用户改 PI_DIR/换平台时与实际脱节。"""
1525
- p = _config_path(entry)
1526
- return os.path.join(os.path.dirname(p), "models.json") if p else None
1527
-
1528
-
1529
- def _pi_write_json(path, mutate):
1530
- """读—改—写一件 JSON 配置;非法 JSON 或顶层非对象一律中止不覆盖。
1531
- 原文件先落 .bak 再写(与其余 CLI 同步同一套)。返回错误串或 None。"""
1532
- text = ""
1533
- if os.path.isfile(path):
1534
- text = _read_text(path, preserve_newlines=True)
1535
- try:
1536
- data = json.loads(text) if text.strip() else {}
1537
- except Exception:
1538
- return "%s 不是合法 JSON,已中止(避免覆盖)" % os.path.basename(path)
1539
- if not isinstance(data, dict):
1540
- return "%s 结构异常(顶层不是对象),已中止" % os.path.basename(path)
1541
- mutate(data)
1542
- if os.path.isfile(path):
1543
- shutil.copyfile(path, path + ".bak")
1544
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1545
- _atomic_write_bytes(path,
1546
- json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1547
- return None
1548
-
1549
-
1550
- def _pi_merge_models(data, block):
1551
- provs = data.get("providers")
1552
- if not isinstance(provs, dict):
1553
- provs = {}
1554
- old = provs.get(_PI_PROVIDER)
1555
- merged = dict(old) if isinstance(old, dict) else {}
1556
- merged.update(block) # 端点/协议/密钥引用/模型由托管段定,用户自加的 compat 等保留
1557
- provs[_PI_PROVIDER] = merged
1558
- data["providers"] = provs
1559
-
1560
-
1561
- def _pi_merge_settings(data, model):
1562
- data["defaultProvider"] = _PI_PROVIDER
1563
- data["defaultModel"] = model
1564
-
1565
-
1566
- def _sync_pi_settings(entry, model, prov, env=None):
1567
- """pi 专属注入:把绑定供应商写进 ~/.pi/agent/models.json 的 providers.codebee,
1568
- 再把 settings.json 的 defaultProvider/defaultModel 指过去。
1569
-
1570
- pi 解析启动模型要 (defaultProvider, defaultModel) 二元组命中 models.json 注册表、
1571
- 且该供应商有凭据,否则回落到「已知供应商的默认模型」——2026-09-24 pi 19 连败、
1572
- 零次真实成功即卡在这:这边只写 defaultModel、defaultProvider 留空,pi 便拿内置
1573
- anthropic 的 claude-opus-4-8 去打注入的 Bigmodel 端点,被上游一句
1574
- `403 {"error":{"type":"forbidden","message":"Request not allowed"}}` 挡回。
1575
- anthropic 面额外置 authHeader(Bearer 头),与 claude-code 走同一把
1576
- ANTHROPIC_AUTH_TOKEN 的既有实证保持一致。返回错误串或 None。"""
1577
- if tmp_data_no_home_write():
1578
- return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1579
- proto = str(prov.get("protocol") or "").lower()
1580
- api = _PI_API_BY_PROTOCOL.get(proto)
1581
- env = env or {}
1582
- if not model or not api:
1583
- return ("pi 只认 anthropic / openai 协议供应商(当前 %s)——"
1584
- "请在「模型调度(可选)」页为 Pi 绑一个协议明确、带模型的供应商"
1585
- % (proto or "未指定协议"))
1586
- key_env = next((k for k in _PI_KEY_ENV[proto] if env.get(k)), "")
1587
- base = ""
1588
- if proto == "anthropic":
1589
- base = str(env.get("ANTHROPIC_BASE_URL") or prov.get("base_url") or "")
1590
- else:
1591
- base = str(env.get("OPENAI_API_BASE") or prov.get("base_url") or "")
1592
- base = base.rstrip("/")
1593
- if not base or not key_env:
1594
- return "pi 注入缺少可用端点或密钥环境变量,未写入配置"
1595
- block = {"baseUrl": base, "api": api, "apiKey": "$" + key_env,
1596
- "models": [{"id": model}]}
1597
- if proto == "anthropic":
1598
- block["authHeader"] = True
1599
- settings_path = _config_path(entry)
1600
- models_path = _pi_models_path(entry)
1601
- if not settings_path or not models_path:
1602
- return "pi 配置路径无效或越出用户主目录,已拒绝"
1603
- for path, mutate in ((models_path, lambda d: _pi_merge_models(d, block)),
1604
- (settings_path, lambda d: _pi_merge_settings(d, model))):
1605
- err = _pi_write_json(path, mutate)
1606
- if err:
1607
- return err
1608
- return None
1609
-
1610
-
1611
- def _claude_sync_enabled():
1612
- """「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
1613
- 关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
1614
- 防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
1615
- try:
1616
- from . import settings
1617
- return settings.load().get("claude_config_sync", True)
1618
- except Exception:
1619
- return True
1620
-
1621
-
1622
- def sync_cli_config_now(agent_id):
1623
- """运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
1624
- 绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
1625
- try:
1626
- if agent_id == "claude-code" and not _claude_sync_enabled():
1627
- return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
1628
- from . import modelhub
1629
- entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
1630
- if not entry:
1631
- return ""
1632
- b = modelhub.bindings().get(agent_id) or {}
1633
- note = _sync_agent_injection(entry, b)
1634
- if note:
1635
- return "已自动注入 %s 配置:%s" % (agent_id, note)
1636
- return ""
1637
- except Exception as e:
1638
- return "自动注入失败: %r" % e
1639
-
1640
-
1641
- # 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
1642
- # 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
1643
- _AGENT_INJECTORS = {
1644
- "claude-code": (("anthropic",), _sync_claude_settings),
1645
- "opencode": (("anthropic", "openai"), _sync_opencode_settings),
1646
- "qwencode": (("openai",), _sync_qwen_settings),
1647
- "kimi-code": (("openai", "anthropic"), _sync_kimi_settings),
1648
- "mimo-code": (("openai",), _sync_mimo_settings),
1649
- }
1650
-
1651
- # 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
1652
- # (mimo 已实证配置注入:provider.codebee 块(apiKey 走 options);grok 吃 XAI_API_KEY 但
1653
- # 无端点 env 可指中转,openai 协议供应商也用不上)
1654
- _NO_CHANNEL_HINT = ("grok-build",) # mimo 已有 provider 块注入(2026-09-24);
1655
- # pi 已有 providers.codebee 注入(2026-09-24 403 案)
1656
-
1657
-
1658
- def _sync_agent_injection(entry, binding):
1659
- """打开前把绑定链里第一个可注入供应商落进 CLI 自家配置。与编排降级链解耦:
1660
- 协议不匹配不降级(claude 拿 openai 的 key 等于没 key)。返回给用户看的提示
1661
- (成功注入 / 不可用原因),None=该 CLI 无需处理。"""
1662
- from . import modelhub
1663
- spec = _AGENT_INJECTORS.get(entry["id"])
1664
- if spec:
1665
- protocols, injector = spec
1666
- if entry["id"] == "claude-code" and not _claude_sync_enabled():
1667
- return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
1668
- pick, note = modelhub.launch_pick(entry["id"], protocols)
1669
- if pick:
1670
- prov = pick["provider"]
1671
- err = injector(entry, pick["model"], prov)
1672
- if err:
1673
- return "%s 凭据同步失败:%s(打开后可能需在其自带界面登录)" % (
1674
- entry.get("name", entry["id"]), err)
1675
- return "已注入 %s(%s · %s)" % (prov.get("name") or "供应商",
1676
- prov.get("protocol"), prov.get("base_url", ""))
1677
- return note
1678
- if entry["id"] in _NO_CHANNEL_HINT:
1679
- if binding.get("env"):
1680
- return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
1681
- return "未指定可用供应商:打开后使用 CLI 自带登录;需要固定注入请到「模型调度(可选)」页指定"
1682
- return None
1683
-
1684
-
1685
- def _sync_launch_model(entry, binding):
1686
- """打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
1687
- 交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
1688
- 「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
1689
- codex 已改为不落盘(2026-09-21 起:全局 model_provider 归 CC Switch/
1690
- 用户管理,编排任务的绑定走 runner 的 -c 一次性注入)。返回给用户
1691
- 看的同步笔记列表。
1692
-
1693
- 笔记契约:失败项必须含「失败」字样(或英文 failed)——
1694
- sync_runtime_config 据此判定同步失败并触发 ENV_BLOCK 预检,
1695
- 新增失败路径不得改用其他措辞。"""
1696
- notes = []
1697
- model = (binding.get("model") or "").strip()
1698
- prov = binding.get("provider") or {}
1699
- fmt = (entry.get("config") or {}).get("format")
1700
- is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1701
- is_codex = entry["id"] in ("codex-cli", "codex")
1702
- cp = binding.get("codex_provider")
1703
- if is_codex:
1704
- # codex 全局配置不落盘(2026-09-21 用户决策):全局 model_provider 交还
1705
- # CC Switch/用户手动管理,一键打开不再顶掉外部选择。编排任务不受影响
1706
- # ——runner 的 -c 一次性注入仍带绑定供应商;交互 TUI 打开即用外部当前
1707
- # 配置,想用绑定供应商起编排任务即可。
1708
- notes.append("codex 配置保持外部管理(绑定 %s 仅用于编排 -c 注入,未写 config.toml)"
1709
- % (cp.get("name") or "orch"))
1710
- return notes
1711
- if model and fmt in _WRITABLE_FORMATS:
1712
- w = write_model(entry, model)
1713
- notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
1714
- else "模型同步失败:%s" % w.get("error"))
1715
- elif model and not is_dsh and entry["id"] not in _AGENT_INJECTORS:
1716
- # 有专属注入通道的 CLI 由 _sync_agent_injection 负责落模型(含 jsonc),
1717
- # 不再报「只读配置」以免与注入成功的提示互相矛盾
1718
- notes.append("该工具模型为只读配置,按其现有配置打开(绑定模型 %s 未自动写入)" % model)
1719
- if is_dsh and model and prov.get("base_url"):
1720
- err = _sync_dsh_settings(entry, model, prov["base_url"])
1721
- notes.append("dsh 端点已同步为 %s" % prov["base_url"] if not err
1722
- else "dsh 端点同步失败:%s" % err)
1723
- elif is_dsh and not model:
1724
- _fixed, note = _dsh_selfcheck_model(entry)
1725
- if note:
1726
- notes.append(note)
1727
- if is_codex and cp:
1728
- err = _sync_codex_settings(entry, model, cp)
1729
- notes.append("codex 供应商已同步为 %s" % cp.get("base_url", "") if not err
1730
- else "codex 供应商同步失败:%s" % err)
1731
- if entry["id"] == "pi" and model and prov:
1732
- # pi 只认 settings.json 的 defaultModel 配不上供应商:端点与模型注册表在
1733
- # models.json,两件必须同源一起写(binding 解析结果就是编排注入 env 的
1734
- # 那一条,故不走 launch_pick,避免配置与 env 各说各话)
1735
- err = _sync_pi_settings(entry, model, prov, binding.get("env") or {})
1736
- notes.append("Pi 供应商已注入 providers.%s · %s" % (_PI_PROVIDER, model)
1737
- if not err else "Pi 配置注入失败:%s" % err)
1738
- inj = _sync_agent_injection(entry, binding)
1739
- if inj:
1740
- notes.append(inj)
1741
- return notes
1742
-
1743
-
1744
- # kind(runner 调用族)→ catalog 条目 id:flow/编排智能体的 id 常是业务名
1745
- # (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
1746
- _KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
1747
- "qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
1748
- _RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
1749
-
1750
-
1751
- def _runtime_cfg_hash(entry):
1752
- """CLI 自家配置文件的内容指纹;文件不存在返回 None。
1753
-
1754
- pi 的绑定分两件落盘(settings.json 选模型 + models.json 定义供应商与端点),
1755
- 只哈希 settings.json 会让 models.json 被外部毒写后逃过重写——kimi 毒配置同族。"""
1756
- paths = [_safe_config_path((entry.get("config") or {}).get("path"))]
1757
- if entry.get("id") == "pi":
1758
- paths.append(_pi_models_path(entry))
1759
- digests = []
1760
- for p in paths:
1761
- try:
1762
- if p and os.path.isfile(p):
1763
- with open(p, "rb") as fh:
1764
- digests.append(hashlib.sha256(fh.read()).hexdigest())
1765
- except Exception:
1766
- pass
1767
- return "|".join(digests) if digests else None
1768
-
1769
-
1770
- def sync_runtime_config(agent):
1771
- """运行前防线:把「CLI 绑定」页当前链首落进该 CLI 自家配置——与一键打开
1772
- 同一套 _sync_launch_model 写盘,不改任何语义。
1773
-
1774
- 绑定换供应商后 CLI 自家配置不会自愈,而同步过去只发生在一键打开:
1775
- 2026-09-18 opencode 钉死讯飞 404 旧端点案——绑定早切到云知声,起跑撞旧
1776
- 配置 2 秒 UnknownError,3 次续跑全灭。bind_agent 入口调用本函数即全覆盖
1777
- (死链补位也在覆盖内:_dead_binding_substitute 内部同样走 bind_agent)。
1778
-
1779
- 指纹缓存:同绑定(模型/供应商/端点/KEY/codex 段)只写一次盘,步骤级高频
1780
- 调用近零成本,也不与用户手工编辑配置打架(绑定没变就不动);无绑定或
1781
- 条目无配置段直接返回(测试空转,绝不碰真实家目录)。返回 ``True`` 表示
1782
- 已完成或无需同步,``False`` 表示同步失败;调用方必须把失败作为 ENV_BLOCK
1783
- 收口,不能让旧配置继续执行。"""
1784
- try:
1785
- cand = [agent.get("id"), _KIND_ENTRY.get(agent.get("kind") or "")]
1786
- entry = None
1787
- for x in cand:
1788
- if x:
1789
- entry = catalog.by_id(x)
1790
- if entry:
1791
- break
1792
- if entry is None or not (entry.get("config") or {}).get("path"):
1793
- return True
1794
- if entry["id"] in ("codex-cli", "codex"):
1795
- return True # codex 绝不在运行防线上落盘(2026-09-20 orch 劫持事故):
1796
- # 编排步骤走 runner 的 -c 一次性注入,不依赖 config.toml;
1797
- # 在这里写盘会把全局 ~/.codex/config.toml 的 model_provider
1798
- # 顶掉(CC Switch / 用户手动选的供应商被劫持)。交互 TUI
1799
- # 场景由 launch() 自行同步,且端点防线在 _sync_codex_settings。
1800
- from . import modelhub
1801
- binding = modelhub.resolve_binding(entry["id"]) or {}
1802
- model = (binding.get("model") or "").strip()
1803
- env = binding.get("env") or {}
1804
- if not model and not env:
1805
- return True # 没配链或链全死:没有可落的目标,绝不动用户配置
1806
- # 指纹取解析结果整体:env 里带端点+KEY(provider 字典不含原始 KEY,
1807
- # 换 KEY 必须可见),codex 段单独入纹
1808
- fp = (model, repr(sorted(env.items())),
1809
- repr(binding.get("codex_provider") or "")[:300])
1810
- with _RUNTIME_SYNC["lock"]:
1811
- if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
1812
- and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
1813
- return True
1814
- # 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
1815
- # 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
1816
- # ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
1817
- # 命中跳过重写,kimi 带毒配置静默退出码 1
1818
- notes = _sync_launch_model(entry, binding)
1819
- # 测试隔离闸明确拒绝写真实用户配置;这是安全跳过,不是生产同步
1820
- # 失败。测试进程仍需把绑定 env 传给子进程,不能被误判成 ENV_BLOCK。
1821
- if any("测试数据目录" in str(note) and "拦截" in str(note)
1822
- for note in (notes or ())):
1823
- return True
1824
- failed = any("失败" in str(note) or "failed" in str(note).lower()
1825
- for note in (notes or ()))
1826
- if failed:
1827
- return False
1828
- _RUNTIME_SYNC["fps"][entry["id"]] = fp
1829
- _RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
1830
- return True
1831
- except Exception:
1832
- return False
1833
-
1834
-
1835
- def launch(entry, open_browser=True):
1836
- """一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
1837
-
1838
- 打开前把「CLI 绑定」页的模型落盘到该 CLI 配置文件(dsh 连端点与 models
1839
- 目录一起同步),并注入绑定密钥 env——保证打开即选中可用模型。"""
1840
- if not detect_entry(entry).get("installed"):
1841
- return {"ok": False, "error": "未安装,无法打开"}
1842
- launch = entry.get("launch") or {}
1843
- cmd = (launch.get("command") or "").strip()
1844
- if not cmd:
1845
- return {"ok": False, "error": "未配置打开命令(可在 data/catalog.json 补 launch 字段)"}
1846
- is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1847
- try:
1848
- from . import modelhub
1849
- binding = modelhub.resolve_binding(entry["id"]) or {}
1850
- except Exception:
1851
- binding = {}
1852
- env = os.environ.copy()
1853
- env.update(binding.get("env") or {})
1854
- notes = _sync_launch_model(entry, binding)
1855
- if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
1856
- notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
1857
- "要打开即用,请到「模型调度(可选)」页给 DeepSeek Harness 指定 openai 协议供应商")
1858
- name = entry.get("name", entry["id"])
1859
- kind = (launch.get("kind") or "console").lower()
1860
- extra = (";".join(notes)) if notes else ""
1861
-
1862
- if kind == "web":
1863
- try:
1864
- port = int(launch.get("port") or 0)
1865
- except (TypeError, ValueError):
1866
- port = 0
1867
- if port <= 0:
1868
- return {"ok": False, "error": "web 类打开必须配置固定端口(launch.port)"}
1869
- bare = "http://127.0.0.1:%d" % port
1870
- log_path = _launch_log_path(entry)
1871
- if not log_path:
1872
- return {"ok": False, "error": "启动日志路径不可信,已拒绝打开"}
1873
- if _port_open(port):
1874
- # 已在运行:新实例抢不到端口、新 token 也拿不到——从上次启动日志
1875
- # 恢复带 token 的信任 URL(dsh web 有 /?token=... 围栏,裸开是 401)。
1876
- # 模型同步照做:正在跑的实例不重启,改的是它下次生效的配置
1877
- url = _best_url(log_path, port) or bare
1878
- if open_browser:
1879
- webbrowser.open(url)
1880
- msg = "服务已在运行,已打开 " + url
1881
- if extra:
1882
- msg += "(" + extra + ")"
1883
- return {"ok": True, "kind": "web", "url": url, "message": msg}
1884
- # 后台起服务:无窗口、不阻塞请求;子进程输出重定向到启动日志(cmd 层
1885
- # 重定向,路径含空格才加引号)——web 类普遍会在启动行打印带 token 的
1886
- # 信任 URL(如 dsh web),就绪线程从日志提取后再开浏览器
1887
- ls = str(log_path)
1888
- spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1889
- try:
1890
- log_path.parent.mkdir(parents=True, exist_ok=True)
1891
- # 重定向语法 sh 与 cmd 通用,只换解释器;start_new_session 让子服务
1892
- # 脱离本服务进程组(杀树/退出互不牵连)
1893
- shell = ["cmd", "/c"] if os.name == "nt" else ["/bin/sh", "-c"]
1894
- subprocess.Popen(shell + [spawn_cmd], cwd=str(paths.ROOT), env=env,
1895
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1896
- stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
1897
- start_new_session=(os.name != "nt"))
1898
- except Exception as e:
1899
- return {"ok": False, "error": "无法启动服务: %r" % e}
1900
- if open_browser:
1901
- threading.Thread(target=_open_when_ready, args=(port, bare, log_path),
1902
- name="launch-wait-%d" % port, daemon=True).start()
1903
- return {"ok": True, "kind": "web", "url": bare,
1904
- "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1905
- % (name, bare, (";" + extra) if extra else "")}
1906
-
1907
- if sys.platform == "darwin":
1908
- # Terminal.app 新开窗口跑交互 TUI;do script 的命令串常驻窗口,等价
1909
- # Windows 的 cmd /k(CLI 退出后窗口保留,报错不至于一闪而过)。
1910
- # 两层转义各管各的:shlex.quote 管 shell 层(cd 路径的引号),
1911
- # 反斜杠/双引号替换管 AppleScript 字符串层。
1912
- shell_cmd = "cd %s && %s" % (shlex.quote(str(paths.ROOT)), cmd)
1913
- asc = 'tell application "Terminal" to do script ' + \
1914
- '"' + shell_cmd.replace("\\", "\\\\").replace('"', '\\"') + '"'
1915
- try:
1916
- subprocess.Popen(["osascript", "-e", asc], cwd=str(paths.ROOT), env=env,
1917
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1918
- stderr=subprocess.DEVNULL)
1919
- except Exception as e:
1920
- return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1921
- return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1922
- % (name, ("(" + extra + ")") if extra else "")}
1923
-
1924
- if sys.platform != "win32":
1925
- return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
1926
- # start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
1927
- # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1928
- argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
1929
- "cmd", "/k", cmd]
1930
- try:
1931
- subprocess.Popen(argv, cwd=str(paths.ROOT), env=env,
1932
- creationflags=CREATE_NO_WINDOW)
1933
- except Exception as e:
1934
- return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1935
- return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1936
- % (name, ("(" + extra + ")") if extra else "")}
1937
-
1938
-
1939
- def _open_when_ready(port, url, log_path, timeout=30):
1940
- """等 web 服务端口就绪后开浏览器。优先从启动日志提取带 token 的信任 URL
1941
- (端口通了 token 行可能还差几十毫秒才落盘,故就绪后最多再等 6 秒);
1942
- 超时兜底也开——服务可能只是慢,用户手动刷新即可。"""
1943
- deadline = time.time() + timeout
1944
- while time.time() < deadline:
1945
- if _port_open(port):
1946
- break
1947
- time.sleep(0.5)
1948
- token_deadline = time.time() + 6
1949
- best = None
1950
- while time.time() < token_deadline:
1951
- best = _best_url(log_path, port)
1952
- if best and "token=" in best:
1953
- break
1954
- time.sleep(0.5)
1955
- try:
1956
- webbrowser.open(best or url)
1957
- except Exception:
1958
- pass
1959
-
1960
-
1961
- # ---------------------------------------------------------------- 安装/升级
1962
-
1963
- # winget「包已装且无可用升级」退出码(有符号 -1978335189 / 无符号 2316632107)
1964
- _WINGET_NOOP_EXIT = 0x8A15002B
1965
- # 双语结论标记:winget 对已装包重复 install 会转 upgrade,无可升级即打这些话
1966
- _WINGET_NOOP_MARKERS = ("找不到可用的升级", "没有可用的较新的包版本",
1967
- "No available upgrade", "No newer package versions")
1968
-
1969
-
1970
- def _winget_already_ok(cmd, op, res):
1971
- """winget install/upgrade 撞「已装且无可升级」时的良性判定。
1972
-
1973
- 该场景命令非零退出,但目标态(已安装)本就达成——按失败走会触发 AI
1974
- 修复白烧一轮诊断(2026-09-30 Claude Code 实案:检测漏装 + 点安装 =
1975
- 假失败 + 假修复)。只认显式「无事可做」的退出码/结论话术,且调用方
1976
- 还要复检 installed 才按成功收口。"""
1977
- if res.get("ok") or op not in ("install", "upgrade"):
1978
- return False
1979
- # 首 token 可能已被 _resolve_mgmt_tool 换成绝对路径(PATH 快照落空兜底),
1980
- # 所以按 basename 认 winget,别用 startswith
1981
- head = str(cmd or "").strip().split()[0] if str(cmd or "").strip() else ""
1982
- if os.path.basename(head).lower().rstrip(".exe") != "winget":
1983
- return False
1984
- code = res.get("exit_code")
1985
- try:
1986
- if code is not None and (int(code) & 0xFFFFFFFF) == _WINGET_NOOP_EXIT:
1987
- return True
1988
- except (TypeError, ValueError):
1989
- pass
1990
- out = "%s\n%s" % (res.get("stdout") or "", res.get("stderr") or "")
1991
- return any(m in out for m in _WINGET_NOOP_MARKERS)
1992
-
1993
-
1994
- def _uv_install_hint():
1995
- if sys.platform == "darwin":
1996
- return "brew install uv(或官网 curl -LsSf https://astral.sh/uv/install.sh | sh)"
1997
- return "winget install astral-sh.uv,或 pip install uv"
1998
-
1999
-
2000
- def _uv_mirror_env():
2001
- """uv 系命令的镜像兜底 env(用户没自己配才补默认,绝不覆盖)。
2002
-
2003
- uv tool install --python 3.12 会现场下载 CPython 独立构建(GitHub 直连,
2004
- 国内常断流),包体走 PyPI——npmmirror/清华这对镜像是 2026-09 本机 uv
2005
- 实装验证过的组合。"""
2006
- env = {}
2007
- if not os.environ.get("UV_PYTHON_INSTALL_MIRROR"):
2008
- env["UV_PYTHON_INSTALL_MIRROR"] = (
2009
- "https://registry.npmmirror.com/-/binary/python-build-standalone")
2010
- if not os.environ.get("UV_DEFAULT_INDEX"):
2011
- env["UV_DEFAULT_INDEX"] = "https://pypi.tuna.tsinghua.edu.cn/simple"
2012
- return env
2013
-
2014
-
2015
- def _cmd_head_is_uv(cmd):
2016
- stripped = str(cmd or "").strip()
2017
- if not stripped:
2018
- return False
2019
- return os.path.basename(stripped.split()[0]).lower() in ("uv", "uv.exe")
2020
-
2021
-
2022
- # pip/uv 的 git+ 直装要从 GitHub 拉源码,国内链路对 GitHub 的 HTTP/2 常被中间设备
2023
- # 掐断(git 报 "Error in the HTTP2 framing layer",exit 128)——强制 HTTP/1.1 是
2024
- # 标准绕法。用 GIT_CONFIG_* 环境变量注入,不动用户全局 git 配置;老 git 不识别
2025
- # 这组变量时静默忽略,无害。
2026
- _GIT_FLAKE_MARKERS = ("HTTP2 framing layer", "unable to access", "RPC failed",
2027
- "Connection reset", "connection was reset", "Recv failure")
2028
-
2029
-
2030
- def git_http11_env():
2031
- """git HTTPS 传输层 HTTP/1.1 兜底 env(返回新字典,调用方可自由合并)。
2032
-
2033
- 本模块是安装/工作台/git 插件拉取各执行面的共享源;L0 的 tlsctx 够不着
2034
- (L2 不准反向 import L0),runner 又在并行在制品里,故落在 manager。"""
2035
- return {"GIT_CONFIG_COUNT": "1",
2036
- "GIT_CONFIG_KEY_0": "http.version",
2037
- "GIT_CONFIG_VALUE_0": "HTTP/1.1"}
2038
-
2039
-
2040
- def _git_safe_env(cmd):
2041
- """命令涉及 git+ 远端拉取时的 git 传输层兜底 env(只加键,不覆盖既有)。"""
2042
- if "git+" not in str(cmd or ""):
2043
- return {}
2044
- return git_http11_env()
2045
-
2046
-
2047
- def _git_transport_flake(res):
2048
- """安装失败输出是否为 git 传输层抖动类错误(值得原地重试一次的那种)。
2049
-
2050
- 接受 run_process 结果字典,也接受纯文本 blob(gitmod/market_remote 的
2051
- subprocess 输出直接传字符串)。"""
2052
- if not isinstance(res, str):
2053
- res = "%s%s" % ((res or {}).get("stderr") or "", (res or {}).get("stdout") or "")
2054
- return any(m in res for m in _GIT_FLAKE_MARKERS)
2055
-
2056
-
2057
- def run_install_cmd(cmd, cancel_event=None, log_path=None):
2058
- """安装类命令(目录页 install/upgrade 与 AI 修复命令)的统一执行面。
2059
-
2060
- - uv 系挂镜像兜底(_uv_mirror_env,只补缺不覆盖)
2061
- - 带 git+ 拉取的命令强制 git 走 HTTP/1.1
2062
- - git 传输层抖动失败自动同命令重试一次——链路瞬断不值得烧一轮 AI 诊断
2063
- """
2064
- env = {}
2065
- if _cmd_head_is_uv(cmd):
2066
- env.update(_uv_mirror_env())
2067
- env.update(_git_safe_env(cmd))
2068
- res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
2069
- cancel_event=cancel_event, log_path=log_path,
2070
- env=env or None)
2071
- if not res["ok"] and not res.get("cancelled") and _git_transport_flake(res):
2072
- res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
2073
- cancel_event=cancel_event, log_path=log_path,
2074
- env=env or None,
2075
- audit_notes=["首跑 git 传输层抖动,同命令自动重试一次"])
2076
- res["retried"] = True
2077
- return res
2078
-
2079
-
2080
- def _bootstrap_uv(cancel_event=None, log_path=None):
2081
- """uv 不在本机时自动补装一个——安装按钮「点了就该装上」的契约兜底。
2082
-
2083
- 按平台选渠道,全部免 sudo 非交互:POSIX 先 brew(在才用)后官方 curl
2084
- 脚本(落 ~/.local/bin,探测表可寻址);Windows 先 winget 后 pip。装完
2085
- 不指望 PATH 快照自愈——调用方重跑 _resolve_mgmt_tool 用绝对路径接管。
2086
- 返回 {ok, detail};失败 detail 列出试过的渠道与结局,供人话收口。"""
2087
- candidates = []
2088
- if sys.platform == "win32":
2089
- if shutil.which("winget"):
2090
- candidates.append(("winget", "winget install -e --id astral-sh.uv --silent"))
2091
- if shutil.which("py"):
2092
- candidates.append(("pip(py)", "py -3 -m pip install uv"))
2093
- elif shutil.which("python"):
2094
- candidates.append(("pip", "python -m pip install uv"))
2095
- else:
2096
- if shutil.which("brew"):
2097
- candidates.append(("brew", "brew install uv"))
2098
- if shutil.which("curl"):
2099
- candidates.append(("curl", "curl -LsSf https://astral.sh/uv/install.sh | sh"))
2100
- attempts = []
2101
- for label, cmd in candidates:
2102
- res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
2103
- timeout=900, cancel_event=cancel_event,
2104
- log_path=log_path)
2105
- if res["ok"]:
2106
- return {"ok": True, "detail": "已自动补装 uv(%s)" % label}
2107
- attempts.append("%s 退出码 %s" % (label, res.get("exit_code")))
2108
- return {"ok": False,
2109
- "detail": "、".join(attempts) or "本机没有 brew/curl/winget/pip 任一渠道"}
2110
-
2111
-
2112
- def _resolve_mgmt_tool(cmd, op):
2113
- """install/upgrade 命令首 token 的工具本体解析。
2114
-
2115
- 服务 PATH 是启动快照,本机后装的 uv/npm 不在快照里时 shell 报
2116
- command not found 退出码 127,零信息量。磁盘已知落点能探到就把首 token
2117
- 原地换成绝对路径(装完即用,不必重启服务);探不到且是 uv 系安装命令
2118
- 则返回 uv_missing=True——本机连 uv 都没有,包无从装起,人话报错并跳过
2119
- AI 修复(修复链装上 uv 后复检目标 CLI 仍未装,白烧一轮诊断)。
2120
- 返回 (替换后的命令, uv_missing)。"""
2121
- stripped = str(cmd or "").strip()
2122
- first = stripped.split()[0] if stripped else ""
2123
- if not first or os.path.isabs(first) or shutil.which(first):
2124
- return cmd, False
2125
- found = runner._fallback_probe_cli(first)
2126
- if found:
2127
- return found + stripped[len(first):], False
2128
- return cmd, (first == "uv" and op in ("install", "upgrade"))
2129
-
2130
-
2131
- def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
2132
- """执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
2133
- if op == "uninstall":
2134
- cmd = catalog.uninstall_command(entry)
2135
- if not cmd:
2136
- return {"ok": False,
2137
- "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
2138
- % entry["id"]}
2139
- else:
2140
- cmd = entry.get(op)
2141
- if not cmd:
2142
- return {"ok": False,
2143
- "error": "未配置 %s 命令:请在 data/catalog.json 的 \"%s\" 里补充,或用官方渠道安装"
2144
- % (op, entry["id"])}
2145
- cmd, uv_missing = _resolve_mgmt_tool(cmd, op)
2146
- if uv_missing:
2147
- # 「提供安装就该能装上」:uv 不在本机先自动补装,装 uv 不该是用户
2148
- # 操心的事;真补不上才退回人话指引(2026-10-09 Mac 双装失败实案)
2149
- boot = _bootstrap_uv(cancel_event=cancel_event, log_path=log_path)
2150
- if boot["ok"]:
2151
- cmd, uv_missing = _resolve_mgmt_tool(cmd, op)
2152
- if uv_missing:
2153
- return {"ok": False, "uv_missing": True,
2154
- "error": "自动安装 uv 未成功(%s)。请手动安装:%s;"
2155
- "装完重启 CodeBee,再点一次安装。"
2156
- % (boot["detail"], _uv_install_hint())}
2157
- res = run_install_cmd(cmd, cancel_event=cancel_event, log_path=log_path)
2158
- winget_noop = _winget_already_ok(cmd, op, res)
2159
- detect_all(force=True)
2160
- with _LOCK:
2161
- _STATE["versions"].pop(entry["id"], None)
2162
- if winget_noop and detect_entry(entry)["installed"]:
2163
- # winget 说「已装且无可升级」、复检也在装:install 语义(确保在装)已达成
2164
- refresh_update_async(entry)
2165
- return {"ok": True, "exit_code": res["exit_code"], "command": cmd,
2166
- "error": "",
2167
- "note": "winget 报已安装且无可用升级,复检确认本机在装"}
2168
- if res["ok"]:
2169
- # 安装/升级成功即作废该条目的更新检查缓存并后台复检:否则 10 分钟 TTL
2170
- # 内徽章仍显示「有新版本」,诱导同版本重装(重装易撞 EBUSY 文件锁,
2171
- # 2026-09-18 dsh 案)。
2172
- refresh_update_async(entry)
2173
- tail = runner.clean_cli_text(res["stderr"])[-800:]
2174
- if not tail and not res["ok"]:
2175
- # winget 把结论打在 stdout(stderr 恒空),只回「退出码 N」零信息量
2176
- tail = runner.clean_cli_text(res["stdout"])[-800:]
2177
- return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
2178
- "error": "" if res["ok"] else (tail or "退出码 %s" % res["exit_code"])}
2179
-
2180
-
2181
- def refresh_update_async(entry):
2182
- """作废单条目的更新检查缓存,并后台立刻复检一次远端最新版本。
2183
-
2184
- 完成的安装/升级调用它,卡片徽章马上脱离旧结论(unknown → 几秒后
2185
- 「已是最新/有新版本」),不用等 10 分钟缓存过期。npm view 在后台线程
2186
- 跑,不阻塞 mgmt 任务收尾。
2187
- """
2188
- with _LOCK:
2189
- _UPDATE_CACHE.pop(entry["id"], None)
2190
- try:
2191
- threading.Thread(target=check_update, args=(entry,), kwargs={"force": True},
2192
- name="update-recheck-%s" % entry.get("id"), daemon=True).start()
2193
- except Exception:
2194
- pass
2195
-
2196
-
2197
- # ---------------------------------------------------------------- 版本检查
2198
-
2199
- _UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
2200
- UPDATE_TTL = 600
2201
-
2202
-
2203
- def _npm_pkg_name(cmd):
2204
- """从 npm 安装命令里取包名(实现已统一到 catalog.npm_pkg_name)。"""
2205
- return catalog.npm_pkg_name(cmd)
2206
-
2207
-
2208
- def _ver_tuple(s):
2209
- return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
2210
-
2211
-
2212
- def check_update(entry, force=False):
2213
- """检查是否有新版本可用。npm 走 npm view;winget 走 winget upgrade 列表;
2214
- 其他渠道标记为不支持。结果缓存 10 分钟。"""
2215
- eid = entry["id"]
2216
- if not force:
2217
- cached = _UPDATE_CACHE.get(eid)
2218
- if cached and time.time() - cached[0] < UPDATE_TTL:
2219
- return cached[1]
2220
- current = version_of(entry)
2221
- cur_num = ".".join(str(x) for x in _ver_tuple(current)) or "-"
2222
- result = {"current": current, "latest": None, "updatable": None, "note": ""}
2223
-
2224
- cmd = entry.get("install") or entry.get("upgrade") or ""
2225
- pkg = _npm_pkg_name(cmd)
2226
- if pkg:
2227
- # Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
2228
- npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
2229
- else ["npm", "view", pkg, "version"]
2230
- r = runner.run_process(argv=npm_view, timeout=90)
2231
- latest = ""
2232
- if r["ok"]:
2233
- for line in (r["stdout"] or "").splitlines():
2234
- line = line.strip()
2235
- if line and re.match(r"^\d", line):
2236
- latest = line
2237
- if not latest:
2238
- result["note"] = "查询 npm 失败(网络或 registry 问题):" + (r["stderr"] or "")[-200:]
2239
- else:
2240
- result["latest"] = latest
2241
- result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
2242
- if not result["updatable"]:
2243
- result["note"] = "已是最新版本"
2244
- elif os.name == "nt" and "winget" in cmd:
2245
- m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
2246
- wid = m.group(1) if m else None
2247
- if not wid:
2248
- result["note"] = "无法从命令中解析 winget 包 ID"
2249
- else:
2250
- r = runner.run_process(argv=["cmd", "/c", "winget", "upgrade"], timeout=180)
2251
- if not r["ok"]:
2252
- result["note"] = "winget upgrade 查询失败:" + (r["stderr"] or "")[-200:]
2253
- else:
2254
- hit = [l for l in (r["stdout"] or "").splitlines() if wid.lower() in l.lower()]
2255
- result["updatable"] = bool(hit)
2256
- if hit:
2257
- result["latest"] = "(winget 有可用更新)"
2258
- else:
2259
- result["latest"] = cur_num
2260
- result["note"] = "已是最新版本"
2261
- else:
2262
- result["note"] = "该渠道暂不支持自动检查更新,可直接点升级尝试"
2263
-
2264
- with _LOCK:
2265
- _UPDATE_CACHE[eid] = (time.time(), result)
2266
- return result
2267
-
2268
-
2269
- # 「进入智能体目录页自动检查更新」的后台任务状态
2270
- _UPDATE_CHECK = {"running": False, "total": 0, "done": 0}
2271
-
2272
-
2273
- def updates_checking():
2274
- with _LOCK:
2275
- return bool(_UPDATE_CHECK["running"])
2276
-
2277
-
2278
- def update_info(entry):
2279
- """单个 CLI 的更新检查结果(供管理页卡片展示)。没查过时为 unknown。"""
2280
- with _LOCK:
2281
- cached = _UPDATE_CACHE.get(entry["id"])
2282
- if not cached:
2283
- return {"status": "unknown", "latest": None, "updatable": None,
2284
- "note": "", "checked_at": ""}
2285
- res = cached[1] or {}
2286
- up = res.get("updatable")
2287
- status = "updatable" if up is True else ("current" if up is False else "unsupported")
2288
- return {"status": status, "latest": res.get("latest"), "updatable": up,
2289
- "note": res.get("note") or "",
2290
- "checked_at": time.strftime("%H:%M", time.localtime(cached[0]))}
2291
-
2292
-
2293
- def _checkable_entries():
2294
- """已安装且配了 install/upgrade 的条目——只有这些才谈得上「是否有新版本」。"""
2295
- detected = detect_all()
2296
- out = []
2297
- for e in catalog.load():
2298
- det = (detected or {}).get(e["id"]) or {}
2299
- if det.get("installed") and (e.get("upgrade") or e.get("install")):
2300
- out.append(e)
2301
- return out
2302
-
2303
-
2304
- def check_updates_async(force=False):
2305
- """后台逐个检查已安装 CLI 的远端最新版本,返回本次要检查的条目数。
2306
-
2307
- 进入「智能体目录」页时自动触发。单条结果复用 check_update 的 10 分钟缓存,
2308
- 所以反复进出页面几乎不产生额外子进程/网络开销;已在跑时不重复起线程。
2309
- """
2310
- with _LOCK:
2311
- if _UPDATE_CHECK["running"]:
2312
- return 0
2313
- entries = _checkable_entries()
2314
- _UPDATE_CHECK.update(running=True, total=len(entries), done=0)
2315
-
2316
- def _worker():
2317
- try:
2318
- for e in entries:
2319
- try:
2320
- check_update(e, force=force)
2321
- except Exception:
2322
- pass
2323
- finally:
2324
- with _LOCK:
2325
- _UPDATE_CHECK["done"] += 1
2326
- finally:
2327
- with _LOCK:
2328
- _UPDATE_CHECK["running"] = False
2329
-
2330
- threading.Thread(target=_worker, name="catalog-update-check", daemon=True).start()
2331
- return len(entries)
2332
-
2333
-
2334
- def upgrade_all_targets():
2335
- """「一键升级全部」的目标条目,按目录顺序返回。
2336
-
2337
- 只收同时满足:已安装 + 配了 upgrade 命令(op=upgrade 硬依赖该字段,
2338
- 没配的跟单条一样不出「升级」按钮)+ 更新检查不是「已是最新」。
2339
- 检查结果为 unknown(还没查)/ unsupported(渠道查不了)的也升级——
2340
- npm/pip 的升级命令幂等,宁可空跑一次也不漏升;唯一跳过「已是最新」,
2341
- 防无谓重装(重装易撞 EBUSY 文件锁)。
2342
- """
2343
- detected = detect_all()
2344
- out = []
2345
- for e in catalog.load():
2346
- det = (detected or {}).get(e["id"]) or {}
2347
- if not det.get("installed") or not e.get("upgrade"):
2348
- continue
2349
- if update_info(e).get("status") == "current":
2350
- continue
2351
- out.append(e)
2352
- return out
2353
-
2354
-
2355
- def catalog_view():
2356
- """管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
2357
- from . import registry
2358
- entries = catalog.load()
2359
- detected = detect_all(force=False)
2360
- enabled = registry.load_enabled()
2361
- view = []
2362
- for e in entries:
2363
- det = detected.get(e["id"]) or {}
2364
- pref = enabled.get(e["id"]) or {}
2365
- orch_enabled = bool(pref.get("enabled", e.get("default_enabled", False))) if e.get("orch") else False
2366
- view.append({
2367
- "id": e["id"], "name": e.get("name", e["id"]), "note": e.get("note", ""),
2368
- "group": "installed" if det.get("installed") else "installable",
2369
- "installed": det.get("installed", False),
2370
- "detail": det.get("detail", ""),
2371
- "version": version_of(e),
2372
- "config_path": _config_path(e),
2373
- "config_writable": (e.get("config") or {}).get("format") in _WRITABLE_FORMATS,
2374
- "model": read_model(e),
2375
- "orch_kind": (e.get("orch") or {}).get("kind"),
2376
- "orch_enabled": orch_enabled,
2377
- "update": update_info(e),
2378
- "has_install": bool(e.get("install")),
2379
- "has_upgrade": bool(e.get("upgrade")),
2380
- # 卸载命令由 install/upgrade 推导(或 catalog 显式配置),供 UI 确认框展示
2381
- "uninstall_cmd": catalog.uninstall_command(e) if det.get("installed") else None,
2382
- # 一键打开配置(kind=web/console + command);没配的条目 UI 不出「打开」按钮
2383
- "launch": e.get("launch"),
2384
- })
2385
- return view
1
+ # -*- coding: utf-8 -*-
2
+ """智能体管理器:安装检测、版本、安装/升级、模型配置读写。
3
+
4
+ 安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
5
+ 防止相对路径穿越到预期之外的系统位置。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import glob
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import signal
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ import webbrowser
24
+ import zlib
25
+ from pathlib import Path
26
+
27
+ from . import catalog, paths, runner
28
+
29
+ # 非 Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(runner 同款守卫)
30
+ CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
31
+ VERSION_TTL = 300 # 版本缓存 5 分钟
32
+
33
+ _LOCK = threading.RLock()
34
+ _STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
35
+
36
+ # 能自动写入默认模型的 config.format(其余格式只能手动编辑)
37
+ _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
38
+ "yaml-line")
39
+
40
+
41
+ def _read_text(path, preserve_newlines=False):
42
+ """读取文本并及时关闭句柄;改写配置前可保留原始换行符。"""
43
+ newline = "" if preserve_newlines else None
44
+ with open(path, encoding="utf-8", errors="replace", newline=newline) as fh:
45
+ return fh.read()
46
+
47
+
48
+ def _expand(p):
49
+ return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
50
+
51
+
52
+ def _under(path, root):
53
+ try:
54
+ return os.path.commonpath([os.path.realpath(path), root]) == root
55
+ except ValueError:
56
+ return False
57
+
58
+
59
+ def tmp_data_no_home_write():
60
+ """测试实例防毒闸:TUTTI_DATA 指向系统临时目录、且进程主目录仍是真实
61
+ 主目录时,一切对真实 CLI 配置的写入都应拒绝。
62
+
63
+ 一次性测试服务的 models.json 里全是 a.test/sk-test 夹具,忘设假 HOME 的
64
+ 测试一旦触发 launch/自愈/autobind 同步,就会把夹具毒进真实
65
+ ~/.claude/settings.json(2026-09-22 实案:claude 步骤全体 ENOTFOUND,
66
+ 下一次测试又覆盖回来,形成「越测越坏」循环)。已设假 HOME 的测试不受
67
+ 影响——假 HOME 本身就在临时目录下。
68
+ """
69
+ td = (os.environ.get("TUTTI_DATA") or "").strip()
70
+ if not td:
71
+ return False
72
+ tmp = tempfile.gettempdir()
73
+ home = os.path.abspath(os.path.expanduser("~"))
74
+ return _under(td, tmp) and not _under(home, tmp)
75
+
76
+
77
+ def _safe_config_path(raw):
78
+ """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
79
+ if not raw:
80
+ return None
81
+ full = _expand(raw)
82
+ home = os.path.abspath(os.path.expanduser("~"))
83
+ try:
84
+ if os.path.commonpath([full, home]) != home:
85
+ return None
86
+ except ValueError:
87
+ return None
88
+ return full
89
+
90
+
91
+ # ---------------------------------------------------------------- 检测
92
+
93
+ def _which_cli(cli):
94
+ """PATH 找 CLI;落空再按平台兜底磁盘已知安装位。
95
+
96
+ Windows 兜 winget 便携包两处标准落点:winget 装的 CLI(如 Claude Code)
97
+ 把 exe 放进 %LOCALAPPDATA%\\Microsoft\\WinGet\\Packages\\<PkgId>_*\\,并把
98
+ 该目录写进注册表用户 PATH——注册表更新后已在跑的进程(本服务)不继承,
99
+ shutil.which 落空 → 卡片「未安装」假象,点安装又被 winget「已装无可升级」
100
+ 非零退出拒掉(2026-09-30 实案)。Links 是便携包符号链接标准目录,Packages
101
+ 按 <cli>.exe 通配兜另一形态。POSIX 兜 runner 同款三落点(~/.local/bin 与
102
+ brew bin,Mac 服务吃不到 shell rc 里的 PATH 时同样假「未安装」)。
103
+ 两条都只兜 which 落空的场景,零额外开销。"""
104
+ path = shutil.which(cli)
105
+ if path:
106
+ return path
107
+ if os.name != "nt":
108
+ return runner._fallback_probe_cli(cli)
109
+ local = os.environ.get("LOCALAPPDATA")
110
+ if not local:
111
+ return None
112
+ links = os.path.join(local, "Microsoft", "WinGet", "Links", cli + ".exe")
113
+ if os.path.isfile(links):
114
+ return links
115
+ for hit in sorted(glob.glob(os.path.join(
116
+ local, "Microsoft", "WinGet", "Packages", "*", cli + ".exe"))):
117
+ return hit
118
+ return None
119
+
120
+
121
+ def detect_entry(entry):
122
+ d = entry.get("detect") or {}
123
+ if d.get("cli"):
124
+ path = _which_cli(d["cli"])
125
+ return {"installed": bool(path), "detail": path or ""}
126
+ if d.get("exe"):
127
+ full = _expand(d["exe"])
128
+ return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
129
+ if d.get("uwp"):
130
+ base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
131
+ return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
132
+ if d.get("dir"):
133
+ full = _expand(d["dir"])
134
+ return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
135
+ return {"installed": False, "detail": ""}
136
+
137
+
138
+ def _orphan_signature(cl):
139
+ """「Tutti 专属调用签名」判定(两个平台的清扫共用)。"""
140
+ return (("opencode" in cl and "--model" in cl)
141
+ or ("codex" in cl and "--skip-git-repo-check" in cl)
142
+ or ("kimi-code" in cl and "main.mjs" in cl))
143
+
144
+
145
+ def _sweep_orphans_posix():
146
+ """POSIX 版清扫:ps 一次拉全量,签名同 Windows。孤儿判据不同——POSIX 的
147
+ 孤儿进程会被内核过继给 1 号进程(launchd/init),Windows 则保留死掉的
148
+ ppid,所以这里认 ppid==1;用户手动在终端里跑的同名 CLI 父进程是活着的
149
+ shell(ppid!=1),不会被误杀。"""
150
+ try:
151
+ r = subprocess.run(["ps", "-eo", "pid=,ppid=,command="],
152
+ capture_output=True, timeout=60)
153
+ killed = 0
154
+ for ln in r.stdout.decode("utf-8", "replace").splitlines():
155
+ parts = ln.strip().split(None, 2)
156
+ if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
157
+ continue
158
+ pid, ppid, cmd = int(parts[0]), int(parts[1]), parts[2]
159
+ if pid == 1 or ppid != 1 or not _orphan_signature(cmd.lower()):
160
+ continue
161
+ try:
162
+ os.killpg(pid, signal.SIGKILL) # 服务 spawn 用了 start_new_session,pgid==pid
163
+ killed += 1
164
+ except Exception:
165
+ try:
166
+ os.kill(pid, signal.SIGKILL)
167
+ killed += 1
168
+ except Exception:
169
+ pass
170
+ return killed
171
+ except Exception:
172
+ return 0
173
+
174
+
175
+ def sweep_orphan_cli_processes():
176
+ """启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
177
+ /T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
178
+ `opencode run` 连上僵尸实例后 shell 全在僵尸的项目根里跑(2026-09-17
179
+ mo-so 实测:agent 在 Temp 里找代码,汇报「工作目录没有源码」)。
180
+
181
+ 按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
182
+ opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
183
+ codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
184
+ kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
185
+ 僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
186
+ claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
187
+ if os.name != "nt":
188
+ return _sweep_orphans_posix()
189
+ ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
190
+ "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
191
+ if not os.path.isfile(ps_exe):
192
+ ps_exe = "powershell"
193
+ ps = ("Get-CimInstance Win32_Process | "
194
+ "Where-Object { $_.Name -match '^(opencode|codex|node|cmd)\\.exe$' } | "
195
+ "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
196
+ try:
197
+ r = subprocess.run([ps_exe, "-NoProfile", "-Command", ps],
198
+ capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=60)
199
+ import json as _json
200
+ raw = r.stdout.decode("utf-8", "replace").strip()
201
+ items = _json.loads(raw) if raw else []
202
+ if isinstance(items, dict):
203
+ items = [items]
204
+ live = {i.get("ProcessId") for i in items}
205
+ killed = 0
206
+ for i in items:
207
+ cl = str(i.get("CommandLine") or "").lower()
208
+ ppid = i.get("ParentProcessId")
209
+ if not _orphan_signature(cl) or ppid in live or not i.get("ProcessId"):
210
+ continue
211
+ try:
212
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
213
+ capture_output=True, creationflags=CREATE_NO_WINDOW,
214
+ timeout=15)
215
+ killed += 1
216
+ except Exception:
217
+ pass
218
+ return killed
219
+ except Exception:
220
+ return 0
221
+
222
+
223
+ def detect_all(force=False):
224
+ """检测全部条目。快照用途(force=False)热路径永不等检测:缓存新鲜直接
225
+ 返回;过期返回旧快照、同时把刷新踢到后台线程(stale-while-revalidate)。
226
+
227
+ 检测是慢磁盘 IO:shutil.which/isfile 在 Windows 上遇到断链的 PATH 项
228
+ 可能卡数秒。此前「领队请求自己跑检测、其余请求 ev.wait(30) 排队」,每
229
+ 60s 缓存周期都有一个 /api/catalog、/api/state(含 SSE 推送)一起卡数秒
230
+ 的窗口;前端无超时轮询把浏览器 6 连接池塞满后整个界面假死(2026-09-22
231
+ 用户实测「左栏点不动、右栏不刷新」)。冷启动首轮与 force=True(显式
232
+ 重载/安装回调)仍同步跑拿新鲜结果。"""
233
+ with _LOCK:
234
+ if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
235
+ return _STATE["detected"]
236
+ ev = _STATE["detect_ev"]
237
+ lead = ev is None # 我是本次刷新的发起人
238
+ if lead:
239
+ ev = _STATE["detect_ev"] = threading.Event()
240
+ cold = not _STATE["detected"] # 冷启动首轮:还没有任何快照
241
+ if not lead:
242
+ if not cold:
243
+ return dict(_STATE["detected"]) # 过期快照也比卡请求强
244
+ ev.wait(30) # 冷启动窗口内并发首查:等首轮检测完成(一次性)
245
+ with _LOCK:
246
+ return dict(_STATE["detected"])
247
+ if not cold and not force:
248
+ threading.Thread(target=_detect_refresh, args=(ev,),
249
+ name="catalog-detect", daemon=True).start()
250
+ return dict(_STATE["detected"])
251
+ return _detect_refresh(ev)
252
+
253
+
254
+ def _detect_refresh(ev):
255
+ """跑一轮全量检测并落快照;完成后置时间戳、清在飞标记、放行等待者。"""
256
+ try:
257
+ detected = {}
258
+ for entry in catalog.load():
259
+ try:
260
+ detected[entry["id"]] = detect_entry(entry)
261
+ except Exception as e:
262
+ detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
263
+ if detected:
264
+ with _LOCK:
265
+ _STATE["detected"] = detected
266
+ return dict(_STATE["detected"])
267
+ finally:
268
+ with _LOCK:
269
+ _STATE["detect_ts"] = time.time()
270
+ _STATE["detect_ev"] = None
271
+ ev.set()
272
+
273
+
274
+ def _uwp_version(package_dir):
275
+ import xml.etree.ElementTree as ET
276
+ mf = os.path.join(package_dir, "AppxManifest.xml")
277
+ if not os.path.isfile(mf):
278
+ return None
279
+ try:
280
+ tree = ET.parse(mf)
281
+ for el in tree.iter():
282
+ if el.tag.endswith("}Identity") or el.tag == "Identity":
283
+ return el.get("Version")
284
+ except Exception:
285
+ pass
286
+ return None
287
+
288
+
289
+ def _exe_version(path):
290
+ if os.name != "nt":
291
+ return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
292
+ try:
293
+ r = subprocess.run(
294
+ ["powershell", "-NoProfile", "-Command",
295
+ "(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
296
+ capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
297
+ out = r.stdout.decode("utf-8", "replace").strip()
298
+ return out or None
299
+ except Exception:
300
+ return None
301
+
302
+
303
+ def version_of(entry):
304
+ """版本探测:CLI 走 --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
305
+ eid = entry["id"]
306
+ with _LOCK:
307
+ cached = _STATE["versions"].get(eid)
308
+ if cached and time.time() - cached[0] < VERSION_TTL:
309
+ return cached[1]
310
+ det = (detect_all() or {}).get(eid) or {}
311
+ version = None
312
+ cli = (entry.get("detect") or {}).get("cli")
313
+ if cli and det.get("installed"):
314
+ try:
315
+ # Windows 下 CLI 可能是 npm .cmd 垫片,须经 cmd /c 才能直接点名跑;
316
+ # POSIX 没有垫片,符号链接直接跑即可
317
+ probe = ["cmd", "/c", cli, "--version"] if os.name == "nt" \
318
+ else [cli, "--version"]
319
+ r = subprocess.run(probe, capture_output=True,
320
+ creationflags=CREATE_NO_WINDOW, timeout=20)
321
+ out = (r.stdout or b"").decode("utf-8", "replace").strip()
322
+ if not out:
323
+ out = (r.stderr or b"").decode("utf-8", "replace").strip()
324
+ version = out.splitlines()[0][:60] if out else None
325
+ except Exception:
326
+ version = None
327
+ elif det.get("detail") and os.path.isdir(det["detail"]):
328
+ version = _uwp_version(det["detail"])
329
+ elif det.get("detail") and os.path.isfile(det["detail"]):
330
+ version = _exe_version(det["detail"])
331
+ with _LOCK:
332
+ _STATE["versions"][eid] = (time.time(), version or "-")
333
+ return version or "-"
334
+
335
+
336
+ # ---------------------------------------------------------------- TOML 表内键
337
+
338
+ def _toml_span(lines, table):
339
+ """定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
340
+ 只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
341
+ 子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
342
+ header = re.compile(r"^\[([^\[\]]+)\]\s*$")
343
+ start = None
344
+ for i, ln in enumerate(lines):
345
+ if ln.lstrip().startswith("["):
346
+ m = header.match(ln.strip())
347
+ if not m:
348
+ continue
349
+ if start is not None:
350
+ return start, i
351
+ if m.group(1).strip() == table:
352
+ start = i
353
+ if start is None:
354
+ return None, None
355
+ return start, len(lines)
356
+
357
+
358
+ def _toml_read_value(text, table, leaf):
359
+ m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
360
+ if table is None else None
361
+ if table is None:
362
+ return m.group(1) if m else None
363
+ start, end = _toml_span(text.splitlines(), table)
364
+ if start is None:
365
+ return None
366
+ for ln in text.splitlines()[start + 1:end]:
367
+ m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
368
+ if m:
369
+ return m.group(1)
370
+ return None
371
+
372
+
373
+ def _toml_write_value(text, table, leaf, value):
374
+ """就地写入 TOML 的 [table] leaf(双引号标量),保留其余内容与换行风格。"""
375
+ eol = "\r\n" if "\r\n" in text else "\n"
376
+ lines = text.splitlines()
377
+ new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
378
+ # 换值不换行:保留行尾注释等其余内容
379
+ pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
380
+ if table is None:
381
+ for i, ln in enumerate(lines):
382
+ m = pat.match(ln)
383
+ if m:
384
+ lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
385
+ break
386
+ else:
387
+ lines.append(new_line)
388
+ return eol.join(lines).rstrip("\r\n") + eol
389
+ start, end = _toml_span(lines, table)
390
+ if start is None: # 表不存在:整段追加
391
+ if lines and lines[-1].strip():
392
+ lines.append("")
393
+ lines.append("[%s]" % table)
394
+ lines.append(new_line)
395
+ return eol.join(lines).rstrip("\r\n") + eol
396
+ for i in range(start + 1, end):
397
+ m = pat.match(lines[i])
398
+ if m:
399
+ lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
400
+ return eol.join(lines).rstrip("\r\n") + eol
401
+ lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
402
+ return eol.join(lines).rstrip("\r\n") + eol
403
+
404
+
405
+ # ---------------------------------------------------------------- 模型配置
406
+
407
+ def _config_path(entry):
408
+ cfg = entry.get("config") or {}
409
+ return _safe_config_path(cfg.get("path"))
410
+
411
+
412
+ def _yaml_model_path(cfg):
413
+ """把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
414
+ return _dotted_key(cfg)
415
+
416
+
417
+ def _dotted_key(cfg):
418
+ """model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
419
+ key = (cfg.get("model_key") or "model").strip()
420
+ if "." in key:
421
+ section, leaf = key.split(".", 1)
422
+ return section.strip(), leaf.strip()
423
+ return None, key
424
+
425
+
426
+ def _yaml_quote(value):
427
+ """YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
428
+ return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
429
+
430
+
431
+ def _yaml_unquote(raw):
432
+ """取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
433
+ s = (raw or "").strip()
434
+ if not s:
435
+ return ""
436
+ if s[0] in ("'", '"'):
437
+ q = s[0]
438
+ i = 1
439
+ buf = []
440
+ while i < len(s):
441
+ ch = s[i]
442
+ if q == '"' and ch == "\\" and i + 1 < len(s):
443
+ nxt = s[i + 1]
444
+ buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
445
+ i += 2
446
+ continue
447
+ if ch == q:
448
+ if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
449
+ buf.append("'")
450
+ i += 2
451
+ continue
452
+ break
453
+ buf.append(ch)
454
+ i += 1
455
+ return "".join(buf).strip()
456
+ # 无引号:截断行尾注释(# 前的空白才算注释起始)
457
+ return re.split(r"\s+#", s, 1)[0].strip()
458
+
459
+
460
+ def _yaml_span(lines, section):
461
+ """定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
462
+ start = None
463
+ for i, ln in enumerate(lines):
464
+ m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
465
+ if not m:
466
+ continue
467
+ if m.group(1).strip() == section:
468
+ start = i
469
+ continue
470
+ if start is not None:
471
+ return start, i
472
+ if start is None:
473
+ return None, None
474
+ return start, len(lines)
475
+
476
+
477
+ def _yaml_read_value(text, section, leaf):
478
+ lines = text.splitlines()
479
+ if section is None:
480
+ m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
481
+ return _yaml_unquote(m.group(1)) or None if m else None
482
+ start, end = _yaml_span(lines, section)
483
+ if start is None:
484
+ return None
485
+ for ln in lines[start + 1:end]:
486
+ m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
487
+ if m:
488
+ return _yaml_unquote(m.group(1)) or None
489
+ return None
490
+
491
+
492
+ def _yaml_write_value(text, section, leaf, value):
493
+ """就地写入 YAML 的 section.leaf,保留其余内容、缩进与换行风格。"""
494
+ eol = "\r\n" if "\r\n" in text else "\n"
495
+ lines = text.splitlines()
496
+ quoted = _yaml_quote(value)
497
+ if section is None:
498
+ for i, ln in enumerate(lines):
499
+ if re.match(r"^%s\s*:" % re.escape(leaf), ln):
500
+ lines[i] = "%s: %s" % (leaf, quoted)
501
+ break
502
+ else:
503
+ lines.append("%s: %s" % (leaf, quoted))
504
+ return eol.join(lines).rstrip("\r\n") + eol
505
+ start, end = _yaml_span(lines, section)
506
+ if start is None: # 段不存在:整段追加
507
+ if lines and lines[-1].strip():
508
+ lines.append("")
509
+ lines.append("%s:" % section)
510
+ lines.append(" %s: %s" % (leaf, quoted))
511
+ return eol.join(lines).rstrip("\r\n") + eol
512
+ for i in range(start + 1, end):
513
+ m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
514
+ if m: # 键已存在:只换值,保留原缩进
515
+ lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
516
+ return eol.join(lines).rstrip("\r\n") + eol
517
+ indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
518
+ for i in range(start + 1, end):
519
+ if not lines[i].strip():
520
+ continue
521
+ if indent == " ":
522
+ m = re.match(r"^(\s+)\S", lines[i])
523
+ if m:
524
+ indent = m.group(1)
525
+ last = i
526
+ lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
527
+ return eol.join(lines).rstrip("\r\n") + eol
528
+
529
+
530
+ def _json_path_get(data, keys):
531
+ """沿点号路径下钻 JSON 嵌套;终点必须是字符串。"""
532
+ cur = data
533
+ for k in keys:
534
+ if isinstance(cur, dict) and k in cur:
535
+ cur = cur[k]
536
+ else:
537
+ return None
538
+ return cur if isinstance(cur, str) else None
539
+
540
+
541
+ def _json_path_set(data, keys, value):
542
+ """沿点号路径写入 JSON 嵌套,缺中间对象就地创建;
543
+ 中途遇到非 dict(如 string 简写形式)升级为对象。"""
544
+ cur = data
545
+ for k in keys[:-1]:
546
+ if not isinstance(cur.get(k), dict):
547
+ cur[k] = {}
548
+ cur = cur[k]
549
+ cur[keys[-1]] = value
550
+
551
+
552
+ def read_model(entry):
553
+ path = _config_path(entry)
554
+ cfg = entry.get("config") or {}
555
+ if not path or not os.path.isfile(path) or not cfg.get("format"):
556
+ return None
557
+ try:
558
+ text = _read_text(path)
559
+ except Exception:
560
+ return None
561
+ if cfg["format"] == "toml-line":
562
+ m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
563
+ return m.group(1) if m else None
564
+ if cfg["format"] == "toml-section":
565
+ table, leaf = _dotted_key(cfg)
566
+ return _toml_read_value(text, table, leaf)
567
+ if cfg["format"] == "json":
568
+ try:
569
+ v = json.loads(text).get("model")
570
+ if v:
571
+ return v
572
+ except Exception:
573
+ pass
574
+ m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
575
+ return m.group(1) if m else None
576
+ if cfg["format"] == "jsonc":
577
+ keys = (cfg.get("model_key") or "model").split(".")
578
+ try:
579
+ data = json.loads(_jsonc_strip_comments(text) or "{}")
580
+ except Exception:
581
+ return None
582
+ return _json_path_get(data, keys)
583
+ if cfg["format"] == "json-path":
584
+ try:
585
+ data = json.loads(text or "{}")
586
+ except Exception:
587
+ return None
588
+ return _json_path_get(data, (cfg.get("model_key") or "model").split("."))
589
+ if cfg["format"] == "yaml-line":
590
+ return _yaml_read_value(text, *_yaml_model_path(cfg))
591
+ return None
592
+
593
+
594
+ def _atomic_write_bytes(path, data):
595
+ """CLI 自家配置写盘统一走临时文件 + os.replace 原子替换。
596
+
597
+ 就地 write_bytes 有「截断→重写」窗口:并行评审爆发期多个 CLI 同时被
598
+ 拉起(bind_agent 先同步配置再 spawn),正在启动的 CLI 恰好读配置就会
599
+ 读到半截文件——opencode 对残缺 jsonc 的反应正是打一行横幅就退出码 1
600
+ (2026-09-30 14:56 实案)。os.replace 原地换名,读者要么见旧要么见新。
601
+ """
602
+ path = str(path)
603
+ d = os.path.dirname(path) or "."
604
+ fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".",
605
+ suffix=".tmp", dir=d)
606
+ try:
607
+ with os.fdopen(fd, "wb") as fh:
608
+ fh.write(data)
609
+ os.replace(tmp, path)
610
+ except Exception:
611
+ try:
612
+ os.remove(tmp)
613
+ except OSError:
614
+ pass
615
+ raise
616
+
617
+
618
+ def write_model(entry, model):
619
+ """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
620
+ json / json-path / jsonc / yaml-line。"""
621
+ if tmp_data_no_home_write():
622
+ return {"ok": False,
623
+ "error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
624
+ path = _config_path(entry)
625
+ cfg = entry.get("config") or {}
626
+ fmt = cfg.get("format")
627
+ if not path:
628
+ return {"ok": False,
629
+ "error": "配置路径无效或不在用户主目录内,已拒绝写入"}
630
+ if fmt not in _WRITABLE_FORMATS:
631
+ return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
632
+ model = (model or "").strip()
633
+ if not model:
634
+ return {"ok": False, "error": "模型名不能为空"}
635
+ # 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
636
+ # 校验通过后统一改用解析路径写入
637
+ home = os.path.abspath(os.path.expanduser("~"))
638
+ path = os.path.realpath(path)
639
+ try:
640
+ if os.path.commonpath([path, home]) != home:
641
+ return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
642
+ except ValueError:
643
+ return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
644
+ try:
645
+ if os.path.isfile(path):
646
+ shutil.copyfile(path, path + ".bak")
647
+ else:
648
+ # 各 CLI 首次运行都未必建主配置(grok 不建 config.toml、pi 不建
649
+ # settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
650
+ # 内置默认」);它正是用户层覆盖的落点,缺了按需创建
651
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
652
+ _atomic_write_bytes(path, b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
653
+ if fmt == "jsonc":
654
+ # mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
655
+ # _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
656
+ keys = (cfg.get("model_key") or "model").split(".")
657
+ text = _read_text(path, preserve_newlines=True)
658
+ new_text, ok = _jsonc_set(text, tuple(keys),
659
+ json.dumps(model, ensure_ascii=False))
660
+ if not ok:
661
+ return {"ok": False,
662
+ "error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
663
+ _atomic_write_bytes(path, new_text.encode("utf-8"))
664
+ elif fmt == "toml-line":
665
+ text = _read_text(path)
666
+ new_line = 'model = "%s"' % model
667
+ if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
668
+ text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
669
+ else:
670
+ text = text.rstrip("\n") + "\n" + new_line + "\n"
671
+ _atomic_write_bytes(path, text.encode("utf-8"))
672
+ elif fmt == "toml-section":
673
+ text = _read_text(path, preserve_newlines=True)
674
+ text = _toml_write_value(text, *_dotted_key(cfg), value=model)
675
+ _atomic_write_bytes(path, text.encode("utf-8"))
676
+ elif fmt == "yaml-line":
677
+ # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
678
+ # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
679
+ text = _read_text(path, preserve_newlines=True)
680
+ text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
681
+ _atomic_write_bytes(path, text.encode("utf-8"))
682
+ elif fmt == "json-path":
683
+ # openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
684
+ # 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
685
+ text = _read_text(path, preserve_newlines=True)
686
+ try:
687
+ data = json.loads(text) if text.strip() else {}
688
+ except Exception:
689
+ return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
690
+ _json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
691
+ _atomic_write_bytes(path,
692
+ json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
693
+ else:
694
+ try:
695
+ data = json.loads(_read_text(path))
696
+ except FileNotFoundError:
697
+ return {"ok": False,
698
+ "error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
699
+ except Exception:
700
+ return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
701
+ data["model"] = model
702
+ _atomic_write_bytes(path,
703
+ json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
704
+ return {"ok": True, "model": read_model(entry)}
705
+ except Exception as e:
706
+ return {"ok": False, "error": repr(e)}
707
+
708
+
709
+ # ---------------------------------------------------------------- 一键打开
710
+
711
+ def _port_open(port, timeout=0.5):
712
+ try:
713
+ with socket.create_connection(("127.0.0.1", port), timeout=timeout):
714
+ return True
715
+ except OSError:
716
+ return False
717
+
718
+
719
+ def launch_env(entry):
720
+ """打开交互/网页版时注入的子进程环境变量:与编排同源的绑定凭据
721
+ (dsh=DEEPSEEK_*,claude=ANTHROPIC_*,codex=ORCH_API_KEY)。交互进程
722
+ 脱离了编排链路,没有这层注入就拿不到 API key。无绑定时返回 {}。"""
723
+ try:
724
+ from . import modelhub # 惰性导入:modelhub 体量大且避免潜在环
725
+ b = modelhub.resolve_binding(entry["id"]) or {}
726
+ except Exception:
727
+ return {}
728
+ return dict(b.get("env") or {})
729
+
730
+
731
+ def _launch_log_path(entry):
732
+ """web 类启动日志的落点:id 白名单化后仅作文件名成分,最终路径必须仍围栏
733
+ 在数据目录内(catalog.json 用户可编辑,id 不可信;非白名单 id 用 crc32
734
+ 稳定代称——跨进程重启不变,「已在运行」回读上次日志才找得到)。"""
735
+ raw_id = str(entry.get("id") or "")
736
+ if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw_id):
737
+ stem = raw_id
738
+ else:
739
+ stem = "agent-%d" % (zlib.crc32(raw_id.encode("utf-8")) & 0xFFFFFFFF)
740
+ data_root = os.path.abspath(str(paths.DATA_DIR))
741
+ p = Path(data_root, "launch", stem + ".log")
742
+ try:
743
+ if os.path.commonpath([os.path.abspath(str(p)), data_root]) != data_root:
744
+ return None
745
+ except ValueError:
746
+ return None
747
+ return p
748
+
749
+
750
+ def _best_url(log_path, port):
751
+ """从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
752
+ try:
753
+ text = _read_text(str(log_path))
754
+ except Exception:
755
+ return None
756
+ urls = re.findall(r"https?://[^\s\"'<>]+", text)
757
+ same = [u for u in urls if ":%d" % port in u]
758
+ if not same:
759
+ return None
760
+ tokened = [u for u in same if "token=" in u]
761
+ return (tokened or same)[0]
762
+
763
+
764
+ def _yaml_model_ids(text, section):
765
+ """收集 section.models 序列里的全部模型 id(保持顺序)。段/键缺失返回 []。"""
766
+ lines = text.splitlines()
767
+ start, end = _yaml_span(lines, section)
768
+ if start is None:
769
+ return []
770
+ m_indent = None
771
+ for i in range(start + 1, end):
772
+ m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
773
+ if m:
774
+ m_indent = len(m.group(1))
775
+ start = i
776
+ break
777
+ if m_indent is None:
778
+ return []
779
+ ids = []
780
+ for ln in lines[start + 1:end]:
781
+ if not ln.strip():
782
+ continue
783
+ if len(ln) - len(ln.lstrip(" ")) <= m_indent:
784
+ break # models 列表结束(遇到同级或更浅缩进的键)
785
+ m = re.match(r"^\s*-\s+id\s*:\s*(.+?)\s*$", ln)
786
+ if m:
787
+ ids.append(_yaml_unquote(m.group(1)))
788
+ return ids
789
+
790
+
791
+ def _yaml_ensure_model_entry(text, section, model, context_window=1000000):
792
+ """确保 section.models 序列里有 id==model 的条目,缺则按现有条目形状追加
793
+ (id/name/contextWindow——dsh 的 catalog 校验要求 id 与 name 非空)。
794
+ 已存在或段/列表结构不完整时原文返回。返回 (new_text, added)。"""
795
+ eol = "\r\n" if "\r\n" in text else "\n"
796
+ lines = text.splitlines()
797
+ start, end = _yaml_span(lines, section)
798
+ if start is None:
799
+ return text, False
800
+ m_idx = m_indent = None
801
+ for i in range(start + 1, end):
802
+ m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
803
+ if m:
804
+ m_idx, m_indent = i, len(m.group(1))
805
+ break
806
+ if m_idx is None:
807
+ return text, False # 段内没有 models 键:不凭空造结构(保守)
808
+ item_indent = None
809
+ last_item = m_idx
810
+ i = m_idx + 1
811
+ while i < end:
812
+ ln = lines[i]
813
+ if not ln.strip():
814
+ i += 1
815
+ continue
816
+ if len(ln) - len(ln.lstrip(" ")) <= m_indent:
817
+ break # models 列表结束
818
+ m = re.match(r"^(\s*)-\s+id\s*:\s*(.+?)\s*$", ln)
819
+ if m:
820
+ item_indent = m.group(1)
821
+ last_item = i
822
+ if _yaml_unquote(m.group(2)) == model:
823
+ return text, False
824
+ elif re.match(r"^\s+\S", ln):
825
+ last_item = i # 条目的续属性行(name/contextWindow…)
826
+ i += 1
827
+ item_indent = item_indent or (" " * (m_indent + 2))
828
+ block = ["%s- id: %s" % (item_indent, _yaml_quote(model)),
829
+ "%s name: %s" % (item_indent, _yaml_quote(model)),
830
+ "%s contextWindow: %d" % (item_indent, context_window)]
831
+ lines[last_item + 1:last_item + 1] = block
832
+ return eol.join(lines).rstrip("\r\n") + eol, True
833
+
834
+
835
+ def _sync_dsh_settings(entry, model, base_url):
836
+ """dsh 专属:把绑定模型的端点与模型目录写进 ~/.dsh/settings.yaml 的
837
+ llm-deepseek 段(agent-default-model.model 由 write_model 负责)。
838
+ 端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
839
+ models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
840
+ 返回错误串或 None。"""
841
+ if tmp_data_no_home_write():
842
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
843
+ path = _config_path(entry)
844
+ if not path:
845
+ return "dsh 配置路径无效"
846
+ path = os.path.realpath(path)
847
+ home = os.path.abspath(os.path.expanduser("~"))
848
+ try:
849
+ if os.path.commonpath([path, home]) != home:
850
+ return "dsh 配置路径越出用户主目录,已拒绝"
851
+ except ValueError:
852
+ return "dsh 配置路径越出用户主目录,已拒绝"
853
+ try:
854
+ if os.path.isfile(path):
855
+ text = _read_text(path, preserve_newlines=True)
856
+ else:
857
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
858
+ text = ""
859
+ text2 = _yaml_write_value(text, "llm-deepseek", "baseURL", base_url)
860
+ text2, _added = _yaml_ensure_model_entry(text2, "llm-deepseek", model)
861
+ if text2 != text:
862
+ if os.path.isfile(path):
863
+ shutil.copyfile(path, path + ".bak")
864
+ _atomic_write_bytes(path, text2.encode("utf-8"))
865
+ return None
866
+ except Exception as e:
867
+ return repr(e)
868
+
869
+
870
+ def _dsh_selfcheck_model(entry):
871
+ """dsh 无绑定时自检:agent-default-model.model 必须在端点 models 列表内,
872
+ 否则 web UI 打开就是一个选不中的模型(glm-5.3-flash vs V4 端点的实况)。
873
+ 不在列表则改选列表第一个并写回。返回 (生效模型, note)。"""
874
+ path = _config_path(entry)
875
+ if not path or not os.path.isfile(path):
876
+ return None, ""
877
+ try:
878
+ text = _read_text(path, preserve_newlines=True)
879
+ except Exception:
880
+ return None, ""
881
+ cur = _yaml_read_value(text, "agent-default-model", "model")
882
+ ids = _yaml_model_ids(text, "llm-deepseek")
883
+ if not ids:
884
+ return cur, "dsh 端点未登记任何模型,请先在 dsh 侧配置模型目录"
885
+ if cur in ids:
886
+ return cur, ""
887
+ first = ids[0]
888
+ w = write_model(entry, first)
889
+ fixed = w.get("model") if w.get("ok") else None
890
+ note = "dsh 默认模型 %s 不在端点模型列表,已改选 %s" % (cur or "(空)", first)
891
+ if not fixed:
892
+ note += "(写入失败:%s)" % w.get("error")
893
+ return fixed, note
894
+
895
+
896
+ def _dsh_key_present():
897
+ """dsh 的密钥是否有着落:进程 env 或它自己的 ~/.dsh/.env(credentials-local)。"""
898
+ if os.environ.get("DEEPSEEK_API_KEY"):
899
+ return True
900
+ try:
901
+ envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
902
+ return "DEEPSEEK_API_KEY" in _read_text(envfile)
903
+ except Exception:
904
+ return False
905
+
906
+
907
+ def _toml_section_set(text, section, pairs):
908
+ """就地写 TOML 段([section] 下多键)。段存在则逐键替换,缺则整段追加在文末。
909
+ 只处理 codex config.toml 这种顶层简单段;返回 (new_text, changed)。"""
910
+ eol = "\r\n" if "\r\n" in text else "\n"
911
+ lines = text.splitlines()
912
+ start = end = None
913
+ header = "[%s]" % section
914
+ for i, ln in enumerate(lines):
915
+ if ln.strip() == header:
916
+ start = i
917
+ elif start is not None and ln.startswith("[") and ln.rstrip().endswith("]"):
918
+ end = i
919
+ break
920
+ if start is None:
921
+ block = [header] + ["%s = %s" % (k, v) for k, v in pairs]
922
+ if lines and lines[-1].strip():
923
+ lines.append("")
924
+ lines.extend(block)
925
+ return eol.join(lines).rstrip("\r\n") + eol, True
926
+ end = end if end is not None else len(lines)
927
+ changed = False
928
+ todo = dict(pairs)
929
+ for i in range(start + 1, end):
930
+ m = re.match(r"^(\s*)([A-Za-z0-9_.-]+)\s*=", lines[i])
931
+ if m and m.group(2) in todo:
932
+ lines[i] = "%s%s = %s" % (m.group(1), m.group(2), todo.pop(m.group(2)))
933
+ changed = True
934
+ if todo:
935
+ ins = start + 1
936
+ while ins < end and not lines[ins].strip():
937
+ ins += 1
938
+ for k, v in list(todo.items())[::-1]:
939
+ lines.insert(ins, "%s = %s" % (k, v))
940
+ changed = True
941
+ return eol.join(lines).rstrip("\r\n") + eol, changed
942
+
943
+
944
+ def _toml_top_set(text, key, value):
945
+ """写 TOML 顶层键(第一个 [段] 之前的区域)。返回 (new_text, changed)。"""
946
+ eol = "\r\n" if "\r\n" in text else "\n"
947
+ lines = text.splitlines()
948
+ pat = re.compile(r"^(\s*)%s\s*=\s*.+$" % re.escape(key))
949
+ for i, ln in enumerate(lines):
950
+ if pat.match(ln):
951
+ lines[i] = "%s%s = %s" % (pat.match(ln).group(1), key, value)
952
+ return eol.join(lines).rstrip("\r\n") + eol, True
953
+ first_section = next((i for i, ln in enumerate(lines)
954
+ if ln.startswith("[") and ln.rstrip().endswith("]")), len(lines))
955
+ lines.insert(first_section, "%s = %s" % (key, value))
956
+ return eol.join(lines).rstrip("\r\n") + eol, True
957
+
958
+
959
+ _TEST_HOST_SUFFIXES = (".test", ".example", ".invalid", ".localhost")
960
+
961
+
962
+ def _is_dead_endpoint(base):
963
+ """明显打不通的测试/保留地址(*.test、*.example、example.com 等)。
964
+ 这类端点落进 config.toml 后 codex 每次请求都撞死墙——2026-09-20
965
+ orch/p1.test 残留反复劫持全局配置的事故教训:宁可不写,不写必死端点。"""
966
+ host = (base or "").split("://", 1)[-1].split("/", 1)[0].split("@")[-1]
967
+ host = host.split(":")[0].lower().rstrip(".")
968
+ if not host:
969
+ return True
970
+ return (host in ("example.com", "example.org", "example.net", "localhost")
971
+ or host.endswith(_TEST_HOST_SUFFIXES))
972
+
973
+
974
+ def _sync_codex_settings(entry, model, cp):
975
+ """codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
976
+ ([model_providers.orch] 段 + 顶层 model_provider/model)。
977
+
978
+ codex 交互 TUI 不认编排的 -c 一次性覆盖,也不认 ORCH_API_KEY env——
979
+ 没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
980
+ 不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
981
+ _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
982
+ if _is_dead_endpoint(cp.get("base_url")):
983
+ return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
984
+ if tmp_data_no_home_write():
985
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
986
+ path = _config_path(entry)
987
+ if not path:
988
+ return "codex 配置路径无效"
989
+ path = os.path.realpath(path)
990
+ home = os.path.abspath(os.path.expanduser("~"))
991
+ try:
992
+ if os.path.commonpath([path, home]) != home:
993
+ return "codex 配置路径越出用户主目录,已拒绝"
994
+ except ValueError:
995
+ return "codex 配置路径越出用户主目录,已拒绝"
996
+ name = cp.get("name", "orch")
997
+ def q(v):
998
+ return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
999
+ if (cp.get("wire_api") or "responses") == "chat":
1000
+ # codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
1001
+ # 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
1002
+ return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
1003
+ " config.toml——请为 codex 绑定 responses 兼容的供应商")
1004
+ pairs = [("name", q(cp.get("name", name))),
1005
+ ("base_url", q(cp.get("base_url", ""))),
1006
+ ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
1007
+ ("wire_api", q(cp.get("wire_api", "responses")))]
1008
+ try:
1009
+ if os.path.isfile(path):
1010
+ text = _read_text(path, preserve_newlines=True)
1011
+ else:
1012
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1013
+ text = ""
1014
+ text2, _ = _toml_section_set(text, "model_providers.%s" % name, pairs)
1015
+ text2, _ = _toml_top_set(text2, "model_provider", q(name))
1016
+ if model:
1017
+ text2, _ = _toml_top_set(text2, "model", q(model))
1018
+ if text2 != text:
1019
+ if os.path.isfile(path):
1020
+ shutil.copyfile(path, path + ".bak")
1021
+ _atomic_write_bytes(path, text2.encode("utf-8"))
1022
+ return None
1023
+ except Exception as e:
1024
+ return repr(e)
1025
+
1026
+
1027
+ # ---------------------------------------------------------------- 打开前凭据注入
1028
+
1029
+ def _jsonc_scan_object(text, start):
1030
+ """扫描 text[start](须为 '{')起的 JSON(C) 对象:返回 (闭合偏移, 键表)。
1031
+ 键表为 [key, key_start, key_end, value_start, value_end](偏移相对整个 text,
1032
+ value_end 指向值结束后一格)。跳过字符串转义、// 与 /* */ 注释、嵌套括号,
1033
+ 未闭合时容错返回文末。"""
1034
+ n = len(text)
1035
+ i = start + 1
1036
+ keys = []
1037
+ state = "key"
1038
+ depth = 0
1039
+
1040
+ def skip_string(j):
1041
+ while j < n:
1042
+ if text[j] == "\\":
1043
+ j += 2
1044
+ continue
1045
+ if text[j] == '"':
1046
+ return j + 1
1047
+ j += 1
1048
+ return n
1049
+
1050
+ def skip_comment(j):
1051
+ if j + 1 < n and text[j + 1] == "/":
1052
+ e = text.find("\n", j)
1053
+ return n if e < 0 else e
1054
+ if j + 1 < n and text[j + 1] == "*":
1055
+ e = text.find("*/", j + 2)
1056
+ return n if e < 0 else e + 2
1057
+ return j + 1 # 非注释的孤立斜杠:当普通字符
1058
+
1059
+ while i < n:
1060
+ ch = text[i]
1061
+ if ch in " \t\r\n":
1062
+ i += 1
1063
+ continue
1064
+ if ch == "/":
1065
+ i = skip_comment(i)
1066
+ continue
1067
+ if state == "key":
1068
+ if ch == "}":
1069
+ return i, keys
1070
+ if ch == ",":
1071
+ i += 1
1072
+ continue
1073
+ if ch == '"':
1074
+ j = skip_string(i + 1)
1075
+ keys.append([text[i + 1:j - 1], i, j, 0, 0])
1076
+ state = "colon"
1077
+ i = j
1078
+ continue
1079
+ i += 1
1080
+ continue
1081
+ if state == "colon":
1082
+ if ch == ":":
1083
+ state = "value"
1084
+ i += 1
1085
+ continue
1086
+ # state == value
1087
+ if not keys:
1088
+ return n, keys # 结构异常:放弃扫描
1089
+ keys[-1][3] = i
1090
+ if ch in "{[":
1091
+ depth = 0
1092
+ j = i
1093
+ while j < n:
1094
+ c = text[j]
1095
+ if c == '"':
1096
+ j = skip_string(j + 1)
1097
+ continue
1098
+ if c == "/":
1099
+ j = skip_comment(j)
1100
+ continue
1101
+ if c in "{[":
1102
+ depth += 1
1103
+ elif c in "}]":
1104
+ depth -= 1
1105
+ if depth == 0:
1106
+ break
1107
+ j += 1
1108
+ keys[-1][4] = j + 1
1109
+ i = j + 1
1110
+ state = "key"
1111
+ elif ch == '"':
1112
+ j = skip_string(i + 1)
1113
+ keys[-1][4] = j
1114
+ i = j
1115
+ state = "key"
1116
+ else: # 数字 / true / false / null(或值后直接撞上 , } 换行的容错)
1117
+ j = i
1118
+ while j < n and text[j] not in ",}\r\n":
1119
+ j += 1
1120
+ keys[-1][4] = j
1121
+ # j==i 说明值位置直接是分隔符/换行(空值或状态残留)——必须前进一格,
1122
+ # 否则 while i < n 永远停在原地(死循环)
1123
+ i = j if j > i else j + 1
1124
+ state = "key"
1125
+ return n, keys
1126
+
1127
+
1128
+ def _jsonc_strip_comments(text):
1129
+ """剥掉 jsonc 的 // 行注释与 /* */ 块注释,供 json.loads 解析。
1130
+ 字符串字面量内部的 //($schema 的 https:// 等)不剥——逐字符扫描;
1131
+ 简单的 re.sub(r"//[^\\n]*") 会把 URL 截断导致解析失败。"""
1132
+ out = []
1133
+ i, n = 0, len(text)
1134
+ while i < n:
1135
+ ch = text[i]
1136
+ if ch == '"':
1137
+ j = i + 1
1138
+ while j < n:
1139
+ if text[j] == "\\":
1140
+ j += 2
1141
+ continue
1142
+ if text[j] == '"':
1143
+ break
1144
+ j += 1
1145
+ out.append(text[i:min(j + 1, n)])
1146
+ i = j + 1
1147
+ continue
1148
+ if ch == "/" and i + 1 < n and text[i + 1] == "/":
1149
+ e = text.find("\n", i)
1150
+ i = n if e < 0 else e # 行注释:换行符本身保留
1151
+ continue
1152
+ if ch == "/" and i + 1 < n and text[i + 1] == "*":
1153
+ e = text.find("*/", i + 2)
1154
+ i = n if e < 0 else e + 2
1155
+ out.append(" ")
1156
+ continue
1157
+ out.append(ch)
1158
+ i += 1
1159
+ return "".join(out)
1160
+
1161
+
1162
+ def _jsonc_set(text, path, value_json):
1163
+ """JSON(C) 顶层就地写键:path=("provider","orch") 或 ("model",)。
1164
+ 只动目标片段,其余文本(含注释与原格式)原样保留。返回 (new_text, ok)。
1165
+ 空文件按空对象起笔(write_model 缺文件按需创建的产物)。"""
1166
+ brace = text.find("{")
1167
+ if brace < 0 and not text.strip():
1168
+ return ('{\n "%s": %s\n}' % (path[0], value_json), True)
1169
+ if brace < 0:
1170
+ return text, False
1171
+ end, keys = _jsonc_scan_object(text, brace)
1172
+ head = path[0]
1173
+ if len(path) == 1:
1174
+ for _k, _ks, _ke, vs, ve in keys:
1175
+ if _k == head:
1176
+ return text[:vs] + value_json + text[ve:], True
1177
+ return _jsonc_insert_entry(text, brace, end, head, value_json), True
1178
+ # 二级路径:先定位一级键的值对象
1179
+ tgt = None
1180
+ for k, _ks, _ke, vs, ve in keys:
1181
+ if k == head:
1182
+ tgt = (vs, ve)
1183
+ break
1184
+ if tgt is None: # 一级键不存在:整体插入
1185
+ block = json.dumps({path[1]: json.loads(value_json)}, ensure_ascii=False)
1186
+ return _jsonc_insert_entry(text, brace, end, head, block), True
1187
+ vs, ve = tgt
1188
+ if text[vs:ve].lstrip()[0:1] != "{":
1189
+ return text, False # 一级值不是对象:保守放弃(不覆盖用户的非标结构)
1190
+ end2, keys2 = _jsonc_scan_object(text, vs)
1191
+ for k, _ks, _ke, v2s, v2e in keys2:
1192
+ if k == path[1]:
1193
+ return text[:v2s] + value_json + text[v2e:], True
1194
+ seg = text[vs:end2 + 1]
1195
+ new_seg = _jsonc_insert_entry(seg, 0, end2 - vs, path[1], value_json)
1196
+ if new_seg is None:
1197
+ return text, False
1198
+ return text[:vs] + new_seg + text[end2 + 1:], True
1199
+
1200
+
1201
+ def _jsonc_insert_entry(text, brace, end, key, value_json):
1202
+ """在 {brace..end} 对象的开头插入 "key": value(带尾逗号,不依赖原文件的
1203
+ 逗号风格);对象为空时去掉多余逗号。"""
1204
+ m = re.match(r"\{([ \t\r\n]*)", text[brace:end + 1])
1205
+ first = brace + (m.end(1) if m else 1) # m 从 brace 起 match:end(1) 已含 '{'
1206
+ if first >= end: # 空对象 {}
1207
+ return text[:brace + 1] + ' "%s": %s ' % (key, value_json) + text[end:]
1208
+ return text[:first] + '"%s": %s,\n ' % (key, value_json) + text[first:]
1209
+
1210
+
1211
+ def _sync_settings_env(path, updates, remove_keys=()):
1212
+ """把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
1213
+ 启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
1214
+ 不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
1215
+ if tmp_data_no_home_write():
1216
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1217
+ try:
1218
+ if os.path.isfile(path):
1219
+ text = _read_text(path)
1220
+ try:
1221
+ data = json.loads(text)
1222
+ except Exception:
1223
+ return "settings.json 不是合法 JSON,已中止(避免覆盖)"
1224
+ else:
1225
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1226
+ data = {}
1227
+ if not isinstance(data, dict):
1228
+ return "settings.json 结构异常(顶层不是对象),已中止"
1229
+ env = data.get("env")
1230
+ if not isinstance(env, dict):
1231
+ env = {}
1232
+ for k in remove_keys:
1233
+ env.pop(k, None)
1234
+ env.update(updates)
1235
+ data["env"] = env
1236
+ if os.path.isfile(path):
1237
+ shutil.copyfile(path, path + ".bak")
1238
+ _atomic_write_bytes(path, json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1239
+ return None
1240
+ except Exception as e:
1241
+ return repr(e)
1242
+
1243
+
1244
+ def _guard_home(path):
1245
+ """展开并校验配置路径必须在用户主目录内;通过则返回 realpath,否则 None。"""
1246
+ if not path:
1247
+ return None
1248
+ path = os.path.realpath(path)
1249
+ home = os.path.abspath(os.path.expanduser("~"))
1250
+ try:
1251
+ if os.path.commonpath([path, home]) != home:
1252
+ return None
1253
+ except ValueError:
1254
+ return None
1255
+ return path
1256
+
1257
+
1258
+ def _sync_claude_settings(entry, model, prov):
1259
+ """claude-code 专属:把 anthropic 供应商的端点+密钥+模型写进
1260
+ ~/.claude/settings.json 的 env 段(交互 TUI 与无头共用该文件,只认
1261
+ ANTHROPIC_*;编排降级链给的 ORCH_API_KEY 对它等于没 key)。
1262
+ 返回错误串或 None。"""
1263
+ path = _guard_home(_config_path(entry))
1264
+ if not path:
1265
+ return "claude 配置路径无效或越出用户主目录,已拒绝"
1266
+ updates = {"ANTHROPIC_BASE_URL": prov.get("base_url") or "",
1267
+ # AUTH_TOKEN 走 Bearer 头(Z.ai 等原生 anthropic 网关的用法);
1268
+ # 与 x-api-key 互斥,清掉可能残留的 ANTHROPIC_API_KEY 防止带错头
1269
+ "ANTHROPIC_AUTH_TOKEN": prov.get("api_key") or ""}
1270
+ if model:
1271
+ updates["ANTHROPIC_MODEL"] = model
1272
+ return _sync_settings_env(path, updates, remove_keys=("ANTHROPIC_API_KEY",))
1273
+
1274
+
1275
+ def _sync_qwen_settings(entry, model, prov):
1276
+ """qwencode 专属:openai 兼容供应商写进 ~/.qwen/settings.json 的 env 段
1277
+ (qwen-code 实测认 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL,存在即
1278
+ 优先走 openai 兼容通道——2026-09-15 真机对维云端点实测请求到达并鉴权)。
1279
+ anthropic 协议未实证,不开(协议不匹配时宁可提示)。返回错误串或 None。"""
1280
+ path = _guard_home(_config_path(entry))
1281
+ if not path:
1282
+ return "qwen 配置路径无效或越出用户主目录,已拒绝"
1283
+ # 内闸防 anthropic 面混进 OPENAI_BASE_URL(2026-09-24 戍边骑奴案:史上
1284
+ # 某版把 .../api/anthropic 写进去,qwen 拿 openai 协议打 anthropic 路由,
1285
+ # 网关恒回 404 被误判「网关限流」,每轮 run 起点白烧 2-3 次重试)。
1286
+ # auto 协议不拦——适配层探出的 openai 面由外层 launch_pick 负责把关。
1287
+ if str(prov.get("protocol") or "").lower() == "anthropic":
1288
+ return ("qwen 只认 OpenAI 兼容端点,拒绝把 anthropic 面 %s 写进 OPENAI_BASE_URL"
1289
+ % (prov.get("base_url") or "?"))
1290
+ updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
1291
+ "OPENAI_BASE_URL": prov.get("base_url") or ""}
1292
+ if model:
1293
+ updates["OPENAI_MODEL"] = model
1294
+ return _sync_settings_env(path, updates)
1295
+
1296
+
1297
+ def _opencode_config_candidates(entry):
1298
+ """opencode 配置的候选路径:catalog 登记的 jsonc 优先,其次同目录的
1299
+ opencode.json(opencode 两种文件名都认,用户现有安装多用 json)。"""
1300
+ out = []
1301
+ primary = _config_path(entry)
1302
+ if primary:
1303
+ out.append(primary)
1304
+ alt = os.path.join(os.path.abspath(os.path.expanduser("~/.config/opencode")),
1305
+ "opencode.json")
1306
+ if alt not in out:
1307
+ out.append(alt)
1308
+ return out
1309
+
1310
+
1311
+ def _sync_mimo_settings(entry, model, prov):
1312
+ """mimo(opencode 系)专属:把绑定供应商落进 mimocode.jsonc 的
1313
+ provider.codebee 块。mimo 不读任何 env key(内嵌文档明示
1314
+ MIMO_API_KEY is never read),唯一凭据通道是配置内 provider.options.apiKey
1315
+ ——此前只有模型名提示,绑定后照样裸奔:2026-09-24 禅道双单实案,评审器
1316
+ 连续 20+ 轮全灭在 Invalid API Key,而 Key 本身是好的。
1317
+ 只吃 OpenAI 兼容端点;anthropic 协议供应商明确拒绝(不猜 wire)。"""
1318
+ if tmp_data_no_home_write():
1319
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1320
+ if str(prov.get("protocol") or "").lower() != "openai":
1321
+ return ("mimo 只吃 OpenAI 兼容端点,供应商 %s 是 %s 协议——请绑 openai 协议供应商"
1322
+ % (prov.get("name") or prov.get("id") or "?", prov.get("protocol") or "?"))
1323
+ key = str(prov.get("api_key") or "")
1324
+ if not key:
1325
+ ks = prov.get("keys") or []
1326
+ key = str((ks[0] or {}).get("key") or "") if ks else ""
1327
+ base = str(prov.get("base_url") or "").rstrip("/")
1328
+ if not key or not base:
1329
+ return "供应商缺少 API 地址或密钥,无法写入 mimo 配置"
1330
+ path = _config_path(entry)
1331
+ if not path:
1332
+ return "mimo 配置路径无效"
1333
+ try:
1334
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1335
+ text = ""
1336
+ if os.path.isfile(path):
1337
+ text = _read_text(path, preserve_newlines=True)
1338
+ shutil.copyfile(path, path + ".bak")
1339
+ block = {"npm": "@ai-sdk/openai-compatible",
1340
+ "options": {"baseURL": base, "apiKey": key},
1341
+ "models": {model: {}}}
1342
+ new_text, ok = _jsonc_set(text, ("provider", "codebee"),
1343
+ json.dumps(block, ensure_ascii=False))
1344
+ if not ok:
1345
+ return "mimocode.jsonc 结构异常,未能写入 provider 块(已避免覆盖)"
1346
+ new_text, ok = _jsonc_set(new_text, ("model",),
1347
+ json.dumps("codebee/" + model))
1348
+ if not ok:
1349
+ return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
1350
+ _atomic_write_bytes(path, new_text.encode("utf-8"))
1351
+ return None
1352
+ except OSError as e:
1353
+ return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
1354
+
1355
+
1356
+ def _sync_opencode_settings(entry, model, prov):
1357
+ """opencode 专属:把绑定供应商写进其配置的 provider.orch 段 + 顶层 model
1358
+ (opencode 交互 TUI 只认自家配置文件里的凭据,ORCH_API_KEY env 对它等于
1359
+ 没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
1360
+ 全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1361
+ 文本级就地 patch(保留注释)。返回错误串或 None。"""
1362
+ if tmp_data_no_home_write():
1363
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1364
+ npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1365
+ base = prov.get("base_url") or ""
1366
+ if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
1367
+ # @ai-sdk/anthropic 在 baseURL 后只拼 /messages(官方默认 baseURL 本身带
1368
+ # /v1),而 models.json 里 anthropic 供应商的 base 不带 /v1(modelhub
1369
+ # 发请求时自己补)——不补会打到 <host>/messages,网关回 "Not Allowed"
1370
+ # (2026-09-17 公司Anthropic 实测)。
1371
+ base = base.rstrip("/") + "/v1"
1372
+ block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
1373
+ "options": {"baseURL": base,
1374
+ "apiKey": prov.get("api_key") or ""},
1375
+ "models": {model: {"name": model}} if model else {}}
1376
+ top_perms = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
1377
+ top_model = ("orch/" + model) if model else ""
1378
+ targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
1379
+ or [_opencode_config_candidates(entry)[0]]
1380
+ errs = []
1381
+ for path in targets:
1382
+ try:
1383
+ path = os.path.realpath(path)
1384
+ home = os.path.abspath(os.path.expanduser("~"))
1385
+ try:
1386
+ if os.path.commonpath([path, home]) != home:
1387
+ errs.append("路径越出主目录:%s" % path)
1388
+ continue
1389
+ except ValueError:
1390
+ errs.append("路径越出主目录:%s" % path)
1391
+ continue
1392
+ if os.path.isfile(path):
1393
+ text = _read_text(path, preserve_newlines=True)
1394
+ else:
1395
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1396
+ text = ""
1397
+ try:
1398
+ data = json.loads(text) if text.strip() else {}
1399
+ except Exception:
1400
+ data = None
1401
+ if data is not None: # 纯 JSON:整体读改写(保序)
1402
+ if not isinstance(data, dict):
1403
+ errs.append("结构异常(顶层不是对象):%s" % path)
1404
+ continue
1405
+ provs = data.get("provider")
1406
+ if not isinstance(provs, dict):
1407
+ provs = {}
1408
+ provs["orch"] = block
1409
+ data["provider"] = provs
1410
+ if top_model:
1411
+ data["model"] = top_model
1412
+ # 无人值守必配:headless 下 opencode 工具调用默认要审批,全部被
1413
+ # 拒("The user rejected permission...",2026-09-17 实测)——
1414
+ # 按用户拍板的「默认给全部权限」写入放行段
1415
+ data["permission"] = top_perms
1416
+ new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1417
+ else: # JSONC(带注释):文本级 patch
1418
+ block_json = json.dumps(block, ensure_ascii=False)
1419
+ new_text, ok = _jsonc_set(text, ("provider", "orch"), block_json)
1420
+ if not ok:
1421
+ errs.append("JSONC 就地改写失败:%s" % path)
1422
+ continue
1423
+ if top_model:
1424
+ new_text, _ = _jsonc_set(new_text, ("model",),
1425
+ json.dumps(top_model))
1426
+ new_text, _ = _jsonc_set(new_text, ("permission",),
1427
+ json.dumps(top_perms))
1428
+ if os.path.isfile(path):
1429
+ shutil.copyfile(path, path + ".bak")
1430
+ _atomic_write_bytes(path, new_text.encode("utf-8"))
1431
+ except Exception as e:
1432
+ errs.append("%s: %r" % (path, e))
1433
+ return ";".join(errs) or None
1434
+
1435
+
1436
+ def _kimi_render(prov, model):
1437
+ """渲染 kimi-code 的 CodeBee 托管块(顶层键在前,表在后——TOML 语义)。"""
1438
+ import re as _re
1439
+ ptype = "anthropic" if prov.get("protocol") == "anthropic" else "openai"
1440
+ base = prov.get("base_url") or ""
1441
+ alias = model or "default"
1442
+ pname = (prov.get("name") or "CodeBee").replace("\"", "")
1443
+ return (
1444
+ "# >>> CodeBee managed (do not edit between markers) >>>\n"
1445
+ "defaultProvider = \"orch\"\n"
1446
+ "defaultModel = \"%s\"\n"
1447
+ "yolo = true\n"
1448
+ "defaultPermissionMode = \"yolo\"\n"
1449
+ "\n"
1450
+ "[providers.orch]\n"
1451
+ "type = \"%s\"\n"
1452
+ "name = \"%s\"\n"
1453
+ "baseUrl = \"%s\"\n"
1454
+ "apiKey = \"%s\"\n"
1455
+ "\n"
1456
+ "[models.\"%s\"]\n"
1457
+ "provider = \"orch\"\n"
1458
+ "model = \"%s\"\n"
1459
+ "maxContextSize = 131072\n"
1460
+ "displayName = \"%s · %s\"\n"
1461
+ "# <<< CodeBee managed <<<\n"
1462
+ % (alias, ptype, pname, base,
1463
+ (prov.get("api_key") or "").replace("\"", ""),
1464
+ alias, alias, pname, alias))
1465
+
1466
+
1467
+ def _sync_kimi_settings(entry, model, prov):
1468
+ """kimi-code 专属:把绑定供应商写进 ~/.kimi-code/config.toml。
1469
+
1470
+ schema 从官方 bundle 反推(2026-09-17):顶层 defaultProvider/defaultModel/
1471
+ yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
1472
+ baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
1473
+ 要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
1474
+ 之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
1475
+ if tmp_data_no_home_write():
1476
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1477
+ import re as _re
1478
+ top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
1479
+ targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
1480
+ errs = []
1481
+ for path in targets:
1482
+ try:
1483
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1484
+ text = ""
1485
+ if os.path.isfile(path):
1486
+ text = _read_text(path, preserve_newlines=True)
1487
+ shutil.copyfile(path, path + ".bak")
1488
+ # 移除旧托管块与旧顶层键(防重复/防键落进别的表)
1489
+ text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
1490
+ "", text, flags=_re.S)
1491
+ for k in top_keys:
1492
+ text = _re.sub(r"(?m)^%s\s*=.*$\n?" % k, "", text)
1493
+ body = _re.sub(r"\n+$", "\n", text).lstrip("\n")
1494
+ managed = _kimi_render(prov, model)
1495
+ has_table = _re.search(r"(?m)^\[", body) is not None
1496
+ top = "".join("%s\n" % ln for ln in managed.split("\n")
1497
+ if _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1498
+ tables = "\n".join(ln for ln in managed.split("\n")
1499
+ if not _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1500
+ if has_table:
1501
+ new_text = top + "\n" + body + "\n" + tables
1502
+ else:
1503
+ new_text = (body + "\n" if body else "") + managed
1504
+ _atomic_write_bytes(path, new_text.encode("utf-8"))
1505
+ except Exception as e:
1506
+ errs.append("%s: %r" % (path, e))
1507
+ return ";".join(errs) or None
1508
+
1509
+
1510
+ # pi 的自定义供应商注册表与 settings.json 同目录(pi 的 getModelsPath()),
1511
+ # 供应商名固定为一个托管位,绑定怎么换都只重写这一块。
1512
+ _PI_PROVIDER = "codebee"
1513
+ _PI_API_BY_PROTOCOL = {"anthropic": "anthropic-messages", "openai": "openai-completions"}
1514
+ # 各协议下「编排注入的那把密钥」env 名:models.json 用 $VAR 引用取值,
1515
+ # 配置文件里不落明文(与 dsh 走 DEEPSEEK_API_KEY 同一原则)
1516
+ _PI_KEY_ENV = {"anthropic": ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"),
1517
+ "openai": ("ORCH_API_KEY", "OPENAI_API_KEY")}
1518
+
1519
+
1520
+ def _pi_models_path(entry):
1521
+ """由 catalog 里 settings.json 的路径推导同目录 models.json。
1522
+
1523
+ 不另存一份绝对路径:pi 用同一个 getAgentDir() 取这两件,硬编码第二份
1524
+ 路径会在用户改 PI_DIR/换平台时与实际脱节。"""
1525
+ p = _config_path(entry)
1526
+ return os.path.join(os.path.dirname(p), "models.json") if p else None
1527
+
1528
+
1529
+ def _pi_write_json(path, mutate):
1530
+ """读—改—写一件 JSON 配置;非法 JSON 或顶层非对象一律中止不覆盖。
1531
+ 原文件先落 .bak 再写(与其余 CLI 同步同一套)。返回错误串或 None。"""
1532
+ text = ""
1533
+ if os.path.isfile(path):
1534
+ text = _read_text(path, preserve_newlines=True)
1535
+ try:
1536
+ data = json.loads(text) if text.strip() else {}
1537
+ except Exception:
1538
+ return "%s 不是合法 JSON,已中止(避免覆盖)" % os.path.basename(path)
1539
+ if not isinstance(data, dict):
1540
+ return "%s 结构异常(顶层不是对象),已中止" % os.path.basename(path)
1541
+ mutate(data)
1542
+ if os.path.isfile(path):
1543
+ shutil.copyfile(path, path + ".bak")
1544
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1545
+ _atomic_write_bytes(path,
1546
+ json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1547
+ return None
1548
+
1549
+
1550
+ def _pi_merge_models(data, block):
1551
+ provs = data.get("providers")
1552
+ if not isinstance(provs, dict):
1553
+ provs = {}
1554
+ old = provs.get(_PI_PROVIDER)
1555
+ merged = dict(old) if isinstance(old, dict) else {}
1556
+ merged.update(block) # 端点/协议/密钥引用/模型由托管段定,用户自加的 compat 等保留
1557
+ provs[_PI_PROVIDER] = merged
1558
+ data["providers"] = provs
1559
+
1560
+
1561
+ def _pi_merge_settings(data, model):
1562
+ data["defaultProvider"] = _PI_PROVIDER
1563
+ data["defaultModel"] = model
1564
+
1565
+
1566
+ def _sync_pi_settings(entry, model, prov, env=None):
1567
+ """pi 专属注入:把绑定供应商写进 ~/.pi/agent/models.json 的 providers.codebee,
1568
+ 再把 settings.json 的 defaultProvider/defaultModel 指过去。
1569
+
1570
+ pi 解析启动模型要 (defaultProvider, defaultModel) 二元组命中 models.json 注册表、
1571
+ 且该供应商有凭据,否则回落到「已知供应商的默认模型」——2026-09-24 pi 19 连败、
1572
+ 零次真实成功即卡在这:这边只写 defaultModel、defaultProvider 留空,pi 便拿内置
1573
+ anthropic 的 claude-opus-4-8 去打注入的 Bigmodel 端点,被上游一句
1574
+ `403 {"error":{"type":"forbidden","message":"Request not allowed"}}` 挡回。
1575
+ anthropic 面额外置 authHeader(Bearer 头),与 claude-code 走同一把
1576
+ ANTHROPIC_AUTH_TOKEN 的既有实证保持一致。返回错误串或 None。"""
1577
+ if tmp_data_no_home_write():
1578
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1579
+ proto = str(prov.get("protocol") or "").lower()
1580
+ api = _PI_API_BY_PROTOCOL.get(proto)
1581
+ env = env or {}
1582
+ if not model or not api:
1583
+ return ("pi 只认 anthropic / openai 协议供应商(当前 %s)——"
1584
+ "请在「模型调度(可选)」页为 Pi 绑一个协议明确、带模型的供应商"
1585
+ % (proto or "未指定协议"))
1586
+ key_env = next((k for k in _PI_KEY_ENV[proto] if env.get(k)), "")
1587
+ base = ""
1588
+ if proto == "anthropic":
1589
+ base = str(env.get("ANTHROPIC_BASE_URL") or prov.get("base_url") or "")
1590
+ else:
1591
+ base = str(env.get("OPENAI_API_BASE") or prov.get("base_url") or "")
1592
+ base = base.rstrip("/")
1593
+ if not base or not key_env:
1594
+ return "pi 注入缺少可用端点或密钥环境变量,未写入配置"
1595
+ block = {"baseUrl": base, "api": api, "apiKey": "$" + key_env,
1596
+ "models": [{"id": model}]}
1597
+ if proto == "anthropic":
1598
+ block["authHeader"] = True
1599
+ settings_path = _config_path(entry)
1600
+ models_path = _pi_models_path(entry)
1601
+ if not settings_path or not models_path:
1602
+ return "pi 配置路径无效或越出用户主目录,已拒绝"
1603
+ for path, mutate in ((models_path, lambda d: _pi_merge_models(d, block)),
1604
+ (settings_path, lambda d: _pi_merge_settings(d, model))):
1605
+ err = _pi_write_json(path, mutate)
1606
+ if err:
1607
+ return err
1608
+ return None
1609
+
1610
+
1611
+ def _claude_sync_enabled():
1612
+ """「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
1613
+ 关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
1614
+ 防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
1615
+ try:
1616
+ from . import settings
1617
+ return settings.load().get("claude_config_sync", True)
1618
+ except Exception:
1619
+ return True
1620
+
1621
+
1622
+ def sync_cli_config_now(agent_id):
1623
+ """运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
1624
+ 绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
1625
+ try:
1626
+ if agent_id == "claude-code" and not _claude_sync_enabled():
1627
+ return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
1628
+ from . import modelhub
1629
+ entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
1630
+ if not entry:
1631
+ return ""
1632
+ b = modelhub.bindings().get(agent_id) or {}
1633
+ note = _sync_agent_injection(entry, b)
1634
+ if note:
1635
+ return "已自动注入 %s 配置:%s" % (agent_id, note)
1636
+ return ""
1637
+ except Exception as e:
1638
+ return "自动注入失败: %r" % e
1639
+
1640
+
1641
+ # 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
1642
+ # 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
1643
+ _AGENT_INJECTORS = {
1644
+ "claude-code": (("anthropic",), _sync_claude_settings),
1645
+ "opencode": (("anthropic", "openai"), _sync_opencode_settings),
1646
+ "qwencode": (("openai",), _sync_qwen_settings),
1647
+ "kimi-code": (("openai", "anthropic"), _sync_kimi_settings),
1648
+ "mimo-code": (("openai",), _sync_mimo_settings),
1649
+ }
1650
+
1651
+ # 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
1652
+ # (mimo 已实证配置注入:provider.codebee 块(apiKey 走 options);grok 吃 XAI_API_KEY 但
1653
+ # 无端点 env 可指中转,openai 协议供应商也用不上)
1654
+ _NO_CHANNEL_HINT = ("grok-build",) # mimo 已有 provider 块注入(2026-09-24);
1655
+ # pi 已有 providers.codebee 注入(2026-09-24 403 案)
1656
+
1657
+
1658
+ def _sync_agent_injection(entry, binding):
1659
+ """打开前把绑定链里第一个可注入供应商落进 CLI 自家配置。与编排降级链解耦:
1660
+ 协议不匹配不降级(claude 拿 openai 的 key 等于没 key)。返回给用户看的提示
1661
+ (成功注入 / 不可用原因),None=该 CLI 无需处理。"""
1662
+ from . import modelhub
1663
+ spec = _AGENT_INJECTORS.get(entry["id"])
1664
+ if spec:
1665
+ protocols, injector = spec
1666
+ if entry["id"] == "claude-code" and not _claude_sync_enabled():
1667
+ return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
1668
+ pick, note = modelhub.launch_pick(entry["id"], protocols)
1669
+ if pick:
1670
+ prov = pick["provider"]
1671
+ err = injector(entry, pick["model"], prov)
1672
+ if err:
1673
+ return "%s 凭据同步失败:%s(打开后可能需在其自带界面登录)" % (
1674
+ entry.get("name", entry["id"]), err)
1675
+ return "已注入 %s(%s · %s)" % (prov.get("name") or "供应商",
1676
+ prov.get("protocol"), prov.get("base_url", ""))
1677
+ return note
1678
+ if entry["id"] in _NO_CHANNEL_HINT:
1679
+ if binding.get("env"):
1680
+ return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
1681
+ return "未指定可用供应商:打开后使用 CLI 自带登录;需要固定注入请到「模型调度(可选)」页指定"
1682
+ return None
1683
+
1684
+
1685
+ def _sync_launch_model(entry, binding):
1686
+ """打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
1687
+ 交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
1688
+ 「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
1689
+ codex 已改为不落盘(2026-09-21 起:全局 model_provider 归 CC Switch/
1690
+ 用户管理,编排任务的绑定走 runner 的 -c 一次性注入)。返回给用户
1691
+ 看的同步笔记列表。
1692
+
1693
+ 笔记契约:失败项必须含「失败」字样(或英文 failed)——
1694
+ sync_runtime_config 据此判定同步失败并触发 ENV_BLOCK 预检,
1695
+ 新增失败路径不得改用其他措辞。"""
1696
+ notes = []
1697
+ model = (binding.get("model") or "").strip()
1698
+ prov = binding.get("provider") or {}
1699
+ fmt = (entry.get("config") or {}).get("format")
1700
+ is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1701
+ is_codex = entry["id"] in ("codex-cli", "codex")
1702
+ cp = binding.get("codex_provider")
1703
+ if is_codex:
1704
+ # codex 全局配置不落盘(2026-09-21 用户决策):全局 model_provider 交还
1705
+ # CC Switch/用户手动管理,一键打开不再顶掉外部选择。编排任务不受影响
1706
+ # ——runner 的 -c 一次性注入仍带绑定供应商;交互 TUI 打开即用外部当前
1707
+ # 配置,想用绑定供应商起编排任务即可。
1708
+ notes.append("codex 配置保持外部管理(绑定 %s 仅用于编排 -c 注入,未写 config.toml)"
1709
+ % (cp.get("name") or "orch"))
1710
+ return notes
1711
+ if model and fmt in _WRITABLE_FORMATS:
1712
+ w = write_model(entry, model)
1713
+ notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
1714
+ else "模型同步失败:%s" % w.get("error"))
1715
+ elif model and not is_dsh and entry["id"] not in _AGENT_INJECTORS:
1716
+ # 有专属注入通道的 CLI 由 _sync_agent_injection 负责落模型(含 jsonc),
1717
+ # 不再报「只读配置」以免与注入成功的提示互相矛盾
1718
+ notes.append("该工具模型为只读配置,按其现有配置打开(绑定模型 %s 未自动写入)" % model)
1719
+ if is_dsh and model and prov.get("base_url"):
1720
+ err = _sync_dsh_settings(entry, model, prov["base_url"])
1721
+ notes.append("dsh 端点已同步为 %s" % prov["base_url"] if not err
1722
+ else "dsh 端点同步失败:%s" % err)
1723
+ elif is_dsh and not model:
1724
+ _fixed, note = _dsh_selfcheck_model(entry)
1725
+ if note:
1726
+ notes.append(note)
1727
+ if is_codex and cp:
1728
+ err = _sync_codex_settings(entry, model, cp)
1729
+ notes.append("codex 供应商已同步为 %s" % cp.get("base_url", "") if not err
1730
+ else "codex 供应商同步失败:%s" % err)
1731
+ if entry["id"] == "pi" and model and prov:
1732
+ # pi 只认 settings.json 的 defaultModel 配不上供应商:端点与模型注册表在
1733
+ # models.json,两件必须同源一起写(binding 解析结果就是编排注入 env 的
1734
+ # 那一条,故不走 launch_pick,避免配置与 env 各说各话)
1735
+ err = _sync_pi_settings(entry, model, prov, binding.get("env") or {})
1736
+ notes.append("Pi 供应商已注入 providers.%s · %s" % (_PI_PROVIDER, model)
1737
+ if not err else "Pi 配置注入失败:%s" % err)
1738
+ inj = _sync_agent_injection(entry, binding)
1739
+ if inj:
1740
+ notes.append(inj)
1741
+ return notes
1742
+
1743
+
1744
+ # kind(runner 调用族)→ catalog 条目 id:flow/编排智能体的 id 常是业务名
1745
+ # (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
1746
+ _KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
1747
+ "qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
1748
+ _RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
1749
+
1750
+
1751
+ def _runtime_cfg_hash(entry):
1752
+ """CLI 自家配置文件的内容指纹;文件不存在返回 None。
1753
+
1754
+ pi 的绑定分两件落盘(settings.json 选模型 + models.json 定义供应商与端点),
1755
+ 只哈希 settings.json 会让 models.json 被外部毒写后逃过重写——kimi 毒配置同族。"""
1756
+ paths = [_safe_config_path((entry.get("config") or {}).get("path"))]
1757
+ if entry.get("id") == "pi":
1758
+ paths.append(_pi_models_path(entry))
1759
+ digests = []
1760
+ for p in paths:
1761
+ try:
1762
+ if p and os.path.isfile(p):
1763
+ with open(p, "rb") as fh:
1764
+ digests.append(hashlib.sha256(fh.read()).hexdigest())
1765
+ except Exception:
1766
+ pass
1767
+ return "|".join(digests) if digests else None
1768
+
1769
+
1770
+ def sync_runtime_config(agent):
1771
+ """运行前防线:把「CLI 绑定」页当前链首落进该 CLI 自家配置——与一键打开
1772
+ 同一套 _sync_launch_model 写盘,不改任何语义。
1773
+
1774
+ 绑定换供应商后 CLI 自家配置不会自愈,而同步过去只发生在一键打开:
1775
+ 2026-09-18 opencode 钉死讯飞 404 旧端点案——绑定早切到云知声,起跑撞旧
1776
+ 配置 2 秒 UnknownError,3 次续跑全灭。bind_agent 入口调用本函数即全覆盖
1777
+ (死链补位也在覆盖内:_dead_binding_substitute 内部同样走 bind_agent)。
1778
+
1779
+ 指纹缓存:同绑定(模型/供应商/端点/KEY/codex 段)只写一次盘,步骤级高频
1780
+ 调用近零成本,也不与用户手工编辑配置打架(绑定没变就不动);无绑定或
1781
+ 条目无配置段直接返回(测试空转,绝不碰真实家目录)。返回 ``True`` 表示
1782
+ 已完成或无需同步,``False`` 表示同步失败;调用方必须把失败作为 ENV_BLOCK
1783
+ 收口,不能让旧配置继续执行。"""
1784
+ try:
1785
+ cand = [agent.get("id"), _KIND_ENTRY.get(agent.get("kind") or "")]
1786
+ entry = None
1787
+ for x in cand:
1788
+ if x:
1789
+ entry = catalog.by_id(x)
1790
+ if entry:
1791
+ break
1792
+ if entry is None or not (entry.get("config") or {}).get("path"):
1793
+ return True
1794
+ if entry["id"] in ("codex-cli", "codex"):
1795
+ return True # codex 绝不在运行防线上落盘(2026-09-20 orch 劫持事故):
1796
+ # 编排步骤走 runner 的 -c 一次性注入,不依赖 config.toml;
1797
+ # 在这里写盘会把全局 ~/.codex/config.toml 的 model_provider
1798
+ # 顶掉(CC Switch / 用户手动选的供应商被劫持)。交互 TUI
1799
+ # 场景由 launch() 自行同步,且端点防线在 _sync_codex_settings。
1800
+ from . import modelhub
1801
+ binding = modelhub.resolve_binding(entry["id"]) or {}
1802
+ model = (binding.get("model") or "").strip()
1803
+ env = binding.get("env") or {}
1804
+ if not model and not env:
1805
+ return True # 没配链或链全死:没有可落的目标,绝不动用户配置
1806
+ # 指纹取解析结果整体:env 里带端点+KEY(provider 字典不含原始 KEY,
1807
+ # 换 KEY 必须可见),codex 段单独入纹
1808
+ fp = (model, repr(sorted(env.items())),
1809
+ repr(binding.get("codex_provider") or "")[:300])
1810
+ with _RUNTIME_SYNC["lock"]:
1811
+ if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
1812
+ and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
1813
+ return True
1814
+ # 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
1815
+ # 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
1816
+ # ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
1817
+ # 命中跳过重写,kimi 带毒配置静默退出码 1
1818
+ notes = _sync_launch_model(entry, binding)
1819
+ # 测试隔离闸明确拒绝写真实用户配置;这是安全跳过,不是生产同步
1820
+ # 失败。测试进程仍需把绑定 env 传给子进程,不能被误判成 ENV_BLOCK。
1821
+ if any("测试数据目录" in str(note) and "拦截" in str(note)
1822
+ for note in (notes or ())):
1823
+ return True
1824
+ failed = any("失败" in str(note) or "failed" in str(note).lower()
1825
+ for note in (notes or ()))
1826
+ if failed:
1827
+ return False
1828
+ _RUNTIME_SYNC["fps"][entry["id"]] = fp
1829
+ _RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
1830
+ return True
1831
+ except Exception:
1832
+ return False
1833
+
1834
+
1835
+ def launch(entry, open_browser=True):
1836
+ """一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
1837
+
1838
+ 打开前把「CLI 绑定」页的模型落盘到该 CLI 配置文件(dsh 连端点与 models
1839
+ 目录一起同步),并注入绑定密钥 env——保证打开即选中可用模型。"""
1840
+ if not detect_entry(entry).get("installed"):
1841
+ return {"ok": False, "error": "未安装,无法打开"}
1842
+ launch = entry.get("launch") or {}
1843
+ cmd = (launch.get("command") or "").strip()
1844
+ if not cmd:
1845
+ return {"ok": False, "error": "未配置打开命令(可在 data/catalog.json 补 launch 字段)"}
1846
+ is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1847
+ try:
1848
+ from . import modelhub
1849
+ binding = modelhub.resolve_binding(entry["id"]) or {}
1850
+ except Exception:
1851
+ binding = {}
1852
+ env = os.environ.copy()
1853
+ env.update(binding.get("env") or {})
1854
+ notes = _sync_launch_model(entry, binding)
1855
+ if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
1856
+ notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
1857
+ "要打开即用,请到「模型调度(可选)」页给 DeepSeek Harness 指定 openai 协议供应商")
1858
+ name = entry.get("name", entry["id"])
1859
+ kind = (launch.get("kind") or "console").lower()
1860
+ extra = (";".join(notes)) if notes else ""
1861
+
1862
+ if kind == "web":
1863
+ try:
1864
+ port = int(launch.get("port") or 0)
1865
+ except (TypeError, ValueError):
1866
+ port = 0
1867
+ if port <= 0:
1868
+ return {"ok": False, "error": "web 类打开必须配置固定端口(launch.port)"}
1869
+ bare = "http://127.0.0.1:%d" % port
1870
+ log_path = _launch_log_path(entry)
1871
+ if not log_path:
1872
+ return {"ok": False, "error": "启动日志路径不可信,已拒绝打开"}
1873
+ if _port_open(port):
1874
+ # 已在运行:新实例抢不到端口、新 token 也拿不到——从上次启动日志
1875
+ # 恢复带 token 的信任 URL(dsh web 有 /?token=... 围栏,裸开是 401)。
1876
+ # 模型同步照做:正在跑的实例不重启,改的是它下次生效的配置
1877
+ url = _best_url(log_path, port) or bare
1878
+ if open_browser:
1879
+ webbrowser.open(url)
1880
+ msg = "服务已在运行,已打开 " + url
1881
+ if extra:
1882
+ msg += "(" + extra + ")"
1883
+ return {"ok": True, "kind": "web", "url": url, "message": msg}
1884
+ # 后台起服务:无窗口、不阻塞请求;子进程输出重定向到启动日志(cmd 层
1885
+ # 重定向,路径含空格才加引号)——web 类普遍会在启动行打印带 token 的
1886
+ # 信任 URL(如 dsh web),就绪线程从日志提取后再开浏览器
1887
+ ls = str(log_path)
1888
+ spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1889
+ try:
1890
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1891
+ # 重定向语法 sh 与 cmd 通用,只换解释器;start_new_session 让子服务
1892
+ # 脱离本服务进程组(杀树/退出互不牵连)
1893
+ shell = ["cmd", "/c"] if os.name == "nt" else ["/bin/sh", "-c"]
1894
+ subprocess.Popen(shell + [spawn_cmd], cwd=str(paths.ROOT), env=env,
1895
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1896
+ stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
1897
+ start_new_session=(os.name != "nt"))
1898
+ except Exception as e:
1899
+ return {"ok": False, "error": "无法启动服务: %r" % e}
1900
+ if open_browser:
1901
+ threading.Thread(target=_open_when_ready, args=(port, bare, log_path),
1902
+ name="launch-wait-%d" % port, daemon=True).start()
1903
+ return {"ok": True, "kind": "web", "url": bare,
1904
+ "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1905
+ % (name, bare, (";" + extra) if extra else "")}
1906
+
1907
+ if sys.platform == "darwin":
1908
+ # Terminal.app 新开窗口跑交互 TUI;do script 的命令串常驻窗口,等价
1909
+ # Windows 的 cmd /k(CLI 退出后窗口保留,报错不至于一闪而过)。
1910
+ # 两层转义各管各的:shlex.quote 管 shell 层(cd 路径的引号),
1911
+ # 反斜杠/双引号替换管 AppleScript 字符串层。
1912
+ shell_cmd = "cd %s && %s" % (shlex.quote(str(paths.ROOT)), cmd)
1913
+ asc = 'tell application "Terminal" to do script ' + \
1914
+ '"' + shell_cmd.replace("\\", "\\\\").replace('"', '\\"') + '"'
1915
+ try:
1916
+ subprocess.Popen(["osascript", "-e", asc], cwd=str(paths.ROOT), env=env,
1917
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1918
+ stderr=subprocess.DEVNULL)
1919
+ except Exception as e:
1920
+ return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1921
+ return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1922
+ % (name, ("(" + extra + ")") if extra else "")}
1923
+
1924
+ if sys.platform != "win32":
1925
+ return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
1926
+ # start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
1927
+ # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1928
+ argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
1929
+ "cmd", "/k", cmd]
1930
+ try:
1931
+ subprocess.Popen(argv, cwd=str(paths.ROOT), env=env,
1932
+ creationflags=CREATE_NO_WINDOW)
1933
+ except Exception as e:
1934
+ return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1935
+ return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1936
+ % (name, ("(" + extra + ")") if extra else "")}
1937
+
1938
+
1939
+ def _open_when_ready(port, url, log_path, timeout=30):
1940
+ """等 web 服务端口就绪后开浏览器。优先从启动日志提取带 token 的信任 URL
1941
+ (端口通了 token 行可能还差几十毫秒才落盘,故就绪后最多再等 6 秒);
1942
+ 超时兜底也开——服务可能只是慢,用户手动刷新即可。"""
1943
+ deadline = time.time() + timeout
1944
+ while time.time() < deadline:
1945
+ if _port_open(port):
1946
+ break
1947
+ time.sleep(0.5)
1948
+ token_deadline = time.time() + 6
1949
+ best = None
1950
+ while time.time() < token_deadline:
1951
+ best = _best_url(log_path, port)
1952
+ if best and "token=" in best:
1953
+ break
1954
+ time.sleep(0.5)
1955
+ try:
1956
+ webbrowser.open(best or url)
1957
+ except Exception:
1958
+ pass
1959
+
1960
+
1961
+ # ---------------------------------------------------------------- 安装/升级
1962
+
1963
+ # winget「包已装且无可用升级」退出码(有符号 -1978335189 / 无符号 2316632107)
1964
+ _WINGET_NOOP_EXIT = 0x8A15002B
1965
+ # 双语结论标记:winget 对已装包重复 install 会转 upgrade,无可升级即打这些话
1966
+ _WINGET_NOOP_MARKERS = ("找不到可用的升级", "没有可用的较新的包版本",
1967
+ "No available upgrade", "No newer package versions")
1968
+
1969
+
1970
+ def _winget_already_ok(cmd, op, res):
1971
+ """winget install/upgrade 撞「已装且无可升级」时的良性判定。
1972
+
1973
+ 该场景命令非零退出,但目标态(已安装)本就达成——按失败走会触发 AI
1974
+ 修复白烧一轮诊断(2026-09-30 Claude Code 实案:检测漏装 + 点安装 =
1975
+ 假失败 + 假修复)。只认显式「无事可做」的退出码/结论话术,且调用方
1976
+ 还要复检 installed 才按成功收口。"""
1977
+ if res.get("ok") or op not in ("install", "upgrade"):
1978
+ return False
1979
+ # 首 token 可能已被 _resolve_mgmt_tool 换成绝对路径(PATH 快照落空兜底),
1980
+ # 所以按 basename 认 winget,别用 startswith
1981
+ head = str(cmd or "").strip().split()[0] if str(cmd or "").strip() else ""
1982
+ if os.path.basename(head).lower().rstrip(".exe") != "winget":
1983
+ return False
1984
+ code = res.get("exit_code")
1985
+ try:
1986
+ if code is not None and (int(code) & 0xFFFFFFFF) == _WINGET_NOOP_EXIT:
1987
+ return True
1988
+ except (TypeError, ValueError):
1989
+ pass
1990
+ out = "%s\n%s" % (res.get("stdout") or "", res.get("stderr") or "")
1991
+ return any(m in out for m in _WINGET_NOOP_MARKERS)
1992
+
1993
+
1994
+ def _uv_install_hint():
1995
+ if sys.platform == "darwin":
1996
+ return "brew install uv(或官网 curl -LsSf https://astral.sh/uv/install.sh | sh)"
1997
+ return "winget install astral-sh.uv,或 pip install uv"
1998
+
1999
+
2000
+ def _uv_mirror_env():
2001
+ """uv 系命令的镜像兜底 env(用户没自己配才补默认,绝不覆盖)。
2002
+
2003
+ uv tool install --python 3.12 会现场下载 CPython 独立构建(GitHub 直连,
2004
+ 国内常断流),包体走 PyPI——npmmirror/清华这对镜像是 2026-09 本机 uv
2005
+ 实装验证过的组合。"""
2006
+ env = {}
2007
+ if not os.environ.get("UV_PYTHON_INSTALL_MIRROR"):
2008
+ env["UV_PYTHON_INSTALL_MIRROR"] = (
2009
+ "https://registry.npmmirror.com/-/binary/python-build-standalone")
2010
+ if not os.environ.get("UV_DEFAULT_INDEX"):
2011
+ env["UV_DEFAULT_INDEX"] = "https://pypi.tuna.tsinghua.edu.cn/simple"
2012
+ return env
2013
+
2014
+
2015
+ def _cmd_head_is_uv(cmd):
2016
+ stripped = str(cmd or "").strip()
2017
+ if not stripped:
2018
+ return False
2019
+ return os.path.basename(stripped.split()[0]).lower() in ("uv", "uv.exe")
2020
+
2021
+
2022
+ # pip/uv 的 git+ 直装要从 GitHub 拉源码,国内链路对 GitHub 的 HTTP/2 常被中间设备
2023
+ # 掐断(git 报 "Error in the HTTP2 framing layer",exit 128)——强制 HTTP/1.1 是
2024
+ # 标准绕法。用 GIT_CONFIG_* 环境变量注入,不动用户全局 git 配置;老 git 不识别
2025
+ # 这组变量时静默忽略,无害。
2026
+ _GIT_FLAKE_MARKERS = ("HTTP2 framing layer", "unable to access", "RPC failed",
2027
+ "Connection reset", "connection was reset", "Recv failure")
2028
+
2029
+
2030
+ def git_http11_env():
2031
+ """git HTTPS 传输层 HTTP/1.1 兜底 env(返回新字典,调用方可自由合并)。
2032
+
2033
+ 本模块是安装/工作台/git 插件拉取各执行面的共享源;L0 的 tlsctx 够不着
2034
+ (L2 不准反向 import L0),runner 又在并行在制品里,故落在 manager。"""
2035
+ return {"GIT_CONFIG_COUNT": "1",
2036
+ "GIT_CONFIG_KEY_0": "http.version",
2037
+ "GIT_CONFIG_VALUE_0": "HTTP/1.1"}
2038
+
2039
+
2040
+ def _git_safe_env(cmd):
2041
+ """命令涉及 git+ 远端拉取时的 git 传输层兜底 env(只加键,不覆盖既有)。"""
2042
+ if "git+" not in str(cmd or ""):
2043
+ return {}
2044
+ return git_http11_env()
2045
+
2046
+
2047
+ def _git_transport_flake(res):
2048
+ """安装失败输出是否为 git 传输层抖动类错误(值得原地重试一次的那种)。
2049
+
2050
+ 接受 run_process 结果字典,也接受纯文本 blob(gitmod/market_remote 的
2051
+ subprocess 输出直接传字符串)。"""
2052
+ if not isinstance(res, str):
2053
+ res = "%s%s" % ((res or {}).get("stderr") or "", (res or {}).get("stdout") or "")
2054
+ return any(m in res for m in _GIT_FLAKE_MARKERS)
2055
+
2056
+
2057
+ def run_install_cmd(cmd, cancel_event=None, log_path=None):
2058
+ """安装类命令(目录页 install/upgrade 与 AI 修复命令)的统一执行面。
2059
+
2060
+ - uv 系挂镜像兜底(_uv_mirror_env,只补缺不覆盖)
2061
+ - 带 git+ 拉取的命令强制 git 走 HTTP/1.1
2062
+ - git 传输层抖动失败自动同命令重试一次——链路瞬断不值得烧一轮 AI 诊断
2063
+ """
2064
+ env = {}
2065
+ if _cmd_head_is_uv(cmd):
2066
+ env.update(_uv_mirror_env())
2067
+ env.update(_git_safe_env(cmd))
2068
+ res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
2069
+ cancel_event=cancel_event, log_path=log_path,
2070
+ env=env or None)
2071
+ if not res["ok"] and not res.get("cancelled") and _git_transport_flake(res):
2072
+ res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
2073
+ cancel_event=cancel_event, log_path=log_path,
2074
+ env=env or None,
2075
+ audit_notes=["首跑 git 传输层抖动,同命令自动重试一次"])
2076
+ res["retried"] = True
2077
+ return res
2078
+
2079
+
2080
+ def _bootstrap_uv(cancel_event=None, log_path=None):
2081
+ """uv 不在本机时自动补装一个——安装按钮「点了就该装上」的契约兜底。
2082
+
2083
+ 按平台选渠道,全部免 sudo 非交互:POSIX 先 brew(在才用)后官方 curl
2084
+ 脚本(落 ~/.local/bin,探测表可寻址);Windows 先 winget 后 pip。装完
2085
+ 不指望 PATH 快照自愈——调用方重跑 _resolve_mgmt_tool 用绝对路径接管。
2086
+ 返回 {ok, detail};失败 detail 列出试过的渠道与结局,供人话收口。"""
2087
+ candidates = []
2088
+ if sys.platform == "win32":
2089
+ if shutil.which("winget"):
2090
+ candidates.append(("winget", "winget install -e --id astral-sh.uv --silent"))
2091
+ if shutil.which("py"):
2092
+ candidates.append(("pip(py)", "py -3 -m pip install uv"))
2093
+ elif shutil.which("python"):
2094
+ candidates.append(("pip", "python -m pip install uv"))
2095
+ else:
2096
+ if shutil.which("brew"):
2097
+ candidates.append(("brew", "brew install uv"))
2098
+ if shutil.which("curl"):
2099
+ candidates.append(("curl", "curl -LsSf https://astral.sh/uv/install.sh | sh"))
2100
+ attempts = []
2101
+ for label, cmd in candidates:
2102
+ res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
2103
+ timeout=900, cancel_event=cancel_event,
2104
+ log_path=log_path)
2105
+ if res["ok"]:
2106
+ return {"ok": True, "detail": "已自动补装 uv(%s)" % label}
2107
+ attempts.append("%s 退出码 %s" % (label, res.get("exit_code")))
2108
+ return {"ok": False,
2109
+ "detail": "、".join(attempts) or "本机没有 brew/curl/winget/pip 任一渠道"}
2110
+
2111
+
2112
+ def _resolve_mgmt_tool(cmd, op):
2113
+ """install/upgrade 命令首 token 的工具本体解析。
2114
+
2115
+ 服务 PATH 是启动快照,本机后装的 uv/npm 不在快照里时 shell 报
2116
+ command not found 退出码 127,零信息量。磁盘已知落点能探到就把首 token
2117
+ 原地换成绝对路径(装完即用,不必重启服务);探不到且是 uv 系安装命令
2118
+ 则返回 uv_missing=True——本机连 uv 都没有,包无从装起,人话报错并跳过
2119
+ AI 修复(修复链装上 uv 后复检目标 CLI 仍未装,白烧一轮诊断)。
2120
+ 返回 (替换后的命令, uv_missing)。"""
2121
+ stripped = str(cmd or "").strip()
2122
+ first = stripped.split()[0] if stripped else ""
2123
+ if not first or os.path.isabs(first) or shutil.which(first):
2124
+ return cmd, False
2125
+ found = runner._fallback_probe_cli(first)
2126
+ if found:
2127
+ return found + stripped[len(first):], False
2128
+ return cmd, (first == "uv" and op in ("install", "upgrade"))
2129
+
2130
+
2131
+ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
2132
+ """执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
2133
+ if op == "uninstall":
2134
+ cmd = catalog.uninstall_command(entry)
2135
+ if not cmd:
2136
+ return {"ok": False,
2137
+ "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
2138
+ % entry["id"]}
2139
+ else:
2140
+ cmd = entry.get(op)
2141
+ if not cmd:
2142
+ return {"ok": False,
2143
+ "error": "未配置 %s 命令:请在 data/catalog.json 的 \"%s\" 里补充,或用官方渠道安装"
2144
+ % (op, entry["id"])}
2145
+ cmd, uv_missing = _resolve_mgmt_tool(cmd, op)
2146
+ if uv_missing:
2147
+ # 「提供安装就该能装上」:uv 不在本机先自动补装,装 uv 不该是用户
2148
+ # 操心的事;真补不上才退回人话指引(2026-10-09 Mac 双装失败实案)
2149
+ boot = _bootstrap_uv(cancel_event=cancel_event, log_path=log_path)
2150
+ if boot["ok"]:
2151
+ cmd, uv_missing = _resolve_mgmt_tool(cmd, op)
2152
+ if uv_missing:
2153
+ return {"ok": False, "uv_missing": True,
2154
+ "error": "自动安装 uv 未成功(%s)。请手动安装:%s;"
2155
+ "装完重启 CodeBee,再点一次安装。"
2156
+ % (boot["detail"], _uv_install_hint())}
2157
+ res = run_install_cmd(cmd, cancel_event=cancel_event, log_path=log_path)
2158
+ winget_noop = _winget_already_ok(cmd, op, res)
2159
+ detect_all(force=True)
2160
+ with _LOCK:
2161
+ _STATE["versions"].pop(entry["id"], None)
2162
+ if winget_noop and detect_entry(entry)["installed"]:
2163
+ # winget 说「已装且无可升级」、复检也在装:install 语义(确保在装)已达成
2164
+ refresh_update_async(entry)
2165
+ return {"ok": True, "exit_code": res["exit_code"], "command": cmd,
2166
+ "error": "",
2167
+ "note": "winget 报已安装且无可用升级,复检确认本机在装"}
2168
+ if res["ok"]:
2169
+ # 安装/升级成功即作废该条目的更新检查缓存并后台复检:否则 10 分钟 TTL
2170
+ # 内徽章仍显示「有新版本」,诱导同版本重装(重装易撞 EBUSY 文件锁,
2171
+ # 2026-09-18 dsh 案)。
2172
+ refresh_update_async(entry)
2173
+ tail = runner.clean_cli_text(res["stderr"])[-800:]
2174
+ if not tail and not res["ok"]:
2175
+ # winget 把结论打在 stdout(stderr 恒空),只回「退出码 N」零信息量
2176
+ tail = runner.clean_cli_text(res["stdout"])[-800:]
2177
+ return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
2178
+ "error": "" if res["ok"] else (tail or "退出码 %s" % res["exit_code"])}
2179
+
2180
+
2181
+ def refresh_update_async(entry):
2182
+ """作废单条目的更新检查缓存,并后台立刻复检一次远端最新版本。
2183
+
2184
+ 完成的安装/升级调用它,卡片徽章马上脱离旧结论(unknown → 几秒后
2185
+ 「已是最新/有新版本」),不用等 10 分钟缓存过期。npm view 在后台线程
2186
+ 跑,不阻塞 mgmt 任务收尾。
2187
+ """
2188
+ with _LOCK:
2189
+ _UPDATE_CACHE.pop(entry["id"], None)
2190
+ try:
2191
+ threading.Thread(target=check_update, args=(entry,), kwargs={"force": True},
2192
+ name="update-recheck-%s" % entry.get("id"), daemon=True).start()
2193
+ except Exception:
2194
+ pass
2195
+
2196
+
2197
+ # ---------------------------------------------------------------- 版本检查
2198
+
2199
+ _UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
2200
+ UPDATE_TTL = 600
2201
+
2202
+
2203
+ def _npm_pkg_name(cmd):
2204
+ """从 npm 安装命令里取包名(实现已统一到 catalog.npm_pkg_name)。"""
2205
+ return catalog.npm_pkg_name(cmd)
2206
+
2207
+
2208
+ def _ver_tuple(s):
2209
+ return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
2210
+
2211
+
2212
+ def check_update(entry, force=False):
2213
+ """检查是否有新版本可用。npm 走 npm view;winget 走 winget upgrade 列表;
2214
+ 其他渠道标记为不支持。结果缓存 10 分钟。"""
2215
+ eid = entry["id"]
2216
+ if not force:
2217
+ cached = _UPDATE_CACHE.get(eid)
2218
+ if cached and time.time() - cached[0] < UPDATE_TTL:
2219
+ return cached[1]
2220
+ current = version_of(entry)
2221
+ cur_num = ".".join(str(x) for x in _ver_tuple(current)) or "-"
2222
+ result = {"current": current, "latest": None, "updatable": None, "note": ""}
2223
+
2224
+ cmd = entry.get("install") or entry.get("upgrade") or ""
2225
+ pkg = _npm_pkg_name(cmd)
2226
+ if pkg:
2227
+ # Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
2228
+ npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
2229
+ else ["npm", "view", pkg, "version"]
2230
+ r = runner.run_process(argv=npm_view, timeout=90)
2231
+ latest = ""
2232
+ if r["ok"]:
2233
+ for line in (r["stdout"] or "").splitlines():
2234
+ line = line.strip()
2235
+ if line and re.match(r"^\d", line):
2236
+ latest = line
2237
+ if not latest:
2238
+ result["note"] = "查询 npm 失败(网络或 registry 问题):" + (r["stderr"] or "")[-200:]
2239
+ else:
2240
+ result["latest"] = latest
2241
+ result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
2242
+ if not result["updatable"]:
2243
+ result["note"] = "已是最新版本"
2244
+ elif os.name == "nt" and "winget" in cmd:
2245
+ m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
2246
+ wid = m.group(1) if m else None
2247
+ if not wid:
2248
+ result["note"] = "无法从命令中解析 winget 包 ID"
2249
+ else:
2250
+ r = runner.run_process(argv=["cmd", "/c", "winget", "upgrade"], timeout=180)
2251
+ if not r["ok"]:
2252
+ result["note"] = "winget upgrade 查询失败:" + (r["stderr"] or "")[-200:]
2253
+ else:
2254
+ hit = [l for l in (r["stdout"] or "").splitlines() if wid.lower() in l.lower()]
2255
+ result["updatable"] = bool(hit)
2256
+ if hit:
2257
+ result["latest"] = "(winget 有可用更新)"
2258
+ else:
2259
+ result["latest"] = cur_num
2260
+ result["note"] = "已是最新版本"
2261
+ else:
2262
+ result["note"] = "该渠道暂不支持自动检查更新,可直接点升级尝试"
2263
+
2264
+ with _LOCK:
2265
+ _UPDATE_CACHE[eid] = (time.time(), result)
2266
+ return result
2267
+
2268
+
2269
+ # 「进入智能体目录页自动检查更新」的后台任务状态
2270
+ _UPDATE_CHECK = {"running": False, "total": 0, "done": 0}
2271
+
2272
+
2273
+ def updates_checking():
2274
+ with _LOCK:
2275
+ return bool(_UPDATE_CHECK["running"])
2276
+
2277
+
2278
+ def update_info(entry):
2279
+ """单个 CLI 的更新检查结果(供管理页卡片展示)。没查过时为 unknown。"""
2280
+ with _LOCK:
2281
+ cached = _UPDATE_CACHE.get(entry["id"])
2282
+ if not cached:
2283
+ return {"status": "unknown", "latest": None, "updatable": None,
2284
+ "note": "", "checked_at": ""}
2285
+ res = cached[1] or {}
2286
+ up = res.get("updatable")
2287
+ status = "updatable" if up is True else ("current" if up is False else "unsupported")
2288
+ return {"status": status, "latest": res.get("latest"), "updatable": up,
2289
+ "note": res.get("note") or "",
2290
+ "checked_at": time.strftime("%H:%M", time.localtime(cached[0]))}
2291
+
2292
+
2293
+ def _checkable_entries():
2294
+ """已安装且配了 install/upgrade 的条目——只有这些才谈得上「是否有新版本」。"""
2295
+ detected = detect_all()
2296
+ out = []
2297
+ for e in catalog.load():
2298
+ det = (detected or {}).get(e["id"]) or {}
2299
+ if det.get("installed") and (e.get("upgrade") or e.get("install")):
2300
+ out.append(e)
2301
+ return out
2302
+
2303
+
2304
+ def check_updates_async(force=False):
2305
+ """后台逐个检查已安装 CLI 的远端最新版本,返回本次要检查的条目数。
2306
+
2307
+ 进入「智能体目录」页时自动触发。单条结果复用 check_update 的 10 分钟缓存,
2308
+ 所以反复进出页面几乎不产生额外子进程/网络开销;已在跑时不重复起线程。
2309
+ """
2310
+ with _LOCK:
2311
+ if _UPDATE_CHECK["running"]:
2312
+ return 0
2313
+ entries = _checkable_entries()
2314
+ _UPDATE_CHECK.update(running=True, total=len(entries), done=0)
2315
+
2316
+ def _worker():
2317
+ try:
2318
+ for e in entries:
2319
+ try:
2320
+ check_update(e, force=force)
2321
+ except Exception:
2322
+ pass
2323
+ finally:
2324
+ with _LOCK:
2325
+ _UPDATE_CHECK["done"] += 1
2326
+ finally:
2327
+ with _LOCK:
2328
+ _UPDATE_CHECK["running"] = False
2329
+
2330
+ threading.Thread(target=_worker, name="catalog-update-check", daemon=True).start()
2331
+ return len(entries)
2332
+
2333
+
2334
+ def upgrade_all_targets():
2335
+ """「一键升级全部」的目标条目,按目录顺序返回。
2336
+
2337
+ 只收同时满足:已安装 + 配了 upgrade 命令(op=upgrade 硬依赖该字段,
2338
+ 没配的跟单条一样不出「升级」按钮)+ 更新检查不是「已是最新」。
2339
+ 检查结果为 unknown(还没查)/ unsupported(渠道查不了)的也升级——
2340
+ npm/pip 的升级命令幂等,宁可空跑一次也不漏升;唯一跳过「已是最新」,
2341
+ 防无谓重装(重装易撞 EBUSY 文件锁)。
2342
+ """
2343
+ detected = detect_all()
2344
+ out = []
2345
+ for e in catalog.load():
2346
+ det = (detected or {}).get(e["id"]) or {}
2347
+ if not det.get("installed") or not e.get("upgrade"):
2348
+ continue
2349
+ if update_info(e).get("status") == "current":
2350
+ continue
2351
+ out.append(e)
2352
+ return out
2353
+
2354
+
2355
+ def catalog_view():
2356
+ """管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
2357
+ from . import registry
2358
+ entries = catalog.load()
2359
+ detected = detect_all(force=False)
2360
+ enabled = registry.load_enabled()
2361
+ view = []
2362
+ for e in entries:
2363
+ det = detected.get(e["id"]) or {}
2364
+ pref = enabled.get(e["id"]) or {}
2365
+ orch_enabled = bool(pref.get("enabled", e.get("default_enabled", False))) if e.get("orch") else False
2366
+ view.append({
2367
+ "id": e["id"], "name": e.get("name", e["id"]), "note": e.get("note", ""),
2368
+ "group": "installed" if det.get("installed") else "installable",
2369
+ "installed": det.get("installed", False),
2370
+ "detail": det.get("detail", ""),
2371
+ "version": version_of(e),
2372
+ "config_path": _config_path(e),
2373
+ "config_writable": (e.get("config") or {}).get("format") in _WRITABLE_FORMATS,
2374
+ "model": read_model(e),
2375
+ "orch_kind": (e.get("orch") or {}).get("kind"),
2376
+ "orch_enabled": orch_enabled,
2377
+ "update": update_info(e),
2378
+ "has_install": bool(e.get("install")),
2379
+ "has_upgrade": bool(e.get("upgrade")),
2380
+ # 卸载命令由 install/upgrade 推导(或 catalog 显式配置),供 UI 确认框展示
2381
+ "uninstall_cmd": catalog.uninstall_command(e) if det.get("installed") else None,
2382
+ # 一键打开配置(kind=web/console + command);没配的条目 UI 不出「打开」按钮
2383
+ "launch": e.get("launch"),
2384
+ })
2385
+ return view