codebee 0.1.49 → 0.1.51

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.
@@ -6,6 +6,7 @@
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import hashlib
9
10
  import json
10
11
  import os
11
12
  import re
@@ -15,6 +16,7 @@ import signal
15
16
  import socket
16
17
  import subprocess
17
18
  import sys
19
+ import tempfile
18
20
  import threading
19
21
  import time
20
22
  import webbrowser
@@ -46,6 +48,31 @@ def _expand(p):
46
48
  return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
47
49
 
48
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
+
49
76
  def _safe_config_path(raw):
50
77
  """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
51
78
  if not raw:
@@ -165,21 +192,38 @@ def sweep_orphan_cli_processes():
165
192
 
166
193
 
167
194
  def detect_all(force=False):
168
- """检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
169
- Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
170
- (曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
171
- """
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
+ 重载/安装回调)仍同步跑拿新鲜结果。"""
172
204
  with _LOCK:
173
205
  if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
174
206
  return _STATE["detected"]
175
207
  ev = _STATE["detect_ev"]
176
- lead = ev is None # 我是本次检测的执行者
208
+ lead = ev is None # 我是本次刷新的发起人
177
209
  if lead:
178
210
  ev = _STATE["detect_ev"] = threading.Event()
211
+ cold = not _STATE["detected"] # 冷启动首轮:还没有任何快照
179
212
  if not lead:
180
- ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
213
+ if not cold:
214
+ return dict(_STATE["detected"]) # 过期快照也比卡请求强
215
+ ev.wait(30) # 冷启动窗口内并发首查:等首轮检测完成(一次性)
181
216
  with _LOCK:
182
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
+ """跑一轮全量检测并落快照;完成后置时间戳、清在飞标记、放行等待者。"""
183
227
  try:
184
228
  detected = {}
185
229
  for entry in catalog.load():
@@ -190,12 +234,12 @@ def detect_all(force=False):
190
234
  if detected:
191
235
  with _LOCK:
192
236
  _STATE["detected"] = detected
237
+ return dict(_STATE["detected"])
193
238
  finally:
194
239
  with _LOCK:
195
240
  _STATE["detect_ts"] = time.time()
196
241
  _STATE["detect_ev"] = None
197
242
  ev.set()
198
- return detected
199
243
 
200
244
 
201
245
  def _uwp_version(package_dir):
@@ -521,6 +565,9 @@ def read_model(entry):
521
565
  def write_model(entry, model):
522
566
  """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
523
567
  json / json-path / jsonc / yaml-line。"""
568
+ if tmp_data_no_home_write():
569
+ return {"ok": False,
570
+ "error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
524
571
  path = _config_path(entry)
525
572
  cfg = entry.get("config") or {}
526
573
  fmt = cfg.get("format")
@@ -750,6 +797,8 @@ def _sync_dsh_settings(entry, model, base_url):
750
797
  端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
751
798
  models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
752
799
  返回错误串或 None。"""
800
+ if tmp_data_no_home_write():
801
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
753
802
  path = _config_path(entry)
754
803
  if not path:
755
804
  return "dsh 配置路径无效"
@@ -891,6 +940,8 @@ def _sync_codex_settings(entry, model, cp):
891
940
  _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
892
941
  if _is_dead_endpoint(cp.get("base_url")):
893
942
  return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
943
+ if tmp_data_no_home_write():
944
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
894
945
  path = _config_path(entry)
895
946
  if not path:
896
947
  return "codex 配置路径无效"
@@ -1120,6 +1171,8 @@ def _sync_settings_env(path, updates, remove_keys=()):
1120
1171
  """把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
1121
1172
  启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
1122
1173
  不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
1174
+ if tmp_data_no_home_write():
1175
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1123
1176
  try:
1124
1177
  if os.path.isfile(path):
1125
1178
  text = _read_text(path)
@@ -1213,6 +1266,8 @@ def _sync_opencode_settings(entry, model, prov):
1213
1266
  没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
1214
1267
  全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1215
1268
  文本级就地 patch(保留注释)。返回错误串或 None。"""
1269
+ if tmp_data_no_home_write():
1270
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1216
1271
  npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1217
1272
  base = prov.get("base_url") or ""
1218
1273
  if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
@@ -1323,7 +1378,9 @@ def _sync_kimi_settings(entry, model, prov):
1323
1378
  yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
1324
1379
  baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
1325
1380
  要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
1326
- 之间)幂等重写,用户自有内容保留在外。"""
1381
+ 之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
1382
+ if tmp_data_no_home_write():
1383
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1327
1384
  import re as _re
1328
1385
  top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
1329
1386
  targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
@@ -1357,10 +1414,23 @@ def _sync_kimi_settings(entry, model, prov):
1357
1414
  return ";".join(errs) or None
1358
1415
 
1359
1416
 
1417
+ def _claude_sync_enabled():
1418
+ """「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
1419
+ 关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
1420
+ 防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
1421
+ try:
1422
+ from . import settings
1423
+ return settings.load().get("claude_config_sync", True)
1424
+ except Exception:
1425
+ return True
1426
+
1427
+
1360
1428
  def sync_cli_config_now(agent_id):
1361
1429
  """运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
1362
1430
  绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
1363
1431
  try:
1432
+ if agent_id == "claude-code" and not _claude_sync_enabled():
1433
+ return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
1364
1434
  from . import modelhub
1365
1435
  entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
1366
1436
  if not entry:
@@ -1397,6 +1467,8 @@ def _sync_agent_injection(entry, binding):
1397
1467
  spec = _AGENT_INJECTORS.get(entry["id"])
1398
1468
  if spec:
1399
1469
  protocols, injector = spec
1470
+ if entry["id"] == "claude-code" and not _claude_sync_enabled():
1471
+ return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
1400
1472
  pick, note = modelhub.launch_pick(entry["id"], protocols)
1401
1473
  if pick:
1402
1474
  prov = pick["provider"]
@@ -1466,7 +1538,19 @@ def _sync_launch_model(entry, binding):
1466
1538
  # (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
1467
1539
  _KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
1468
1540
  "qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
1469
- _RUNTIME_SYNC = {"fps": {}, "lock": threading.Lock()}
1541
+ _RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
1542
+
1543
+
1544
+ def _runtime_cfg_hash(entry):
1545
+ """CLI 自家配置文件的内容指纹;文件不存在返回 None。"""
1546
+ try:
1547
+ p = _safe_config_path((entry.get("config") or {}).get("path"))
1548
+ if p and os.path.isfile(p):
1549
+ with open(p, "rb") as fh:
1550
+ return hashlib.sha256(fh.read()).hexdigest()
1551
+ except Exception:
1552
+ pass
1553
+ return None
1470
1554
 
1471
1555
 
1472
1556
  def sync_runtime_config(agent):
@@ -1509,10 +1593,16 @@ def sync_runtime_config(agent):
1509
1593
  fp = (model, repr(sorted(env.items())),
1510
1594
  repr(binding.get("codex_provider") or "")[:300])
1511
1595
  with _RUNTIME_SYNC["lock"]:
1512
- if _RUNTIME_SYNC["fps"].get(entry["id"]) == fp:
1596
+ if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
1597
+ and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
1513
1598
  return
1599
+ # 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
1600
+ # 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
1601
+ # ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
1602
+ # 命中跳过重写,kimi 带毒配置静默退出码 1
1514
1603
  _sync_launch_model(entry, binding)
1515
1604
  _RUNTIME_SYNC["fps"][entry["id"]] = fp
1605
+ _RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
1516
1606
  except Exception:
1517
1607
  pass
1518
1608
 
@@ -290,8 +290,34 @@ def install_files(pack_id, name, files, extra=None):
290
290
  record.update(extra)
291
291
  installed[pack_id] = record
292
292
  _save_registry(reg)
293
+ # 装后冒烟验证(SkillForge 证据驱动借鉴):装完重新走 skills 的解析链,
294
+ # 证明该包能被加载、名字对得上、正文非空——写盘成功 ≠ 技能可用,
295
+ # frontmatter 缺失/正文为空都能在这里当场暴露,而不是下次任务注入时静默丢失。
296
+ smoke = ""
297
+ try:
298
+ with skills._LOCK:
299
+ skills._user_pack_cache.clear()
300
+ skills._user_dir_mtime["ts"] = 0.0
301
+ skills._user_dir_mtime["ids"] = None
302
+ up = next((p for p in skills.user_packs()
303
+ if str(p.get("file") or "").replace("\\", "/").endswith("/" + target)), None)
304
+ if up is None:
305
+ smoke = "装后冒烟未找到已装包(解析失败?)"
306
+ elif (up.get("name") or "").strip() != name.strip():
307
+ smoke = "装后冒烟名字不符:%s ≠ %s" % (up.get("name"), name)
308
+ elif not str(up.get("body") or "").strip():
309
+ smoke = "装后冒烟正文为空"
310
+ else:
311
+ smoke = "ok:%d字" % len(str(up.get("body") or ""))
312
+ except Exception as e:
313
+ smoke = "装后冒烟异常: %s" % str(e)[:120]
314
+ with _LOCK:
315
+ reg = _load_registry()
316
+ if pack_id in (reg.get("installed") or {}):
317
+ reg["installed"][pack_id]["smoke"] = smoke
318
+ _save_registry(reg)
293
319
  return {"ok": True, "id": pack_id, "name": name, "file": target,
294
- "already": already, "scan": scan_note}, None
320
+ "already": already, "scan": scan_note, "smoke": smoke}, None
295
321
 
296
322
 
297
323
  def remove(pack_id):
@@ -448,6 +448,30 @@ def _drop_model_refs(data, prov, name):
448
448
  _sync_chain_refs(b)
449
449
 
450
450
 
451
+ def _prune_binding_chains(data, prov_ids=(), pairs=()):
452
+ """停用即出调度:把指向已停用供应商/模型的条目从所有绑定链里剔除。
453
+
454
+ 与删除的清引用(_drop_model_refs / providers_op delete)不同,停用可逆,
455
+ 只摘链上死条目,不动主供应商 provider_id 与厂商默认模型——重新启用后
456
+ 原样恢复。链上剔除不回填:调度页所见即所绑,不靠运行时降级默默跳过
457
+ 占位死条目(2026-09-22 用户拍板:禁用就直接去掉,自动就行)。
458
+ """
459
+ ids = {i for i in (prov_ids or ()) if i}
460
+ prs = {(p, m) for p, m in (pairs or ()) if p and m}
461
+ if not ids and not prs:
462
+ return
463
+ for b in (data.get("bindings") or {}).values():
464
+ chain = b.get("chain")
465
+ if not chain:
466
+ continue
467
+ kept = [c for c in chain
468
+ if (c.get("provider_id") or "") not in ids
469
+ and (c.get("provider_id") or "", c.get("model") or "") not in prs]
470
+ if len(kept) != len(chain):
471
+ b["chain"] = kept
472
+ _sync_chain_refs(b)
473
+
474
+
451
475
  _MODEL_OPS = ("enable", "disable", "delete", "restore")
452
476
 
453
477
 
@@ -475,6 +499,7 @@ def _apply_model_op(data, prov, models, name, op):
475
499
  if not target.get("enabled", True):
476
500
  return False, ""
477
501
  target["enabled"] = False
502
+ _prune_binding_chains(data, pairs=[(prov.get("id"), name)])
478
503
  elif op == "delete":
479
504
  if target.get("hidden"):
480
505
  return False, ""
@@ -494,6 +519,8 @@ def model_ops(provider_id, names, op):
494
519
 
495
520
  先整体校验再落盘:任一名不存在、或 enable/disable 选中了已删除的模型,
496
521
  整个请求都不生效,避免只改一半。全部改动合并为一次写盘。
522
+ disable 即出调度:命中的 (供应商, 模型) 条目自动从所有绑定链剔除,
523
+ 不回填;delete 走 _drop_model_refs 连默认模型引用一起清。
497
524
  restore 与可见模型混选时,可见项直接跳过(不算失败),便于「恢复所选」。
498
525
  """
499
526
  names = list(dict.fromkeys(n for n in (names or []) if n))
@@ -656,9 +683,13 @@ def provider_view():
656
683
  _KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
657
684
  MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
658
685
  MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
659
- # 欠费/配额类失败:换 KEY 有意义(同厂商另一账号还能用),与瞬态网络错误分开记
686
+ # 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
687
+ # 与瞬态网络错误分开记。429 限流文案一并纳入——只记错不冷却会让每个新步骤都
688
+ # 从超限的首选 KEY 重新烧起(2026-09-22 首选超限不切备用实案)。
660
689
  _QUOTA_HINTS = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
661
- "payment required", "402", "欠费", "余额", "额度", "exceeded")
690
+ "payment required", "402", "欠费", "余额", "额度", "exceeded",
691
+ "too many requests", "rate limit", "rate_limit", "http 429",
692
+ "并发", "超过限")
662
693
 
663
694
 
664
695
  def _quota_error(err):
@@ -938,8 +969,9 @@ def upsert_provider(entry):
938
969
  def providers_op(ids, op):
939
970
  """批量供应商操作(enable | disable | delete | duplicate)。返回 (改动数, 错误)。
940
971
 
941
- 停用只影响 Tutti 编排时的运行时解析(resolve_binding 返回空 → 回落 CLI 默认),
942
- 不清除配置,也不影响绑定引用,随时可再启用。
972
+ 停用即出调度:绑定链里它的条目自动剔除(_prune_binding_chains),配置与
973
+ 模型列表保留,随时可再启用;已剔除的链条目不回填。运行时解析本来就会
974
+ 跳过停用供应商,这里把落盘链一并清掉,调度页所见即所绑。
943
975
  duplicate 复制一条(同地址同密钥的第二个账号/新网关),副本不带绑定引用。
944
976
  """
945
977
  ids = list(dict.fromkeys(i for i in (ids or []) if i))
@@ -998,6 +1030,8 @@ def providers_op(ids, op):
998
1030
  p["enabled"] = want
999
1031
  changed += 1
1000
1032
  if changed:
1033
+ if not want:
1034
+ _prune_binding_chains(data, prov_ids=ids)
1001
1035
  # 启用置顶:本次选中的启用者排最前,其余启用跟随,停用的殿后;
1002
1036
  # 排序稳定,各块内部保持原有顺序。落盘重排,列表页看到的就是它。
1003
1037
  sel = set(ids)
@@ -2298,11 +2332,13 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2298
2332
  entries.append(_chain_entry_env(prov, model, target=agent_kind_or_id,
2299
2333
  endpoint=ep, key=kk["key"],
2300
2334
  key_id=kk.get("id") or "", provider_id=pid))
2335
+ mk = (chain_keys or [{}])[0] # 回退模型沿用首选可用 KEY:失败也要能记账冷却
2301
2336
  for n in fallbacks:
2302
2337
  if len(entries) >= MAX_CHAIN_ATTEMPTS:
2303
2338
  break
2304
2339
  entries.append(_chain_entry_env(prov, n, target=agent_kind_or_id,
2305
- endpoint=ep, provider_id=pid))
2340
+ endpoint=ep, key=mk.get("key") or "",
2341
+ key_id=mk.get("id") or "", provider_id=pid))
2306
2342
  head = entries[0]
2307
2343
  out = {"model": model, "env": head["env"], "provider": prov,
2308
2344
  "model_fallbacks": fallbacks,
@@ -2406,6 +2442,18 @@ def launch_pick(agent_id, protocols):
2406
2442
  protocols = tuple(p for p in (protocols or ()) if p)
2407
2443
  b = bindings().get(agent_id) or {}
2408
2444
  chain = _binding_chain(b)
2445
+ if not chain and str(b.get("provider_id") or "").strip():
2446
+ # 只锁定了供应商、没排链也没选模型的绑定:与 resolve_binding 的无链分支
2447
+ # 同语义补一条,打开前才有凭据可注入。否则这类绑定永远「未指定供应商」,
2448
+ # CLI 自家配置里的残留(a.test 占位案)等不到自愈覆盖——2026-09-22
2449
+ # claude ENOTFOUND 四连败实测,settings.json 毒端点靠它长期存活。
2450
+ _pid = str(b.get("provider_id")).strip()
2451
+ _prov = {p.get("id"): p for p in providers()}.get(_pid)
2452
+ _model = str(b.get("model") or "").strip()
2453
+ if _prov and not _model:
2454
+ _names = [m["name"] for m in _enabled_models(_prov)]
2455
+ _model = _prov.get("model") or (_names[0] if _names else "")
2456
+ chain = [{"provider_id": _pid, "model": _model}]
2409
2457
  provs = {p.get("id"): p for p in providers()}
2410
2458
  disabled, mismatch = [], False
2411
2459
  for item in chain: