codebee 0.1.95 → 0.1.96

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