codebee 0.1.101 → 0.1.102

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.102(2026-10-10)
8
+
9
+ - **蜂巢 3D 蜜蜂办公室全面重建(程序化 WebGL 场景)**:3D 场景从参考贴图升级为全程序化 WebGL 实时渲染——办公室/工位/灯光/蜜蜂形态全部由代码建模并大幅精修(关节手打字、椭圆半透明翅膀、腹部金环纹、人体工学椅、双层光源与接触阴影),静态几何 GPU 批处理+按需渲染更流畅,轨道/平移/复位手感修正,实时遥测投到工位屏幕玻璃上,场景 overlay 支持读屏与键盘;新增**全屏工作台**:参考图/WebGL/2D 三种视图皆可一键全屏(保留画面比例),全屏冒烟测试常驻护航
10
+ - **发布台存草稿四修**:44 章假成功真凶=submit 被 auto_submit 人工闸吞掉而 manager 仍记成功;现以编辑器保存反馈为凭据、起草前 attach-only 对账剔除已发布章、连接瞬断同章自动重试一次、跨批自动发布浏览器互斥(拒起/让位);对账剔除/跳过/重试在发布台进度提示可见
11
+ - **连载超限快照改存 git 对象库**:书稿 manuscript.md 涨破 1MB 快照单文件上限时,评审步曾全灭报「评审模型不可用」伞形文案(增长型悬崖);现超限前态改 `hash-object` 存工作区对象库(≤1MB 仍内嵌),快照/预览/恢复全链路适配并修正大文件恢复指纹;非 git 目录维持拒绝(宁拒不假装可恢复)
12
+ - **大屏实时流对 CLI 步骤回退读日志**:外部 CLI 只落日志文件不写 stream/thinking,连载全 CLI 流程大屏此前永远停在「等待实时输出…」;现回退读步骤日志尾巴(与蜂巢卡同源翻译),跑马灯取实时窗口末行
13
+ - **运行详情步骤行窄主列折行**:模型/供应商徽章把定宽列总和顶到超宽,浏览器缩放/窄窗/检查器挤压主列时必出面板横向滚动条、CLI 日志按钮被切半;≤820px 容器查询自动折行,宽主列单行形态不变,老浏览器优雅降级
14
+ - **命令执行后端可选 Docker 容器**:设置页新增沙箱后端选择(本机默认/Docker 容器);Docker 仅适用于内置命令工具,ACP、外部 CLI 与 MCP 暂不支持容器化、将安全拒绝执行
15
+
7
16
  ## v0.1.101(2026-10-10)
8
17
 
9
18
  - **重写未达标章带上旧账(连载)**:上一轮评审的 major 意见(章节点名+全书级)随重写提示词下发(`__REDO_ISSUES__` 渲染「上一轮评审未达标原因」块),被 major 点名的过线章一并回炉——总判定卡 major 时只重写未过线章消不掉账的空转实案修复,作者第一遍就知道往哪改
package/README.md CHANGED
@@ -25,13 +25,14 @@ Trae Agent、OpenClaw……),提供
25
25
  ---
26
26
 
27
27
  <!-- relnotes:start -->
28
- ### 最新版更新内容(v0.1.101)
29
-
30
- - 连载重写带旧账:上一轮评审 major 意见随重写提示词下发,被点名章一并回炉
31
- - 评审口径事前下发作者面:写作前就知道按什么标准被审(与评审面同源,永不脱节)
32
- - 插件安装失败回滚修复(人话报错+残缺自愈,不再 500/永久撞车)
33
- - 错误行带崩溃现场(traceback 尾帧直达 UI,同因止损按位置区分)
34
- - 代码块长行缺省自动换行,运行详情不再撑横向滚动条
28
+ ### 最新版更新内容(v0.1.102)
29
+
30
+ - 蜂巢 3D 蜜蜂办公室全面重建:全程序化 WebGL 实时渲染+大幅视觉精修,参考图/WebGL/2D 三视图皆可一键全屏
31
+ - 发布台存草稿四修:假成功真凶(submit 被人工闸吞掉)根治,保存反馈当凭据+对账剔除+瞬断重试+跨批互斥
32
+ - 连载书稿涨破 1MB 快照上限不再全灭:超限前态改存 git 对象库,恢复链路全适配
33
+ - 大屏实时流对 CLI 步骤回退读日志,全 CLI 连载流程不再永远「等待实时输出」
34
+ - 运行详情窄主列自动折行,缩放/窄窗不再出横向滚动条
35
+ - 命令执行后端可选 Docker 容器(内置命令工具专属)
35
36
  <!-- relnotes:end -->
36
37
 
37
38
 
@@ -15,6 +15,34 @@ from .env_scrub import scrub_env
15
15
 
16
16
  _MAX_LINE = 2 * 1024 * 1024
17
17
  _MAX_TEXT = 2 * 1024 * 1024
18
+ _MAX_ACP_EVENTS = 200
19
+ _ACP_EVENT_TYPES = {
20
+ "agent_thought_chunk", "tool_call", "tool_call_update", "plan",
21
+ "current_mode_update", "config_option_update", "session_info_update",
22
+ "available_commands_update", "usage_update", "session_notification",
23
+ }
24
+
25
+
26
+ def _record_session_update(events, update):
27
+ """Keep bounded, redacted ACP event metadata without tool payloads."""
28
+ if not isinstance(update, dict) or len(events) >= _MAX_ACP_EVENTS:
29
+ return
30
+ kind = str(update.get("sessionUpdate") or "")[:60]
31
+ if kind not in _ACP_EVENT_TYPES:
32
+ return
33
+ from .redact import scrub_text
34
+ item = {"sessionUpdate": kind}
35
+ for key in ("toolCallId", "title", "status", "modeId", "configId", "commandId"):
36
+ value = update.get(key)
37
+ if isinstance(value, (str, int, float, bool)):
38
+ item[key] = scrub_text(str(value), limit=160)
39
+ entries = update.get("entries")
40
+ if kind == "plan" and isinstance(entries, list):
41
+ item["entries"] = [{
42
+ "content": scrub_text(str(entry.get("content") or ""), limit=160),
43
+ "status": scrub_text(str(entry.get("status") or ""), limit=40),
44
+ } for entry in entries[:12] if isinstance(entry, dict)]
45
+ events.append(item)
18
46
 
19
47
 
20
48
  class ACPError(Exception):
@@ -336,6 +364,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
336
364
  if absolute_deadline is None:
337
365
  absolute_deadline = started + max(1.0, float(timeout or 1200))
338
366
  text_parts = []
367
+ acp_events = []
339
368
  terminals = {}
340
369
  session_id = str(resume or "")
341
370
 
@@ -344,8 +373,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
344
373
  if message.get("method") == "session/update":
345
374
  params = message.get("params") or {}
346
375
  update = params.get("update") or {}
347
- if params.get("sessionId") == session_id and \
348
- update.get("sessionUpdate") == "agent_message_chunk":
376
+ if params.get("sessionId") == session_id:
377
+ _record_session_update(acp_events, update)
378
+ if params.get("sessionId") == session_id and update.get("sessionUpdate") == "agent_message_chunk":
349
379
  content = update.get("content") or {}
350
380
  if content.get("type") == "text":
351
381
  piece = str(content.get("text") or "")
@@ -395,7 +425,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
395
425
  return {"ok": True, "text": "".join(text_parts)[:_MAX_TEXT], "json": None,
396
426
  "cost_usd": 0.0, "tokens": 0, "usage": None, "error": "",
397
427
  "error_code": "", "raw": {"exit_code": 0,
398
- "duration": time.monotonic() - started},
428
+ "duration": time.monotonic() - started,
429
+ "acp_events": acp_events},
399
430
  "kind": "acp", "model": None, "sid": session_id,
400
431
  "attempts": []}
401
432
  except InterruptedError as exc:
package/app/core/board.py CHANGED
@@ -66,22 +66,16 @@ def run_detail(run_id):
66
66
  }
67
67
 
68
68
 
69
- def _tail(step):
70
- """步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
71
- for key in ("stream", "thinking"):
72
- v = step.get(key)
73
- if isinstance(v, str) and v.strip():
74
- lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
75
- if lines:
76
- return lines[-1][:_TAIL_MAX]
77
- return ""
78
-
79
-
80
- def _live_block(step):
69
+ def _live_windows(run_id, step):
81
70
  """步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
82
71
 
83
72
  返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
84
- 翻译——大屏要做多语言,后端不下发中文。都没有返回 ("", "")。
73
+ 翻译——大屏要做多语言,后端不下发中文。
74
+
75
+ 内置智能体步骤边跑边把流写进步骤记录(store.stream_step);外部 CLI 步骤
76
+ 只落日志文件、run.json 里没有 stream——此前的连载全 CLI 流程在大屏上永远
77
+ 「等待实时输出…」(弹窗日志却有内容,2026-10-10 用户反馈)。故回退读日志
78
+ 尾巴:与蜂巢卡 hiveTick 同源同窗口(tail 夹取,不整读大日志)。
85
79
  """
86
80
  for key, code in (("stream", "output"), ("thinking", "thinking")):
87
81
  v = step.get(key)
@@ -91,6 +85,14 @@ def _live_block(step):
91
85
  lines = [ln for ln in lines if ln]
92
86
  if lines:
93
87
  return code, "\n".join(lines[-_TAIL_LINES:])
88
+ rel = step.get("log")
89
+ if rel and run_id:
90
+ from . import store
91
+ text = store.read_step_log(str(run_id), str(rel), tail=2400, pretty=True)
92
+ lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in (text or "").splitlines()]
93
+ lines = [ln for ln in lines if ln]
94
+ if lines:
95
+ return "output", "\n".join(lines[-_TAIL_LINES:])
94
96
  return "", ""
95
97
 
96
98
 
@@ -129,18 +131,19 @@ def _duration_s(run):
129
131
  return None
130
132
 
131
133
 
132
- def _step_view(steps, prov_map):
134
+ def _step_view(steps, prov_map, run_id=""):
133
135
  """进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
134
136
  done = sum(1 for s in steps if s.get("status") == "done")
135
137
  cur = next((s for s in steps if s.get("status") == "running"), None)
136
138
  cur_v = None
137
139
  if cur:
138
- live_label, live_text = _live_block(cur)
140
+ live_label, live_text = _live_windows(run_id, cur)
139
141
  model = str(cur.get("model") or "")[:60]
142
+ tail = (live_text.splitlines() or [""])[-1][:_TAIL_MAX] if live_text else ""
140
143
  cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
141
144
  "agent": cur.get("agent_label") or cur.get("agent") or "",
142
145
  "summary": (cur.get("summary") or "")[:80],
143
- "tail": _tail(cur),
146
+ "tail": tail,
144
147
  "model": model, "provider": prov_map.get(model, "") if model else "",
145
148
  "live_label": live_label, "live_text": live_text}
146
149
  return done, len(steps), cur_v
@@ -167,7 +170,8 @@ def _build():
167
170
  run = latest.get(tid)
168
171
  status = t.get("status")
169
172
  if status == "running" and run and run.get("status") == "running":
170
- done, total, cur = _step_view(run.get("steps") or [], prov_map)
173
+ done, total, cur = _step_view(run.get("steps") or [], prov_map,
174
+ run_id=run.get("id"))
171
175
  running.append({
172
176
  "task_id": tid, "run_id": run.get("id"),
173
177
  "title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
@@ -441,13 +441,20 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
441
441
  # policy keeps the user-approved full-trust tier (2026-09-22) instead of
442
442
  # refusing every command.
443
443
  has_bwrap = os.name == "posix" and bool(shutil.which("bwrap"))
444
- if sandbox.get("network") is False and not has_bwrap:
444
+ if sandbox.get("network") is False and not has_bwrap and sandbox.get("backend") != "docker":
445
445
  return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
446
- if sandbox.get("env_allowlist"):
446
+ if sandbox.get("env_allowlist") and sandbox.get("backend") != "docker":
447
447
  # run_process 的 env 是注入语义(合并进完整父环境,供凭据注入),
448
448
  # 不是白名单替换——环境收窄在子进程侧无法兑现,宁拒不假装
449
449
  return "(沙箱拒绝:该任务带环境变量白名单,命令执行无法兑现该策略,未执行)"
450
- if has_bwrap:
450
+ if sandbox.get("backend") == "docker":
451
+ try:
452
+ from .sandbox_runtime import docker_argv, docker_env
453
+ argv = docker_argv(cmdline, sandbox=sandbox, workdir=cwd)
454
+ launch_env = docker_env(sandbox)
455
+ except Exception as exc:
456
+ return "(沙箱拒绝:%s,命令未执行)" % str(exc)[:240]
457
+ elif has_bwrap:
451
458
  argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
452
459
  # Mount only the runtime needed by ordinary commands. Do not expose the
453
460
  # entire host root read-only: read access can still leak user secrets.
@@ -468,10 +475,12 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
468
475
  argv.extend(["--dir", root_path])
469
476
  argv.extend(["--bind", root_path, root_path])
470
477
  argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
478
+ launch_env = None
471
479
  else:
472
480
  argv = None # 全信任档:runner 按平台自包 cmd /c 或 /bin/sh -c
481
+ launch_env = None
473
482
  r = runner.run_process(argv=argv, shell_cmd=cmdline if argv is None else None,
474
- cwd=cwd, env=None,
483
+ cwd=cwd, env=launch_env,
475
484
  timeout=min(timeout, CMD_MAX_TIMEOUT),
476
485
  deadline=deadline, cancel_event=cancel_event)
477
486
  out = runner.clean_cli_text(
@@ -200,15 +200,19 @@ DEFAULT_CATALOG = [
200
200
  {
201
201
  "id": "trae-agent", "name": "Trae Agent", "cli_group": "installable",
202
202
  "note": "字节跳动开源 trae-agent;可执行名 trae-cli;PyPI 无包,uv tool 从 "
203
- "GitHub 直装(Python ≥3.12);无头 trae-cli run \"提示词\"(一次性任务"
204
- "形态);配置 trae_config.yaml 默认找当前工作目录(TRAE_CONFIG_FILE "
205
- "可指定),默认模型是 agents.trae_agent.model 指向 models 表别名的两层"
206
- "引用,CodeBee 暂不托管其模型落盘——绑定模型请在 trae_config.yaml 自配",
203
+ "GitHub 直装(Python ≥3.12);--with 钉 cryptography<49——49.0.0 起"
204
+ "上游不再发 Intel Mac wheel,缺 brew 的 Mac 落 sdist 编译必死于 "
205
+ "openssl-sys;无头 trae-cli run \"提示词\"(一次性任务形态);配置 "
206
+ "trae_config.yaml 默认找当前工作目录(TRAE_CONFIG_FILE 可指定),"
207
+ "默认模型是 agents.trae_agent.model 指向 models 表别名的两层引用,"
208
+ "CodeBee 暂不托管其模型落盘——绑定模型请在 trae_config.yaml 自配",
207
209
  "detect": {"cli": "trae-cli"},
208
210
  "orch": {"kind": "generic", "command": "trae-cli", "argv_template": ["run", "{prompt}"]},
209
211
  "config": {"path": "~/.trae/trae_config.yaml", "format": None, "model_key": None},
210
- "install": "uv tool install --python 3.12 git+https://github.com/bytedance/trae-agent",
211
- "upgrade": "uv tool install --force --python 3.12 git+https://github.com/bytedance/trae-agent",
212
+ "install": 'uv tool install --python 3.12 --with "cryptography<49"'
213
+ " git+https://github.com/bytedance/trae-agent",
214
+ "upgrade": 'uv tool install --force --python 3.12 --with "cryptography<49"'
215
+ " git+https://github.com/bytedance/trae-agent",
212
216
  "uninstall": "uv tool uninstall trae-agent",
213
217
  "default_enabled": False,
214
218
  },
@@ -395,6 +399,24 @@ def _apply_install_patch(entries):
395
399
  cur = (e.get("install") or "").strip()
396
400
  if "pip" in cur and "install" in cur:
397
401
  e["install"], e["upgrade"] = aider_uv
402
+ # trae-agent 经 google-genai→google-auth 拉 cryptography,而 cryptography
403
+ # 49.0.0 起不再发 Intel Mac wheel(只剩 macosx_11_0_arm64)——uv 找不到
404
+ # wheel 落 sdist 经 maturin/cargo 编译,openssl-sys 在无 brew 的 Mac 上
405
+ # 缺 pkg-config/OpenSSL 开发头必死(2026-10-10 Mac 实案)。钉 <49(48.x
406
+ # 是最后带 macosx_10_9_universal2 的系列,google-auth 只要求 >=38.0.3)
407
+ # 让 uv 恒走预编译 wheel。凡 uv 形态且未钉 cryptography 的旧命令一律
408
+ # 迁移——钉版是兼容底线不是偏好,trae 的安装命令本就无人手改。
409
+ trae_pinned = ('uv tool install --python 3.12 --with "cryptography<49"'
410
+ " git+https://github.com/bytedance/trae-agent")
411
+ trae_upgrade_pinned = ('uv tool install --force --python 3.12'
412
+ ' --with "cryptography<49"'
413
+ " git+https://github.com/bytedance/trae-agent")
414
+ for e in entries:
415
+ if e.get("id") != "trae-agent":
416
+ continue
417
+ cur = (e.get("install") or "").strip()
418
+ if "uv tool install" in cur and "cryptography<" not in cur:
419
+ e["install"], e["upgrade"] = trae_pinned, trae_upgrade_pinned
398
420
  if sys.platform == "win32":
399
421
  return
400
422
  for e in entries:
@@ -7,6 +7,7 @@ import json
7
7
  import base64
8
8
  import difflib
9
9
  import os
10
+ import re
10
11
  import secrets
11
12
  import stat
12
13
  import subprocess
@@ -21,7 +22,7 @@ from .redact import scrub_text
21
22
  _DIR = paths.DATA_DIR / "checkpoints"
22
23
  _LOCK = threading.RLock()
23
24
  _TERMINAL = {"done", "failed", "cancelled", "timeout", "unknown"}
24
- _SNAPSHOT_MAX_BYTES = 1024 * 1024
25
+ _SNAPSHOT_MAX_BYTES = 1024 * 1024 # ≤此值内嵌快照 JSON;超限进 git 对象库
25
26
  _SNAPSHOT_TOTAL_MAX_BYTES = 16 * 1024 * 1024
26
27
  _SUPPORTS_DIR_FD_RENAME = os.rename in getattr(os, "supports_dir_fd", set())
27
28
 
@@ -141,7 +142,12 @@ def replay_preview(run_id):
141
142
  or x.get("step") not in starts), None)
142
143
  if not steps:
143
144
  unknown = {"reason": "checkpoint_not_found"}
145
+ total_steps = len(steps)
146
+ max_preview_steps = 200
147
+ steps = steps[-max_preview_steps:]
144
148
  return {"run_id": str(run_id or ""), "steps": steps,
149
+ "total_steps": total_steps,
150
+ "truncated": total_steps > max_preview_steps,
145
151
  "replayable": unknown is None,
146
152
  "blocked_reason": ("checkpoint_not_found" if not steps else
147
153
  "requires_reconciliation" if unknown else "")}
@@ -198,15 +204,17 @@ def _load_snapshot(run_id, rel):
198
204
  def capture_file(run_id, workdir, path):
199
205
  """Persist the first pre-edit bytes for a workspace-relative file.
200
206
 
201
- Full-content snapshots are intentionally bounded; unsupported/large files
202
- are reported as untracked rather than silently treated as reversible.
207
+ ≤1MB 内嵌进快照 JSON(b64);更大的文件(连载全书稿这类持续增长的
208
+ tracked 脏文件)前态进工作区 git 对象库,不再顶着 1MB 上限把整步拒死
209
+ (2026-10-10 实案:manuscript.md 涨到 1.14MB 后全部评审步被「已有改动
210
+ 无法完整快照」拒起 CLI)。对象库也存不进(非 git 目录)才维持拒绝:
211
+ 起跑闸宁拒不假装可恢复。
203
212
  """
204
213
  root, rel = _safe_snapshot_path(workdir, path)
205
214
  if root is None:
206
215
  return {"ok": False, "reason": "path_outside_workspace"}
207
216
  with _LOCK:
208
- existing = _load_snapshot(run_id, rel)
209
- if existing:
217
+ if _load_snapshot(run_id, rel):
210
218
  return {"ok": True, "existing": True, "path": rel}
211
219
  snap_dir = _snapshot_dir(run_id)
212
220
  try:
@@ -215,28 +223,32 @@ def capture_file(run_id, workdir, path):
215
223
  target = root / rel
216
224
  existed = target.exists()
217
225
  raw = target.read_bytes() if existed else b""
218
- if len(raw) > _SNAPSHOT_MAX_BYTES or total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
219
- return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
220
- item = {"path": rel, "existed": existed,
221
- "before_b64": base64.b64encode(raw).decode("ascii"),
222
- "before_sha256": hashlib.sha256(raw).hexdigest() if existed else "",
223
- "after_sha256": None, "captured_at": time.time()}
224
- _snapshot_file(run_id, rel).write_text(
225
- json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
226
- return {"ok": True, "existing": False, "path": rel}
226
+ if len(raw) > _SNAPSHOT_MAX_BYTES:
227
+ return _write_oversize_snapshot(run_id, root, rel, existed,
228
+ file_path=target)
229
+ if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
230
+ # b64 预算满:大内容照样可以走对象库,别在这里误杀
231
+ return _write_oversize_snapshot(run_id, root, rel, existed,
232
+ file_path=target) if raw else \
233
+ {"ok": False, "reason": "snapshot_size_limit", "path": rel}
234
+ return _write_snapshot(run_id, rel, existed, raw=raw)
227
235
  except OSError as exc:
228
236
  return {"ok": False, "reason": "snapshot_io_error", "path": rel,
229
237
  "detail": str(exc)[:200]}
230
238
 
231
239
 
232
240
  def capture_bytes(run_id, workdir, path, raw, *, existed=True):
233
- """Persist an explicit pre-step byte image (used for clean Git baseline files)."""
241
+ """Persist an explicit pre-step byte image (used for clean Git baseline files).
242
+
243
+ 超限字节流同样进 git 对象库兜底:finish 阶段从 HEAD 取回的 clean 基线
244
+ (如归档后的整本 manuscript.md)此前只能给 warning,现在也能完整留底。
245
+ """
234
246
  root, rel = _safe_snapshot_path(workdir, path)
235
247
  if root is None:
236
248
  return {"ok": False, "reason": "path_outside_workspace"}
237
249
  raw = bytes(raw or b"") if existed else b""
238
250
  if len(raw) > _SNAPSHOT_MAX_BYTES:
239
- return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
251
+ return _write_oversize_snapshot(run_id, root, rel, existed, raw=raw)
240
252
  with _LOCK:
241
253
  if _load_snapshot(run_id, rel):
242
254
  return {"ok": True, "existing": True, "path": rel}
@@ -246,27 +258,87 @@ def capture_bytes(run_id, workdir, path, raw, *, existed=True):
246
258
  total = sum(p.stat().st_size for p in snap_dir.glob("*.json") if p.is_file())
247
259
  if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
248
260
  return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
249
- item = {"path": rel, "existed": bool(existed),
250
- "before_b64": base64.b64encode(raw).decode("ascii"),
251
- "before_sha256": hashlib.sha256(raw).hexdigest() if existed else "",
252
- "after_sha256": None, "captured_at": time.time()}
253
- _snapshot_file(run_id, rel).write_text(
254
- json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
255
- return {"ok": True, "existing": False, "path": rel}
261
+ return _write_snapshot(run_id, rel, existed, raw=raw)
256
262
  except OSError as exc:
257
263
  return {"ok": False, "reason": "snapshot_io_error", "path": rel,
258
264
  "detail": str(exc)[:200]}
259
265
 
260
266
 
261
- def _git_bytes(workdir, *args):
267
+ def _git_bytes(workdir, *args, data=None):
262
268
  try:
263
269
  return subprocess.run(["git", "-C", str(workdir), *args],
264
270
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
271
+ input=data,
265
272
  timeout=15, check=False)
266
273
  except (OSError, subprocess.SubprocessError):
267
274
  return None
268
275
 
269
276
 
277
+ def _git_blob_sha(workdir, *, file_path=None, raw=None):
278
+ """把内容存进工作区 git 仓库的对象库,返回 blob sha(空串=失败)。
279
+
280
+ 超限文件的前态走这里:blob 内容寻址、批间归档提交后自然转为被引用;
281
+ 未被引用期间也只在 git gc 的两周宽限后才可能被清,与检查点生命周期同量级。
282
+ """
283
+ if file_path is not None:
284
+ res = _git_bytes(workdir, "hash-object", "-w", "--", str(file_path))
285
+ else:
286
+ res = _git_bytes(workdir, "hash-object", "-w", "--stdin", data=bytes(raw or b""))
287
+ if not res or res.returncode:
288
+ return ""
289
+ sha = os.fsdecode(res.stdout).strip()
290
+ return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else ""
291
+
292
+
293
+ def _snapshot_before_bytes(item, root):
294
+ """快照的前态字节:小文件取 JSON 内嵌 b64,超限文件回对象库取 blob。
295
+
296
+ 返回 None = 前态不可得(对象库不可用或已被清理),调用方按不可恢复处理。
297
+ """
298
+ b64 = item.get("before_b64")
299
+ if b64:
300
+ return base64.b64decode(b64, validate=True)
301
+ sha = item.get("before_git_blob") or ""
302
+ if sha:
303
+ res = _git_bytes(root, "cat-file", "blob", sha)
304
+ if res is not None and not res.returncode:
305
+ return res.stdout
306
+ return None
307
+
308
+
309
+ def _write_snapshot(run_id, rel, existed, raw=None, blob_sha=""):
310
+ """落一份前态快照记录:小文件内嵌 b64,超限文件只记 blob 引用。"""
311
+ item = {"path": rel, "existed": bool(existed),
312
+ "before_sha256": hashlib.sha256(raw or b"").hexdigest() if existed else "",
313
+ "after_sha256": None, "captured_at": time.time()}
314
+ if blob_sha:
315
+ item["before_git_blob"] = blob_sha
316
+ else:
317
+ item["before_b64"] = base64.b64encode(raw or b"").decode("ascii")
318
+ _snapshot_file(run_id, rel).write_text(
319
+ json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
320
+ return {"ok": True, "existing": False, "path": rel}
321
+
322
+
323
+ def _write_oversize_snapshot(run_id, root, rel, existed, *, file_path=None, raw=None):
324
+ """超限前态的落盘:存进 git 对象库;存不进(非 git 目录)才维持拒绝。"""
325
+ if file_path is not None:
326
+ sha = _git_blob_sha(root, file_path=file_path)
327
+ else:
328
+ sha = _git_blob_sha(root, raw=raw)
329
+ if not sha:
330
+ return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
331
+ with _LOCK:
332
+ if _load_snapshot(run_id, rel):
333
+ return {"ok": True, "existing": True, "path": rel}
334
+ try:
335
+ _snapshot_dir(run_id).mkdir(parents=True, exist_ok=True)
336
+ return _write_snapshot(run_id, rel, existed, blob_sha=sha)
337
+ except OSError as exc:
338
+ return {"ok": False, "reason": "snapshot_io_error", "path": rel,
339
+ "detail": str(exc)[:200]}
340
+
341
+
270
342
  def _git_changed_paths(workdir, gitmod):
271
343
  """Return every changed path, including both sides of renames/copies."""
272
344
  result = _git_bytes(workdir, "status", "--porcelain", "-z", "--untracked-files=all")
@@ -453,7 +525,7 @@ def snapshot_preview(run_id, workdir):
453
525
  target = root / rel
454
526
  current = target.read_bytes() if target.is_file() and not target.is_symlink() else None
455
527
  current_hash = hashlib.sha256(current).hexdigest() if current is not None else None
456
- before = base64.b64decode(item.get("before_b64") or "", validate=True)
528
+ before = _snapshot_before_bytes(item, root)
457
529
  status = "unchanged" if current_hash == item.get("before_sha256") else "changed"
458
530
  if "after_exists" in item:
459
531
  can_restore = (bool(item.get("after_exists")) == (current is not None)
@@ -464,7 +536,9 @@ def snapshot_preview(run_id, workdir):
464
536
  else:
465
537
  can_restore = item.get("after_sha256") == current_hash
466
538
  diff = ""
467
- if current is not None and item.get("existed"):
539
+ if before is None and item.get("before_git_blob"):
540
+ diff = "(大文件前态存于 git 对象库且当前不可读取,差异省略)"
541
+ elif current is not None and item.get("existed") and before is not None:
468
542
  try:
469
543
  old_text = before.decode("utf-8").splitlines(True)
470
544
  new_text = current.decode("utf-8").splitlines(True)
@@ -521,16 +595,26 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
521
595
  try:
522
596
  if not stat.S_ISREG(os.fstat(file_fd).st_mode):
523
597
  return False
524
- with os.fdopen(file_fd, "rb", closefd=False) as handle:
525
- current = handle.read(_SNAPSHOT_MAX_BYTES + 1)
598
+ # 全量流式哈希:截断读会让超限文件(前态走对象库的那种)
599
+ # 的指纹恒不匹配、永远拒恢复。
600
+ digest = hashlib.sha256()
601
+ while True:
602
+ chunk = os.read(file_fd, 1 << 20)
603
+ if not chunk:
604
+ break
605
+ digest.update(chunk)
606
+ current = digest.hexdigest()
526
607
  finally:
527
608
  os.close(file_fd)
528
609
  current_exists = current is not None
529
- current_hash = hashlib.sha256(current).hexdigest() if current_exists else None
610
+ current_hash = current if current_exists else None
530
611
  if current_exists != bool(expected_exists) or current_hash != expected_hash:
531
612
  return False
532
613
 
533
614
  if item.get("existed"):
615
+ raw = _snapshot_before_bytes(item, root)
616
+ if raw is None:
617
+ return False
534
618
  if missing_parent:
535
619
  parent_fd = root_fd
536
620
  for part in parts[:-1]:
@@ -541,7 +625,6 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
541
625
  child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
542
626
  owned_fds.append(child_fd)
543
627
  parent_fd = child_fd
544
- raw = base64.b64decode(item.get("before_b64") or "", validate=True)
545
628
  temp_name = ".codebee-restore-%s.tmp" % secrets.token_hex(12)
546
629
  temp_fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL
547
630
  | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent_fd)
@@ -611,7 +694,10 @@ def restore_files(run_id, workdir):
611
694
  conflicts.append(rel)
612
695
  continue
613
696
  if item.get("existed"):
614
- raw = base64.b64decode(item.get("before_b64") or "", validate=True)
697
+ raw = _snapshot_before_bytes(item, root)
698
+ if raw is None:
699
+ conflicts.append(rel)
700
+ continue
615
701
  target.parent.mkdir(parents=True, exist_ok=True)
616
702
  safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
617
703
  if safe_root is None or safe_rel != rel:
@@ -56,12 +56,20 @@ def evaluate(manifest, results, baseline=None):
56
56
  manifest = normalize_manifest(manifest)
57
57
  results = results if isinstance(results, dict) else {}
58
58
  baseline = baseline if isinstance(baseline, dict) else {}
59
+ baseline_present = bool(baseline)
60
+ baseline_manifest_hash = (baseline.get("manifest_sha256")
61
+ if isinstance(baseline.get("manifest_sha256"), str) else None)
62
+ baseline_scores = baseline.get("scores") if isinstance(baseline.get("scores"), dict) else baseline
63
+ baseline_compatible = (not baseline_present or
64
+ bool(baseline_manifest_hash) and
65
+ baseline_manifest_hash == manifest["manifest_sha256"])
66
+ comparable_baseline = baseline_scores if baseline_compatible else {}
59
67
  matrix, regressions, totals = [], [], {}
60
68
  for candidate in manifest["candidates"]:
61
69
  scores = []
62
70
  for case in manifest["cases"]:
63
71
  candidate_results = results.get(candidate)
64
- candidate_baseline = baseline.get(candidate)
72
+ candidate_baseline = comparable_baseline.get(candidate)
65
73
  value = (candidate_results.get(case["id"]) if isinstance(candidate_results, dict) else None)
66
74
  old_value = (candidate_baseline.get(case["id"]) if isinstance(candidate_baseline, dict) else None)
67
75
  score = _score(value)
@@ -79,4 +87,5 @@ def evaluate(manifest, results, baseline=None):
79
87
  return {"manifest": manifest, "matrix": matrix, "averages": totals,
80
88
  "best_candidate": ranked[0][1] if ranked else "",
81
89
  "regressions": regressions,
90
+ "baseline_compatible": baseline_compatible,
82
91
  "passed": not regressions}
@@ -98,10 +98,15 @@ def _sandbox_launch(server, sandbox, workdir):
98
98
  from . import policy
99
99
  if not sandbox or not workdir:
100
100
  raise RuntimeError("MCP 调用缺少任务沙箱策略或工作目录,拒绝启动")
101
- if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
102
- raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
103
101
  root = Path(workdir).expanduser().resolve(strict=True)
104
102
  normalized = policy.normalize_sandbox(sandbox, root)
103
+ if normalized.get("backend") == "docker":
104
+ # MCP command/args may include host-specific executables and absolute
105
+ # script paths. Until those are explicitly mapped into an image and
106
+ # verified mounts, refuse instead of launching a misleading container.
107
+ raise RuntimeError("MCP Docker 启动映射尚未配置;拒绝降级到宿主机")
108
+ if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
109
+ raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
105
110
  roots = [Path(value).resolve(strict=True)
106
111
  for value in normalized.get("allowed_roots") or []]
107
112
  if not roots:
@@ -12,6 +12,14 @@ def document():
12
12
  "TaskCreate": {"type": "object", "required": ["goal"], "properties": {
13
13
  "type": {"type": "string"}, "title": {"type": "string"},
14
14
  "goal": {"type": "string", "maxLength": 4000}, "workdir": {"type": "string"},
15
+ "sandbox": {"type": "object", "properties": {
16
+ "backend": {"type": "string", "enum": ["native", "docker"]},
17
+ "docker_image": {"type": "string", "maxLength": 240},
18
+ "allowed_roots": {"type": "array", "maxItems": 16,
19
+ "items": {"type": "string", "maxLength": 1024}},
20
+ "env_allowlist": {"type": "array", "maxItems": 64,
21
+ "items": {"type": "string", "maxLength": 128}},
22
+ "network": {"type": "boolean"}, "timeout_s": {"type": "integer"}}},
15
23
  "acceptance_criteria": {"type": "array", "maxItems": 40,
16
24
  "items": {"type": "string", "maxLength": 500}},
17
25
  "approval_required": {"type": "boolean"}}},
@@ -586,6 +586,8 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
586
586
  from . import policy
587
587
  root = os.path.realpath(workdir or ".")
588
588
  normalized = policy.normalize_sandbox(sandbox, root)
589
+ if normalized.get("backend") == "docker":
590
+ return "沙箱拒绝:Docker 目前仅接入内置命令工具,外部 CLI 未验证容器启动,拒绝运行"
589
591
  if (agent or {}).get("kind") == "codex":
590
592
  if normalized.get("network") is False:
591
593
  return "沙箱拒绝:该 CLI 后端未接入网络隔离,拒绝运行网络受限任务"
@@ -912,7 +914,8 @@ def _finish_step_result(run_id, step, res, role, agent, start):
912
914
  followups=res.get("followups"),
913
915
  # 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
914
916
  thinking=res.get("reasoning") or None,
915
- partial=bool(res.get("partial")))
917
+ partial=bool(res.get("partial")),
918
+ acp_events=(raw.get("acp_events") or []))
916
919
  try:
917
920
  checkpoint_status = ("unknown" if raw.get("unknown") else
918
921
  "timeout" if status == "timeout" else
@@ -50,9 +50,18 @@ def normalize_sandbox(spec=None, workdir=None):
50
50
  else:
51
51
  # Invalid policy input must never silently widen network access.
52
52
  network = False
53
+ backend = str(spec.get("backend") or "native").strip().lower()
54
+ if backend not in ("native", "docker"):
55
+ raise ValueError("沙箱 backend 必须是 native 或 docker")
56
+ image = str(spec.get("docker_image") or "").strip()[:240]
57
+ if backend == "docker":
58
+ import re
59
+ if not image or not re.match(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]*$", image):
60
+ raise ValueError("Docker 镜像名无效")
53
61
  return {"allowed_roots": [str(x) for x in roots], "env_allowlist": env,
54
62
  "network": network, "timeout_s": timeout,
55
63
  "max_output_bytes": output,
64
+ "backend": backend, "docker_image": image,
56
65
  "disabled_tools": normalize_disabled_tools(spec.get("disabled_tools"))}
57
66
 
58
67