codebee 0.1.68 → 0.1.70

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