"""DockerExecutor:fs / shell / run_python 全走 docker exec,持 key 工具留 host(§7.5 #6)。

Backend 二分(§7.5 #6 信任域,2026-05-26 修正:`paths.py::resolve_user_path` 校验
原本是 DESIGN 假命题 ── 实际 host 工具 base_dir = Path.cwd() 无校验,模型能 read
host 整个 fs。改物理边界替代代码护栏):
- **container exec**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` /
  `grep` —— 全走 docker exec,容器内 user_root=/workspace 物理边界
- **host in-process**:`load_skill` / `web_*` / `seedream` / `seedance` —— 持
  Bocha/ARK API key 不能入容器 env(SaaS 时 key 泄漏面);load_skill 是 SKILL 注册表
  内存查找无 fs 访问越界

容器准入(per call):
1. `pool.ensure(user_id)` —— 拿到 / 起 `zcbot-sandbox-<uid>` 容器(per-user lock 已串行化)
2. 命令分两类:
   - shell/run_python:`docker exec --user zcbot --workdir /workspace/<wd> -e ... setsid bash -c '<cmd>'`
   - read/write/edit/glob/grep:`docker exec --user zcbot --workdir /workspace/<wd>
     <c> python /sandbox/tool_runner.py <tool_name>`,JSON args 走 stdin
     (不被 shell metachar 切,CJK 路径透明传)
3. timeout 到 → 杀 docker CLI 客户端(Popen.kill())
4. 完成 → `pool.mark_active(user_id)` 刷 idle 计时

run_python tmp .py 落 host 侧 `<user_root>/.zcbot_tmp/<task_id>/<rand>.py`(bind mount
自动可见于容器 `/workspace/.zcbot_tmp/<task_id>/`),执行完 unlink。dotfile 起头让
`/v1/files` API 天然过滤(`web/app.py:169` startswith(".")),用户视野不污染。

Cancel limitation(第一版接受):
- docker exec 客户端断开后,容器内 server 端进程**不会**因此终止 —— 这是 docker 设计
- 第一版只杀 docker CLI(Popen.kill());容器内残留进程靠 idle 5min reaper / 下次
  ensure 时 rm -f 兜底
- 升级触发(§7.5 #3 PGID 协议):用户反馈"取消了但还在烧 CPU" / 多次 cancel 后
  容器内进程堆积 → 启用「ZCBOT_EXEC_ID env + PGID 写文件 + 二次 exec kill」协议
"""
from __future__ import annotations

import os
import secrets
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID

import json


# canceller 侧线程 poll cancel_check 的间隔;单测可 patch 此常量加速
_CANCEL_POLL_INTERVAL_S = 0.2

from tools.base import format_timeout_result

from .executor import ExecCtx, Executor, ToolResult
from .executor_host import HostExecutor
from .pysyntax import precheck_python
from .sandbox import SandboxPool
from tools.base import compact_tool_output


# write/edit 走配额 gate;read/glob/grep 不消耗磁盘,放行
_FS_TOOLS_WRITE = frozenset({"write", "edit"})


# 信任域分类(§7.5 #6,2026-05-26 修正):
# - SHELL_LIKE:执行任意代码,Popen 直接喂 cmd / script,setsid 包一层
# - FS_TOOLS:fs 操作,docker exec → /sandbox/tool_runner.py + stdin 喂 JSON args
# 二者都走 docker exec,但调用形态不同(setsid bash vs python tool_runner)
SHELL_LIKE_TOOLS = frozenset({"shell", "run_python"})
FS_TOOLS = frozenset({"read", "write", "edit", "glob", "grep"})
CONTAINER_TOOLS = SHELL_LIKE_TOOLS | FS_TOOLS

# 容器内非 root 用户:用 username 让 docker 解析容器内 /etc/passwd 自动拿 uid。
# Dockerfile 里 `useradd -u ${HOST_UID} zcbot` 已对齐 host uid,这里写死 "zcbot"
# 让镜像 build 时不同 HOST_UID 部署形态(1000 / 1001 / 其他)都不用改代码或 env。
# 写死 uid:gid 形式("1000:1000")会与 bind mount owner 错配,导致 EACCES。
DEFAULT_EXEC_USER = "zcbot"

# 容器 rootfs 只读(docker run --read-only),/home/zcbot 不可写 ── 所有 exec 路径
# (shell / run_python / fs 工具)共用这套 env,用 `docker exec -e` 确定性覆盖容器默认值:
#   - PYTHONPATH=/sandbox:/workspace:让 `from skills.xxx import ...` 全路径生效。
#     /sandbox 在前(skills bind mount 到 /sandbox/skills:ro,SKILL.md 教这条 import
#     path),/workspace 在后(用户 task 目录的本地脚本)。原先只 run_python 注入,
#     shell `python -c "from skills..."` 撞 ModuleNotFoundError;收编到这一处。
#   - HOME=/tmp:只读 home 下 matplotlib/fontconfig 等想往 ~/.config / ~/.cache 写缓存
#     会刷 "Read-only file system" / "No writable cache" 噪音。指到 /tmp(tmpfs 可写)
#     一刀盖掉两类(matplotlib → /tmp/.config,fontconfig → /tmp/.cache),无需逐个
#     专用变量(MPLCONFIGDIR / XDG_CACHE_HOME)。
_CONTAINER_ENV = {
    "PYTHONPATH": "/sandbox:/workspace",
    "HOME": "/tmp",
}

# research skill 在容器里直连 paper_server,这两个 env 按需透传(host .env → docker exec -e)。
# PAPER_SERVER_API_KEY 是唯一刻意放进 sandbox 的凭证(低价值只读文献库 key,可随时在
# paper_server admin 撤销),与「Bocha/ARK 等高价值 key 不入容器」的规矩不冲突;
# PAPER_SERVER_URL 顺带透传,修掉 host 覆盖 URL 时 docker 模式仍用硬编码默认值的缺口。
_PASSTHROUGH_ENV = ("PAPER_SERVER_URL", "PAPER_SERVER_API_KEY")


def _sandbox_env(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
    """shell / run_python 的容器 env:静态 _CONTAINER_ENV + host 透传项 + 调用点补充。"""
    env = dict(_CONTAINER_ENV)
    for k in _PASSTHROUGH_ENV:
        v = os.environ.get(k, "").strip()
        if v:
            env[k] = v
    if extra:
        env.update(extra)
    return env

# host 侧 tmp 脚本目录(user_root 内 dotfile,被 /v1/files API 隐藏)
TMP_SUBDIR = ".zcbot_tmp"


class DockerExecutor(Executor):
    """组合 HostExecutor + docker exec dispatch shell/run_python。

    host backend 仍承担 schema 列表 + 大部分 tool 执行;本类只在 shell/run_python
    命中时夺路接管,docker exec 在 per-user 容器里跑。
    """

    def __init__(
        self,
        host: HostExecutor,
        pool: SandboxPool,
        user_id: UUID,
        user_root: Path,
        working_dir: Path,
    ) -> None:
        self.host = host
        self.pool = pool
        self.user_id = user_id
        self.user_root = user_root.resolve()
        self.working_dir = working_dir.resolve()
        # 容器内对应路径 /workspace/<wd_name>
        try:
            wd_rel = self.working_dir.relative_to(self.user_root)
            self.container_workdir = "/workspace/" + wd_rel.as_posix()
        except ValueError:
            # working_dir 不在 user_root 下 —— 防御性兜底,正常路径不会到这里
            self.container_workdir = "/workspace"
        self.exec_user = os.getenv("ZCBOT_SANDBOX_EXEC_USER", DEFAULT_EXEC_USER)

    # ── Executor 接口 ────────────────────────────────────────

    def has_tool(self, name: str) -> bool:
        return self.host.has_tool(name)

    def schemas(self) -> List[Dict[str, Any]]:
        return self.host.schemas()

    def call_tool(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
        if name not in CONTAINER_TOOLS:
            return self.host.call_tool(name, args, ctx)
        if not self.host.has_tool(name):
            # caps.enable_run_python=False 等场景下,host 没装该工具 → schema 也没暴露
            return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2)
        try:
            if name == "shell":
                return self._exec_shell(args, ctx)
            if name == "run_python":
                return self._exec_python(args, ctx)
            if name in FS_TOOLS:
                return self._exec_fs_tool(name, args, ctx)
        except Exception as e:
            return ToolResult(
                content=f"[Error executing {name} via docker] {type(e).__name__}: {e}",
                exit_code=1,
            )
        return ToolResult(content=f"[Error] unhandled container tool: {name}", exit_code=2)

    # ── shell ────────────────────────────────────────────────

    def _exec_shell(self, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
        cmd = args.get("command")
        if not isinstance(cmd, str) or not cmd.strip():
            return ToolResult(
                content="[Error] bad arguments to shell: command must be non-empty string",
                exit_code=2,
            )
        if args.get("background"):
            return self._exec_background("shell", args, ctx)
        timeout = int(args.get("timeout") or 60)

        container = self.pool.ensure(self.user_id)
        # 2026-05-29 删 setsid:`docker exec ... setsid python script` 在 setsid 调
        # setsid() syscall 把进程变 new session leader 后,docker exec / runc 的 stdio
        # attach 出问题,延迟输出(sleep 2 后 print)被截掉,LLM 拿到空 [exit 0]。
        # PGID kill 协议(§7.5 Step 3b)未来要做时换 `setsid --wait` 或 wrapper,
        # 不能裸 setsid。
        argv = self._docker_exec_argv(
            container, extra_env=_sandbox_env()
        ) + ["bash", "-c", cmd]
        result = self._run_subprocess(argv, timeout=timeout, ctx=ctx)
        self.pool.mark_active(self.user_id)
        return self._compact_shell_like_result(result)

    # ── run_python ───────────────────────────────────────────

    def _exec_python(self, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
        if args.get("background"):
            return self._exec_background("run_python", args, ctx)
        code = args.get("code")
        script_path = args.get("script_path")
        if script_path is not None:
            if not isinstance(script_path, str) or not script_path.strip():
                return ToolResult(
                    content="[Error] bad arguments to run_python: script_path must be non-empty string",
                    exit_code=2,
                )
            container_script, err = self._prepare_script(script_path, code)
            if err is not None:
                return ToolResult(content=err, exit_code=2)
            timeout = int(args.get("timeout") or 120)
            container = self.pool.ensure(self.user_id)
            argv = self._docker_exec_argv(
                container,
                extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}),
            ) + ["python", "-u", container_script]
            result = self._run_subprocess(argv, timeout=timeout, ctx=ctx)
            self.pool.mark_active(self.user_id)
            return self._compact_shell_like_result(result)

        if not isinstance(code, str):
            return ToolResult(
                content="[Error] bad arguments to run_python: code or script_path must be provided",
                exit_code=2,
            )
        hint = precheck_python(code)
        if hint:
            return ToolResult(content=hint, exit_code=2)
        timeout = int(args.get("timeout") or 120)

        # tmp .py 落 host 侧 `.zcbot_tmp/<task_id>/<rand>.py`;
        # 容器内对应 /workspace/.zcbot_tmp/<task_id>/<rand>.py
        tmp_root = self.user_root / TMP_SUBDIR / str(ctx.task_id)
        tmp_root.mkdir(parents=True, exist_ok=True)
        rand_name = f"{int(time.time() * 1000)}-{secrets.token_hex(4)}.py"
        host_script = tmp_root / rand_name
        container_script = f"/workspace/{TMP_SUBDIR}/{ctx.task_id}/{rand_name}"
        host_script.write_text(code, encoding="utf-8")

        try:
            container = self.pool.ensure(self.user_id)
            argv = self._docker_exec_argv(
                container,
                extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}),
            ) + ["python", "-u", container_script]  # 删 setsid 同上(_exec_shell 注释)
            result = self._run_subprocess(argv, timeout=timeout, ctx=ctx)
            self.pool.mark_active(self.user_id)
            return self._compact_shell_like_result(result)
        finally:
            try:
                host_script.unlink()
            except OSError:
                pass

    # ── background(bg proc,DESIGN §8.12)──────────────────

    def _exec_background(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
        """background=true:专用容器跑长进程,立即返回 proc_id。

        为什么不是 `docker exec -d` 进 sandbox 容器:sandbox 有 idle 5min reaper +
        启动时 shutdown_all,长进程会随容器陪葬。专用容器 product=proc 与这两条
        生命周期解耦(pool.run_proc_container),状态协议同 host 模式(core/procs.py):
        runner.sh 结束时写 exit_code,check_process/sweep 负责回收容器。
        """
        from core import procs

        anchor = self.user_root
        if procs.count_running(anchor) >= procs.MAX_RUNNING_PER_USER:
            return ToolResult(
                content=(
                    f"[Error] 已有 {procs.MAX_RUNNING_PER_USER} 个后台进程在跑(上限)。"
                    f"用 check_process 查看,等待完成或 kill 掉不需要的再启动。"
                ),
                exit_code=2,
            )

        raw_timeout = args.get("timeout")
        fg_default = 60 if name == "shell" else 120
        try:
            raw_timeout = int(raw_timeout) if raw_timeout else 0
        except (TypeError, ValueError):
            raw_timeout = 0
        timeout_s = procs.clamp_timeout(raw_timeout if raw_timeout > fg_default else None)

        proc_id = procs.new_proc_id()
        d = procs.procs_root(anchor) / str(ctx.task_id) / proc_id
        d.mkdir(parents=True, exist_ok=True)
        cdir = f"/workspace/{procs.PROCS_SUBDIR}/{ctx.task_id}/{proc_id}"

        if name == "shell":
            cmd = args.get("command")
            if not isinstance(cmd, str) or not cmd.strip():
                return ToolResult(
                    content="[Error] bad arguments to shell: command must be non-empty string",
                    exit_code=2,
                )
            (d / "cmd.sh").write_text(cmd + "\n", encoding="utf-8", newline="\n")
            inner = f"bash {cdir}/cmd.sh"
            display, kind = cmd, "shell"
        else:
            script_path = args.get("script_path")
            code = args.get("code")
            if isinstance(script_path, str) and script_path.strip():
                container_script, err = self._prepare_script(script_path, code)
                if err is not None:
                    return ToolResult(content=err, exit_code=2)
                inner = f"python -u {container_script}"
                display, kind = f"python {script_path.strip()}", "python"
            elif isinstance(code, str) and code.strip():
                hint = precheck_python(code)
                if hint:
                    return ToolResult(content=hint, exit_code=2)
                (d / "script.py").write_text(code, encoding="utf-8", newline="\n")
                inner = f"python -u {cdir}/script.py"
                display, kind = f"python <inline {len(code)} chars>", "python"
            else:
                return ToolResult(
                    content="[Error] bad arguments to run_python: code or script_path must be provided",
                    exit_code=2,
                )

        # runner.sh:限时跑 + 截日志 + 写 exit_code(exit_code 是唯一终态信号,最后写)
        runner = (
            "#!/bin/bash\n"
            f"cd {self.container_workdir} || {{ echo '[runner] cd failed' > {cdir}/output.log; "
            f"echo 1 > {cdir}/exit_code; exit 1; }}\n"
            f"timeout {timeout_s}s {inner} > {cdir}/output.log 2>&1\n"
            "ec=$?\n"
            f"if [ $ec -eq 124 ]; then echo '[runner] timeout after {timeout_s}s, killed' >> {cdir}/output.log; fi\n"
            f"tail -c 10485760 {cdir}/output.log > {cdir}/output.log.trunc 2>/dev/null "
            f"&& mv {cdir}/output.log.trunc {cdir}/output.log\n"
            f"echo $ec > {cdir}/exit_code\n"
        )
        (d / "runner.sh").write_text(runner, encoding="utf-8", newline="\n")

        meta: Dict[str, Any] = {
            "proc_id": proc_id,
            "task_id": str(ctx.task_id),
            "kind": kind,
            "backend": "docker",
            "command": display[:500],
            "cwd": self.container_workdir,
            "timeout_s": timeout_s,
            "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
            "created_ts": time.time(),
        }
        procs.write_meta(d, meta)

        try:
            container = self.pool.run_proc_container(self.user_id, proc_id, str(d))
        except Exception as e:
            return ToolResult(content=f"[Error] 后台容器启动失败: {e}", exit_code=1)
        meta["container"] = container
        procs.write_meta(d, meta)

        argv = self._docker_exec_argv(
            container, extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}), detach=True
        ) + ["bash", f"{cdir}/runner.sh"]
        r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
        if r.returncode != 0:
            subprocess.run(["docker", "rm", "-f", container], capture_output=True)
            return ToolResult(
                content=f"[Error] 后台进程启动失败: {(r.stderr or '').strip()[:300]}",
                exit_code=1,
            )
        return ToolResult(
            content=(
                f"[Background] 已启动后台进程 proc_id={proc_id}({display[:150]}),"
                f"最长运行 {timeout_s}s。\n"
                f"用 check_process(proc_id=\"{proc_id}\") 查进度和日志。进程独立于本轮对话运行,"
                f"服务重启也不中断。现在可以继续其他工作;若无事可做,结束回合并告知用户稍后询问进度。"
            ),
            exit_code=0,
        )

    def _prepare_script(self, script_path: str, code: Any) -> tuple:
        """script_path → 容器内绝对路径,宿主侧做存在性预检。返回 (container_path, err)。

        背景(0.58.22,失败聚集 run_python/exit「can't open file」15 次/7 天):原实现
        不预检直接 `docker exec python <path>`,模型拿到裸 stderr 自愈慢。三种形态:
        - 路径带工作目录名前缀 → 双重拼接。根因是 fs 工具输出渲染 user_root 相对路径
          (tools/base.py::_display),模型原样回传,而这里按 workdir 相对解析 ——
          展示/解析不对称。容错:workdir 相对不存在时按 user_root 相对再试一次。
        - code + script_path 同给且文件不存在 → 模型意图是「把 code 存到 script_path
          再跑」(工具描述本就引导先写后跑),照做,别扔掉完好的 code。
        - 没写就跑(主体,模型幻觉)→ 返回带纠偏提示的 [Error],不进容器起 python。
        """
        p = script_path.replace("\\", "/").strip()
        if p.startswith("/"):
            if p != "/workspace" and not p.startswith("/workspace/"):
                # /tmp 等容器内路径,宿主不可映射 —— 跳过预检,交给容器报错
                return p, None
            rest = p[len("/workspace"):].lstrip("/")
            container, host = "/workspace/" + rest, self.user_root / rest
        else:
            rel = p.lstrip("./")
            container = self.container_workdir.rstrip("/") + "/" + rel
            host = self.working_dir / rel
            if not host.is_file() and (self.user_root / rel).is_file():
                container, host = "/workspace/" + rel, self.user_root / rel
        # 越界防护:下面的 code 回写发生在宿主侧,不受容器 /workspace 物理边界保护
        try:
            host.resolve().relative_to(self.user_root)
        except (ValueError, OSError):
            return "", f"[Error] script_path out of bounds: {script_path}"
        if not host.is_file():
            if isinstance(code, str) and code.strip():
                host.parent.mkdir(parents=True, exist_ok=True)
                host.write_text(code, encoding="utf-8")
            else:
                return "", (
                    f"[Error] script_path not found: {script_path}。"
                    "先用 write 创建该文件再执行,或直接用 code 传源码;"
                    "相对路径基于 task 工作目录,不要带工作目录名前缀。"
                )
        # 语法预检:host 侧 compile() 拦下语法坏码(中文正文硬拼进源码的引号/标点崩坏),
        # 返锐化诊断、省一次进容器往返;通过则原样放行。读盘失败不拦(交容器报)。
        try:
            hint = precheck_python(host.read_text(encoding="utf-8", errors="replace"))
        except OSError:
            hint = None
        if hint:
            return "", hint
        return container, None

    # ── fs tools(read/write/edit/glob/grep)──────────────────

    def _exec_fs_tool(
        self, name: str, args: Dict[str, Any], ctx: ExecCtx
    ) -> ToolResult:
        """fs 工具走 `python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args。

        fs 工具的 cancel / timeout 都用与 shell/run_python 不同的默认值:
        - timeout 短(30s),fs 操作不会跑很久,卡住就说明撞 mount / 大目录扫描
        - cancel 仍 poll(模型可能 grep 全 user_root 然后用户停止,响应即时)

        write/edit 起手 check 磁盘配额(§7.5 #4),超额返 [Error] 不调容器。
        read/glob/grep 不消耗磁盘放行。
        """
        if name in _FS_TOOLS_WRITE:
            err = _check_user_disk_quota(self.user_id)
            if err is not None:
                return ToolResult(content=err, exit_code=2)

        timeout = int(args.get("timeout") or 30) if name == "grep" else 30

        container = self.pool.ensure(self.user_id)
        argv = self._docker_exec_argv(
            container,
            extra_env={**_CONTAINER_ENV, "PYTHONIOENCODING": "utf-8"},
            stdin_open=True,
        ) + ["python", "/sandbox/tool_runner.py", name]

        # tool_runner.py 从 stdin 拿 args(JSON)── 路径含 CJK / 引号都透明传
        stdin_payload = json.dumps(args, ensure_ascii=False)
        result = self._run_subprocess(
            argv, timeout=timeout, ctx=ctx, stdin=stdin_payload
        )
        self.pool.mark_active(self.user_id)
        return result

    # ── helpers ──────────────────────────────────────────────

    def _docker_exec_argv(
        self,
        container: str,
        extra_env: Optional[Dict[str, str]] = None,
        stdin_open: bool = False,
        detach: bool = False,
    ) -> List[str]:
        """`stdin_open=True` 时加 `-i` 让 stdin 通到容器(fs tool_runner 用);
        `detach=True` 加 `-d`(bg proc 的 runner.sh,不接 stdio 立即返回)。"""
        argv = [
            "docker", "exec",
            "--user", self.exec_user,
            "--workdir", self.container_workdir,
        ]
        if stdin_open:
            argv.append("-i")
        if detach:
            argv.append("-d")
        env: Dict[str, str] = {}
        if extra_env:
            env.update(extra_env)
        for k, v in env.items():
            argv.extend(["-e", f"{k}={v}"])
        argv.append(container)
        return argv

    def _run_subprocess(
        self,
        argv: List[str],
        timeout: int,
        ctx: ExecCtx,
        stdin: Optional[str] = None,
    ) -> ToolResult:
        """跑 docker exec 子进程;单次 communicate + 侧线程 poll cancel。

        2026-05-29 重写:历史实现在 poll loop 里反复 `communicate(timeout=0.5)`,
        违反 subprocess API 假设(communicate 应只调一次)+ 配合 `setsid bash -c`
        block-buffered stdout 在多 chunk 输出场景下静默丢数据(返空 `[exit 0]`)。
        改主线程单次 `communicate(timeout=timeout)`,cancel 检查移到侧线程。

        fs tool_runner 返回形态特殊:stdout 直返(无 [stdout] 包装);
        exit != 0 时 stderr 含 [Error executing ...] 透传给 LLM。
        """
        is_fs_tool = stdin is not None
        cancel_check = ctx.cancel_check

        # 入口同步快路径:cancel_check 已经 True 时直接返,免起 Popen / 侧线程
        if cancel_check is not None and cancel_check():
            return ToolResult(
                content="[Error] command cancelled by user", exit_code=130
            )

        try:
            proc = subprocess.Popen(
                argv,
                stdin=subprocess.PIPE if stdin is not None else None,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                encoding="utf-8",
                errors="replace",
            )
        except FileNotFoundError as e:
            return ToolResult(content=f"[Error] docker CLI not found: {e}", exit_code=2)

        cancel_hit = threading.Event()
        stop_canceller = threading.Event()

        def _canceller() -> None:
            while not stop_canceller.wait(_CANCEL_POLL_INTERVAL_S):
                try:
                    hit = cancel_check is not None and cancel_check()
                except Exception:
                    hit = False
                if hit:
                    cancel_hit.set()
                    try:
                        proc.kill()
                    except ProcessLookupError:
                        pass
                    return

        cancel_thread = None
        if cancel_check is not None:
            cancel_thread = threading.Thread(target=_canceller, daemon=True)
            cancel_thread.start()

        timeout_hit = False
        try:
            try:
                stdout, stderr = proc.communicate(input=stdin, timeout=timeout)
            except subprocess.TimeoutExpired:
                timeout_hit = True
                try:
                    proc.kill()
                except ProcessLookupError:
                    pass
                # kill 后 communicate() 续读 self._fileobj2output 累积 chunks,
                # 不会丢已读到的历史输出(标准 subprocess 行为)
                stdout, stderr = proc.communicate()
        finally:
            stop_canceller.set()
            if cancel_thread is not None:
                cancel_thread.join(timeout=1.0)

        # cancel 优先于 timeout:canceller 设了 hit 即使 communicate 也抛 TO 时
        if cancel_hit.is_set():
            return ToolResult(
                content="[Error] command cancelled by user", exit_code=130
            )
        if timeout_hit:
            # kill 后 communicate() 已续读超时前的部分输出(见上),别丢 —— 带上让模型续跑
            return ToolResult(
                content=format_timeout_result(stdout, stderr, timeout),
                exit_code=124,
            )

        if is_fs_tool:
            if proc.returncode == 0:
                return ToolResult(content=stdout, exit_code=0)
            err_msg = stderr.strip() or f"tool_runner exit {proc.returncode}"
            return ToolResult(content=err_msg, exit_code=proc.returncode)

        parts: List[str] = []
        if stdout:
            parts.append(f"[stdout]\n{stdout.rstrip()}")
        if stderr:
            parts.append(f"[stderr]\n{stderr.rstrip()}")
        parts.append(f"[exit {proc.returncode}]")
        return ToolResult(content="\n".join(parts), exit_code=proc.returncode)

    def _compact_shell_like_result(self, result: ToolResult) -> ToolResult:
        content = compact_tool_output(result.content)
        return ToolResult(content=content, exit_code=result.exit_code)


def _check_user_disk_quota(user_id: UUID):
    """write/edit 前 gate;读 yaml 配额 + 查 user_disk_usage 表。

    放这里(模块级 helper)而非 DockerExecutor 方法是因为 host_executor 路径
    也复用同款 gate(/v1/files/upload),实现一次写两处用。
    """
    try:
        from core.agent_builder import load_config
        from core.storage.disk_quota import check_disk_quota, parse_bytes
        cfg = load_config() or {}
        quotas = cfg.get("quotas") or {}
        limit = parse_bytes(quotas.get("disk_bytes_per_user"))
        if limit is None or limit <= 0:
            return None
        return check_disk_quota(user_id, limit)
    except Exception:
        # 配额查询失败不阻塞主路径(写仍放行,日志靠 caller)
        return None
